Let the user deny a tool for the rest of the session
The permission prompt now offers a fourth choice, "Deny for this session" (ACP `reject_always`). It records a denial scoped the same way an "allow for this session" grant is: denying `git push origin main` stops every `git push ...` for the thread, not every command. `decision_for` checks these denials before grants, and they go away with the rest of the session's permission state. The TUI prompt gets the same choice on `d`, and `/permissions` lists the denials next to the grants. Closes #199 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 9, 2026 at 00:08 UTC
b705a7141b2c7919c4e8269f5b618e63b3f9fda0
5 files changed
+208
-29
.agents/AGENTS.md
+6
-5
@@ -289,11 +289,12 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
289
- **`src/permissions.rs`** — tool permission policy. Every tool call passes through
290
`decision_for` before executing: read-only tools always run; mutating tools (and all
291
`mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
292
- mutating tools with a present-a-plan message), session "always allow" grants, per-tool
293
- overrides and the default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`,
294
- default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`, the ACP path sends
295
- `session/request_permission` (allow once / allow for session / deny) and the TUI pauses the
296
- inference task on a y/a/n prompt. On the ACP path the decision is taken *before* the call is
292
+ mutating tools with a present-a-plan message), session "always deny" and "always allow"
293
+ choices (deny checked first, both scoped by `session_grant_rule` and dropped with the session),
294
+ per-tool overrides and the default mode from `[permissions]` in `settings.toml`
295
+ (`allow`/`ask`/`deny`, default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`,
296
+ the ACP path sends `session/request_permission` (allow once / allow for session / deny / deny
297
+ for session) and the TUI pauses the inference task on a y/a/n/d prompt. On the ACP path the decision is taken *before* the call is
298
announced, because it sets the announced status: a call that will ask starts `pending`, the
299
permission request carries that call's own id, and an `in_progress` update follows approval.
300
That update also puts the card's title back, since the permission request overwrites it
src/chat.rs
+18
-6
@@ -652,6 +652,9 @@ mod tui {
652
Session,
653
/// skip the call; the model gets an explanatory tool result
654
Deny,
655
+ /// skip it and stop asking: deny calls like it for the rest of the
656
+ /// session
657
+ DenySession,
658
}
659
660
enum ModelLoadUpdate {
@@ -736,7 +739,7 @@ mod tui {
739
stream_buf: String,
740
inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
741
model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
739
- /// a tool call waiting on the user's y/a/n answer; the inference task is
742
+ /// a tool call waiting on the user's y/a/n/d answer; the inference task is
743
/// paused on the other end of the channel
744
pending_approval: Option<(String, oneshot::Sender<ApprovalChoice>)>,
745
thinking: bool,
@@ -2590,7 +2593,7 @@ mod tui {
2593
2594
if let Some((tool, _)) = &app.pending_approval {
2595
spans.push(Span::styled(
2593
- format!(" allow {tool}? [y]es · [a]lways · [n]o"),
2596
+ format!(" allow {tool}? [y]es · [a]lways · [n]o · [d]eny always"),
2597
Style::default().fg(Color::Yellow),
2598
));
2599
} else if app.thinking || app.switching_model || app.is_streaming() {
@@ -3272,7 +3275,7 @@ mod tui {
3275
3276
// Permission gate: read-only tools pass straight through; a
3277
// mutating tool consults policy and may pause on the user's
3275
- // y/a/n answer (delivered over a oneshot from the event loop).
3278
+ // y/a/n/d answer (delivered over a oneshot from the event loop).
3279
use crate::permissions::{self, Decision, TUI_SESSION};
3280
let output = match permissions::decision_for(TUI_SESSION, &tc.name, &tc.arguments) {
3281
Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
@@ -3305,6 +3308,11 @@ mod tui {
3308
log::info!(" ✗ {} denied by user", tc.name);
3309
permissions::user_denial(&tc.name)
3310
}
3311
+ Ok(ApprovalChoice::DenySession) => {
3312
+ log::info!(" ✗ {} denied by user for the session", tc.name);
3313
+ permissions::deny_for_session(TUI_SESSION, &tc.name, &tc.arguments);
3314
+ permissions::user_denial(&tc.name)
3315
+ }
3316
// The UI dropped the reply channel (Ctrl+C or
3317
// quit): the whole turn is over, not just this
3318
// call. Close out the round and stop instead of
@@ -3596,7 +3604,7 @@ mod tui {
3604
app.messages.push(ChatMessage::system(format!("error: {msg}")));
3605
}
3606
Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
3599
- // The y/a/n prompt lives on the Session tab; make
3607
+ // The y/a/n/d prompt lives on the Session tab; make
3608
// sure the user can see what they're answering.
3609
app.active_tab = Tab::Session;
3610
let call = if args.is_empty() {
@@ -3605,7 +3613,7 @@ mod tui {
3613
format!("{tool}({args})")
3614
};
3615
app.messages.push(ChatMessage::system(format!(
3608
- "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
3616
+ "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o · [d]eny this session"
3617
)));
3618
app.pending_approval = Some((tool, reply));
3619
}
@@ -3709,7 +3717,7 @@ mod tui {
3717
continue;
3718
}
3719
3712
- // pending tool approval — y/a/n answer the prompt; the
3720
+ // pending tool approval — y/a/n/d answer the prompt; the
3721
// inference task is paused on the reply channel. Checked
3722
// before the busy gate because the app *is* busy here.
3723
if app.pending_approval.is_some() {
@@ -3736,6 +3744,9 @@ mod tui {
3744
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
3745
Some(ApprovalChoice::Deny)
3746
}
3747
+ KeyCode::Char('d') | KeyCode::Char('D') => {
3748
+ Some(ApprovalChoice::DenySession)
3749
+ }
3750
_ => None,
3751
}
3752
};
@@ -3746,6 +3757,7 @@ mod tui {
3757
ApprovalChoice::Once => "allowed once",
3758
ApprovalChoice::Session => "allowed for this session",
3759
ApprovalChoice::Deny => "denied",
3760
+ ApprovalChoice::DenySession => "denied for this session",
3761
};
3762
app.messages.push(ChatMessage::system(format!(
3763
"{tool}: {verdict}"
src/main.rs
+16
-2
@@ -3900,8 +3900,9 @@ impl SiGitAgent {
3900
/// Ask the ACP client for permission to run one tool call. The request
3901
/// names the call by `tool_call_id`, the id it was announced under, so the
3902
/// client updates that one card instead of drawing a second. Presents
3903
- /// allow-once / allow-for-session / deny; an "always allow" choice is
3904
- /// recorded via [`permissions::grant_for_session`]. Only safe to call from
3903
+ /// allow-once / allow-for-session / deny / deny-for-session; an "always"
3904
+ /// choice is recorded via [`permissions::grant_for_session`] or
3905
+ /// [`permissions::deny_for_session`]. Only safe to call from
3906
/// a spawned task (see the handler registration in `run_acp_server`): the
3907
/// dispatch loop must be free to route the client's answer back to us.
3908
///
@@ -3951,6 +3952,11 @@ impl SiGitAgent {
3952
PermissionOptionKind::AllowAlways,
3953
),
3954
PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
3955
+ PermissionOption::new(
3956
+ "reject_session",
3957
+ "Deny for this session",
3958
+ PermissionOptionKind::RejectAlways,
3959
+ ),
3960
],
3961
);
3962
@@ -3967,6 +3973,14 @@ impl SiGitAgent {
3973
);
3974
PermissionVerdict::Approved
3975
}
3976
+ "reject_session" => {
3977
+ permissions::deny_for_session(
3978
+ &session_id.to_string(),
3979
+ tool_name,
3980
+ arguments,
3981
+ );
3982
+ PermissionVerdict::Denied(permissions::user_denial(tool_name))
3983
+ }
3984
_ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
3985
}
3986
}
src/permissions.rs
+71
-15
@@ -13,7 +13,9 @@
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
15
//! `git push origin main` records `run_command(git push)`); other tools
16
-//! record the bare tool name.
16
+//! record the bare tool name. "Always deny this session" records a rule
17
+//! scoped the same way, and a session denial is checked before a session
18
+//! grant.
19
//! 3. **Rule lists** — `[permissions.rules]` in `settings.toml`: ordered
20
//! `deny` and `allow` lists of rules shaped `tool_name` or
21
//! `tool_name(argument_pattern)`, e.g. `run_command(git status)`,
@@ -134,6 +136,8 @@ pub fn classify(tool_name: &str) -> ToolRisk {
136
struct SessionPerms {
137
/// Tools the user chose "always allow this session" for.
138
always_allow: HashSet<String>,
139
+ /// Tools the user chose "always deny this session" for.
140
+ always_deny: HashSet<String>,
141
/// When set, every mutating tool is denied with a plan-mode message.
142
plan_mode: bool,
143
/// Session override picked from the panel Permissions dropdown. `None`
@@ -294,15 +298,26 @@ pub fn decision_for(session: &str, tool_name: &str, arguments: &str) -> Decision
298
return Decision::Allow;
299
}
300
297
- let (plan_mode, granted) = with_session(session, |s| {
301
+ let (plan_mode, denied, granted) = with_session(session, |s| {
302
+ let matches = |set: &HashSet<String>| {
303
+ set.iter()
304
+ .find(|rule| rule_matches(rule, tool_name, argument.as_deref()))
305
+ .cloned()
306
+ };
307
(
308
s.plan_mode,
300
- s.always_allow
301
- .iter()
302
- .any(|grant| rule_matches(grant, tool_name, argument.as_deref())),
309
+ matches(&s.always_deny),
310
+ matches(&s.always_allow).is_some(),
311
)
312
});
313
314
+ if let Some(rule) = denied {
315
+ return Decision::Deny(format!(
316
+ "The user chose to always deny `{rule}` in this session, so `{tool_name}` \
317
+ was not executed. Do not retry it; continue with an approach that does \
318
+ not need this tool, or ask the user how to proceed."
319
+ ));
320
+ }
321
if plan_mode {
322
return Decision::Deny(plan_mode_denial(tool_name));
323
}
@@ -345,6 +360,16 @@ pub fn grant_for_session(session: &str, tool_name: &str, arguments: &str) {
360
});
361
}
362
363
+/// Record an "always deny this session" choice for a tool call, scoped the
364
+/// same way as [`grant_for_session`]: denying `git push origin main` stops
365
+/// every `git push …` for the rest of the session, not every command.
366
+pub fn deny_for_session(session: &str, tool_name: &str, arguments: &str) {
367
+ let rule = session_grant_rule(tool_name, arguments);
368
+ with_session(session, |s| {
369
+ s.always_deny.insert(rule);
370
+ });
371
+}
372
+
373
/// The rule string recorded for one approved call (see [`grant_for_session`]).
374
/// Falls back to the bare tool name when the command is absent or empty, which
375
/// grants the whole tool — exactly what the pre-granular behavior was.
@@ -411,16 +436,18 @@ pub fn describe(session: &str) -> String {
436
Some(mode) => format!("{mode} (session override)"),
437
None => format!("{default} (stored default)"),
438
};
414
- let granted = with_session(session, |s| {
415
- let mut names: Vec<&str> = s.always_allow.iter().map(String::as_str).collect();
416
- names.sort_unstable();
417
- names.join(", ")
439
+ let (granted, denied) = with_session(session, |s| {
440
+ let render = |set: &HashSet<String>| {
441
+ let mut names: Vec<&str> = set.iter().map(String::as_str).collect();
442
+ names.sort_unstable();
443
+ if names.is_empty() {
444
+ "none".to_string()
445
+ } else {
446
+ names.join(", ")
447
+ }
448
+ };
449
+ (render(&s.always_allow), render(&s.always_deny))
450
});
419
- let granted = if granted.is_empty() {
420
- "none".to_string()
421
- } else {
422
- granted
423
- };
451
let rules = settings::permission_rules();
452
let render = |list: &[String]| {
453
if list.is_empty() {
@@ -430,7 +457,8 @@ pub fn describe(session: &str) -> String {
457
}
458
};
459
format!(
433
- "permissions: mode={mode} | plan mode: {plan} | session grants: {granted}\n\
460
+ "permissions: mode={mode} | plan mode: {plan} | session grants: {granted} \
461
+ | session denials: {denied}\n\
462
rules: deny: {} | allow: {}\n\
463
read-only tools always run; configure [permissions] in settings.toml",
464
render(&rules.deny),
@@ -662,6 +690,34 @@ mod tests {
690
assert_ne!(decision_for(session, "edit_file", args), Decision::Allow);
691
}
692
693
+ #[test]
694
+ fn session_denial_stops_asking_and_outranks_a_grant() {
695
+ let _guard = env_guard();
696
+ let session = "t-deny";
697
+ reset_session(session);
698
+ let push = r#"{"command":"git push origin main"}"#;
699
+ deny_for_session(session, "run_command", push);
700
+ // Scoped like a grant: the whole `git push …` family, nothing else.
701
+ assert!(matches!(
702
+ decision_for(session, "run_command", r#"{"command":"git push --tags"}"#),
703
+ Decision::Deny(_)
704
+ ));
705
+ assert_eq!(
706
+ decision_for(session, "run_command", r#"{"command":"git status"}"#),
707
+ Decision::Ask
708
+ );
709
+ // A denial is checked before a grant covering the same call.
710
+ grant_for_session(session, "run_command", r#"{"command":"git push"}"#);
711
+ assert!(matches!(
712
+ decision_for(session, "run_command", push),
713
+ Decision::Deny(_)
714
+ ));
715
+ assert!(describe(session).contains("session denials: run_command(git push)"));
716
+ // Dropped with the rest of the session's state.
717
+ reset_session(session);
718
+ assert_eq!(decision_for(session, "run_command", push), Decision::Ask);
719
+ }
720
+
721
#[test]
722
fn approval_preview_shows_short_arguments_in_full() {
723
let args = r#"{"command":"cargo test"}"#;
tests/acp_permissions.rs
+97
-1
@@ -384,7 +384,22 @@ fn permission_round_trip_cancel_then_allow() {
384
.iter()
385
.map(|option| option["optionId"].as_str().unwrap_or_default())
386
.collect();
387
- assert_eq!(option_ids, ["allow_once", "allow_session", "reject_once"]);
387
+ assert_eq!(
388
+ option_ids,
389
+ [
390
+ "allow_once",
391
+ "allow_session",
392
+ "reject_once",
393
+ "reject_session"
394
+ ]
395
+ );
396
+ let reject_always = params["options"]
397
+ .as_array()
398
+ .unwrap()
399
+ .iter()
400
+ .find(|option| option["optionId"] == "reject_session")
401
+ .unwrap();
402
+ assert_eq!(reject_always["kind"], "reject_always");
403
404
agent.respond(
405
permission["id"].clone(),
@@ -627,6 +642,87 @@ fn a_call_denied_at_the_prompt_ends_failed() {
642
let _ = std::fs::remove_dir_all(&scratch);
643
}
644
645
+/// "Deny for this session" answers the call it was picked for and every later
646
+/// call in the same family without asking again (issue #199).
647
+#[test]
648
+fn a_call_denied_for_the_session_is_not_asked_about_again() {
649
+ let endpoint = start_fake_endpoint(vec![
650
+ sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-never"}"#),
651
+ sse_tool_call(
652
+ "call_2",
653
+ "run_command",
654
+ r#"{"command":"echo sigit-never again"}"#,
655
+ ),
656
+ sse_text("ok, I will stop"),
657
+ ]);
658
+
659
+ let scratch =
660
+ std::env::temp_dir().join(format!("sigit_acp_perm_deny_all_{}", std::process::id()));
661
+ let config_dir = scratch.join("config");
662
+ let cwd = scratch.join("cwd");
663
+ std::fs::create_dir_all(&config_dir).unwrap();
664
+ std::fs::create_dir_all(&cwd).unwrap();
665
+
666
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
667
+
668
+ let id = agent.request(
669
+ "initialize",
670
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
671
+ );
672
+ agent.wait_for_response(id);
673
+
674
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
675
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
676
+ .as_str()
677
+ .expect("session id")
678
+ .to_string();
679
+
680
+ let prompt_id = agent.request(
681
+ "session/prompt",
682
+ json!({
683
+ "sessionId": session_id,
684
+ "prompt": [{"type": "text", "text": "run the command"}],
685
+ }),
686
+ );
687
+
688
+ let permission = agent.wait_for_agent_request("session/request_permission");
689
+ agent.respond(
690
+ permission["id"].clone(),
691
+ json!({"outcome": {"outcome": "selected", "optionId": "reject_session"}}),
692
+ );
693
+
694
+ // The second call is denied by the recorded choice: no second request,
695
+ // which `wait_for_response_with_updates` would fail on.
696
+ let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
697
+ assert_eq!(response["result"]["stopReason"], "end_turn");
698
+ let second = updates
699
+ .iter()
700
+ .rev()
701
+ .find(|update| {
702
+ update["sessionUpdate"] == "tool_call_update" && update["toolCallId"] == "call_2"
703
+ })
704
+ .expect("the second call closes");
705
+ assert_eq!(second["status"], "failed", "{second}");
706
+
707
+ let requests = endpoint.requests.lock().unwrap();
708
+ let result = requests[2]["messages"]
709
+ .as_array()
710
+ .expect("messages")
711
+ .iter()
712
+ .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_2")
713
+ .expect("tool result for the second call");
714
+ assert!(
715
+ result["content"]
716
+ .as_str()
717
+ .unwrap_or_default()
718
+ .contains("always deny"),
719
+ "the model is told why: {result}"
720
+ );
721
+
722
+ drop(agent);
723
+ let _ = std::fs::remove_dir_all(&scratch);
724
+}
725
+
726
#[test]
727
fn successful_config_option_changes_are_rendered_as_system_status_cards() {
728
let endpoint = start_fake_endpoint(vec![]);