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 pub const TUI_SESSION: &str = "tui";
38
39 /// How risky a tool is to run without the user's sign-off.
40 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
41 pub enum ToolRisk {
42 /// Observes state without changing it; always allowed to run.
43 ReadOnly,
44 /// Changes files, runs commands, or has unknown side effects; governed by
45 /// the permission policy.
46 Mutating,
47 }
48
49 /// The outcome of the policy check for one tool call.
50 #[derive(Debug, Clone, PartialEq, Eq)]
51 pub enum Decision {
52 /// Run the tool without asking.
53 Allow,
54 /// Ask the user before running (surface-specific: ACP permission request
55 /// or TUI approval prompt).
56 Ask,
57 /// Do not run the tool; the string is returned to the model as the tool
58 /// result so it can adapt instead of retrying blindly.
59 Deny(String),
60 }
61
62 /// Classify a tool by name. Unknown names and MCP tools are mutating: the
63 /// conservative default for anything whose side effects we can't see.
64 pub fn classify(tool_name: &str) -> ToolRisk {
65 match tool_name {
66 "read_file" | "list_directory" | "search_files" | "glob" | "read_website"
67 | "write_todos" | "skill" => ToolRisk::ReadOnly,
68 _ => ToolRisk::Mutating,
69 }
70 }
71
72 /// Per-session permission state.
73 #[derive(Default)]
74 struct SessionPerms {
75 /// Tools the user chose "always allow this session" for.
76 always_allow: HashSet<String>,
77 /// When set, every mutating tool is denied with a plan-mode message.
78 plan_mode: bool,
79 }
80
81 fn sessions() -> &'static Mutex<HashMap<String, SessionPerms>> {
82 static SESSIONS: OnceLock<Mutex<HashMap<String, SessionPerms>>> = OnceLock::new();
83 SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
84 }
85
86 fn with_session<T>(session: &str, f: impl FnOnce(&mut SessionPerms) -> T) -> T {
87 let mut map = sessions()
88 .lock()
89 .unwrap_or_else(|poisoned| poisoned.into_inner());
90 f(map.entry(session.to_string()).or_default())
91 }
92
93 /// The message returned to the model when a mutating tool is blocked by plan
94 /// mode. Instructive rather than terse so the model changes course in one turn.
95 fn plan_mode_denial(tool_name: &str) -> String {
96 format!(
97 "Plan mode is active: `{tool_name}` was not executed because it modifies state. \
98 Present a concise plan of the changes you intend to make and ask the user to \
99 approve it (they can run /plan off to enable execution). Read-only tools \
100 (read_file, search_files, glob, list_directory) remain available for research."
101 )
102 }
103
104 /// The message returned to the model when the user (or policy) denies a tool.
105 pub fn user_denial(tool_name: &str) -> String {
106 format!(
107 "The user denied permission to run `{tool_name}`. Do not retry the same call. \
108 Explain what you wanted to do and ask the user how to proceed, or continue \
109 with an approach that does not need this tool."
110 )
111 }
112
113 /// Policy check for one tool call. See the module docs for the layering.
114 pub fn decision_for(session: &str, tool_name: &str) -> Decision {
115 if classify(tool_name) == ToolRisk::ReadOnly {
116 return Decision::Allow;
117 }
118
119 let (plan_mode, granted) = with_session(session, |s| {
120 (s.plan_mode, s.always_allow.contains(tool_name))
121 });
122
123 if plan_mode {
124 return Decision::Deny(plan_mode_denial(tool_name));
125 }
126 if granted {
127 return Decision::Allow;
128 }
129
130 match settings::permission_mode_for(tool_name) {
131 PermissionMode::Allow => Decision::Allow,
132 PermissionMode::Ask => Decision::Ask,
133 PermissionMode::Deny => Decision::Deny(format!(
134 "`{tool_name}` is denied by the permission policy in settings.toml. \
135 Do not retry it; work without this tool or ask the user to change \
136 the policy."
137 )),
138 }
139 }
140
141 /// Record an "always allow this session" grant for a tool.
142 pub fn grant_for_session(session: &str, tool_name: &str) {
143 with_session(session, |s| {
144 s.always_allow.insert(tool_name.to_string());
145 });
146 }
147
148 /// Toggle plan mode for a session. Returns the new state.
149 pub fn set_plan_mode(session: &str, enabled: bool) -> bool {
150 with_session(session, |s| {
151 s.plan_mode = enabled;
152 s.plan_mode
153 })
154 }
155
156 /// Whether plan mode is active for a session.
157 pub fn plan_mode(session: &str) -> bool {
158 with_session(session, |s| s.plan_mode)
159 }
160
161 /// Drop all recorded state for a session (fresh session, /clear, or session
162 /// teardown) so grants never outlive the conversation they were given in.
163 pub fn reset_session(session: &str) {
164 let mut map = sessions()
165 .lock()
166 .unwrap_or_else(|poisoned| poisoned.into_inner());
167 map.remove(session);
168 }
169
170 /// One-line status summary for `/permissions` and `/status`.
171 pub fn describe(session: &str) -> String {
172 let plan = if plan_mode(session) { "on" } else { "off" };
173 let default = settings::permission_default();
174 let granted = with_session(session, |s| {
175 let mut names: Vec<&str> = s.always_allow.iter().map(String::as_str).collect();
176 names.sort_unstable();
177 names.join(", ")
178 });
179 let granted = if granted.is_empty() {
180 "none".to_string()
181 } else {
182 granted
183 };
184 format!(
185 "permissions: default={default} | plan mode: {plan} | session grants: {granted}\n\
186 read-only tools always run; configure [permissions] in settings.toml"
187 )
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193
194 /// `decision_for` reads settings (env + file), and the settings test
195 /// mutates `SIGIT_CONFIG_DIR`/`SIGIT_PERMISSIONS` under this lock — hold it
196 /// here too so parallel test runs don't race, and point the config dir at
197 /// an empty sandbox so a developer's real settings.toml can't skew results.
198 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
199 let guard = crate::ENV_TEST_LOCK
200 .lock()
201 .unwrap_or_else(|poisoned| poisoned.into_inner());
202 let dir = std::env::temp_dir().join(format!("sigit_perm_tests_{}", std::process::id()));
203 // SAFETY: process-global env mutation, serialized by ENV_TEST_LOCK; the
204 // other env-touching tests re-set these before reading.
205 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
206 unsafe { std::env::remove_var("SIGIT_PERMISSIONS") };
207 guard
208 }
209
210 #[test]
211 fn read_only_tools_always_allowed() {
212 let _guard = env_guard();
213 for tool in [
214 "read_file",
215 "list_directory",
216 "search_files",
217 "glob",
218 "read_website",
219 "write_todos",
220 "skill",
221 ] {
222 assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
223 assert_eq!(decision_for("t-ro", tool), Decision::Allow, "{tool}");
224 }
225 }
226
227 #[test]
228 fn mutating_and_unknown_tools_are_gated() {
229 for tool in [
230 "edit_file",
231 "multi_edit",
232 "create_file",
233 "create_directory",
234 "delete_file",
235 "run_command",
236 "remember",
237 "mcp__server__anything",
238 "totally_unknown_tool",
239 ] {
240 assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
241 }
242 }
243
244 #[test]
245 fn plan_mode_denies_mutating_and_spares_read_only() {
246 let _guard = env_guard();
247 let session = "t-plan";
248 reset_session(session);
249 set_plan_mode(session, true);
250 assert!(matches!(
251 decision_for(session, "run_command"),
252 Decision::Deny(_)
253 ));
254 assert_eq!(decision_for(session, "read_file"), Decision::Allow);
255 set_plan_mode(session, false);
256 reset_session(session);
257 }
258
259 #[test]
260 fn session_grant_short_circuits_ask() {
261 let _guard = env_guard();
262 let session = "t-grant";
263 reset_session(session);
264 grant_for_session(session, "edit_file");
265 assert_eq!(decision_for(session, "edit_file"), Decision::Allow);
266 // Other tools are unaffected by the grant.
267 assert_ne!(decision_for(session, "delete_file"), Decision::Allow);
268 reset_session(session);
269 assert_ne!(decision_for(session, "edit_file"), Decision::Allow);
270 }
271
272 #[test]
273 fn plan_mode_outranks_session_grant() {
274 let _guard = env_guard();
275 let session = "t-rank";
276 reset_session(session);
277 grant_for_session(session, "edit_file");
278 set_plan_mode(session, true);
279 assert!(matches!(
280 decision_for(session, "edit_file"),
281 Decision::Deny(_)
282 ));
283 reset_session(session);
284 }
285 }