1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
//! Deterministic risk classification of ACP permission requests, shared by
//! the TUI's tiered autonomy and the fleet MCP bridge's auto-policy
//! (TUI_SPEC §5: reversible + in-worktree ⇒ auto; everything else gates).
use agent_client_protocol::schema::v1::{ToolCallUpdateFields, ToolKind};
/// Risk tier of a permission request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Risk {
/// Reads/searches and writes confined to the project tree.
Low,
/// Deletes, command execution, network access, writes outside the project
/// tree, or anything unclassifiable (conservative default).
High,
}
/// Classify from the tool call's structured fields. Conservative: only
/// reads/searches and writes provably confined to the project tree
/// (`workroot`, which also contains `.bitrouter/worktrees/`) classify Low;
/// deletes, command execution, network access, unknown kinds, and
/// unverifiable writes are High. (Spend-based classification needs metering
/// data that isn't available at permission time.)
pub fn classify(fields: &ToolCallUpdateFields, workroot: &std::path::Path) -> Risk {
match fields.kind {
Some(ToolKind::Read | ToolKind::Search | ToolKind::Think | ToolKind::SwitchMode) => {
Risk::Low
}
Some(ToolKind::Edit | ToolKind::Move) => {
let locations = fields.locations.as_deref().unwrap_or(&[]);
if !locations.is_empty() && locations.iter().all(|l| l.path.starts_with(workroot)) {
Risk::Low
} else {
// Outside the tree, or no locations to verify against.
Risk::High
}
}
// Delete, Execute (arbitrary commands), Fetch (network), Other/None.
_ => Risk::High,
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_client_protocol::schema::v1::ToolCallLocation;
fn fields(kind: Option<ToolKind>, paths: &[&str]) -> ToolCallUpdateFields {
let locations: Vec<ToolCallLocation> =
paths.iter().map(|p| ToolCallLocation::new(*p)).collect();
ToolCallUpdateFields::new().kind(kind).locations(locations)
}
#[test]
fn classify_is_deterministic_over_kind_and_paths() {
let root = std::path::Path::new("/repo");
// Reads/searches: low regardless of location.
for kind in [ToolKind::Read, ToolKind::Search, ToolKind::Think] {
assert_eq!(
classify(&fields(Some(kind), &["/etc/passwd"]), root),
Risk::Low,
"{kind:?} is low"
);
}
// Writes inside the tree (including bitrouter worktrees): low.
assert_eq!(
classify(
&fields(
Some(ToolKind::Edit),
&["/repo/src/x.rs", "/repo/.bitrouter/worktrees/w1/y.rs"]
),
root
),
Risk::Low
);
// Writes outside the tree: high.
assert_eq!(
classify(
&fields(Some(ToolKind::Edit), &["/home/user/.ssh/config"]),
root
),
Risk::High
);
// One outside path taints the whole request.
assert_eq!(
classify(
&fields(Some(ToolKind::Edit), &["/repo/src/x.rs", "/tmp/out"]),
root
),
Risk::High
);
// A write with no locations is unverifiable: high.
assert_eq!(
classify(&fields(Some(ToolKind::Edit), &[]), root),
Risk::High
);
// Deletes, execution, network, unknown: high.
for kind in [
ToolKind::Delete,
ToolKind::Execute,
ToolKind::Fetch,
ToolKind::Other,
] {
assert_eq!(
classify(&fields(Some(kind), &["/repo/src/x.rs"]), root),
Risk::High,
"{kind:?} is high"
);
}
assert_eq!(
classify(&fields(None, &["/repo/src/x.rs"]), root),
Risk::High,
"missing kind is high"
);
}
}