diff --git a/src/hooks/README.md b/src/hooks/README.md index a0c76b76de..9aba51503d 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -76,15 +76,15 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i | Verdict | Trigger | rewrite_cmd exit | Hook behavior | |---------|---------|-----------------|---------------| | Deny | `permissions.deny` rule matched | 2 | Passthrough — host tool handles denial | -| Ask | `permissions.ask` rule matched | 3 | Rewrite + let host tool prompt user | +| Ask | `permissions.ask` rule matched | 3 | Claude native hook: rewrite + `permissionDecision: "allow"` (current hook schema requires allow with `updatedInput`); shell hook: rewrite + prompt | | Allow | `permissions.allow` rule matched | 0 | Rewrite + auto-allow | -| Default | No rule matched | 3 | Rewrite + let host tool prompt user | +| Default | No rule matched | 3 | Claude native hook: rewrite + `permissionDecision: "allow"` (current hook schema requires allow with `updatedInput`); shell hook: rewrite + prompt | ### Per-tool support | Tool | ask support | Behavior on Default | |------|------------|-------------------| -| Claude Code (rtk-rewrite.sh) | Yes | `permissionDecision: "ask"` — user prompted | +| Claude Code (`rtk hook claude`) | No for `updatedInput` | `permissionDecision: "allow"` — required by current Claude hook schema | | Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` — user prompted | | Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) | | Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) | diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 953080fdfa..ff9f075f41 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -349,7 +349,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) { + let rewritten = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { return PayloadAction::Skip { reason: "skip:deny_rule", @@ -362,8 +362,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { cmd: cmd.to_string(), } } - HookDecision::AllowRewrite(r) => (r, true), - HookDecision::AskRewrite(r) => (r, false), + HookDecision::AllowRewrite(r) | HookDecision::AskRewrite(r) => r, }; let updated_input = { @@ -374,19 +373,13 @@ fn process_claude_payload(v: &Value) -> PayloadAction { ti }; - let mut hook_output = json!({ + let hook_output = json!({ "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": "allow", "permissionDecisionReason": "RTK auto-rewrite", "updatedInput": updated_input }); - if allow { - hook_output - .as_object_mut() - .unwrap() - .insert("permissionDecision".into(), json!("allow")); - } - PayloadAction::Rewrite { cmd: cmd.to_string(), rewritten, @@ -1005,13 +998,23 @@ mod tests { let hook = &v["hookSpecificOutput"]; assert_eq!(hook["hookEventName"], PRE_TOOL_USE_KEY); - // permissionDecision is only set when an explicit allow rule matches; - // with default-to-ask semantics (no rules configured), it is absent. + // Claude rejects updatedInput unless the hook explicitly allows it. + assert_eq!(hook["permissionDecision"], "allow"); assert_eq!(hook["permissionDecisionReason"], "RTK auto-rewrite"); assert!(hook["updatedInput"].is_object()); assert!(hook["updatedInput"]["command"].is_string()); } + #[test] + fn test_claude_rewrite_never_emits_updated_input_without_allow() { + let result = run_claude_inner(&claude_input("git status")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let hook = &v["hookSpecificOutput"]; + + assert!(hook["updatedInput"].is_object()); + assert_eq!(hook["permissionDecision"], "allow"); + } + #[test] fn test_claude_no_tool_input_passthrough() { let input = json!({ "tool_name": "Bash" }).to_string();