Skip to content

fix(security): close all seven security-review findings - #53

Merged
ojassug merged 4 commits into
mainfrom
fix/security-review-findings
Sep 4, 2026
Merged

fix(security): close all seven security-review findings#53
ojassug merged 4 commits into
mainfrom
fix/security-review-findings

Conversation

@ojassug

@ojassug ojassug commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Closes all seven findings from the 2026-08-30 security review, and records Session 4b.

The review ran four sessions, the last an independent falsification pass that reproduced every finding against a build, downgraded one, and retracted one of the report's own narrowings. docs/security-review-2026-08-30.md carries the full record; this PR is the remediation.

What changed

ID Sev Fix How it was verified
F-01 Med Per-connection session id replaces the shared 'default-session' literal Two connections now produce two sessions where they produced one; keep-alive keeps a client's second turn in its own
F-02 Med Refuse hash refs shorter than ELISION_HASH_PREFIX_LENGTH, mirroring TokenHasher.resolve Empty and 1–11 character refs now resolve to nothing; the real marker path still resolves
F-03 Low stderr warning naming ingested files git is ignoring R-04's fixture: all three secret-bearing files named; a clean tree stays silent
F-04 Low --diff-html report written 0600, then narrowed again ext4 under WSL2: fresh file 600, pre-existing 644 report narrowed to 600
F-05 Low Dropped directives reported as offset + length + digest, not verbatim text Secret absent from the trace on both the CLI and MCP routes, across both affected populations
F-06 Low Line breaks escaped in the envelope label POSIX, real newline-bearing filename: three files now produce three headers, not four
F-07 Low README section stating markers are unauthenticated

Each fix lands with the test that would have caught it, per oxaudit-split.md §9 — test/unit/security-review-findings.test.ts, 22 cases.

Design calls

Four were the user's, taken before any code was written, because they are product decisions rather than defects with an obvious fix:

  • F-01 fixes the default collision only. It does not authenticate. A client naming an explicit x-session-id can still bind to any id including another client's — unchanged, and the documented exec trust boundary. Session state is also still mutated before any credential check, so a request ending in 401 has already written to the store. Recorded in the CHANGELOG, not fixed.
  • F-05 uses a stable excerpt, not truncation. Truncation loses on the reproduction's own numbers: the secret starts at character 24 of a 53-character line, so any cap wide enough to stay readable still emits it.
  • F-06 escapes the label only. A delimiter-shaped line in file content still passes through, because escaping content would corrupt the bytes this tool exists to deliver intact. Documented instead.
  • F-07 is documented, not fixed with a nonce. A per-run nonce would change emitted bytes every run and make output non-deterministic across runs, colliding with invariant 1.

Output impact

Optimized stdout is unchanged. F-03 writes to stderr only (verified: zero occurrences of the warning on stdout, header count unchanged), F-01 is off the optimize route entirely, F-05 changes the trace on stderr, and F-07 is documentation. The one change that can touch stdout — F-06's label escaping — alters only paths containing CR or LF: 0 of 70 corpus files. That is a real fix the instrument cannot see rather than an inert one, in the shape of OX-L7.

No corpus run is included for that reason; the affected populations are named above instead.

Two implementation notes worth reading

Both of these were wrong on first write and only testing caught them:

  • F-03's git check-ignore ran in the process's own cwd, so optimize /other/repo reported nothing at all — silently, which is precisely the failure shape the warning exists to prevent. It now runs in the tree being read.
  • -z is load-bearing. Without it, core.quotePath C-quotes every Windows path, so the warning printed "C:\\Users\\..." rather than something a caller can act on. Paths go over stdin with execFile and an argument array, so no filename ever reaches a command line and a path beginning with - cannot become a flag.

Also in this branch

90721bb adds §8.6 to the review — Session 4b, which falsified Session 3's own work (R-12 and clean-list entries 29–36). All eight hold; four now rest on the full population rather than a sample of one. Notably it adds the Shannon-entropy sweep R-12 said it lacked: 25,512 candidate tokens over 198 commits and 1,050 blobs, 217 high-entropy mixed-case, of which 214 are npm lockfile integrity hashes and 3 are benign identifiers. Zero credentials.

