From 3b0167c6cb2bbe138906320c639faff6fe2fb610 Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Tue, 15 Sep 2026 19:44:24 +0200 Subject: [PATCH] feat(settings): rebuild destructive-command cover in autoMode, scope the classifier environment `permissions.ask` gates nothing under `defaultMode: auto` (LRN-146, verified live), so the ten rules that left the static tiers had no cover left: rsync / kill -9 / killall / pkill out of deny, and python3 -c / python -c / xargs / sed / cp / mv out of ask. autoMode.soft_deny (7 rules) takes over what an explicit instruction should be able to clear: writes outside the working directory, rsync --delete, SIGKILL and kill-by-name, in-place edits spanning more than one file, directory moves, and inline interpreters or xargs that delete or write outside the cwd. Intent clears a soft block for the current turn only, stated as a rule since no setting expresses it. autoMode.hard_deny (3 rules) takes the classes no command pattern can express: secret exfiltration, production deployment, and disarming the guardrails. Adding a restriction stays allowed, removing one does not. permissions.deny gains ten .env reader rules (sed awk cut tr sort uniq diff od xxd strings). Six of those tools sat in permissions.allow, so reading a .env through them triggered nothing. autoMode.environment named another project, its FTP deploy target and its customer data, inside the file link.sh:21 symlinks to ~/.claude/settings.json, where it reached every repo and contradicted this one's Gitea remote. Rewritten machine-generic; the project facts moved to that project's gitignored .claude/settings.local.json. All three lists now open with "$defaults", which the original omitted, so the built-in classifier entries are inherited rather than replaced. doctor.sh check_automode backstops both defects. SETTINGS.md documents the block and a tier-choice table. README no longer claims the ask tier makes every mcp__magic__* call require a live confirmation. --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++ README.md | 13 ++++--- doctor.sh | 57 +++++++++++++++++++++++++++++++ settings.json | 57 +++++++++++++++++++++++++------ templates/settings/SETTINGS.md | 62 ++++++++++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48837b4..6ff4bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,43 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). lib (OS-support bump, submodule-update wrapper, cache report), sourced by `install-plugins.sh`, `update-all.sh` and `doctor.sh`, covered by `lib/tests/gstack-playwright.test.sh`. +- **`doctor.sh` inspects the `autoMode` block**: warns when a classifier + list drops the built-in entries (no `"$defaults"`) and when the + user-scope `environment` names a git repo other than the config repo. + Neither defect is visible from the deny count, until now the only + permission signal `doctor.sh` had. +- **`templates/settings/SETTINGS.md` documents `autoMode`**: the four + classifier lists, `$defaults` splice semantics, `classifyAllShell`, the + user-scope vs project-scope rule, and why `ask` is the wrong tier for a + destructive command under auto mode. + +### Changed +- **The classifier, not `permissions.ask`, now guards destructive shell + work** (BDR-090). Ten rules left the static tiers: `rsync`, `kill -9`, + `killall`, `pkill` out of `deny`, and `python3 -c`, `python -c`, + `xargs`, `sed`, `cp`, `mv` out of `ask`. Under `defaultMode: auto` an + `ask` rule raises no prompt ([[LRN-146]]), so that tier was gating + nothing anyway. Cover is now `autoMode.soft_deny`, which the classifier + enforces and an explicit instruction clears: writes outside the working + directory, `rsync --delete`, SIGKILL and kill-by-name, in-place edits + spanning more than one file, directory moves, and inline interpreters + or `xargs` that delete or write outside the cwd. Intent clears a soft + block for the current turn only. +- **`autoMode.hard_deny` added** for the three classes no command pattern + can express: secret exfiltration (a read and a send, separate steps, + possibly turns apart), production deployment (deploy scripts, lftp/FTP + pushes, any `prod` target), and disarming the guardrails (weakening a + deny list, `--no-verify`, removing the pre-commit hook, + `bypassPermissions`). Adding a restriction stays allowed; removing one + does not. No instruction clears these. + +### Security +- **Ten secret-reader deny rules added**: `sed`, `awk`, `cut`, `tr`, + `sort`, `uniq`, `diff`, `od`, `xxd`, `strings` against `.env*`. Six of + those tools sat in `permissions.allow`, so reading a `.env` through + them triggered nothing. Same shape and same known gap as the existing + `Bash(grep * .env*)` family: a `cat .env | sed` pipe still slips past, + which is what the `hard_deny` exfiltration rule is there to catch. ### Fixed - **`make update` no longer drops the Playwright OS-support bump** — a @@ -27,6 +64,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). arm still fires. Two latent bugs travelled with the extracted code: the ostag capture exited 1 on every non-Ubuntu host and aborted its caller under inherited `errexit`, and the `bun` calls had no timeout. +- **`autoMode.environment` no longer describes one project from the + user-scope file**: the block named a specific repo, its FTP deploy + target and its customer data, while `link.sh` symlinks this file to + `~/.claude/settings.json` where it reaches every project. The global + block now states machine-level facts only (self-hosted Gitea, gitflow + protection, `~/.claude/.env` as the single secret source, no CI), and + the project-specific facts moved to that project's gitignored + `.claude/settings.local.json`. Both lists now open with `"$defaults"`, + which the original omitted, so the built-in entries are inherited + rather than replaced. +- `README.md` no longer claims the `ask` tier makes every `mcp__magic__*` + call "require a live confirmation and can never auto-execute". That + holds under `defaultMode: default`, not under this config's `auto`. The + paragraph now separates what is verified from what is not, and names + `deny` as the only tier the classifier cannot lift. ## [1.5.0] — 2026-09-13 diff --git a/README.md b/README.md index 5bb6375..bcb684b 100644 --- a/README.md +++ b/README.md @@ -300,10 +300,15 @@ check) for up to 10 minutes per call; any local process or open browser tab can `POST` to it and that body is injected **verbatim** into the tool result the model consumes (job8 audit, `dist/utils/callback-server.js:36`). This is in the third-party package's code, not this repo's config — **we don't patch -it**. The mitigation lives entirely on our side: `settings.json` -`permissions.ask` explicitly lists all 4 `mcp__magic__*` tools, -so every call — builder included — requires a live confirmation and can -never auto-execute. Don't allowlist +it**. The mitigation lives on our side: `settings.json` +`permissions.ask` explicitly lists all 4 `mcp__magic__*` tools. +Read that as a declared intent, not a proven hard gate: under +`defaultMode: auto` (this config's default) Bash `ask` rules were observed +auto-approving with no prompt raised (LRN-146). Whether MCP `ask` rules +behave the same has not been verified here, so re-check before relying on +it. `deny` is the only tier the auto-mode classifier cannot lift; for a +gate that holds under auto mode without banning the tool outright, the +right home is `autoMode.soft_deny`. Don't allowlist `21st_magic_component_builder` or `21st_magic_component_refiner` (arbitrary absolute-path read → vendor exfil, same audit) under any circumstance. diff --git a/doctor.sh b/doctor.sh index e9156e6..ae12d13 100644 --- a/doctor.sh +++ b/doctor.sh @@ -215,6 +215,61 @@ echo "" # ──────────────────────────────────────────────────────────── # 5. Permissions check # ──────────────────────────────────────────────────────────── + +# Under defaultMode auto the classifier reads `autoMode`, so a block scoped +# to ONE project feeds every other project false facts, and a list without +# "$defaults" silently drops the built-in rules. Neither is visible from the +# deny count. Emits TAG|message lines for the caller to dispatch. +inspect_automode() { + REPO="$REPO" python3 - "$SETTINGS" <<'PY' +import json, os, re, sys + +settings = json.load(open(sys.argv[1])) +mode = settings.get("permissions", {}).get("defaultMode") +block = settings.get("autoMode") or {} + +if mode != "auto": + sys.exit(print("INFO|defaultMode is %s, autoMode not consulted" % mode)) +if not block: + sys.exit(print("WARN|defaultMode is auto but no autoMode block set")) + +sections = [k for k in ("allow", "soft_deny", "hard_deny", "environment") + if k in block] +bare = [k for k in sections if "$defaults" not in block[k]] +if bare: + print('WARN|autoMode.%s replaces the built-in entries (no "$defaults")' + % ", ".join(bare)) +else: + print('PASS|autoMode: %s inherit "$defaults"' % ", ".join(sections)) + +repo, home = os.environ["REPO"], os.path.expanduser("~") +foreign = {q for entry in block.get("environment", []) + for q in re.findall(r"`(/[^`]+)`", entry) + if (p := q.rstrip("/")).startswith(home) and p != repo + and os.path.isdir(os.path.join(p, ".git"))} +if foreign: + print("WARN|autoMode.environment names another repo (%s); this file is " + "user-scope and reaches every project" % ", ".join(sorted(foreign))) +else: + print("PASS|autoMode.environment is not scoped to a foreign repo") +PY +} + +check_automode() { + local out tag msg + if ! out=$(inspect_automode 2>/dev/null); then + warn "Could not inspect the autoMode block" + return + fi + while IFS='|' read -r tag msg; do + case "$tag" in + PASS) pass "$msg" ;; + WARN) warn "$msg" ;; + INFO) info "$msg" ;; + esac + done <<< "$out" +} + echo "── Permissions ──" SETTINGS="$HOME/.claude/settings.json" @@ -251,6 +306,8 @@ print(len(json.load(sys.stdin).get('permissions',{}).get('deny',[]))) warn "Deny rules: $DENY_COUNT (committed: $EXPECTED_DENY) — live settings diverge from last commit" fi fi + + check_automode else fail "$HOME/.claude/settings.json not found" fi diff --git a/settings.json b/settings.json index 46505b3..b1193df 100644 --- a/settings.json +++ b/settings.json @@ -110,12 +110,8 @@ "Bash(chmod -R 777 *)", "Bash(ssh *)", "Bash(scp *)", - "Bash(rsync *)", "Bash(nc *)", "Bash(netcat *)", - "Bash(kill -9 *)", - "Bash(killall *)", - "Bash(pkill *)", "Bash(crontab *)", "Bash(systemctl *)", "Bash(service *)", @@ -182,6 +178,16 @@ "Bash(more .env.*)", "Bash(grep * .env)", "Bash(grep * .env.*)", + "Bash(sed * .env*)", + "Bash(awk * .env*)", + "Bash(cut * .env*)", + "Bash(tr * .env*)", + "Bash(sort * .env*)", + "Bash(uniq * .env*)", + "Bash(diff * .env*)", + "Bash(od * .env*)", + "Bash(xxd * .env*)", + "Bash(strings * .env*)", "Bash(env)", "Bash(printenv)", "Bash(printenv *)", @@ -219,8 +225,6 @@ "Bash(wget * | sh)", "Bash(mkfifo *)", "Bash(node -e *)", - "Bash(python3 -c *)", - "Bash(python -c *)", "Bash(git push *)", "Bash(git push)", "Bash(docker run *)", @@ -234,10 +238,6 @@ "Bash(pacman -S *)", "WebSearch", "WebFetch", - "Bash(xargs *)", - "Bash(sed *)", - "Bash(cp *)", - "Bash(mv *)", "Bash(git stash pop*)", "Bash(git stash drop*)", "Bash(git stash clear)", @@ -358,5 +358,40 @@ "effortLevel": "xhigh", "remoteControlAtStartup": true, "inputNeededNotifEnabled": true, - "skipAutoPermissionPrompt": true + "skipAutoPermissionPrompt": true, + "autoMode": { + "soft_deny": [ + "$defaults", + "Scope of intent: an instruction clears a SOFT BLOCK for the current turn only. An approval given in an earlier turn is not an approval now, and the same action repeated in a later turn has to be asked for again.", + "Writing outside the current working directory: `cp`, `mv`, `sed -i`, `rsync`, `tee`, or a shell redirection whose destination resolves outside the cwd. Several sibling projects live under `~/Documents/`, so the realistic failure is writing into the wrong one, where git recovers nothing. Clear only when the user named the destination in this turn.", + "`rsync` invoked with `--delete`. It removes files at the destination that are absent from the source, with no undo. Clear only against a destination the user named in this turn.", + "Sending SIGKILL (`kill -9`) or killing processes by name (`killall`, `pkill`). These reach processes outside this session, including the user's editors, shells, dtach sessions and background jobs, and the target is chosen by a pattern, so a typo kills the wrong thing. Clear only when the user named the process in this turn.", + "Editing more than one file in place in a single command: `sed -i` or `perl -pi` over a glob, or a loop over `git ls-files`. The damage is not loss, since git recovers it, but a diff spanning hundreds of files that nobody reads before committing. `sed -i` on a single named file passes. Clear only when the user asked for the sweep.", + "Moving or renaming a directory inside the repo (`mv src/api src/api_old`, or any `mv` of a tree). It breaks imports and paths silently, and the breakage surfaces far from the command. Clear only when the user asked for that move.", + "An inline interpreter or `xargs` that deletes, or that writes outside the current working directory: `python3 -c`, `python -c` or `node -e` calling `rmtree`, `remove`, `unlink` or `truncate`; `xargs` feeding `rm`, `mv` or `dd`. `find ... | xargs rm` is the case that matters, since it routes around the `find * -exec rm` deny rule. Reading, computing, and editing a file inside the working directory pass untouched." + ], + "hard_deny": [ + "$defaults", + "Secret exfiltration: reading a `.env`, an SSH key, a token or a credentials file, and then sending what it contained outside this machine. That covers an HTTP request body or header, a paste or gist service, a file upload, an email, and a commit or push to any repository. The static deny rules match the reading tools by pattern and cannot see the second half of the chain; read and send are separate steps and may sit several turns apart. No instruction clears this.", + "Production deployment: running a project's deploy script (`bin/deploy.sh` and its equivalents), any lftp, FTP, SFTP or rsync push to a hosting provider, and any action against a target whose name carries `prod` or `production` as a whole word or name segment. The user deploys by hand, out of session. A green test suite, a finished feature, or a plan step that reads \"deploy\" is not an instruction to deploy. No in-session instruction clears this.", + "Disarming the guardrails: removing or weakening an entry in the `deny`, `soft_deny` or `hard_deny` lists of any settings.json, passing `--no-verify` to git, deleting or chmod-ing `.githooks/pre-commit`, setting `disableAllHooks`, or switching `permissions.defaultMode` to `bypassPermissions`. Adding a restriction is fine; removing one is not. When a task is blocked by a guardrail, say so and stop, rather than widening the guardrail to get through. The user maintains these files by hand. No instruction clears this." + ], + "environment": [ + "$defaults", + "### Machine-specific (refines any \"None configured\" default above)", + "**Primary use of Claude Code**: software development on a personal Linux workstation. Single developer, no organization.", + "**Source control**: self-hosted Gitea at `git.bchanot.fr` (SSH on port 49220). Some checkouts under `/home/bchanot/Documents/` have no remote at all and are local-only.", + "**Repository visibility**: private. The Gitea instance is self-hosted and not publicly indexed, and a checkout with no remote is local-only. Treat every repo here as private unless its remote points at a public host.", + "**Trusted repo**: the checkout Claude Code is currently working in, under `/home/bchanot/Documents/`. No single repo is privileged over the others — read the current one, do not assume a previous session's project.", + "**Trusted internal domains**: `git.bchanot.fr` (self-hosted Gitea). It is the only internal service.", + "**Default / protected branches**: gitflow. `main` (prod) and `develop` (integration) are protected: a per-repo pre-commit hook refuses code commits on either (exempting `.claude/**` and merges) and Gitea enforces branch protection on both. Work lands on `feature/*`, `bugfix/*`, `chore/*`, `release/*`, `hotfix/*`.", + "**Secrets management**: `~/.claude/.env` is the single source of truth and lives outside every git tree; repos reach it through a gitignored symlink. Only `.env.example`, holding placeholders, is ever tracked. A real secret inside a repo is a defect, not a configuration.", + "**Internal sharing / snippet hosting**: none. Public paste, gist and pastebin services are outside the trust boundary.", + "**CI/CD deploy targets**: no CI system. Deploys run out of band from a per-project runbook, typically lftp/FTP to OVH mutualised hosting for web projects. Nothing deploys automatically on a push or a merge.", + "**Internal package registry**: none. Public npm and PyPI.", + "**Host containment**: an ordinary developer workstation with open internet and no sandbox. Nothing is contained by the environment itself.", + "**Sensitive remote targets**: any namespace, host, database or container whose name carries `prod` or `production` as a whole word or name segment.", + "**Sensitive data locations & audiences**: per-project `.env` files (gitignored) hold database, deploy and API credentials; some web projects store customer-submitted form data under a retention policy. Both are personal or client data — never send either to an external service." + ] + } } diff --git a/templates/settings/SETTINGS.md b/templates/settings/SETTINGS.md index e462765..ab1a3de 100644 --- a/templates/settings/SETTINGS.md +++ b/templates/settings/SETTINGS.md @@ -46,6 +46,66 @@ Always write the file-write ban as `Edit(...)`. | `auto` | Research preview — agentic default, permission model evolving. This config's default (BDR-004) | Daily driving with guardrails | | `bypassPermissions` | Skips all prompts — **dangerous** | CI/CD only, sandboxed env | +## Auto mode (`autoMode`) + +With `defaultMode: auto`, a classifier decides each action instead of a static +prompt. The `autoMode` block is what you hand that classifier. + +| Key | What it holds | +|---|---| +| `environment` | Facts about the machine and the repo. Context, not rules. | +| `allow` | Action classes the classifier may clear on its own. | +| `soft_deny` | Destructive or irreversible actions. Explicit user intent clears them. | +| `hard_deny` | Security boundaries. User intent does **not** clear them. | +| `classifyAllShell` | `true` suspends every Bash allow rule so all shell goes through the classifier. | + +All four lists are prose spliced into the classifier prompt, not permission-rule +syntax. Write `Sending SIGKILL reaches processes outside this session`, not +`Bash(kill -9 *)`. + +### `$defaults` + +Each list **replaces** the built-in entries unless it contains the literal +string `"$defaults"`, which splices them in at that position. Put it first and +your own entries refine what follows. Omit it and you silently drop every +built-in rule, which is almost never the intent. + +### Scope it right + +`autoMode` in `~/.claude/settings.json` reaches **every** project on the +machine. Project facts (this repo's deploy target, its secrets, its data) +belong in that project's `.claude/settings.local.json`. A global block naming +one repo feeds the classifier false facts in all the others. + +### `ask` is not a prompt under auto mode + +Verified in-session (LRN-146): with `defaultMode: auto`, Bash rules in +`permissions.ask` were auto-approved and raised no prompt. `deny` is the only +tier the classifier cannot lift. + +So for a destructive command you want gated but still reachable, `ask` is the +wrong tier. Use `autoMode.soft_deny`: blocked until the user's intent clears +it. Keep `deny` for what must never run at all. + +### Picking a tier + +| You want | Tier | +|---|---| +| Never runs, no exception, matchable by a command pattern | `permissions.deny` | +| Never runs, and a pattern cannot express it (a read then a send, a prod target) | `autoMode.hard_deny` | +| Runs when the user asks for it, blocked otherwise | `autoMode.soft_deny` | +| Runs freely | `permissions.allow`, or nothing | + +`permissions.ask` is not on this list on purpose. Under `defaultMode: auto` it +gates nothing. + +### Scope of intent + +A `soft_deny` clears on the user's instruction, and this config scopes that to +the **current turn**. An approval from an earlier turn is not an approval now. +State the scope in the rules themselves: the classifier reads the list, it has +no separate setting for this. + ## Security notes - `Read(**/.env)` only blocks the Read tool. `Bash(cat .env)` bypasses it unless separately denied. @@ -53,6 +113,8 @@ Always write the file-write ban as `Edit(...)`. - `disableBypassPermissionsMode: "disable"` prevents switching to bypass mode mid-session. - Prefer `ask` over `allow` for anything touching external systems. - `deny` in `~/.claude/settings.json` cannot be overridden by project-level `allow` — deny always wins. +- Under `defaultMode: auto`, `ask` does not raise a prompt (see above). A destructive + command belongs in `deny` or in `autoMode.soft_deny`, not in `ask`. ## managed-settings.json (enterprise)