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:
2026-09-22 22:26:18 -07:00
co-authored by Claude Opus 5.5
parent 92a2d9de4a
commit 05d991181d
11 changed files with 3038 additions and 8 deletions
+25 -2
View File
@@ -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.