feat(acl): gate every app command per window via a Tauri AppManifest
build.rs now derives an AppManifest from generate_handler!, which makes tauri 2.11 apply the ACL to app commands (it skips them entirely without one). default.json grants the 110 main-window commands, file-viewer.json the five viewer_* commands, and build.rs refuses to build on a missing, misspelled, duplicated, misfiled or deny-* grant, or on a hand-written permission file. Stale autogenerated permissions are pruned per build. Closes the residual risk recorded by the terminal file viewer: a compromised viewer window could invoke any app command. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,8 @@ tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
# build.rs reads capabilities/*.json to cross-check them against generate_handler!.
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
+121
-2
@@ -1,3 +1,122 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses
|
||||
//! to build unless every registered command is granted in exactly one capability file — the
|
||||
//! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri
|
||||
//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window
|
||||
//! could call any command.
|
||||
//!
|
||||
//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles,
|
||||
//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
|
||||
|
||||
#[path = "src/command_census.rs"]
|
||||
mod command_census;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn fail(problems: &[String]) -> ! {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"capabilities do not match generate_handler! ({} problem{}):",
|
||||
problems.len(),
|
||||
if problems.len() == 1 { "" } else { "s" }
|
||||
);
|
||||
for p in problems {
|
||||
eprintln!(" - {p}");
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
|
||||
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
|
||||
capabilities/default.json. See src/command_census.rs."
|
||||
);
|
||||
eprintln!();
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// tauri-build already emits rerun-if-changed for `capabilities` and `permissions`.
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
println!("cargo:rerun-if-changed=src/command_census.rs");
|
||||
|
||||
let lib_rs = std::fs::read_to_string("src/lib.rs")
|
||||
.expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable");
|
||||
let Some(commands) = command_census::registered_commands(&lib_rs) else {
|
||||
fail(&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from"
|
||||
.to_string()]);
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") {
|
||||
let path = entry.expect("readable entry in capabilities/").path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
let name = path
|
||||
.file_name()
|
||||
.expect("a file name")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let json =
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
match command_census::capability_file(&name, &json) {
|
||||
Ok(file) => files.push(file),
|
||||
Err(problem) => fail(&[problem]),
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
let problems = command_census::check(&commands, &files);
|
||||
if !problems.is_empty() {
|
||||
fail(&problems);
|
||||
}
|
||||
|
||||
prune_permissions(&commands);
|
||||
|
||||
// `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so
|
||||
// there is no owned form; leaking is fine in a process that exits right after.
|
||||
let leaked: Vec<&'static str> = commands
|
||||
.into_iter()
|
||||
.map(|c| &*Box::leak(c.into_boxed_str()))
|
||||
.collect();
|
||||
let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice());
|
||||
let attributes = tauri_build::Attributes::new()
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(leaked));
|
||||
if let Err(error) = tauri_build::try_build(attributes) {
|
||||
// Same shape as `tauri_build::build()`: message on stdout, then exit 1.
|
||||
println!("{error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// tauri-build writes `permissions/autogenerated/<command>.toml` for every manifest command
|
||||
/// and never deletes one, so a command removed from `lib.rs` would leave a permission a
|
||||
/// capability could still reference (and the build would pass). Delete only the stale files:
|
||||
/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would
|
||||
/// touch every mtime and re-run this script — and recompile the crate — on every cargo
|
||||
/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot
|
||||
/// see, so it is refused.
|
||||
fn prune_permissions(commands: &[String]) {
|
||||
let root = Path::new("permissions");
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries {
|
||||
let path = entry.expect("readable entry in permissions/").path();
|
||||
if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() {
|
||||
for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") {
|
||||
let file = file.expect("readable entry").path();
|
||||
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let live = file.extension().is_some_and(|e| e == "toml")
|
||||
&& commands.iter().any(|c| c == stem);
|
||||
if !live {
|
||||
std::fs::remove_file(&file)
|
||||
.unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(&[format!(
|
||||
"{} is not generated by build.rs; hand-written permissions are not allowed \
|
||||
(every grant is a bare allow-* string in a capability file)",
|
||||
path.display()
|
||||
)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,116 @@
|
||||
"core:event:allow-unlisten",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
"dialog:allow-save",
|
||||
"allow-check-docker",
|
||||
"allow-check-image-exists",
|
||||
"allow-build-image",
|
||||
"allow-get-container-info",
|
||||
"allow-list-projects",
|
||||
"allow-add-project",
|
||||
"allow-remove-project",
|
||||
"allow-update-project",
|
||||
"allow-start-project-container",
|
||||
"allow-stop-project-container",
|
||||
"allow-rebuild-project-container",
|
||||
"allow-reconcile-project-statuses",
|
||||
"allow-list-notes",
|
||||
"allow-save-note",
|
||||
"allow-delete-note",
|
||||
"allow-get-container-staleness",
|
||||
"allow-migrate-project-to-base",
|
||||
"allow-confirm-migration",
|
||||
"allow-rollback-migration",
|
||||
"allow-get-migration-state",
|
||||
"allow-set-auth-bridge-enabled",
|
||||
"allow-get-auth-bridge-status",
|
||||
"allow-set-browser-view-enabled",
|
||||
"allow-get-browser-view-status",
|
||||
"allow-check-browser-view-support",
|
||||
"allow-install-browser-view-support",
|
||||
"allow-install-browser-view-browser",
|
||||
"allow-open-browser-view-popout",
|
||||
"allow-close-browser-view-popout",
|
||||
"allow-get-browser-view-popout-state",
|
||||
"allow-set-browser-view-popout-always-on-top",
|
||||
"allow-open-page-in-container-browser",
|
||||
"allow-set-container-page-viewport",
|
||||
"allow-get-container-page-state",
|
||||
"allow-close-container-page",
|
||||
"allow-set-browser-view-match-window",
|
||||
"allow-get-browser-view-match-window",
|
||||
"allow-acquire-claude-token",
|
||||
"allow-submit-claude-token-code",
|
||||
"allow-cancel-claude-token",
|
||||
"allow-has-claude-token",
|
||||
"allow-clear-claude-token",
|
||||
"allow-sweep-claude-token-snapshots",
|
||||
"allow-get-settings",
|
||||
"allow-update-settings",
|
||||
"allow-pull-image",
|
||||
"allow-detect-aws-config",
|
||||
"allow-inspect-ca-cert-path",
|
||||
"allow-list-aws-profiles",
|
||||
"allow-detect-host-timezone",
|
||||
"allow-export-settings",
|
||||
"allow-preview-settings-import",
|
||||
"allow-apply-settings-import",
|
||||
"allow-open-terminal-session",
|
||||
"allow-terminal-input",
|
||||
"allow-terminal-resize",
|
||||
"allow-close-terminal-session",
|
||||
"allow-paste-image-to-terminal",
|
||||
"allow-upload-host-file-to-terminal",
|
||||
"allow-start-audio-bridge",
|
||||
"allow-send-audio-data",
|
||||
"allow-stop-audio-bridge",
|
||||
"allow-list-container-files",
|
||||
"allow-download-container-backup",
|
||||
"allow-download-container-file",
|
||||
"allow-upload-files-to-container",
|
||||
"allow-read-container-file",
|
||||
"allow-rename-container-path",
|
||||
"allow-create-container-directory",
|
||||
"allow-open-file-viewer",
|
||||
"allow-aws-sso-refresh",
|
||||
"allow-get-app-version",
|
||||
"allow-check-for-updates",
|
||||
"allow-check-image-update",
|
||||
"allow-get-help-content",
|
||||
"allow-open-url-external",
|
||||
"allow-detect-install-options",
|
||||
"allow-run-docker-install",
|
||||
"allow-start-web-terminal",
|
||||
"allow-stop-web-terminal",
|
||||
"allow-get-web-terminal-status",
|
||||
"allow-regenerate-web-terminal-token",
|
||||
"allow-get-stt-status",
|
||||
"allow-start-stt",
|
||||
"allow-stop-stt",
|
||||
"allow-build-stt-image",
|
||||
"allow-pull-stt-image",
|
||||
"allow-transcribe-audio",
|
||||
"allow-get-gateway-status",
|
||||
"allow-start-gateway",
|
||||
"allow-stop-gateway",
|
||||
"allow-check-gateway-health",
|
||||
"allow-build-gateway-image",
|
||||
"allow-pull-gateway-image",
|
||||
"allow-set-gateway-api-key",
|
||||
"allow-clear-gateway-api-key",
|
||||
"allow-get-gateway-auth-token",
|
||||
"allow-regenerate-gateway-auth-token",
|
||||
"allow-list-claude-sessions",
|
||||
"allow-resume-session-command",
|
||||
"allow-list-container-capabilities",
|
||||
"allow-list-scheduled-tasks",
|
||||
"allow-add-scheduled-task",
|
||||
"allow-update-scheduled-task",
|
||||
"allow-get-scheduled-task-log",
|
||||
"allow-set-scheduled-task-enabled",
|
||||
"allow-run-scheduled-task-now",
|
||||
"allow-remove-scheduled-task",
|
||||
"allow-get-scheduler-notifications",
|
||||
"allow-clear-scheduler-notifications"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-destroy",
|
||||
"core:webview:allow-internal-toggle-devtools"
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"allow-viewer-get-state",
|
||||
"allow-viewer-choose-file",
|
||||
"allow-viewer-read-file",
|
||||
"allow-viewer-poll-file",
|
||||
"allow-viewer-write-file"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,14 @@ mod tests {
|
||||
assert_eq!(
|
||||
permissions,
|
||||
vec![
|
||||
// App commands (bare): the five viewer commands, and nothing else — build.rs
|
||||
// refuses any other bare grant in this file.
|
||||
"allow-viewer-choose-file",
|
||||
"allow-viewer-get-state",
|
||||
"allow-viewer-poll-file",
|
||||
"allow-viewer-read-file",
|
||||
"allow-viewer-write-file",
|
||||
// Plugin/core grants, unchanged.
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
|
||||
@@ -912,7 +912,10 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut sorted = listed.clone();
|
||||
// Plugin and core grants: the exact reviewed list, unchanged by the lockdown.
|
||||
let (bare, prefixed): (Vec<String>, Vec<String>) =
|
||||
listed.iter().cloned().partition(|g| !g.contains(':'));
|
||||
let mut sorted = prefixed;
|
||||
sorted.sort();
|
||||
let mut expected = vec![
|
||||
"core:event:allow-listen",
|
||||
@@ -924,11 +927,31 @@ mod tests {
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
sorted, expected,
|
||||
"the capability set changed. That is allowed — but it is the IPC \
|
||||
"the plugin/core capability set changed. That is allowed — but it is the IPC \
|
||||
surface a compromised webview can call, so update this list \
|
||||
deliberately rather than to make the test pass."
|
||||
);
|
||||
|
||||
// App commands: since build.rs declares the AppManifest, the bare `allow-*` grants
|
||||
// are the complete list of app commands the main window may call. `build.rs` already
|
||||
// fails the build when they disagree with generate_handler!; this keeps the reviewed
|
||||
// rule ("every non-viewer command, exactly") visible where the plugin census lives.
|
||||
let registered = crate::command_census::registered_commands(include_str!("lib.rs"))
|
||||
.expect("lib.rs should contain a generate_handler! list");
|
||||
let mut expected_bare: Vec<String> = registered
|
||||
.iter()
|
||||
.filter(|c| crate::command_census::expected_windows(c) == ["main"])
|
||||
.map(|c| crate::command_census::allow_permission(c))
|
||||
.collect();
|
||||
expected_bare.sort();
|
||||
let mut bare = bare;
|
||||
bare.sort();
|
||||
assert_eq!(
|
||||
bare, expected_bare,
|
||||
"default.json's app-command grants must be exactly the main-window commands"
|
||||
);
|
||||
assert!(bare.len() >= 100, "the census found {} app grants; the parser has stopped seeing the list", bare.len());
|
||||
|
||||
// Belt and braces: the `*:default` aliases are the specific trap here,
|
||||
// because they expand to a set the file never spells out. `store:*` in
|
||||
// particular was an arbitrary host-file read/write primitive.
|
||||
|
||||
Reference in New Issue
Block a user