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