Skip to content

fix: anchor hermit state-dir resolution against a drifted session cwd - #721

Merged
gtapps merged 5 commits into
mainfrom
chore/state-dir-drift-fixes
Aug 14, 2026
Merged

fix: anchor hermit state-dir resolution against a drifted session cwd#721
gtapps merged 5 commits into
mainfrom
chore/state-dir-drift-fixes

Conversation

@gtapps

@gtapps gtapps commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

A cd earlier in a session silently changed what three separate resolvers found on disk. The sharpest case is feed-hermit's WebFetch allowlist: it read feed-sources.md relative to the process cwd and fails open, so once the shell drifted the domain guard stopped enforcing entirely — with no signal. This PR anchors each resolver on an existence-checked CLAUDE_PROJECT_DIR or a bounded walk to the .claude-code-hermit/config.json sentinel.

Each fallback is chosen so no hermit loses data: dev keeps its deliberately load-bearing null, and core still records into a scaffolded-but-unhatched dir rather than dropping a row.

Changes

feed-hermit (security-relevant)

  • fetch-guard.ts resolves the project root (env → 8-level walk → cwd) instead of reading a bare relative path. Drift no longer disables the allowlist.
  • validate-sources.ts validates the file the hook actually reported. It previously gated on the payload's file_path and then read a same-named feed-sources.md under cwd — so it could pass a bad table and fail a good one.
  • Corrected a stale CLAUDE.md section that described fetch-guard.ts as slated for deletion pending a shared core hook; that hook was dropped in July, so the plugin-local guard is permanent.

claude-code-dev-hermit

  • findHermitDir honors CLAUDE_PROJECT_DIR, existence-checked against the config.json sentinel and falling through to the walk on a miss — so a stale value degrades to today's behavior rather than to null or a foreign project's store. Under drift the guard had been dropping operator protected_branches back to the built-in main/master list and skipping last-test.json writes.
  • Callers are unchanged: git-push-guard's baseCwd branch resolution and record-test-result's run/write --cwd semantics are untouched. dev-pr-transforms' parent-rooted lookup is intentionally left alone (single-store design) and was verified unaffected.

claude-code-hermit (core)

  • Exported the bounded walk behind hermitDir() as findHermitDir(startDir) — same 8-check cap and sentinel, null instead of the fail-open tail. hermitDir() behavior is unchanged for all ~39 importers.
  • routines/event.ts stops re-deriving where the ledger lives. precheck, finish and due each already hold a resolved hermit dir, and used to hand over its parent so logRoutineEvent could walk back down to it. That round trip could only lose information: with the two resolvers disagreeing it could append to a different project's routine-metrics.jsonl, and in finish it split the run record from the ledger row across two roots — markOutcome used the resolved dir while stamp() used whatever the re-walk found. logRoutineEvent now takes the hermit dir directly. The walk survives only in the log-event CLI verb, which fires from a CronCreate prompt with no caller-supplied anchor, and is capped at 8 levels like every other resolver.
  • An append into a hermit dir with no state/ now returns the function's documented error string instead of throwing a stack trace out of that CLI verb.

Scope note — worktrees

An earlier revision of this branch claimed the resolution change also fixed git worktrees, on the premise that .worktreeinclude ships a config-less .claude-code-hermit/ there. That premise is wrong and the claim has been removed from the code comments, the changelog, and this description.

The managed block copies config.json into the worktree deliberately, because dev-hermit's /dev-quality and /dev-pr read commands.test and commands.pr_create from it in a worktree session. So config.json cannot discriminate a worktree copy from a real project — for any operator, not just in this repo. Doing that needs a sentinel the copy does not carry (state/ is the candidate), applied in hermitDir() rather than in the routine ledger. That is tracked separately and is out of scope here.

What this PR does deliver for the ledger is agreement by construction: one resolved root per invocation, no second resolver to disagree with it.

A bare config-less dir still counts as a fallback in the CLI walk, because hatch scaffolds the tree before the config wizard runs and an aborted hatch can leave it that way. Refusing there would drop the row silently, and a short ledger still reads source: 'ok' to routine health.

Test plan

All run in this worktree, all exit 0:

  • cd plugins/feed-hermit && bun test60 pass
  • cd plugins/claude-code-dev-hermit && bash tests/run-all.sh150 pass (27 + 73 + 37 + 13)
  • cd plugins/claude-code-hermit && bun test3781 pass
  • bunx tsc --noEmit → clean

New regressions, written red-first where the fix was behavioral:

  • feed: drifted cwd inside a hatched project still blocks an off-allowlist URL; env-named project beats a cwd that has its own competing allowlist; stale env falls through to the walk; a never-hatched project keeps the documented fail-open. Verified these fail (5 failures) against the pre-fix source.
  • feed: two same-named feed-sources.md files — the payload-named one is validated, in both directions (good payload/bad cwd and bad payload/good cwd).
  • dev: env-named project wins over drifted cwd; stale env falls through; no-env drift still degrades to the built-in list. Both dev suites also had to scrub the inherited CLAUDE_PROJECT_DIR — note spawnSync snapshots the parent env, so an in-process delete does not reach children that omit an explicit env. Suite re-run with CLAUDE_PROJECT_DIR exported to prove the scrub holds.
  • core: the 8-check cap boundary (finds at 7 levels, gives up at 8), walking past a config-less decoy to the real project, and findHermitDir ignoring ambient env so an explicit caller-supplied start wins.
  • core: logRoutineEvent writes under the hermit dir it is given and does not walk. Confirmed red-first by replaying the old resolution against the same fixture — given <root>/child/.claude-code-hermit, it resolved to <root>/.claude-code-hermit instead.

