Skip to content

Commit 955cb68

Browse files
authored
Merge pull request #62 from efecnc/feat/edit-approval-gate
Add approval gate for file mutations
2 parents 2f22e59 + ae22461 commit 955cb68

7 files changed

Lines changed: 608 additions & 19 deletions

File tree

src/agent/mod.rs

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,28 @@ fn shell_policy_mode_for_session(
413413
}
414414
}
415415

416+
fn edit_policy_mode_for_session(
417+
policy: &ResolvedShellPolicy,
418+
unattended_session: bool,
419+
) -> ShellPolicyMode {
420+
if unattended_session {
421+
policy.unattended_edit_mode
422+
} else {
423+
policy.interactive_edit_mode
424+
}
425+
}
426+
427+
/// Reason shown when the edit policy blocks a mutation. Unattended sessions
428+
/// default to Deny independently of plan mode, so the message distinguishes the
429+
/// two cases rather than hardcoding "plan mode active" everywhere (PR #62 review #2).
430+
fn edit_policy_block_reason(unattended_session: bool) -> &'static str {
431+
if unattended_session {
432+
"File edit blocked by policy: unattended edit mode is active."
433+
} else {
434+
"File edit blocked by policy: plan mode active — finalize or apply the plan first."
435+
}
436+
}
437+
416438
fn command_preview(command: &str) -> String {
417439
const MAX_PREVIEW: usize = 160;
418440
if command.len() <= MAX_PREVIEW {
@@ -433,6 +455,11 @@ fn is_code_exec_tool(tool_name: &str) -> bool {
433455
)
434456
}
435457

458+
/// Tools that mutate a workspace file and therefore need the edit policy gate.
459+
fn is_file_mutate_tool(tool_name: &str) -> bool {
460+
matches!(tool_name, "write_file" | "edit_file")
461+
}
462+
436463
/// Code-exec tools that run *arbitrary* code (Python source / session cells) where the
437464
/// destructive-shell-pattern heuristic does not meaningfully apply, so any such call is
438465
/// treated as approval-worthy in ask/deny mode.
@@ -478,6 +505,26 @@ mod code_exec_gate_tests {
478505
assert!(!is_code_exec_tool("web_search"));
479506
}
480507

508+
#[test]
509+
fn file_mutate_category_covers_write_and_edit_only() {
510+
assert!(is_file_mutate_tool("write_file"));
511+
assert!(is_file_mutate_tool("edit_file"));
512+
assert!(!is_file_mutate_tool("read_file"));
513+
assert!(!is_file_mutate_tool("exec"));
514+
assert!(!is_file_mutate_tool("list_dir"));
515+
assert!(!is_file_mutate_tool("search_text"));
516+
}
517+
518+
#[test]
519+
fn edit_block_reason_distinguishes_unattended_and_plan_mode() {
520+
// PR #62 review #2: the Deny message must match why the edit was blocked.
521+
let unattended = edit_policy_block_reason(true);
522+
assert!(unattended.contains("unattended"), "{unattended}");
523+
assert!(!unattended.contains("plan mode"), "{unattended}");
524+
let plan = edit_policy_block_reason(false);
525+
assert!(plan.contains("plan mode"), "{plan}");
526+
}
527+
481528
#[test]
482529
fn extracts_command_for_exec_and_code_for_execution_tools() {
483530
assert_eq!(
@@ -754,6 +801,7 @@ async fn execute_tool_call_with_activity(
754801

755802
with_tool_exec_and_progress_scope(tool_exec_ctx, progress_emitter, async move {
756803
let mut args = args;
804+
let mut approved_mutation_preview = None;
757805
let activity_handle = tool_execution_activity
758806
.as_ref()
759807
.map(|a| a.start(chat_id.as_str(), tool_name.as_str()));
@@ -809,11 +857,14 @@ async fn execute_tool_call_with_activity(
809857
"timeout_secs": 1800,
810858
"allow_empty": false
811859
});
860+
// System-initiated approval prompt: bypass the sub-agent tool
861+
// allowlist so a restricted sub-agent (e.g. allowlist={exec})
862+
// can still surface the approval dialog.
812863
let ask_result = tools
813864
.execute_tool_scoped(
814865
"ask_user",
815866
ask_payload,
816-
allow.as_deref(),
867+
None,
817868
is_subagent,
818869
)
819870
.await;
@@ -892,18 +943,96 @@ async fn execute_tool_call_with_activity(
892943
}
893944
}
894945

946+
// Run this after steering hooks: a hook may rewrite the arguments, and the
947+
// user must see the exact mutation that will be executed.
948+
if is_file_mutate_tool(&tool_name) {
949+
match edit_policy_mode_for_session(&runtime.shell_policy, runtime.unattended_session) {
950+
ShellPolicyMode::Allow => {}
951+
ShellPolicyMode::Deny => {
952+
return ToolExecutionFinished::Completed(Err(
953+
edit_policy_block_reason(runtime.unattended_session).to_string(),
954+
));
955+
}
956+
ShellPolicyMode::Ask => {
957+
let preview = match tools
958+
.preview_mutation_scoped(&tool_name, &args, allow.as_deref(), is_subagent)
959+
.await
960+
{
961+
Ok(preview) => preview,
962+
// Invalid/no-op edits retain their ordinary tool result; there is no
963+
// mutation to approve in that case.
964+
Err(error) => {
965+
return ToolExecutionFinished::Completed(Err(format!(
966+
"Could not prepare edit approval: {error}"
967+
)));
968+
}
969+
};
970+
if let Some(preview) = preview {
971+
let ask_payload = serde_json::json!({
972+
"prompt": format!(
973+
"Approve edit to `{}`? Review the attached diff, then reply with approve or deny.",
974+
preview.path
975+
),
976+
"choices": ["approve", "deny"],
977+
"timeout_secs": 1800,
978+
"allow_empty": false,
979+
"metadata": {
980+
"edit_diff": {
981+
"file": preview.path,
982+
"diff": preview.diff,
983+
"truncated": preview.diff_truncated,
984+
}
985+
}
986+
});
987+
// System-initiated approval prompt: bypass the sub-agent tool
988+
// allowlist so a restricted sub-agent (e.g. allowlist={write_file})
989+
// can still surface the edit approval dialog.
990+
let reply = match tools
991+
.execute_tool_scoped(
992+
"ask_user",
993+
ask_payload,
994+
None,
995+
is_subagent,
996+
)
997+
.await
998+
{
999+
Ok(reply) => reply,
1000+
Err(error) => {
1001+
return ToolExecutionFinished::Completed(Err(format!(
1002+
"Edit policy approval failed: {error}"
1003+
)));
1004+
}
1005+
};
1006+
if !shell_approval_reply_is_grant(&reply) {
1007+
return ToolExecutionFinished::Completed(Err(
1008+
"Edit not approved by user; mutation skipped.".to_string(),
1009+
));
1010+
}
1011+
approved_mutation_preview = Some(preview);
1012+
}
1013+
}
1014+
}
1015+
}
1016+
8951017
let args_for_post = args.clone();
8961018
let completed = match cancel_owned.as_ref() {
8971019
None => Some(
8981020
tools
899-
.execute_tool_scoped(&tool_name, args, allow.as_deref(), is_subagent)
1021+
.execute_tool_scoped_with_approved_mutation(
1022+
&tool_name,
1023+
args,
1024+
approved_mutation_preview.as_ref(),
1025+
allow.as_deref(),
1026+
is_subagent,
1027+
)
9001028
.await,
9011029
),
9021030
Some(token) => {
9031031
tokio::select! {
904-
res = tools.execute_tool_scoped(
1032+
res = tools.execute_tool_scoped_with_approved_mutation(
9051033
&tool_name,
9061034
args,
1035+
approved_mutation_preview.as_ref(),
9071036
allow.as_deref(),
9081037
is_subagent,
9091038
) => Some(res),
@@ -4257,6 +4386,8 @@ mod tests {
42574386
shell_policy: Arc::new(crate::config::ResolvedShellPolicy {
42584387
interactive_mode: crate::config::ShellPolicyMode::Ask,
42594388
unattended_mode: crate::config::ShellPolicyMode::Deny,
4389+
interactive_edit_mode: crate::config::ShellPolicyMode::Ask,
4390+
unattended_edit_mode: crate::config::ShellPolicyMode::Deny,
42604391
approval_patterns: Vec::new(),
42614392
}),
42624393
hook_tool_ctx: None,
@@ -4344,6 +4475,8 @@ mod tests {
43444475
shell_policy: crate::config::ResolvedShellPolicy {
43454476
interactive_mode: crate::config::ShellPolicyMode::Ask,
43464477
unattended_mode: crate::config::ShellPolicyMode::Deny,
4478+
interactive_edit_mode: crate::config::ShellPolicyMode::Ask,
4479+
unattended_edit_mode: crate::config::ShellPolicyMode::Deny,
43474480
approval_patterns: Vec::new(),
43484481
},
43494482
hook_tool_ctx: None,
@@ -4402,6 +4535,8 @@ mod tests {
44024535
shell_policy: crate::config::ResolvedShellPolicy {
44034536
interactive_mode: crate::config::ShellPolicyMode::Ask,
44044537
unattended_mode: crate::config::ShellPolicyMode::Deny,
4538+
interactive_edit_mode: crate::config::ShellPolicyMode::Ask,
4539+
unattended_edit_mode: crate::config::ShellPolicyMode::Deny,
44054540
approval_patterns: Vec::new(),
44064541
},
44074542
hook_tool_ctx: None,

src/agent/subagent.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,8 @@ mod tests {
13251325
shell_policy: Arc::new(ResolvedShellPolicy {
13261326
interactive_mode: crate::config::ShellPolicyMode::Ask,
13271327
unattended_mode: crate::config::ShellPolicyMode::Deny,
1328+
interactive_edit_mode: crate::config::ShellPolicyMode::Ask,
1329+
unattended_edit_mode: crate::config::ShellPolicyMode::Deny,
13281330
approval_patterns: Vec::new(),
13291331
}),
13301332
hook_tool_ctx: None,

src/config.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ pub struct ShellPolicyConfig {
183183
pub unattended_default: Option<String>,
184184
/// Extra lowercase substrings that should require approval in `ask` mode.
185185
pub interactive_requires_approval_for: Option<Vec<String>>,
186+
/// File edit mode: `ask`, `deny`, or `allow` (default `ask`).
187+
pub edit_mode: Option<String>,
188+
/// File edit mode for unattended/autonomous sessions (default `deny`).
189+
pub edit_unattended_default: Option<String>,
186190
}
187191

188192
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -196,6 +200,9 @@ pub enum ShellPolicyMode {
196200
pub struct ResolvedShellPolicy {
197201
pub interactive_mode: ShellPolicyMode,
198202
pub unattended_mode: ShellPolicyMode,
203+
/// File mutation policy, intentionally independent from shell execution.
204+
pub interactive_edit_mode: ShellPolicyMode,
205+
pub unattended_edit_mode: ShellPolicyMode,
199206
pub approval_patterns: Vec<String>,
200207
}
201208

@@ -753,6 +760,14 @@ impl AppConfig {
753760
shell_cfg.and_then(|s| s.unattended_default.as_deref()),
754761
ShellPolicyMode::Deny,
755762
);
763+
let interactive_edit_mode = parse_shell_policy_mode(
764+
shell_cfg.and_then(|s| s.edit_mode.as_deref()),
765+
ShellPolicyMode::Ask,
766+
);
767+
let unattended_edit_mode = parse_shell_policy_mode(
768+
shell_cfg.and_then(|s| s.edit_unattended_default.as_deref()),
769+
ShellPolicyMode::Deny,
770+
);
756771
let mut approval_patterns = vec![
757772
"rm -rf".to_string(),
758773
"rm -fr".to_string(),
@@ -778,6 +793,8 @@ impl AppConfig {
778793
ResolvedShellPolicy {
779794
interactive_mode,
780795
unattended_mode,
796+
interactive_edit_mode,
797+
unattended_edit_mode,
781798
approval_patterns,
782799
}
783800
}

src/tools.rs

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::traits::Tool;
1+
use crate::traits::{MutationPreview, Tool};
22
use serde_json::Value;
33
use std::collections::{HashMap, HashSet};
44
use std::sync::{Arc, RwLock};
@@ -116,6 +116,63 @@ impl ToolRegistry {
116116
}
117117
}
118118

119+
/// Ask a tool for its file-mutation preview without executing it.
120+
pub async fn preview_mutation_scoped(
121+
&self,
122+
name: &str,
123+
args: &Value,
124+
allowlist: Option<&HashSet<String>>,
125+
is_subagent: bool,
126+
) -> Result<Option<MutationPreview>, String> {
127+
self.authorize_scoped(name, allowlist, is_subagent)?;
128+
match self.get_tool(name) {
129+
Some(tool) => tool.preview_mutation(args).await,
130+
None => Err(format!("Tool '{}' not found", name)),
131+
}
132+
}
133+
134+
/// Execute with a preview previously approved by the user.
135+
pub async fn execute_tool_scoped_with_approved_mutation(
136+
&self,
137+
name: &str,
138+
args: Value,
139+
approved_preview: Option<&MutationPreview>,
140+
allowlist: Option<&HashSet<String>>,
141+
is_subagent: bool,
142+
) -> Result<String, String> {
143+
self.authorize_scoped(name, allowlist, is_subagent)?;
144+
match self.get_tool(name) {
145+
Some(tool) => {
146+
tool.execute_with_approved_mutation(args, approved_preview)
147+
.await
148+
}
149+
None => Err(format!("Tool '{}' not found", name)),
150+
}
151+
}
152+
153+
fn authorize_scoped(
154+
&self,
155+
name: &str,
156+
allowlist: Option<&HashSet<String>>,
157+
is_subagent: bool,
158+
) -> Result<(), String> {
159+
if is_subagent && Self::is_subagent_restricted_tool(name) {
160+
return Err(format!(
161+
"Tool '{}' is not available inside a sub-agent run",
162+
name
163+
));
164+
}
165+
if let Some(set) = allowlist {
166+
if !set.is_empty() && !set.contains(name) {
167+
return Err(format!(
168+
"Tool '{}' is not allowed for this sub-agent (allowlist)",
169+
name
170+
));
171+
}
172+
}
173+
Ok(())
174+
}
175+
119176
/// Tools that must not run inside a sub-agent loop (prevents unbounded recursion).
120177
pub fn is_subagent_restricted_tool(name: &str) -> bool {
121178
matches!(name, "subagent_spawn" | "subagent_plan_execute")
@@ -182,20 +239,7 @@ impl ToolRegistry {
182239
allowlist: Option<&HashSet<String>>,
183240
is_subagent: bool,
184241
) -> Result<String, String> {
185-
if is_subagent && Self::is_subagent_restricted_tool(name) {
186-
return Err(format!(
187-
"Tool '{}' is not available inside a sub-agent run",
188-
name
189-
));
190-
}
191-
if let Some(set) = allowlist {
192-
if !set.is_empty() && !set.contains(name) {
193-
return Err(format!(
194-
"Tool '{}' is not allowed for this sub-agent (allowlist)",
195-
name
196-
));
197-
}
198-
}
242+
self.authorize_scoped(name, allowlist, is_subagent)?;
199243
self.execute_tool(name, args).await
200244
}
201245
}
@@ -324,6 +368,31 @@ mod scoped_tools_tests {
324368
assert!(err.contains("not available"));
325369
}
326370

371+
#[tokio::test]
372+
async fn system_initiated_ask_user_bypasses_subagent_allowlist() {
373+
// Regression for PR #62 review feedback: system-initiated approval prompts
374+
// (shell + edit policy gates) call ask_user with allowlist=None so a
375+
// restricted sub-agent allowlist (e.g. {write_file} / {exec}) can still
376+
// surface the approval dialog.
377+
let mut r = ToolRegistry::new();
378+
r.register(Box::new(NamedTool { n: "ask_user" }));
379+
r.register(Box::new(NamedTool { n: "write_file" }));
380+
381+
// A sub-agent allowlisted to {write_file} (no ask_user) blocks a direct
382+
// ask_user call — this is the failure the None-allowlist bypass avoids.
383+
let allow: HashSet<String> = ["write_file".to_string()].into_iter().collect();
384+
let err = r
385+
.execute_tool_scoped("ask_user", Value::Null, Some(&allow), true)
386+
.await
387+
.unwrap_err();
388+
assert!(err.contains("not allowed"), "{err}");
389+
390+
// With allowlist=None the system-initiated ask_user succeeds.
391+
r.execute_tool_scoped("ask_user", Value::Null, None, true)
392+
.await
393+
.expect("system-initiated ask_user must bypass the allowlist");
394+
}
395+
327396
#[test]
328397
fn list_scoped_filters_allowlist_and_nested_tools() {
329398
let mut r = ToolRegistry::new();

0 commit comments

Comments
 (0)