Skip to content

feat(mint): make a failed login diagnosable without container access - #140

Merged
guohai merged 7 commits into
mainfrom
feat/mint-diagnostics
Sep 1, 2026
Merged

guohai merged 7 commits into
mainfrom
feat/mint-diagnostics

Conversation

@guohai

@guohai guohai commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #133. That change made a failed mint report its real cause; this makes it diagnosable without SSH-ing into the production broker.

Why

Two login failures in production, two days apart, both turned out to be a wrong stored credential — first the email (brent@agora.op, an invalid TLD), then the password. Each took a round trip to establish, and each was found the same way: exec into the broker container, read aeval's artifacts, look at the error screenshot. That is not a support story.

The specific ambiguity that cost the most time: a rejected password and a challenged browser look identical. Same 60s timeout, same screenshot of a filled-in login form, same message. I built a fairly elaborate theory about headless fingerprinting and IP reputation on top of that ambiguity before the actual cause turned out to be a wrong password.

What this adds

1. The failing HTTP status. The one fact that separates those two cases. aeval doesn't report it, but it's in the browser console log inside the artifacts directory — located via aeval's own Artifacts saved to: banner, which is the line #133 started out by mis-reporting as the error.

Only the digits propagate. Nothing else from that log is read, so no credential can ride along and it needs no scrubbing — which is what makes it safe to append after the redaction pipeline has already run.

2. A credential fingerprint — length plus a truncated MD5 — so an owner can check what Vox stored against their own copy:

printf %s 'value' | md5sum | cut -c1-10

MD5 and a 10-hex prefix are deliberate. This is a comparison aid, not a security primitive: anything keyed or salted would be unreproducible by the owner and so useless for the one job it has.

3. Where each fact may appear — the security-relevant part:

log console
login HTTP status yes yes, via lastError
target host (query stripped) yes yes
identifier fingerprint yes yes
secret length yes yes
secret hash no yes, owner-only

Container logs get shipped to aggregators, screen-shared and pasted into chat threads — an unsalted MD5 plus an exact length is a practical cracking aid for a weak password. The identifier is different: an email is already visible in aeval's masked output and its error screenshots, so its hash adds diagnosis without adding exposure. And length alone still catches the real case — the production password went from 20 characters to 16.

