Skip to content

M5 hardening: cross-tenant vault scoping + dialog handling breadth#8

Merged
gcharang merged 2 commits into
masterfrom
m5-vault-tenant-scoping
Jul 17, 2026
Merged

M5 hardening: cross-tenant vault scoping + dialog handling breadth#8
gcharang merged 2 commits into
masterfrom
m5-vault-tenant-scoping

Conversation

@gcharang

Copy link
Copy Markdown
Contributor

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.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 tenants, so this check belongs server-side, not in a consumer-side breakwater.

Fix (server-side, 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't straddle the namespace prefix.

Keeps the vault://<tenant>/ref convention, the deployed vault keys, and the published @understudy/connector@0.2.0 absolute-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.

  • protocol (→0.5.0): new dialog Event, single-sourced via DialogRecordSchema so extension/backend/connector all derive from one definition.
  • extension: type-aware 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 channel is always freed even when the report is a no-op (WS down) or the dialog type is unclassifiable.
  • backend: capped SessionState.dialogs, surfaced via GET /v1/sessions/:id.
  • connector (→0.3.0): 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 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

  • 230 tests pass (protocol 26, extension 56, connector 28, backend 120); typecheck + build clean across all four packages.
  • Both changes passed a three-lane review (clean-code, architecture, adversarial QA) with every substantive finding fixed and re-verified.
  • A changeset bumps @understudy/protocol and @understudy/connector minor (backend/extension are private, not published).

🤖 Generated with Claude Code

gcharang and others added 2 commits July 17, 2026 14:51
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>
@gcharang
gcharang merged commit 644d31b into master Jul 17, 2026
1 check passed
@gcharang
gcharang deleted the m5-vault-tenant-scoping branch July 17, 2026 12:14
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