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(¤t);
2300
+ let config_options = build_config_options(¤t, &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(¤t, &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(¤t);
2383
+ let config_options = build_config_options(¤t, &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(¤t);
2413
+ let config_options = build_config_options(¤t, &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(¤t, &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(¤t, &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(¤t)
3295
+ build_config_options(¤t, &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(¤t)).unwrap();
4349
+ let options = serde_json::to_value(build_config_options(¤t, "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()));