Notes for review

  • The hook-payload cwd field was probed live (CC v2.1.232) rather than assumed: it is present on every PreToolUse/PostToolUse payload but tracks the drifted shell cwd and always equals the hook process's own process.cwd(), so it anchors nothing. That is why no fix here consults it. The probe also confirmed tool_input.file_path is normalized to absolute before hook dispatch, which is what makes the one-line validate-sources fix safe.
  • Core's findHermitDir is deliberately env-free: the log-event CLI verb passes an explicit start, and ambient env must not override an explicit argument.
  • The dev resolver's INVARIANT comment documents one intended divergence from core's hermitDir(): core's CLAUDE_PROJECT_DIR branch accepts a bare .claude-code-hermit/ while dev's requires the config.json sentinel. That matters for a scaffolded-but-unhatched project, not for worktrees.

gtapps added 5 commits August 14, 2026 11:16
A `cd` earlier in a session silently changed what three resolvers found. The
worst case is feed's WebFetch allowlist: it reads feed-sources.md relative to
cwd and fails open, so drift stopped the domain guard enforcing at all. Its
table validator had the same shape — it gated on the hook's file_path, then
read a same-named file under cwd instead. Dev's hooks never consulted
CLAUDE_PROJECT_DIR, so drift dropped operator protected_branches back to the
built-in list, and core's routine ledger accepted the config-less decoy
`.claude-code-hermit/` a worktree carries, writing rows nobody reads.

Each resolver now anchors on an existence-checked CLAUDE_PROJECT_DIR or a
bounded walk to the config.json sentinel, with the fallbacks chosen so no
hermit loses data: dev keeps its load-bearing null, and core still records
into a scaffolded-but-unhatched dir rather than dropping the row.
Review of the drift fix found the hatched-project preference inert whenever a
caller passes a relative root. findHermitDir does not resolve its start, so
path.dirname('.') ends the walk after one check, pass 1 returns null, and the
nearest-.claude-code-hermit fallback wins — exactly the behavior the anchor
replaced. routine-due.ts derives its root from argv, whose absoluteness is
documented in the skill but not enforced.

The bare-dir fallback also promised an error string it could not deliver:
appendFileSync throws when the resolved dir has no state/, and run() does not
catch, so a scaffolded-but-unfinished hatch produced a stack trace instead of
one stderr line.

The dev resolver's INVARIANT comment claimed env-precedence parity with core
that does not exist — core's CLAUDE_PROJECT_DIR branch accepts a bare
.claude-code-hermit while dev requires the config.json sentinel. The comment
exists to keep the resolvers in sync, so it now names the one case where they
disagree.
…-deriving it

precheck, finish and due each resolved a hermit dir, took its parent, and handed
that to logRoutineEvent so it could walk back down. The round trip could only
lose information. Once the walk gained a config.json sentinel the two resolvers
could disagree and append to a different project's ledger, and in finish.ts it
already split the run record and the ledger row across two roots — markOutcome
used the resolved dir while stamp() used whatever the re-walk found.

logRoutineEvent now takes the hermit dir directly. The walk survives only in the
log-event CLI verb, which fires from a CronCreate prompt with no caller-supplied
anchor.

Also corrects what the walk's comment claims about worktrees. A worktree's
partial state dir carries config.json — the dev hermit's /dev-quality and
/dev-pr read commands.test and commands.pr_create from it — so the config.json
sentinel does not identify a worktree copy, and the changelog entry no longer
promises that it does. Discriminating that case needs a sentinel the copy does
not carry, in hermitDir() rather than here.
Two comments asserted that a git worktree's partial `.claude-code-hermit/`
carries no config.json. It does — `.worktreeinclude`'s managed block copies it
in, because the dev hermit's /dev-quality and /dev-pr read commands.test and
commands.pr_create from it there.

So the dev resolver's INVARIANT comment described a core-vs-dev divergence that
does not occur: both land on the worktree's copy. It now names the divergence
that is real, a CLAUDE_PROJECT_DIR pointing at a scaffolded-but-unhatched
project, which core accepts and dev walks past. The cc-compat test's decoy
fixture is a valid walk-past-a-config-less-dir case; only its justification
claimed to be the worktree shape.

Also drops a stray blank line that had made the core changelog's Fixed list
render loose.
@gtapps
gtapps merged commit 6c16988 into main Aug 14, 2026
8 checks passed
pull Bot pushed a commit to bryanwills/claude-code-hermit that referenced this pull request Aug 14, 2026
…eader

A `claude --worktree` session got a state dir the dev hermit could not read:
/dev-pr failed Gate 0 pointing at a re-hatch, which was the wrong diagnosis —
the hatch was fine, commands.pr_create simply was not in the copy. The
resolver comments merged in gtapps#721 already assert the block carries config.json;
only this repo's hand-fixed .worktreeinclude made that true. Writes stay pinned
to the main checkout, so the single-writer invariant the original two-path
allow-list protected is unaffected; the template-sync test now asserts the
no-runtime-state half directly instead of by counting lines.

routines.ts health resolved a relative argv against the process cwd while both
documented callers pass the relative `.claude-code-hermit`. Any earlier `cd`
turned a real ledger into `source: missing`, which reflect and hermit-evolution
both read as "no candidates" — a silent skip. Now anchored like weekly-review's
hermit arg, with an absolute argv still honoured as passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant