software-engineering #claude-code #always-ask #rm #permissions #coding-agents #change-control

Print the Redirect Before You File Always-Ask Off

A missing always-ask prompt on a dangerous rm is not proof always-ask is off. Print the redirect, the prompt, and the named Claude Code owner before you file the ticket.

The junior pastes a terminal screenshot. The coding agent ran a delete. There was no always-ask prompt. They file the ticket: “always-ask is off, so this rm is allowed.”

I stop the run there. Print the full command, including the redirect, before you file always-ask off. The public Claude Code notes that named this field are blunt: a dangerous rm on / or the home directory lost its always-ask safeguard when the same command also redirected output to a ~ or wildcard path. That is a redirect on the same line, not a named allow and not a missing setting. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.287] [Source: https://code.claude.com/docs/en/changelog]

I already refused to treat a NUL byte in a permission rule as a wildcard allow in A NUL Byte in a Permission Rule Is Not a Wildcard Allow. I already refused to treat sandbox auto-allow as a retry tax on equals in Sandbox Auto-Allow Is Not a Retry Tax on Equals in Inline Scripts. I already refused to treat Copilot’s ready-to-approve line as a required merge vote in Leave Copilot Approve Off. This post is the same desk rule for always-ask on a dangerous delete. Print the redirect. Print whether always-ask still fired. Name who owns the always-ask answers.

The question is not whether the prompt appeared. The question is whether the named owner can still tell a tilde redirect from a setting that is actually off.

Three columns: Redirect, Always-ask, Named owner

The ticket that looks like always-ask is off

Juniors treat a missing prompt the way they treat a broken lock. Yesterday the same rm stopped and asked. Today the agent appended > ~/agent.log or 2> *.log. The prompt vanished. They page the desk: “always-ask is off.”

Two jobs collide on that line.

  1. Stop a dangerous delete long enough for a human. Official permission-mode docs: removals targeting a critical path still prompt as a circuit breaker. Root and home directory removals such as rm -rf / still prompt even when other permission prompts are skipped. [Source: https://code.claude.com/docs/en/permissions] [Source: https://code.claude.com/docs/en/permission-modes]
  2. Keep the safeguard honest when the same line also writes a file. The public fix names the old matcher: always-ask dropped when that dangerous rm also redirected output to a ~ or wildcard path. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.287]

If you only screenshot “it did not ask,” you will file always-ask-broken. You will not file the redirect.

I do not invent a fake overnight wipe of /. I use the public contract. The redirect field is the ticket, not a version pin in the title.

What the public notes actually named

Read the GitHub release body for the tag that named this field, then read the same line on the official changelog. Do not trust a social recap. Do not trust this post without those two pages.

The named field, published 1 October 2026, prerelease false:

Fixed a dangerous rm (such as one on / or the home directory) losing its always-ask safeguard when the same command also redirected output to a ~ or wildcard path.

[Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.287] [Source: https://code.claude.com/docs/en/changelog]

That sentence has three parts. Print all three on the ticket.

PartWhat it isWhat it is not
Dangerous rmA delete aimed at / or the home directoryEvery rm in a build script
Always-ask safeguardThe circuit-breaker prompt on that class of deleteA permissions.allow line you thought you wrote
Redirect to ~ or wildcard> >> 2> into a home path or a globA second command on a later line

The junior ticket collapses those three parts into one: “always-ask is off.” That collapse is the bug in the ticket, not a setting you then turn back on.

Official permissions docs still say permission rules are enforced by Claude Code, not by the model. Instructions in a prompt or CLAUDE.md shape what Claude tries. They do not change what Claude Code allows. [Source: https://code.claude.com/docs/en/permissions]

So a CLAUDE.md line that says “always ask before rm” is not this field. A missing prompt on a redirected dangerous rm is this field.

Where the pin sits after the decision, not in the title
The GitHub tag that named the redirect field is v2.1.287, published 2026-10-01T18:00:22Z, prerelease false. npm dist-tags on the morning of this post: latest 2.1.288, stable 2.1.285, next 2.1.288. The later tag names a different field: a dangerous rm inside bash -c or sh -c running without a prompt in bypassPermissions mode or under a shell allow rule. Do not merge those two fields. Do not put either pin in the title. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.287] [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.288] [Source: https://registry.npmjs.org/@anthropic-ai/claude-code]

Four checks before you file

A mid-senior screenshots this list. A junior who only has the screenshot still has a ticket they can close.

  1. Print the full command as one string. Include every redirect. If the log truncates at the first | or >, the log is the ticket, not always-ask.
  2. Mark ~ or a wildcard on the redirect target. Home expansion and globs are the named field. A redirect into ./build/agent.log is a different ticket until you prove otherwise.
  3. Replay the same rm as a string compare, without the redirect, in a dry log. If always-ask still fires on the dry form and vanished on the redirected form, you have this field. If always-ask is absent on both, you have a mode or allow-rule ticket.
  4. Write one human name. CLAUDE_CODE_ALWAYS_ASK_OWNER. A coding agent does not own the always-ask answers. A Slack channel does not own them.

Four checks: Full command, Tilde or glob, Dry compare, Named owner

Those four lines are the whole post. Everything below is how you print them without turning this page into a delete recipe.

Do not run rm -rf / to “prove” the old matcher. Do not run rm -rf ~. The public release already states the old matcher. You need a parser on a logged command string.

Copy this probe. It classifies a string. It does not call rm.

 1#!/usr/bin/env python3
 2"""Classify a logged shell string. Never execute it."""
 3from __future__ import annotations
 4
 5import re
 6import sys
 7from pathlib import Path
 8
 9REDIRECT = re.compile(r"(?:^|\s)(?:[0-9]*)>>?\s*(\S+)|(?:^|\s)(?:[0-9]*)>\s*(\S+)")
10DANGEROUS_RM = re.compile(r"\brm\b.*(?:\s/|\s~(?:/|\s|$)|\$HOME)", re.I)
11
12
13def classify(command: str) -> dict[str, bool | str]:
14    targets = [t for pair in REDIRECT.findall(command) for t in pair if t]
15    tilde_or_glob = any(
16        t.startswith("~") or "*" in t or "?" in t or t.startswith("$HOME")
17        for t in targets
18    )
19    return {
20        "dangerous_rm": bool(DANGEROUS_RM.search(command)),
21        "has_redirect": bool(targets),
22        "redirect_targets": " ".join(targets),
23        "tilde_or_glob_redirect": tilde_or_glob,
24        "this_field": bool(DANGEROUS_RM.search(command) and tilde_or_glob),
25    }
26
27
28def main() -> int:
29    raw = Path(sys.argv[1]).read_text(encoding="utf-8") if len(sys.argv) > 1 else sys.stdin.read()
30    row = classify(raw.strip())
31    for key, value in row.items():
32        print(f"{key}={value}")
33    if row["this_field"]:
34        print("ticket=redirect_dropped_always_ask")
35        return 2
36    if row["dangerous_rm"] and not row["has_redirect"]:
37        print("ticket=dangerous_rm_without_redirect")
38        return 1
39    print("ticket=not_this_field")
40    return 0
41
42
43if __name__ == "__main__":
44    raise SystemExit(main())

A fixture file is enough. Put the logged command in logged-command.txt. Run the probe against the file. Paste the four printed keys onto the ticket next to the owner name.

 1# tests/test_redirect_always_ask_probe.py
 2from pathlib import Path
 3import importlib.util
 4
 5spec = importlib.util.spec_from_file_location(
 6    "probe", Path(__file__).resolve().parents[1] / "scripts" / "probe_redirect_always_ask.py"
 7)
 8probe = importlib.util.module_from_spec(spec)
 9spec.loader.exec_module(probe)
10
11
12def test_tilde_redirect_is_this_field():
13    row = probe.classify("rm -rf / > ~/agent.log")
14    assert row["dangerous_rm"] is True
15    assert row["tilde_or_glob_redirect"] is True
16    assert row["this_field"] is True
17
18
19def test_wildcard_redirect_is_this_field():
20    row = probe.classify("rm -rf $HOME 2> /tmp/*.log")
21    assert row["this_field"] is True
22
23
24def test_plain_build_rm_is_not_this_field():
25    row = probe.classify("rm -rf ./build")
26    assert row["this_field"] is False
27    assert row["dangerous_rm"] is False

Those tests never delete a file. They pin the classifier. If a junior changes the regex to “any rm,” the build_rm test fails. That is the point.

Ask rules, permission modes, and critical-path rm

Always-ask on a dangerous delete sits next to ordinary permission rules. Do not pretend they are the same list.

Official permissions docs: rules follow Tool or Tool(specifier). A scoped rule such as Bash(rm *) leaves the tool available and blocks matching calls. Deny, then ask, then allow. The first match in that order wins. [Source: https://code.claude.com/docs/en/permissions]

Copy the docs example as a probe of the lists, not as a bypass recipe.

 1{
 2  "permissions": {
 3    "allow": [
 4      "Bash(npm run *)",
 5      "Bash(git commit *)"
 6    ],
 7    "deny": [
 8      "Bash(git push *)",
 9      "Read(./.env)",
10      "Read(./.env.*)"
11    ]
12  }
13}

[Source: https://code.claude.com/docs/en/permissions] [Source: https://code.claude.com/docs/en/settings]

That JSON is not this field. A Bash(rm *) deny is a deny. An ask rule that names git clean is an ask rule. Official docs: an ask rule like Bash(git clean *) still prompts for cd /tmp && git clean -f even in auto mode. [Source: https://code.claude.com/docs/en/permissions]

The redirect field is the circuit breaker on a dangerous rm that still has to ask after those lists. Official permission-mode docs: rm and rmdir removals targeting a critical path, which no allow rule or PreToolUse hook "allow" approves. In bypassPermissions, root and home directory removals still prompt as a circuit breaker. [Source: https://code.claude.com/docs/en/permission-modes] [Source: https://code.claude.com/docs/en/permissions]

Print the mode. Print the lists. Then print the redirect. If you only print the mode, you will file “bypassPermissions ate always-ask.” BypassPermissions is documented to keep the critical-path prompt. The named bug was that prompt dropping when a ~ or wildcard redirect sat on the same line. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.287]

Flow: Lists, then circuit breaker, then redirect on the same line

Permission modes in one table, from the official page, so a junior does not invent a fifth mode.

ModeOrdinary shellCritical-path rm
default / acceptEditsAsks on most shellAsks
autoClassifier, fewer promptsStill a circuit breaker, with a timed prompt in a terminal
dontAskDenies what would have promptedDenies
bypassPermissionsSkips most promptsStill asks on root and home removals

[Source: https://code.claude.com/docs/en/permission-modes]

If the screenshot is auto mode and the command has no redirect, do not file this post’s ticket. If the screenshot is bypassPermissions and the command has a tilde redirect, file this field first, then the mode.

Neighbor tickets that are not this field

Print these so a junior does not collapse every rm headline into this redirect.

  1. Command-substitution rm. A recursive rm whose target is only command-substitution output, such as rm -rf "$(pwd)", is a different public fix. After that fix the command asks even with a Bash allow rule unless CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT=1. That is substitution, not a tilde redirect. Do not clone it. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281] [Source: https://code.claude.com/docs/en/changelog]
  2. bash -c / sh -c. A later public note names a dangerous rm inside bash -c or sh -c running without a prompt in bypassPermissions mode or under a shell allow rule. Different wrapper. Different ticket. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.288] [Source: https://code.claude.com/docs/en/changelog]
  3. A NUL byte in a permission rule. Matcher bytes, not redirects. Already a post. [Source: https://zemna.net/blog/a-nul-byte-in-a-permission-rule-is-not-a-wildcard/]
  4. Sandbox auto-allow on python3 -c. Equals matcher, not always-ask on rm. Already a post. [Source: https://zemna.net/blog/sandbox-auto-allow-is-not-a-retry-tax-on-equals/]
  5. A laravel/ai lockfile line. Fetch client, not a shell redirect. Already a post. [Source: https://zemna.net/blog/a-laravel-ai-lockfile-line-is-not-a-patched-fetch-client/]
  6. Copilot approve. A ready-to-approve line is not a merge vote. Already a post. [Source: https://zemna.net/blog/leave-copilot-approve-off/]

If the logged command is rm -rf "$(pwd)" with no redirect, you are on neighbor 1. If the logged command is bash -c 'rm -rf /' with no > ~, you are on neighbor 2. If the logged command is rm -rf / > ~/agent.log, you are on this field.

What you must not do

Forbidden:

  1. File “always-ask is off” without printing the full command, the redirect target, the dry compare, and one human name.
  2. Put 2.1.287 or 2.1.288 in the title or the first line. The pin is evidence after the decision.
  3. Mix this field with substitution-rm, bash -c wrapping, a NUL permission rule, sandbox equals, or a laravel/ai lockfile line. Those are other posts.
  4. Write a live rm against / or the home directory to “demo” the old matcher. The public release already states the old matcher. You do not need a live wipe.
  5. Treat npm latest as this deploy. Print the installed Claude Code line the same way you print a lockfile.
  6. Treat stable 2.1.285 as “this field is absent.” Dist-tags are not the logged command.
  7. Set CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT=1 or CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT=1 as a demo. Those flags belong to neighbor tickets. This page does not turn them on. [Source: https://code.claude.com/docs/en/changelog]
  8. Recommend buying a plan, a seat, or a scanner because a prompt was missing.
  9. Clone the auto-start post, the green-deploy post, the /readyz post, the runner-deadline post, the 2,500+ inventory post, the sandbox-equals post, or the lockfile post as a synonym. Those URLs already shipped. GSC this week has no striking-distance query that asks for another copy.
  10. File “every rm” or “every redirect” as this field. The public notes name a dangerous rm on / or the home directory plus a ~ or wildcard redirect.

Allowed:

  1. Print the logged command as one string.
  2. Classify it with probe_redirect_always_ask.py. Do not execute it.
  3. Answer dry-compare always-ask yes/no in one sentence owned by a human.
  4. Name one human as CLAUDE_CODE_ALWAYS_ASK_OWNER.
  5. Print the installed Claude Code version after the decision, in a details block, not in the title.
  6. Schedule the client update as change control when the logged command matches this field and the installed line predates the public fix.
  7. Keep substitution-rm and bash -c wrapping as separate tickets, not as fake all-clears.

If you need the broader habit, start at /ai-agent-operations/. Tooling notes live under /developer-tools/. Laravel plus Vue notes live under /laravel-vue-saas/. A first-week map is at /start-here/.

A changelog bullet about a missing always-ask prompt is not permission to skip the four lines.

Print four lines: Command, Redirect, Dry compare, Owner

What you should do Monday morning

  1. Open the repo that actually ships. Export CLAUDE_CODE_ALWAYS_ASK_OWNER to a human name. Collect the last coding-agent shell logs that contain rm. Run probe_redirect_always_ask.py against each logged string. Write this_field=True or False on the ticket next to that name.
  2. For every this_field=True row, answer dry-compare in one sentence: the same rm without the redirect still prompts, or it does not. If you cannot answer, the ticket is “owner missing,” not “always-ask off.”
  3. If the logged command matches this field and the installed Claude Code line predates the public fix, file “redirect dropped always-ask,” not “always-ask is off.” If there is no redirect, file no-redirect. If the wrapper is bash -c, file the wrapper ticket. If the target is only "$(pwd)", file substitution.
  4. If someone pastes a single npm i -g @anthropic-ai/claude-code as the close of substitution-rm, redirect-rm, and bash -c wrapping, split the ticket. Print three logged strings. Close each field on its own evidence. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.281] [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.288]
  5. Print claude --version from the machine that ran the agent, not from a laptop that did not. If the laptop line is not the CI line, do not treat the laptop as production.
  6. Confirm coding-agent instructions on this desk name the same owner and forbid “always-ask is off” without the four lines. Forbid any live rm against / or the home directory as a demo.

The question is not whether the changelog demos well in a gist. The question is whether the named owner can still tell a tilde redirect from a setting that is actually off after handoff.

Further reading

Source GitHub — Claude Code v2.1.287 release notes

Source Claude Code docs — changelog

Source Claude Code docs — configure permissions