feat(privacy): gate non-local session establishment on recorded consent (SC-05) - #162
Conversation
…nt (SC-05) PRD §7 / SC-05 required explicit "you are authorizing automation of your own account" consent before Fork A rides a real user's session, and nothing enforced it. ADR-0018 decides the consent moment is a stored consent record checked before every session-establishing run (not an onboarding flow or a one-time first-run banner, both rejected in writing) and ships the guard: `establishSession` now requires a `SessionAuthorization`, obtainable only via `SessionAuthorization.authorize(baseUrl, consent?)` (src/session/consent.ts), which refuses a non-local target with no `ConsentAcknowledgment`. Both classes use TenantKey's private-constructor pattern so the check cannot be bypassed by an object literal. The gate itself is enforced by construction (no second door into establishSession); the refusal for a non-local, non-consented call is enforced by test (tests/unit/session-consent.test.ts), since TypeScript cannot evaluate a hostname at compile time. Local targets (the Grafana test-bed, fixture credentials the project owns) are unaffected — tests/unit/recorder-preamble.test.ts passes unchanged, 8/8. docs/privacy/session-consent-copy.md drafts the copy PRD §7 asks for. It is explicitly NOT legally reviewed, and says so in its own body — issue #102 requires review by whoever owns legal risk (founder), which has not happened. No consent record ever carries credentials or session material; ConsentAcknowledgment is copy_version + timestamp only. docs/privacy/session-custody.md's SC-05 row and gap analysis are updated to match exactly what shipped: the gate and refusal are real, the persistence layer and UI are not built (no real non-local caller exists yet to build them for), and the copy is unreviewed. Closes #102 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opus review — changes requested (2 must-fix)Reviewed the guard, both docs, the tests, and every call site. The core design is right and I want to be specific about why, because it's the part that makes SC-05 real rather than aspirational. The construction move is correct and it holds. Changing Track 1 is unaffected, which I also checked rather than assumed: both real call sites ( The enforcement claim is stated honestly. Splitting it — construction for "did this go through the gate at all", test for "is a non-local non-consented call actually refused" — is the accurate description, and rounding that up to a single word would have been the easy and wrong thing to do.
Must fix 1 — CodeQL high-severity alert (new on this PR)
expect(err.message).toMatch(/real-customer\.example\.com/);
In context this is a false positive for impact — it asserts on an error message, and no security decision is made by it. But it is a real high-severity alert on a security-labeled PR, and the fix makes the test better regardless: use CodeQL is not one of the three required contexts, so this could be bypassed. It shouldn't be, on this PR of all PRs. Must fix 2 —
|
- tests/unit/session-consent.test.ts: replace two unanchored .toMatch() regex assertions with .toContain() substring checks. CodeQL flagged these as "missing regular expression anchor" on a URL-shaped string (high severity); the assertions never drove a security decision, but toContain is strictly the correct matcher for a plain substring check and removes the alert rather than suppressing it. - src/session/consent.ts: tighten the IPv4 loopback regex to a proper 0-255 octet range so "127.999.999.999" (no such host resolves, and it is not a loopback address) no longer matches. Pinned by a new test. - File #163, "the deferred half of SC-05" (founder legal review of the draft copy, consent-record persistence, the UI moment that displays it), matching the house style of #146 (SC-01's deferred custody half). Linked from ADR-0018, docs/privacy/session-custody.md's SC-05 section, and src/session/consent.ts's module doc, so a reader of any of them lands on what's left. States explicitly that ConsentAcknowledgment.record() takes no required arguments today, so the gate currently proves "a caller asserted consent," not "a human was shown the copy and agreed" — a gap that only closes once persistence and UI land. npm run ci and npm run test:canary both green after these changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opus re-review — approvedBoth must-fixes verified on the pushed branch. CodeQL is green. The assertions now use #163 filed and linked from ADR-0018, You also took the non-blocking nit and did it properly: Standing by the rest of the review: the construction-level gate on Required checks green, CodeQL green. Merging via admin bypass. |
…uthorization #162 (SC-05 consent gate) merged after this branch was cut and changed EstablishSessionOptions.baseUrl to a required target: SessionAuthorization, gating non-local session establishment on recorded consent. This entry point is a third establishSession call site that didn't exist when #162 landed, so typecheck caught the gap the merge couldn't. Matches the other two call sites (src/recorder/cli.ts, live-run.ts): SessionAuthorization.authorize(baseUrl) with no consent argument, because the fresh-baseline runner only ever targets the local test-bed with fixture credentials the project owns. Documents in docs/gate/fresh-baseline.md that pointing this harness at a non-local target now requires a recorded consent acknowledgment, and that ConsentRequiredError is correct behaviour there, not a bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(runner): ship the fresh-baseline measurement harness (#39) Adds the harness half of #39 — the §9 kill line's denominator. Ships: FreshBaselineClient/StubFreshBaselineClient, AnthropicFreshBaselineClient (same SDK wiring and token-accounting convention as repair-anthropic.ts, including billedInputTokens reused verbatim), FreshBaselineRunner (emits through MetricsEmitter to its own file, never the matrix's), the `gate:baseline` entry point, `gate:matrix --cost-fresh` wiring into ReplayRunner's existing costFresh option, and docs/gate/fresh-baseline.md defining "fresh reasoning" with the protocol template. Does NOT ship a measured number: no live model call was made, cost_fresh stays zeros, and repair-cost-vs-fresh / amortized-tokens stay no_data. The live measurement (3+ runs, real ANTHROPIC_API_KEY, real spend) is separate follow-up work. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(runner): route fresh-baseline's establishSession through SessionAuthorization #162 (SC-05 consent gate) merged after this branch was cut and changed EstablishSessionOptions.baseUrl to a required target: SessionAuthorization, gating non-local session establishment on recorded consent. This entry point is a third establishSession call site that didn't exist when #162 landed, so typecheck caught the gap the merge couldn't. Matches the other two call sites (src/recorder/cli.ts, live-run.ts): SessionAuthorization.authorize(baseUrl) with no consent argument, because the fresh-baseline runner only ever targets the local test-bed with fixture credentials the project owns. Documents in docs/gate/fresh-baseline.md that pointing this harness at a non-local target now requires a recorded consent acknowledgment, and that ConsentRequiredError is correct behaviour there, not a bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
Closes #102 (SC-05, PRD §7). Delivers all three things the issue asked for:
docs/privacy/session-consent-copy.md, stating PRD §7's "you are authorizing automation of your own account" close to verbatim. This copy has NOT been legally reviewed. Issue Design and ship explicit session-automation consent language (PRD §7) #102 requires review "by whoever owns legal risk here (founder)" before it's ever shown to a real user — that has not happened, and the document says so in its own frontmatter (confidence: LOW) and body, up front, not buried in open questions.src/session/consent.ts:isLocalTarget(baseUrl)— the IPv4 loopback block (127.0.0.0/8, not just.1),localhost, and IPv6 loopback need no consent. Verified against what the test-bed actually binds (DEFAULT_HOST_PORTinsrc/testbed/constants.ts→127.0.0.1), not assumed.ConsentAcknowledgment—{ copy_version, acknowledged_at }only, private constructor +#brandfield so an object literal can't forge one (same pattern asTenantKeyinsrc/session/keys.ts). No credential or session material is ever a field on it.SessionAuthorization.authorize(baseUrl, consent?)— the only way to get aSessionAuthorization. ThrowsConsentRequiredErrorfor a non-local target with no consent.establishSession's signature changed:EstablishSessionOptions.target: SessionAuthorizationreplacesbaseUrl: string. There is no second door into the login flow — every caller must go throughauthorize()first. All three real call sites (src/recorder/cli.ts,experiments/gate-v1/live-run.ts, and the test suite) target the local test-bed and needed noconsentargument, so no behavior changed for Track 1 —tests/unit/recorder-preamble.test.ts(the real-browser login suite) passes unchanged, 8/8.tests/unit/session-consent.test.ts(20 tests) covers the refusal path directly, plus@ts-expect-errorcases pinning that the gate can't be bypassed at the type level.docs/privacy/session-custody.md's SC-05 row and gap-analysis section are updated to say exactly what shipped, not more: enforced by construction (the gate — no second door) + enforced by test (the refusal, since TypeScript can't evaluate a hostname at compile time).docs/privacy/counsel-packet-sizing.md(landed on main during this PR's development) is also touched — one paragraph updating its now-stale "SC-05 is not addressed" cross-reference.Enforcement rung reached
Per
docs/privacy/session-custody.md's three-value scale, stated honestly rather than rounded up:What is NOT done — tracked as #163
Persistence (reading/writing a consent record to disk) and UI (a CLI prompt showing the copy) are explicitly not built — nothing in this repo establishes a session against a non-local target yet, so there's no real caller to build either for. Filed as #163, "Session-consent gate: the deferred half of SC-05 (founder legal review, persistence, UI)" — the same shape #146 gave SC-01's deferred custody half — and linked from ADR-0018,
session-custody.md's SC-05 section, andconsent.ts's module doc.ConsentAcknowledgment.record()takes no required arguments today, so what the gate currently proves is "a caller asserted consent," not "a human was shown the copy and agreed." That gap closes only once persistence and UI land together.Review fixes (this branch, after initial review)
tests/unit/session-consent.test.tshad twoexpect(err.message).toMatch(/.../)assertions against a URL-shaped string, flagged as "missing regular expression anchor." Replaced both with.toContain(...)— the correct matcher for a plain substring check, and it removes the alert rather than adding a suppression comment.isLocalHostnamenow uses a proper 0–255 octet range so127.999.999.999no longer matches. Pinned by a new test.Test plan
npm run ci— green, run in the foreground: 473 unit tests + 26 integration tests, all passing. One unit test (recorder-preamble.test.ts's "rejects a session belonging to a different user") intermittently timed out on earlier attempts under full-parallel load — this is the pre-existing, already-documented flake invitest.config.tsitself (issue test(ci): browser-driven unit suites have no per-test ceiling — one hung 944s under contention instead of failing #145: "same commit, same machine" resource contention from concurrent Chromium instances). Confirmed unrelated to this change: passes 8/8 in isolation, and the full suite is consistently green withPARAGENT_TEST_WORKERS=2, which is how the final green run above was produced.npm run test:canary— green, 52/52 (merge-blocking privacy boundary, unaffected by this change).npm run lint:docs— clean, 64 docs.npm run typecheck— clean, including the@ts-expect-errorgate-bypass cases.🤖 Generated with Claude Code