Skip to content

fix: let the engine find its own installed logger instead of filing nothing - #63

Merged
gurinderu merged 10 commits into
mainfrom
fix/logger-finds-itself
Sep 4, 2026
Merged

gurinderu merged 10 commits into
mainfrom
fix/logger-finds-itself

Conversation

@gurinderu

Copy link
Copy Markdown
Owner

Why

Measured on a live run of the installed plugin, not reasoned about: neither craftRoot nor CLAUDE_PLUGIN_ROOT was set in the logger agent's shell, so the prelude refused and the run filed no record at all. The refusal itself is right — resolving the logger relative to the reviewed repository would execute that repository's own script with the user's privileges, which is why the :-. fallback was removed. What was false was the assumption in front of it: the code said an installed plugin has the variable set for it, and it does not.

The cost is not one lost record but zero records in that mode, and it is invisible — the store stays full of older runs, so neither the file count nor the index shows the hole. Only the report says so, and only to whoever reads it. Measurement across engine revisions is the thing that quietly stops being possible.

What changed

The engine looks for its own installed copy before giving up: under the user's own plugin cache (honouring $CLAUDE_CONFIG_DIR), and only for the exact version it is stamped with, so a 0.18.1 engine can never log through a 0.16.0 script and file records describing a run that build never made. The version comes off the record being filed rather than a parameter of its own, so the copy the logger is found by cannot drift from the version the record claims.

Along the way the logger path picked up the constraint --dir has carried since 226adc4. Every candidate — explicit craftRoot, the environment variable, the installed copy — now goes through one predicate: absolute, present, and outside the directory the command is about to cd into. The containment question itself is named once, containsPath in JS with insideStore as one caller and craft_usable in the shell, and lib/path-containment.test.mjs runs one table of ten cases against both.

Ten review rounds, and what they found

Every round found something real, and the pattern is the finding: nine of ten were defects inside the previous round's fix, and several were holes the fix itself introduced rather than inherited.

  • The search had a second, unpinned candidate. The marketplace directory is a git clone that moves on its own, so a machine whose clone had advanced would have executed a newer script — which stamps engineRevision and craftCommit from its own build — while the record said this version. A record misdescribing which engine ran is worse than no record, because it gets counted.
  • A relative $CLAUDE_CONFIG_DIR reopened the original hole by another door. [ -f ] runs in the agent's cwd, node runs after the cd — so a relative candidate resolves in the reviewed repository. A project-local config dir plus a hostile repo shipping .claude/plugins/cache/craft/craft/<version>/lib/craft-log-run.mjs (the version is public in the manifest) would have executed it.
  • An explicit craftRoot returned early, skipping both the absoluteness check and the refusal. craftRoot=. against an untrusted repo printed PWNED with rc 0 — the removed :-. hole verbatim, and craftRoot arrives in the model-composed args string.
  • Absolute was not enough: an absolute path may still name the repo. Naming it, climbing back with .., and a symlink pointing at it are now one refused case, with pwd -P on both sides.
  • A symlinked FILE still passed, because pwd -P resolved the directory and the basename was re-appended unresolved.
  • A bound that failed open. The symlink walk stopped after sixteen hops and then continued on the unresolved path; a twenty-one-link chain leaving the repo and returning executed. A bound that fails open is not a bound, it is a longer attack.
  • A repo of / inverted the predicate into an allow-all on both sides (//* matches no ordinary path).
  • The resolver swallowed every error, not only the not-yet-created case its comment named, so a symlink loop degraded into a lexical answer.

Tests, and what was wrong with them

Half the rounds found the defect in a test rather than in the code, which is worth stating plainly:

  • an assertion that passed with the guard it names deleted — every case omitted repo, so it defaulted to . and the refusals came from containment rather than absoluteness;
  • an end-to-end check that passed with the bypass fully restored, because node was not on PATH in the scrubbed env and PWNED could not print either way — it uses process.execPath now and carries a positive control, since an absence proves nothing until the same fixture is shown able to produce the presence;
  • a shell reader that scored any non-zero exit as "refused", so a syntax error would have satisfied six rows for a reason unrelated to containment;
  • two guards added in one commit and tested in neither, each found by the next round;
  • an injection payload set that was inert under the defect it named — the payload that actually executes was missing.

Every fix on this branch was falsified by breaking exactly its own property and watching exactly its own test go red. Where a mutation changes no observable behaviour, the guard is labelled belt-and-braces in the code rather than dressed up as covered.

Deliberately open

  • Node chore: bring the OpenCode delivery under a gate #45 is not closed by this PR. Its condition is a live run of an installed build carrying this fix, and no such build exists before a release.
  • The version pin cannot distinguish the running engine from a cached build of the same version number. When craft reviews its own checkout, craftRoot is the repo under review and is correctly refused, the environment variable is unset, and the search finds the cached copy of the same version. The record is then filed by that script while the branch's engine ran — telemetry attribution, not safety.
  • A candidate that does not exist is answered differently by the two implementations (the shell refuses at [ -f ], the JS answers lexically). Named in the table's header rather than asserted as an agreement that does not hold.

Gates

node --test 479/479 · eslint 0 · check-workflows 0 (25 inlined regions match) · check-skills 0 · check-evals 0 — all read as raw exit codes.

…othing

Measured on a live run of the installed plugin: neither craftRoot nor
CLAUDE_PLUGIN_ROOT was set in the logger agent's shell, so the prelude refused
and the run filed no record at all. The refusal itself is right — resolving the
logger relative to the reviewed repository would execute that repository's own
script with the user's privileges, which is why the `:-.` fallback was removed —
but the assumption before it was false: the code said an installed plugin has
the env var set for it, and it does not. The cost is not one lost record but
ZERO records in that mode, and it is invisible: the store stays full of older
runs, so neither the file count nor the index shows the hole. Only the report
says so, and only to whoever reads it. Measurement across engine revisions is
the thing that quietly stops being possible.

So the engine now looks for its own installed copy before giving up, and the
search is deliberately narrow: only under the user's own plugin cache, and only
for the exact version the engine is stamped with. A 0.18.1 engine can never log
through a 0.16.0 script and file records describing a run that build never made.
The version comes off the record being filed rather than a parameter of its own,
so the copy the logger is found by cannot drift from the version the record
claims. The reviewed repository is never a candidate — verified by planting a
lib/craft-log-run.mjs in the working directory and watching the block refuse.

Order matters and was wrong at first: the search is a FALLBACK, not an override.
Written the other way round the loop overwrote a good path from
CLAUDE_PLUGIN_ROOT with whatever the cache held, so a launch from a checkout
would have logged through some other installed version without a word.

Three executed tests, each falsified separately by breaking exactly its property:
a planted install is found when the environment says nothing; a version with no
installed copy refuses rather than borrowing another; and the environment wins
when it resolves.

Two existing tests failed on this change and both were asserting the SPELLING of
the old prelude rather than its property — one pinned the exact former one-liner,
the other banned the `:-` operator outright when what is dangerous is a default
PATH (`${VAR:-.}` supplies a directory; `${VAR:-}` expands to nothing). Rewritten
as properties: the environment is consulted first, no default path exists, and an
unresolvable logger stops the command by name. A third now pins HOME, since a
test whose outcome depends on what is installed on the machine running it is not
a test.
… its version too

Cold review found the recurring pattern three times in the previous commit.

The search had a SECOND candidate — the marketplace directory — and it is not a
versioned release but a git clone that moves on its own, which is exactly what
the version pin exists to prevent. On a machine whose clone had fetched a newer
build, an engine stamped 0.18.0 would have executed that newer script, which
stamps engineRevision and craftCommit from its own build, while the record body
said 0.18.0. A record misdescribing which engine ran is worse than no record,
because it gets counted. One candidate now, version-pinned, and a test proves an
unpinned copy is refused even when it is the only one present.

The fix reached two of the three logger commands in review.js. LOGGER_PRELUDE
was computed at line 128 with no version — CRAFT_VERSION is declared six hundred
lines below and moving it is release-please's business — so the prior-round read
kept refusing exactly as before while the record write and the checkpoints found
their script. Deferring the prelude to a function called at use time lets all
three agree.

And the assertions that were supposed to guard all this inspected the
VERSIONLESS prelude, so the search block was not in the string under test at
all: adding `$PWD` as a candidate left both tests whose stated job is "the
working directory is never a candidate" fully green. They now assert the prelude
as the engine actually emits it, and ban every shape that names the current
directory — `$PWD`, `$(pwd)`, a `:-` default supplying a path, a bare relative —
not just the one literal the old assertion knew.

Two more from the same review: CLAUDE_CONFIG_DIR is honoured, since a session
configured that way keeps its plugins elsewhere and hardcoding ~/.claude would
have left that user with the defect unfixed and no sign of it; and the version
is quoted into the shell rather than interpolated raw. The version is a source
literal today and nothing external reaches it, but it was the one place in this
file bypassing the quoting every other interpolation goes through.

The harness's plugin-cache layout is someone else's surface, relied on here for
the first time, so it is now pinned by observation in the realm (@nick/craft
#48) and referenced from the code — including the distinction that decides the
fix: a versioned directory asserts its version, a marketplace checkout asserts
nothing.
…from its payload

Third round on this branch. The first finding is the one that matters: the hole
the `:-.` removal closed had re-entered by another door.

`[ -f ]` runs in the logger agent's cwd, but `node "$CRAFT_LOGGER"` runs AFTER
the cd into the reviewed repository — so a RELATIVE $CLAUDE_CONFIG_DIR makes
that repository a search candidate. A user with a project-local config dir and a
hostile repo shipping .claude/plugins/cache/craft/craft/<version>/lib/
craft-log-run.mjs — the version is public in the manifest — would have executed
it with their own privileges. A non-absolute value is now refused outright
rather than normalized: guessing what the user meant is how this class keeps
coming back. Executed with a real relative value, because the relative-ness
arrives from the environment and can never appear as a literal in the emitted
string — no amount of matching the prelude text could have seen it, which is
also why the string-based "no cwd candidate" assertions could not.

Second: `checkpointPrompt` took the version as a plumbing argument with a silent
'' default, and it was duly forgotten at one of three call sites — deleting it
there left every gate green while that engine's checkpoints refused exactly as
before the fix, with finalize and prior-round succeeding beside them. It derives
from the payload now, the way the record builder derives from the record, so
there is nothing to forget. craftVersion rides on every checkpoint slice as a
result, which it should have anyway: a slice that cannot say which build wrote
it is a slice `recover` cannot place.

Third: the version reached the shell unquoted in the refusal message. The search
itself was quoted, so the injection sat in the one place nobody looks — the
error path. Both are quoted now.

Worth recording, because it cost a round: the test written to catch that
injection first reported a breach that was not there. The refusal message quotes
the version back verbatim, so a naive /PWNED/ match cannot tell execution from
mention. It now asserts on a line that IS the word. A falsifier that goes red is
no more trustworthy than one that stays green — both need reading.
Fourth round. The invariant this branch states — nothing may resolve the logger
against the reviewed repository — was applied to one of the three ways
CRAFT_LOGGER gets set.

An explicit `craftRoot` returned EARLY, before the absoluteness check and before
the refusal. `craftRoot` arrives in the model-composed args string, so a review
launched with `craftRoot=.` against an untrusted repo shipping
lib/craft-log-run.mjs emitted `CRAFT_LOGGER='.'/lib/craft-log-run.mjs` and then
`cd <reviewed repo> && node "$CRAFT_LOGGER"` — the removed `:-.` hole restored
verbatim, bypassing the version pin and the loud refusal with it. Executed, it
printed PWNED with rc 0. `${CLAUDE_PLUGIN_ROOT:-}` had no absoluteness check
either. The shape predates the branch; stating the invariant while leaving two
doors open is what made it this branch's problem.

Every candidate now runs the same three checks — absolute, present, ordered
(explicit root, environment, then this engine's own installed copy) — and any
miss falls through to the same refusal. Verified by executing the emitted block
from inside a planted hostile repo for all four attacks and both legitimate
paths.

Two tests were pinning the unguarded shape as correct, so the gate was defending
the hole: both asserted the exact early-return one-liner. They assert the
property now — an explicit root is consulted, and is guarded like everything
else.

And the injection test's payload set was inert under the very defect it names:
stripping the quoting left all of `"; echo …`, `$(…)` and backticks harmless —
the first opens a quote that swallows to the next one, the other two have their
output assigned rather than printed. The payload that actually executes,
`1.0.0; echo PWNED; :`, was missing. A falsifier that cannot go red is an
assertion that cannot fail, one layer out; it is now the first payload in the
list and reds on the stripped quoting.
…e reviewed repo

The comment promised more than the code gave. It called craftRoot "exactly as
untrusted as the --dir this project already refuses", but the pipeline enforced
only absoluteness — and an absolute path may still name the repository under
review. Executed: craftRoot=<the repo>, craftRoot=<repo>/lib/.., and a symlink
pointing at the repo all ran the repo's own script, rc 0.

So the resolved logger is now compared against the directory the command is
about to cd into and refused if it lies within it, with pwd -P on both sides.
That normalization is what makes the three routes one case: a `..` climb and a
symlink into the repo are indistinguishable from naming it outright. This is the
constraint --dir has carried since 226adc4; the logger path had half of it.

Also from the same review, and it is the sharper lesson: two assertions carried
an alternation whose second branch was the PRE-FIX spelling. `engine-harness`
is the only cross-engine guard on the logger command, and with
`|CLAUDE_PLUGIN_ROOT:\?` in it a FULL revert of this branch left the suite
green. Both dead branches removed — reverting the prelude to main's now reds 17
tests. A branch that can only be satisfied by the old shape is not a fallback,
it is a blind spot, and it sat on the one guard that is supposed to notice the
whole class coming back.

Realm #45 updated: the route actually taken is a third one, neither of the two
the question named — the engine searches for its own version in the harness's
plugin cache (#48, observed rather than documented). Recorded with the reason it
beats "name the mode unsupported": that mode is not unsupported, it is the one
the plugin actually runs in at a consumer. The closing condition is unchanged
and still unmet — it needs a live run of an INSTALLED build carrying this fix,
which cannot exist before a release.
A symlinked FILE pointing into the reviewed repository still passed: `pwd -P`
resolved the DIRECTORY and the basename was re-appended unresolved, so the check
compared a path that was never the one node would open. The comment and the
realm node both claimed symlinks were covered; only symlinked directories were.
The test written for it covered three routes and stopped one short of the
fourth, which is why the gate was green.

Underneath that: this property has now been got wrong three separate times, each
in a place the previous fix did not name — a string prefix that called
`/x/store-evil` a child of `/x/store`; a check applied to CLAUDE_CONFIG_DIR
while an explicit craftRoot skipped it; and this one. Each fix was correct and
each left a sibling wrong, because the property was tested where it had just
been repaired.

So the question is named once. `containsPath(parent, child)` answers it in JS,
resolving symlinks as far as the path exists (realpathSync throws on a path not
yet created, which is ordinary for a run directory), and `insideStore` is now
one caller of it. The shell prelude answers the same question through
`craft_usable`, which every candidate goes through — absolute, present, outside
the repo — with symlink chains followed to their target and both sides
normalized. `lib/path-containment.test.mjs` holds ONE table of eight cases and
runs it against BOTH: plainly inside, a sibling sharing a prefix, a `..` climb,
a symlinked directory, a symlinked file, a chain of symlinks, an unrelated
directory, a symlinked parent. Falsified per side and per historical bug:
dropping the slash anchor reds the sibling row, dropping symlink resolution reds
the three symlink rows, in whichever implementation it is dropped.

Making the predicate per-candidate also fixed a second finding: containment was
a terminal `exit 1` on the winner, so an explicit craftRoot naming the repo
killed the whole command instead of falling through to the environment and the
installed copy. That is craft reviewing its own checkout — the mode this repo
mandates for itself — filing zero records. Now the candidate is rejected and the
next one is tried.

Two assertions were guarding nothing. `split('cd ')[1]` took the first `cd ` in
the prompt, which since the containment preamble is `$(cd <repo> && pwd -P)` in
a subshell — three lines mentioning no logger at all. Reintroducing the original
vulnerability left it green; both are anchored on the command line that actually
enters the repo now, and that mutation reds four tests.

And one mechanical trap worth recording: a line that is exactly `}` at column 0
ends an extracted inline region, so the shell function's closing brace truncated
the region and the four engines got an unbalanced copy. The checker caught it —
by compiling the region, not by comparing bytes. The brace is indented.
Seventh round, and the finding is a hole this branch introduced rather than
inherited. The symlink walk stopped after sixteen hops and then carried on: the
path was still a symlink, the containment comparison tested that unresolved
string, nothing matched, and the candidate was accepted. A chain of twenty-one
links leaving the reviewed repository and returning to it executed the repo's
script with the user's privileges — demonstrated, not reasoned about. Since the
explicit craftRoot candidate is tried first, it also beat a perfectly good
CLAUDE_PLUGIN_ROOT. Every exit from the predicate that is not a fully resolved
path outside the repo is now a refusal.

The eight-case table could not see it: it pinned the three historical bugs and
stopped at the boundary the new implementation itself introduced. That is the
same shape one level up — coverage aimed at where the property was last
repaired. There is a ninth row now, and building it taught something worth
keeping: with the hops inside the repo the row does not discriminate, because a
walk that gives up early still leaves a path under the repo and the comparison
catches it anyway. Only a chain that leaves and returns makes full resolution
the one thing standing in the way, which is how the attack was actually shown.

Two tests were green for the wrong reason and one is now genuinely green:
- the end-to-end "the reviewed repository never executes" passed with the
  symlink walk fully disabled, because `node` is not on PATH in the scrubbed
  env, so PWNED could not print either way. It uses `process.execPath` now and
  carries a positive control: the same fixture must be shown able to execute a
  legitimate logger, or an absence proves nothing.
- `shellSaysContained` read any non-zero exit as "contained", so a prelude with
  a syntax error would have satisfied six rows for a reason unrelated to
  containment. It reads the FAILED marker now, and throws if the prelude neither
  resolved nor refused.

And two guards are labelled belt-and-braces rather than dressed up as covered:
with CRAFT_REPO empty the case pattern degenerates to `/*` and refuses every
absolute path anyway, and `pwd -P` can only fail where `[ -f ]` already did.
Removing either changes no observable behaviour, so no test distinguishes them —
said here instead of implied by a test that would pass either way.
…he predicate

Eighth round. The first finding is this branch's own shape sitting in this
branch's own test file.

`EVERY way of naming the logger is refused when it is not absolute` passed with
the absoluteness guard DELETED — every case omitted `repo`, so it defaulted to
`.` and containment resolved it to the cwd, which the test had set to the
planted directory. Every refusal therefore came from containment, and the only
assertion this branch has against defect #45's own class could not see that
class. The cases now declare a repo that is NOT the cwd, so the relative
candidates land somewhere containment has nothing to say about and only
absoluteness can refuse them; they also assert that nothing in the cwd
executed. Deleting the guard now reds exactly this test.

Second: a repo of `/` inverted the predicate into an allow-all, on both sides.
`"$CRAFT_REPO"/*` becomes `//*`, which matches no ordinary path, so every
candidate read as outside; `containsPath` compared against `//` and answered the
same. The root contains everything and nothing can be outside it — degenerate
input, and precisely where a guard must fail closed rather than open. `repoArg`
comes from the model-composed args string, the same untrusted channel as
craftRoot. Both sides fixed, and the shared table has a row for it: asked of one
implementation only, the wrong answer would have looked like agreement.

Third: `CRAFT_LOGGER` was assigned the unresolved candidate while the predicate
had validated the resolved one. Between the check and `node "$CRAFT_LOGGER"` sit
the mktemp, the whole heredoc of a record that can be hundreds of kilobytes, and
the cd — a window in which a symlink component of the candidate can be
re-pointed into the reviewed repository. The resolved path is handed over now.

Three tests compared against unresolved temp paths and started failing for that
reason: on macOS /var is a symlink to /private/var, so a resolved actual against
an unresolved expected reports a difference the code did not make. They realpath
their fixtures now.

The root row is deliberately skipped by the end-to-end execution check: with the
declared repo `/`, nothing can be outside it, so the positive control that test
depends on has no possible fixture. Skipping a case whose control cannot exist
is honest; skipping one whose control merely fails would not be.
…ed every error

Ninth round. The first finding is the branch's own pattern reproduced inside the
very commit that fixed another instance of it: `d2f5232` added the
resolved-vs-unresolved handover and shipped it untested. Reverting
`CRAFT_LOGGER="$CRAFT_REAL"` to `"$CRAFT_TRY"` — the check-then-use window fully
restored across the mktemp, the record heredoc and the cd — left all 476 tests
green. Not a race to reproduce either: the difference is directly observable, so
one assertion on a symlinked fixture pins it. That is how the `:-.` hole and the
absoluteness hole each came back, and it was about to be how this one did.

Second, and the same shape one layer down: `resolveAsFarAsItExists` caught EVERY
realpathSync error while its comment named only the not-yet-created case. A
symlink loop, an unreadable parent or a bad mount therefore degraded into a
purely lexical answer, and `insideStore` reported a path as inside the store
that resolves nowhere near it. Only ENOENT is walked past now; anything else is
unresolvable, and `containsPath` returns no verdict rather than inventing one —
callers read it to decide whether a path is safe to act on, and "I could not
tell" must never arrive dressed as "no".

Both falsified: reverting either reds exactly its own test.

One input is named in the table's header rather than given a row, because the
two implementations genuinely disagree on it: a candidate that does not EXIST is
refused by the shell at `[ -f ]` and answered lexically by the JS side. Every row
builds a real file. A row asserting an agreement that does not hold would have
been worse than the note.
…left open

The checkpoint builder's version derivation got a test when it was made
underivable-by-forgetting; its twin on the record path did not. Replacing it
with '' left all 478 tests and every gate green while the plugin-cache search
stopped being emitted for `write` and `finalize` in all four engines — defect
#45 walking back in through the front door of the branch that exists to close
it. Every loggerPrelude test passes a literal version, so nothing observed the
one wiring that takes it off the record.

Three lines, red on the reverted derivation. Recorded because the shape is the
one this branch keeps meeting: a fix applied to two places and tested in one.
@gurinderu
gurinderu merged commit e74d4a7 into main Sep 4, 2026
1 check 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