diff --git a/.claude/settings.json b/.claude/settings.json index a27dda6..365790b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,9 @@ "env": { "CAVEMAN_DEFAULT_MODE": "off" }, + "enabledPlugins": { + "caveman@caveman": false + }, "permissions": { "allow": [ "Bash(git status*)", "Bash(git diff*)", "Bash(git log*)", "Bash(git pull*)", @@ -57,6 +60,7 @@ "Bash(juju destroy-controller *)", "Bash(maas list*)", "Bash(git push --force*)", "Bash(git push -f*)", + "Bash(*python3 -m scripts*)", "Bash(*caveman-compress*)", "Read(~/vault-init/**)", "Read(~/as-executed/**)", "Read(~/tenant-*/**)", "Read(**/*-cred.txt)", "Read(**/*appcred*)", "Edit(~/vault-init/**)" diff --git a/CLAUDE.md b/CLAUDE.md index 390ad60..84c58c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,19 @@ operations (init/rekey/generate-root) are operator-only, verbatim from the runbook -- the guard hook hard-blocks them. +## Tooling guardrails (enforced by permission rules + repo-lint; also a norm) + +Never run `/caveman-compress` -- or its underlying `python3 -m scripts ` +compressor -- against ANY repo-tracked file. It overwrites the target IN PLACE +with a lossy caveman-speak rewrite and drops a `.original.md` backup, which +is incompatible with this repo's verbatim-record discipline (GA-R1 exact +captures, GA-R5 quoted operator utterances, the append-only D-NNN log). The +prose-record corpus is the whole hazard surface, not a short list: CLAUDE.md, +`docs/CURRENT-STATE.md`, `docs/design-decisions.md`, `docs/security-ledger.md`, +`docs/audit/*`, the runbooks, the skill, and every changelog. Layered +enforcement: the caveman plugin is DISABLED (SEC-017); a permission `deny` blocks +the compressor's invocation; repo-lint L11 FAILS on any `*.original.md` residue. + ## Where things are - Deploy gate: `bash scripts/preflight.sh` (the ONLY pre-deploy entry). diff --git a/docs/changelog-20260724-caveman-disable.md b/docs/changelog-20260724-caveman-disable.md new file mode 100644 index 0000000..2ccdb99 --- /dev/null +++ b/docs/changelog-20260724-caveman-disable.md @@ -0,0 +1,102 @@ +# Changelog -- 2026-07-24 -- caveman plugin: disable on live host + governance + guardrails + +Session scope: acting on a 4-seat council review of the third-party `caveman` +Claude Code plugin (installed + off-by-default-guarded 2026-07-23, commit +338c919). Operator accepted the review recommendations (exact utterance: "I'll +accept your recommendations"). Harness-config + repo-governance only -- NO cloud +mutation, NO runbook change. CURRENT-STATE deliberately untouched (adding a SEC +row is not an L10 trigger; the G14 open-SEC count reconciles at Stage 4 close -- +see item 2). Council finding that drove the disable: `enabled:true` runs +third-party `node` on every session start + every prompt REGARDLESS of +`CAVEMAN_DEFAULT_MODE=off` (the env guard gates arming/terse-output, not hook +execution), and the plugin's own honest docs put net token savings at +break-even-to-negative on this host's gated-approval workload -- so a standing +per-turn execution surface on a credential-bearing jumphost was not worth +carrying. + +## Item 1 -- caveman DISABLED on the live host (enabled:false, both scopes) + +- `~/.claude/settings.json` (user scope, authoritative for vcloud): + `enabledPlugins."caveman@caveman"` flipped `true` -> `false`. Stops the + SessionStart + UserPromptSubmit hooks from wiring at the next session start. +- `/home/jessea123/openstack-caracal-dc-dc/.claude/settings.json` (committed + repo scope): added `enabledPlugins {"caveman@caveman": false}`. Project scope + outranks user scope (Local > Project > User), so this is a fail-closed, + travels-with-the-clone off-switch across the operator's workstations -- + mirrors the dual placement used for the env guard. +- `CAVEMAN_DEFAULT_MODE=off` retained in both files as a residual layer; the + `extraKnownMarketplaces.caveman` entry RETAINED (plugin stays installed) so a + deliberate re-enable for hardened-workflow deployment testing remains cheap. +- Verified: both settings.json parse (`jq`); user + repo + `enabledPlugins."caveman@caveman"` = `false`; no `~/.claude/.caveman-active` + flag (not armed). repo-lint 0 fail / 1 legacy warn (unchanged). +- **Revert:** set both `enabledPlugins."caveman@caveman"` back to `true` + (repo copy via `git revert`/edit; user copy by hand). Note: re-enabling + re-incurs the SEC-017 re-verify duty. + +## Item 2 -- SEC-017 opened (supply-chain re-verify obligation) + +- Added row **SEC-017** to `docs/security-ledger.md` (next-free by grep; + highest prior = SEC-016). Records the standing third-party per-turn + code-execution surface, its point-in-time benign verdict at SHA `0d95a81`, + the not-declaratively-pinnable caveat, the disable disposition, and the + STANDING DUTY to re-verify the hooks on any re-enable or plugin update. +- Register call = OPS (no D-number): fails GA-R3's Roosevelt-delta / A1 grep + test (no bare-metal analog of a jumphost output-style plugin); precedent + D-129 (OPS plugin-install stays OPS). The SEC row is the durable home the + security-ledger mandate requires ("a row at discovery, never only a note") -- + the prior changelog (338c919) is GA-R2 non-citable/archive-bound, so the + posture had no durable home before this. +- Count: open SEC rows 12 -> 13. This is NOT an L10 status-couple trigger + (L10 fires on docs/audit/*, design-decisions Status lines, and session-ledger + gate rows -- not security-ledger.md), so CURRENT-STATE is untouched; the G14 + count reconciles at Stage 4 close per its existing deferral pattern. +- **Self-measured drift baseline (sha256, 2026-07-24; executing cache copy == + git clone at `0d95a81`, only a `.in_use` runtime marker differing):** + - `src/hooks/caveman-activate.js` `bc0af3ba327657630e5f6c60be619e6b5394bf03edc29b1384a15f09a1b348bf` + - `src/hooks/caveman-mode-tracker.js` `3e34f004d4a9e65609f8c95b47dfe5f0e3a6f2d16b88c7f8af730187ee2fb246` + - `src/hooks/caveman-config.js` `f47fe2e6440578eeb20408bc3131b63fb8e1ae4f6ea26dbdcca33e7901e5ee8c` + - `src/hooks/caveman-stats.js` `433a345a279c2d54be51864ef77c0a7b19d0f8604b9a441ce0a1eded31f113e8` + - `src/hooks/cavecrew-model-overrides.js` `9f2601e8551653609f0b9d700c9bd75bffa43747d2db18ffe83fb73de5dc3607` + - `.claude-plugin/plugin.json` `b76999bd244fa59c2b8805b1c2b573458e2d065b58e5904fc8e9e545e3dac7a6` +- **Revert:** delete the SEC-017 row from `docs/security-ledger.md` + (`git revert ` for the commit). + +## Item 3 -- guardrails: repo-lint L11 + fail-closed compress prohibition + +Council-reviewed guardrail set. (a) a repo-lint hash-baseline of the plugin +files was REJECTED (layering violation -- the plugin lives outside the repo +under `~/.claude`, non-portable across workstations, and duplicates the +plugin's own `checksums.sha256`; drift-on-update belongs in the SEC-017 +re-verify duty, not repo-lint). Built (b) and (c): + +- **(b) repo-lint L11 -- caveman-compress residue detector.** `scripts/repo_lint.py` + gains L11: FAIL on any `*.original.md` in the tree (the backup signature of a + lossy in-place `/caveman-compress` rewrite). Excludes `.git`, `.claude` + worktrees, `.terraform`, `__pycache__` (reuses the `all_text()` skip set); + opt-out marker `repo-lint: allow-original-md` per the L9 convention. Docstring + catalog + `repo-lint.sh` header updated L1-L10 -> L1-L11. Harness + `tests/repo-lint/run-tests.sh` gains T42 (residue FAILS), T43 (opt-out + suppresses), T44 (worktree excluded): **45/45 PASS** (was 42). It is a + POST-HOC detector (the original is already overwritten when the backup + exists) -- it complements, not replaces, (c). +- **(c) fail-closed compress prohibition.** Mechanism measured read-only: + `/caveman-compress ` is a SKILL that mutates by running + `python3 -m scripts ` from the skill dir (in-place overwrite + + `.original.md` backup). Two layers added: (1) `.claude/settings.json` + `deny` rules `Bash(*python3 -m scripts*)` + `Bash(*caveman-compress*)` -- + verified no legit repo usage collides (the strings appear only inside + `repo_lint.py`'s L11 text, never in an executed argv); (2) a CLAUDE.md + "Tooling guardrails" section prohibiting the compressor against ANY + repo-tracked prose file, scoped to the whole record corpus (not a 4-file + hand-list, which would rot). Note the pre-existing `Skill(*)` ask rule already + gates the skill invocation itself; the deny is the fail-closed backstop that + does not depend on that routing. +- Net enforcement stack: plugin DISABLED (item 1) is primary; `Skill(*)` ask + + the compress `deny` gate invocation; L11 catches residue; CLAUDE.md is the + human-readable rationale. +- Verification: `jq` valid; all touched files ASCII/LF; repo-lint 0 fail / 1 + legacy warn; `bash scripts/run-tests-all.sh` -> **GAUNTLET: ALL GREEN + (77 harnesses)**. +- **Revert:** `git revert ` -- removes L11 (+ its 3 harness cases), the two + `deny` rules, and the CLAUDE.md section together. diff --git a/docs/security-ledger.md b/docs/security-ledger.md index ffa9473..bdd9766 100644 --- a/docs/security-ledger.md +++ b/docs/security-ledger.md @@ -27,6 +27,8 @@ | SEC-016 | 2026-07-23 | **Dedicated dc1 MAAS->libvirt power key (per-DC isolation -- operator-ruled).** dc1 commissioning needs `power_type=virsh`, which has the REGION's MAAS snap SSH to the dc1 rack's libvirt to power-cycle nodes. RULING (GA-R5) -- question presented: "How should the region drive the dc1 rack's libvirt for node power control (power_type=virsh)?" options (A) reuse the SEC-012 key on dc1, (B) mint a dedicated dc1 power key. Operator answer, exact utterance (option selected): **"Mint a dedicated dc1 power key"** -- a SEPARATE per-DC MAAS->libvirt keypair, NOT cross-DC reuse of SEC-012's key. Mechanism: private half in the region MAAS snap (`/var/snap/maas/current/root/.ssh/` + per-host `ssh config` Host 172.31.0.6 -> that IdentityFile, so 172.31.0.2/dc0 keeps SEC-012 and 172.31.0.6/dc1 uses this one); public half authorizes jessea123 (libvirt group) on the dc1 rack. The maas-node-power SCRIPT's own mapping virsh (jessea123 on voffice1) uses the dc1 SERVICE key via voffice1 `ssh config` -- the dc0 split exactly (script=svc key, MAAS=dedicated key). | 2026-07-23 session changelog; docs/changelog-20260723-g12-dc1-edge.md; dc0 precedent SEC-012 | operator | **OPEN -- rotation obligation.** (1) ROTATE at v1 close or if the region/dc1 rack is rebuilt/shared. (2) Same libvirt-group SCOPE hardening candidate as SEC-012 (power-only polkit grant). (3) FRAGILITY: the snap-side key + ssh config live under per-revision `/var/snap/maas/current/` and may not survive a snap refresh -- re-assert after any refresh (queued to DC standup DoD). Roosevelt-relevance: establishes PER-DC power-credential isolation (bare metal: per-DC IPMI/BMC creds, never a shared cross-DC power credential) -- whether that becomes a standing D-principle is a follow-up, not blocking. | +| SEC-017 | 2026-07-24 | **Third-party Claude Code plugin `caveman@caveman` (JuliusBrussee/caveman) installed at USER scope on vcloud -- a standing per-session/per-prompt third-party code-execution surface on a credential-bearing jumphost.** SessionStart + UserPromptSubmit hooks run `node` on every session start and every prompt while `enabledPlugins` is true, INDEPENDENT of the `CAVEMAN_DEFAULT_MODE` guard (the env var gates terse-output ARMING, NOT hook EXECUTION). Inspected benign at SHA `0d95a81` by a 4-seat council audit + operator self-remeasure 2026-07-24: no network egress, no shell, one gated subprocess (`/caveman-stats` only), symlink-hardened flag I/O, zero PATH contribution/shadowing. This verdict is POINT-IN-TIME: `plugin.json` carries no `version` field (every git commit is a new "version"), the marketplace source tracks the default branch with no tag/SHA ref -> the code is NOT declaratively pinnable; third-party-marketplace auto-update is OFF by default (Claude Code 2.1.218) but flippable via the `/plugin` UI or managed settings. Baseline: git clone HEAD `0d95a81d35a9f2d123a5e9430d1cfc43d55f1bb0` (tree clean), executing cache copy == clone (only a `.in_use` runtime marker differs); 6 audited files' full sha256 recorded in the 2026-07-24 changelog. | docs/changelog-20260723-caveman-optin-guard.md (commit 338c919); docs/changelog-20260724-caveman-disable.md; this session's council audit | operator | **OPEN -- supply-chain re-verify obligation (NOT a credential rotation).** DISABLED on the live host 2026-07-24: `enabledPlugins."caveman@caveman"=false` at BOTH user (`~/.claude/settings.json`) and committed repo (`.claude/settings.json`, higher precedence) scope, `CAVEMAN_DEFAULT_MODE=off` retained -- this STOPS the per-turn hook execution from the next session start. Marketplace entry RETAINED so a deliberate re-enable for hardened-workflow deployment TESTING stays possible. STANDING DUTY: on ANY re-enable OR plugin update, RE-VERIFY the hooks against the baseline (confirm the clone is at `0d95a81`; re-hash + `diff` the cache copy vs the marketplace clone) BEFORE trusting -- the benign verdict does not survive a version bump. Keep-available-vs-uninstall decision DEFERRED (NOT yet ruled). Provenance for the disable: operator accepted the council-review recommendations this session (exact utterance: "I'll accept your recommendations"). Register note: OPS adoption, no D-number (GA-R3 -- no Roosevelt-delta; precedent D-129); this row is the durable home the ledger mandate requires ("row at discovery, never only a note"). Count note: this row takes open SEC rows 12 -> 13; CURRENT-STATE G14 reconciles the count at Stage 4 close per its existing deferral pattern. | + **STANDING CONVENTION (SEC-009, 2026-07-15): per-site credential/env consolidation.** ALL sensitive files AND environment/config files for a site live in a single `~/-creds/` folder on vcloud, mode 0700, files 0600 (public keys 0644). No loose env files in `~`. Sites: `~/vr1-office1-creds/` diff --git a/scripts/repo-lint.sh b/scripts/repo-lint.sh index d467437..7521ecf 100644 --- a/scripts/repo-lint.sh +++ b/scripts/repo-lint.sh @@ -5,7 +5,7 @@ # Mutates NOTHING -- except under --record-clientdocs-sweep (DOCFIX-104, rewrites # ONLY clientdocs/sweep-receipt.txt) or --record-guide-skill-coupling (rewrites # ONLY clientdocs/guide-skill-coupling.txt, the L8 pin); both then lint as usual. -# See the .py docstring for the check catalog (L1-L10). +# See the .py docstring for the check catalog (L1-L11). # Exit: 0 clean | 1 FAIL findings | 2 warnings only. ASCII + LF. set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/repo_lint.py b/scripts/repo_lint.py index 1f31e2a..b36e812 100644 --- a/scripts/repo_lint.py +++ b/scripts/repo_lint.py @@ -71,6 +71,15 @@ Severity: WARN during sweep Batch 0, then FAIL -- flip L10_FAIL (phase5-sweep-plan Batch 0: "WARN for one batch"). + L11 caveman-resid caveman-compress residue (SEC-017): /caveman-compress + overwrites a prose file IN PLACE and drops a + .original.md backup beside it -- the signature of a + lossy in-place rewrite having run against a repo-tracked + file, incompatible with the verbatim-record discipline + (GA-R1/R5). FAILS on any *.original.md in the tree + (.git / .claude worktrees / .terraform / __pycache__ + excluded). Opt out with 'repo-lint: allow-original-md' in + the file for a genuine, reviewed exception. Exit: 0 clean | 1 FAIL findings | 2 warnings only. ASCII + LF. Usage: python3 scripts/repo_lint.py [--record-clientdocs-sweep] [--record-guide-skill-coupling] [repo-root] @@ -438,6 +447,28 @@ "references/kubernetes.md + troubleshooting.md for the SAME " "update, then re-record: %s" % COUPLING_CMD) + # ---- L11 caveman-compress residue (SEC-017) ---- + # /caveman-compress rewrites a prose file IN PLACE and drops a + # .original.md backup beside it. That backup is the residue signature + # of a lossy in-place rewrite having run against a repo-tracked file -- + # incompatible with this repo's verbatim-record discipline (GA-R1/R5). FAIL + # on any *.original.md in the tree; reuse the all_text() skip set. Escape + # hatch: a file carrying a `repo-lint: allow-original-md` marker line is a + # reviewed exception (L9 opt-out convention). + for p in R.rglob("*.original.md"): + if not p.is_file() or ".git" in p.parts or "__pycache__" in p.parts \ + or ".terraform" in p.parts \ + or (".claude" in p.parts and "worktrees" in p.parts): + continue + if "repo-lint: allow-original-md" in p.read_text(errors="replace"): + continue + rel = p.relative_to(R).as_posix() + fails.append("L11 %s: caveman-compress residue (a *.original.md backup = " + "a lossy in-place rewrite ran against a repo-tracked file). " + "Restore the original and delete the backup; /caveman-compress " + "must NEVER run against repo-tracked prose (CLAUDE.md; SEC-017)." + % rel) + for w in warns: print(" [WARN] %s" % w) for f in fails: diff --git a/tests/repo-lint/run-tests.sh b/tests/repo-lint/run-tests.sh index 88440a9..dd738c0 100644 --- a/tests/repo-lint/run-tests.sh +++ b/tests/repo-lint/run-tests.sh @@ -193,5 +193,17 @@ run 1 'L10 pending change set.*docs/audit/' \ "T41 L10 untracked new capture file FAILS" "$D" +# ---- L11 caveman-compress residue (SEC-017) ---- +D=$(mkrepo l11a); printf '# stale in-place copy\n' > "$D/docs/CLAUDE.original.md" + run 1 'L11 docs/CLAUDE.original.md.*caveman-compress residue' \ + "T42 L11 *.original.md residue FAILS" "$D" +D=$(mkrepo l11b); printf '# reviewed exception -- repo-lint: allow-original-md\n' > "$D/docs/keep.original.md" + run 0 'PASS: repo lint' \ + "T43 L11 opt-out marker suppresses residue FAIL" "$D" +D=$(mkrepo l11c); mkdir -p "$D/.claude/worktrees/agent-y/docs" +printf '# worktree copy\n' > "$D/.claude/worktrees/agent-y/docs/x.original.md" + run 0 'PASS: repo lint' \ + "T44 L11 excludes .claude worktrees" "$D" + echo; echo "RESULT: PASS=$PASS FAIL=$FAIL" [[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1