fix: self-test findings across adapters, engine, graph, commands and handoff - #5
Conversation
… run execa's timeout signals the direct child only. A grandchild inherits the stdout pipe, the pipe never closes, and the run stays blocked for the command's full duration - timeoutSec: 2 on a command that backgrounds sleep 30 finished at +30s and only relabelled the result as a timeout. runProcess spawns detached so the child leads its own process group, then SIGTERMs the group at the deadline and escalates to SIGKILL. timedOut is now the helper's own flag: the shell often exits 0 before the deadline while an orphan keeps the run alive, so the exit code cannot be trusted to say whether the run completed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, clamp costs H1: claude and opencode now go through runProcess, so timeoutSec kills the whole process group. A CLI that leaves a grandchild holding stdout used to keep the node blocked for the command's full duration. M11: execa with reject:false swallowed the spawn error, so a missing binary surfaced as "could not parse claude json output:" and never said which binary was missing. ENOENT now fails with "<bin> not found on PATH". L3: a negative or non-finite reported price is recorded as 0 instead of driving the run budget backwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…issed
The JSON-encoded Authorization header could not be seen at all: the rule went
straight from the header name to the scheme, so the quote after the colon in
{"Authorization":"Bearer ..."} ended the match before it started. A .netrc row
carries no separator any assignment rule can key on, and an opencode auth.json
stores its OAuth material under refresh/access/credential. Every one of these
produced "scan clean" on a bundle that was carrying a live credential.
Excerpts stay masked to four characters, so a finding still cannot be pasted
into a report and read as the secret it warns about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zod strips unknown keys, so `adapter:` on a command or human node was silently accepted and the node ran as a plain shell command with no warning. Extend the existing `model` guard to cover `adapter`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… sandbox env H2: bwrap fails per tool call, not at startup, so a broken sandbox produces codex exit 0 plus a confident agent message from a run that read nothing - 'I could not read any files, but nothing looks wrong. PASS' was scored as a pass. detectSandboxFailure now looks for a line starting with 'bwrap:' on stderr AND stdout, regardless of exit code, and fails the node with that line. Anchoring to the line prefix keeps an agent message that merely discusses bwrap from failing the node. L2: LOOMGRAPH_CODEX_SANDBOX was cast, not validated - 'danger-full-access' read as a wider policy that was never applied and 'READ-ONLY' failed inside codex. resolveCodexSandbox rejects anything but the three documented values. H1/M11/L3 for codex: runProcess for the group-kill timeout, ENOENT reported as '<bin> not found on PATH', reported cost clamped to >= 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An --answer for a node id that does not exist, or that exists but is not awaiting an answer, was silently discarded (exit 4, no warning), so a typo was indistinguishable from a correct answer. Validate both before resuming and exit 1 naming the node id. Also stop delegating --answer pair parsing to the shared --var parser, which leaked "--var expects key=value" into the --answer error message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The assignment rule only knew names ending in TOKEN, SECRET, PASSWD, PASSWORD
or API_KEY, so `key=...` and `{"key": "..."}` - the shape several agent config
files actually use - walked past the scanner. API-KEY with a hyphen was missed
for the same reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lg validate accepted {{nodes.nope.output}} and exited 0; the run then died
mid-flight with 'unknown template reference' and was left stranded. Check
every {{...}} reference at graph load time against the resolver's own
grammar and the graph's declared node ids.
Var references are deliberately not checked: lg run --var injects vars the
graph never declares, so an undeclared var reference is not statically
decidable. Also fixes 'a adapter' -> 'an adapter' in the node-type guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The optional name prefix was unanchored, so at every offset in a line the engine consumed to end-of-line looking for an underscore. A 64k single-character line took 8.3 seconds and doubled fourfold per doubling; it now takes 2 milliseconds. A lookbehind pins the name to a real word start, which fixes the cost and also stops `monkey=` being read as `key=`. An unquoted value must now be at least eight characters. A quoted value is a config value at any length, but a short bare word is prose: the line "Standalone token: user" was reported as a secret and blocked a pack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A raw substring test let PASSWORD and BYPASS satisfy pass: "PASS". Match the pass string as a whole token instead, escaping regex metacharacters so a value like v1.0 stays literal. Case-sensitive, as documented.
Path rewriting removed the repo root, the home directory and the username but left the machine name in place, so every bundle published the box it was built on. Both forms go: what `hostname` prints and what `hostname -s` prints, the long form first so no dangling `.local` is left behind. The boundary is host-shaped rather than path-shaped, because a hostname may contain `.` and `-` - `web1` must not be pulled out of `web10` or `xweb1`. The option is optional, so callers that do not pass one are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M8. `lg report <runId> --out ./r1.html --publish` handed dirname(--out) to enclave, which in practice meant the whole working directory: the graph yaml, the entire .loomgraph state dir, and every other report. One flag, a mass upload. Publish now stages a fresh mkdtemp directory the tool owns, copies only the generated report into it, hands that to enclave, and removes it in a finally so a failed push does not leak the staging dir. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maxNodeRuns N now permits N runs and maxUsd 2.00 permits exactly 2.00; only going over a ceiling stops the run. Owner decision. Updates the tests that encoded the old inclusive rule.
maxNodeRuns bounded only batch boundaries, so a 4-way fan-out ran every node and landed every side effect before the run reported it was over budget. Admit nodes one at a time against a projected spend and stop dispatching the moment the ceiling would be crossed.
M7. The "private" | "org" union was compile-time only, so `--visibility public` and `--visibility hackerman` were forwarded verbatim to enclave and the command exited 0. Publishing a run report as public is a leak. Refuse anything but private or org before the staging dir is created or enclave is spawned, exit 1, and match the refusal wording lg-handoff push already uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A retry is a node run, but the retry loop never consulted the budget, so maxNodeRuns 2 with retries 2 reached 4 runs and emitted no budget_exceeded. Check the projected spend before each attempt and stop through the same budget-exceeded path the admission pass uses.
L9. The generated run report had no <meta charset> and no lang on <html>, so any non-ASCII character in a prompt, an agent's output or an error message was left to browser encoding guesswork, and screen readers had no language to pick a voice from. charset is now the first child of <head>, well inside the first 1024 bytes where it is actually honoured. Two existing assertions on the lowercase <!doctype html> were updated to the canonical uppercase form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lved
An unresolvable {{...}} threw straight out of execute(), leaving the run
stranded at status running with no node_finished and no run_finished.
Route it through the normal node-failure path via a TemplateError, and
do not retry a deterministic error.
The reviewer literally saw {{nodes.pre.output}}. Run the question
through the same resolver agent prompts use, and fail the run instead of
pausing when the reference cannot be resolved.
L7. `lg events <id> --kind node_startedd` exited 0 with empty output, which reads exactly like "there are no events of that kind". A typo and a genuine empty result were indistinguishable. --kind is now validated against the 9 documented event kinds, held in one exported EVENT_KINDS const, before any event is read. An unknown kind names itself on stderr, lists the valid set, and exits 1. A valid kind with zero matches still exits 0 with empty output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reported reset does not reproduce: execute() resumes from the persisted checkpoint and keeps counting. Pin that with a kill/resume regression test, and record the real accounting gap - an attempt that was in flight when the process died emits node_started but is never committed, so the event count runs ahead of spent.nodeRuns.
M9. files.txt is documented as a repo-relative manifest but nothing enforced it. A bare absolute path outside the repo - /opt/vendor/data/config.json - survived into the bundle untouched and unflagged, publishing the host filesystem layout. Windows absolute paths and `..` escapes did the same. Every line is now normalised to a repo-relative path with forward slashes. Anything that cannot be expressed relative to the repo root is dropped, and packCommand logs one warning per dropped entry naming it - a short files.txt must not be mistakable for "nothing else was touched". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L6. `lg-handoff push /typo` exited 2 while `lg-handoff scan /typo` exited 1, so the same typo read as two different classes of problem depending on the verb. The README table says 1. push now checks the directory exists before anything else and reports it the way scan already does. Narrowly scoped: the catch-all 2 for an unexpected push failure is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L5. isValidExpires only matched a shape, so 2026-13-45 and 2026-02-30 passed the local gate and became an enclave error after the artifact was already published - the one moment the gate exists to prevent. Dates now round-trip through Date.UTC so rollover cannot smuggle an impossible day through, the optional clock part is bounds-checked, and a zero duration (0d) is rejected: it expires the link the instant it is minted, which is always a typo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L10 the quickstart transcript omitted the cost note the CLI actually prints after the budget line. H3/C2 the budget ceilings are now exclusive, and they are checked before every single node - each fan-out member and each retry attempt - so "Nothing further is dispatched" is literally true. M5 a human node's field is `question`, not `prompt`, and it is now template-interpolated. Documented with an example; using `prompt` on one hard-fails and the README never said so. L1 a failed run cannot be resumed at all. Undocumented, and it cuts against the opening pitch, so it gets its own subsection. H1 timeoutSec kills the process group. H2 a `bwrap:` line on either stream fails a codex verifier regardless of exit code. L2 the sandbox env var is validated. M1 `adapter:` on a command/human node is a validation error. M3 validate catches unknown node references but not unknown vars. M6 a bad `--answer` node id exits 1. M7 `--visibility` and L7 `--kind` are validated. Scanner: the JSON auth header, .netrc rows, opencode auth.json material and bare `key=` assignments are no longer gaps. Added the one new gap the fix created - an unquoted value under eight characters - and kept the honest framing and the four shapes still missed. Hostname rewriting is NOT documented: scan.ts implements it behind an optional parameter, but nothing passes os.hostname() to rewritePaths yet, so hostnames still reach a published bundle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ite is not inert S1 added hostname rewriting behind an optional rewritePaths parameter; nothing passed one, so hostnames still reached a published brief. Thread os.hostname() through redactSession and pin it with a pack-level test. Also retarget the M2 engine test: lg validate now rejects an unknown node reference at parse time, so that graph can no longer be built from source. The engine keeps its runtime guard because execute() is a library entry point.
The multi-line netrc(5) form could never match: scanText runs every rule per line, but netrc-credentials required machine/login/password on one. auth-header only knew Bearer and Basic, so token, ApiKey, Digest and AWS SigV4 all published in the clear. The hostname and username rewrites were case-sensitive with no residual rule behind them, so a case-mismatched occurrence shipped while the CLI reported the scan clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta.json went into the bundle unredacted, so an arbitrary --title could carry a home path, hostname or username into a published artifact, and createdBy shipped the OS account name that every other path scrubs. Routes title, sessionId, createdBy and the repo remote/branch through the same rules, and picks up session.model and session.warnings, which render verbatim into the brief and were leaking the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…roup detached:true bought the process-group kill but disabled execa's cleanup and moved the child out of the terminal's foreground group, so Ctrl-C on lg run left a long-running agent spending budget with nothing tracking it. Tracks live children and kills them from exit/SIGINT/SIGTERM. Separately, the SIGKILL escalation was cleared as soon as the direct child exited, so a SIGTERM-ignoring grandchild survived a reported timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each node projected its attempts against a snapshot of shared state that only commit updates, so siblings in a batch were invisible to each other and the ceiling overshot by roughly the batch width - 9 dispatches against a maxNodeRuns of 5. Attempts now reserve against a shared counter that the batch loop settles as each result lands. Also: maxUsd is inclusive again, since dollars are the one ceiling admission cannot project forward; template validation covers a human node's question and no longer accepts Object.prototype keys as node references; a pass string is only word-bounded on an edge that is itself a word char; and a template error no longer charges a node run or announces a dispatch that never happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A run that hit a ceiling and had a node fail for a real reason in the same batch reported only the budget message, so an operator raised the ceiling and hit the same wall. Two causes: the finish reason returned early on the budget branch, and - the deeper one - a budget-refused retry overwrote the node's own lastError with the budget reason, destroying the real error before the reason was ever built. A node now keeps the error from its last real attempt, and the finish reason names both causes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…annot stall the scan An unbounded scheme class before the :// anchor backtracked from every word boundary, so a 100k-char line of hyphenated text containing no credentials at all took ~4.3s - a scanner that runs on arbitrary bundle content should not be a denial of service. Bounding the scheme to 21 chars brings that to ~2ms and still covers every real scheme. stripUrlCredentials had the same shape and is fixed with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scan pass could not see a leftover hostname or username because it was never told what they were, so a missed replacement published while the CLI reported the bundle clean. scanText and scanBundleDir now take an optional known identity, and packCommand passes the same values it redacted with, so it cannot scan blind. This caught a real gap: the username replacement's boundary set does not include a comma, so a username followed by one was being left in the clear. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review — verified locally
The shape of this is good: every fix is narrow, every non-obvious choice carries a comment explaining the failure it prevents, and the regex hardening (bounded scheme quantifier, anchored prefix) fixes real ReDoS with concrete measurements. The budget reservation counter ( What I'd fix before merge is concentrated in one place: the four new/loosened scanner rules are too broad, and the scanner is fail-closed. Each false positive below makes Blocking — verified by running
|
…nnot self-collide
The fixture builds `... but never on ${short}-ci` as the control string that
redaction must leave alone. On a host with no dot, `host === short`, so the
negative `not.toContain(\`on ${host}\`)` matched inside that very control
string and failed even though the rewrite was correct. Every GitHub runner has
a single-label hostname, so CI was red on both node 22 and 24 while the code
under test was fine.
Assert with the same right-hand boundary the rewrite uses - the hostname must
not be followed by a hostname character - which is the property actually under
test. Verified by mutation: disabling the rewrite in rewritePaths still fails
this test.
datj9-reader
left a comment
There was a problem hiding this comment.
Verdict: approve
Reviewed by re-running the branch and probing the two claims that matter most (secret scanner, concurrency ceiling) rather than reading them.
Verified by execution
npx vitest run-> 445/445 pass, 25 filesnpx tsc --noEmit-> 0 errors- Worktree clean at
82e2e9e
Concurrency ceiling actually holds. I built a graph fanning one entry node out to 10 agent nodes so all 10 become ready in the same batch, with maxNodeRuns: 3:
ceiling=3 fanout=10 agent_dispatches=3 peak_concurrent=2 nodeRuns=3
Exactly 3 dispatched, 7 never spawned. The reservation in engine.ts:274 (recordSpend(state, { nodeRuns: reservedNodeRuns + 1 }) before dispatch, decremented on result.attempts at :444) is what makes this work under a wide batch. This was the highest-risk change in the diff and it does what it claims.
Scanner: 28/30 adversarial cases. All 11 real credential shapes caught (Anthropic, OpenAI, GitHub, AWS, JWT, PEM, URL creds, bearer, netrc one-line and continuation, auth.json). All 7 prose false-positive guards stayed quiet, including Authorization: see the docs and password reset instructions. Identity backstop catches residual username/hostname on word boundaries but not inside words (dat fires, dataset does not).
rewritePaths -> scanText round-trip leaves zero findings:
cwd ${REPO_ROOT} and ${HOME}/x and ${HOME}\y ran on ${HOSTNAME}
post-rewrite findings: none
No ReDoS. Worst case 15ms on a 100k-char hyphen run; the other three pathological inputs were 1-2ms.
The 2 scanner misses, both narrow
Authorization: Bearer <lowercase-letters-only>does not fire. Needs a digit, uppercase,-,_or=anywhere in the value to match. A hex token, UUID, base64 or Basic credential all fire, so a realistic token is caught; a token that is purely[a-z]+is not.- Unquoted assignment values below 8 chars (
API_TOKEN=abc1234) do not fire. The quoted formAPI_TOKEN="1234567"does.
Both look like deliberate false-positive tradeoffs and neither is worth blocking on. Worth a comment in SCAN_RULES naming the lowercase-only gap so a future widening is a decision rather than an accident.
Notes
- Graph validation correctly rejects unknown adapters with the node id in the message (
node "n0": adapter: Invalid option: expected one of "claude"|"codex"|"opencode"), matching the "validation stays loud" rule. - Commit authorship on all 33 commits resolves to
datj9.
Summary
Fixes found by running loomgraph against itself (self-test). 31 commits across five parallel slices, merged into one branch.
What changed
Adapters — kill the process group so
timeoutSecactually aborts a run; name the missing binary instead of a generic ENOENT; clamp negative/NaN costs; validate the codex sandbox env and fail a verifier whose sandbox is broken; match verifier pass strings on word boundaries.Engine / budget — gate the node-run ceiling before every dispatch and re-check the budget on every retry attempt; make the three ceilings mutually exclusive; fail the run when a template reference cannot be resolved; interpolate a human node's question.
Graph validation — reject unknown template node references, and reject
adapteroncommandandhumannodes, at validate time.Commands —
eventsrejects an unknown--kindinstead of printing nothing;resumerejects--answerfor unknown or non-paused node ids;reportvalidates--visibilityat runtime and publishes only the report, not its parent directory; report HTML declares charset and language.Handoff — rewrite the machine hostname out of a bundle (and wire the hostname through so the rewrite is not inert);
files.txtis repo-relative only and never drops entries silently;pushexits 1 when the bundle directory is missing;--expiresis validated as a real date; scanner catches the auth-header,.netrcandauth.jsonshapes it missed, treats a barekeyassignment as a credential, and the assignment rule is linear so it no longer fires on prose.Docs — README updated to match the landed behavior.
Test plan
npm testgreen (2316 insertions include tests for every fix above)npm run lint/ typecheck greentimeoutSecand the child process group dies🤖 Generated with Claude Code