Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
29 changes: 16 additions & 13 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading