fix(claude-config): bind partial appends to the writer's epoch, validate records, pin the write tree - #2446
Conversation
…ate records, pin the write tree Three review findings against run-state.sh, which shipped in 0.38.0 (#2441) before the fixes were pushed. All one family — a control that does not enforce what its surface claims: - `partial append` read `owner_epoch` from the lease at append time, so a stale holder waking after an adopter incremented it appended into the ADOPTER's file, defeating the isolation §3 describes and interleaving two writers under one attempt ordinal — by §3's own account the one failure the attempt machinery cannot absorb. `--epoch <held>` now names the writer's own file whatever the lease says, and reports FENCED when the two differ. - The record check accepted any string starting with `{`, so `{bad json}` landed permanently in an append-only artifact whose only readers are resume and assembly. Now validated: jq where installed, otherwise a scan tracking string context and escapes. jq stays optional — failing the state path closed on a missing tool would cost the artifact the check protects. - `lease acquire` created whatever `--run-dir` it was handed and wrote a lease into it, so a wrong or invented run dir (the target root, say) broke the skill's promise that a bare audit writes nothing into the target. It now requires `--plugin-data` and refuses any run dir outside <plugin-data>/runs/. Each ships an assertion; the no-jq rung runs with a PATH holding only bash, and the containment test asserts the refused directory is not created. 73 checks. Refs #2280 (F3, F5) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 4m 54s —— View job Claude's Code ReviewTasks
SummaryReviewed the three fixes against Two of the three fixes read as correct on inspection:
Fix 1 (epoch binding) has one real gap — flagged inline on Everything else — version bump, CHANGELOG entry, doc updates, and test additions — is consistent and proportionate to the changes. |
|
Claude finished @kyle-sexton's task in 4m 4s —— View job Security review complete
Scope: only the 6 files changed in this PR (no GitHub Actions workflows are touched, so no zizmor-lane items apply here). Findings1. IMPORTANT / Confidence: High — the no-
When
None of these are well-formed JSON ( Impact: on any host without Suggested fix: either (a) tighten the fallback to also validate that each Other areas reviewed, no issues found
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8fb7583ffd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…esolve symlinks
Four review findings on the hardening itself, all the same class it was fixing:
- `partial append` printed "FENCED ... this run must abort" and returned 0, so
the abort depended on the caller spotting a substring on stderr — a control
announcing a state it never establishes. A fenced append now exits 3; the
record is still written to the writer's own epoch file, and SKILL.md Phase 3
says what to do with the code.
- The no-jq rung accepted `{"a" garbage}`: it balances and opens with a quoted
key, and no non-parser catches that. python3 is now the second definitive rung,
and where neither parser exists the structural scan announces on stderr that it
checked structurally only, rather than passing for a validator.
- Containment was a lexical prefix test, which is not containment while symlinks
exist: runs/link -> /elsewhere passed the string comparison and the write then
followed the link out of the tree. Both sides are canonicalized with `pwd -P`
(readlink -f is GNU-only), --run-dir through its deepest existing ancestor.
- report-location-and-schema.md §7 still described `partial append` as taking the
epoch from the lease, so an audit following that page after an adoption would
have omitted --epoch and reinstated the interleaving. Both surfaces agree now.
79 checks. The symlink arm skips on Windows (ln -s refused) and runs on CI.
Refs #2280 (F3, F5)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 5m 37s —— View job Claude's Code ReviewTasks
SummaryThis PR was already reviewed once at commit One new gap surfaced in the fix that closes the symlink issue — flagged inline on A second, minor finding (nit, inline on line 725): the new Everything else — the version bump, the additive I did not execute |
|
Claude finished @kyle-sexton's task in 2m 56s —— View job Security review complete
Scope: the 7 files changed in this PR (no workflow files touched, so zizmor's supply-chain / trigger / permissions lane doesn't apply here). This is the third commit on the branch, and it specifically responds to the two automated review passes already on this PR (Codex's P1 finding and Claude's own prior "FENCED is advisory only" finding). I re-verified all three against the current head ( 1. FENCED is now actually enforced, not just announced — confirmed fixed. 2. No- 3. Write-tree containment now survives symlinks — confirmed fixed. New finding (SUGGESTION, low confidence) — posted inline on Other areas reviewed, no issues found:
No CRITICAL or IMPORTANT severity findings. The three defects this PR set out to fix, and the two gaps flagged by the prior automated review round on this same PR, all check out as genuinely closed in the current head. |
…t exist yet Review of the containment fix caught it breaking the one run no fixture in the file represents. `pwd -P` cannot resolve a directory that is not there, so canonicalizing --plugin-data before creating it made `lease acquire` fail on a plugin's VERY FIRST run — the only run whose data root has never existed. Every test pre-makes that directory, so none could see it. Reproduced against the previous commit: run-state.sh: --plugin-data cannot be resolved: /tmp/.../fresh prev rc=2 fixed rc=0 The plugin's own data root is now created before it is resolved — that is inside this script's mandate and is not a target write — and a test exercises the fresh-install path. Two smaller review points folded in: the containment guard's check-then-act window is recorded in the code as a disclosed residual instead of left implied, and require_int_at_least_one takes its reason as an argument rather than always printing the staleness-specific one at an --epoch rejection. Refs #2280 (F3, F5) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 16s —— View job Claude's Code ReviewTasks
SummaryThis PR has already been through two prior automated review rounds on this same branch (at I traced the fix against the code and it closes the gap correctly:
No new findings. I did not have an approved shell in this session to execute |
|
Claude finished @kyle-sexton's task in 1m 26s —— View job Security review complete
Scope: this PR's changed files only ( This PR has already had two full security-review rounds at Order of operations is still safe. No new escape via The disclosed check-then-act residual (plugin_data resolution → The No new CRITICAL, IMPORTANT, or SUGGESTION security findings in this commit. All three original defects, plus the gaps raised across the two prior review rounds on this PR, check out as genuinely closed at current HEAD ( |
…ate records, pin the write tree (#2446) ## Summary **`claude-config` 0.38.0 is on `main` carrying three defects in the script it shipped.** They were found by review on #2441; that PR merged at 13:19:55Z while the fixes were still being written, so they ship here as 0.38.1. Anyone on 0.38.0 has all three. All three are one family, and it is the family this batch keeps finding: **a control that does not enforce what its surface claims.** **1. The partial was named for the lease's *current* epoch, not the writer's.** `partial append` read `owner_epoch` out of the lease at append time. That defeats precisely the isolation `run-state-and-resumability.md` §3 describes: a stale holder that wakes after an adopter has incremented the epoch reads the *adopter's* value and appends into the *adopter's* file, so two writers interleave under one attempt ordinal — which §3 itself calls "the one failure the attempt machinery cannot absorb". The script was contradicting the paragraph it was written to enforce. `partial append --epoch <held>` now names the writer's own file whatever the lease says, and prints `FENCED` on stderr when the two differ so the run aborts on the signal rather than corrupting the artifact quietly. Omitting the flag still falls back to the lease's epoch, correct only for a run whose epoch nothing has moved — and that is now stated rather than assumed. **2. `{bad json}` was accepted and appended permanently.** The check was `case "$record" in '{'*)`, which passes any string starting with a brace. A malformed row in an append-only artifact cannot be taken back, and `--resume` and assembly are its only readers, so a quoting slip in the caller cost the run's whole persisted state rather than one record. Records are now verified as well-formed single-line JSON objects. `jq` decides where it is installed; where it is not, a scan that tracks string context and escape sequences still rejects `{bad json}`, a truncated row, and an unbalanced one. **`jq` is deliberately not a hard requirement** — this is the run's state-persistence path, and failing it closed on a missing optional tool would cost the artifact the check exists to protect. Both rungs are asserted; the fallback runs with a `PATH` holding only `bash`. **3. `lease acquire` created and wrote into any `--run-dir` it was handed.** A wrong or invented run directory — the target root, say — was created and had a `lease` written into it. This skill keeps Bash specifically for state writes *while promising that a bare audit writes nothing into the target*, so that promise was enforceable only by the caller getting the argument right. `acquire` is the only command that creates a directory, so it is where the write tree is pinned: it now requires `--plugin-data` and refuses any run directory outside `<plugin-data>/runs/`. Every later command operates on a directory `acquire` already validated. Version: `claude-config` **0.38.0 to 0.38.1**. The shipped `## [0.38.0]` section is untouched — 0.38.1 is additive and says outright that 0.38.0 carries all three. ## Test plan **Fail-before / pass-after, per finding.** Each was reproduced against the merged 0.38.0 script before the fix, and each ships an assertion that fails without it. Verbatim transcript. `v0380.sh` is `git show origin/main:.../run-state.sh` — the script as merged. The lease is moved to `owner_epoch=9` as an adopter would leave it, while the writer still holds 3. ``` --- 1. epoch binding, 0.38.0 (writer holds 3, lease says 9) --- /tmp/tmp.RpfHhnqzmO/data/runs/demo/r1/findings.partial.9.jsonl rc=0 --- 2. malformed record, 0.38.0 --- /tmp/tmp.RpfHhnqzmO/data/runs/demo/r1/findings.partial.9.jsonl rc=0 --- 3. write-tree containment, 0.38.0 --- /tmp/tmp.RpfHhnqzmO/target-repo/.claude/lease rc=0 lease ^ the lease was written into a non-plugin tree === now the fixed script === --- 1. fixed --- run-state.sh: FENCED — lease owner_epoch is 9 but this writer holds 3; appending to the writer's own epoch file and this run must abort /tmp/tmp.RpfHhnqzmO/data/runs/demo/r1/findings.partial.3.jsonl rc=0 --- 2. fixed --- run-state.sh: --record must be a well-formed single-line JSON object: {bad json} rc=2 --- 3. fixed --- run-state.sh: --run-dir is not under $plugin-data/runs/ — refusing to write outside the plugin's own tree: /tmp/tmp.RpfHhnqzmO/target-repo2/.claude rc=2 ``` Read line 2 against line 12: on 0.38.0 the record the writer holding epoch 3 appended landed in `findings.partial.**9**.jsonl` — the adopter's file. On the fix it lands in `.3.` and the fence is announced. Line 4 is `{bad json}` accepted and appended at `rc=0`; line 7 is a lease written into `target-repo/.claude`, a directory the script created outside the plugin's tree. **Suite — 73 checks, up from 57:** ``` $ bash plugins/claude-config/skills/audit-pass/scripts/run-state.test.sh PASS: a run dir outside <plugin-data>/runs/ is refused PASS: the refusal says it will not write outside the plugin tree PASS: the refused run dir was not created PASS: a run dir containing '..' is refused PASS: acquire without --plugin-data has nothing to check containment against, and refuses PASS: a balanced-but-malformed record is refused, not appended PASS: a truncated record is refused PASS: a brace inside a string does not fool the check PASS: and the malformed records left the artifact untouched PASS: without jq, a balanced-but-malformed record is still refused PASS: without jq, a truncated record is still refused PASS: without jq, a brace inside a string does not fool the fallback PASS: without jq, an empty object is accepted PASS: a fenced writer appends to its own epoch file, not the adopter's PASS: and the fence is reported rather than swallowed PASS: omitting --epoch still falls back to the lease's current epoch ... All 73 checks passed. ``` The three mutation-based negative tests from 0.38.0 still pass unchanged — each deletes exactly one check from a copy of the script and asserts the mutated copy reaches the outcome the real one refuses. **Repo gates, locally:** ``` $ shellcheck --rcfile .shellcheckrc -S info plugins/claude-config/skills/audit-pass/scripts/*.sh (clean) $ typos --config _typos.toml plugins/claude-config/ (clean) $ bash scripts/check-changed-skills.sh origin/main CHECK-SKILL audit-pass: PASS — 0 errors, 2 warning(s) $ bash scripts/check-changelog-parity.sh --check-preserved origin/main All 1 changed changelog(s) preserve every version heading they carried at e4501a0 (68 compared). $ bash scripts/check-changelog-parity.sh --check-bump origin/main Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry. $ npx markdownlint-cli2 "plugins/claude-config/skills/audit-pass/**/*.md" "plugins/claude-config/CHANGELOG.md" Summary: 0 issues in 0 files ``` Both `.sh` files carry mode `100755` — the `exec-bit` sub-check of `hygiene` fails on `100644`, and it is invisible to every local linter. ## Review rounds on this PR, and what they changed Seven further findings landed after the first push. All are fixed here, each with an assertion, and every one is the same class again — a control not enforcing what its surface claims. Recorded because three of them were in *my own fix for that class*. - **🔴 `FENCED` was advisory only.** The script printed "this run must abort" and returned 0, so the abort depended on the caller spotting a substring on stderr. A fenced append now **exits 3**; the record still lands in the writer's own epoch file, and `SKILL.md` Phase 3 says what to do with the code. The test asserts the exit code, not just the message. - **🔴 The symlink fix broke the very first run.** `pwd -P` cannot resolve a directory that is not there, so canonicalizing `--plugin-data` before creating it made `lease acquire` fail on a plugin's first-ever run — the only run whose data root has never existed. **Every test in the file pre-makes that directory, so none could see it.** Reproduced against the prior commit (`--plugin-data cannot be resolved`, `rc=2`), fixed, and a test now exercises the fresh-install path. - **P1: the no-parser rung over-claimed.** `{"a" garbage}` balances and opens with a quoted key, and no non-parser catches it. `python3` is now the second definitive rung after `jq`, and where neither exists the structural scan **announces on stderr that it checked structurally only** rather than passing for a validator. - **P2: containment was lexical, so a symlink defeated it.** Both sides are canonicalized with `pwd -P` — `--run-dir` through its deepest existing ancestor. Tests assert the escape is refused, that no lease was written through the link, and that a symlink resolving back inside still works. The arm skips on Windows (`ln -s` refused) and **passes on CI**. - **P2: `report-location-and-schema.md` §7 still described the old append contract**, so an audit following that page after an adoption would have omitted `--epoch` and reinstated the interleaving. Both surfaces now carry the same command. - **🟡 check-then-act window** between symlink resolution and `mkdir`: accepted and now recorded in the code as a disclosed residual. Closing it needs an atomic create-and-verify no portable shell offers, and the attacker would already need write access inside the plugin's own data directory. - **🟡 a shared validator printed a staleness-specific reason at an `--epoch` rejection.** The reason is now an argument. Suite: **81 checks**, all passing. All six bot threads are replied to and resolved; the one open thread is my own do-not-merge marker. ## Related Refs #2280 No linked issue closes here. #2280 was closed by #2441's merge and its four live rows (F3, F5, F12, F13) are genuinely retired by the mechanism that shipped; this PR repairs three defects *in that mechanism* rather than reopening a row, so it deliberately carries no closing keyword. Follow-up to #2441 (`claude-config` 0.38.0). Origin: handoff-inbox item `20260811-020411-claude-config-audit-pass-report-path-inside-scan-set`. **Note for the reviewer, recorded rather than left implicit.** #2441 was merged by another actor while it carried an explicit "do not merge" and an unresolved review comment naming two deliberate spec reductions for sign-off. Nothing shipped that is unsafe, but three real defects reached `main` that would not have. Those two reductions — the 60-second wall-clock heartbeat becoming boundary-driven refresh with the thresholds recorded in the lease, and §5's separate run manifest becoming the partial's own lane records — are now live on `main` unreviewed and still want a human read. They are described in #2441's body and in the `## [0.38.0]` CHANGELOG entry. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ate records, pin the write tree (#2446) ## Summary **`claude-config` 0.38.0 is on `main` carrying three defects in the script it shipped.** They were found by review on #2441; that PR merged at 13:19:55Z while the fixes were still being written, so they ship here as 0.38.1. Anyone on 0.38.0 has all three. All three are one family, and it is the family this batch keeps finding: **a control that does not enforce what its surface claims.** **1. The partial was named for the lease's *current* epoch, not the writer's.** `partial append` read `owner_epoch` out of the lease at append time. That defeats precisely the isolation `run-state-and-resumability.md` §3 describes: a stale holder that wakes after an adopter has incremented the epoch reads the *adopter's* value and appends into the *adopter's* file, so two writers interleave under one attempt ordinal — which §3 itself calls "the one failure the attempt machinery cannot absorb". The script was contradicting the paragraph it was written to enforce. `partial append --epoch <held>` now names the writer's own file whatever the lease says, and prints `FENCED` on stderr when the two differ so the run aborts on the signal rather than corrupting the artifact quietly. Omitting the flag still falls back to the lease's epoch, correct only for a run whose epoch nothing has moved — and that is now stated rather than assumed. **2. `{bad json}` was accepted and appended permanently.** The check was `case "$record" in '{'*)`, which passes any string starting with a brace. A malformed row in an append-only artifact cannot be taken back, and `--resume` and assembly are its only readers, so a quoting slip in the caller cost the run's whole persisted state rather than one record. Records are now verified as well-formed single-line JSON objects. `jq` decides where it is installed; where it is not, a scan that tracks string context and escape sequences still rejects `{bad json}`, a truncated row, and an unbalanced one. **`jq` is deliberately not a hard requirement** — this is the run's state-persistence path, and failing it closed on a missing optional tool would cost the artifact the check exists to protect. Both rungs are asserted; the fallback runs with a `PATH` holding only `bash`. **3. `lease acquire` created and wrote into any `--run-dir` it was handed.** A wrong or invented run directory — the target root, say — was created and had a `lease` written into it. This skill keeps Bash specifically for state writes *while promising that a bare audit writes nothing into the target*, so that promise was enforceable only by the caller getting the argument right. `acquire` is the only command that creates a directory, so it is where the write tree is pinned: it now requires `--plugin-data` and refuses any run directory outside `<plugin-data>/runs/`. Every later command operates on a directory `acquire` already validated. Version: `claude-config` **0.38.0 to 0.38.1**. The shipped `## [0.38.0]` section is untouched — 0.38.1 is additive and says outright that 0.38.0 carries all three. ## Test plan **Fail-before / pass-after, per finding.** Each was reproduced against the merged 0.38.0 script before the fix, and each ships an assertion that fails without it. Verbatim transcript. `v0380.sh` is `git show origin/main:.../run-state.sh` — the script as merged. The lease is moved to `owner_epoch=9` as an adopter would leave it, while the writer still holds 3. ``` --- 1. epoch binding, 0.38.0 (writer holds 3, lease says 9) --- /tmp/tmp.RpfHhnqzmO/data/runs/demo/r1/findings.partial.9.jsonl rc=0 --- 2. malformed record, 0.38.0 --- /tmp/tmp.RpfHhnqzmO/data/runs/demo/r1/findings.partial.9.jsonl rc=0 --- 3. write-tree containment, 0.38.0 --- /tmp/tmp.RpfHhnqzmO/target-repo/.claude/lease rc=0 lease ^ the lease was written into a non-plugin tree === now the fixed script === --- 1. fixed --- run-state.sh: FENCED — lease owner_epoch is 9 but this writer holds 3; appending to the writer's own epoch file and this run must abort /tmp/tmp.RpfHhnqzmO/data/runs/demo/r1/findings.partial.3.jsonl rc=0 --- 2. fixed --- run-state.sh: --record must be a well-formed single-line JSON object: {bad json} rc=2 --- 3. fixed --- run-state.sh: --run-dir is not under $plugin-data/runs/ — refusing to write outside the plugin's own tree: /tmp/tmp.RpfHhnqzmO/target-repo2/.claude rc=2 ``` Read line 2 against line 12: on 0.38.0 the record the writer holding epoch 3 appended landed in `findings.partial.**9**.jsonl` — the adopter's file. On the fix it lands in `.3.` and the fence is announced. Line 4 is `{bad json}` accepted and appended at `rc=0`; line 7 is a lease written into `target-repo/.claude`, a directory the script created outside the plugin's tree. **Suite — 73 checks, up from 57:** ``` $ bash plugins/claude-config/skills/audit-pass/scripts/run-state.test.sh PASS: a run dir outside <plugin-data>/runs/ is refused PASS: the refusal says it will not write outside the plugin tree PASS: the refused run dir was not created PASS: a run dir containing '..' is refused PASS: acquire without --plugin-data has nothing to check containment against, and refuses PASS: a balanced-but-malformed record is refused, not appended PASS: a truncated record is refused PASS: a brace inside a string does not fool the check PASS: and the malformed records left the artifact untouched PASS: without jq, a balanced-but-malformed record is still refused PASS: without jq, a truncated record is still refused PASS: without jq, a brace inside a string does not fool the fallback PASS: without jq, an empty object is accepted PASS: a fenced writer appends to its own epoch file, not the adopter's PASS: and the fence is reported rather than swallowed PASS: omitting --epoch still falls back to the lease's current epoch ... All 73 checks passed. ``` The three mutation-based negative tests from 0.38.0 still pass unchanged — each deletes exactly one check from a copy of the script and asserts the mutated copy reaches the outcome the real one refuses. **Repo gates, locally:** ``` $ shellcheck --rcfile .shellcheckrc -S info plugins/claude-config/skills/audit-pass/scripts/*.sh (clean) $ typos --config _typos.toml plugins/claude-config/ (clean) $ bash scripts/check-changed-skills.sh origin/main CHECK-SKILL audit-pass: PASS — 0 errors, 2 warning(s) $ bash scripts/check-changelog-parity.sh --check-preserved origin/main All 1 changed changelog(s) preserve every version heading they carried at e4501a0 (68 compared). $ bash scripts/check-changelog-parity.sh --check-bump origin/main Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry. $ npx markdownlint-cli2 "plugins/claude-config/skills/audit-pass/**/*.md" "plugins/claude-config/CHANGELOG.md" Summary: 0 issues in 0 files ``` Both `.sh` files carry mode `100755` — the `exec-bit` sub-check of `hygiene` fails on `100644`, and it is invisible to every local linter. ## Review rounds on this PR, and what they changed Seven further findings landed after the first push. All are fixed here, each with an assertion, and every one is the same class again — a control not enforcing what its surface claims. Recorded because three of them were in *my own fix for that class*. - **🔴 `FENCED` was advisory only.** The script printed "this run must abort" and returned 0, so the abort depended on the caller spotting a substring on stderr. A fenced append now **exits 3**; the record still lands in the writer's own epoch file, and `SKILL.md` Phase 3 says what to do with the code. The test asserts the exit code, not just the message. - **🔴 The symlink fix broke the very first run.** `pwd -P` cannot resolve a directory that is not there, so canonicalizing `--plugin-data` before creating it made `lease acquire` fail on a plugin's first-ever run — the only run whose data root has never existed. **Every test in the file pre-makes that directory, so none could see it.** Reproduced against the prior commit (`--plugin-data cannot be resolved`, `rc=2`), fixed, and a test now exercises the fresh-install path. - **P1: the no-parser rung over-claimed.** `{"a" garbage}` balances and opens with a quoted key, and no non-parser catches it. `python3` is now the second definitive rung after `jq`, and where neither exists the structural scan **announces on stderr that it checked structurally only** rather than passing for a validator. - **P2: containment was lexical, so a symlink defeated it.** Both sides are canonicalized with `pwd -P` — `--run-dir` through its deepest existing ancestor. Tests assert the escape is refused, that no lease was written through the link, and that a symlink resolving back inside still works. The arm skips on Windows (`ln -s` refused) and **passes on CI**. - **P2: `report-location-and-schema.md` §7 still described the old append contract**, so an audit following that page after an adoption would have omitted `--epoch` and reinstated the interleaving. Both surfaces now carry the same command. - **🟡 check-then-act window** between symlink resolution and `mkdir`: accepted and now recorded in the code as a disclosed residual. Closing it needs an atomic create-and-verify no portable shell offers, and the attacker would already need write access inside the plugin's own data directory. - **🟡 a shared validator printed a staleness-specific reason at an `--epoch` rejection.** The reason is now an argument. Suite: **81 checks**, all passing. All six bot threads are replied to and resolved; the one open thread is my own do-not-merge marker. ## Related Refs #2280 No linked issue closes here. #2280 was closed by #2441's merge and its four live rows (F3, F5, F12, F13) are genuinely retired by the mechanism that shipped; this PR repairs three defects *in that mechanism* rather than reopening a row, so it deliberately carries no closing keyword. Follow-up to #2441 (`claude-config` 0.38.0). Origin: handoff-inbox item `20260811-020411-claude-config-audit-pass-report-path-inside-scan-set`. **Note for the reviewer, recorded rather than left implicit.** #2441 was merged by another actor while it carried an explicit "do not merge" and an unresolved review comment naming two deliberate spec reductions for sign-off. Nothing shipped that is unsafe, but three real defects reached `main` that would not have. Those two reductions — the 60-second wall-clock heartbeat becoming boundary-driven refresh with the thresholds recorded in the lease, and §5's separate run manifest becoming the partial's own lane records — are now live on `main` unreviewed and still want a human read. They are described in #2441's body and in the `## [0.38.0]` CHANGELOG entry. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
claude-config0.38.0 is onmaincarrying three defects in the script it shipped. They werefound by review on #2441; that PR merged at 13:19:55Z while the fixes were still being written, so
they ship here as 0.38.1. Anyone on 0.38.0 has all three.
All three are one family, and it is the family this batch keeps finding: a control that does not
enforce what its surface claims.
1. The partial was named for the lease's current epoch, not the writer's.
partial appendreadowner_epochout of the lease at append time. That defeats precisely the isolationrun-state-and-resumability.md§3 describes: a stale holder that wakes after an adopter hasincremented the epoch reads the adopter's value and appends into the adopter's file, so two
writers interleave under one attempt ordinal — which §3 itself calls "the one failure the attempt
machinery cannot absorb". The script was contradicting the paragraph it was written to enforce.
partial append --epoch <held>now names the writer's own file whatever the lease says, and printsFENCEDon stderr when the two differ so the run aborts on the signal rather than corrupting theartifact quietly. Omitting the flag still falls back to the lease's epoch, correct only for a run
whose epoch nothing has moved — and that is now stated rather than assumed.
2.
{bad json}was accepted and appended permanently. The check wascase "$record" in '{'*),which passes any string starting with a brace. A malformed row in an append-only artifact cannot be
taken back, and
--resumeand assembly are its only readers, so a quoting slip in the caller cost therun's whole persisted state rather than one record.
Records are now verified as well-formed single-line JSON objects.
jqdecides where it is installed;where it is not, a scan that tracks string context and escape sequences still rejects
{bad json}, atruncated row, and an unbalanced one.
jqis deliberately not a hard requirement — this is therun's state-persistence path, and failing it closed on a missing optional tool would cost the artifact
the check exists to protect. Both rungs are asserted; the fallback runs with a
PATHholding onlybash.3.
lease acquirecreated and wrote into any--run-dirit was handed. A wrong or invented rundirectory — the target root, say — was created and had a
leasewritten into it. This skill keepsBash specifically for state writes while promising that a bare audit writes nothing into the target,
so that promise was enforceable only by the caller getting the argument right.
acquireis the onlycommand that creates a directory, so it is where the write tree is pinned: it now requires
--plugin-dataand refuses any run directory outside<plugin-data>/runs/. Every later commandoperates on a directory
acquirealready validated.Version:
claude-config0.38.0 to 0.38.1. The shipped## [0.38.0]section is untouched —0.38.1 is additive and says outright that 0.38.0 carries all three.
Test plan
Fail-before / pass-after, per finding. Each was reproduced against the merged 0.38.0 script
before the fix, and each ships an assertion that fails without it.
Verbatim transcript.
v0380.shisgit show origin/main:.../run-state.sh— the script as merged. Thelease is moved to
owner_epoch=9as an adopter would leave it, while the writer still holds 3.Read line 2 against line 12: on 0.38.0 the record the writer holding epoch 3 appended landed in
findings.partial.**9**.jsonl— the adopter's file. On the fix it lands in.3.and the fence isannounced. Line 4 is
{bad json}accepted and appended atrc=0; line 7 is a lease written intotarget-repo/.claude, a directory the script created outside the plugin's tree.Suite — 73 checks, up from 57:
The three mutation-based negative tests from 0.38.0 still pass unchanged — each deletes exactly one
check from a copy of the script and asserts the mutated copy reaches the outcome the real one refuses.
Repo gates, locally:
Both
.shfiles carry mode100755— theexec-bitsub-check ofhygienefails on100644, and itis invisible to every local linter.
Review rounds on this PR, and what they changed
Seven further findings landed after the first push. All are fixed here, each with an assertion, and
every one is the same class again — a control not enforcing what its surface claims. Recorded because
three of them were in my own fix for that class.
FENCEDwas advisory only. The script printed "this run must abort" and returned 0, so theabort depended on the caller spotting a substring on stderr. A fenced append now exits 3; the
record still lands in the writer's own epoch file, and
SKILL.mdPhase 3 says what to do with thecode. The test asserts the exit code, not just the message.
pwd -Pcannot resolve a directory that is notthere, so canonicalizing
--plugin-databefore creating it madelease acquirefail on a plugin'sfirst-ever run — the only run whose data root has never existed. Every test in the file pre-makes
that directory, so none could see it. Reproduced against the prior commit (
--plugin-data cannot be resolved,rc=2), fixed, and a test now exercises the fresh-install path.{"a" garbage}balances and opens with a quoted key, andno non-parser catches it.
python3is now the second definitive rung afterjq, and where neitherexists the structural scan announces on stderr that it checked structurally only rather than
passing for a validator.
pwd -P—--run-dirthrough its deepest existing ancestor. Tests assert the escape is refused,that no lease was written through the link, and that a symlink resolving back inside still works.
The arm skips on Windows (
ln -srefused) and passes on CI.report-location-and-schema.md§7 still described the old append contract, so an auditfollowing that page after an adoption would have omitted
--epochand reinstated the interleaving.Both surfaces now carry the same command.
mkdir: accepted and now recorded inthe code as a disclosed residual. Closing it needs an atomic create-and-verify no portable shell
offers, and the attacker would already need write access inside the plugin's own data directory.
--epochrejection. The reasonis now an argument.
Suite: 81 checks, all passing. All six bot threads are replied to and resolved; the one open
thread is my own do-not-merge marker.
Related
Refs #2280
No linked issue closes here. #2280 was closed by #2441's merge and its four live rows (F3, F5, F12,
F13) are genuinely retired by the mechanism that shipped; this PR repairs three defects in that
mechanism rather than reopening a row, so it deliberately carries no closing keyword.
Follow-up to #2441 (
claude-config0.38.0). Origin: handoff-inbox item20260811-020411-claude-config-audit-pass-report-path-inside-scan-set.Note for the reviewer, recorded rather than left implicit. #2441 was merged by another actor
while it carried an explicit "do not merge" and an unresolved review comment naming two deliberate
spec reductions for sign-off. Nothing shipped that is unsafe, but three real defects reached
mainthat would not have. Those two reductions — the 60-second wall-clock heartbeat becoming
boundary-driven refresh with the thresholds recorded in the lease, and §5's separate run manifest
becoming the partial's own lane records — are now live on
mainunreviewed and still want a humanread. They are described in #2441's body and in the
## [0.38.0]CHANGELOG entry.