@@ -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+
416438fn 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 ,
0 commit comments