Checks

98 test files / 887 tests pass, 2 skipped (POSIX-only file-mode assertions, verified separately under WSL2). typecheck, lint and build all clean.

🤖 Generated with Claude Code

ojassug and others added 4 commits September 4, 2026 22:15
Closes the gap section 8.5 recorded: Session 3 landed while Session 4 was in
flight, so R-12 and clean-list entries 29-36 had never been attacked. All eight
examined; all eight hold. None retracted, none downgraded.

The value is not the verdicts. Four of the eight rested on a sample of one or
on a narrow pattern, and each now rests on the full population:

- R-12 admitted it did no entropy scoring, so a high-entropy secret in an
  unrecognised format would be missed, and invited disagreement. Added it:
  25,512 candidate tokens over 198 commits / 1,050 blobs, 217 high-entropy
  mixed-case, of which 214 are npm lockfile integrity hashes and 3 are benign
  identifiers. Zero credentials. The admitted limit costs nothing here.
- Entry 30 checked one .js.map for sourcesContent; checked all 70, plus the
  absence of any non-.d.ts TypeScript in the tarball.
- Entry 34 rested on grep "fetch(" - the same narrow shape that already failed
  for clean-list item 22. Redone as an exhaustive builtin-import census: src/
  imports child_process, crypto, events, fs, http, path, stream and nothing
  else. No net, tls, dgram, https or dns anywhere.
- Entry 31 re-measured at v1.7.3: zero issuer-pattern matches in the tarball
  (Session 3's sk-tolerance is a substring of --risk-tolerance and does not
  survive a length-bounded pattern), zero high-entropy tokens from 876.

Also recorded: every credential pattern in the whole of history resolves to one
path, this report, introduced by three commits; a size-filter coverage
hypothesis of my own, tested and disproven (0 blobs >= 2MB); citation drift in
entry 35 (server.ts:71 -> 134), the same v1.6.0-coordinates problem section 8.4
records for Sessions 1-2; and a developer path in tools/corpus-harness that is
committed but not shipped.

Nothing under src/ was modified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… mode

Two findings from the 2026-08-30 security review, both with the test that
would have caught them (oxaudit-split.md section 9). Neither changes optimized
output; no corpus run applies.

F-02 - GatewaySessionStore.getContent resolved an arbitrarily short prefix.
The scan returned stored plaintext whenever exactly one hash started with the
ref, with no minimum length, so ref=a recovered a block in ~16 guesses per hex
digit. Session 4b found it is worse than reported: ref= (empty) recovers it in
zero guesses when the session holds one block, because startsWith('') matches
every hash. Both are reachable from a string, not just an API call, because
normalizeHashOrRef extracts ref= out of a marker-shaped argument.

Refused below ELISION_HASH_PREFIX_LENGTH now, mirroring TokenHasher.resolve,
which has always had the guard. No shipping caller narrows: session-dedup
emits contentHash.slice(0, 12). Full digests still resolve at any length
because the exact-match branch runs first.

F-04 - the --diff-html report embeds every item's full before and after
content and was written with no mode, landing at 0666 & ~umask. Measured on
ext4: 644. Now 0600.

Both a write mode and a following chmodSync, and the second is not redundant:
writeFileSync's mode applies only when the file is created, so overwriting an
existing report kept its older wider mode. Verified on ext4 under WSL2 both
ways - fresh file 600, pre-existing 644 report narrowed to 600. The two POSIX
assertions skip on Windows, where ACLs and not mode bits are the operative
control; README now states both the 0600 guarantee and that the report is a
full plaintext copy.

98 files / 876 tests pass, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more findings from the 2026-08-30 security review, each with the test that
would have caught it.

F-05 - CONSTRAINT_DIRECTIVE_LOST quoted the directive verbatim, and that
message becomes trace.fallbackReason: stderr on every CLI run, and the full
trace returned to an MCP client by get_optimization_trace. The report's own
reproduction shows a secret riding along inside the quoted clause.

