Merge feature/automode-config-alignment into develop
This commit is contained in:
@@ -99,6 +99,7 @@ rules:
|
|||||||
| BDR-087 | 2026-09-03 | Stop hook = attention signal only, never control flow; one script for Notification + Stop | accepted |
|
| BDR-087 | 2026-09-03 | Stop hook = attention signal only, never control flow; one script for Notification + Stop | accepted |
|
||||||
| BDR-088 | 2026-09-15 | gstack Playwright bump shared via lib, re-applied after submodule update; update helper never touches the submodule tree | accepted |
|
| BDR-088 | 2026-09-15 | gstack Playwright bump shared via lib, re-applied after submodule update; update helper never touches the submodule tree | accepted |
|
||||||
| BDR-089 | 2026-09-15 | No Playwright browser-cache pruner; read-only doctor report — .links proved 0 bytes reclaimable | accepted |
|
| BDR-089 | 2026-09-15 | No Playwright browser-cache pruner; read-only doctor report — .links proved 0 bytes reclaimable | accepted |
|
||||||
|
| BDR-090 | 2026-09-15 | Destructive shell work → autoMode soft_deny/hard_deny; `ask` tier abandoned (inert under auto) | accepted |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1146,3 +1147,13 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate).
|
|||||||
- **Alternatives rejected**: hand-rolled pruner guarded on "revision resolved by gstack's local playwright" (the originally requested shape) — that guard keeps 1228 and DELETES 1243, breaking gsd-pi. The guard was wrong, not just its implementation.
|
- **Alternatives rejected**: hand-rolled pruner guarded on "revision resolved by gstack's local playwright" (the originally requested shape) — that guard keeps 1228 and DELETES 1243, breaking gsd-pi. The guard was wrong, not just its implementation.
|
||||||
- **Status**: accepted.
|
- **Status**: accepted.
|
||||||
- **Reference**: commit 2cebecb. Links [[LRN-151]], [[BDR-088]].
|
- **Reference**: commit 2cebecb. Links [[LRN-151]], [[BDR-088]].
|
||||||
|
|
||||||
|
## BDR-090 — Destructive shell work → autoMode soft_deny/hard_deny; `ask` tier abandoned
|
||||||
|
- **Date**: 2026-09-15
|
||||||
|
- **Decision**: 10 rules leave the static tiers (user's own edit): `rsync` `kill -9` `killall` `pkill` out of `deny`; `python3 -c` `python -c` `xargs` `sed` `cp` `mv` out of `ask`. Cover rebuilt in `autoMode` — 7 `soft_deny` (write outside cwd, `rsync --delete`, SIGKILL/kill-by-name, in-place edit spanning >1 file, directory move, inline interpreter or `xargs` that deletes or writes outside cwd) + 3 `hard_deny` (secret exfiltration, prod deploy, disarming guardrails). Intent clears a soft block for the CURRENT TURN only — encoded as a rule line, no setting exists for it. `classifyAllShell` stays false. `permissions.deny` +10 `.env` reader rules (`sed awk cut tr sort uniq diff od xxd strings`), 6 of which sat in `allow`.
|
||||||
|
- **Why**: `ask` raises no prompt under `defaultMode: auto` ([[LRN-146]], verified live). It gated nothing, so a destructive rule moved deny→ask was a silent loosening dressed as a confirmation. `soft_deny` = the tier the classifier enforces and user intent clears. `hard_deny` = the 3 classes no command pattern can express — read-then-send spans turns, a prod target is a name not a verb, widening a deny list is self-disarming.
|
||||||
|
- **Alternatives rejected**: keep them in `ask` — inert, false sense of a gate. Back to `deny` — blocks legit process cleanup and inter-project copy, and the user works Bash-first under auto mode. `classifyAllShell: true` — closes the allow-tier blind spot but bills a classifier call on every `git status`. Published-history rewrite as `hard_deny` — user declined; `rebase` then an ordinary push stays uncovered, known gap.
|
||||||
|
- **Scope fix (same commit)**: `autoMode.environment` named `/home/bchanot/Documents/atlast`, its FTP deploy target and its customer data, inside the file `link.sh:21` symlinks to `~/.claude/settings.json`. Every project received atlast's facts, and this repo's own Gitea remote contradicted the block's "no remote configured". Global block now machine-generic; atlast facts moved to atlast's gitignored `.claude/settings.local.json`.
|
||||||
|
- **Caveat**: the guardrail `hard_deny` bars REMOVING a `deny`/`soft_deny`/`hard_deny` entry, not adding one. Future loosening goes through `/permissions` or the user's own edit — deliberate, confirmed with the user.
|
||||||
|
- **Status**: accepted.
|
||||||
|
- **Reference**: `settings.json`, `doctor.sh` `check_automode`, `templates/settings/SETTINGS.md`. Links [[LRN-153]], [[LRN-146]], [[BDR-004]].
|
||||||
|
|||||||
@@ -462,3 +462,10 @@ rules:
|
|||||||
- Attention signal refined: per-event labels (BDR-087 follow-on), silence on non-attention events, and no turn-end signal while `background_tasks` non-empty ([[LRN-149]]). Payload dump beat the docs: `background_tasks` undocumented for Stop but present on the wire. Branch bugfix/notify-subagent-spawn.
|
- Attention signal refined: per-event labels (BDR-087 follow-on), silence on non-attention events, and no turn-end signal while `background_tasks` non-empty ([[LRN-149]]). Payload dump beat the docs: `background_tasks` undocumented for Stop but present on the wire. Branch bugfix/notify-subagent-spawn.
|
||||||
- gstack Playwright: bump extracted to `lib/gstack-playwright.sh`, now re-applied after a successful submodule update ([[BDR-088]]); read-only browsers report in doctor, no pruner — `.links` proved 0 bytes reclaimable and the guard I first proposed would have deleted gsd-pi's rev 1243 ([[BDR-089]], [[LRN-151]]). 4 challengers → 6 BLOCKER, recovery branch withdrawn at the gate ([[EVAL-029]]). 2cebecb on feature/gstack-playwright-lib.
|
- gstack Playwright: bump extracted to `lib/gstack-playwright.sh`, now re-applied after a successful submodule update ([[BDR-088]]); read-only browsers report in doctor, no pruner — `.links` proved 0 bytes reclaimable and the guard I first proposed would have deleted gsd-pi's rev 1243 ([[BDR-089]], [[LRN-151]]). 4 challengers → 6 BLOCKER, recovery branch withdrawn at the gate ([[EVAL-029]]). 2cebecb on feature/gstack-playwright-lib.
|
||||||
- Node checked against Playwright: already v24 (1.61 needs >=18, 1.63 needs >=20), not the macOS constraint. macOS audit deferred to its own cycle — found statically: `sed -i` with no suffix x3 in install-plugins.sh (BSD sed eats the next arg), `${x,,}` in url-guard.sh (bash 4+, macOS ships 3.2), `readlink -f` in doctor.sh (absent pre-Monterey 12.3).
|
- Node checked against Playwright: already v24 (1.61 needs >=18, 1.63 needs >=20), not the macOS constraint. macOS audit deferred to its own cycle — found statically: `sed -i` with no suffix x3 in install-plugins.sh (BSD sed eats the next arg), `${x,,}` in url-guard.sh (bash 4+, macOS ships 3.2), `readlink -f` in doctor.sh (absent pre-Monterey 12.3).
|
||||||
|
|
||||||
|
## 2026-09-15
|
||||||
|
- Aligned repo config + deployment on the user's hand-edited `settings.json`. Destructive shell work rebuilt in `autoMode` soft_deny/hard_deny once `ask` was established as inert under auto mode ([[BDR-090]]); `permissions.deny` +10 `.env` reader rules, 6 of which sat in `allow`.
|
||||||
|
- `autoMode.environment` was scoped to ANOTHER project inside the user-scope file, so every repo got atlast's facts. Rewritten machine-generic, atlast facts moved to atlast's own `settings.local.json`, `$defaults` added to all three lists ([[LRN-153]]).
|
||||||
|
- `doctor.sh` gained `check_automode` (missing `$defaults`, foreign-repo scope, both arms tested). `SETTINGS.md` documents the block + a tier-choice table. README's magic-MCP "ask = live confirmation" claim corrected — false under `defaultMode: auto`.
|
||||||
|
- Found, not fixed: `.claude/settings.local.json` = 14.6 KB shadow copy of the global settings at HIGHER precedence, incl. a `config-protection.sh` hook whose script does not exist. Logged F1-F3 in TODO.
|
||||||
|
- `make test` 0 RED, `doctor.sh` 0 errors, `shellcheck` clean.
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ rules:
|
|||||||
| LRN-150 | 2026-09-15 | Sourced lib shares caller shell: bare `ok/warn/info` override its printers, and its `set -e` applies inside | any new lib/*.sh |
|
| LRN-150 | 2026-09-15 | Sourced lib shares caller shell: bare `ok/warn/info` override its printers, and its `set -e` applies inside | any new lib/*.sh |
|
||||||
| LRN-151 | 2026-09-15 | Playwright cache truth = union over `.links`, dir name maps `_`→`-`, revisionOverrides exist | shared versioned binary caches |
|
| LRN-151 | 2026-09-15 | Playwright cache truth = union over `.links`, dir name maps `_`→`-`, revisionOverrides exist | shared versioned binary caches |
|
||||||
| LRN-152 | 2026-09-15 | git `protocol.file=user` blocks submodule fixtures; `-c` misses the code under test, `GIT_CONFIG_*` env does not | tests building git fixtures |
|
| LRN-152 | 2026-09-15 | git `protocol.file=user` blocks submodule fixtures; `-c` misses the code under test, `GIT_CONFIG_*` env does not | tests building git fixtures |
|
||||||
|
| LRN-153 | 2026-09-15 | `autoMode` lists replace built-ins without `"$defaults"`; a user-scope block reaches every project | any `autoMode` edit |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1450,3 +1451,12 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s
|
|||||||
- **Also**: fixture repos need LOCAL `user.email`/`user.name` (no global identity here) and `git init -b main` + explicit `submodule.<name>.branch`, else `--remote` resolves a different branch than production does.
|
- **Also**: fixture repos need LOCAL `user.email`/`user.name` (no global identity here) and `git init -b main` + explicit `submodule.<name>.branch`, else `--remote` resolves a different branch than production does.
|
||||||
- **Future application**: any test building a git submodule fixture. Symptom is a hard "transport 'file' not allowed" before the first assertion, which reads like a broken test rather than a policy.
|
- **Future application**: any test building a git submodule fixture. Symptom is a hard "transport 'file' not allowed" before the first assertion, which reads like a broken test rather than a policy.
|
||||||
- **Reference**: `lib/tests/gstack-playwright.test.sh`.
|
- **Reference**: `lib/tests/gstack-playwright.test.sh`.
|
||||||
|
|
||||||
|
## LRN-153 — `autoMode` lists replace built-ins unless `"$defaults"` is spliced in
|
||||||
|
- **Date**: 2026-09-15
|
||||||
|
- **Pattern**: every list under `autoMode` (`allow` `soft_deny` `hard_deny` `environment`) is a FULL replacement by default. Omit the literal `"$defaults"` and the built-in classifier rules are dropped silently — no warning, no schema error, the classifier just runs thinner. Put `"$defaults"` first, own entries after: built-ins inherited, then refined.
|
||||||
|
- **Scope trap, same block**: `autoMode` in `~/.claude/settings.json` reaches EVERY project. A block generated while working in one repo (its deploy target, its secrets, its data) ships that repo's facts to all the others, and contradicts whichever repo is actually open. Project facts belong in that project's `.claude/settings.local.json`.
|
||||||
|
- **Format**: these lists are prose spliced into the classifier prompt, not permission-rule syntax. Write "Sending SIGKILL reaches processes outside this session", never `Bash(kill -9 *)`.
|
||||||
|
- **Backstop**: `doctor.sh` `check_automode` warns on a list missing `$defaults` and on a user-scope `environment` naming a git repo other than the config repo. Both arms exercised against the defective block before shipping.
|
||||||
|
- **Future application**: any `autoMode` edit — check `$defaults` presence and scope before anything else.
|
||||||
|
- **Reference**: `doctor.sh`, `templates/settings/SETTINGS.md`. Links [[BDR-090]].
|
||||||
|
|||||||
@@ -1,5 +1,88 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
|
## 2026-09-15 — align config + deployment on the hand-edited settings.json (feature/automode-config-alignment)
|
||||||
|
User edited global `settings.json` by hand: 4 destructive rules moved
|
||||||
|
deny→ask (`rsync`, `kill -9`, `killall`, `pkill`), 4 removed from ask
|
||||||
|
(`xargs`, `sed`, `cp`, `mv` — coherent with auto mode's Bash-first
|
||||||
|
workflow; the `.env`-scoped `cp`/`mv`/`xargs` deny rules still stand),
|
||||||
|
and an `autoMode.environment` block added. Two defects found:
|
||||||
|
(1) the environment block describes **atlast** (`bin/deploy.sh` lftp/FTP
|
||||||
|
to OVH, quote-request data, "no remote configured") but lives in the
|
||||||
|
user-scope file symlinked to `~/.claude/settings.json` by `link.sh:21`
|
||||||
|
— so every project gets atlast's facts; claude-config itself has a
|
||||||
|
Gitea remote, contradicting the block. (2) no `"$defaults"` sentinel,
|
||||||
|
so the built-in classifier environment entries are replaced, not
|
||||||
|
extended. Third finding: LRN-146 records, verified in session, that
|
||||||
|
`ask` rules raise no prompt under `defaultMode: auto` — the deny→ask
|
||||||
|
move therefore traded a static block for a classifier decision.
|
||||||
|
User decisions (2026-09-15): atlast block → atlast's own
|
||||||
|
`settings.local.json`, global block rewritten machine-generic; the 4
|
||||||
|
destructive rules → `autoMode.soft_deny` (the section that actually
|
||||||
|
binds under auto mode) instead of `ask`.
|
||||||
|
- [x] T1 global `settings.json` — machine-generic `autoMode.environment`
|
||||||
|
with `$defaults`; new `autoMode.soft_deny` with `$defaults` + the
|
||||||
|
4 destructive rules; drop those 4 from `permissions.ask`
|
||||||
|
- [x] T2 `/home/bchanot/Documents/atlast/.claude/settings.local.json` —
|
||||||
|
receives the atlast-specific `autoMode.environment` (gitignored,
|
||||||
|
personal scope); verify project-scope `autoMode` is honored
|
||||||
|
- [x] T3 `templates/settings/SETTINGS.md` — document the `autoMode`
|
||||||
|
block (environment / soft_deny / hard_deny / allow, `$defaults`
|
||||||
|
semantics, `classifyAllShell`) + the "ask ≠ prompt under auto"
|
||||||
|
caveat that makes soft_deny the right tier
|
||||||
|
- [x] T4 `README.md` — magic-MCP paragraph claims the `ask` tier makes
|
||||||
|
every `mcp__magic__*` call "require a live confirmation and never
|
||||||
|
auto-execute"; false under auto mode per LRN-146. Correct the
|
||||||
|
claim, flag the soft_deny option to the user (don't decide it)
|
||||||
|
- [x] T5 `doctor.sh` — permissions section is blind to `autoMode`, now a
|
||||||
|
live security surface. Add a check: block present, `$defaults`
|
||||||
|
inherited, no foreign absolute project path hardcoded
|
||||||
|
- [x] T6a CHANGELOG (Added/Changed/Fixed under [Unreleased])
|
||||||
|
- [ ] T6b registries BDR-090 + LRN-153 + journal — drafted, awaiting user approval
|
||||||
|
- [x] T7 verify: `make test`, `bash doctor.sh`, `shellcheck`
|
||||||
|
NOT in scope: the 3 dirty `skills/graphify/*` files (pre-existing,
|
||||||
|
unrelated) — never staged.
|
||||||
|
|
||||||
|
### Second pass (2026-09-15, user decisions)
|
||||||
|
User confirmed the `ask` removals were deliberate (`/permissions`), asked
|
||||||
|
for the diff vs develop and for guards where the removals left a hole.
|
||||||
|
Answered: writes outside cwd → soft_deny; in-place edits beyond one named
|
||||||
|
file → soft_deny; inline interpreters + `xargs` → soft_deny when they
|
||||||
|
delete or write outside cwd; hard_deny for secret exfiltration, prod
|
||||||
|
deploy, disarming guardrails (history rewrite NOT retained, so a `rebase`
|
||||||
|
then an ordinary push stays uncovered); extend the static deny family to
|
||||||
|
the `.env` readers; `classifyAllShell` stays false; intent clears a soft
|
||||||
|
block for the CURRENT TURN only.
|
||||||
|
- [x] S1 `permissions.deny` +10 reader rules (sed awk cut tr sort uniq
|
||||||
|
diff od xxd strings vs `.env*`) — 6 of them were in `allow`
|
||||||
|
- [x] S2 `autoMode.soft_deny` — 7 rules + the intent-scope line
|
||||||
|
- [x] S3 `autoMode.hard_deny` — 3 rules, "adding a restriction is fine,
|
||||||
|
removing one is not"
|
||||||
|
- [x] S4 `SETTINGS.md` — tier-choice table + scope-of-intent section
|
||||||
|
- [x] S5 CHANGELOG — Changed rewritten, new Security block
|
||||||
|
- [ ] S6 CONSEQUENCE to confirm: the hard_deny guardrail rule means I can
|
||||||
|
no longer edit a deny/soft_deny/hard_deny list to REMOVE an entry.
|
||||||
|
Tightening stays allowed. Future permission loosening goes through
|
||||||
|
`/permissions` or the user's own edit.
|
||||||
|
|
||||||
|
### Follow-up found while doing this (not fixed, needs a decision)
|
||||||
|
`.claude/settings.local.json` (gitignored, 14.6 KB) is a near-complete
|
||||||
|
shadow copy of the global `settings.json` at a HIGHER precedence tier:
|
||||||
|
185 allow / 30 ask / 106 deny, plus its own `cleanupPeriodDays`,
|
||||||
|
`attribution`, `statusLine`, `enabledPlugins`, `extraKnownMarketplaces`,
|
||||||
|
`effortLevel`, `remoteControlAtStartup`, `inputNeededNotifEnabled`,
|
||||||
|
`skipAutoPermissionPrompt` — all identical to the global today, so the
|
||||||
|
duplication is invisible until the global drifts, which it just did
|
||||||
|
(no `autoMode`, 106 deny vs 116). It defeats the config-guard premise
|
||||||
|
(hand-curated `settings.json`) with a file nobody reviews.
|
||||||
|
- [ ] F1 `WebSearch` sits in global `ask` and in local `allow` — in this
|
||||||
|
repo it never reaches the ask tier. Intended or drift?
|
||||||
|
- [ ] F2 local `hooks` block registers `bash ~/.claude/hooks/config-protection.sh`
|
||||||
|
on PreToolUse/Bash. That script does not exist, in `hooks/` or in
|
||||||
|
`~/.claude/hooks/`. Dead hook firing on every Bash call here.
|
||||||
|
- [ ] F3 decide: prune the local file down to the session-accumulated
|
||||||
|
allow rules only, dropping every key that merely restates the
|
||||||
|
global, or keep the copy deliberately and document why.
|
||||||
|
|
||||||
## 2026-08-25 — darwin fresh baseline: 32 skill-systems + 23 agents (feature/darwin-optimize-20260825)
|
## 2026-08-25 — darwin fresh baseline: 32 skill-systems + 23 agents (feature/darwin-optimize-20260825)
|
||||||
User: `/darwin-skill all skills and agents` (background). Fresh-from-zero
|
User: `/darwin-skill all skills and agents` (background). Fresh-from-zero
|
||||||
(results.tsv wiped 2026-06-23, journal 2026-06-30). Scope per BDR-015/043 +
|
(results.tsv wiped 2026-06-23, journal 2026-06-30). Scope per BDR-015/043 +
|
||||||
|
|||||||
@@ -17,6 +17,43 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
lib (OS-support bump, submodule-update wrapper, cache report), sourced by
|
lib (OS-support bump, submodule-update wrapper, cache report), sourced by
|
||||||
`install-plugins.sh`, `update-all.sh` and `doctor.sh`, covered by
|
`install-plugins.sh`, `update-all.sh` and `doctor.sh`, covered by
|
||||||
`lib/tests/gstack-playwright.test.sh`.
|
`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
|
### Fixed
|
||||||
- **`make update` no longer drops the Playwright OS-support bump** — a
|
- **`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
|
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
|
ostag capture exited 1 on every non-Ubuntu host and aborted its caller
|
||||||
under inherited `errexit`, and the `bun` calls had no timeout.
|
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
|
## [1.5.0] — 2026-09-13
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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`
|
it**. The mitigation lives on our side: `settings.json`
|
||||||
`permissions.ask` explicitly lists all 4 `mcp__magic__*` tools,
|
`permissions.ask` explicitly lists all 4 `mcp__magic__*` tools.
|
||||||
so every call — builder included — requires a live confirmation and can
|
Read that as a declared intent, not a proven hard gate: under
|
||||||
never auto-execute. Don't allowlist
|
`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
|
`21st_magic_component_builder` or `21st_magic_component_refiner` (arbitrary
|
||||||
absolute-path read → vendor exfil, same audit) under any circumstance.
|
absolute-path read → vendor exfil, same audit) under any circumstance.
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,61 @@ echo ""
|
|||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
# 5. Permissions check
|
# 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 ──"
|
echo "── Permissions ──"
|
||||||
|
|
||||||
SETTINGS="$HOME/.claude/settings.json"
|
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"
|
warn "Deny rules: $DENY_COUNT (committed: $EXPECTED_DENY) — live settings diverge from last commit"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
check_automode
|
||||||
else
|
else
|
||||||
fail "$HOME/.claude/settings.json not found"
|
fail "$HOME/.claude/settings.json not found"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+46
-11
@@ -110,12 +110,8 @@
|
|||||||
"Bash(chmod -R 777 *)",
|
"Bash(chmod -R 777 *)",
|
||||||
"Bash(ssh *)",
|
"Bash(ssh *)",
|
||||||
"Bash(scp *)",
|
"Bash(scp *)",
|
||||||
"Bash(rsync *)",
|
|
||||||
"Bash(nc *)",
|
"Bash(nc *)",
|
||||||
"Bash(netcat *)",
|
"Bash(netcat *)",
|
||||||
"Bash(kill -9 *)",
|
|
||||||
"Bash(killall *)",
|
|
||||||
"Bash(pkill *)",
|
|
||||||
"Bash(crontab *)",
|
"Bash(crontab *)",
|
||||||
"Bash(systemctl *)",
|
"Bash(systemctl *)",
|
||||||
"Bash(service *)",
|
"Bash(service *)",
|
||||||
@@ -182,6 +178,16 @@
|
|||||||
"Bash(more .env.*)",
|
"Bash(more .env.*)",
|
||||||
"Bash(grep * .env)",
|
"Bash(grep * .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(env)",
|
||||||
"Bash(printenv)",
|
"Bash(printenv)",
|
||||||
"Bash(printenv *)",
|
"Bash(printenv *)",
|
||||||
@@ -219,8 +225,6 @@
|
|||||||
"Bash(wget * | sh)",
|
"Bash(wget * | sh)",
|
||||||
"Bash(mkfifo *)",
|
"Bash(mkfifo *)",
|
||||||
"Bash(node -e *)",
|
"Bash(node -e *)",
|
||||||
"Bash(python3 -c *)",
|
|
||||||
"Bash(python -c *)",
|
|
||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(git push)",
|
"Bash(git push)",
|
||||||
"Bash(docker run *)",
|
"Bash(docker run *)",
|
||||||
@@ -234,10 +238,6 @@
|
|||||||
"Bash(pacman -S *)",
|
"Bash(pacman -S *)",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"WebFetch",
|
"WebFetch",
|
||||||
"Bash(xargs *)",
|
|
||||||
"Bash(sed *)",
|
|
||||||
"Bash(cp *)",
|
|
||||||
"Bash(mv *)",
|
|
||||||
"Bash(git stash pop*)",
|
"Bash(git stash pop*)",
|
||||||
"Bash(git stash drop*)",
|
"Bash(git stash drop*)",
|
||||||
"Bash(git stash clear)",
|
"Bash(git stash clear)",
|
||||||
@@ -358,5 +358,40 @@
|
|||||||
"effortLevel": "xhigh",
|
"effortLevel": "xhigh",
|
||||||
"remoteControlAtStartup": true,
|
"remoteControlAtStartup": true,
|
||||||
"inputNeededNotifEnabled": 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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
|
## Security notes
|
||||||
|
|
||||||
- `Read(**/.env)` only blocks the Read tool. `Bash(cat .env)` bypasses it unless separately denied.
|
- `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.
|
- `disableBypassPermissionsMode: "disable"` prevents switching to bypass mode mid-session.
|
||||||
- Prefer `ask` over `allow` for anything touching external systems.
|
- Prefer `ask` over `allow` for anything touching external systems.
|
||||||
- `deny` in `~/.claude/settings.json` cannot be overridden by project-level `allow` — deny always wins.
|
- `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)
|
## managed-settings.json (enterprise)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user