Summary
In src/hooks/permissions.rs, check_command_with_rules uses an any_allow flag
for compound commands. If any single segment matches an allow rule, the entire
compound command receives PermissionVerdict::Allow — even when other segments
have no matching allow rule and should default to ask.
Reproduction
Given ~/.claude/settings.json:
{
"permissions": {
"allow": ["Bash(git status *)", "Bash(git status)", "Bash(cargo *)"]
}
}
$ rtk rewrite "git add ." # exit 3 (ask) — correct
$ rtk rewrite "git status && git add ." # exit 0 (allow) — BUG
$ rtk rewrite "cargo test && git add . && git commit -m foo" # exit 0 (allow) — BUG
git add . alone correctly returns exit 3 (ask), but chaining it after an allowed
command escalates the entire chain to exit 0 (auto-allow).
Root cause
// src/hooks/permissions.rs — check_command_with_rules()
if !any_allow && !any_ask {
for pattern in allow_rules {
if command_matches_pattern(segment, pattern) {
any_allow = true; // set by first segment, never unset
break;
}
}
}
Once the first segment sets any_allow = true, subsequent segments skip the allow
check entirely. The final verdict any_allow → Allow grants auto-allow to the
whole chain.
Expected behavior
A compound command should only receive Allow when all non-empty segments
individually match an allow rule. If any segment lacks an allow match, the verdict
should be Default (ask).
Suggested fix
Replace any_allow with all_allow logic:
let mut all_segments_allowed = true;
for segment in &segments {
let segment = segment.trim();
if segment.is_empty() { continue; }
// deny — unchanged (any deny → immediate Deny)
for pattern in deny_rules {
if command_matches_pattern(segment, pattern) {
return PermissionVerdict::Deny;
}
}
// ask — unchanged
if !any_ask {
for pattern in ask_rules {
if command_matches_pattern(segment, pattern) {
any_ask = true;
break;
}
}
}
// allow — check every segment independently
if all_segments_allowed {
let matched = allow_rules.iter()
.any(|p| command_matches_pattern(segment, p));
if !matched {
all_segments_allowed = false;
}
}
}
if any_ask { PermissionVerdict::Ask }
else if all_segments_allowed && !allow_rules.is_empty() { PermissionVerdict::Allow }
else { PermissionVerdict::Default }
Impact
An LLM agent can construct <allowed_cmd> && <unapproved_cmd> to bypass user
confirmation on the second command. This is a permission escalation via compound
command chaining.
Related
Summary
In
src/hooks/permissions.rs,check_command_with_rulesuses anany_allowflagfor compound commands. If any single segment matches an allow rule, the entire
compound command receives
PermissionVerdict::Allow— even when other segmentshave no matching allow rule and should default to ask.
Reproduction
Given
~/.claude/settings.json:{ "permissions": { "allow": ["Bash(git status *)", "Bash(git status)", "Bash(cargo *)"] } }git add .alone correctly returns exit 3 (ask), but chaining it after an allowedcommand escalates the entire chain to exit 0 (auto-allow).
Root cause
Once the first segment sets
any_allow = true, subsequent segments skip the allowcheck entirely. The final verdict
any_allow → Allowgrants auto-allow to thewhole chain.
Expected behavior
A compound command should only receive
Allowwhen all non-empty segmentsindividually match an allow rule. If any segment lacks an allow match, the verdict
should be
Default(ask).Suggested fix
Replace
any_allowwithall_allowlogic:Impact
An LLM agent can construct
<allowed_cmd> && <unapproved_cmd>to bypass userconfirmation on the second command. This is a permission escalation via compound
command chaining.
Related