@setoelkahfi / sigit / commits / 48aa255

Add tool permission system: approval prompts, policy, plan mode

Every tool call now passes a permission gate before executing. Read-only tools (read_file, list_directory, search_files, glob, read_website, write_todos, skill) always run; mutating tools — including all mcp__* and unknown tools, the safe default for external side effects — are governed by layered policy: per-session plan mode, then session "always allow" grants, then [permissions] in settings.toml (per-tool overrides + default allow/ask/deny, fresh-install default ask; SIGIT_PERMISSIONS env overrides the default for headless runs and clients without permission support). - src/permissions.rs: classification, policy resolution, session-keyed grants/plan-mode state, model-facing denial messages. - settings.rs: [permissions] table (PermissionMode, per-tool map), permission_default()/permission_mode_for(), env override. - ACP (main.rs): on "ask", sends session/request_permission with allow once / allow for session / deny options and honors the outcome; a cancelled request stops the turn with StopReason::Cancelled. Turn- affecting handlers (prompt, session lifecycle, config) now run in cx.spawn'ed tasks serialized by SiGitAgent::turn_lock — the dispatch loop must stay free to route the client's permission answer mid-turn (awaiting a client request from an inline handler deadlocks). - TUI (chat.rs): the inference task pauses on a oneshot while the footer and transcript show "allow <tool>? [y]es / [a]lways this session / [n]o"; Ctrl+C cancels the turn (dropping the channel reads as deny). - /plan [on|off] and /permissions slash commands on both surfaces, advertised over ACP; /clear and session load reset per-session state. Verified: cargo fmt + clippy (-D warnings, CI target) + 104 tests green on the CI toolchain (1.96), plus a scripted ACP stdio smoke test (initialize -> session/new -> /permissions -> /plan on) proving the spawned-handler restructure does not deadlock the dispatch loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f6zfWDT1v5TeSpA29rEyx