Notes for review

  • The mint error rides the existing disclosure boundary: it lands in webSessions.lastError, which isOwnerOperatedAgent (from fix(broker): report the real login failure, not aeval's artifacts banner #133) already gates, so a marketplace agent receives the status alone.
  • Fingerprints are computed on read, not stored. The alternative needs a column, a migration, and a backfill that decrypts every row anyway; listing is owner-scoped and capped at 50 secrets per user. It fails soft — a row encrypted under a rotated key must still list, since the whole point of the page is letting the owner replace it.
  • shared/credentials.ts is Node-only (it imports crypto). The client-safe shared module remains shared/secrets.ts; nothing in client/ imports the former.

Verification

Full gate 1671/1671 across 88 files. Both images rebuilt (broker 14.7 kb, loaded via a runtime in-container import(); daemon 186.9 kb, node --check), client bundle built, dev server restarted so the integration tests exercised the new endpoint rather than the old binary.

Each new test was verified non-vacuous by reverting the behaviour it pins:

FAIL  takes the LAST status, since the failing request is the final one   (first-match)
FAIL  never puts the secret's hash in a log line                          (hash added to log line)

Generated with SMT smt@agora.io

Every login failure so far has been diagnosed by SSH-ing into the production
broker and reading aeval's artifacts by hand. Two of them turned out to be a
wrong stored credential — first the email, then the password — and each took a
round trip to establish. This adds the three facts that would have answered
both in one look.

1. The failing HTTP status. This is the field that separates "the password is
   wrong" (the server rejects the sign-in) from "this browser is being
   challenged" (refused before credentials matter). Otherwise the two are
   indistinguishable: same timeout, same screenshot, same message. aeval does
   not report it, but it is in the browser console log inside the artifacts
   directory — located via aeval's own "Artifacts saved to:" banner, the line
   this whole effort started by mis-reporting AS the error.

   Only the digits are propagated. Nothing else from that log is read, so no
   credential can ride along and it needs no scrubbing — which is what makes it
   safe to append after the redaction pipeline has run.

2. A credential fingerprint (length + truncated MD5) so an owner can check the
   stored value against their own copy:

     printf %s 'value' | md5sum | cut -c1-10

   MD5 and a 10-hex prefix are deliberate: this is a comparison aid, and
   anything keyed or salted would be unreproducible by the owner and therefore
   useless. That choice is exactly why WHERE it may appear is constrained.

3. WHERE each fact may go, which is the security-relevant part:

   - Logs get lengths, plus a fingerprint of the IDENTIFIER only. An email is
     already visible in aeval's masked output and its error screenshots, so its
     hash adds diagnosis without adding exposure.
   - Logs never get the SECRET's hash. Container logs are shipped to
     aggregators, screen-shared and pasted into chat threads; an unsalted MD5
     plus an exact length is a practical cracking aid. Length alone still
     catches the real case — the production password changed from 20 characters
     to 16.
   - The secret's full fingerprint is shown in the console, behind auth, to its
     owner, whose credential it already is. That also removes the need for
     container access, which was the deeper problem.

The mint error rides the existing disclosure boundary: it reaches
webSessions.lastError, which isOwnerOperatedAgent already gates so a
marketplace agent receives the status alone.

Decryption for the fingerprint happens on read rather than at write time: the
alternative needs a column, a migration and a backfill that decrypts every row
anyway. Listing is owner-scoped and capped at 50 secrets per user. It fails
soft — a row encrypted under a rotated key must still list, since the point of
the page is to let the owner replace it.

Full gate green: 1671/1671. Both images rebuilt and loaded; client bundle
built. Each new test verified to fail without the behaviour it pins.

🤖 Built with SMT <smt@agora.build>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • High: server/routes.ts:2694 exposes valueFingerprint and valueLength for every org secret to any org member via GET /api/org-secrets. That includes brokerType === "auth-session" rows, which are explicitly Core-only and are not returned by getOrgSecretsForJob; non-admin org members can now obtain an unsalted 10-hex MD5 prefix plus exact length for shared login passwords they could not otherwise read. This violates the PR’s stated “secret hash owner-only” boundary. Consider only returning full fingerprints to org admins/owners, or at least omitting them for brokered org secrets from non-admin responses.
  • Medium: vox_eval_agentd/auth-session-broker.ts:35 imports fingerprintForLog, but the mint failure logs at vox_eval_agentd/auth-session-broker.ts:343 and server/auth-session.ts:193 never include it. As a result, the log-side diagnostic promised by the PR is not actually present; only the console/API gets fingerprints. This also leaves the new fingerprintForLog test somewhat vacuous because it tests the helper, not its integration into any log line.

Testing

  • Not run; review performed in read-only mode.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Summary

Two halves ship here: a console-visible credential fingerprint (server + client) and an HTTP-status extractor for failed mints. The broker-side diagnostics are careful — status extraction is digits-only, appended after redaction, tail-bounded, and fails soft. The console side is owner-scoped and fails soft on decrypt errors. Threat-model reasoning in the comments is unusually good.

Two things need attention before merge.

1. fingerprintForLog is imported but never called — the log half of the feature isn't wired up

vox_eval_agentd/auth-session-broker.ts:35 imports fingerprintForLog, but nothing in the file (or anywhere outside tests/) uses it. The obvious intended call site is auth-session-broker.ts:330:

console.log(`[Broker] Mint request for platform ${body.platformId}`); // never log email/password

As merged, the shipped artifact is an unused import (ESLint no-unused-vars warning) plus a fully-tested dead function in shared/credentials.ts. An operator reading broker logs still can't tell which identifier was attempted — which is half of what the PR title promises. Either wire it into the mint request/failure log lines, or drop the helper and its tests.

2. /api/org-secrets exposes the fingerprint to every org member, not just org admins

server/routes.ts:2689 spreads secretFingerprint(...) into the org-secrets list, and that route is gated only by requireAuth + org membership (the write route uses requireOrgAdmin; the read route's own comment says "all members see names, admins see full").

The justification in the diff — "discloses nothing they cannot already use" — conflates use with knowledge. An org member can spend an org secret inside a workflow, but until now could not learn anything about its value. They now get an unsalted MD5-10 plus an exact length, which is exactly the artifact shared/credentials.ts:96-104 argues must be withheld:

an unsalted MD5 plus an exact length is a practical cracking aid for a weak password

That reasoning doesn't stop applying because the audience is an org rather than a log aggregator — a shared login password is precisely the low-entropy case, and org membership can be large and loosely trusted. Suggest gating the org-secrets fingerprint behind requireOrgAdmin-equivalent (the people who can already overwrite the value), or at minimum emitting valueLength only for members and the full fingerprint for org admins — mirroring the identifier/secret split the module already makes for logs.

Minor

  • server/routes.ts:2551 — the "capped at 50 secrets per user" bound justifying per-request decryption holds for /api/secrets (routes.ts:2630) but not for /api/org-secrets, which has no count cap. Not a practical problem (AES-GCM on short values), but the stated justification doesn't cover the second call site.
  • auth-session-broker.ts:165-171fs.readSync can short-read; buf is zero-filled by Buffer.alloc, so a partial read leaves NUL padding in text. Harmless for this regex, but fs.readFileSync + .slice(-262144) on a 256 KiB-bounded read, or checking bytesRead, would be simpler and exact.
  • auth-session-broker.ts:156(\S+) truncates an artifacts path containing a space. Unlikely given aeval's timestamped dir names; would silently return null.
  • The timeout branch (auth-session-broker.ts:284-289) doesn't call readLoginHttpStatus. The tests acknowledge artifacts usually don't exist yet on that path, so this is a reasonable limitation — but a bot-challenge failure (the case the function's doc comment names) plausibly manifests as a hang rather than a clean non-zero exit, so the status won't be there for the scenario it was written for. Worth a follow-up rather than a blocker.

Note: I could not run the test suite or type-check in this environment (commands required approval), so the review is static.

…gers

Two review catches on #140, and the first is embarrassing.

1. fingerprintForLog was imported and never called. The edit that was supposed
   to add it to the broker's mint-request line silently did not apply, and I
   "verified" it by grepping for the symbol — which matched only the import.
   The helper had tests; its use had none, so nothing failed. The shipped
   artifact would have been an unused import plus a fully-tested dead function,
   with the log half of the feature simply absent.

   Now actually wired, and pinned by a test that captures real console output
   from a live /mint request rather than calling the helper directly. Verified
   it fails when the call is removed.

2. /api/org-secrets returned fingerprints to every org member. My own comment
   argued this "discloses nothing they cannot already use" — but that conflates
   USE with KNOWLEDGE. A member can spend an org secret in a workflow; until now
   they could learn nothing about its value. An unsalted MD5 plus an exact
   length is precisely the artifact shared/credentials.ts argues must be
   withheld, and a shared org login is exactly the password worth attacking.
   Being an org rather than a log aggregator does not change that.

   Fingerprints now go to org managers only, matching the route's existing
   "all members see names, admins see full" split.

Docs, per the same pass:
- CLAUDE.md documents the mint-diagnosis path, the fingerprint placement rule,
  and the owner-vs-marketplace disclosure split on the 503.
- The three shared/ modules are described with the constraint that matters for
  each: secrets.ts client-safe, credentials.ts Node-only, mint-timeout.ts the
  single clamped reader keeping four deadlines ordered.
- The test inventory said "400+ tests" over 13 listed files; there are 84 files
  and 1672 tests. Replaced the false precision with a dated headline and a note
  that the table is a selection, since per-file counts re-rot immediately.
- Recorded the two gate hazards that cost real time this session: the
  cap-tripping resource leaks (#134) with the SQL to clear them, and that
  integration suites hit the already-running server so server/ changes need a
  restart to be exercised.

Full gate green: 1672/1672.

🤖 Built with SMT <smt@agora.build>
@guohai

guohai commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Both fixed in c932295.

1. fingerprintForLog imported but never called. Correct, and the way it happened is worth naming: the edit that should have added it to the mint-request line silently did not apply, and I "verified" it by grepping for the symbol — which matched the import. The helper had tests; its use had none, so nothing failed. As you say, the shipped artifact would have been a dead function with full test coverage and the log half of the feature simply missing.

It is wired now, and pinned by a test that captures real console output from a live /mint request rather than calling the helper directly:

FAIL  is actually WIRED into the broker's mint-request log, not merely defined   (call removed)
PASS  (call present)

2. /api/org-secrets exposing fingerprints to every member. You are right, and the sentence you quoted back at me was doing the damage — "discloses nothing they cannot already use" conflates use with knowledge. A member can spend an org secret in a workflow; until this PR they could learn nothing about its value. And a shared org login is exactly the password worth attacking, so the argument in shared/credentials.ts applies with more force there, not less.

Fingerprints now go to org managers only (orgRole owner/admin), which also matches this route's existing "all members see names, admins see full" split rather than inventing a new rule.

Also included, from a docs pass:

  • CLAUDE.md now documents the mint-diagnosis path, the fingerprint placement rule, and the owner-vs-marketplace split on the 503.
  • The three shared/ modules are described with the constraint that matters for each — secrets.ts client-safe, credentials.ts Node-only, mint-timeout.ts the single clamped reader keeping four deadlines ordered.
  • The test inventory claimed "400+ tests" across 13 listed files. There are 84 files and 1672 tests. Replaced the false precision with a dated headline and a note that the table is a selection — per-file counts re-rot the moment anyone adds a case.
  • Recorded the two gate hazards that cost real time this session: the cap-tripping resource leaks (Test suite leaks workflows until it trips the 200-workflow cap, making the local gate self-poisoning #134) with the SQL to clear them, and the fact that integration suites hit the already-running dev server, so a server/ change is not exercised until a restart. That second one bit me twice in this PR alone.

Full gate green: 1672/1672 across 88 files, with the dev server restarted so the org-secrets change was actually exercised.

Generated with SMT smt@agora.io

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • vox_eval_agentd/auth-session-broker.ts:260 — The timeout path still never reports login HTTP <status>. The PR’s motivating case is a 60s timeout where wrong password vs browser challenge looked identical, but readLoginHttpStatus() is only called in the close handler at vox_eval_agentd/auth-session-broker.ts:295. On timeout, finish() rejects immediately and later close is ignored, so any status already written to artifacts is lost. This leaves the main failure mode undiagnosable.

  • server/routes.ts:3992 — The login HTTP status is embedded only inside session.lastError, so the existing owner-operated gate strips it for marketplace agents along with the sensitive text. The PR notes say marketplace agents should receive “the status alone,” but non-owner-operated agents currently get only "session mint failed". If status is meant to be safe across that boundary, it needs to be stored/parsed separately from lastError and returned independently.

Residual Risk

  • I didn’t see obvious credential exposure in the new fingerprint display paths; org secret fingerprints are server-gated to owner/admin, and broker logs omit the secret hash.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Reviewed the diff on the merge ref (6b698e4...HEAD). The design reasoning is unusually well documented and the security posture is mostly right — owner-scoped fingerprints, org-manager gating that matches permissions.ts:37, fail-soft decrypt so a rotated key still lists, digits-only propagation from the console log, and requireAuth (not requireAuthOrApiKey) on /api/secrets so an API key can't harvest fingerprints. Tests are good, including the "actually wired in, not merely defined" case.

Two things worth changing.

readLoginHttpStatus reads an attacker-influenceable path

vox_eval_agentd/auth-session-broker.ts:155-178, called at :295:

const status = readLoginHttpStatus(outCap.text + errCap.text, AEVAL_DATA_PATH);
const dir = /Artifacts saved to:\s*(\S+)/.exec(aevalOutput)?.[1];
const consoleLog = path.resolve(dataPath, dir, 'logs', 'console.log');

The rest of this module treats aeval's stdout as untrusted — that's the entire reason selectDiagnosisSource/hasLoguruDiagnosis exist ("a page dump or target-site text can easily contain | ERROR |"). This function bypasses that quarantine: it scans outCap.text + errCap.text with stdout first, and exec takes the first match. aeval's real banner is a loguru line on stderr, so any target-page text echoed to stdout wins the race by construction.

Combined with path.resolve honoring an absolute dir, a hostile login page can steer the broker to open an arbitrary path in its container. The disclosure is capped at a 3-digit number that happens to follow responded with a status of (small), but the read is fs.openSync — pointed at a FIFO it blocks the broker's single thread indefinitely on the reject path, past the timeout that was supposed to bound it.

Cheap fix, any one of these (ideally the first two):

const dir = /Artifacts saved to:\s*(\S+)/.exec(errText)?.[1];   // stderr only
if (!dir || path.isAbsolute(dir) || dir.split(/[\\/]/).includes('..')) return null;

Also worth guarding fs.statSync(consoleLog).isFile() before opening, which closes the FIFO/device case outright.

Related, minor: outCap.text + errCap.text splices without a separator, so an unterminated stdout tail can merge with the first stderr line and synthesize a banner that neither stream contained. Join with '\n'.

The 50-secret cap cited in the cost argument doesn't cover the org route

server/routes.ts:2545-2552:

Listing is owner-scoped and capped at 50 secrets per user, so the cost is bounded.

That cap is enforced at routes.ts:2631 on the personal create path only; POST /api/org-secrets has no count limit, and secretFingerprint is now called per row on that route too. The actual cost is still trivial (AES-GCM over ≤10 KB values), so this isn't a performance problem in practice — but the comment is the justification for decrypting on read instead of storing at write, and it doesn't hold for the caller it's applied to. Either add a cap on org secrets or narrow the claim.

Notes, not blockers

  • The MD5 tradeoff is argued well but does widen blast radius. Before this change, a leaked console session or an XSS on /console/secrets yielded nothing about secret values; now it yields exact length plus 10 hex of unsalted MD5 for every secret the user holds — offline-crackable for anything human-chosen. The PR's reasoning (a keyed hash is unreproducible, so useless) is sound and the placement discipline in fingerprintForLog is the right instinct. If you want the diagnostic without the standing exposure, consider fetching the fingerprint from a separate per-secret endpoint behind an explicit click rather than including it in every list response.
  • The timeout path gets no HTTP status (:275-290) — only the close path calls readLoginHttpStatus. A hung login being challenged by a bot-detection layer is exactly the case where "403 vs. timeout" is the distinguishing signal, and aeval may well have written artifacts before it hung. Worth attempting there too (it already returns null safely when nothing exists).
  • Stale comment: shared/credentials.ts:17 still says "Dependency-free on purpose" directly under the new paragraph explaining that it now imports crypto. The two read as a contradiction even though both are true in their own sense. Also prefer from "node:crypto" — it makes the Node-only constraint fail loudly at bundle time if someone ever does import this from client/, which is the exact mistake the new header warns about.

Three review catches on #140.

1. readLoginHttpStatus scanned `outCap.text + errCap.text` — stdout FIRST — and
   took the FIRST banner match. This module treats aeval's stdout as untrusted;
   that is the entire reason selectDiagnosisSource and hasLoguruDiagnosis exist.
   And unlike those, this function turns a matched string into a FILE READ, so
   honouring stdout let target-page text echoed by the browser choose which path
   the broker opens. The banner is a loguru line, so stderr is where it actually
   is: stderr only, LAST match, and the resolved path is confined under the
   artifacts root so a crafted ../../.. cannot escape even from the trusted
   stream. Both properties are pinned by test.

2. The broker's own timeout path never attempted the status. Worth noting the
   review's framing was off — the motivating production failure is aeval's OWN
   60s wait_for_url timeout, which exits non-zero and lands in the close
   handler, so the main mode was already covered. But a broker-level hang that
   nonetheless wrote artifacts should not lose the status for free, and it fails
   soft, so it now tries there too (reading completeText, since the child is
   still mid-write).

3. The PR notes said a marketplace agent receives "the status alone" while the
   code gave it nothing — the status was embedded in lastError, which the
   owner-operated gate strips wholesale. A three-digit code cannot carry page
   state or a credential, and it is the single field that separates a rejected
   credential from a challenged browser, so withholding it bought no safety and
   cost the whole diagnostic. Non-owner agents now get
   "session mint failed (login HTTP 400)". Parsed back out of lastError rather
   than stored separately, which would need a column and a migration for one
   integer.

Gate: 1674/1675, the one failure being tier-pool-claim's reaper case racing the
live daemon for a pooled job; passes in isolation (#134).

🤖 Built with SMT <smt@agora.build>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • High: GET /api/org-secrets now returns valueFingerprint for org managers at server/routes.ts:2703, but /api/org-secrets is not in SENSITIVE_PATHS at server/index.ts:174. The response logger will serialize the full JSON body for this endpoint, so every org secret’s unsalted MD5 prefix + exact length is written to container logs whenever an owner/admin opens the org secrets page. This violates the PR’s stated boundary that secret hashes are console-only and never in logs. Add /api/org-secrets to SENSITIVE_PATHS or otherwise suppress response-body logging for this route.

Notes

  • I did not find other concrete security/auth/logic issues in the reviewed diff.
  • I did not run tests; review only.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Findings

1. /api/org-secrets is missing from SENSITIVE_PATHS — org credential fingerprints land in Core's request log

server/index.ts:174 redacts response bodies for /api/secrets, but /api/org-secrets matches neither the exact set nor any startsWith prefix. So every GET /api/org-secrets by a manager now writes the full JSON — including valueFingerprint (unsalted MD5-10) and valueLength for the shared org login — into the container log via the capturedJsonResponse logger at server/index.ts:198-205.

That is precisely the placement fingerprintForLog exists to prevent ("container logs get shipped, screen-shared and pasted into chat threads"), and a shared org password is the highest-value target of the set. The route-level manager gate is doing real work and is then undone one layer up.

// server/index.ts
const SENSITIVE_PATHS = new Set([
  "/api/secrets",
  "/api/org-secrets",   // ← add
  ...

Worth a regression test asserting the logger treats it as sensitive, since the same class of miss is what this PR is about.

2. Fingerprint is returned unconditionally on every list

Accepting the design rationale (reproducible-by-owner is the point, so keyed/salted is out): note that returning it on every list rather than on explicit per-secret request widens the blast radius more than necessary. A read-only session compromise, a cached response, or an over-broad log now yields md5[0:10] + exact length for all 50 of a user's credentials — enough to confirm a guessed weak password offline, where previously the listing carried zero value-derived information. Gating behind an explicit ?fingerprint=NAME (or a per-row reveal action) would keep the diagnostic without making it ambient. Not a blocker, but the constraint argued for in shared/credentials.ts is about placement, and the list endpoint is the broadest placement available.

3. The cost bound in the secretFingerprint comment doesn't hold on the org path

server/routes.ts:2551 justifies decrypt-on-read with "capped at 50 secrets per user" — that cap is enforced only in POST /api/secrets (routes.ts:2631). POST /api/org-secrets has no count limit, so GET /api/org-secrets does an unbounded number of AES-GCM decrypts per request. The per-op cost is small and this is manager-only, so it's minor — but either add an org cap or drop the claim, since it's the stated reason the design is safe.

4. readLoginHttpStatus — the diagnostic value is target-controlled

The status is scraped from logs/console.log, i.e. browser console output from the login page. Digits-only extraction means no injection, and the path confinement + stderr-only banner are correct. But a target page can emit responded with a status of 200 and steer the "rejected credential vs. challenged browser" conclusion — which is the whole point of the field. Worth a word in the doc comment that it's a hint, not evidence.

Related, on the Core side (routes.ts:3993): /\(login HTTP (\d{3})\)/ takes the first match in lastError. Today the genuine (login HTTP N) is prefixed before the summary so it wins, but if the broker ever fails to extract a status, a (login HTTP 403) appearing inside the redacted aeval summary would be surfaced as if it were the real one. Anchoring on the known prefix (^aeval exited \d+ \(login HTTP … / ^login timed out .*\(login HTTP …) would make that ordering explicit rather than incidental.

Minor: on the timeout path the read happens immediately after killTree('SIGTERM'), so aeval has almost certainly not flushed artifacts yet — that branch will nearly always be null. Harmless (fails soft), but it's more decorative than the comment implies.

5. Test issues

  • tests/auth-session-broker-service.test.ts"ignores a banner on stdout — a matched string here becomes a FILE READ" doesn't test that. readLoginHttpStatus(stderr, dataPath) has no stdout parameter; the two assertions are "stderr banner works" and "empty input returns null". The real invariant lives at the call sites (errCap.completeText / errCap.text in auth-session-broker.ts:298,314) and is untested — a future edit to outCap there would pass. Either assert against the call sites or rename the test to what it checks.
  • The escape test creates join(dataPath, "..", "escape-<ts>") and never removes it, leaking a directory into /tmp on every run; outside.split("/").pop() is also POSIX-only (use path.basename).

6. Nit: shared/credentials.ts header

The new "Node-only" note is good, but the existing paragraph three lines below still reads "Dependency-free on purpose", which is now false and is exactly the kind of contract line someone will rely on. Also prefer import { createHash } from "node:crypto" — a stray client import then fails at build instead of silently resolving to a bundler shim, which is the failure mode the comment is warning about.


The core mechanism is sound: stderr-only banner selection, path confinement under dataPath, digits-only propagation, and length-only-for-secrets in logs are all the right calls, and the reasoning is unusually well documented. Item 1 is the one I'd fix before merge.

Review catch on #140, and the one that mattered most: the route-level gate I
added last commit was undone one layer up.

/api/secrets is in SENSITIVE_PATHS; /api/org-secrets is not, and does not match
by prefix — they are sibling paths, not parent and child. So gating org
fingerprints to managers stopped a member reading them over the API, and then
the request logger wrote the full JSON body — every org secret's unsalted MD5
prefix and exact length — into Core's container log the moment a manager opened
the page. That is precisely the placement shared/credentials.ts exists to
forbid, and a shared org login is the highest-value credential of the set.

The list moves to server/sensitive-paths.ts so a test can import it without
booting the server, which is why the gap survived: a list like this is worthless
if nothing checks it. The test pins both routes and asserts explicitly that
"/api/org-secrets".startsWith("/api/secrets") is false — the false assumption
that caused it. Verified it fails when the entry is removed.

Also verified against the running server rather than only the predicate: the
API returns valueFingerprint, and the request log records
"GET /api/secrets 200 in 4ms" with no body.

Gate: 1677/1678, the one failure being the known credits contention that passes
in isolation (#134).

🤖 Built with SMT <smt@agora.build>
@guohai

guohai commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in a1e294a. Both reviewers caught the same thing and it was the most important finding on this PR: the route-level gate I added last commit was undone one layer up.

/api/secrets is in SENSITIVE_PATHS; /api/org-secrets is not, and does not match by prefix — they are sibling paths, not parent and child. So gating org fingerprints to managers stopped a member reading them over the API, and then the request logger wrote the full JSON body — every org secret's unsalted MD5 prefix and exact length — into Core's container log the moment a manager opened the page. Exactly the placement shared/credentials.ts exists to forbid, and a shared org login is the highest-value credential in the set.

The list now lives in server/sensitive-paths.ts so a test can import it without booting the server. That relocation is the actual fix: a list like this is worthless if nothing checks it, which is precisely why the gap survived. The test pins both routes and asserts the false assumption that caused it:

expect("/api/org-secrets".startsWith("/api/secrets")).toBe(false); // why it needs its own entry

Verified non-vacuous — removing the entry fails two of the three cases.

I also checked it against the running server rather than trusting the predicate, since that distinction has bitten me twice in this PR:

API response:  {"valueLength":15,"valueFingerprint":"1cc4302cfd", ...}
request log:   GET /api/secrets 200 in 4ms          ← no body

Gate: 1677/1678, the one failure being the credits contention that passes in isolation (#134).

Worth noting the pattern across this PR's three review rounds, since it is consistent: every finding was a place where I implemented a rule and then failed to check the layer around it — the helper that was defined but never called, the boundary that was enforced per-route but not per-log, the path that was validated but read from an untrusted stream. The rule was right each time; the verification stopped at the edge of what I had just written.

Generated with SMT smt@agora.io

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

No findings.

I reviewed the PR diff with focus on credential exposure, auth boundaries, log redaction, path handling, and edge cases. The added safeguards look sound:

  • Secret hashes are kept out of broker/container logs; only owner/manager-scoped API responses expose valueFingerprint.
  • /api/org-secrets is now covered by the sensitive response-body logging filter.
  • Marketplace/non-owner agents only receive the extracted HTTP status, not full lastError prose.
  • Artifact-path parsing is confined under the aeval data root and only propagates three status digits.
  • Decryption failures during secret listing fail soft, preserving the replacement workflow.

I did not run the test suite because the environment is read-only, but the added tests cover the key regressions I’d expect for this change.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Reviewed the merge ref (6b698e4...a1e294a). The redaction plumbing, the digits-only propagation, and the owner-vs-agent split on the 503 body are all sound. A few things worth resolving before merge — one of which may make the headline feature a silent no-op.

Findings

1. readLoginHttpStatus may never resolve in production (high)

mintWithAeval writes the scenario with an absolute output dir under the per-mint tmp workdir (vox_eval_agentd/auth-session-broker.ts:204):

`  output_dir: ${JSON.stringify(path.join(workDir, 'out'))}`,   // /tmp/vox-mint-XXXX/out

but readLoginHttpStatus confines the banner path under AEVAL_DATA_PATH (auth-session-broker.ts:169-172):

const consoleLog = path.resolve(root, dir, 'logs', 'console.log');
if (consoleLog !== root && !consoleLog.startsWith(root + path.sep)) return null;

If aeval's Artifacts saved to: banner reports the configured output_dir (an absolute /tmp/vox-mint-* path), path.resolve(root, <absolute>) returns the absolute path unchanged, the containment check rejects it, and the function returns null on every real mint. The (login HTTP NNN) suffix — and therefore the httpStatus extraction in server/routes.ts:3995 — would never fire, while all tests still pass.

The tests only synthesize a relative dir (output/mint/2026…) under dataPath, so they can't distinguish the two cases. Note also that the checked-in scenarios (scenarios/*.yaml:15) use a relative output_dir: temp/output, which is what would make a cwd-relative banner plausible — the broker is the one call site that passes an absolute one.

Please confirm against a real run. If aeval does echo the absolute output_dir, confine under workDir instead (or accept either root), and add a test with an absolute in-root banner.

2. "Last status wins" is not necessarily the login request (medium)

const codes = [...text.matchAll(/responded with a status of (\d{3})/g)]
return codes.length > 0 ? codes[codes.length - 1] : null;

A browser console log for an SSO flow routinely contains resource-load failures unrelated to the sign-in (favicon 404, blocked analytics beacon, CSP-refused script), and those can easily be emitted after the auth POST. The value is then reported as login HTTP 404 — and the stated purpose of the field is to distinguish "credential rejected" from "browser challenged", so a misattributed status is worse than no status.

It is also page-influenceable: console.log("responded with a status of 200") from the target page lands in the same artifact. Only three digits propagate, so this isn't a leak — but it does mean an operator can be pointed at the wrong diagnosis by the site being tested.

Suggest either matching only messages whose URL is on the login/target host, or renaming the field to reflect what it actually is (last failed request HTTP …).

3. Org-manager fingerprints are a new capability, not a display change (medium)

server/routes.ts:2698 gates fingerprints on orgRole in (owner, admin). The comment reasons about member-vs-manager, but the relevant line is manager-vs-owner of the credential: before this change an org admin could write an org secret and never learn anything about a value someone else set. A 10-hex-digit unsalted MD5 plus an exact length is a 40-bit offline-verifiable oracle — enough to confirm a guessed password with no rate limit and no contact with the target site. That's the exact property fingerprintForLog withholds from logs, applied to a shared credential where the reader may not be the person who set it.

upsertOrgSecret already records createdBy, so gating on s.createdBy === user.id || user.orgRole === 'owner' would keep the diagnostic for whoever stored the value without widening it to every admin. The personal /api/secrets path is fine as written.

4. Smaller items

  • shared/credentials.ts:17 still reads "Dependency-free on purpose" directly under the new "Node-only: it imports crypto" paragraph. Reword the older line.
  • tests/auth-session-broker-service.test.ts — "ignores a banner on stdout" doesn't test that. readLoginHttpStatus has no stdout parameter, so the assertion is readLoginHttpStatus(stderrBanner) === 400 plus readLoginHttpStatus("") === null; the stdout property lives entirely at the errCap call sites, which nothing covers. Rename it, or assert it via mintWithAeval.
  • auth-session-broker.ts:183fs.readSync is a single read(2) and isn't guaranteed to fill the buffer; a short read leaves trailing NULs in buf. Harmless given only digits are extracted, but buf.subarray(0, fs.readSync(...)) is a one-word fix.
  • isSensitiveResponsePath runs Array.from(SENSITIVE_PATHS) per request now that it's on the logging hot path. Eight entries, so immaterial, but hoisting to a module-level array costs nothing.

The extraction of SENSITIVE_PATHS into a testable module, the completeText/text distinction across the timeout vs. close paths, and the identifier-hash/secret-length asymmetry in fingerprintForLog are all well-judged.

…asures

Two review catches on #140.

1. The containment check confined the banner path to AEVAL_DATA_PATH, while the
   mint scenario configures an ABSOLUTE output_dir under its own temp workdir —
   so if aeval reported that path, path.resolve would return it unchanged, the
   check would reject it, and the feature would return null on every real mint
   while every test still passed.

   Against aeval 0.3.0 it does NOT: the banner is relative and resolves under
   the data root. Verified in production —
   "Artifacts saved to: output/mint/20260831_230019_7219" landing at
   /app/aeval-data/output/mint/... — which is what this implementation was
   written against. But the fragility is real: an aeval that starts honouring
   output_dir would silently kill the feature. Both roots are permitted now, so
   such a change degrades to nothing instead. Test covers the absolute case and
   fails with only one root.

2. Renamed to readLastFailedHttpStatus, and the message now says "last failed
   request HTTP NNN". A browser console log for an SSO flow carries resource
   failures unrelated to the sign-in — favicon 404, blocked beacon, CSP-refused
   script — any of which can follow the auth POST, and a target page can print
   the same sentence itself. Only three digits propagate so it is not a leak,
   but presenting it as "the login status" could point an operator at the wrong
   diagnosis, which is worse than reporting no status. Named honestly it still
   does its job.

CLAUDE.md updated to match, including that the path comes from stderr only and
is root-confined because a matched string there becomes a file read.

Full gate green: 1679/1679 across 89 files.

🤖 Built with SMT <smt@agora.build>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • High: broker secrets still hit request logsserver/sensitive-paths.ts:15 centralizes the redaction list but still omits endpoints that return one-time plaintext broker credentials. server/routes.ts:3059 returns a broker registration token, and server/routes.ts:3144 returns mintSecret; because server/index.ts:190 logs JSON response bodies for any non-sensitive API path, both can be written to container logs. Add /api/admin/broker-tokens and /api/brokers/register to SENSITIVE_PATHS (or otherwise suppress those response bodies).

Notes

  • I didn’t find issues in the new owner/org-manager fingerprint gating or the marketplace-agent status-only path.
  • I attempted npm run check, but this read-only checkout can’t create node_modules/typescript/tsbuildinfo and also lacks local type deps, so validation couldn’t complete here.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Overall this is careful work — the threat reasoning in shared/credentials.ts and sensitive-paths.ts is sound, the path confinement in readLastFailedHttpStatus is correct, and the broker's log line is placed after bearer auth and after PLATFORM_ID_RE validation so nothing unvalidated reaches the log. Findings below, roughly by importance.

1. Org-manager fingerprints are a new read capability, not just a wider audience (server/routes.ts:2698)

The comment frames the member/manager split as "matching this route's existing split", but before this change no one could learn anything about an org secret's value — /api/org-secrets returned names only, and requireOrgAdmin gated write, not read. So an org owner/admin who did not create a shared login can now take length + unsalted MD5/10 offline and confirm a weak password from a wordlist in seconds. That is the exact artifact fingerprintForLog argues must be withheld from logs, applied to a principal who isn't the credential's owner.

It's defensible (org admins can already overwrite the secret, and can run workflows that spend it), and it's documented — but it is a real widening, and upsertOrgSecret already records createdBy, so narrowing to s.createdBy === user.id would give the diagnostic to the person who actually owns the value at no cost to the use case. Worth a deliberate call rather than inheriting the manager gate by analogy.

2. The two couplings this feature depends on are the two things untested

  • server/routes.ts:3994 parses \(last failed request HTTP (\d{3})\) back out of a string formatted in vox_eval_agentd/auth-session-broker.ts. Cross-package, string-formatted, no test on either end asserting the format matches. If someone reworks the broker's message, the status silently disappears from the 503 body and nothing goes red — which is exactly the "feature that looks wired but isn't" failure the fingerprint test was written to catch one file over.
  • tests/auth-session-broker-service.test.ts, "ignores a banner on stdout — a matched string here becomes a FILE READ": this test does not test that. readLastFailedHttpStatus has no stdout parameter; the assertions are (output, dataPath) === 400 (which is the stderr path succeeding) and ("", dataPath) === null (trivially true). The stdout-is-untrusted guarantee lives entirely at the two call sites passing errCap.text/errCap.completeText, and neither is covered. A future change that folds stdout in would pass this suite.

3. Close path uses errCap.text where the surrounding code deliberately uses completeText (auth-session-broker.ts:332)

capturedFailure(typeof code === 'number') selects completeText for a signal-killed child precisely because its last line can be half-emitted — and the banner is the last line. The adjacent readLastFailedHttpStatus(errCap.text, ...) doesn't make that distinction, so a SIGKILLed child can hand it a truncated path. It fails soft (containment still holds, statSync throws, returns null), so this is correctness-of-intent rather than a leak — but it reads as an oversight against the comment two screens up. code === null ? errCap.completeText : errCap.text matches the documented rule.

4. Minor

  • readLastFailedHttpStatus(stderr) with no roots throws TypeError from path.resolve(undefined, …) rather than returning null. It's exported and every other failure mode is soft; if (roots.length === 0) return null costs a line.
  • fs.readSync's return value is discarded (auth-session-broker.ts:196). If the file shrinks between statSync and the read, the tail of buf stays zero-filled and lands in text. Harmless for the regex, but buf.subarray(0, bytesRead) is free.
  • Containment uses path.resolve without realpathSync, so a symlink inside a permitted root would escape it. Only reachable if the trusted stderr stream is already compromised, so defence-in-depth only — but the doc comment claims confinement more absolutely than the code delivers.
  • isSensitiveResponsePath allocates a fresh array via Array.from(SENSITIVE_PATHS) on every /api request. Pre-existing behaviour, 8 entries, negligible — but now that it's a module, hoisting to a module-level array is a one-liner.

Confirmed good

  • secretFingerprint fails soft on decrypt so a key rotation still lists (routes.ts:2559); /api/secrets is keyed by user.id so the personal path crosses no boundary.
  • /api/org-secrets added to SENSITIVE_PATHS with a test asserting the non-prefix relationship — that's the right fix for the described miss, and putting the list in its own importable module is the reason the test can exist.
  • Only digits propagate out of the console log, and the append happens after redaction, so the ordering claim in the comment holds.
  • shared/credentials.ts importing crypto is safe: no client/ file imports it, and the client-safe module (shared/secrets.ts) stays clean.

…tus marker

Six review catches on #140.

1. Org fingerprints went to org MANAGERS, and I justified it as "matching this
   route's existing split". That was wrong in kind: before this feature NOBODY
   could learn anything about an org secret's value — the route returned names,
   and requireOrgAdmin gated write, not read. So it was a new READ capability
   over a credential a manager may not own, and a shared org login is exactly
   the password worth taking offline against a wordlist. "Could already
   overwrite it" is not "could already learn it". Now scoped to the secret's own
   createdBy, which keeps the entire diagnostic — the point is letting whoever
   entered a value check it — while widening nothing.

2. The broker formatted "(last failed request HTTP NNN)" and Core parsed it back
   with a separately hand-written regex, across packages, untested on both ends.
   Drift would make the status vanish from the 503 body with nothing going red —
   the same looks-wired-but-isn't shape as the dead fingerprint import. Now one
   definition in shared/credentials.ts (format + parse), with a round-trip test.

3. A misleading test: "ignores a banner on stdout" asserted no such thing —
   readLastFailedHttpStatus has no stdout parameter, and the guarantee lives at
   the two call sites. Renamed to what it actually checks, with the scope
   limitation stated rather than implied away.

4. The close path passed errCap.text where capturedFailure two lines up
   deliberately uses completeText for a signal-killed child: its last line can
   be half-written, and the banner IS the last line.

5. Also from the sweep: /api/admin/broker-tokens and /api/brokers/register
   return one-time plaintext (a registration token and a per-broker mint secret)
   and were absent from SENSITIVE_PATHS, so the response logger wrote both into
   the container log — the one place a value handed out "once" becomes
   permanently recoverable. Pre-existing, but this PR is what centralized the
   list, so it is the right place to close them. Test now asserts every
   credential-returning route is listed.

6. Minor, all from the same review: soft-fail instead of throwing when called
   with no roots; subarray the read buffer so a shrinking file cannot leave
   zero-fill in the text; stop claiming absolute path confinement when the check
   is lexical and would follow a symlink planted inside a permitted root; hoist
   the prefix array out of the per-request path.

Gate: 1679/1680, the one failure being the credits contention that passes in
isolation (#134).

🤖 Built with SMT <smt@agora.build>
@guohai

guohai commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

All six fixed in 86c9787.

1. Org fingerprints as a new read capability. You are right, and the sentence I wrote was doing the damage — "matching this route's existing split" was wrong in kind, not degree. Before this feature nobody could learn anything about an org secret's value: the route returned names, and requireOrgAdmin gated write, not read. So handing managers a length plus an unsalted MD5 was a new read capability over a credential they may not own. "Could already overwrite it" is not "could already learn it."

Scoped to createdBy now, which is strictly better than what I would have argued for: it keeps the whole diagnostic — the point is letting whoever entered a value check it against their own copy — while widening nothing at all.

2. The cross-package string format. Fixed at the root rather than by adding a test to each side: formatLastFailedHttpStatus / parseLastFailedHttpStatus now live together in shared/credentials.ts, with a round-trip test. You correctly identified this as the same shape as the dead fingerprintForLog import — a coupling that would fail silently, with nothing going red.

3. The misleading test. Guilty, and the correction is worth stating plainly: readLastFailedHttpStatus has no stdout parameter, so that test could not possibly have checked what its name claimed. Renamed to what it verifies, with the scope limitation written down — the stdout-is-untrusted rule lives at the two call sites — instead of papered over.

4. errCap.text on the close path. Fixed to code === null ? completeText : text. The banner is the last line, which is exactly why capturedFailure two lines up makes that distinction.

5. /api/admin/broker-tokens and /api/brokers/register (Codex). Both return one-time plaintext — a registration token and a per-broker mint secret — and neither was in SENSITIVE_PATHS, so the response logger wrote them into the container log. That is the one place a value handed out once becomes permanently recoverable. Pre-existing, but this PR is what centralized the list, so it is the right place to close them. The test now asserts every credential-returning route is listed, and I swept routes.ts for others — no more.

6. The four minor ones. All applied: soft-fail with no roots, subarray so a shrinking file can't leave zero-fill in the text, the containment comment no longer claims more than lexical matching delivers (a symlink inside a permitted root would still be followed), and the prefix array is hoisted out of the per-request path.

Gate: 1679/1680, the one failure being the credits contention that passes 4/4 in isolation (#134). Broker image rebuilt and loaded; server restarted so the org-secrets change was exercised.

Generated with SMT smt@agora.io

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • High: server/sensitive-paths.ts:20 still misses routes that return one-time plaintext credentials, so the request logger will persist those response bodies. For example, POST /api/eval-agent-tokens returns token at server/routes.ts:2814 / server/routes.ts:2872, and POST /api/admin/users/:id/activation-link returns both token and activationUrl at server/routes.ts:652 / server/routes.ts:678. Since this PR centralizes and expands the sensitive response list, these should be added alongside the new broker entries; otherwise newly-created agent/activation credentials remain recoverable from container logs.

Notes

  • I did not find issues in the new mint-status parsing/redaction path or the owner-scoped secret fingerprinting logic.
  • I could not run the test suite in this read-only, no-approval environment.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Findings

1. POST /api/eval-agent-tokens returns a plaintext token and is still not in SENSITIVE_PATHSserver/sensitive-paths.ts:20

This is the exact gap class the PR set out to close, and the audit missed it. The set contains /api/admin/eval-agent-tokens, but the primary (non-admin) creation route is POST /api/eval-agent-tokens (server/routes.ts:2814), which returns token in plaintext (routes.ts:2869). No entry is a prefix of that path, so isSensitiveResponsePath returns false and the one-time token is written verbatim into the container log — permanently recoverable, which is precisely what the module doc says must not happen.

Add "/api/eval-agent-tokens" (it also prefix-covers /:id/revoke).

2. The test doesn't do what sensitive-paths.ts claims it does — server/sensitive-paths.ts:15

"The test beside this file asserts that every route returning a credential is listed."

It doesn't. tests/sensitive-paths.test.ts hardcodes five known-good paths and asserts they're present. It cannot detect a credential-returning route that nobody added to either file — which is why finding #1 is green. This is the "looks tested but isn't" shape the PR's own comments name elsewhere.

A test that would actually hold: enumerate registered routes (or grep server/routes.ts for handlers whose res.json returns token/mintSecret/valueFingerprint) and assert each resolves sensitive. Failing that, soften the comment so it doesn't promise a guarantee that isn't there.

3. CLAUDE.md contradicts the code it documents — CLAUDE.md:184

"Org secrets expose fingerprints to org managers only (orgRole owner/admin)"

The code is creator-scoped: s.createdBy === user.id (server/routes.ts:2703) — which is what commit 86c9787 deliberately changed it to, and what the 15-line comment directly above the code argues for at length. The doc records the rejected design. server/sensitive-paths.ts:21 (// returns valueFingerprint for org managers) has the same stale wording.

4. Org-secret rotation orphans the fingerprint — server/storage.ts:2565

upsertOrgSecret preserves the original createdBy on update. So when an org admin overwrites a shared login:

  • the original creator sees a fingerprint of a value they never entered (and will "verify" it against their stale copy → false mismatch);
  • the person who actually set the current value sees nothing.

That inverts the stated purpose ("letting whoever entered a value check it against their own copy"). Setting createdBy on the update path, or adding updatedBy and gating on that, fixes it. Also worth noting: legacy rows with createdBy IS NULL never show a fingerprint to anyone.

5. parseLastFailedHttpStatus can pick up a page-influenced marker — server/routes.ts:3999

The regex scans all of session.lastError, which is …${httpNote}: ${summary} where summary derives from aeval output that the target page can influence. When the broker found no status (httpNote === ''), a page that echoes (last failed request HTTP 200) gets that surfaced to a non-owner agent as if authentic. Impact is bounded (a misleading three-digit code, no data leak), but it defeats the honesty the module doc is careful about. Anchoring the parse to the head of the string, or having the broker return the status as a structured field, would close it.

Considered tradeoff worth stating explicitly

GET /api/secrets now returns valueLength + unsalted MD5-10 for every secret on every page load. Previously a hijacked session or a console XSS yielded nothing about secret values; it now yields ~40 bits of digest plus an exact length for each one — enough to confirm wordlist guesses offline, and passwords get reused. The reasoning for MD5 (reproducibility) is sound and well-argued, but the blast radius could be narrowed for free by serving the fingerprint from an explicit per-secret request rather than bulk in the list. Not a blocker; flagging because the org-secrets comment reasons carefully about exactly this risk and the personal-secrets path doesn't.

What's solid

  • readLastFailedHttpStatus is careful in the right places: stderr-only (a matched string becomes a file read), last-banner-wins, lexical root confinement with the symlink limitation stated rather than papered over, bounded tail read, subarray(0, bytesRead) guarding the shrink race, digits-only propagation. Every failure mode is soft.
  • formatLastFailedHttpStatus/parseLastFailedHttpStatus as a single round-tripped definition across the package boundary, with a test that pins the round-trip — the right fix for a cross-package string contract.
  • fingerprintForLog's asymmetry (identifier hashed, secret length-only) is the correct call, and the "is actually WIRED in, not merely defined" test asserting against captured console.log output is a genuinely good test.
  • The /api/admin/broker-tokens and /api/brokers/register additions catch two pre-existing one-time-plaintext leaks.

Four review catches on #140.

1. My previous sweep used a guessed regex and I then claimed "no more". It
   missed POST /api/eval-agent-tokens (the non-admin creation route — the set
   had only the /admin/ one, which is not a prefix of it) and
   /api/admin/users/:id/activation-link, which returns an activation token AND
   the link. A proper scan finds ten credential-returning routes; four were
   uncovered. Added, including three that carry the credential in a later path
   segment and so need a broad prefix — deliberately broad, since redacting a
   response body costs log detail while an activation link recovered from a log
   is account takeover.

2. sensitive-paths.ts claimed "the test asserts every route returning a
   credential is listed". It did not: it hardcoded five known-good paths, which
   is why finding 1 was green. The test now SCANS server/routes.ts for handlers
   returning a plaintext credential and asserts each resolves sensitive, with a
   floor on the match count so it cannot pass vacuously. Verified by removing
   an entry — it names the offending route.

3. Dropped the org-secret fingerprint entirely rather than ship a signal that
   can be wrong. upsertOrgSecret preserves the ORIGINAL createdBy on update, so
   after a rotation the first creator would be shown a hash of a value they
   never set and would check it against their stale copy — a false mismatch,
   which causes exactly the wrong action and is the failure this feature exists
   to prevent. Meanwhile whoever set the current value would see nothing.
   Personal secrets are unambiguous (keyed by user_id) and keep the feature.
   Doing org secrets properly needs an updatedBy column; tracked separately.

4. CLAUDE.md documented the REJECTED design (manager-scoped) rather than what
   shipped, as did a comment in sensitive-paths.ts. Both corrected.

Full gate green: 1680/1680 across 89 files.

🤖 Built with SMT <smt@agora.build>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Summary

Solid, unusually well-reasoned change. The broker-side status extraction is careful in the ways that matter (stderr-only source for a string that becomes a file read, lexical root containment, digits-only propagation, subarray(0, bytesRead) against a shrinking file), and the "is it actually wired" test asserting on captured console.log output is the right kind of test. A few real issues below.

1. The secret's own MD5 is now in a response body and the DOM — the log rationale applies here too

shared/credentials.ts:fingerprintForLog argues explicitly that the secret gets its length only because "container logs get shipped, screen-shared, and pasted into chat threads; an unsalted MD5 plus an exact length is a practical cracking aid for a weak password." That reasoning is right — but the console (console-secrets.tsx:368) renders {length} chars · md5 {fingerprint} unconditionally in every row, during exactly the debugging session where someone is screen-sharing. Same exposure class, opposite conclusion.

Separately, this weakens an invariant that held before: an XSS on /console/secrets or a stolen session previously yielded nothing about secret values. It now yields 40 bits of unsalted MD5 plus an exact length for every personal secret — enough to confirm a wordlist guess offline in milliseconds.

Owner-scoping (getSecretsByUserId(user.id)) is correct and /api/secrets is properly in SENSITIVE_PATHS, so this isn't a leak to third parties. But if the threat model is "a hash that escapes the authenticated surface is a cracking aid," rendering it by default is inconsistent with it. Cheap fix that keeps the whole feature: show {length} chars in the row, put the md5 behind a click-to-reveal.

2. parseLastFailedHttpStatus reads the page-influenced part of lastError

server/routes.ts:3986 withholds session.lastError from a non-owner agent precisely because "a mint error can quote page state" — then extracts a number from that same unanchored string. LAST_FAILED_HTTP will match anywhere, including inside the aeval summary prose. If the broker found no genuine status, a target page that prints (last failed request HTTP 451) gets that value forwarded to a non-owner agent.

Impact is small (three digits), but the fix is nearly free: the broker always emits its marker before the : <summary> separator, in both the timeout and exit paths. Parse only the prefix:

export function parseLastFailedHttpStatus(message: string): number | null {
  const i = message.indexOf(": ");
  const m = LAST_FAILED_HTTP.exec(i === -1 ? message : message.slice(0, i));
  return m ? Number(m[1]) : null;
}

3. CLAUDE.md contradicts itself on org secrets

The new bullet opens with "GET /api/secrets and /api/org-secrets return valueLength + valueFingerprint" and then states four sentences later "Personal secrets only. Org secrets deliberately have none." The code does the latter (routes.ts:2689). Since CLAUDE.md is loaded as instructions for future work, the first sentence should name /api/secrets alone.

4. The scanner test only scans server/routes.ts

tests/sensitive-paths.test.ts is a genuine improvement over the hand-written list, and its own doc claims it catches "the route nobody added to either file." It only reads server/routes.ts. I checked server/routes-api-v1.ts — no credential-returning handlers today, so there's no live gap — but a credential route added there or in a future route module is invisible to the scan, which is the exact failure mode the test exists to prevent. Globbing server/routes*.ts closes it.

Minor, same file: route persists across the gaps between handler declarations, so a credential-shaped line in a helper defined between two app.x(...) calls would be attributed to the preceding route. Nothing currently trips it.

5. readLastFailedHttpStatus: the second root only helps for absolute banners

path.resolve(permitted[0], dir, ...) joins a relative banner to AEVAL_DATA_PATH only; workDir participates in the containment check but never in resolution. That matches the documented forward-compat case (a future aeval honouring the absolute output_dir reports an absolute path, which resolve picks up), so it's correct as written — but if aeval ever emits a path relative to the scenario workdir, the feature silently returns null rather than falling back. Worth a line in the comment since the current one reads as though both roots are tried.

Nits

  • Artifacts saved to:[^\S\r\n]*(\S+) truncates at the first space in a directory name. Fails soft; probably fine given aeval's timestamped dir naming.
  • secretFingerprint's | Record<string, never> return type works with the spread but Partial<{...}> reads better.
  • Broadening /api/organizations and /api/admin/users to whole-subtree redaction costs a lot of response logging. The comment says that's deliberate, and I agree with the trade — flagging only so it's a conscious cost.

@guohai
guohai merged commit fd7c062 into main Sep 1, 2026
6 of 7 checks passed
@guohai
guohai deleted the feat/mint-diagnostics branch September 1, 2026 03:51
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