Skip to content

feat(privacy): gate non-local session establishment on recorded consent (SC-05) - #162

Merged
myselfsiddharth merged 2 commits into
mainfrom
wave0/b0-consent-language
Aug 14, 2026
Merged

feat(privacy): gate non-local session establishment on recorded consent (SC-05)#162
myselfsiddharth merged 2 commits into
mainfrom
wave0/b0-consent-language

Conversation

@myselfsiddharth

@myselfsiddharth myselfsiddharth commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #102 (SC-05, PRD §7). Delivers all three things the issue asked for:

  1. Where the consent moment livesADR-0018 decides it's a stored consent record, checked before every session-establishing run — not an onboarding flow (this product has no onboarding step) and not a one-time first-run banner (can't express "did the user agree to this account," and can't be checked on any run after the first). Both alternatives are rejected in writing there, with the reasoning.
  2. The actual copydocs/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.
  3. The checkable engineering formsrc/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_PORT in src/testbed/constants.ts127.0.0.1), not assumed.
    • ConsentAcknowledgment{ copy_version, acknowledged_at } only, private constructor + #brand field so an object literal can't forge one (same pattern as TenantKey in src/session/keys.ts). No credential or session material is ever a field on it.
    • SessionAuthorization.authorize(baseUrl, consent?) — the only way to get a SessionAuthorization. Throws ConsentRequiredError for a non-local target with no consent.
    • establishSession's signature changed: EstablishSessionOptions.target: SessionAuthorization replaces baseUrl: string. There is no second door into the login flow — every caller must go through authorize() 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 no consent argument, so no behavior changed for Track 1tests/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-error cases 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:

  • "Can a session be established without going through the check at all" — enforced by construction.
  • "Does a non-local, non-consented call actually get refused" — enforced by test, not by construction (a runtime hostname check, not an unrepresentable type state).

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, and consent.ts's module doc.

  • No persistence layer for the consent record (no caller exists yet to need one).
  • No UI/CLI prompt that shows the copy or captures a real acknowledgment.
  • The copy is not legally reviewed. Do not treat it as cleared.
  • Stated explicitly in Session-consent gate: the deferred half of SC-05 (founder legal review, persistence, UI) #163, not just a module docstring: 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)

  • CodeQL high-severity alert fixed, not suppressed. tests/unit/session-consent.test.ts had two expect(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.
  • IPv4 loopback regex tightened (non-blocking nit from review): isLocalHostname now uses a proper 0–255 octet range so 127.999.999.999 no 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 in vitest.config.ts itself (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 with PARAGENT_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-error gate-bypass cases.

🤖 Generated with Claude Code

…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>
@myselfsiddharth
myselfsiddharth requested a review from a team as a code owner August 14, 2026 19:19
@github-actions github-actions Bot added the size/XL > 600 changed lines — consider splitting label Aug 14, 2026
@github-actions
github-actions Bot requested a review from OM152002 August 14, 2026 19:19
@github-actions github-actions Bot added documentation Improvements or additions to documentation proposal Design / governance proposal gate PRD section 9 gate measurement area: recorder Touches recorder area: experiments Touches experiments privacy-boundary Touches the privacy boundary — canary is merge-blocking labels Aug 14, 2026
Comment thread tests/unit/session-consent.test.ts Fixed
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

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 EstablishSessionOptions from baseUrl: string to target: SessionAuthorization — with a private constructor and a #brand field mirroring TenantKey — means the check isn't something a caller should do, it's something they cannot route around. I verified the "no second door" claim independently rather than taking it on trust: grep for establishSession across src/, experiments/, and tests/ returns only preamble.ts (the definition), recorder/index.ts (a re-export), the two real call sites, and tests; and preamble.ts:144 is the only /login navigation in the repo. There is no alternate login path.

Track 1 is unaffected, which I also checked rather than assumed: both real call sites (src/recorder/cli.ts:394, experiments/gate-v1/live-run.ts:387) pass SessionAuthorization.authorize(baseUrl) with no consent argument against the local test-bed, which authorizes unconditionally.

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. session-custody.md's three-value ladder is used correctly.

isLocalHostname covering all of 127.0.0.0/8, bracketed IPv6, and case-insensitive localhost — rather than pinning the one literal the test-bed happens to bind — is the right instinct, and treating an unparseable URL as non-local is the correct fail-closed direction.

Must fix 1 — CodeQL high-severity alert (new on this PR)

tests/unit/session-consent.test.ts:123:

expect(err.message).toMatch(/real-customer\.example\.com/);

Missing regular expression anchor — when this is used as a regular expression on a URL, it may match anywhere, and arbitrary hosts may come before or after it.

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 expect(err.message).toContain("real-customer.example.com"). That's the right matcher for a substring assertion, it says what it means, and it takes the regex out of the picture entirely. Same treatment for the /SC-05/ assertion below it.

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 — Closes #102 is premature; file the deferred half

The issue's "What to produce" item 2 is "The actual copy, reviewed by whoever owns legal risk here (founder)." That review has not happened — session-consent-copy.md carries confidence: LOW and says so itself. Also outstanding: no persistence layer for the consent record, and no UI/CLI moment that shows anyone the copy. You documented all three accurately, which is why this isn't a correctness complaint — it's a tracking one. On merge, Closes #102 retires the issue and the founder's legal review stops being tracked anywhere.

This repo already has the pattern for exactly this: #146, "Session-key custody: the deferred half of SC-01 (KMS, rotation, tenant offboarding)" — filed when ADR-0016 decided the mechanism but deferred the operational half. Do the same here. File "the deferred half of SC-05": founder legal review of the copy, consent-record persistence, and the UI moment that displays it. Link it from ADR-0018 and from session-custody.md's SC-05 section. Then Closes #102 is honest, because what remains has a home.

Include this in that issue explicitly, because it is the sharpest limitation and it should not live only in a module docstring: ConsentAcknowledgment.record() can be called with no arguments by any caller, so what the gate currently proves is "a caller asserted consent", not "a human was shown the copy and agreed". That gap closes only when the persistence and UI halves land, and it is worth a reader of the issue knowing it up front.

Non-blocking

/^127(?:\.\d{1,3}){3}$/ accepts 127.999.999.999, which is not a valid address — technically over-permissive in the unsafe direction. No such host resolves, so this is a nit, not a finding. Tighten it or leave it.

Verdict: hold for the CodeQL fix and the follow-up issue. The engineering is sound and I'd merge it on those two.

- 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>
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Opus re-review — approved

Both must-fixes verified on the pushed branch.

CodeQL is green. The assertions now use toContain for both the hostname and SC-05, which is the right matcher for a substring check and takes the regex out of the picture rather than suppressing the alert. The remaining toMatch(/cookie|password|token|session/i) is a genuine pattern match on a negative assertion — correct as a regex, and not what CodeQL flagged.

#163 filed and linked from ADR-0018, session-custody.md, and the PR body. Closes #102 is now honest: what shipped is the decision, the copy, and the gate; what didn't — founder legal review, persistence, and the UI moment — has its own tracked home, following the #146 precedent.

You also took the non-blocking nit and did it properly: OCTET as a real 0-255 pattern instead of \d{1,3}, with a test pinning 127.999.999.999 and 127.256.0.1 as non-local. Small, but it's the kind of thing that rots into a false-local match later.

Standing by the rest of the review: the construction-level gate on establishSession is the right mechanism, there is genuinely no second door into the login flow, and the enforcement claim is stated at the rung it actually reached rather than rounded up.

Required checks green, CodeQL green. Merging via admin bypass.

@myselfsiddharth
myselfsiddharth merged commit 3a3a743 into main Aug 14, 2026
13 checks passed
@myselfsiddharth
myselfsiddharth deleted the wave0/b0-consent-language branch August 14, 2026 19:31
myselfsiddharth added a commit that referenced this pull request Aug 15, 2026
…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>
myselfsiddharth added a commit that referenced this pull request Aug 15, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: experiments Touches experiments area: recorder Touches recorder documentation Improvements or additions to documentation gate PRD section 9 gate measurement privacy-boundary Touches the privacy boundary — canary is merge-blocking proposal Design / governance proposal size/XL > 600 changed lines — consider splitting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Design and ship explicit session-automation consent language (PRD §7)

2 participants