Skip to content

fix: self-test findings across adapters, engine, graph, commands and handoff - #5

Merged
datj9 merged 39 commits into
mainfrom
fix/self-test-findings
Aug 26, 2026
Merged

datj9 merged 39 commits into
mainfrom
fix/self-test-findings

Conversation

@datj9

@datj9 datj9 commented Aug 23, 2026

Copy link
Copy Markdown
Owner

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 timeoutSec actually 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 adapter on command and human nodes, at validate time.

Commandsevents rejects an unknown --kind instead of printing nothing; resume rejects --answer for unknown or non-paused node ids; report validates --visibility at 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.txt is repo-relative only and never drops entries silently; push exits 1 when the bundle directory is missing; --expires is validated as a real date; scanner catches the auth-header, .netrc and auth.json shapes it missed, treats a bare key assignment 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 test green (2316 insertions include tests for every fix above)
  • npm run lint / typecheck green
  • Manual: run a handoff bundle and confirm hostname + credential redaction
  • Manual: confirm a run aborts at timeoutSec and the child process group dies

🤖 Generated with Claude Code

datj9 and others added 30 commits August 23, 2026 08:36
… 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>
datj9 and others added 8 commits August 23, 2026 10:44
…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>
@datj9

datj9 commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Review — verified locally

npm run typecheck clean, npm test green (25 files / 445 tests) on fix/self-test-findings at 3a5b6de. Read the full non-test diff (~1760 lines) across adapters, core, commands and handoff.

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 (reservedNodeRuns) is the right fix for the concurrent-batch overshoot, and prepareDispatch correctly moves interpolation ahead of the reservation so a TemplateError charges no node run.

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 lg-handoff scan exit 2 and push refuse, on ordinary text.

Blocking — verified by running scanText against this branch

"I edited .claude/settings.local.json to allow it"  => residual-local-hostname
"check the .env.local file"                          => residual-local-hostname
"Password changed"                                   => netrc-credentials
"login failed"                                       => netrc-credentials
'"key": "value"'                                     => env-assignment
'the config uses "key": "id" for lookups'            => env-assignment
scanText("handed off by user on the box", {username:"user"}) => residual-username

1. residual-local-hostname fires on every *.local.* filename. src/handoff/scan.ts — the pattern \b[A-Za-z0-9](...)?\.local\b cannot tell a Bonjour hostname from settings.local.json, .env.local, next.config.local.ts. This repo's own workflow mentions settings.local.json, so a handoff of a session about the harness self-blocks. Anchor it: require .local to end the token (\.local(?![A-Za-z0-9._-])) and require the label to look like a hostname rather than a filename stem — or drop the rule and rely on residual-hostname, which now covers the real case with the actual hostname.

2. netrc-credentials's second alternative fires on two-word prose. ^\s*(?:login|password)\s+\S+\s*$ matches any standalone line of exactly login <word> or password <word>Password changed, login failed, login required. The comment argues prose has "more than one word after the keyword"; two-word lines are the counterexample, and they are common in a transcript or a quoted git log. Tighten the value instead: require something credential-shaped (length ≥ 8, or containing a non-lowercase-letter), the same trick the auth-header rule already uses successfully.

3. Adding bare KEY to env-assignment catches every JSON "key" field. The 8-char minimum only guards the unquoted branch; the quoted branch "[^"\s]+" has no length floor, so "key": "id" fires. Any transcript that pastes JSON with a key field is blocked. Either apply the same minimum length to the quoted branch, or restrict bare KEY to the unquoted/whitespace-delimited shape you actually needed for .netrc/auth.json. Side note: with KEY in the alternation, API[_-]?KEY is now redundant.

4. residual-username self-fires when the OS account is literally user. replaceUsernameToken rewrites the username to the literal user; identityRules then flags every standalone user token. On a container, a CI runner, or any box where whoami is user, every bundle reports a finding it created itself and can never be pushed. Skip the rule when username equals the placeholder — and consider the same for other placeholder-colliding accounts (admin, runner, ubuntu are all ≥3 chars and appear in prose).

Non-blocking

5. The wall-clock ceiling is no longer a ceiling at 0. src/core/budget.ts — the doc says node-runs and wall-clock are both "projected forward at admission time", but only node-runs are. With maxWallClockSec: 0, elapsed (0) > 0 is false, so a batch is admitted. That is precisely the argument the same comment uses to keep maxUsd inclusive; wall-clock cannot be projected either. Either keep >= for wall-clock, or special-case 0.

6. The SIGKILL escalation can be disarmed before it fires. src/adapters/types.ts — on timeout, killGroup(SIGTERM) runs, the escalation timer is armed for 2s, then the finally block deletes the pid from liveChildPids. If the parent exits inside that 2s window, the exit handler no longer knows the pid and the unref'd escalation timer dies with the process — so a grandchild that ignored SIGTERM survives, which is the case the commit set out to kill. Keep the pid tracked until the escalation resolves, or have the exit handler drain a separate "pending kill" set.

7. Library-level signal handlers change host behavior. Same file — installLifecycleHandlersOnce registers SIGINT/SIGTERM handlers that call process.exit(130/143). Any embedding host (or a future graceful-checkpoint-on-interrupt path in execute) loses its chance to run: the exit is immediate and synchronous. Worth confining to the CLI entrypoint, or gating behind an opt-in so the adapter module stays a library.

8. report --publish --out foo.html publishes an artifact with no index.html. src/commands/report.ts — staging by basename(htmlPath) is right for the default (index.html), but with a custom --out the enclave artifact has no entry page. Copy it in as index.html regardless of the local filename.

Nits

  • containsPassToken with pass: "" builds an empty regex that matches everything. Worth a guard (or a schema min(1)) so a blank pass: cannot turn a verifier into a no-op.
  • writeBundle's excluded is assigned inside the key loop and would be overwritten if a second key ever needed sanitizing. Correct today; fragile if BundleFiles grows. Accumulate instead of assign.
  • detached: true + process.kill(-pid) is a no-op on Windows; the fallback to process.kill(pid) handles it, but the process-group guarantee in the doc comment only holds on POSIX. Worth saying so.
  • The unchecked items in the test plan are now partly verifiable: typecheck and test are green. The two manual items (a real timeout kill, hostname/credential redaction on a live bundle) are the ones still worth doing by hand.

Findings 1–4 are the merge blockers; the rest can land as follow-ups.

…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 datj9-reader left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 files
  • npx 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

  1. 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.
  2. Unquoted assignment values below 8 chars (API_TOKEN=abc1234) do not fire. The quoted form API_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.

@datj9
datj9 merged commit fdb1aa2 into main Aug 26, 2026
3 checks passed
@datj9
datj9 deleted the fix/self-test-findings branch August 26, 2026 15:22
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.

2 participants