[BUG] "Don't ask again for similar commands" saves verbatim command strings, so near-identical commands re-prompt every time

Status Open
Reported on v2.1.228
Maintainer reply None cached
Activity 3 comments · opened Aug 12, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Environment — Windows 11 (26100), "defaultMode": "acceptEdits" in project settings.

Summary — The dialog offers "Yes, and don't ask again for similar commands in <project-dir>". It does not create a rule matching similar commands; it appends the verbatim command string, absolute paths included, to permissions.allow. Any command differing by one argument re-prompts and appends another single-use entry. One session: ~20 prompts, ~20 rules, none of which ever matched again.

Repro — Approve node a.mjs <dir> via the "similar commands" option → run node b.mjs <dir> → prompted again → repeat for c, d, e → inspect settings.local.json.

Expected — A rule generalising over the executable (optionally + first arg). Or, if only exact matches are possible, a label that says "this exact command" so the option isn't clicked 20 times expecting accumulation.

Actual — Sanitised excerpt showing five entries for one script pattern and four curl entries against one localhost port differing only by a timeout flag. Also notes that piped/heredoc commands appear ungeneralisable and re-prompt unconditionally.

Secondary — 2: acceptEdits excludes Bash with no UI indication; in this session every single prompt was Bash, so the enabled mode was silently irrelevant to all of them. 3: Bash rules take command prefixes but not directory scopes, so "allow node, but only in thi— the only prompt-stopping rule is a machine-wide grant for that binary.

Suggested fixes — generalise the rule or relabel; surface that acceptEdits doesy scoping for Bash.

Impact — Approval fatigue with no accumulating benefit; the natural escape is -s, a materially worse posture than the correctly-scoped rule the model can'texpress.

What Should Happen?

A rule generalising over the executable (optionally + first arg). Or, if only exact matches are possible, a label that says "this exact command" so the option isn't clicked 20 times expecting accumulation.

Error Messages/Logs

Steps to Reproduce

Approve node a.mjs <dir> via the "similar commands" option → run node b.mjs <dir> → prompted again → repeat for c, d, e → inspect settings.local.json.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.228

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

VS Code integrated terminal

Additional Information

When I asked for a fix explicitly refusing to run claude --dangerously-skip-permissions as this whole procedure feels like privilage extortion Opus 4.8 suggested i paste:

{
"permissions": {
"allow": [
"Bash(node:)", "Bash(npm:)", "Bash(git:*)",
"Bash(ls:)", "Bash(cat:)", "Bash(grep:)", "Bash(sed:)",
"Bash(curl:)", "Bash(python:)", "Bash(mkdir:*)",
"Read(//c/Users/<my_username>/)"
],
"deny": ["Read(.env)", "Read(./secrets/
)", "Bash(rm -rf:)", "Bash(git push:)"],
"defaultMode": "acceptEdits"
}
}

This feels extremely wrong - using Claude in VS terminal without allowing full access is impossible with 50+ conformation requests each prompt.

View original on GitHub ↗

3 Comments

yurukusa · 1 month ago

I reproduced this on 2.1.228 (same version you reported), Linux, isolated HOME. Non-interactive claude -p, so a command that isn't pre-authorized by a rule simply fails instead of prompting — that makes "did the rule match?" directly observable.

Your core observation holds: the verbatim entry does not generalise.

| rule in permissions.allow | node a.mjs | node b.mjs | node c.mjs |
|---|---|---|---|
| Bash(node a.mjs) — the shape the dialog writes | ran | blocked | — |
| Bash(node:*) — prefix form | ran | ran | ran |
| (no rule — control) | blocked | — | — |

The control matters: without it, "blocked" could just mean the harness was broken. It isn't — one hand-written prefix rule covers every argument variation, so the ~20 accumulated entries collapse into one.

The whole replacement for those ~20 entries is this, in the project's .claude/settings.local.json:

{
  "permissions": {
    "allow": ["Bash(node:*)"],
    "deny": []
  }
}

Runnable repro, if you want to check it on your own box before trusting it:

mkdir -p lab/projA/.claude lab/projB/.claude && cd lab
for d in projA projB; do echo 'console.log("ran '"$d"'");' > $d/a.mjs; done
echo '{"permissions":{"allow":["Bash(node:*)"],"deny":[]}}' > projA/.claude/settings.local.json
echo '{"permissions":{"allow":[],"deny":[]}}'               > projB/.claude/settings.local.json

# same machine, same HOME, same binary, same command — only the settings file differs
(cd projA && claude -p 'Run exactly this and print only its stdout: node a.mjs' --output-format text)
(cd projB && claude -p 'Run exactly this and print only its stdout: node a.mjs' --output-format text)

-p is doing real work here: with no TTY there is nothing to approve, so an unauthorised command fails instead of prompting. That turns "did the rule match?" into a yes/no you can read off stdout.

One premise in the report is worth correcting, because it changes your escape route.

