M5 hardening: cross-tenant vault scoping + dialog handling breadth#8
Merged
Conversation
SessionAgent.fillSecret resolved any caller-supplied secretRef against the single shared KV vault with no tenant scoping, so a consumer authenticated as tenant B — driving its own legitimate session — could fill_secret with secretRef "vault://tenantA/..." and type tenant A's plaintext into a field B controls. understudy owns the shared vault across every tenant, so this check belongs server-side here, not delegated to a consumer-side breakwater (which governs only that consumer's own agent, never one tenant vs another). Fix (Option A, non-breaking): - auth.ts::tenantOf derives a session's authoritative tenant from its HMAC-signed sessionId (never a caller claim); scopeSession delegates to it. - fillSecret refuses any secretRef outside the session tenant's vault://<tenantId>/ namespace before any vault read, returning the same scrubbed ok:false an absent secret gives (no existence oracle, DL-008). - isValidTenantId (non-empty, no "/") enforced at mintSessionId (fail-closed at session creation) and re-checked in tenantOf, so a "/"-bearing tenant can never straddle the namespace prefix (acme vs acme/eu). - dryRun previews the same refusal, so a governance pre-approval simulation is honest on the tenant axis (still zero vault access, no wire traffic). Keeps the vault://<tenant>/ref convention, the deployed vault keys, and the published @understudy/connector@0.2.0 absolute-secretRef contract unchanged. Adds the two-tenant vault isolation e2e (cross-tenant refusal with no vault read and no plaintext on the wire; own-tenant still resolves; guard runs before replay and before the liveness gate; confusable edge-shape refs; dryRun preview) plus tenantOf / isValidTenantId / forge-signed-id unit tests. 116 backend tests pass, typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
….3.0) A page dialog (alert/confirm/prompt/beforeunload) blocks the single CDP channel, so the extension now answers it locally and synchronously with a type-aware disposition and reports it to the consumer after the fact - the consumer is never in the response path (an open dialog leaves no time to ask). - protocol: new `dialog` Event, single-sourced via DialogRecordSchema so the extension, backend, and connector all derive from one definition (no hand-copied field lists to drift). - extension: dialogDisposition (alert/beforeunload accept, confirm/prompt dismiss) replaces the prior blind dismiss - a beforeunload dismiss was cancelling the automation's own navigations. applyDialogDecision answers before reporting, so the CDP channel is always freed even when the report is a no-op (WS down) or the dialog type is unclassifiable. - backend: rememberDialog records into a capped SessionState.dialogs, surfaced via GET /v1/sessions/:id. - connector: get_dialogs observe read over an egress-guarded GET. The dialogs surface is BEST-EFFORT (like page_event): a report emitted while the WS is momentarily down (e.g. an MV3 service-worker reconnect) is not replayed - the dialog is still answered, only its notification is lost. Documented in the protocol/backend comments and plan residual #6; guaranteed-delivery-on-reconnect is a deliberate follow-up. 230 tests pass (protocol 26, extension 56, connector 28, backend 120); typecheck + build clean. Changeset bumps @understudy/protocol and @understudy/connector minor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent M5 substrate-hardening items, each a self-contained commit.
1. Cross-tenant vault scoping + two-tenant isolation e2e (
6a888ff)Writing the two-tenant isolation e2e surfaced a high-severity cross-tenant credential-exfiltration gap:
SessionAgent.fillSecretresolved any caller-suppliedsecretRefagainst the single shared KV vault with no tenant scoping, so a consumer authenticated as tenant B — driving its own legitimate session — couldfill_secretwithsecretRef: "vault://tenantA/…"and type tenant A's plaintext into a field B controls. understudy owns the shared vault across tenants, so this check belongs server-side, not in a consumer-side breakwater.Fix (server-side, non-breaking):
auth.ts::tenantOfderives a session's authoritative tenant from its HMAC-signed sessionId (never a caller claim);scopeSessiondelegates to it.fillSecretrefuses anysecretRefoutside the session tenant'svault://<tenantId>/…namespace before any vault read, returning the same scrubbedok:falsean absent secret gives (no existence oracle, DL-008).isValidTenantId(non-empty, no/) enforced atmintSessionId(fail-closed at session creation) and re-checked intenantOf, so a/-bearing tenant can't straddle the namespace prefix.Keeps the
vault://<tenant>/refconvention, the deployed vault keys, and the published@understudy/connector@0.2.0absolute-secretRef contract unchanged.2. Dialog handling breadth (
432e2cc)A page dialog (alert/confirm/prompt/beforeunload) blocks the single CDP channel, so the extension answers it locally and synchronously (the consumer is never in the response path) and reports it after the fact.
dialogEvent, single-sourced viaDialogRecordSchemaso extension/backend/connector all derive from one definition.dialogDisposition(alert/beforeunload→accept, confirm/prompt→dismiss) replaces the prior blind dismiss — abeforeunloaddismiss was cancelling the automation's own navigations.applyDialogDecisionanswers before reporting, so the channel is always freed even when the report is a no-op (WS down) or the dialog type is unclassifiable.SessionState.dialogs, surfaced viaGET /v1/sessions/:id.get_dialogsobserve read over an egress-guarded GET.The dialogs surface is best-effort (like
page_event): a report emitted while the WS is momentarily down isn't replayed — the dialog is still answered, only its notification is lost. Documented in the protocol/backend comments and plan residual #6; guaranteed-delivery-on-reconnect is a deliberate follow-up.Verification
@understudy/protocoland@understudy/connectorminor (backend/extension are private, not published).🤖 Generated with Claude Code