1 //! Tool permission policy: which agent tools may run, and when to ask.
2 //!
3 //! Every tool call funnels through one decision point before execution
4 //! (`decision_for`). Tools are classified by risk: *read-only* tools (reading
5 //! files, searching, listing, fetching a web page) always run, while *mutating*
6 //! tools (writing files, deleting, shell commands, MCP tools) are governed by
7 //! policy. The policy layers, first match wins:
8 //!
9 //! 1. **Plan mode** — a per-session switch that denies every mutating tool with
10 //! a message telling the model to present a plan instead. Toggled via
11 //! `/plan on|off` (TUI and ACP).
12 //! 2. **Session grants** — "always allow this session", recorded when the user
13 //! picks that option in an approval prompt.
14 //! 3. **Per-tool override** — `[permissions.tools]` in `settings.toml`, e.g.
15 //! `run_command = "ask"`, `edit_file = "allow"`, `delete_file = "deny"`.
16 //! 4. **Default mode** — `[permissions] default = "ask"|"allow"|"deny"` in
17 //! `settings.toml`; `ask` on a fresh install.
18 //!
19 //! The `SIGIT_PERMISSIONS` env var (`allow`/`ask`/`deny`) overrides the stored
20 //! default without writing the file — the escape hatch for ACP clients that
21 //! cannot answer `session/request_permission` and for CI/headless runs.
22 //!
23 //! Tools discovered from MCP servers (`mcp__*`) and any unknown tool name are
24 //! treated as mutating: external tools can have arbitrary side effects, so the
25 //! safe assumption is to gate them. The one exception is the official
26 //! sigit.si server's query tools (see [`classify`]), which are read-only.
27 //!
28 //! Session state (grants + plan mode) lives in a process-global keyed by
29 //! session id — the same pattern as `mcp.rs`'s server cache — so the ACP
30 //! multi-session surface and the single-session TUI share one implementation.
31
32 use std::collections::{HashMap, HashSet};
33 use std::sync::{Mutex, OnceLock};
34
35 use crate::settings::{self, PermissionMode};
36
37 /// Session key used by the interactive TUI, which only ever has one session.
38 /// The TUI (`chat.rs`) is `#[cfg(unix)]`, so this is its only consumer and is
39 /// dead on non-Unix targets — the rest of the module is used on all platforms.
40 #[cfg_attr(not(unix), allow(dead_code))]
41 pub const TUI_SESSION: &str = "tui";
42
43 /// How risky a tool is to run without the user's sign-off.
44 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45 pub enum ToolRisk {
46 /// Observes state without changing it; always allowed to run.
47 ReadOnly,
48 /// Changes files, runs commands, or has unknown side effects; governed by
49 /// the permission policy.
50 Mutating,
51 }
52
53 /// The outcome of the policy check for one tool call.
54 #[derive(Debug, Clone, PartialEq, Eq)]
55 pub enum Decision {
56 /// Run the tool without asking.
57 Allow,
58 /// Ask the user before running (surface-specific: ACP permission request
59 /// or TUI approval prompt).
60 Ask,
61 /// Do not run the tool; the string is returned to the model as the tool
62 /// result so it can adapt instead of retrying blindly.
63 Deny(String),
64 }
65
66 /// Classify a tool by name. Unknown names and MCP tools are mutating: the
67 /// conservative default for anything whose side effects we can't see.
68 /// `task` is read-only because the subagent it launches is restricted to the
69 /// read-only toolset (see `SUBAGENT_TOOL_NAMES` in `tools.rs`), so delegated
70 /// research stays available in plan mode.
71 ///
72 /// One MCP exception: the *official* sigit.si server (`mcp__sigit__*`) is
73 /// first-party, so its query tools — names starting `list_` or `get_`, plus
74 /// `search_code` and `web_search` — are read-only and never prompt (the TUI's
75 /// Repo tab depends on this). Every other `mcp__*` tool stays mutating.
76 pub fn classify(tool_name: &str) -> ToolRisk {
77 match tool_name {
78 "read_file" | "list_directory" | "search_files" | "glob" | "read_website"
79 | "write_todos" | "skill" | "task" | "command_output" => ToolRisk::ReadOnly,
80 _ => {
81 if let Some(bare) = crate::mcp::official_tool_suffix(tool_name)
82 && (bare.starts_with("list_")
83 || bare.starts_with("get_")
84 || bare == "search_code"
85 || bare == "web_search")
86 {
87 return ToolRisk::ReadOnly;
88 }
89 ToolRisk::Mutating
90 }
91 }
92 }
93
94 /// Per-session permission state.
95 #[derive(Default)]
96 struct SessionPerms {
97 /// Tools the user chose "always allow this session" for.
98 always_allow: HashSet<String>,
99 /// When set, every mutating tool is denied with a plan-mode message.
100 plan_mode: bool,
101 }
102
103 fn sessions() -> &'static Mutex<HashMap<String, SessionPerms>> {
104 static SESSIONS: OnceLock<Mutex<HashMap<String, SessionPerms>>> = OnceLock::new();
105 SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
106 }
107
108 fn with_session<T>(session: &str, f: impl FnOnce(&mut SessionPerms) -> T) -> T {
109 let mut map = sessions()
110 .lock()
111 .unwrap_or_else(|poisoned| poisoned.into_inner());
112 f(map.entry(session.to_string()).or_default())
113 }
114
115 /// The message returned to the model when a mutating tool is blocked by plan
116 /// mode. Instructive rather than terse so the model changes course in one turn.
117 fn plan_mode_denial(tool_name: &str) -> String {
118 format!(
119 "Plan mode is active: `{tool_name}` was not executed because it modifies state. \
120 Present a concise plan of the changes you intend to make and ask the user to \
121 approve it (they can run /plan off to enable execution). Read-only tools \
122 (read_file, search_files, glob, list_directory) remain available for research."
123 )
124 }
125
126 /// The message returned to the model when the user (or policy) denies a tool.
127 pub fn user_denial(tool_name: &str) -> String {
128 format!(
129 "The user denied permission to run `{tool_name}`. Do not retry the same call. \
130 Explain what you wanted to do and ask the user how to proceed, or continue \
131 with an approach that does not need this tool."
132 )
133 }
134
135 /// Render a tool call's arguments for an approval prompt. The person deciding
136 /// must be able to see what they are approving, so the cap is generous and any
137 /// cut is marked with how much is hidden — silently truncating could hide the
138 /// tail of a command from the user who is about to allow it.
139 pub fn approval_preview(arguments: &str) -> String {
140 const MAX_CHARS: usize = 500;
141 let total = arguments.chars().count();
142 if total <= MAX_CHARS {
143 return arguments.to_string();
144 }
145 let shown: String = arguments.chars().take(MAX_CHARS).collect();
146 format!("{shown}… [+{} more chars]", total - MAX_CHARS)
147 }
148
149 /// Policy check for one tool call. See the module docs for the layering.
150 pub fn decision_for(session: &str, tool_name: &str) -> Decision {
151 if classify(tool_name) == ToolRisk::ReadOnly {
152 return Decision::Allow;
153 }
154
155 let (plan_mode, granted) = with_session(session, |s| {
156 (s.plan_mode, s.always_allow.contains(tool_name))
157 });
158
159 if plan_mode {
160 return Decision::Deny(plan_mode_denial(tool_name));
161 }
162 if granted {
163 return Decision::Allow;
164 }
165
166 match settings::permission_mode_for(tool_name) {
167 PermissionMode::Allow => Decision::Allow,
168 PermissionMode::Ask => Decision::Ask,
169 PermissionMode::Deny => Decision::Deny(format!(
170 "`{tool_name}` is denied by the permission policy in settings.toml. \
171 Do not retry it; work without this tool or ask the user to change \
172 the policy."
173 )),
174 }
175 }
176
177 /// Record an "always allow this session" grant for a tool.
178 pub fn grant_for_session(session: &str, tool_name: &str) {
179 with_session(session, |s| {
180 s.always_allow.insert(tool_name.to_string());
181 });
182 }
183
184 /// Toggle plan mode for a session. Returns the new state.
185 pub fn set_plan_mode(session: &str, enabled: bool) -> bool {
186 with_session(session, |s| {
187 s.plan_mode = enabled;
188 s.plan_mode
189 })
190 }
191
192 /// Whether plan mode is active for a session.
193 pub fn plan_mode(session: &str) -> bool {
194 with_session(session, |s| s.plan_mode)
195 }
196
197 /// Drop all recorded state for a session (fresh session, /clear, or session
198 /// teardown) so grants never outlive the conversation they were given in.
199 pub fn reset_session(session: &str) {
200 let mut map = sessions()
201 .lock()
202 .unwrap_or_else(|poisoned| poisoned.into_inner());
203 map.remove(session);
204 }
205
206 /// Drop the recorded state for *every* session. Called at ACP session
207 /// boundaries (new/load/fork): the agent drives one shared engine, so only one
208 /// conversation is live at a time and grants must never cross a boundary. This
209 /// also keeps the map from accumulating entries for session ids that will
210 /// never be used again.
211 pub fn reset_all() {
212 let mut map = sessions()
213 .lock()
214 .unwrap_or_else(|poisoned| poisoned.into_inner());
215 map.clear();
216 }
217
218 /// One-line status summary for `/permissions` and `/status`.
219 pub fn describe(session: &str) -> String {
220 let plan = if plan_mode(session) { "on" } else { "off" };
221 let default = settings::permission_default();
222 let granted = with_session(session, |s| {
223 let mut names: Vec<&str> = s.always_allow.iter().map(String::as_str).collect();
224 names.sort_unstable();
225 names.join(", ")
226 });
227 let granted = if granted.is_empty() {
228 "none".to_string()
229 } else {
230 granted
231 };
232 format!(
233 "permissions: default={default} | plan mode: {plan} | session grants: {granted}\n\
234 read-only tools always run; configure [permissions] in settings.toml"
235 )
236 }
237
238 #[cfg(test)]
239 mod tests {
240 use super::*;
241
242 /// `decision_for` reads settings (env + file), and the settings test
243 /// mutates `SIGIT_CONFIG_DIR`/`SIGIT_PERMISSIONS` under this lock — hold it
244 /// here too so parallel test runs don't race, and point the config dir at
245 /// an empty sandbox so a developer's real settings.toml can't skew results.
246 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
247 let guard = crate::ENV_TEST_LOCK
248 .lock()
249 .unwrap_or_else(|poisoned| poisoned.into_inner());
250 let dir = std::env::temp_dir().join(format!("sigit_perm_tests_{}", std::process::id()));
251 // SAFETY: process-global env mutation, serialized by ENV_TEST_LOCK; the
252 // other env-touching tests re-set these before reading.
253 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
254 unsafe { std::env::remove_var("SIGIT_PERMISSIONS") };
255 guard
256 }
257
258 #[test]
259 fn read_only_tools_always_allowed() {
260 let _guard = env_guard();
261 for tool in [
262 "read_file",
263 "list_directory",
264 "search_files",
265 "glob",
266 "read_website",
267 "write_todos",
268 "skill",
269 "task",
270 "command_output",
271 ] {
272 assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
273 assert_eq!(decision_for("t-ro", tool), Decision::Allow, "{tool}");
274 }
275 }
276
277 #[test]
278 fn mutating_and_unknown_tools_are_gated() {
279 for tool in [
280 "edit_file",
281 "multi_edit",
282 "create_file",
283 "create_directory",
284 "delete_file",
285 "run_command",
286 "kill_command",
287 "remember",
288 "mcp__server__anything",
289 "totally_unknown_tool",
290 ] {
291 assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
292 }
293 }
294
295 #[test]
296 fn official_mcp_query_tools_are_read_only() {
297 let _guard = env_guard();
298 for tool in [
299 "mcp__sigit__list_issues",
300 "mcp__sigit__list_pull_requests",
301 "mcp__sigit__get_issue",
302 "mcp__sigit__get_pull_request",
303 "mcp__sigit__list_repositories",
304 "mcp__sigit__get_file_contents",
305 "mcp__sigit__search_code",
306 "mcp__sigit__web_search",
307 ] {
308 assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
309 // Read-only means it never prompts, whatever the policy layers say.
310 assert_eq!(decision_for("t-official", tool), Decision::Allow, "{tool}");
311 }
312 }
313
314 #[test]
315 fn official_mcp_mutating_and_foreign_servers_stay_gated() {
316 for tool in [
317 // official server, but not a query tool
318 "mcp__sigit__create_issue",
319 "mcp__sigit__merge_pull_request",
320 "mcp__sigit__delete_repository",
321 "mcp__sigit__",
322 // query-shaped names on other servers get no exemption
323 "mcp__other__list_issues",
324 "mcp__other__get_issue",
325 "mcp__github__search_code",
326 // `sigit` must match the whole server name
327 "mcp__sigitx__list_issues",
328 ] {
329 assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
330 }
331 }
332
333 #[test]
334 fn plan_mode_denies_mutating_and_spares_read_only() {
335 let _guard = env_guard();
336 let session = "t-plan";
337 reset_session(session);
338 set_plan_mode(session, true);
339 assert!(matches!(
340 decision_for(session, "run_command"),
341 Decision::Deny(_)
342 ));
343 assert_eq!(decision_for(session, "read_file"), Decision::Allow);
344 set_plan_mode(session, false);
345 reset_session(session);
346 }
347
348 #[test]
349 fn session_grant_short_circuits_ask() {
350 let _guard = env_guard();
351 let session = "t-grant";
352 reset_session(session);
353 grant_for_session(session, "edit_file");
354 assert_eq!(decision_for(session, "edit_file"), Decision::Allow);
355 // Other tools are unaffected by the grant.
356 assert_ne!(decision_for(session, "delete_file"), Decision::Allow);
357 reset_session(session);
358 assert_ne!(decision_for(session, "edit_file"), Decision::Allow);
359 }
360
361 #[test]
362 fn approval_preview_shows_short_arguments_in_full() {
363 let args = r#"{"command":"cargo test"}"#;
364 assert_eq!(approval_preview(args), args);
365 }
366
367 #[test]
368 fn approval_preview_marks_truncation_explicitly() {
369 let args = format!(r#"{{"command":"echo {}; rm -rf /"}}"#, "x".repeat(600));
370 let preview = approval_preview(&args);
371 assert!(preview.chars().count() < args.chars().count());
372 assert!(
373 preview.contains("more chars]"),
374 "hidden content must be flagged, got: {preview}"
375 );
376 }
377
378 #[test]
379 fn plan_mode_outranks_session_grant() {
380 let _guard = env_guard();
381 let session = "t-rank";
382 reset_session(session);
383 grant_for_session(session, "edit_file");
384 set_plan_mode(session, true);
385 assert!(matches!(
386 decision_for(session, "edit_file"),
387 Decision::Deny(_)
388 ));
389 reset_session(session);
390 }
391 }