test(privacy): cover the toTrajectory() bypass; discover trajectories instead of listing them - #110
Conversation
… instead of listing them Closes #99. Follow-up to #37 (SC-02). Stacked on #109. Two holes, one root cause: a guarantee that depends on a specific call path or a hand-maintained list rather than on the data shape itself. 1. assertNoLiteralSecrets runs inside write(). toTrajectory() -- the method that builds the object -- has no equivalent, so a caller that serializes its return value directly gets no guard. Nothing in the tree does that today; nothing stops it either. tests/unit/trajectory-guard.test.ts records a login-shaped task, takes the object from toTrajectory() without ever calling write(), and asserts it passes the same guard write() would have applied -- pretty-printed and compact, since the patterns match differently across the two. It asserts the PROPERTY the guard protects, not that the guard ran. It therefore fails only if the recorder actually starts leaking, which is the thing worth knowing. 2. validate-contracts.mjs schema-checked trajectories named in a hand-maintained extraTrajectories array. A new recording nobody remembered to add was never checked -- including against additionalProperties:false, the mechanism that makes an accidental `cookies` field unrepresentable rather than discouraged. Now discovered by walking experiments/**/trajectories/*.json. No new dependency: CI is on Node 20 and fs.glob is 22+. Discovering zero is a hard failure, not "nothing to do" -- an empty result means the walk broke, which is the same silent hole in a new shape. Guard-proofing found a gap in my own tests. The bypass assertions are all "does not throw", so weakening the guard makes them MORE likely to pass. Deleting the "value" pattern from assertNoLiteralSecrets left all eight green. Each of the guard's six patterns is now pinned individually, plus the negative case that a parameter type map ("password":"secret_ref") is a slot declaration, not a value. Not closed, and said so in the doc: toTrajectory() still has no guard of its own. Adding one changes behaviour -- it could throw where it previously did not -- and #99 asked for the bypass to be covered, not closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewCovers two SC-02 holes: adds What I checkedRead the full diff and Strengths
Issue foundDiscovery is location-based, so it swaps a hand-maintained list for a hand-maintained location convention.
This is reachable through the supported interface: Filed as #116 with a suggested shape-based fix (validate any Note on the stacked baseBase is Verdict: Merge (after retargeting to |
|
@OM152002 Pls retarget to main and address the issues found. |
Closes #99. Follow-up to #37 (SC-02). Stacked on #109 — base is
track1/b5-secret-scan-storage-state; retarget tomainonce that lands. Both editdocs/privacy/session-custody.md, which is why this is stacked rather than parallel.Two holes with one root cause: a guarantee that depends on a specific call path or a hand-maintained list, rather than on the data shape itself.
1. The
toTrajectory()bypassassertNoLiteralSecrets()runs insidewrite().toTrajectory()— the method that actually builds the object — has no equivalent, so a caller that serializes its return value directly gets no guard at all. Nothing in the tree does that today; nothing stops it either.tests/unit/trajectory-guard.test.tsrecords a login-shaped task against a loopback fixture, takes the object fromtoTrajectory()without ever callingwrite(), and asserts it passes the same guardwrite()would have applied — in both pretty-printed and compact form, since"cookies"\s*:matches differently across the two.The framing is deliberate: it asserts the property the guard protects, not that the guard ran. So it fails only if the recorder actually starts leaking, which is the thing worth knowing. A test that
write()calls the guard would tell you nothing about the bypass.2. Trajectories are discovered, not listed
validate-contracts.mjsschema-checked whatever was named in a hand-maintainedextraTrajectoriesarray. A new recording nobody remembered to add was never checked — including againstadditionalProperties: false, which is the mechanism that makes an accidentalcookiesfield unrepresentable rather than merely discouraged. The failure was silent and grew with every recording.Now discovered by walking
experiments/**/trajectories/*.json. No new dependency: CI runs Node 20 andfs.globis 22+, so it's a small recursive walk, consistent with the repo's no-new-deps convention.Discovering zero is a hard failure, not "nothing to do":
An empty result means the walk broke, which is the same silent hole in a new shape.
Guard-proofing found a gap in my own tests
The bypass assertions are all "does not throw" — so weakening the guard makes them more likely to pass. They structurally cannot detect it.
Verified by deleting the
"value"pattern fromassertNoLiteralSecrets: all eight tests stayed green. Each of the guard's six patterns is now pinned individually, and re-running the same sabotage fails exactly one case:Plus the negative case that matters for that first pattern:
{"parameters":{"password":"secret_ref"}}declares a slot and must not fire, while{"password":"an-actual-password"}fills one and must.Breaking discovery is proven too — the CLI refuses to report clean, and two discovery tests fail.
Not closed, and the doc says so
toTrajectory()still has no guard of its own. Adding one changes behaviour — it could throw where it previously did not — and #99 asked for the bypass to be covered, not closed.docs/privacy/session-custody.mdrecords that plainly under SC-02 rather than implying the gap is gone.SC-02's schema-validation note is updated too: it previously described the
extraTrajectoriesarray and named its residual gap, which is now the thing this PR removes.Stacked-PR caveat:
pull_requesttargetsmainonly, so this gets no CI while based on #109. Everything above was run locally against this exact tree. Retarget once #109 merges and it picks up the real thing.🤖 Generated with Claude Code