Skip to content

Commit 7e330bd

Browse files
committed
fix(acl): grant IPC commands explicitly and guard ACL consistency
codex-trace relied on Tauri implicitly permitting its own app commands, so none of them appeared in the ACL. Mirror claude-code-trace's hardening: define an explicit [default] permission set granting each generate_handler! command, reference it from capabilities/default.json, and add a regression test that cross-checks the handler against the ACL in both directions (so a command wired up but ungranted fails CI instead of at runtime with 'Command <name> not allowed by ACL').
1 parent 426ea62 commit 7e330bd

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

src-tauri/capabilities/default.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"fs:allow-read-dir",
1111
"fs:allow-stat",
1212
"core:event:default",
13-
"opener:default"
13+
"opener:default",
14+
"default"
1415
]
1516
}

src-tauri/permissions/default.toml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
[default]
2+
description = "Default permissions for all codex-trace commands"
3+
permissions = [
4+
"allow-load-session",
5+
"allow-watch-session",
6+
"allow-unwatch-session",
7+
"allow-list-sessions",
8+
"allow-watch-picker",
9+
"allow-unwatch-picker",
10+
"allow-get-settings",
11+
"allow-set-sessions-dir",
12+
"allow-switch-to-browser",
13+
]
14+
15+
[[permission]]
16+
identifier = "allow-load-session"
17+
description = "Allow loading a session file"
18+
commands.allow = ["load_session"]
19+
20+
[[permission]]
21+
identifier = "allow-watch-session"
22+
description = "Allow watching a session file for changes"
23+
commands.allow = ["watch_session"]
24+
25+
[[permission]]
26+
identifier = "allow-unwatch-session"
27+
description = "Allow stopping session file watching"
28+
commands.allow = ["unwatch_session"]
29+
30+
[[permission]]
31+
identifier = "allow-list-sessions"
32+
description = "Allow listing sessions in the sessions directory"
33+
commands.allow = ["list_sessions"]
34+
35+
[[permission]]
36+
identifier = "allow-watch-picker"
37+
description = "Allow watching the picker for changes"
38+
commands.allow = ["watch_picker"]
39+
40+
[[permission]]
41+
identifier = "allow-unwatch-picker"
42+
description = "Allow stopping picker watching"
43+
commands.allow = ["unwatch_picker"]
44+
45+
[[permission]]
46+
identifier = "allow-get-settings"
47+
description = "Allow reading app settings"
48+
commands.allow = ["get_settings"]
49+
50+
[[permission]]
51+
identifier = "allow-set-sessions-dir"
52+
description = "Allow setting the sessions directory"
53+
commands.allow = ["set_sessions_dir"]
54+
55+
[[permission]]
56+
identifier = "allow-switch-to-browser"
57+
description = "Allow switching to browser mode"
58+
commands.allow = ["switch_to_browser"]

