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)