Now reported as length, offset and a 12-char digest. Truncation was the other
candidate and loses on measurement: the secret in the repro starts at character
24 of a 53-character line, so any cap wide enough to stay readable still emits
it, and any cap tight enough to suppress it is no longer diagnostic.

Verified against both populations end to end. The markdown case matters: it
reaches this with no elision and no language support at all, through whole-item
hashing, so the report's narrowing to "three languages inside an elided region"
was wrong in the direction of under-reporting.

validation-integration.test.ts asserted the message contained the directive
text. That assertion is now inverted, with the reason at the site - it was
pinning the behaviour F-05 removes. I initially claimed no test depended on
that message, from a grep for the message prefix rather than the directive
text; the wider grep is what found it.

F-06 - the multi-item envelope prints ==> item.path <== unescaped, and a POSIX
filename may contain newlines, so a crafted name planted a second well-formed
header naming a file that does not exist. Demonstrated on ext4: three real
files, four headers, and the attacker's own file appearing only as the
malformed remainder. Line breaks in the label are escaped now: three files,
three headers.

Label vector only. A delimiter-shaped line in file *content* still passes
through, because escaping content would corrupt the bytes this tool exists to
deliver intact; documented instead, and mitigated by shape, since every genuine
label on a shipping CLI route is an absolute path. No corpus row moves - no
corpus file has a newline in its name - which is a real fix the instrument
cannot see rather than an inert one.

98 files / 885 tests pass, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g, F-07 docs

The last three findings from the 2026-08-30 security review. All four remaining
design calls were the user's, taken before any code was written.

F-01 - getSessionIdFromHeaders fell back to the literal 'default-session', so
every client setting no session header shared one session object. That is the
common case: the tools exec exists to wrap generally set none. Two unrelated
local processes wrote into each other's dedup state, and a third could flush it
with a single request carrying 130 blocks.

The fallback is now per connection, keyed on the socket - the only thing here a
caller cannot choose, since every peer is 127.0.0.1 and the address
discriminates nothing. Measured: two connections give two sessions where they
gave one, and keep-alive keeps a client's second turn in its own, so cross-turn
dedup is unaffected. Explicit x-session-id is unchanged and still shared, which
is the documented exec trust boundary.

Scope stated rather than overclaimed: this narrows a default, it does not
authenticate. Session state is also still mutated before any credential check,
so a request ending in 401 has already written to the store. Recorded, not
fixed.

F-03 - ingestion never consults .gitignore, so optimize <dir> read
secrets.yaml, serviceAccount.json and config/credentials.yaml and wrote them to
stdout. It now names them on stderr. It reports rather than filters: skipping
would change which bytes the pipeline sees, and .gitignore covers plenty a
caller may legitimately mean to optimize.

Two things the implementation had to get right, both found by testing rather
than by reading. git check-ignore answers according to the repository it runs
in, so running it in the process's own cwd reported nothing at all for
optimize /other/repo - silently, which is the failure shape this warning exists
to prevent; it now runs in the tree being read. And -z is load-bearing: without
it core.quotePath C-quotes every Windows path, printing "C:\Users\..." rather
than something the caller can act on. Paths go over stdin with execFile and an
argument array, so no filename reaches a command line.

F-07 - documented rather than fixed. A per-run nonce in markers would change
emitted bytes every run and make output non-deterministic across runs, which
collides with invariant 1. The README note records the practical
discriminator instead: every genuine header on every CLI route carries an
absolute path, so a bare or relative one did not come from the ingester.

Optimized stdout is unchanged by all three. F-03 writes to stderr only
(verified: 0 occurrences of the warning on stdout, 4 headers unchanged), F-01
is off the optimize route entirely, F-07 is documentation. The one prior change
that can touch stdout, F-06's label escaping, alters only paths containing CR
or LF: 0 of 70 corpus files and 0 of 4 fixture files qualify.

98 files / 887 tests pass, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ojassug
ojassug merged commit d8885a8 into main Sep 4, 2026
3 checks passed
@ojassug
ojassug deleted the fix/security-review-findings branch September 4, 2026 17:00
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