src-tauri/tests/acl_consistency.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//! Regression guard for the Tauri ACL.
2+
//!
3+
//! Every IPC command registered in `tauri::generate_handler![ ... ]` must also be
4+
//! granted by the `[default]` permission set in `permissions/default.toml`, and
5+
//! the capability in `capabilities/default.json` must reference that set.
6+
//! Otherwise the desktop build rejects the call at runtime with
7+
//! `Command <name> not allowed by ACL` — the failure mode that hit claude-code-trace
8+
//! when a feature added the handler but forgot the matching `commands.allow`
9+
//! entry. The check runs in both directions so a stale ACL grant is caught too.
10+
11+
use std::collections::{BTreeMap, BTreeSet};
12+
13+
const LIB_RS: &str = include_str!("../src/lib.rs");
14+
const DEFAULT_TOML: &str = include_str!("../permissions/default.toml");
15+
const CAPABILITY_JSON: &str = include_str!("../capabilities/default.json");
16+
17+
/// Contents of every double-quoted string in `s`.
18+
fn quoted_strings(s: &str) -> BTreeSet<String> {
19+
s.split('"')
20+
.enumerate()
21+
.filter(|(i, _)| i % 2 == 1)
22+
.map(|(_, part)| part.to_string())
23+
.collect()
24+
}
25+
26+
/// Command names registered in `tauri::generate_handler![ ... ]` (last path segment).
27+
fn handler_commands() -> BTreeSet<String> {
28+
let start = LIB_RS
29+
.find("generate_handler![")
30+
.expect("generate_handler! macro present in lib.rs");
31+
let rest = &LIB_RS[start..];
32+
let open = rest.find('[').expect("opening [ for generate_handler!");
33+
let close = rest.find(']').expect("closing ] for generate_handler!");
34+
rest[open + 1..close]
35+
.split(',')
36+
.map(str::trim)
37+
.filter(|t| !t.is_empty())
38+
.map(|t| t.rsplit("::").next().unwrap().trim().to_string())
39+
.collect()
40+
}
41+
42+
/// Identifiers referenced by the `[default]` permission set.
43+
fn default_permission_set() -> BTreeSet<String> {
44+
let key = "permissions = [";
45+
let start = DEFAULT_TOML
46+
.find(key)
47+
.expect("[default] permissions array present");
48+
let after = &DEFAULT_TOML[start + key.len()..];
49+
let end = after.find(']').expect("closing ] for default permissions");
50+
quoted_strings(&after[..end])
51+
}
52+
53+
/// Map of each `[[permission]]` identifier to the commands it allows.
54+
fn permission_blocks() -> BTreeMap<String, BTreeSet<String>> {
55+
let mut map = BTreeMap::new();
56+
for chunk in DEFAULT_TOML.split("[[permission]]").skip(1) {
57+
let Some(id_line) = chunk
58+
.lines()
59+
.find(|l| l.trim_start().starts_with("identifier"))
60+
else {
61+
continue;
62+
};
63+
let Some(id) = quoted_strings(id_line).into_iter().next() else {
64+
continue;
65+
};
66+
let key = "commands.allow = [";
67+
let cmds = match chunk.find(key) {
68+
Some(s) => {
69+
let after = &chunk[s + key.len()..];
70+
let end = after.find(']').unwrap_or(after.len());
71+
quoted_strings(&after[..end])
72+
}
73+
None => BTreeSet::new(),
74+
};
75+
map.insert(id, cmds);
76+
}
77+
map
78+
}
79+
80+
/// Commands granted by the `[default]` permission set.
81+
fn acl_granted_commands() -> BTreeSet<String> {
82+
let referenced = default_permission_set();
83+
let blocks = permission_blocks();
84+
referenced
85+
.iter()
86+
.filter_map(|id| blocks.get(id))
87+
.flat_map(|cmds| cmds.iter().cloned())
88+
.collect()
89+
}
90+
91+
#[test]
92+
fn handler_and_acl_grant_the_same_commands() {
93+
let handler = handler_commands();
94+
let granted = acl_granted_commands();
95+
96+
let missing_from_acl: Vec<_> = handler.difference(&granted).collect();
97+
assert!(
98+
missing_from_acl.is_empty(),
99+
"commands registered in generate_handler! but missing an ACL `commands.allow` entry \
100+
(these fail at runtime with `Command <name> not allowed by ACL`): {missing_from_acl:?}"
101+
);
102+
103+
let extra_in_acl: Vec<_> = granted.difference(&handler).collect();
104+
assert!(
105+
extra_in_acl.is_empty(),
106+
"ACL grants commands that are not registered in generate_handler!: {extra_in_acl:?}"
107+
);
108+
}
109+
110+
#[test]
111+
fn capability_references_the_default_permission_set() {
112+
// The granular commands.allow entries only take effect if the capability
113+
// actually pulls in the local `default` permission set.
114+
assert!(
115+
quoted_strings(CAPABILITY_JSON).contains("default"),
116+
"capabilities/default.json must reference the \"default\" permission set so the \
117+
app command grants in permissions/default.toml are applied"
118+
);
119+
}
120+
121+
#[test]
122+
fn core_session_commands_are_acl_granted() {
123+
let granted = acl_granted_commands();
124+
for cmd in ["load_session", "list_sessions", "get_settings"] {
125+
assert!(granted.contains(cmd), "{cmd} must be ACL-granted");
126+
}
127+
}

0 commit comments

Comments
 (0)