You wrote that Bash rules take command prefixes but not directory scopes, so the only prompt-stopping rule is a machine-wide grant for that binary. The scoping isn't in the rule syntax — it's in which settings file holds the rule. I put Bash(node:*) in one project's .claude/settings.local.json and left a second project without it, same machine, same HOME, same binary, same node a.mjs:

| where the command ran | result |
|---|---|
| project with the rule in its .claude/settings.local.json | ran |
| project without it | blocked |

So "allow node, but only in this directory" is expressible today. It just isn't what the dialog produces, and nothing in the UI points at it.

Your secondary point also reproduces: with "defaultMode": "acceptEdits" and an empty allow-list, node a.mjs was still blocked. acceptEdits does not cover Bash, and nothing says so at the point where it matters.

Practical consequence. The thing that makes this worth fixing isn't the extra clicks — it's that the accumulating-but-never-matching entries teach people the permission system doesn't work, and the obvious next step is --dangerously-skip-permissions, which drops every Bash guard machine-wide. A correctly-scoped prefix rule in the project's settings.local.json gets the same quiet session with a far smaller blast radius. Writing one is currently something you have to already know how to do.

ZeoStarDelta · 1 month ago

This is reproducible in Claude 1.40609.0 (f65e38) 2026-08-27T16:19:51.000Z on my Windows 11 machine and it just happened. Despite the Bash allow list covering the command, it still prompts. As I understand it, Claude Code understands separators like &&, ||, ;, |, and each subcommand must match independently. That means for cd /path && git commit, Claude Code splits it and checks each part separately against my rules. Bash(*) should still match each subcommand, but if the Windows implementation isn't splitting and matching correctly, that would explain exactly what I've been seeing lately.

yurukusa · 26 days ago

@ZeoStarDelta Your splitting model matches what I measure on Linux, so what you are hitting is probably not "Windows fails to split" — it is more likely that one part of your compound is not covered by any rule. Here is the measurement, plus a way to see exactly which string your machine is matching against.

Claude Code 2.1.278, Linux, isolated HOME, claude -p (no TTY, so anything unauthorised fails instead of prompting — that makes "did the rule match?" readable). A PreToolUse hook logged tool_input.command verbatim and exited 0, so the table below is based on the string that actually reached the permission check, not on what I asked for.

Same command in every row: touch zzz.txt && node a.mjs. The hook confirmed that exact string arrived each time.

| permissions.allow | compound ran? | zzz.txt created? |
|---|---|---|
| ["Bash(node:*)"] | no | no |
| ["Bash(touch:*)"] | no | yes |
| ["Bash(node:*)","Bash(touch:*)"] | yes | yes |
| [] — control | no | no |

Rows 1 and 2 are the informative ones. In both, the compound itself was refused, and the agent then retried the two halves as separate tool calls — the hook log shows touch zzz.txt && node a.mjs followed by the halves on their own. Whichever half had a rule then ran: in row 1 node a.mjs alone succeeded; in row 2 touch zzz.txt alone succeeded and left the file behind. Only row 3, where both halves are covered, ran the compound in one call.

Row 4 is the control. Without it, "no" in the other rows would be unreadable — it could just mean my harness was broken.

One caveat that cost me a wrong conclusion. Before this run I tested with echo HI && node a.mjs and cd . && node a.mjs, and both ran with only ["Bash(node:*)"] in the allow-list. I nearly wrote that as "the whole compound passes if one part matches". It is not that — echo and cd simply do not need a rule. If your failing example starts with cd, that part is not what is being refused. Measure with a part that leaves a visible side effect, or you cannot tell the two explanations apart.

Related trap in the same run: the agent retries. My first pass scored "did the expected stdout appear?", and it appeared even in row 1 — produced by a later, different command. Score a side effect, not the output.

The diagnostic for your machine. The thing the prompt does not show you is the exact string being matched. Log it:

mkdir -p .claude
cat > .claude/log-cmd.sh <<'EOF'
#!/bin/bash
IN=$(cat)
printf '%s\n' "$IN" | jq -r '.tool_input.command // empty' >> /tmp/cc-cmds.txt
exit 0
EOF
chmod +x .claude/log-cmd.sh
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": ".claude/log-cmd.sh" } ] }
    ]
  }
}

It exits 0, so it decides nothing — it only records. Reproduce the prompt, then read /tmp/cc-cmds.txt and check every part of the logged string against your allow-list, not just the part you were thinking of. On Windows also check what the shell wrapper adds: if the logged string is not byte-for-byte what you typed, the rule you wrote may not be matching what is actually being evaluated.

If every part is covered verbatim and it still prompts, that is a platform-specific bug worth its own report — and you would have the exact string to attach to it.

Before trusting any of this on your box, confirm the instrument can fail: empty the allow-list and check the same command is refused. If it still runs, the settings file you are editing is not the one in effect.