Add ACP permission mode selector

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Seto Elkahfi committed Sep 9, 2026 at 11:22 UTC 8f6097efdf56d67efffc80f363af46bfcbbab3a7
5 files changed +453 -44
CHANGELOG.md
+8
@@ -4,6 +4,14 @@
4
5 ### What changed
6
7 +- **Editor panels can switch permission mode without a slash command.** ACP
8 + clients now get a Permissions selector next to the model controls with
9 + Manual, Auto, and Plan choices for the current session. Manual keeps the
10 + existing approval prompts, Auto lets mutating tools run unattended while
11 + still respecting explicit deny rules in `settings.toml`, and Plan keeps the
12 + agent in research-only mode. The selector follows `/plan` and `/clear`, and
13 + it is deliberately session-scoped so a risky Auto choice does not persist
14 + into the next task
15 - **`@smbcloud/sigit` gets releases again.** The npm scope moved to
16 `@getsigit` in 1.5.5 and the old package was left sitting on the registry at
17 1.5.2, so an install made before the rename went quiet — `npm update` had
src/main.rs
+143 -12
@@ -1395,7 +1395,7 @@ impl SiGitAgent {
1395
1396 let config_options = {
1397 let guard = self.current_model.lock().unwrap();
1398 - build_model_config_options(&guard)
1398 + build_config_options(&guard, &args.session_id.to_string())
1399 };
1400
1401 self.advertise_commands(cx, args.session_id.clone());
@@ -1444,7 +1444,7 @@ impl SiGitAgent {
1444
1445 let config_options = {
1446 let guard = self.current_model.lock().unwrap();
1447 - build_model_config_options(&guard)
1447 + build_config_options(&guard, &new_id.to_string())
1448 };
1449
1450 self.advertise_commands(cx, new_id.clone());
@@ -1491,7 +1491,7 @@ impl SiGitAgent {
1491
1492 let config_options = {
1493 let guard = self.current_model.lock().unwrap();
1494 - build_model_config_options(&guard)
1494 + build_config_options(&guard, &session_id.to_string())
1495 };
1496
1497 self.advertise_commands(cx, session_id.clone());
@@ -2241,7 +2241,7 @@ impl SiGitAgent {
2241 // Push refreshed picker + commands so the editor reflects current state.
2242 let config_options = {
2243 let guard = self.current_model.lock().unwrap();
2244 - build_model_config_options(&guard)
2244 + build_config_options(&guard, &session_id.to_string())
2245 };
2246 self.send_tool_call_update(
2247 cx,
@@ -2297,7 +2297,46 @@ impl SiGitAgent {
2297 .ok();
2298 // Rebuild so the Model picker reflects the new emphasis/order.
2299 let current = self.current_model.lock().unwrap().clone();
2300 - let config_options = build_model_config_options(&current);
2300 + let config_options = build_config_options(&current, &args.session_id.to_string());
2301 + return Ok(SetSessionConfigOptionResponse::new(config_options));
2302 + }
2303 +
2304 + // ── Permissions dropdown (issue #76) ────────────────────────────────
2305 + if args.config_id.0.as_ref() == PERMISSION_MODE_CONFIG_ID {
2306 + let session_key = args.session_id.to_string();
2307 + let message = match args.value.as_value_id().map(|v| v.0.as_ref()) {
2308 + Some(PERMISSION_MODE_MANUAL) => {
2309 + permissions::set_plan_mode(&session_key, false);
2310 + permissions::set_session_mode(
2311 + &session_key,
2312 + Some(settings::PermissionMode::Ask),
2313 + );
2314 + "Permissions: Manual — the agent asks before running mutating tools."
2315 + }
2316 + Some(PERMISSION_MODE_AUTO) => {
2317 + permissions::set_plan_mode(&session_key, false);
2318 + permissions::set_session_mode(
2319 + &session_key,
2320 + Some(settings::PermissionMode::Allow),
2321 + );
2322 + "Permissions: Auto — tools run without asking, subject to settings.toml rules."
2323 + }
2324 + Some(PERMISSION_MODE_PLAN) => {
2325 + permissions::set_plan_mode(&session_key, true);
2326 + "Permissions: Plan — the agent researches with read-only tools and presents \
2327 + a plan; edits and commands are blocked."
2328 + }
2329 + other => {
2330 + return Err(agent_client_protocol::Error::new(
2331 + -32602,
2332 + format!("unknown Permissions value: {other:?}"),
2333 + ));
2334 + }
2335 + };
2336 + self.send_system_status(cx, args.session_id.clone(), message)
2337 + .ok();
2338 + let current = self.current_model.lock().unwrap().clone();
2339 + let config_options = build_config_options(&current, &session_key);
2340 return Ok(SetSessionConfigOptionResponse::new(config_options));
2341 }
2342
@@ -2341,7 +2380,7 @@ impl SiGitAgent {
2380 "set_session_config_option: {} is already the active selection, skipping",
2381 current.display_name
2382 );
2344 - let config_options = build_model_config_options(&current);
2383 + let config_options = build_config_options(&current, &args.session_id.to_string());
2384 return Ok(SetSessionConfigOptionResponse::new(config_options));
2385 }
2386 }
@@ -2371,7 +2410,7 @@ impl SiGitAgent {
2410 }
2411
2412 let current = self.current_model.lock().unwrap().clone();
2374 - let config_options = build_model_config_options(&current);
2413 + let config_options = build_config_options(&current, &args.session_id.to_string());
2414 return Ok(SetSessionConfigOptionResponse::new(config_options));
2415 }
2416
@@ -2608,7 +2647,7 @@ impl SiGitAgent {
2647
2648 let config_options = {
2649 let guard = self.current_model.lock().unwrap();
2611 - build_model_config_options(&guard)
2650 + build_config_options(&guard, &args.session_id.to_string())
2651 };
2652
2653 log::info!("model switch complete");
@@ -2648,6 +2687,16 @@ const LOCAL_INFERENCE_CONFIG_ID: &str = "sigit-local-inference";
2687 const LOCAL_INFERENCE_ON: &str = "local-inference-on";
2688 const LOCAL_INFERENCE_OFF: &str = "local-inference-off";
2689
2690 +/// config option ID for the Permissions dropdown (issue #76): flips the
2691 +/// session between asking for approval, running tools unattended, and plan
2692 +/// mode, without touching `settings.toml`.
2693 +const PERMISSION_MODE_CONFIG_ID: &str = "sigit-permission-mode";
2694 +
2695 +/// `select` value ids for the Permissions dropdown.
2696 +const PERMISSION_MODE_MANUAL: &str = "permission-mode-manual";
2697 +const PERMISSION_MODE_AUTO: &str = "permission-mode-auto";
2698 +const PERMISSION_MODE_PLAN: &str = "permission-mode-plan";
2699 +
2700 /// Replace non-ASCII chars so a downstream byte-index truncation can't split a
2701 /// multi-byte char. Zed slices the model-picker label at a fixed byte offset
2702 /// (`agent_ui/src/config_options.rs`) and panics — crashing the whole editor —
@@ -2659,7 +2708,10 @@ fn ascii_safe(s: &str) -> String {
2708 .collect()
2709 }
2710
2662 -fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
2711 +fn build_config_options(
2712 + current_model: &GgufModelConfig,
2713 + session_key: &str,
2714 +) -> Vec<SessionConfigOption> {
2715 // The full list, including the siGit Code Cloud tiers, so the panel picker
2716 // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection.
2717 let items = models::build_model_picker_items();
@@ -2749,8 +2801,46 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
2801 )
2802 .description("Toggle on-device inference; changes which models are highlighted");
2803
2804 + // Permissions dropdown (issue #76): Manual (ask), Auto (run unattended),
2805 + // Plan (research only). Derived, never stored — see `permissions.rs`.
2806 + let permission_current = SessionConfigValueId::new(if permissions::plan_mode(session_key) {
2807 + PERMISSION_MODE_PLAN
2808 + } else {
2809 + match permissions::effective_mode(session_key) {
2810 + settings::PermissionMode::Allow => PERMISSION_MODE_AUTO,
2811 + settings::PermissionMode::Ask | settings::PermissionMode::Deny => {
2812 + PERMISSION_MODE_MANUAL
2813 + }
2814 + }
2815 + });
2816 + let permission_options = vec![
2817 + SessionConfigSelectOption::new(
2818 + SessionConfigValueId::new(PERMISSION_MODE_MANUAL),
2819 + "Manual".to_string(),
2820 + )
2821 + .description("Ask before running mutating tools".to_string()),
2822 + SessionConfigSelectOption::new(
2823 + SessionConfigValueId::new(PERMISSION_MODE_AUTO),
2824 + "Auto".to_string(),
2825 + )
2826 + .description("Run tools without asking, subject to settings.toml rules".to_string()),
2827 + SessionConfigSelectOption::new(
2828 + SessionConfigValueId::new(PERMISSION_MODE_PLAN),
2829 + "Plan".to_string(),
2830 + )
2831 + .description("Research only; no edits or commands".to_string()),
2832 + ];
2833 + let permission_option = SessionConfigOption::select(
2834 + PERMISSION_MODE_CONFIG_ID,
2835 + "Permissions",
2836 + permission_current,
2837 + permission_options,
2838 + )
2839 + .category(SessionConfigOptionCategory::Mode)
2840 + .description("How tool calls are approved for this session");
2841 +
2842 if options.is_empty() {
2753 - return vec![local_option];
2843 + return vec![local_option, permission_option];
2844 }
2845
2846 let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
@@ -2760,6 +2850,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
2850 .category(SessionConfigOptionCategory::Model)
2851 .description("Select an on-device model or a siGit Code Cloud tier"),
2852 local_option,
2853 + permission_option,
2854 ]
2855 }
2856
@@ -2970,6 +3061,19 @@ async fn exec_slash_acp(
3061 permissions::reset_session(&session_id.to_string());
3062 // The saved session must not resurrect what the user just wiped.
3063 session_store::delete(&session_id.to_string());
3064 + // The wipe drops the session's Permissions override too; push the
3065 + // refreshed chip so it doesn't keep showing a stale pick.
3066 + let config_options = {
3067 + let current = agent.current_model.lock().unwrap();
3068 + build_config_options(&current, &session_id.to_string())
3069 + };
3070 + agent
3071 + .send_tool_call_update(
3072 + cx,
3073 + session_id.clone(),
3074 + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3075 + )
3076 + .ok();
3077 agent
3078 .send_assistant_message(
3079 cx,
@@ -2989,6 +3093,19 @@ async fn exec_slash_acp(
3093 "Plan mode OFF — the agent may execute tools again (subject to the \
3094 permission policy)."
3095 };
3096 + // Keep the panel Permissions chip in step: /plan on moves it to
3097 + // Plan, /plan off drops it back to whatever the session mode is.
3098 + let config_options = {
3099 + let current = agent.current_model.lock().unwrap();
3100 + build_config_options(&current, &session_key)
3101 + };
3102 + agent
3103 + .send_tool_call_update(
3104 + cx,
3105 + session_id.clone(),
3106 + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3107 + )
3108 + .ok();
3109 agent.send_assistant_message(cx, session_id, message).ok();
3110 }
3111 SlashCommand::Permissions => {
@@ -3175,7 +3292,7 @@ async fn exec_slash_acp(
3292 // Refresh the panel so the Model picker reflects the new emphasis.
3293 let config_options = {
3294 let current = agent.current_model.lock().unwrap();
3178 - build_model_config_options(&current)
3295 + build_config_options(&current, &session_id.to_string())
3296 };
3297 agent
3298 .send_tool_call_update(
@@ -4229,7 +4346,7 @@ mod tests {
4346 approx_memory: "Cloud".to_string(),
4347 chat_template: None,
4348 };
4232 - let options = serde_json::to_value(build_model_config_options(&current)).unwrap();
4349 + let options = serde_json::to_value(build_config_options(&current, "t-config")).unwrap();
4350 let all_options = options.as_array().expect("config options");
4351
4352 let model = all_options
@@ -4265,5 +4382,19 @@ mod tests {
4382 .filter_map(|option| option["name"].as_str())
4383 .collect();
4384 assert_eq!(inference_names, ["Local", "Cloud"]);
4385 +
4386 + let permissions = all_options
4387 + .iter()
4388 + .find(|option| option["id"] == PERMISSION_MODE_CONFIG_ID)
4389 + .expect("permissions config option");
4390 + assert_eq!(permissions["category"], "mode");
4391 + assert_eq!(permissions["currentValue"], PERMISSION_MODE_MANUAL);
4392 + let permission_names: Vec<&str> = permissions["options"]
4393 + .as_array()
4394 + .expect("permissions select options")
4395 + .iter()
4396 + .filter_map(|option| option["name"].as_str())
4397 + .collect();
4398 + assert_eq!(permission_names, ["Manual", "Auto", "Plan"]);
4399 }
4400 }
src/permissions.rs
+132 -7
@@ -8,7 +8,7 @@
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).
11 +//! `/plan on|off` (TUI and ACP) or the panel Permissions dropdown.
12 //! 2. **Session grants** — "always allow this session", recorded when the user
13 //! picks that option in an approval prompt. For `run_command` the grant is
14 //! scoped to the command's first two whitespace-separated tokens (approving
@@ -24,8 +24,11 @@
24 //! before `allow`, so a deny always beats an allow matching the same call.
25 //! 4. **Per-tool override** — `[permissions.tools]` in `settings.toml`, e.g.
26 //! `run_command = "ask"`, `edit_file = "allow"`, `delete_file = "deny"`.
27 -//! 5. **Default mode** — `[permissions] default = "ask"|"allow"|"deny"` in
28 -//! `settings.toml`; `ask` on a fresh install.
27 +//! 5. **Session mode (panel dropdown), else the stored default** — the ACP
28 +//! panel's Permissions selector (`Manual`/`Auto`) picks a
29 +//! [`settings::PermissionMode`] for the session, stored beside `plan_mode`;
30 +//! with no session override, `[permissions] default = "ask"|"allow"|"deny"`
31 +//! in `settings.toml` applies (`ask` on a fresh install).
32 //!
33 //! Pattern matching (rules and session grants share it): `*` is a glob-style
34 //! wildcard (the `glob` tool's translator). A pattern ending in `*` matches
@@ -133,6 +136,9 @@ struct SessionPerms {
136 always_allow: HashSet<String>,
137 /// When set, every mutating tool is denied with a plan-mode message.
138 plan_mode: bool,
139 + /// Session override picked from the panel Permissions dropdown. `None`
140 + /// means "use the stored default" (see [`effective_mode`]).
141 + mode: Option<PermissionMode>,
142 }
143
144 fn sessions() -> &'static Mutex<HashMap<String, SessionPerms>> {
@@ -312,7 +318,11 @@ pub fn decision_for(session: &str, tool_name: &str, arguments: &str) -> Decision
318 return Decision::Allow;
319 }
320
315 - match settings::permission_mode_for(tool_name) {
321 + let mode = match settings::permission_mode_explicit(tool_name) {
322 + Some(explicit) => explicit,
323 + None => with_session(session, |s| s.mode).unwrap_or_else(settings::permission_default),
324 + };
325 + match mode {
326 PermissionMode::Allow => Decision::Allow,
327 PermissionMode::Ask => Decision::Ask,
328 PermissionMode::Deny => Decision::Deny(format!(
@@ -363,6 +373,25 @@ pub fn plan_mode(session: &str) -> bool {
373 with_session(session, |s| s.plan_mode)
374 }
375
376 +/// Set (or clear) the session's permission-mode override, picked from the
377 +/// panel Permissions dropdown. `None` reverts the session to the stored
378 +/// default (see [`effective_mode`]).
379 +pub fn set_session_mode(session: &str, mode: Option<PermissionMode>) {
380 + with_session(session, |s| s.mode = mode);
381 +}
382 +
383 +/// The session's permission-mode override, if one was picked from the panel
384 +/// dropdown. `None` means the session follows the stored default.
385 +pub fn session_mode(session: &str) -> Option<PermissionMode> {
386 + with_session(session, |s| s.mode)
387 +}
388 +
389 +/// The permission mode that actually governs this session: its dropdown
390 +/// override when set, else `settings::permission_default()`.
391 +pub fn effective_mode(session: &str) -> PermissionMode {
392 + session_mode(session).unwrap_or_else(settings::permission_default)
393 +}
394 +
395 /// Drop all recorded state for a session (fresh session, /clear, or session
396 /// teardown) so grants never outlive the conversation they were given in.
397 pub fn reset_session(session: &str) {
@@ -384,11 +413,16 @@ pub fn reset_all() {
413 map.clear();
414 }
415
387 -/// Status summary for `/permissions` and `/status`: the default mode, plan
388 -/// mode, the granular session grants, and the active rule lists.
416 +/// Status summary for `/permissions` and `/status`: the default mode, the
417 +/// session mode override (if any), plan mode, the granular session grants,
418 +/// and the active rule lists.
419 pub fn describe(session: &str) -> String {
420 let plan = if plan_mode(session) { "on" } else { "off" };
421 let default = settings::permission_default();
422 + let mode = match session_mode(session) {
423 + Some(mode) => format!("{mode} (session override)"),
424 + None => format!("{default} (stored default)"),
425 + };
426 let granted = with_session(session, |s| {
427 let mut names: Vec<&str> = s.always_allow.iter().map(String::as_str).collect();
428 names.sort_unstable();
@@ -408,7 +442,7 @@ pub fn describe(session: &str) -> String {
442 }
443 };
444 format!(
411 - "permissions: default={default} | plan mode: {plan} | session grants: {granted}\n\
445 + "permissions: mode={mode} | plan mode: {plan} | session grants: {granted}\n\
446 rules: deny: {} | allow: {}\n\
447 read-only tools always run; configure [permissions] in settings.toml",
448 render(&rules.deny),
@@ -781,6 +815,97 @@ mod tests {
815 reset_session(session);
816 }
817
818 + #[test]
819 + fn session_mode_overrides_default_mode() {
820 + let _guard = env_guard();
821 + let session = "t-mode";
822 + reset_session(session);
823 + assert_eq!(effective_mode(session), PermissionMode::Ask);
824 + assert_eq!(run_command_decision(session, "cargo test"), Decision::Ask);
825 +
826 + set_session_mode(session, Some(PermissionMode::Allow));
827 + assert_eq!(effective_mode(session), PermissionMode::Allow);
828 + assert_eq!(run_command_decision(session, "cargo test"), Decision::Allow);
829 +
830 + set_session_mode(session, None);
831 + assert_eq!(effective_mode(session), PermissionMode::Ask);
832 + assert_eq!(run_command_decision(session, "cargo test"), Decision::Ask);
833 + reset_session(session);
834 + }
835 +
836 + #[test]
837 + fn explicit_tool_override_beats_session_mode() {
838 + let _guard = env_guard();
839 + let session = "t-mode-override";
840 + reset_session(session);
841 + set_session_mode(session, Some(PermissionMode::Allow));
842 +
843 + let mut settings = settings::load();
844 + settings
845 + .permissions
846 + .tools
847 + .insert("delete_file".to_string(), PermissionMode::Deny);
848 + settings::store(&settings).unwrap();
849 +
850 + assert!(matches!(
851 + decision_for(session, "delete_file", r#"{"path":"src/a.rs"}"#),
852 + Decision::Deny(_)
853 + ));
854 + assert_eq!(
855 + run_command_decision(session, "cargo test"),
856 + Decision::Allow,
857 + "run_command has no explicit override, so the session mode applies"
858 + );
859 + reset_session(session);
860 + }
861 +
862 + #[test]
863 + fn deny_rule_beats_session_mode() {
864 + let _guard = env_guard();
865 + let session = "t-mode-deny-rule";
866 + reset_session(session);
867 + set_session_mode(session, Some(PermissionMode::Allow));
868 + store_rules(&[], &["run_command(git push*)"]);
869 +
870 + assert!(matches!(
871 + run_command_decision(session, "git push"),
872 + Decision::Deny(_)
873 + ));
874 + assert_eq!(
875 + run_command_decision(session, "git status"),
876 + Decision::Allow,
877 + "an unmatched command still follows the session mode"
878 + );
879 + reset_session(session);
880 + }
881 +
882 + #[test]
883 + fn plan_mode_outranks_session_mode() {
884 + let _guard = env_guard();
885 + let session = "t-mode-plan";
886 + reset_session(session);
887 + set_session_mode(session, Some(PermissionMode::Allow));
888 + set_plan_mode(session, true);
889 + assert!(matches!(
890 + run_command_decision(session, "cargo test"),
891 + Decision::Deny(_)
892 + ));
893 + set_plan_mode(session, false);
894 + reset_session(session);
895 + }
896 +
897 + #[test]
898 + fn reset_session_clears_session_mode() {
899 + let _guard = env_guard();
900 + let session = "t-mode-reset";
901 + reset_session(session);
902 + set_session_mode(session, Some(PermissionMode::Allow));
903 + assert_eq!(session_mode(session), Some(PermissionMode::Allow));
904 + reset_session(session);
905 + assert_eq!(session_mode(session), None);
906 + assert_eq!(effective_mode(session), PermissionMode::Ask);
907 + }
908 +
909 #[test]
910 fn file_tool_rules_match_on_path() {
911 let _guard = env_guard();
src/settings.rs
+12 -25
@@ -230,28 +230,13 @@ pub fn permission_rules() -> PermissionRules {
230 }
231
232 /// The tool's explicit `[permissions.tools]` override, or `None` when it has
233 -/// no entry. Unlike [`permission_mode_for`] this never falls back to the
234 -/// default or the env override, so callers can tell "the user set a mode for
235 -/// this exact tool" apart from "the tool inherits the default".
233 +/// no entry. This never falls back to the default or the env override, so
234 +/// callers can tell "the user set a mode for this exact tool" apart from "the
235 +/// tool inherits the default".
236 pub fn permission_mode_explicit(tool_name: &str) -> Option<PermissionMode> {
237 load().permissions.tools.get(tool_name).copied()
238 }
239
240 -/// The effective permission mode for one tool: its `[permissions.tools]`
241 -/// override when present, else the default (see [`permission_default`]).
242 -pub fn permission_mode_for(tool_name: &str) -> PermissionMode {
243 - let settings = load();
244 - if let Some(mode) = settings.permissions.tools.get(tool_name) {
245 - return *mode;
246 - }
247 - if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
248 - && let Some(mode) = PermissionMode::parse(&raw)
249 - {
250 - return mode;
251 - }
252 - settings.permissions.default
253 -}
254 -
240 #[cfg(test)]
241 mod tests {
242 use super::*;
@@ -295,7 +280,7 @@ mod tests {
280 // overrides.
281 unsafe { std::env::remove_var(PERMISSIONS_ENV) };
282 assert_eq!(permission_default(), PermissionMode::Ask);
298 - assert_eq!(permission_mode_for("run_command"), PermissionMode::Ask);
283 + assert_eq!(permission_mode_explicit("run_command"), None);
284
285 let mut settings = load();
286 settings.permissions.default = PermissionMode::Allow;
@@ -305,16 +290,18 @@ mod tests {
290 .insert("delete_file".to_string(), PermissionMode::Deny);
291 store(&settings).unwrap();
292 assert_eq!(permission_default(), PermissionMode::Allow);
308 - assert_eq!(permission_mode_for("run_command"), PermissionMode::Allow);
309 - assert_eq!(permission_mode_for("delete_file"), PermissionMode::Deny);
293 + assert_eq!(permission_mode_explicit("run_command"), None);
294 + assert_eq!(
295 + permission_mode_explicit("delete_file"),
296 + Some(PermissionMode::Deny)
297 + );
298
299 unsafe { std::env::set_var(PERMISSIONS_ENV, "deny") };
300 assert_eq!(permission_default(), PermissionMode::Deny);
313 - assert_eq!(permission_mode_for("run_command"), PermissionMode::Deny);
301 assert_eq!(
315 - permission_mode_for("delete_file"),
316 - PermissionMode::Deny,
317 - "per-tool override still wins"
302 + permission_mode_explicit("delete_file"),
303 + Some(PermissionMode::Deny),
304 + "per-tool override is unaffected by the env var"
305 );
306 unsafe { std::env::set_var(PERMISSIONS_ENV, "garbage") };
307 assert_eq!(
tests/acp_permissions.rs
+158
@@ -294,6 +294,10 @@ impl AgentUnderTest {
294 if message["method"] == "session/update" {
295 updates.push(message["params"]["update"].clone());
296 }
297 + assert!(
298 + message["method"] != "session/request_permission",
299 + "unexpected permission request while waiting for response {id}: {message}"
300 + );
301 if message["id"] == id && message.get("method").is_none() {
302 assert!(
303 message.get("error").is_none(),
@@ -555,6 +559,160 @@ fn successful_config_option_changes_are_rendered_as_system_status_cards() {
559 let _ = std::fs::remove_dir_all(&scratch);
560 }
561
562 +#[test]
563 +fn auto_permission_mode_runs_mutating_tools_without_asking() {
564 + let endpoint = start_fake_endpoint(vec![
565 + sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-auto"}"#),
566 + sse_text("done"),
567 + ]);
568 +
569 + let scratch = std::env::temp_dir().join(format!("sigit_acp_auto_perm_{}", std::process::id()));
570 + let config_dir = scratch.join("config");
571 + let cwd = scratch.join("cwd");
572 + std::fs::create_dir_all(&config_dir).unwrap();
573 + std::fs::create_dir_all(&cwd).unwrap();
574 +
575 + let mut agent = spawn_agent(endpoint.port, &config_dir);
576 +
577 + let id = agent.request(
578 + "initialize",
579 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
580 + );
581 + agent.wait_for_response(id);
582 +
583 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
584 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
585 + .as_str()
586 + .expect("session id")
587 + .to_string();
588 +
589 + let id = agent.request(
590 + "session/set_config_option",
591 + json!({
592 + "sessionId": session_id,
593 + "configId": "sigit-permission-mode",
594 + "value": "permission-mode-auto",
595 + }),
596 + );
597 + let (response, updates) = agent.wait_for_response_with_updates(id);
598 + let permissions = response["result"]["configOptions"]
599 + .as_array()
600 + .expect("config options")
601 + .iter()
602 + .find(|option| option["id"] == "sigit-permission-mode")
603 + .expect("permissions config option");
604 + assert_eq!(permissions["currentValue"], "permission-mode-auto");
605 + assert!(
606 + !updates
607 + .iter()
608 + .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
609 + "Permissions picker changes are passive UI state, not chat text: {updates:?}"
610 + );
611 +
612 + let prompt_id = agent.request(
613 + "session/prompt",
614 + json!({
615 + "sessionId": session_id,
616 + "prompt": [{"type": "text", "text": "run the command"}],
617 + }),
618 + );
619 + let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
620 + assert_eq!(response["result"]["stopReason"], "end_turn");
621 +
622 + let requests = endpoint.requests.lock().unwrap();
623 + assert_eq!(requests.len(), 2, "expected tool round and final round");
624 + let messages = requests[1]["messages"].as_array().expect("messages");
625 + let result = messages
626 + .iter()
627 + .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
628 + .expect("tool result for the automatic call");
629 + assert!(
630 + result["content"]
631 + .as_str()
632 + .unwrap_or_default()
633 + .contains("sigit-auto"),
634 + "automatic command output should reach the endpoint: {result}"
635 + );
636 +
637 + drop(agent);
638 + let _ = std::fs::remove_dir_all(&scratch);
639 +}
640 +
641 +#[test]
642 +fn plan_permission_mode_denies_mutating_tools_without_asking() {
643 + let endpoint = start_fake_endpoint(vec![
644 + sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-plan"}"#),
645 + sse_text("planned"),
646 + ]);
647 +
648 + let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_perm_{}", std::process::id()));
649 + let config_dir = scratch.join("config");
650 + let cwd = scratch.join("cwd");
651 + std::fs::create_dir_all(&config_dir).unwrap();
652 + std::fs::create_dir_all(&cwd).unwrap();
653 +
654 + let mut agent = spawn_agent(endpoint.port, &config_dir);
655 +
656 + let id = agent.request(
657 + "initialize",
658 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
659 + );
660 + agent.wait_for_response(id);
661 +
662 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
663 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
664 + .as_str()
665 + .expect("session id")
666 + .to_string();
667 +
668 + let id = agent.request(
669 + "session/set_config_option",
670 + json!({
671 + "sessionId": session_id,
672 + "configId": "sigit-permission-mode",
673 + "value": "permission-mode-plan",
674 + }),
675 + );
676 + let (response, _updates) = agent.wait_for_response_with_updates(id);
677 + let permissions = response["result"]["configOptions"]
678 + .as_array()
679 + .expect("config options")
680 + .iter()
681 + .find(|option| option["id"] == "sigit-permission-mode")
682 + .expect("permissions config option");
683 + assert_eq!(permissions["currentValue"], "permission-mode-plan");
684 +
685 + let prompt_id = agent.request(
686 + "session/prompt",
687 + json!({
688 + "sessionId": session_id,
689 + "prompt": [{"type": "text", "text": "run the command"}],
690 + }),
691 + );
692 + let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
693 + assert_eq!(response["result"]["stopReason"], "end_turn");
694 +
695 + let requests = endpoint.requests.lock().unwrap();
696 + assert_eq!(
697 + requests.len(),
698 + 2,
699 + "expected denied tool round and final round"
700 + );
701 + let messages = requests[1]["messages"].as_array().expect("messages");
702 + let result = messages
703 + .iter()
704 + .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
705 + .expect("tool result for the blocked call");
706 + let content = result["content"].as_str().unwrap_or_default();
707 + assert!(
708 + content.contains("Plan mode is active") && content.contains("was not executed"),
709 + "plan mode should return an instructive denial to the endpoint: {result}"
710 + );
711 +
712 + drop(agent);
713 + let _ = std::fs::remove_dir_all(&scratch);
714 +}
715 +
716 #[test]
717 fn a_slow_commands_progress_reaches_the_client_while_it_runs() {
718 let scratch = std::env::temp_dir().join(format!("sigit_acp_progress_{}", std::process::id()));