fix(hook-utils): stop treating the OS temp tree as project content - #1812
Conversation
|
Claude finished @kyle-sexton's task in 3m 57s —— View job PR Review
|
|
Claude finished @kyle-sexton's task in 4m 49s —— View job Security Review — PR #1812
Summary: No critical or important security issues found. This PR is a targeted, defense-in-depth improvement. One low-impact edge case is noted below. FindingsSUGGESTION — Pipe character in
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 942bf24566
ℹ️ 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".
hook::read_file_path decided project membership by prefix-matching CLAUDE_PROJECT_DIR. When the project directory is the user's home, the OS temp root sits under it, so Claude Code's own per-session scratchpad passed the membership test and every file-scoped hook treated scratch files as project content — typos-format autocorrected a shell variable in a throwaway script from the built-in dictionary, in a location with no repository and so no typos config to allow-list the token with. hook::under_temp_root resolves this host's temp roots from TMPDIR/TMP/TEMP plus the POSIX defaults, through the same canonicalization the membership comparison already uses; read_file_path now rejects a temp-tree file reached from a project root outside the temp tree. A project root that itself lives under temp keeps its files: that is a mktemp -d fixture checkout, which is how this repository's own hook suites run. typos-format's applied-rewrite guidance now also names extend-ignore-re for a region quoted verbatim, which typos documents for markdown code fences. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
The machine-specific-paths hygiene check reads a drive-letter user path in a comment as a machine-specific absolute path. Describe the spelling instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
3b1d2c1 to
12a1ef5
Compare
|
Claude finished @kyle-sexton's task in 2m 50s —— View job PR Review — post-synchronize pass
What the synchronize commit addsThe latest commit ( Finding still open from prior review — actionableCase 7b missing the HOME-under-temp guard · Medium
# lib/hook-utils.test.sh line 529
if [[ -n "$PROJ12C" ]] && ! hook::under_temp_root "$(hook::normalize_path "$(hook::physical_path "$PROJ12C")")"; thenCase 7b in # typos-format.test.sh line 687 — guard as-is
if [[ -n "$SCRATCH_HOME" ]]; thenOn a Docker CI host where The fix is a one-line change matching Test 12c: if [[ -n "$SCRATCH_HOME" ]] && ! hook::under_temp_root "$(hook::normalize_path "$(hook::physical_path "$SCRATCH_HOME")")"; thenNote that the Codex P2 finding — confirmed, low impact
norm="${norm%/}"
[[ -n "$norm" ]] || continueIf Other findings from prior review — unchanged
SummaryThe core fix is correct; the style commit is a clean hygiene pass. One actionable item remains: Case 7b in |
|
Claude finished @kyle-sexton's task in 3m 51s —— View job Security Review — PR #1812
No CRITICAL or IMPORTANT security findings. The new SUGGESTION —
|
A TMPDIR/TMP/TEMP of / lost its only slash to the trailing-slash trim, leaving an empty candidate that the guard discarded — so a root temp tree recognized nothing instead of everything, and scratch files under a home-shaped project dir were admitted again. The root now short-circuits as containing every absolute path, with a regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STASxNiqEm7doy2Z65un4H
…hpad-scope-1769 # Conflicts: # plugins/actionlint/CHANGELOG.md # plugins/autonomy/CHANGELOG.md # plugins/bash-format/CHANGELOG.md # plugins/biome-format/CHANGELOG.md # plugins/claude-ops/CHANGELOG.md # plugins/context-guard/CHANGELOG.md # plugins/desktop-notification/CHANGELOG.md # plugins/eol-normalizer/CHANGELOG.md # plugins/go-format/CHANGELOG.md # plugins/guardrails/CHANGELOG.md # plugins/markdown-format/CHANGELOG.md # plugins/powershell-format/CHANGELOG.md # plugins/rate-limit-guard/CHANGELOG.md # plugins/ruff-format/CHANGELOG.md # plugins/source-control/CHANGELOG.md # plugins/typos-format/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 1m 35s —— View job Security Review — PR #1812 (post-synchronize)
No CRITICAL or IMPORTANT security findings. Everything reachable since the last full review (commits New since the last pass
norm=$(hook::normalize_path "$(hook::physical_path "$cand")")
[[ "$norm" == / ]] && return 0
norm="${norm%/}"
[[ -n "$norm" ]] || continueThis resolves the Codex P2 finding from the prior review pass ( One consequence worth naming explicitly (not a new vulnerability, just tracing the logic through): if The accompanying regression test (Test 12d in Sync integrity check — Merge commit Carried forward from prior passes (unchanged, still low-severity)
Not-findings (reconfirmed at HEAD)
|
…n shipped Main delivered the same version numbers through #1785's carrier bump, so this branch's lib change was no longer version-visible to consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STASxNiqEm7doy2Z65un4H
|
Claude finished @kyle-sexton's task in 34s —— View job Security Review — PR #1812 (post-synchronize, commit
|
|
Claude finished @kyle-sexton's task in 2m 8s —— View job PR Review (code-review lane) — post-synchronize pass
What changed since the last full passThe only new commit is I verified this commit directly rather than trusting the description:
No correctness issues in this commit. Status of prior findings at HEAD (
|
|
Claude has reviewed this PR 2 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
Test 12d's sibling subshell changed ShellCheck's dataflow so the same deliberate subshell-local export now reports SC2030 alongside SC2031. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STASxNiqEm7doy2Z65un4H
|
Claude finished @kyle-sexton's task in 50s —— View job Security Review — PR #1812 (post-synchronize, commit
|
|
Claude finished @kyle-sexton's task in 53s —— View job PR Review (code-review lane) — post-synchronize pass
What changed since the last passThe only new commit is - # shellcheck disable=SC2031 # subshell-local by design
+ # shellcheck disable=SC2030,SC2031 # subshell-local by design
export CLAUDE_PROJECT_DIR="$PROJ12C" TMP="$win_tmp" TEMP="$win_tmp"
unset TMPDIRThis is correct and expected: Test 12d's sibling subshell (added in Status of previously-flagged findings at HEAD (
|
#1815) ## Summary All five audit findings verified against current `main` and fixed here, one remediation each, all inside `claude-ops`. Verification evidence and the per-finding remediation choices are on the issue. - **F1 (IMPORTANT) — `sync` Step 1.** The "Self-heals a stale or corrupt local clone" claim is softened to what the CLI actually does, with [anthropics/claude-code#76129](anthropics/claude-code#76129) (verified **open**) cited for the failure mode, and Step 1 gains the non-zero-exit prose it never had for single/default mode — mirroring the pattern `all` mode and Step 3 already use: report inline under "Action needed", continue to Step 2, and report the catalog as possibly stale rather than current. Cache surgery stays out of scope; a read-only `git fetch` in the marketplace's `installLocation` is named as the safe diagnostic. Declined remediation 2 (`fleet-state.sh` doing its own `git ls-remote` staleness probe): that puts a per-marketplace network round trip inside a script whose whole value is being a cheap local snapshot. - **F3 (IMPORTANT) — version capture.** `SKILL.md`'s report mandates `<id>@<marketplace>: <old> → <new>`; no step instructed capturing either value. New "Version capture for the report" section fixes three sources in precedence order and forbids synthesizing a value. **The ordering is the load-bearing part**: `claude plugin update --help` says *"restart required to apply"*, and nothing in this skill establishes when the CLI writes `installed_plugins.json` relative to that. If the write is deferred, the audit's suggested post-sweep `fleet-state.sh` diff reports *no change* for plugins that did update, and the report line confidently says `Updated: 0` for a sweep that did real work — worse than the gap, because it reads as authoritative. So the post-sweep re-read is the fallback, the CLI's own output is preferred, and an id the CLI reported as updated but whose re-read version is unchanged is reported as the CLI's value or `<unknown>`, never `<old> → <old>` and never as not-updated. - **F2 (SUGGESTION) — TOCTOU scope.** `gotchas.md`'s "Concurrency / TOCTOU" covered installed and enabled state only. Extended to catalog content: a refresh landing mid-session rewrites the catalog, so two reads within one session can legitimately disagree on plugin count — which makes diffing `fleet-state.sh`'s catalog against a separately-read raw `marketplace.json` an invalid staleness check, and a mismatch not evidence of an enumeration bug. Deferred the mtime/content stamp and the duplicate-`.name` warn: code changes for a collision with zero observed occurrences, and the warn needs its own fixture. - **F4 (SUGGESTION, latent) — the only code change.** `PROJECT_ROOT` fell through to bare `$PWD` whenever `CLAUDE_PROJECT_DIR` was unset and cwd was not a git tree, so the "project" settings read became whatever `.claude/settings.json` sat under cwd — in `$HOME`, the user settings file itself. Resolution is now `CLAUDE_PROJECT_DIR` or a real git toplevel, and nothing else. Two things make this safe rather than merely defensible: the downstream reads at `fleet-state.sh:214` and `:249` **already** guard on `[[ -n "$PROJECT_ROOT" ]]`, so removing the fallback exercises an existing path rather than a new one; and `sync.md` Step 2 already documented resolution as exactly those two sources, never mentioning the `$PWD` fallback — so this aligns the script with its own documented contract. Deferred remediation 2 (`has_project_context`): a new emitted field with no reader today expands the output contract for `converge` work that does not exist yet; trigger for revisiting is `converge.md`'s V1 raw-per-scope-map gap. - **F5 (SUGGESTION) — progressive disclosure.** The router table's `sync` Description spelled out the full six-step chain, complete enough to execute from without opening `context/sync.md` — which is how F1's and F3's gaps survived a live run. Descriptions now name territory only, under an explicit instruction that the table is an index, not a substitute. Nothing needed a human call, so nothing is skipped. **One incidental hunk, flagged so it is not a surprise in review.** `fleet-state.test.sh`'s `--argjson` static-guard case used `grep -v '^\s*#'`. `\s` is a GNU extension, and the `shell-portability-lint` lane scans whole changed files — so this pre-existing line would have failed the lane on this PR even though it is untouched by the fixes. Swapped for the POSIX `[[:space:]]` equivalent; the guard's assertion is unchanged and still passes. ## Test plan - **F4 is the only finding with an executable surface**, and it lands repro-first: a new `fleet-state.test.sh` case puts cwd in a non-git directory with `CLAUDE_PROJECT_DIR` unset and an install record whose `projectPath` equals that directory, asserting `currentProject` stays `null`. - before the fix: `32 cases, 1 failed` — `no-project-context: non-git cwd with CLAUDE_PROJECT_DIR unset manufactures no currentProject` (got `true`: phantom project context, exactly the manufactured-`currentProject` risk the audit called theoretical) - after the fix: `32 cases, 0 failed` - the existing `git-fallback` case (cwd in a subdirectory of a real repo, `CLAUDE_PROJECT_DIR` unset, resolves via git toplevel) stays green, so the fallback that matters is untouched - **F1, F2, F3, F5 are doc-only** — skill body and context files, no executable surface to test. Verified by reading each cited location in a fresh worktree off `main` rather than trusting the audit's snapshot; per-finding evidence is tabulated on the issue. - **Gates** — `scripts/check-changed-skills.sh` (skill-quality lane, which keys on `SKILL.md`), `scripts/check-changelog-parity.sh --check-bump origin/main`, `scripts/check-skill-portability.sh --paths`, `scripts/check-shell-portability.sh --paths`, `shellcheck --rcfile .shellcheckrc -x`, `shfmt -d`, `markdownlint-cli2`. ## Related - Verification evidence and per-finding remediation rationale: the issue comment on #1764. - **Version-bump collision, flagged for the merge lane.** `main` has `claude-ops` at `0.24.0`. PR #1812 also bumps it to `0.24.1` (as one of 16 carriers of a shared-lib sync). Whichever lands second conflicts on `plugins/claude-ops/.claude-plugin/plugin.json` and `CHANGELOG.md`. **This PR should yield** — #1812 is older and green. Resolution: take `main`'s version, bump it, and re-insert this entry above the new top section. Fixes #1764 🤖 Generated with [Claude Code](https://claude.com/claude-code) <https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Summary
hook::read_file_path(lib/hook-utils.sh) decides project membership byprefix-matching
CLAUDE_PROJECT_DIR. When the project directory is the user's home — the shape asession started outside any checkout takes — the OS temp root sits under it, so Claude Code's own
per-session scratchpad passes the membership test. Every file-scoped hook that scopes on this guard
(12 of them) then treats scratch files as project content. In the reported case
typos-formatautocorrected a shell variable in a throwaway script from typos' built-in dictionary, in a location
with no repository and therefore no typos config to allow-list the token with, silently breaking
the script. This is the failure the repository's own
hook-precisionrule 5 already names, andtypos-format.sh's header wrongly claimed rule 5 was N/A for it.hook::under_temp_rootinlib/hook-utils.sh;hook::read_file_pathnow rejects a file inside the OS temp tree when the project root is outside it. The exemption is
deliberate and load-bearing: when the project root itself lives under temp — a
mktemp -dfixturecheckout, which is how this repository's own hook suites run — its files are still project content
and are accepted. Temp roots come from
TMPDIR/TMP/TEMPplus the POSIX defaults, resolvedthrough the same
hook::physical_path+hook::normalize_pathpipeline the membership comparisonalready uses (both spellings matter: on Git Bash
TMPDIR=/tmpwhileTEMPcarries the Windowsform of the identical directory, and
realpathresolves one but not the other). Cost: everyin-project file now pays one candidate-resolution pass — after dedup, about two
realpathspawns — before the guard returns; the second call (on the project root) only runs when the file
matched, so the common case pays one pass, not two.
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]branch, so a session withCLAUDE_PROJECT_DIRunset(some headless
-psessions, perbash-format's README) still processes temp-tree files — thereported case had it set, so this closes the report, but the temp tree is not universally excluded.
And a Git worktree living under the OS temp root, reached from a home-shaped project dir, is now
skipped by every file-scoped hook; a git-worktree escape hatch was considered and dropped as a
second heuristic layer. Working inside such a worktree makes it the project root, which the
temp-rooted exemption already covers, so the exposure is narrow — noted because Git worktrees are being created in system temp and dead-session scratchpad paths, leaving orphaned registrations #1774 concerns
worktrees in system temp.
scripts/sync-hook-utils.shrun: all 16 carrying pluginssynced byte-identical, version-bumped, and changelogged.
bash-format's README and setup SKILLstate this guard's contract (they already claimed temp/scratchpad files were skipped — true only
when the project dir was a checkout) and are corrected to state the refined rule.
typos-formatdisclosure. The applied-rewrite guidance pointed only atextend-words/extend-identifiers. It now also namesextend-ignore-refor a region quotedverbatim (a Markdown code fence, a transcript, a signature block), which typos'
reference documents for exactly
that case and supports across lines via
(?s).position) and 3 (correction applied inside a Markdown code fence) are
typos-clibehavior, nothook behavior — declining by syntactic position would mean re-implementing typos' tokenizer. Both
remain live inside a repository, where the consuming repo's typos config is the seam
(
extend-identifiers/extend-ignore-identifiers-re,extend-ignore-re). Suggested-fix item 4("fail loudly rather than rewrite silently") is a plugin posture change, not a defect, and is left
for a human decision;
typos_format_write_changes = falsealready gives a report-only mode.Analysis is on the issue.
Test plan
Repro-first, per
docs/conventions/hook-precision/README.md: each new stay-quiet case fails againstthe unmodified guard and passes after. Run on Windows 11 / Git Bash.
The real production shape, end to end — the committed hook driven against a file inside the
actual harness session scratchpad (
%LOCALAPPDATA%\Temp\claude\<project>\<session>\scratchpad\)with
CLAUDE_PROJECT_DIR='C:\Users\<user>'and no temp-root overrides, so nothing about thefixture is synthetic:
this has the typo→this has the typo, hook reportedREWROTE 1 word(s)—the reported defect, reproduced in place
uses (
TEMP=C:\Users\KYLESE~1\AppData\Local\Temp, whileTMPDIR=/tmpresolves to a differentspelling that
realpathleaves alone). Test 12c pins that spelling with its own case.Black-box hook contract —
plugins/typos-format/hooks/typos-format.test.sh, new Case 7b (atemp-tree file under a home-shaped project dir must be left untouched and silent):
PASS=75 FAIL=2—temp-tree file not silent (rc=0 out={"hookSpecificOutput"... "typos-format REWROTE 1 word(s) in inventory.txt"...})and
temp-tree file was rewritten: this has the typoPASS=77 FAIL=0Shared lib —
lib/hook-utils.test.sh, new Test 12c (four cases: the regression, thetemp-rooted-project exemption, an in-project control, and the Windows backslash spelling of the
temp root). Driven against the pristine copy of the lib and then the fixed one:
FAIL: temp-tree file admitted under a non-temp project root(the exemption and controlcases are non-regression guards, green either way)
PASS=120..121with 0–2 failures, all in thebuffer_stdintiming class (trickle,stall overshoot,late-EOF). 5 runs of the pristinebaseline on the same machine gave
PASS=116..118with 0–2 failures in the same class — thatclass is load-sensitive and pre-existing (fix(hook-utils): expand Windows 8.3 short names in the membership guard #1692's test plan documents it too).
under_temp_roothas no call path into
buffer_stdin.Whole plugin corpus —
scripts/run-plugin-tests.sh: all plugin contract tests pass.Gates —
scripts/sync-hook-utils.sh --check(all 16 copies match),scripts/check-changelog-parity.sh --check,scripts/check-silent-skips.sh,scripts/check-shell-portability.sh --pathson the changed shell files,shellcheck --rcfile .shellcheckrc -x,shfmt -d— all clean.Cleanup guidance for anyone affected
This change stops future rewrites; it cannot repair a file already corrupted. A scratch file written
during a session whose
CLAUDE_PROJECT_DIRwas a home directory may carry silent dictionarycorrections. There is no way to enumerate them after the fact — the hook's disclosure was emitted at
the time, in the transcript. Re-generate rather than trust any scratch script from such a session.
Related
KYLESE~1) scratchpad paths pass the membership guard;correct for in-project files, and it is what exposed this guard's over-broad membership predicate.
docs/conventions/hook-precision/README.mdrule 5 and its repro-first discipline.Fixes #1769
🤖 Generated with Claude Code
https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C