Claude committed Jul 2, 2026 at 08:51 UTC 48aa25582259626f591f8c205bad89fc0b99600b
5 files changed +796 -26
CLAUDE.md
+16 -3
@@ -102,6 +102,17 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
102 official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in and authed with the
103 cloud session token; extra servers live in `mcp.toml` (global `$SIGIT_CONFIG_DIR/mcp.toml` and
104 project-local `.sigit/mcp.toml`). stdio transport is not supported.
105 +- **`src/permissions.rs`** — tool permission policy. Every tool call passes through
106 + `decision_for` before executing: read-only tools always run; mutating tools (and all
107 + `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
108 + mutating tools with a present-a-plan message), session "always allow" grants, per-tool
109 + overrides and the default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`,
110 + default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`, the ACP path sends
111 + `session/request_permission` (allow once / allow for session / deny) and the TUI pauses the
112 + inference task on a y/a/n prompt. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
113 + serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
114 + answer mid-turn — don't move them back inline, and don't await client requests from inline
115 + handlers (deadlock).
116 - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
117 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
118 walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
@@ -120,8 +131,8 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
131 - **`src/models.rs`** — model-picker types shared across platforms.
132
133 Slash commands (`/help`, `/models`, `/skills`, `/mcp`, `/login`, `/logout`, `/whoami`, `/reload`,
123 -`/clear`, `/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the
124 -TUI and ACP sessions.
134 +`/plan`, `/permissions`, `/clear`, `/status`) are advertised via `advertise_commands` in `main.rs`
135 +and handled in both the TUI and ACP sessions.
136
137 ## Model cache (macOS)
138
@@ -142,7 +153,9 @@ verbosity with `RUST_LOG`.
153 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
154 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
155 `SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
145 -server), `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
156 +server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
157 +mutating tools; the escape hatch for clients without permission-request support),
158 +`HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
159
160 ## Releasing
161
src/chat.rs
+147 -3
@@ -88,7 +88,7 @@ mod tui {
88 text::{Line, Span},
89 widgets::{Block, Borders, Clear, Paragraph, Wrap},
90 };
91 - use tokio::sync::mpsc;
91 + use tokio::sync::{mpsc, oneshot};
92 use tokio::time::{Duration, Instant, interval};
93
94 // ── Message types ─────────────────────────────────────────────────────────
@@ -157,6 +157,23 @@ mod tui {
157 /// a complete (non-streamed) assistant reply
158 Response(String),
159 Error(String),
160 + /// the inference task wants to run a mutating tool and is paused on
161 + /// `reply`; the user answers with y (once) / a (session) / n (deny)
162 + ApprovalRequest {
163 + tool: String,
164 + reply: oneshot::Sender<ApprovalChoice>,
165 + },
166 + }
167 +
168 + /// The user's answer to a tool-approval prompt. Dropping the reply channel
169 + /// (quit, cancel) counts as a denial on the inference side.
170 + enum ApprovalChoice {
171 + /// run this one call
172 + Once,
173 + /// run it and stop asking for this tool for the rest of the session
174 + Session,
175 + /// skip the call; the model gets an explanatory tool result
176 + Deny,
177 }
178
179 enum ModelLoadUpdate {
@@ -175,6 +192,9 @@ mod tui {
192 stream_buf: String,
193 inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
194 model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
195 + /// a tool call waiting on the user's y/a/n answer; the inference task is
196 + /// paused on the other end of the channel
197 + pending_approval: Option<(String, oneshot::Sender<ApprovalChoice>)>,
198 thinking: bool,
199 thinking_tick: u8,
200 quit: bool,
@@ -270,6 +290,7 @@ mod tui {
290 stream_buf: String::new(),
291 inference_rx: None,
292 model_load_rx: None,
293 + pending_approval: None,
294 thinking: false,
295 thinking_tick: 0,
296 quit: false,
@@ -342,6 +363,9 @@ mod tui {
363 fn stop_thinking(&mut self) {
364 self.thinking = false;
365 self.inference_rx = None;
366 + // Dropping a pending reply channel reads as a denial on the
367 + // inference side, so a cancelled turn can't leave a tool waiting.
368 + self.pending_approval = None;
369 }
370
371 fn tick_thinking(&mut self) {
@@ -746,6 +770,11 @@ mod tui {
770 Login(Option<String>),
771 Logout,
772 Whoami,
773 + /// Toggle plan mode (research only; mutating tools are denied with a
774 + /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
775 + Plan(Option<bool>),
776 + /// Show the effective permission policy for this session.
777 + Permissions,
778 Exit,
779 Unknown(String),
780 }
@@ -770,6 +799,8 @@ mod tui {
799 "/login" => SlashCommand::Login(arg.map(str::to_string)),
800 "/logout" => SlashCommand::Logout,
801 "/whoami" => SlashCommand::Whoami,
802 + "/plan" => SlashCommand::Plan(parse_on_off(arg)),
803 + "/permissions" => SlashCommand::Permissions,
804 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
805 other => SlashCommand::Unknown(other.to_string()),
806 })
@@ -1139,7 +1170,12 @@ mod tui {
1170 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1171 ];
1172
1142 - if app.thinking || app.switching_model || app.is_streaming() {
1173 + if let Some((tool, _)) = &app.pending_approval {
1174 + spans.push(Span::styled(
1175 + format!(" allow {tool}? [y]es · [a]lways · [n]o"),
1176 + Style::default().fg(Color::Yellow),
1177 + ));
1178 + } else if app.thinking || app.switching_model || app.is_streaming() {
1179 spans.push(Span::styled(
1180 " (busy — Ctrl+C to cancel)",
1181 Style::default().fg(Color::Yellow),
@@ -1367,6 +1403,8 @@ mod tui {
1403 /login E P — sign in to siGit Code Cloud\n\
1404 /logout — sign out\n\
1405 /whoami — show the signed-in account\n\
1406 + /plan [on|off] — plan mode: research only, no edits or commands\n\
1407 + /permissions — show the tool permission policy\n\
1408 /clear — wipe conversation history\n\
1409 /status — show engine status\n\
1410 /exit — quit chat",
@@ -1375,10 +1413,29 @@ mod tui {
1413 SlashCommand::Clear => {
1414 let cleared = engine.clear_history().await;
1415 app.messages.clear();
1416 + crate::permissions::reset_session(crate::permissions::TUI_SESSION);
1417 app.messages.push(ChatMessage::system(format!(
1418 "Cleared {cleared} turn(s). History is empty.",
1419 )));
1420 }
1421 + SlashCommand::Plan(value) => {
1422 + use crate::permissions::{self, TUI_SESSION};
1423 + let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION));
1424 + permissions::set_plan_mode(TUI_SESSION, enabled);
1425 + app.messages.push(ChatMessage::system(if enabled {
1426 + "Plan mode ON — research with read-only tools only; edits and commands \
1427 + are blocked until /plan off."
1428 + } else {
1429 + "Plan mode OFF — tools may execute again (subject to the permission \
1430 + policy)."
1431 + }));
1432 + }
1433 + SlashCommand::Permissions => {
1434 + app.messages
1435 + .push(ChatMessage::system(crate::permissions::describe(
1436 + crate::permissions::TUI_SESSION,
1437 + )));
1438 + }
1439 SlashCommand::Status => {
1440 let info = engine.as_ref().info().await;
1441 let model = info.model_name.as_deref().unwrap_or("(none)");
@@ -1617,7 +1674,41 @@ mod tui {
1674
1675 let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await;
1676
1620 - let output = crate::tools::execute_tool(&tc.name, &tc.arguments).await;
1677 + // Permission gate: read-only tools pass straight through; a
1678 + // mutating tool consults policy and may pause on the user's
1679 + // y/a/n answer (delivered over a oneshot from the event loop).
1680 + use crate::permissions::{self, Decision, TUI_SESSION};
1681 + let output = match permissions::decision_for(TUI_SESSION, &tc.name) {
1682 + Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
1683 + Decision::Deny(reason) => {
1684 + log::info!(" ✗ {} denied by policy", tc.name);
1685 + reason
1686 + }
1687 + Decision::Ask => {
1688 + let (reply_tx, reply_rx) = oneshot::channel();
1689 + let _ = tx
1690 + .send(InferenceUpdate::ApprovalRequest {
1691 + tool: tc.name.clone(),
1692 + reply: reply_tx,
1693 + })
1694 + .await;
1695 + match reply_rx.await {
1696 + Ok(ApprovalChoice::Once) => {
1697 + crate::tools::execute_tool(&tc.name, &tc.arguments).await
1698 + }
1699 + Ok(ApprovalChoice::Session) => {
1700 + permissions::grant_for_session(TUI_SESSION, &tc.name);
1701 + crate::tools::execute_tool(&tc.name, &tc.arguments).await
1702 + }
1703 + // An explicit "no", or the UI dropped the channel
1704 + // (cancel/quit) — either way, do not run the tool.
1705 + Ok(ApprovalChoice::Deny) | Err(_) => {
1706 + log::info!(" ✗ {} denied by user", tc.name);
1707 + permissions::user_denial(&tc.name)
1708 + }
1709 + }
1710 + }
1711 + };
1712 log::info!(" ← {} chars", output.len());
1713
1714 tool_results.push(ToolResult {
@@ -1837,6 +1928,12 @@ mod tui {
1928 app.stop_thinking();
1929 app.messages.push(ChatMessage::system(format!("error: {msg}")));
1930 }
1931 + Some(InferenceUpdate::ApprovalRequest { tool, reply }) => {
1932 + app.messages.push(ChatMessage::system(format!(
1933 + "⚠ permission — allow {tool}? [y]es · [a]lways this session · [n]o"
1934 + )));
1935 + app.pending_approval = Some((tool, reply));
1936 + }
1937 None => {
1938 // task finished, possibly with no text to show
1939 app.finalize_stream();
@@ -1881,6 +1978,53 @@ mod tui {
1978 continue;
1979 }
1980
1981 + // pending tool approval — y/a/n answer the prompt; the
1982 + // inference task is paused on the reply channel. Checked
1983 + // before the busy gate because the app *is* busy here.
1984 + if app.pending_approval.is_some() {
1985 + if key.kind == KeyEventKind::Press {
1986 + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1987 + let choice = if ctrl
1988 + && (key.code == KeyCode::Char('c')
1989 + || key.code == KeyCode::Char('d'))
1990 + {
1991 + // cancel the whole turn: denying is implicit
1992 + // in dropping the reply channel
1993 + app.pending_approval = None;
1994 + app.stop_thinking();
1995 + app.messages.push(ChatMessage::system("(cancelled)"));
1996 + continue;
1997 + } else {
1998 + match key.code {
1999 + KeyCode::Char('y') | KeyCode::Char('Y') => {
2000 + Some(ApprovalChoice::Once)
2001 + }
2002 + KeyCode::Char('a') | KeyCode::Char('A') => {
2003 + Some(ApprovalChoice::Session)
2004 + }
2005 + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
2006 + Some(ApprovalChoice::Deny)
2007 + }
2008 + _ => None,
2009 + }
2010 + };
2011 + if let Some(choice) = choice
2012 + && let Some((tool, reply)) = app.pending_approval.take()
2013 + {
2014 + let verdict = match &choice {
2015 + ApprovalChoice::Once => "allowed once",
2016 + ApprovalChoice::Session => "allowed for this session",
2017 + ApprovalChoice::Deny => "denied",
2018 + };
2019 + app.messages.push(ChatMessage::system(format!(
2020 + "{tool}: {verdict}"
2021 + )));
2022 + let _ = reply.send(choice);
2023 + }
2024 + }
2025 + continue;
2026 + }
2027 +
2028 // busy — only cancel keys work
2029 if app.is_busy() {
2030 if key.kind == KeyEventKind::Press {
src/main.rs
+214 -15
@@ -35,6 +35,7 @@ mod credentials;
35 mod instructions;
36 mod mcp;
37 mod models;
38 +mod permissions;
39 mod provider;
40 mod settings;
41 mod setup;
@@ -62,12 +63,13 @@ use agent_client_protocol::schema::v1::{
63 AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
64 ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
65 ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
65 - LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest,
66 - PromptResponse, SessionCapabilities, SessionConfigOption, SessionConfigOptionCategory,
67 - SessionConfigSelectOption, SessionConfigValueId, SessionForkCapabilities, SessionId,
68 - SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
69 - SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallStatus, ToolCallUpdate,
70 - ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
66 + LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
67 + PermissionOptionKind, PromptRequest, PromptResponse, RequestPermissionOutcome,
68 + RequestPermissionRequest, SessionCapabilities, SessionConfigOption,
69 + SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
70 + SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
71 + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
72 + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
73 };
74 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
75 use onde::inference::{ChatEngine, GgufModelConfig};
@@ -230,6 +232,33 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
232 /// cap tool-call loops so a confused model can't spin forever
233 const MAX_TOOL_ROUNDS: usize = 10;
234
235 +/// Outcome of asking the client for permission to run one tool call.
236 +enum PermissionVerdict {
237 + /// Run the tool.
238 + Approved,
239 + /// Skip the tool; the string becomes its tool result so the model adapts.
240 + Denied(String),
241 + /// The client cancelled the turn while the request was pending; stop the
242 + /// whole prompt with `StopReason::Cancelled` instead of burning rounds.
243 + TurnCancelled,
244 +}
245 +
246 +/// Display kind for the permission dialog, so editors can show a fitting icon.
247 +fn tool_kind_for(tool_name: &str) -> ToolKind {
248 + match tool_name {
249 + "edit_file" | "multi_edit" | "create_file" | "create_directory" | "remember" => {
250 + ToolKind::Edit
251 + }
252 + "delete_file" => ToolKind::Delete,
253 + "run_command" => ToolKind::Execute,
254 + "read_file" | "list_directory" => ToolKind::Read,
255 + "search_files" | "glob" => ToolKind::Search,
256 + "read_website" => ToolKind::Fetch,
257 + "write_todos" => ToolKind::Think,
258 + _ => ToolKind::Other,
259 + }
260 +}
261 +
262 /// Shown when a siGit Code Cloud tier is selected without a signed-in account.
263 const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
264 `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
@@ -350,6 +379,12 @@ struct SiGitAgent {
379 startup_model_name: String,
380 /// for download-progress polling
381 startup_model_id: String,
382 + /// Serializes turn-affecting handlers (prompt, session lifecycle, config
383 + /// changes). They run in `cx.spawn`ed tasks so the JSON-RPC dispatch loop
384 + /// stays free to route client responses (e.g. permission answers) mid-turn;
385 + /// this lock reproduces the strict ordering the dispatch loop used to give
386 + /// them for free.
387 + turn_lock: Arc<tokio::sync::Mutex<()>>,
388 }
389
390 impl SiGitAgent {
@@ -375,6 +410,7 @@ impl SiGitAgent {
410 startup_needs_download,
411 startup_model_name,
412 startup_model_id,
413 + turn_lock: Arc::new(tokio::sync::Mutex::new(())),
414 }
415 }
416
@@ -727,6 +763,12 @@ impl SiGitAgent {
763 AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
764 AvailableCommand::new("whoami", "Show the signed-in account"),
765 AvailableCommand::new("reload", "Re-sync sign-in and model state"),
766 + with_hint(
767 + "plan",
768 + "Plan mode: research only, no edits or commands",
769 + "on|off (optional)",
770 + ),
771 + AvailableCommand::new("permissions", "Show the tool permission policy"),
772 AvailableCommand::new("clear", "Wipe the conversation history"),
773 AvailableCommand::new("status", "Show engine status"),
774 ];
@@ -907,6 +949,10 @@ impl SiGitAgent {
949 *guard = Some(args.cwd.clone());
950 }
951
952 + // A reloaded session starts fresh: grants and plan mode from the
953 + // previous life of this session id must not carry over.
954 + permissions::reset_session(&args.session_id.to_string());
955 +
956 // tool calls use relative paths, so we need to match the editor's cwd
957 if args.cwd.is_dir()
958 && let Err(err) = std::env::set_current_dir(&args.cwd)
@@ -1256,7 +1302,35 @@ impl SiGitAgent {
1302 tc.arguments.chars().take(120).collect::<String>()
1303 );
1304
1259 - let output = tools::execute_tool(&tc.name, &tc.arguments).await;
1305 + // Permission gate: read-only tools pass straight through; a
1306 + // mutating tool consults policy and may ask the client.
1307 + let output = match permissions::decision_for(&session_id.to_string(), &tc.name) {
1308 + permissions::Decision::Allow => {
1309 + tools::execute_tool(&tc.name, &tc.arguments).await
1310 + }
1311 + permissions::Decision::Deny(reason) => {
1312 + log::info!(" ✗ {} denied by policy", tc.name);
1313 + reason
1314 + }
1315 + permissions::Decision::Ask => {
1316 + match self
1317 + .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1318 + .await
1319 + {
1320 + PermissionVerdict::Approved => {
1321 + tools::execute_tool(&tc.name, &tc.arguments).await
1322 + }
1323 + PermissionVerdict::Denied(reason) => {
1324 + log::info!(" ✗ {} denied by user", tc.name);
1325 + reason
1326 + }
1327 + PermissionVerdict::TurnCancelled => {
1328 + log::info!("prompt({}) cancelled at permission gate", session_id);
1329 + return Ok(PromptResponse::new(StopReason::Cancelled));
1330 + }
1331 + }
1332 + }
1333 + };
1334
1335 log::info!(" ← {} chars", output.len());
1336
@@ -1323,6 +1397,74 @@ impl SiGitAgent {
1397 Ok(PromptResponse::new(StopReason::EndTurn))
1398 }
1399
1400 + /// Ask the ACP client for permission to run one tool call. Presents
1401 + /// allow-once / allow-for-session / deny; an "always allow" choice is
1402 + /// recorded via [`permissions::grant_for_session`]. Only safe to call from
1403 + /// a spawned task (see the handler registration in `run_acp_server`): the
1404 + /// dispatch loop must be free to route the client's answer back to us.
1405 + async fn request_tool_permission(
1406 + &self,
1407 + cx: &ConnectionTo<Client>,
1408 + session_id: &SessionId,
1409 + tool_name: &str,
1410 + arguments: &str,
1411 + ) -> PermissionVerdict {
1412 + let args_preview: String = arguments.chars().take(120).collect();
1413 + let title = if args_preview.is_empty() {
1414 + tool_name.to_string()
1415 + } else {
1416 + format!("{tool_name}({args_preview})")
1417 + };
1418 +
1419 + let request = RequestPermissionRequest::new(
1420 + session_id.clone(),
1421 + ToolCallUpdate::new(
1422 + format!("perm-{}", uuid::Uuid::new_v4()),
1423 + ToolCallUpdateFields::new()
1424 + .title(title)
1425 + .kind(tool_kind_for(tool_name))
1426 + .status(ToolCallStatus::Pending),
1427 + ),
1428 + vec![
1429 + PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
1430 + PermissionOption::new(
1431 + "allow_session",
1432 + "Allow for this session",
1433 + PermissionOptionKind::AllowAlways,
1434 + ),
1435 + PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
1436 + ],
1437 + );
1438 +
1439 + match cx.send_request(request).block_task().await {
1440 + Ok(response) => match response.outcome {
1441 + RequestPermissionOutcome::Selected(selected) => {
1442 + match selected.option_id.0.as_ref() {
1443 + "allow_once" => PermissionVerdict::Approved,
1444 + "allow_session" => {
1445 + permissions::grant_for_session(&session_id.to_string(), tool_name);
1446 + PermissionVerdict::Approved
1447 + }
1448 + _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
1449 + }
1450 + }
1451 + RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
1452 + // The outcome enum is non_exhaustive; treat anything unknown as
1453 + // a denial rather than running a mutating tool unapproved.
1454 + _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
1455 + },
1456 + Err(error) => {
1457 + log::warn!("permission request for `{tool_name}` failed: {error}");
1458 + PermissionVerdict::Denied(format!(
1459 + "`{tool_name}` was not executed: this client could not answer the \
1460 + permission request ({error}). The user can pre-approve tools in \
1461 + settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
1462 + for clients without permission support."
1463 + ))
1464 + }
1465 + }
1466 + }
1467 +
1468 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
1469 log::info!("cancel requested for session {}", args.session_id);
1470 Ok(())
@@ -1977,6 +2119,11 @@ enum SlashCommand {
2119 Whoami,
2120 /// Re-sync session state (auth, backend, picker) without a new session.
2121 Reload,
2122 + /// Toggle plan mode (read-only research; mutating tools denied with a
2123 + /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
2124 + Plan(Option<bool>),
2125 + /// Show the effective permission policy for this session.
2126 + Permissions,
2127 Exit,
2128 Unknown(String),
2129 }
@@ -2002,6 +2149,8 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
2149 "/logout" => SlashCommand::Logout,
2150 "/whoami" => SlashCommand::Whoami,
2151 "/reload" => SlashCommand::Reload,
2152 + "/plan" => SlashCommand::Plan(parse_on_off(argument)),
2153 + "/permissions" => SlashCommand::Permissions,
2154 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
2155 other => SlashCommand::Unknown(other.to_string()),
2156 })
@@ -2110,6 +2259,8 @@ async fn exec_slash_acp(
2259 /logout - sign out\n\
2260 /whoami - show the signed-in account\n\
2261 /reload - re-sync sign-in and model state\n\
2262 + /plan [on|off] - plan mode: research only, no edits or commands\n\
2263 + /permissions - show the tool permission policy\n\
2264 /clear - wipe conversation history\n\
2265 /status - show engine status\n\
2266 /exit - end this turn",
@@ -2118,6 +2269,7 @@ async fn exec_slash_acp(
2269 }
2270 SlashCommand::Clear => {
2271 let cleared = agent.engine.clear_history().await;
2272 + permissions::reset_session(&session_id.to_string());
2273 agent
2274 .send_assistant_message(
2275 cx,
@@ -2126,6 +2278,23 @@ async fn exec_slash_acp(
2278 )
2279 .ok();
2280 }
2281 + SlashCommand::Plan(value) => {
2282 + let session_key = session_id.to_string();
2283 + let enabled = value.unwrap_or_else(|| !permissions::plan_mode(&session_key));
2284 + permissions::set_plan_mode(&session_key, enabled);
2285 + let message = if enabled {
2286 + "Plan mode ON — the agent researches with read-only tools and presents a \
2287 + plan; edits and commands are blocked until /plan off."
2288 + } else {
2289 + "Plan mode OFF — the agent may execute tools again (subject to the \
2290 + permission policy)."
2291 + };
2292 + agent.send_assistant_message(cx, session_id, message).ok();
2293 + }
2294 + SlashCommand::Permissions => {
2295 + let summary = permissions::describe(&session_id.to_string());
2296 + agent.send_assistant_message(cx, session_id, summary).ok();
2297 + }
2298 SlashCommand::Status => {
2299 let info = agent.engine.info().await;
2300 let model = info.model_name.as_deref().unwrap_or("(none)");
@@ -2677,11 +2846,21 @@ async fn run_acp_server() -> anyhow::Result<()> {
2846 },
2847 agent_client_protocol::on_receive_request!(),
2848 )
2849 + // Turn-affecting handlers below run in spawned tasks, serialized by
2850 + // `turn_lock`, so the dispatch loop stays free to route client
2851 + // responses (permission answers) while a turn is in flight. Awaiting a
2852 + // client request from *inside* a handler would deadlock: the dispatch
2853 + // loop can't read the response while the handler blocks it.
2854 .on_receive_request(
2855 {
2856 let state = Arc::clone(&state);
2857 async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
2684 - handle_response(responder, state.handle_load_session(&cx, req).await)
2858 + let state = Arc::clone(&state);
2859 + let task_cx = cx.clone();
2860 + cx.spawn(async move {
2861 + let _turn = state.turn_lock.lock().await;
2862 + handle_response(responder, state.handle_load_session(&task_cx, req).await)
2863 + })
2864 }
2865 },
2866 agent_client_protocol::on_receive_request!(),
@@ -2690,7 +2869,12 @@ async fn run_acp_server() -> anyhow::Result<()> {
2869 {
2870 let state = Arc::clone(&state);
2871 async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| {
2693 - handle_response(responder, state.handle_fork_session(&cx, req).await)
2872 + let state = Arc::clone(&state);
2873 + let task_cx = cx.clone();
2874 + cx.spawn(async move {
2875 + let _turn = state.turn_lock.lock().await;
2876 + handle_response(responder, state.handle_fork_session(&task_cx, req).await)
2877 + })
2878 }
2879 },
2880 agent_client_protocol::on_receive_request!(),
@@ -2699,7 +2883,12 @@ async fn run_acp_server() -> anyhow::Result<()> {
2883 {
2884 let state = Arc::clone(&state);
2885 async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| {
2702 - handle_response(responder, state.handle_new_session(&cx, req).await)
2886 + let state = Arc::clone(&state);
2887 + let task_cx = cx.clone();
2888 + cx.spawn(async move {
2889 + let _turn = state.turn_lock.lock().await;
2890 + handle_response(responder, state.handle_new_session(&task_cx, req).await)
2891 + })
2892 }
2893 },
2894 agent_client_protocol::on_receive_request!(),
@@ -2708,7 +2897,12 @@ async fn run_acp_server() -> anyhow::Result<()> {
2897 {
2898 let state = Arc::clone(&state);
2899 async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
2711 - handle_response(responder, state.handle_prompt(&cx, req).await)
2900 + let state = Arc::clone(&state);
2901 + let task_cx = cx.clone();
2902 + cx.spawn(async move {
2903 + let _turn = state.turn_lock.lock().await;
2904 + handle_response(responder, state.handle_prompt(&task_cx, req).await)
2905 + })
2906 }
2907 },
2908 agent_client_protocol::on_receive_request!(),
@@ -2719,10 +2913,15 @@ async fn run_acp_server() -> anyhow::Result<()> {
2913 async move |req: SetSessionConfigOptionRequest,
2914 responder,
2915 cx: ConnectionTo<Client>| {
2722 - handle_response(
2723 - responder,
2724 - state.handle_set_session_config_option(&cx, req).await,
2725 - )
2916 + let state = Arc::clone(&state);
2917 + let task_cx = cx.clone();
2918 + cx.spawn(async move {
2919 + let _turn = state.turn_lock.lock().await;
2920 + handle_response(
2921 + responder,
2922 + state.handle_set_session_config_option(&task_cx, req).await,
2923 + )
2924 + })
2925 }
2926 },
2927 agent_client_protocol::on_receive_request!(),
src/permissions.rs new
+285
@@ -0,0 +1,285 @@
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 +}
src/settings.rs
+134 -5
@@ -5,12 +5,14 @@
5 //! [`crate::credentials`] but holds preferences rather than secrets, so it is
6 //! not permission-restricted.
7 //!
8 -//! The only setting today is `local_inference`: whether on-device inference is
9 -//! the active mode. It is the source of truth for the local/cloud toggle and
10 -//! drives how `/models` presents the picker. It is stored locally so the toggle
11 -//! works even on ACP clients that do not support slash commands (e.g. Xcode),
12 -//! where it is also surfaced as a session config option.
8 +//! Settings today: `local_inference` (whether on-device inference is the
9 +//! active mode — the source of truth for the local/cloud toggle, also surfaced
10 +//! as a session config option for ACP clients without slash commands, e.g.
11 +//! Xcode) and `[permissions]` (the tool permission policy consumed by
12 +//! `crate::permissions`: a default mode for mutating tools plus per-tool
13 +//! overrides).
14
15 +use std::collections::BTreeMap;
16 use std::path::PathBuf;
17
18 use serde::{Deserialize, Serialize};
@@ -20,10 +22,73 @@ use serde::{Deserialize, Serialize};
22 /// style); it never writes the file.
23 const LOCAL_INFERENCE_ENV: &str = "SIGIT_LOCAL_INFERENCE";
24
25 +/// Env override for the default permission mode (`allow`/`ask`/`deny`). Wins
26 +/// over the stored default (but not over per-tool overrides); never writes the
27 +/// file. The escape hatch for ACP clients that cannot answer permission
28 +/// requests and for headless runs.
29 +const PERMISSIONS_ENV: &str = "SIGIT_PERMISSIONS";
30 +
31 fn default_local_inference() -> bool {
32 true
33 }
34
35 +/// What to do when the model calls a mutating tool.
36 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
37 +#[serde(rename_all = "lowercase")]
38 +pub enum PermissionMode {
39 + /// Run without asking.
40 + Allow,
41 + /// Ask the user first (ACP permission request / TUI approval prompt).
42 + #[default]
43 + Ask,
44 + /// Never run; the model gets an explanatory tool result.
45 + Deny,
46 +}
47 +
48 +impl PermissionMode {
49 + fn parse(value: &str) -> Option<Self> {
50 + match value.trim().to_ascii_lowercase().as_str() {
51 + "allow" => Some(Self::Allow),
52 + "ask" => Some(Self::Ask),
53 + "deny" => Some(Self::Deny),
54 + _ => None,
55 + }
56 + }
57 +}
58 +
59 +impl std::fmt::Display for PermissionMode {
60 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 + f.write_str(match self {
62 + Self::Allow => "allow",
63 + Self::Ask => "ask",
64 + Self::Deny => "deny",
65 + })
66 + }
67 +}
68 +
69 +/// The `[permissions]` table: a default mode for mutating tools plus per-tool
70 +/// overrides, e.g.
71 +///
72 +/// ```toml
73 +/// [permissions]
74 +/// default = "ask"
75 +///
76 +/// [permissions.tools]
77 +/// edit_file = "allow"
78 +/// delete_file = "deny"
79 +/// ```
80 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81 +pub struct PermissionSettings {
82 + /// Mode for mutating tools without a per-tool override. `ask` on a fresh
83 + /// install.
84 + #[serde(default)]
85 + pub default: PermissionMode,
86 + /// Per-tool overrides by tool name (MCP tools use their full
87 + /// `mcp__<server>__<tool>` name).
88 + #[serde(default)]
89 + pub tools: BTreeMap<String, PermissionMode>,
90 +}
91 +
92 /// Persisted preferences. New fields must carry `#[serde(default)]` so older
93 /// files keep deserializing.
94 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -32,12 +97,16 @@ pub struct Settings {
97 /// fresh install.
98 #[serde(default = "default_local_inference")]
99 pub local_inference: bool,
100 + /// Permission policy for mutating agent tools.
101 + #[serde(default)]
102 + pub permissions: PermissionSettings,
103 }
104
105 impl Default for Settings {
106 fn default() -> Self {
107 Self {
108 local_inference: default_local_inference(),
109 + permissions: PermissionSettings::default(),
110 }
111 }
112 }
@@ -108,6 +177,32 @@ pub fn set_local_inference(enabled: bool) -> Result<(), String> {
177 store(&settings)
178 }
179
180 +/// The default permission mode for mutating tools: the `SIGIT_PERMISSIONS` env
181 +/// var when set to a recognized mode, else the stored `[permissions] default`.
182 +pub fn permission_default() -> PermissionMode {
183 + if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
184 + && let Some(mode) = PermissionMode::parse(&raw)
185 + {
186 + return mode;
187 + }
188 + load().permissions.default
189 +}
190 +
191 +/// The effective permission mode for one tool: its `[permissions.tools]`
192 +/// override when present, else the default (see [`permission_default`]).
193 +pub fn permission_mode_for(tool_name: &str) -> PermissionMode {
194 + let settings = load();
195 + if let Some(mode) = settings.permissions.tools.get(tool_name) {
196 + return *mode;
197 + }
198 + if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
199 + && let Some(mode) = PermissionMode::parse(&raw)
200 + {
201 + return mode;
202 + }
203 + settings.permissions.default
204 +}
205 +
206 #[cfg(test)]
207 mod tests {
208 use super::*;
@@ -146,6 +241,40 @@ mod tests {
241 "unrecognized env value falls back to stored setting"
242 );
243
244 + // Permissions: fresh install asks; per-tool overrides win over the
245 + // default; the env var overrides the stored default but not per-tool
246 + // overrides.
247 + unsafe { std::env::remove_var(PERMISSIONS_ENV) };
248 + assert_eq!(permission_default(), PermissionMode::Ask);
249 + assert_eq!(permission_mode_for("run_command"), PermissionMode::Ask);
250 +
251 + let mut settings = load();
252 + settings.permissions.default = PermissionMode::Allow;
253 + settings
254 + .permissions
255 + .tools
256 + .insert("delete_file".to_string(), PermissionMode::Deny);
257 + store(&settings).unwrap();
258 + assert_eq!(permission_default(), PermissionMode::Allow);
259 + assert_eq!(permission_mode_for("run_command"), PermissionMode::Allow);
260 + assert_eq!(permission_mode_for("delete_file"), PermissionMode::Deny);
261 +
262 + unsafe { std::env::set_var(PERMISSIONS_ENV, "deny") };
263 + assert_eq!(permission_default(), PermissionMode::Deny);
264 + assert_eq!(permission_mode_for("run_command"), PermissionMode::Deny);
265 + assert_eq!(
266 + permission_mode_for("delete_file"),
267 + PermissionMode::Deny,
268 + "per-tool override still wins"
269 + );
270 + unsafe { std::env::set_var(PERMISSIONS_ENV, "garbage") };
271 + assert_eq!(
272 + permission_default(),
273 + PermissionMode::Allow,
274 + "unrecognized env value falls back to stored setting"
275 + );
276 +
277 + unsafe { std::env::remove_var(PERMISSIONS_ENV) };
278 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
279 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
280 let _ = std::fs::remove_dir_all(&dir);