Skip to content

Commit ae22461

Browse files
committed
fix(agent): address edit-approval-gate review feedback
- Bypass the sub-agent tool allowlist for system-initiated ask_user prompts in both the edit and shell policy gates. A restricted sub-agent allowlist (e.g. {write_file} / {exec}) that omits ask_user previously caused the approval prompt itself to fail. - Make the edit-policy Deny message context-aware (unattended vs plan mode) instead of hardcoding 'plan mode active', which is misleading for the unattended default-deny. - Skip the approval prompt for no-op edits (old_text == new_text) and no-op writes (content identical to the existing file). - Add regression tests covering each fix. Addresses #62 review comments.
1 parent fa3352b commit ae22461

3 files changed

Lines changed: 120 additions & 5 deletions

File tree

src/agent/mod.rs

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,17 @@ fn edit_policy_mode_for_session(
424424
}
425425
}
426426

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+
427438
fn command_preview(command: &str) -> String {
428439
const MAX_PREVIEW: usize = 160;
429440
if command.len() <= MAX_PREVIEW {
@@ -494,6 +505,26 @@ mod code_exec_gate_tests {
494505
assert!(!is_code_exec_tool("web_search"));
495506
}
496507

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+
497528
#[test]
498529
fn extracts_command_for_exec_and_code_for_execution_tools() {
499530
assert_eq!(
@@ -826,11 +857,14 @@ async fn execute_tool_call_with_activity(
826857
"timeout_secs": 1800,
827858
"allow_empty": false
828859
});
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.
829863
let ask_result = tools
830864
.execute_tool_scoped(
831865
"ask_user",
832866
ask_payload,
833-
allow.as_deref(),
867+
None,
834868
is_subagent,
835869
)
836870
.await;
@@ -916,8 +950,7 @@ async fn execute_tool_call_with_activity(
916950
ShellPolicyMode::Allow => {}
917951
ShellPolicyMode::Deny => {
918952
return ToolExecutionFinished::Completed(Err(
919-
"File edit blocked by policy: plan mode active — finalize or apply the plan first."
920-
.to_string(),
953+
edit_policy_block_reason(runtime.unattended_session).to_string(),
921954
));
922955
}
923956
ShellPolicyMode::Ask => {
@@ -951,11 +984,14 @@ async fn execute_tool_call_with_activity(
951984
}
952985
}
953986
});
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.
954990
let reply = match tools
955991
.execute_tool_scoped(
956992
"ask_user",
957993
ask_payload,
958-
allow.as_deref(),
994+
None,
959995
is_subagent,
960996
)
961997
.await

src/tools.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,31 @@ mod scoped_tools_tests {
368368
assert!(err.contains("not available"));
369369
}
370370

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+
371396
#[test]
372397
fn list_scoped_filters_allowlist_and_nested_tools() {
373398
let mut r = ToolRegistry::new();

src/tools/builtin.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,14 @@ impl Tool for WriteFileTool {
361361
.ok_or("Missing 'content' argument")?;
362362
let actual_path = resolve_path(path_str, &self.workspace_dir, self.restrict_to_workspace)?;
363363
let before = match fs::read_to_string(&actual_path) {
364-
Ok(content) => Some(content),
364+
Ok(current_content) => {
365+
// No-op write: the file already holds the exact content. Skip the
366+
// approval prompt — there is no mutation to review.
367+
if current_content == content {
368+
return Ok(None);
369+
}
370+
Some(current_content)
371+
}
365372
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
366373
Err(error) => return Err(format!("Could not preview write target: {error}")),
367374
};
@@ -515,6 +522,12 @@ impl Tool for EditFileTool {
515522
.get("replace_all")
516523
.and_then(|v| v.as_bool())
517524
.unwrap_or(false);
525+
// No-op edit: identical old/new text produces an empty diff. Skip the
526+
// approval prompt — there is no mutation to review. (execute() separately
527+
// returns an "identical" error so the model learns the edit was a no-op.)
528+
if old_text == new_text {
529+
return Ok(None);
530+
}
518531
let actual_path = resolve_path(path_str, &self.workspace_dir, self.restrict_to_workspace)?;
519532
let before = fs::read_to_string(&actual_path)
520533
.map_err(|error| format!("Could not preview edit target: {error}"))?;
@@ -2717,6 +2730,47 @@ mod mutation_preview_tests {
27172730
assert!(preview.diff.contains("+gamma"), "{}", preview.diff);
27182731
let _ = fs::remove_dir_all(root);
27192732
}
2733+
2734+
#[tokio::test]
2735+
async fn write_preview_skips_noop_when_content_unchanged() {
2736+
// PR #62 review #4: writing identical content is a no-op; skip the prompt.
2737+
let root = workspace();
2738+
fs::write(root.join("same.txt"), "identical\n").unwrap();
2739+
let tool = WriteFileTool {
2740+
workspace_dir: root.clone(),
2741+
restrict_to_workspace: true,
2742+
};
2743+
let args = json!({ "path": "same.txt", "content": "identical\n" });
2744+
assert!(
2745+
tool.preview_mutation(&args).await.unwrap().is_none(),
2746+
"identical-content write must be a no-op preview"
2747+
);
2748+
// A genuinely different write still produces a preview.
2749+
let changed = json!({ "path": "same.txt", "content": "new\n" });
2750+
assert!(tool.preview_mutation(&changed).await.unwrap().is_some());
2751+
let _ = fs::remove_dir_all(root);
2752+
}
2753+
2754+
#[tokio::test]
2755+
async fn edit_preview_skips_noop_when_old_equals_new() {
2756+
// PR #62 review #3: identical old/new text is a no-op; skip the prompt.
2757+
let root = workspace();
2758+
fs::write(root.join("notes.txt"), "alpha\nbeta\n").unwrap();
2759+
let tool = EditFileTool {
2760+
workspace_dir: root.clone(),
2761+
restrict_to_workspace: true,
2762+
};
2763+
let args = json!({
2764+
"path": "notes.txt",
2765+
"old_text": "beta",
2766+
"new_text": "beta"
2767+
});
2768+
assert!(
2769+
tool.preview_mutation(&args).await.unwrap().is_none(),
2770+
"identical old/new text must be a no-op preview"
2771+
);
2772+
let _ = fs::remove_dir_all(root);
2773+
}
27202774
}
27212775

27222776
#[cfg(test)]

0 commit comments

Comments
 (0)