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