Skip to content

security: compound command permission escalation — single allowed segment grants auto-allow to entire chain #1213

@vika2603

Description

@vika2603

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingeffort-smallQuelques heures, 1 fichiergood first issueGood for newcomers

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions