diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e09642..3acf65b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: run: npx biome check scripts/manage-ingress-waf.mjs scripts/manage-ingress-waf.test.mjs scripts/wrangler-sqlite.test.mjs scripts/release-preflight.mjs scripts/release-preflight.test.mjs scripts/require-release-approval.mjs scripts/require-release-approval.test.mjs scripts/release-authorization.mjs scripts/release-authorization.test.mjs scripts/release-provider.mjs scripts/release-provider.test.mjs scripts/release-postverify.mjs scripts/release-postverify.test.mjs scripts/verify-release-credentials.mjs scripts/verify-release-credentials.test.mjs scripts/inbox-streams-workerd.test.mjs scripts/two-worker-subscriptions.mjs scripts/restore-transition-workerd.test.mjs - name: Lint normal-pause readback run: npx biome check scripts/release-pause-readback.mjs scripts/release-pause-readback.test.mjs + - name: Lint private pilot observations + run: npx biome check scripts/private-pilot-observations.mjs scripts/private-pilot-observations.test.mjs - run: node --test scripts/*.test.mjs - uses: actions/setup-python@v5 with: diff --git a/docs/compatibility/private-pilot-activation.md b/docs/compatibility/private-pilot-activation.md index ed58fdb..8a15228 100644 --- a/docs/compatibility/private-pilot-activation.md +++ b/docs/compatibility/private-pilot-activation.md @@ -29,20 +29,22 @@ these bindings only in a separately reviewed staging candidate: | `RESTORE_QUARANTINE` | `false` only for independently reviewed current state, with no historical restore | | `ENABLE_MCP_SUBSCRIPTIONS` | `false` | | `ENABLE_OPENAI_EVENTS` | `false`; no global/public advertisement | -| `ALLOW_OUTBOUND_NOTIFICATIONS` | `true` only for the approved active trial; `false` is the independent stop switch | +| `ALLOW_OUTBOUND_NOTIFICATIONS` | Delivery mode: `true` only for the approved active trial; `false` stops delivery. Discovery mode: must be `false` | | `PRIVATE_PILOT_POLICY` | Exact serialized JSON string described below | -`PRIVATE_PILOT_POLICY` has exactly these fields: +`PRIVATE_PILOT_POLICY` has the following required fields and one optional +`observationMode` field. Unknown fields are rejected: | Field | Constraint | | --- | --- | -| `schemaVersion`, `mode` | `1`, `a-only` | +| `schemaVersion`, `mode` | `1`; `a-only` for delivery or `a-only-discovery` for callback discovery without delivery | | `buildSha` | Matches `RELEASE_BUILD_SHA` | | `approvedAt`, `notBefore`, `expiresAt` | UTC ISO timestamps; approval no later than start; positive window of at most two hours | | `approvalRef` | Bounded reference to the separately retained operator decision; not a credential or self-issued permission | | `recipientGuestId`, `recipientConnectionId` | The exact independently authorized receiving guest and application connection | | `tableIds` | One to three distinct UUIDs for the approved synthetic primary/cancellation/control tables | -| `callbackTargets` | Strict reviewed HTTPS `origin` plus canonical `pathPrefix` entries; no arbitrary caller-selected destination | +| `callbackTargets` | Delivery: nonempty strict reviewed HTTPS `origin` plus canonical `pathPrefix` entries. Discovery: exactly an empty array | +| `observationMode` (optional) | Exactly `private-redacted` enables separately reviewed private observations. Required in discovery mode; omitted means no private diagnostic stream | The following is **synthetic schema illustration only**, not an approved or currently usable deployment policy: @@ -89,6 +91,144 @@ callback challenge/signature path under the finite policy. It does not claim stronger same-connection IP/TLS evidence than the owner-approved limited-beta native-fetch premise already recorded for Issue #19. +## Discover an actual host callback without outbound requests + +The receiving host supplies its callback URL in `events/subscribe`. An OAuth +redirect origin, documentation example, guessed hostname or synthetic receiver +does not establish that callback destination. When its actual origin is not yet +known, prepare a separate `a-only-discovery` policy under the same staging-only +`private-pilot-a-only` release profile. It requires the exact A guest/connection +and selected tables, the finite approved window, `observationMode: +"private-redacted"`, an empty `callbackTargets` array and +`ALLOW_OUTBOUND_NOTIFICATIONS="false"`. Enabling outbound in this mode makes +the configuration invalid. + +Only A's freshly authorized connection sees the private Events capability. +An actual subscription request crosses a fresh grant and current TableDO +membership/lifecycle check before producing a sanitized HTTPS origin +observation. Discovery sends no callback challenge or event, persists no +callback URL or signing secret, and does not create or commit a subscription. +It returns an operator-review-required failure. The recorded origin remains +unreviewed routing evidence; it is not an allowlist entry or proof of provider +control. Its observation is limited by a separate persisted rolling admission +bucket to one per 60 seconds for the policy/recipient, at most 120 within the +half-open two-hour window. Ordinary ingress limits also apply. + +Review the observed origin and the provider's path-scope requirements before +proposing an `a-only` delivery policy. Use a root path prefix only when the +provider actually requires unrelated opaque paths and that scope is explicitly +reviewed. A subsequent protected deployment binds the reviewed targets. A +new real host subscription must then pass the existing signed callback-control +challenge before delivery becomes eligible. A failed discovery request is +never reclassified as a successful subscription. + +Discovery has effects even while outbound is false. Its protected authorization +and immediate prewrite gate therefore require its window to be active. An +expired discovery policy cannot be deployed as a supposed stopped delivery +configuration. Expiry stops discovery eligibility; an operator stop uses the +separately reviewed return-to-manual configuration. The ordinary outbound +switch is a delivery stop and is not a discovery stop. + +## Explicit private observations and their limits + +`observationMode: "private-redacted"` is an opt-in private diagnostic stream, +separate from routine categorical telemetry. It requires an additional +operator review of the exact policy and observation handling. A prior policy +that omits the field emits no such stream. The new instrumentation introduces +no SQL schema migration and does not alter retry counts, subscription identity +or transport authority. + +The fixed observation schema admits only the scoped synthetic identifiers, +policy/build association, event and attempt timing, and categorical transport +outcomes needed to compare the actual trial. Callback discovery records only +the sanitized origin. It excludes full callback URLs, signing or bearer +credentials, headers, invitations and message bodies. Keep the collected +observations private; publish only reviewed references and digests. Review +account-specific provider log handling and the collector's retention with the +existing operational disclosures. + +The operator captures the stream for a declared interval and compares it with +the policy-scoped SQLite projection through the existing secret-protected +staging evidence route. That projection reads actual subscription/outbox, +message-identity and terminal-state facts. It is not an agent tool and does not +give an outside guest access to private tables. Start capture before the +source event, retain the actual capture boundaries, and finish after every +included attempt has its final outcome and the cancellation observation +interval has ended. + +Live log delivery can be missing, sampled, truncated or interrupted. The SQL +attempt count is a conservation check, not a source for invented authorization +or dispatch timestamps: a reservation can survive a crash before final +authorization. Any missing, ambiguous or unmatched observation makes the +attempt ledger incomplete. Do not infer that no request occurred from an empty +stream, reconstruct timestamps from counters, or silently retry a scenario as +if the first one had passed. A transport invocation is an observation of the +sender calling its actual HTTPS transport, not proof that bytes reached the +receiver. Response headers and later completion remain distinct observations; +even a genuine 2xx response does not establish a new A-host run. + +### Collect and compare the actual observations + +Use `get_connection` through the receiving host to obtain its own authenticated +guest and connection IDs before creating a policy. Rescan the host's MCP app +when needed to discover the new tool and the subsequently enabled Events +capability. Do not substitute a provider link ID for the server connection ID. + +The collector runs on the authorized operator machine with its existing +Cloudflare credentials. Supply the reviewed configuration, an actual bounded +UTC end time with milliseconds, and a new private output path: + +```sh +node scripts/private-pilot-observations.mjs collect \ + --worker agent-table-staging \ + --until "$PILOT_OBSERVATION_END_UTC" \ + --config "$PILOT_STAGING_CONFIG_PATH" \ + --output "$PILOT_CAPTURE_PATH" +``` + +It launches the installed pinned Wrangler directly with sampling rate 1 and +disk logs disabled. Raw tail output, request metadata, exception text and +stderr never reach the output file or terminal. Any stderr warning, including +a reconnect, invalidates continuity. Bounds are 1 MiB per buffered object, +32 MiB total input, 2,000 retained observations and two hours plus one minute +of collection. Output is exclusively created with mode 0600 and no symlink +following. The first actual tail frame emits a safe `capture: ready` marker; +an ordinary authorized read can produce that initial frame. Wait for readiness +before the source event. Process startup alone is insufficient. + +After stopping capture, request the existing `POST /__staging/evidence` with +its operator secret and JSON fields `guestId`, `connectionId`, `tableId` and +`view: "private-pilot"`. Keep the returned snapshot private. This view requires +the exact policy scope and observation opt-in. It intentionally remains +available after outbound pause, policy expiry or grant revocation so retained +terminal state can be inspected. It exports bounded random identifiers and +state, never transcript content, destinations or credentials. Missing retained +rows, mismatched recipient connections or unknown error categories cannot +satisfy complete evidence. + +Finish after all included attempts complete. SIGINT is an explicit requested +stop; unexpected EOF or source failure is incomplete. Capture the final SQL +snapshot at or after collection ends, within 60 seconds. A snapshot taken +before the end cannot cover a later retry whose log was lost. Any additional +charged attempt appearing in the final readback without its complete observed +stages makes the result incomplete. Then compare the private files: + +```sh +node scripts/private-pilot-observations.mjs verify \ + --capture "$PILOT_CAPTURE_PATH" \ + --snapshot "$PILOT_SNAPSHOT_PATH" \ + --output "$PILOT_OBSERVATION_REPORT_PATH" +``` + +The comparison covers every retained outbox row for that table, with exact +event/message/subscription joins, charged attempt counts, stage flags and +timestamps. A successful report is `conditionally-complete` and still says +`hostExecution: "not-established"`. Keep one continuous collector across a +same-build, same-policy storage-preserving restart if it is to cover both +phases. A new collector cannot supply the earlier events' missing history; +preserve a reconnect or capture gap as incomplete. Actual host idle/run/read/ +reply receipts remain independently required by the pilot acceptance record. + ## Prepare a concrete, reviewable candidate 1. Confirm two independently controlled owners/accounts and their separate @@ -100,7 +240,9 @@ native-fetch premise already recorded for Issue #19. cancellation/control tables before proposing their policy. Do not replace missing identities with guessed IDs or enable unrestricted new admission. 3. Review the actual provider callback origin/prefix and callback-control - procedure, bounded canary, stop plan and retained native-fetch risk. Actual + procedure, bounded canary, stop plan and retained native-fetch risk. If the + actual destination is unavailable, use the separately approved no-outbound + discovery stage above before proposing delivery. Actual callback verification and host execution still need to be observed during the trial; an operator checklist is not their passing test result. 4. Build exact staging/peer configuration files and current inventories using @@ -118,17 +260,20 @@ reviewed **no-restore** fields from the release process still apply. Add an | Field | Meaning | | --- | --- | -| `mode` | `a-only` | +| `mode` | Exactly matches policy mode: `a-only` or `a-only-discovery` | | `policySha256` | SHA-256 of the exact serialized policy string | | `approvalRef` | The same policy decision reference | | `independentOwnerConsentsReviewed` | Both owners' bounded authorities actually reviewed | -| `callbackControlReviewed` | Actual callback scope/control procedure reviewed | -| `nativeFetchRiskAccepted` | Recorded limited-beta transport premise accepted for this scope | +| `callbackControlReviewed` | Delivery mode: actual callback scope/control procedure reviewed | +| `nativeFetchRiskAccepted` | Delivery mode: recorded limited-beta transport premise accepted for this scope | +| `callbackDiscoveryReviewed` | Discovery mode: genuine host callback capture and subsequent separate scope review understood | +| `noOutboundReviewed` | Discovery mode: no challenge, delivery or active subscription is permitted | +| `privateObservationReviewed` | Required whenever `observationMode` is present: private diagnostic fields, capture, incompleteness and retention handling reviewed | | `boundedCanaryReviewed` | Exact table/recipient/window and traffic bounds reviewed | | `stopPlanReviewed` | Outbound stop, terminal action and readback plan reviewed | -The five review fields must be `true` based on actual review. They are not -filled automatically from this example. Structural private-profile evidence +Every applicable review field must be `true` based on actual review. They are +not filled automatically from this example. Structural private-profile evidence requires the existing `application`, `security` and `browser` checks. Completed real-host autonomy evidence is deliberately not an activation prerequisite: the purpose of the approved trial is to obtain or fail that evidence. @@ -151,8 +296,8 @@ retains the Environment approval and stages: 3. [`release-authorization.mjs`](../../scripts/release-authorization.mjs) independently reads main CI, protected Environment approval and live Cloudflare resource ownership. It uses the shared policy parser, binds the - operations decision to its exact policy digest and requires an active - window for an enabled trial. + operations decision to its exact policy digest and mode. It requires an + active window for enabled delivery and for every discovery deployment. 4. [`require-release-approval.mjs`](../../scripts/require-release-approval.mjs) checks the profile-bound manifest and private window again immediately before the write, alongside a fresh main-head check. An expired enabled @@ -160,7 +305,8 @@ retains the Environment approval and stages: 5. [`release-postverify.mjs`](../../scripts/release-postverify.mjs) hashes the actual configuration bytes, verifies the approved build/environment/profile and private policy/window/stop metadata, reads live service settings and the - active version, and checks the no-store `/health` build response. + active version, and checks the no-store `/health` build response. The + manifest's mode and observation setting must exactly match the candidate. Both service settings and active-version bindings must contain the same exact policy as the candidate. Missing, duplicated, secret-typed or divergent policy @@ -177,8 +323,8 @@ remains a separate acceptance exercise. ## Stop, expiry and storage-preserving restart -Policy expiry or `ALLOW_OUTBOUND_NOTIFICATIONS=false` stops new private -registration/delivery eligibility while valid configuration preserves ordinary +For `a-only` delivery, policy expiry or `ALLOW_OUTBOUND_NOTIFICATIONS=false` +stops new private registration/delivery eligibility while valid configuration preserves ordinary authorized reads, closure and the owner's permitted unsubscribe. Do not remove or corrupt the policy while retaining the private profile; an invalid hosted configuration fails closed and can also prevent read/close operations. diff --git a/docs/protocol.md b/docs/protocol.md index 9692485..bed30ed 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,10 +1,10 @@ # MCP and application contract -Status: partially implemented. The MCP 2026-07-28 `/mcp` transport, browser-consented OAuth authentication, connection grants, private TableDO initialization/metadata guards, public table/transcript tools, and authorized private-resource reads exist. Single-inbox resource-update subscriptions are implemented behind a default-off flag and locally verified; outbound webhook delivery, staged subscription cost evidence, and autonomous host execution are not implemented. +Status: the MCP 2026-07-28 `/mcp` transport, browser-consented OAuth authentication, connection grants, private TableDO initialization/metadata guards, table/transcript tools, and authorized private-resource reads exist. Single-inbox resource-update subscriptions remain behind a default-off flag. The bounded private staging profile supports separately approved callback discovery, delivery and observations; actual idle-host execution remains unverified. See [private pilot activation](compatibility/private-pilot-activation.md) for its limits. ## Transport profile -The scaffold exposes `/mcp` under its configured `PUBLIC_ORIGIN` with a fresh server per request and modern-only discovery through the pinned SDK v2/Cloudflare handler. Local development uses HTTP; no public HTTPS origin has been deployed. Legacy requests are explicitly rejected. Future application releases must retain this canonical path without a user, table, or credential in its URL. The SDK owns protocol envelopes, version checks, and transport errors. [MCP-SPEC] +The service exposes `/mcp` under its configured `PUBLIC_ORIGIN` with a fresh server per request and modern-only discovery through the pinned SDK v2/Cloudflare handler. Local development uses HTTP; hosted environments use their reviewed HTTPS origin. Legacy requests are explicitly rejected. Releases must retain this canonical path without a user, table, or credential in its URL. The SDK owns protocol envelopes, version checks, and transport errors. [MCP-SPEC] The first compatibility test uses the official SDK client. An older Streamable HTTP profile may be enabled only after the same authorization and application tests pass through it. Supporting an older tools-only profile does not imply support for legacy resource subscriptions or MCP Events. @@ -26,15 +26,27 @@ Canonical request hashing sorts object keys and preserves message text exactly. | `create_invite` | `table_id` | Issues one single-use invite; a new request intentionally replaces a previous unused invite | | `join_table` | `invitation` | Claims the second seat for the authenticated guest and returns table metadata | | `get_table` | `table_id`; no request ID | Returns authorized metadata, membership IDs, effective limits, and own notification status | +| `get_connection` | Empty object; no request ID | Returns only the authenticated caller's guest/connection IDs, current delegated scopes, and token/grant expiry | | `read_messages` | `table_id`, optional `after_seq`, `limit`, `through_seq`; no request ID | Returns a bounded ordered transcript page | | `post_message` | `table_id`, `body`, optional `reply_to_message_id` | Commits exactly one message per operation key and returns its receipt | | `close_table` | `table_id` | Makes the shared table read-only and cancels future notification work | | `delete_table` | `table_id` | Requests browser-confirmed shared deletion; reports the receipt after confirmation | -`create_table`, `create_invite`, `join_table`, and `get_table` are registered at the authenticated MCP endpoint. The remaining close/delete tools above specify future contracts and are **not** advertised as available capabilities. +All listed tools are registered at the authenticated MCP endpoint. Closure is +an authorized member operation; deletion still requires the independent +browser confirmation described below. Scopes and destructive-action confirmation are defined in [security.md](security.md). The creator alone issues invitations; either member may read, post, close, or request deletion subject to state, limits, and scope. No operation accepts a caller-selected sender identity. +`get_connection` is read-only and rechecks the caller's delegated scopes against +the current GuestDO grant. Its `expiresAt` is the earlier of `grantExpiresAt` +and `tokenExpiresAt`; the latter two are also returned explicitly. It accepts +no identity selectors and returns neither other connections nor credentials. +Revoked, expired, deleted or unavailable authority fails closed. This is an +observation of the current authenticated request, not permission for future +calls or table membership. It lets a host identify its own exact connection +for a separately reviewed private pilot without guessing from OAuth metadata. + `create_table` does not silently create an invitation. An invite result contains the table ID, invite ID, expiry, and an invitation string suitable for the landing page or `join_table`. Treat that string as a credential. Repeating issuance with the same key returns the same usable invite while it remains valid; after it is consumed, revoked, or expired, return its terminal status without disclosing the old secret. A new key is required for deliberate rotation. `join_table` accepts a structured opaque invitation encoding the table ID, invite ID, and secret. Validate lengths before routing. Do not implement invitations as arbitrary URLs that the server fetches. Repeating a successful redemption by the same guest is safe; a different guest cannot reuse it. Once occupied, the second seat is not replaceable. diff --git a/docs/release-preflight.md b/docs/release-preflight.md index 847ea4b..89e2a3e 100644 --- a/docs/release-preflight.md +++ b/docs/release-preflight.md @@ -6,6 +6,12 @@ The staging-only [`private-pilot-a-only` verification profile](compatibility/pri uses the same protected release path with a separately reviewed finite policy. It gathers actual host evidence while global/public Events flags remain false; it does not assert that the real-host compatibility gate has already passed. +Its separately reviewed `a-only-discovery` mode accepts an actual host callback +origin with zero outbound traffic and no active subscription. It requires an +empty target list, explicit private redacted observations and an active finite +window; it cannot be substituted for the reviewed `a-only` delivery policy. +Observation collection may be incomplete and cannot turn missing host evidence +into acceptance. Both modes retain the same exact-main protected release path. ## Boundaries diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 47cc3ce..4f4d5fd 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -29,6 +29,13 @@ reviewed callback targets and the exact candidate configuration. It keeps global/public Events advertisement off and requires its own concrete owner approval; merging the implementation does not activate it. Both profiles require `RESTORE_QUARANTINE=false` only for a verified no-restore release. +The private profile has a separately approved `a-only-discovery` mode for +learning the actual host callback origin without any outbound challenge, +delivery or subscription commit. Its finite window is checked even though +outbound is false. Discovery and optional `private-redacted` observations have +their own operator review fields; the protected manifest and post-deploy +readback bind those choices to the exact policy. See the profile document for +capture completeness and the separate transition to reviewed delivery targets. Public `live`, `events` and `autonomous` profiles are not reachable by editing a flag or inventing a successful test URL: they need separately implemented provider/host evidence verification. Restored snapshots with diff --git a/scripts/private-pilot-observations.mjs b/scripts/private-pilot-observations.mjs new file mode 100644 index 0000000..28f88b3 --- /dev/null +++ b/scripts/private-pilot-observations.mjs @@ -0,0 +1,911 @@ +import { spawn } from "node:child_process"; +import { + closeSync, + constants, + fstatSync, + openSync, + readSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const RECORD_SCHEMA = "agent-table.private-pilot.v1"; +export const CAPTURE_SCHEMA = "agent-table.private-pilot-capture.v1"; +export const SNAPSHOT_SCHEMA = "agent-table.private-pilot-snapshot.v1"; +export const MAX_READBACK_LAG_MS = 60_000; +export const LIMITS = Object.freeze({ + objectBytes: 1_048_576, + totalBytes: 32 * 1_048_576, + records: 2_000, + fileBytes: 4 * 1_048_576, + durationMs: 2 * 60 * 60_000 + 60_000, + depth: 64, +}); +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SHA = /^[0-9a-f]{40}$/; +const REF = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/; +const SUBSCRIPTION = /^sub_[A-Za-z0-9_-]{16,64}$/; +const FAILURE = new Set([ + "transport-error", + "deadline", + "rate-limited", + "server-error", + "receiver-gone", + "payload-rejected", + "authorization-revoked", + "retry-exhausted", +]); +const CAPTURE_FAILURE = new Set([ + "input-overflow", + "object-overflow", + "record-overflow", + "nesting-overflow", + "invalid-json", + "invalid-utf8", + "invalid-framing", + "truncated-input", + "invalid-diagnostic", + "invalid-tail-frame", + "source-diagnostic", + "source-ended", + "source-error", + "source-exit", + "source-kill-timeout", + "source-unverified", + "not-ready", +]); +const BASE = [ + "schema", + "kind", + "buildSha", + "approvalRef", + "tableId", + "observedAt", +]; +const ATTEMPT = [ + ...BASE, + "eventId", + "messageId", + "seq", + "membershipId", + "subscriptionId", + "generation", + "attempt", +]; +const DISCLAIMER = + "Conditional metadata completeness only. Tail logs may be sampled or unavailable; " + + "local files do not authenticate their origin. Inspect independent server and host " + + "receipts. No delivery record proves a host run, authenticated read, or reply."; + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function exact(value, keys) { + return ( + object(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ); +} +function integer(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) { + return Number.isSafeInteger(value) && value >= minimum && value <= maximum; +} +function time(value) { + return integer(value, 1, 8_640_000_000_000_000); +} +function nullableTime(value) { + return value === null || time(value); +} +function matches(pattern, value) { + return typeof value === "string" && pattern.test(value); +} +function publicOrigin(value) { + if (typeof value !== "string" || value.length > 261) return false; + try { + const url = new URL(value); + return ( + url.origin === value && + url.protocol === "https:" && + url.username === "" && + url.password === "" && + url.port === "" && + url.hostname.includes(".") && + !url.hostname.endsWith(".") && + !/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname) && + ![ + ".localhost", + ".local", + ".internal", + ".home.arpa", + ".test", + ".invalid", + ].some((suffix) => url.hostname.endsWith(suffix)) && + url.hostname + .split(".") + .every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) + ); + } catch { + return false; + } +} + +/** Reject extra fields instead of copying a potentially secret-bearing object. */ +export function validateDiagnostic(record) { + if ( + !object(record) || + record.schema !== RECORD_SCHEMA || + !matches(SHA, record.buildSha) || + !matches(REF, record.approvalRef) || + !matches(UUID, record.tableId) || + !time(record.observedAt) + ) + return null; + if (record.kind === "callback-origin") { + return exact(record, [...BASE, "membershipId", "callbackOrigin"]) && + matches(UUID, record.membershipId) && + publicOrigin(record.callbackOrigin) + ? { ...record } + : null; + } + if ( + ![record.eventId, record.messageId, record.membershipId].every( + (id) => typeof id === "string" && UUID.test(id), + ) || + !matches(SUBSCRIPTION, record.subscriptionId) || + !integer(record.seq, 1, 12) || + !integer(record.generation, 1) || + !integer(record.attempt, 1, 8) + ) + return null; + if ( + record.kind === "attempt-authorized" || + record.kind === "transport-invoked" + ) + return exact(record, ATTEMPT) ? { ...record } : null; + if (record.kind === "response-headers") + return exact(record, [...ATTEMPT, "status"]) && + integer(record.status, 100, 599) + ? { ...record } + : null; + if (record.kind !== "attempt-completed") return null; + const extras = ["outcome", "settled", "transportInvoked", "responseObserved"]; + if (record.outcome !== "accepted") extras.push("errorCategory"); + if ( + !exact(record, [...ATTEMPT, ...extras]) || + !["accepted", "transient", "permanent"].includes(record.outcome) || + (record.outcome !== "accepted" && !FAILURE.has(record.errorCategory)) || + !["accepted", "pending", "failed", null].includes(record.settled) || + typeof record.transportInvoked !== "boolean" || + typeof record.responseObserved !== "boolean" || + (record.responseObserved && !record.transportInvoked) || + (record.outcome === "accepted" && + (!record.transportInvoked || !record.responseObserved)) + ) + return null; + return { ...record }; +} + +/** + * Pinned Wrangler prints one pretty JSON object per tail frame. Parse object + * boundaries rather than lines; retain no request, exception or raw log text. + */ +export class TailCapture { + constructor({ + startedAt = Date.now(), + source = "wrangler-tail", + limits = LIMITS, + onReady = () => {}, + } = {}) { + if (!time(startedAt)) throw new Error("Invalid capture start."); + this.startedAt = startedAt; + this.source = source; + this.limits = { ...LIMITS, ...limits }; + this.onReady = onReady; + this.readyAt = null; + this.records = []; + this.errors = new Set(); + this.totalBytes = 0; + this.buffer = ""; + this.offset = 0; + this.depth = 0; + this.quoted = false; + this.escaped = false; + this.decoder = new TextDecoder("utf-8", { fatal: true }); + this.finished = false; + } + + fail(reason) { + if (!CAPTURE_FAILURE.has(reason)) reason = "source-error"; + this.errors.add(reason); + this.buffer = ""; + return false; + } + + push(chunk, receivedAt = Date.now()) { + if (this.finished || this.errors.size) return false; + const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + if ( + !(bytes instanceof Uint8Array) || + !time(receivedAt) || + receivedAt < this.startedAt + ) + return this.fail("invalid-framing"); + this.totalBytes += bytes.byteLength; + if (this.totalBytes > this.limits.totalBytes) + return this.fail("input-overflow"); + if ( + Buffer.byteLength(this.buffer) + bytes.byteLength > + this.limits.objectBytes + ) + return this.fail("object-overflow"); + try { + this.buffer += this.decoder.decode(bytes, { stream: true }); + } catch { + return this.fail("invalid-utf8"); + } + while (this.offset < this.buffer.length) { + const character = this.buffer[this.offset++]; + if (this.depth === 0) { + if (/\s/.test(character)) continue; + if (character !== "{") return this.fail("invalid-framing"); + this.buffer = this.buffer.slice(this.offset - 1); + this.offset = 1; + this.depth = 1; + continue; + } + if (this.quoted) { + if (this.escaped) this.escaped = false; + else if (character === "\\") this.escaped = true; + else if (character === '"') this.quoted = false; + continue; + } + if (character === '"') this.quoted = true; + else if (character === "{" || character === "[") { + if (++this.depth > this.limits.depth) + return this.fail("nesting-overflow"); + } else if (character === "}" || character === "]") { + this.depth--; + if (this.depth !== 0) continue; + let frame; + try { + frame = JSON.parse(this.buffer.slice(0, this.offset)); + } catch { + return this.fail("invalid-json"); + } + this.buffer = this.buffer.slice(this.offset); + this.offset = 0; + if (!this.consume(frame, receivedAt)) return false; + } + } + if (this.depth === 0) { + this.buffer = ""; + this.offset = 0; + } + return true; + } + + consume(frame, receivedAt) { + if (!object(frame) || !Array.isArray(frame.logs)) + return this.fail("invalid-tail-frame"); + if (this.readyAt === null) { + this.readyAt = receivedAt; + try { + this.onReady(receivedAt); + } catch { + return this.fail("source-error"); + } + } + for (const log of frame.logs) { + if (!object(log) || !Array.isArray(log.message)) continue; + for (const message of log.message) { + if ( + typeof message !== "string" || + !message.includes("agent-table.private-pilot.") + ) + continue; + let parsed; + try { + parsed = JSON.parse(message); + } catch { + return this.fail("invalid-diagnostic"); + } + const record = validateDiagnostic(parsed); + if (!record || log.message.length !== 1) + return this.fail("invalid-diagnostic"); + if (this.records.length >= this.limits.records) + return this.fail("record-overflow"); + this.records.push(record); + } + } + return true; + } + + finish(reason = "source-ended", endedAt = Date.now()) { + if (this.finished) throw new Error("Capture already finished."); + this.finished = true; + try { + if (this.decoder.decode() !== "") this.fail("truncated-input"); + } catch { + this.fail("invalid-utf8"); + } + if (this.buffer.trim() || this.depth || this.quoted) + this.fail("truncated-input"); + if (this.readyAt === null) this.fail("not-ready"); + if (this.source !== "wrangler-tail") this.fail("source-unverified"); + if (!["requested-stop", "window-complete"].includes(reason)) + this.fail(CAPTURE_FAILURE.has(reason) ? reason : "source-error"); + if (!time(endedAt) || endedAt < this.startedAt) this.fail("source-error"); + return { + schema: CAPTURE_SCHEMA, + source: this.source, + startedAt: this.startedAt, + readyAt: this.readyAt, + endedAt, + finishReason: reason, + completeStream: this.errors.size === 0, + errors: [...this.errors], + records: this.records, + disclaimer: DISCLAIMER, + }; + } +} + +function captureValid(capture) { + return ( + exact(capture, [ + "schema", + "source", + "startedAt", + "readyAt", + "endedAt", + "finishReason", + "completeStream", + "errors", + "records", + "disclaimer", + ]) && + capture.schema === CAPTURE_SCHEMA && + capture.source === "wrangler-tail" && + time(capture.startedAt) && + time(capture.readyAt) && + time(capture.endedAt) && + capture.startedAt <= capture.readyAt && + capture.readyAt <= capture.endedAt && + ["requested-stop", "window-complete"].includes(capture.finishReason) && + capture.completeStream === true && + Array.isArray(capture.errors) && + capture.errors.length === 0 && + capture.disclaimer === DISCLAIMER && + Array.isArray(capture.records) && + capture.records.length <= LIMITS.records && + capture.records.every((record) => validateDiagnostic(record) !== null) + ); +} + +function snapshotValid(snapshot) { + if ( + !object(snapshot) || + snapshot.schema !== SNAPSHOT_SCHEMA || + snapshot.retention !== "retained" || + !matches(SHA, snapshot.buildSha) || + !matches(REF, snapshot.approvalRef) || + !time(snapshot.capturedAt) || + !object(snapshot.table) || + !matches(UUID, snapshot.table.tableId) || + !["waiting", "open", "closed"].includes(snapshot.table.state) || + !time(snapshot.table.createdAt) || + !time(snapshot.table.expiresAt) || + !nullableTime(snapshot.table.closedAt) || + !integer(snapshot.table.lastSeq, 0, 256) || + !Array.isArray(snapshot.messages) || + snapshot.messages.length > 256 || + !Array.isArray(snapshot.outbox) || + snapshot.outbox.length > 24 + ) + return false; + const subscription = snapshot.subscription; + if ( + !(subscription === null && snapshot.outbox.length === 0) && + (!exact(subscription, [ + "membershipId", + "subscriptionId", + "generation", + "status", + "expiresAt", + "recipientConnectionMatches", + ]) || + !matches(UUID, subscription.membershipId) || + !matches(SUBSCRIPTION, subscription.subscriptionId) || + !integer(subscription.generation, 1) || + !["active", "inactive", "expired"].includes(subscription.status) || + subscription.recipientConnectionMatches !== true || + (subscription.status === "active" + ? !time(subscription.expiresAt) + : subscription.expiresAt !== null)) + ) + return false; + if ( + !snapshot.messages.every( + (row) => + object(row) && + matches(UUID, row.messageId) && + integer(row.seq, 1, 256) && + matches(UUID, row.senderMembershipId) && + time(row.createdAt) && + (row.replyToMessageId === null || matches(UUID, row.replyToMessageId)), + ) + ) + return false; + return snapshot.outbox.every( + (row) => + object(row) && + [row.eventId, row.messageId, row.membershipId].every((id) => + matches(UUID, id), + ) && + matches(SUBSCRIPTION, row.subscriptionId) && + integer(row.generation, 1) && + integer(row.seq, 1, 12) && + integer(row.attemptCount, 0, 8) && + ["pending", "in_flight", "accepted", "failed", "cancelled"].includes( + row.status, + ) && + time(row.createdAt) && + time(row.expiresAt) && + row.createdAt < row.expiresAt && + ["firstAttemptAt", "nextAttemptAt", "leaseDeadline"].every((key) => + nullableTime(row[key]), + ) && + (row.lastErrorCategory === null || FAILURE.has(row.lastErrorCategory)), + ); +} + +/** + * SQL counts are only a conservation check. Missing stages, refunded/abandoned + * reservations, stale snapshots or lost tail frames must never be reconstructed. + */ +export function verifyAgainstSnapshot(capture, snapshot) { + const reasons = new Set(); + const fail = (reason) => reasons.add(reason); + const report = () => ({ + schema: "agent-table.private-pilot-verification.v1", + status: reasons.size === 0 ? "conditionally-complete" : "incomplete", + reasons: [...reasons], + checkedEvents: results, + checkedThrough: + object(capture) && time(capture.endedAt) ? capture.endedAt : null, + snapshotCapturedAt: + object(snapshot) && time(snapshot.capturedAt) + ? snapshot.capturedAt + : null, + hostExecution: "not-established", + disclaimer: DISCLAIMER, + }); + const results = []; + if (!captureValid(capture)) fail("capture-incomplete-or-invalid"); + if (!snapshotValid(snapshot)) fail("snapshot-invalid"); + if (reasons.size) return report(); + // Read SQL after tail shutdown: an earlier snapshot cannot account for a + // later charged retry whose tail frame was lost before capture ended. + if (snapshot.capturedAt < capture.endedAt) + fail("snapshot-precedes-capture-end"); + if (snapshot.capturedAt > capture.endedAt + MAX_READBACK_LAG_MS) + fail("snapshot-readback-too-late"); + const rows = new Map(snapshot.outbox.map((row) => [row.eventId, row])); + const messages = new Map( + snapshot.messages.map((row) => [row.messageId, row]), + ); + if ( + rows.size !== snapshot.outbox.length || + messages.size !== snapshot.messages.length || + new Set(snapshot.messages.map((row) => row.seq)).size !== + snapshot.messages.length + ) + fail("duplicate-snapshot-identity"); + const attempts = new Map(); + for (const record of capture.records) { + if ( + record.buildSha !== snapshot.buildSha || + record.approvalRef !== snapshot.approvalRef + ) { + fail("capture-policy-mismatch"); + continue; + } + if ( + record.observedAt < capture.startedAt || + record.observedAt > capture.endedAt + ) + fail("record-outside-capture"); + if (record.tableId !== snapshot.table.tableId) continue; + if (record.observedAt > snapshot.capturedAt) + fail("snapshot-precedes-record"); + if (record.kind === "callback-origin") continue; + const row = rows.get(record.eventId); + if (!row) { + fail("record-without-retained-row"); + continue; + } + if ( + ["messageId", "seq", "membershipId", "subscriptionId"].some( + (key) => record[key] !== row[key], + ) || + record.generation !== row.generation || + record.attempt > row.attemptCount || + record.observedAt < row.createdAt + ) { + fail("attempt-row-mismatch"); + continue; + } + const key = `${record.eventId}:${record.attempt}`; + const stages = attempts.get(key) ?? new Map(); + const duplicate = stages.get(record.kind); + if (duplicate && JSON.stringify(duplicate) !== JSON.stringify(record)) + fail("conflicting-duplicate-stage"); + else stages.set(record.kind, record); + attempts.set(key, stages); + } + for (const row of snapshot.outbox) { + const subscription = snapshot.subscription; + if ( + row.membershipId !== subscription.membershipId || + row.subscriptionId !== subscription.subscriptionId || + row.generation > subscription.generation || + (["pending", "in_flight"].includes(row.status) && + (subscription.status !== "active" || + row.generation !== subscription.generation)) || + (subscription.status !== "active" && + row.generation >= subscription.generation) || + (snapshot.table.state === "closed" && subscription.status === "active") + ) + fail("subscription-row-mismatch"); + if (capture.readyAt > row.createdAt || row.createdAt > capture.endedAt) + fail("event-outside-observed-window"); + const message = messages.get(row.messageId); + if ( + !message || + message.seq !== row.seq || + message.createdAt !== row.createdAt || + message.senderMembershipId === row.membershipId + ) + fail("event-message-mismatch"); + if ( + (row.attemptCount > 0 && row.firstAttemptAt === null) || + (row.attemptCount === 0 && row.firstAttemptAt !== null) + ) + fail("missing-reservation-origin"); + if ( + row.attemptCount === 0 && + ["accepted", "in_flight"].includes(row.status) + ) + fail("outbox-state-inconsistent"); + for (let attempt = 1; attempt <= row.attemptCount; attempt++) { + const stages = attempts.get(`${row.eventId}:${attempt}`); + const authorized = stages?.get("attempt-authorized"); + const invoked = stages?.get("transport-invoked"); + const response = stages?.get("response-headers"); + const completed = stages?.get("attempt-completed"); + if (!authorized || !completed) { + fail("missing-attempt-stage"); + continue; + } + if ( + new Set([...stages.values()].map((record) => record.generation)) + .size !== 1 + ) + fail("attempt-generation-mismatch"); + if ( + Boolean(invoked) !== completed.transportInvoked || + Boolean(response) !== completed.responseObserved + ) + fail("completion-stage-mismatch"); + if ( + (invoked && invoked.observedAt < authorized.observedAt) || + (response && (!invoked || response.observedAt < invoked.observedAt)) || + completed.observedAt < (response ?? invoked ?? authorized).observedAt || + authorized.observedAt < row.firstAttemptAt + ) + fail("attempt-timeline-invalid"); + if ( + completed.outcome === "accepted" && + (!response || response.status < 200 || response.status >= 300) + ) + fail("acceptance-without-2xx"); + if ( + (completed.settled === "accepted" && + completed.outcome !== "accepted") || + (completed.settled === "pending" && + completed.outcome !== "transient") || + (completed.settled === "failed" && completed.outcome === "accepted") + ) + fail("settlement-outcome-mismatch"); + if ( + attempt === row.attemptCount && + completed.settled === "accepted" && + row.status !== "accepted" + ) + fail("outbox-state-inconsistent"); + if ( + attempt === row.attemptCount && + row.status === "accepted" && + (completed.outcome !== "accepted" || completed.settled !== "accepted") + ) + fail("outbox-state-inconsistent"); + if ( + snapshot.table.closedAt !== null && + authorized.observedAt >= snapshot.table.closedAt + ) + fail("authorization-at-or-after-closure"); + } + results.push({ + eventId: row.eventId, + attemptCount: row.attemptCount, + status: row.status, + }); + } + if (!snapshot.outbox.length) fail("no-retained-events"); + return report(); +} + +/** Bound reads; never include parser or filesystem exception text in output. */ +function readJson(path) { + let fd; + try { + fd = openSync( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > LIMITS.fileBytes) throw new Error(); + const bytes = Buffer.alloc(LIMITS.fileBytes + 1); + let used = 0; + while (used < bytes.length) { + const size = readSync(fd, bytes, used, bytes.length - used, null); + if (!size) break; + used += size; + } + if (used > LIMITS.fileBytes) throw new Error(); + return JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used)), + ); + } catch { + throw new Error("Cannot read bounded JSON input."); + } finally { + if (fd !== undefined) closeSync(fd); + } +} +function writeJson(path, value) { + const fd = openSync( + path, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600, + ); + try { + writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf8", + }); + } finally { + closeSync(fd); + } +} + +/** + * Own both pipes: Wrangler automatically reconnects and reports gaps on stderr. + * Raw output never reaches disk or the operator terminal. The installed pinned + * Wrangler supports WRANGLER_WRITE_LOGS=false; console JSON itself is not logged. + */ +export async function collectLive({ + worker, + until, + config, + onReady = () => {}, + spawnTail = spawn, + now = Date.now, + wranglerPath = fileURLToPath( + new URL("../node_modules/wrangler/wrangler-dist/cli.js", import.meta.url), + ), + signalSource = process, +}) { + const startedAt = now(); + if ( + worker !== "agent-table-staging" || + !time(until) || + until <= startedAt || + until - startedAt > LIMITS.durationMs || + (config !== undefined && + (typeof config !== "string" || config.length > 4096)) + ) + throw new Error("Invalid bounded collection options."); + const collector = new TailCapture({ startedAt, onReady }); + const args = [ + "--no-warnings", + wranglerPath, + "tail", + worker, + "--format", + "json", + "--sampling-rate", + "1", + ]; + if (config !== undefined) args.push("--config", resolve(config)); + let child; + try { + child = spawnTail(process.execPath, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + env: { + ...process.env, + WRANGLER_WRITE_LOGS: "false", + WRANGLER_LOG_SANITIZE: "true", + WRANGLER_LOG: "log", + WRANGLER_SEND_METRICS: "false", + }, + }); + } catch { + collector.fail("source-error"); + return collector.finish("source-error", now()); + } + return await new Promise((resolveCapture) => { + let stopReason; + let done = false; + let killTimer; + let finishTimer; + const cleanup = () => { + clearTimeout(windowTimer); + clearTimeout(killTimer); + clearTimeout(finishTimer); + signalSource.removeListener("SIGINT", interrupt); + signalSource.removeListener("SIGTERM", terminate); + }; + const finish = (reason) => { + if (done) return; + done = true; + cleanup(); + resolveCapture(collector.finish(reason, now())); + }; + const stop = (reason) => { + if (stopReason || done) return; + stopReason = reason; + try { + child.kill("SIGINT"); + } catch { + collector.fail("source-error"); + finish("source-error"); + return; + } + if (done) return; + killTimer = setTimeout(() => { + collector.fail("source-kill-timeout"); + try { + child.kill("SIGKILL"); + } catch { + collector.fail("source-error"); + } + if (done) return; + finishTimer = setTimeout(() => finish("source-kill-timeout"), 1_000); + }, 5_000); + }; + const interrupt = () => stop("requested-stop"); + const terminate = () => stop("source-error"); + const windowTimer = setTimeout( + () => stop("window-complete"), + until - startedAt, + ); + signalSource.on("SIGINT", interrupt); + signalSource.on("SIGTERM", terminate); + child.stdout.on("data", (chunk) => { + if (!collector.push(chunk, now())) stop("source-error"); + }); + child.stdout.on("error", () => { + collector.fail("source-error"); + stop("source-error"); + }); + child.stderr.on("data", () => { + // Any warning might indicate a lost/reconnected tail. Do not copy its text. + collector.fail("source-diagnostic"); + stop("source-error"); + }); + child.stderr.on("error", () => { + collector.fail("source-error"); + stop("source-error"); + }); + child.on("error", () => { + collector.fail("source-error"); + stop("source-error"); + }); + child.on("close", (code, signal) => { + if (code !== 0 && !(stopReason && signal === "SIGINT")) + collector.fail("source-exit"); + finish(stopReason ?? "source-ended"); + }); + }); +} + +function parseArguments(argv) { + const mode = argv[0]; + if (!["collect", "verify"].includes(mode)) + throw new Error("Unknown command."); + const allowed = + mode === "collect" + ? ["--worker", "--until", "--config", "--output"] + : ["--capture", "--snapshot", "--output"]; + const result = { mode }; + for (let index = 1; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + if ( + !allowed.includes(flag) || + value === undefined || + value.length === 0 || + value.startsWith("--") || + Object.hasOwn(result, flag.slice(2)) + ) + throw new Error("Invalid arguments."); + result[flag.slice(2)] = value; + } + const required = + mode === "collect" + ? ["worker", "until", "output"] + : ["capture", "snapshot", "output"]; + if (!required.every((key) => Object.hasOwn(result, key))) + throw new Error("Missing arguments."); + return result; +} + +export async function main(argv = process.argv.slice(2)) { + try { + const options = parseArguments(argv); + if (options.mode === "collect") { + const until = Date.parse(options.until); + if ( + !Number.isFinite(until) || + new Date(until).toISOString() !== options.until + ) + throw new Error("Use a canonical UTC end time with milliseconds."); + const capture = await collectLive({ + worker: options.worker, + until, + config: options.config, + onReady: (at) => + console.log(JSON.stringify({ capture: "ready", observedAt: at })), + }); + writeJson(options.output, capture); + console.log( + JSON.stringify({ + capture: capture.completeStream ? "collected" : "incomplete", + records: capture.records.length, + errors: capture.errors, + }), + ); + return capture.completeStream ? 0 : 2; + } + const report = verifyAgainstSnapshot( + readJson(options.capture), + readJson(options.snapshot), + ); + writeJson(options.output, report); + console.log( + JSON.stringify({ + verification: report.status, + reasons: report.reasons, + checkedEvents: report.checkedEvents.length, + hostExecution: "not-established", + }), + ); + return report.status === "conditionally-complete" ? 0 : 2; + } catch { + // Raw parsing, subprocess and filesystem messages may expose private data. + console.error( + "Private pilot observation command failed; no raw input was printed.", + ); + return 1; + } +} +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + process.exitCode = await main(); diff --git a/scripts/private-pilot-observations.test.mjs b/scripts/private-pilot-observations.test.mjs new file mode 100644 index 0000000..2be4362 --- /dev/null +++ b/scripts/private-pilot-observations.test.mjs @@ -0,0 +1,857 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { test } from "node:test"; +import { + collectLive, + main, + RECORD_SCHEMA, + SNAPSHOT_SCHEMA, + TailCapture, + validateDiagnostic, + verifyAgainstSnapshot, +} from "./private-pilot-observations.mjs"; + +const START = Date.parse("2026-11-13T00:00:00.000Z"); +const BUILD = "a".repeat(40); +const APPROVAL = "owner-review-test-1234"; +const TABLE = "11111111-1111-4111-8111-111111111111"; +const EVENT = "22222222-2222-4222-8222-222222222222"; +const MESSAGE = "33333333-3333-4333-8333-333333333333"; +const A_MEMBER = "44444444-4444-4444-8444-444444444444"; +const B_MEMBER = "55555555-5555-4555-8555-555555555555"; +const SUBSCRIPTION = `sub_${"a".repeat(24)}`; +const SECRET_MARKER = "SECRET_DO_NOT_KEEP"; + +function attempt(kind, observedAt, extra = {}) { + return { + schema: RECORD_SCHEMA, + kind, + buildSha: BUILD, + approvalRef: APPROVAL, + tableId: TABLE, + observedAt, + eventId: EVENT, + messageId: MESSAGE, + seq: 1, + membershipId: A_MEMBER, + subscriptionId: SUBSCRIPTION, + generation: 1, + attempt: 1, + ...extra, + }; +} +function records() { + return [ + attempt("attempt-authorized", START + 2_100), + attempt("transport-invoked", START + 2_101), + attempt("response-headers", START + 2_200, { status: 204 }), + attempt("attempt-completed", START + 2_201, { + outcome: "accepted", + settled: "accepted", + transportInvoked: true, + responseObserved: true, + }), + ]; +} +function frame(items = [], extra = {}) { + return { + outcome: "ok", + logs: items.map((record) => ({ + level: "info", + message: [JSON.stringify(record)], + timestamp: record.observedAt, + })), + event: { + request: { + url: `https://private.example.com/?token=${SECRET_MARKER}`, + headers: { authorization: `Bearer ${SECRET_MARKER}` }, + }, + }, + exceptions: [{ message: SECRET_MARKER, stack: SECRET_MARKER }], + ...extra, + }; +} +function capture(items = records()) { + const collector = new TailCapture({ startedAt: START }); + collector.push(JSON.stringify(frame()), START + 500); + collector.push(JSON.stringify(frame(items)), START + 3_000); + return collector.finish("requested-stop", START + 5_000); +} +function snapshot() { + return { + schema: SNAPSHOT_SCHEMA, + buildSha: BUILD, + approvalRef: APPROVAL, + capturedAt: START + 6_000, + retention: "retained", + table: { + tableId: TABLE, + state: "open", + createdAt: START - 10_000, + expiresAt: START + 60_000, + closedAt: null, + lastSeq: 1, + }, + subscription: { + membershipId: A_MEMBER, + subscriptionId: SUBSCRIPTION, + generation: 1, + status: "active", + expiresAt: START + 60_000, + recipientConnectionMatches: true, + }, + messages: [ + { + messageId: MESSAGE, + seq: 1, + senderMembershipId: B_MEMBER, + createdAt: START + 2_000, + replyToMessageId: null, + }, + ], + outbox: [ + { + eventId: EVENT, + messageId: MESSAGE, + seq: 1, + membershipId: A_MEMBER, + subscriptionId: SUBSCRIPTION, + generation: 1, + status: "accepted", + attemptCount: 1, + firstAttemptAt: START + 2_050, + nextAttemptAt: null, + leaseDeadline: null, + createdAt: START + 2_000, + expiresAt: START + 60_000, + lastErrorCategory: null, + }, + ], + }; +} +function incomplete(value, expectedReason) { + assert.equal(value.status, "incomplete"); + assert.ok( + value.reasons.includes(expectedReason), + JSON.stringify(value.reasons), + ); + assert.equal(value.hostExecution, "not-established"); +} + +test("pretty Wrangler frames and NDJSON survive arbitrary chunk boundaries without raw metadata", () => { + const data = + JSON.stringify(frame(), null, 4) + + "\n" + + JSON.stringify(frame(records()), null, 4) + + "\n"; + for (const width of [1, 3, 17, 257]) { + const collector = new TailCapture({ startedAt: START }); + for (let offset = 0; offset < data.length; offset += width) + assert.equal( + collector.push( + Buffer.from(data.slice(offset, offset + width)), + START + 3_000, + ), + true, + ); + const result = collector.finish("requested-stop", START + 5_000); + assert.equal(result.completeStream, true); + assert.deepEqual(result.records, records()); + assert.doesNotMatch( + JSON.stringify(result), + /SECRET_DO_NOT_KEEP|authorization|exceptions|private\.example/, + ); + } +}); + +test("UTF-8 inside unrelated metadata is safely discarded across split bytes", () => { + const data = Buffer.from( + JSON.stringify(frame(records(), { ignored: '日本語 } ] \\"' })), + ); + const collector = new TailCapture({ startedAt: START }); + for (const byte of data) collector.push(Buffer.from([byte]), START + 3_000); + assert.deepEqual( + collector.finish("requested-stop", START + 5_000).records, + records(), + ); +}); + +test("a quoted callback origin is retained, while paths, queries, local and port targets are refused", () => { + const origin = { + schema: RECORD_SCHEMA, + kind: "callback-origin", + buildSha: BUILD, + approvalRef: APPROVAL, + tableId: TABLE, + membershipId: A_MEMBER, + observedAt: START + 2_100, + callbackOrigin: "https://callback.example.com", + }; + assert.deepEqual(validateDiagnostic(origin), origin); + for (const callbackOrigin of [ + "https://callback.example.com/path", + "https://callback.example.com?secret=x", + "https://name:secret@callback.example.com", + "http://callback.example.com", + "https://127.0.0.1", + "https://host.local", + "https://callback.example.com:444", + "https://callback.example.com/", + "https://callback.example.com#secret", + ]) + assert.equal(validateDiagnostic({ ...origin, callbackOrigin }), null); +}); + +test("extra fields and invalid private log records fail closed without echoing content", () => { + for (const bad of [ + { ...records()[0], authorization: SECRET_MARKER }, + { ...records()[0], seq: 13 }, + { ...records()[0], attempt: 9 }, + { ...records()[0], subscriptionId: SECRET_MARKER }, + { ...records()[0], kind: "new-unreviewed-kind" }, + { ...records()[3], responseObserved: true, transportInvoked: false }, + ]) { + const collector = new TailCapture({ startedAt: START }); + assert.equal( + collector.push(JSON.stringify(frame([bad])), START + 3_000), + false, + ); + const result = collector.finish("requested-stop", START + 5_000); + assert.ok(result.errors.includes("invalid-diagnostic")); + assert.doesNotMatch(JSON.stringify(result), /SECRET_DO_NOT_KEEP/); + } +}); + +test("a multi-value log cannot disguise unreviewed fields alongside an allowed record", () => { + const collector = new TailCapture({ startedAt: START }); + collector.push( + JSON.stringify({ + logs: [{ message: [JSON.stringify(records()[0]), SECRET_MARKER] }], + }), + START + 3_000, + ); + const result = collector.finish("requested-stop", START + 5_000); + assert.ok(result.errors.includes("invalid-diagnostic")); + assert.equal(result.records.length, 0); +}); + +test("framing, UTF-8, truncated objects, byte and record ceilings are categorical failures", () => { + const cases = [ + ["invalid-framing", "PRIVATE_TOKEN_BANNER", {}], + ["invalid-json", '{"logs":,}', {}], + ["invalid-utf8", Buffer.from([0xff]), {}], + ["object-overflow", `{${" ".repeat(101)}`, { objectBytes: 100 }], + ["input-overflow", JSON.stringify(frame()), { totalBytes: 10 }], + ["nesting-overflow", '{"logs":[[[[]]]]}', { depth: 3 }], + ["record-overflow", JSON.stringify(frame(records())), { records: 1 }], + ["invalid-tail-frame", '{"error":"PRIVATE_TOKEN_ERROR"}', {}], + ]; + for (const [reason, input, limits] of cases) { + const collector = new TailCapture({ startedAt: START, limits }); + collector.push(input, START + 3_000); + const result = collector.finish("requested-stop", START + 5_000); + assert.ok(result.errors.includes(reason), JSON.stringify(result.errors)); + assert.doesNotMatch(JSON.stringify(result), /PRIVATE_TOKEN/); + } + const collector = new TailCapture({ startedAt: START }); + collector.push('{"logs":[', START + 3_000); + assert.ok( + collector + .finish("requested-stop", START + 5_000) + .errors.includes("truncated-input"), + ); +}); + +test("unexpected EOF, absent readiness, and unverified stdin are never complete captures", () => { + const eof = new TailCapture({ startedAt: START }); + eof.push(JSON.stringify(frame()), START + 500); + assert.ok( + eof.finish("source-ended", START + 3_000).errors.includes("source-ended"), + ); + assert.ok( + new TailCapture({ startedAt: START }) + .finish("requested-stop", START + 3_000) + .errors.includes("not-ready"), + ); + const stdin = new TailCapture({ startedAt: START, source: "stdin" }); + stdin.push(JSON.stringify(frame()), START + 500); + assert.ok( + stdin + .finish("requested-stop", START + 3_000) + .errors.includes("source-unverified"), + ); +}); + +test("complete conserved records are conditional evidence and establish no host execution", () => { + const report = verifyAgainstSnapshot(capture(), snapshot()); + assert.equal(report.status, "conditionally-complete"); + assert.equal(report.hostExecution, "not-established"); + assert.match(report.disclaimer, /sampled/); + assert.equal(report.checkedEvents[0].attemptCount, 1); +}); + +test("a missing final charge or log stage makes the observation incomplete", () => { + const state = snapshot(); + state.outbox[0].attemptCount = 2; + incomplete(verifyAgainstSnapshot(capture(), state), "missing-attempt-stage"); + for (const kind of ["attempt-authorized", "attempt-completed"]) { + incomplete( + verifyAgainstSnapshot( + capture(records().filter((row) => row.kind !== kind)), + snapshot(), + ), + "missing-attempt-stage", + ); + } + for (const kind of ["transport-invoked", "response-headers"]) { + incomplete( + verifyAgainstSnapshot( + capture(records().filter((row) => row.kind !== kind)), + snapshot(), + ), + "completion-stage-mismatch", + ); + } +}); + +test("explicit no-transport completion can be observed without inventing a dispatch time", () => { + const data = [ + records()[0], + attempt("attempt-completed", START + 2_101, { + outcome: "permanent", + errorCategory: "payload-rejected", + settled: "failed", + transportInvoked: false, + responseObserved: false, + }), + ]; + const state = snapshot(); + state.outbox[0].status = "failed"; + state.outbox[0].lastErrorCategory = "payload-rejected"; + assert.equal( + verifyAgainstSnapshot(capture(data), state).status, + "conditionally-complete", + ); + incomplete( + verifyAgainstSnapshot(capture([...data, records()[1]]), state), + "completion-stage-mismatch", + ); +}); + +test("2xx headers followed by transport/body failure remain distinct from final completion", () => { + const data = records(); + data[3] = { + ...data[3], + outcome: "transient", + errorCategory: "deadline", + settled: "pending", + }; + const state = snapshot(); + state.outbox[0].status = "pending"; + state.outbox[0].lastErrorCategory = "deadline"; + state.outbox[0].nextAttemptAt = START + 10_000; + assert.equal( + verifyAgainstSnapshot(capture(data), state).status, + "conditionally-complete", + ); +}); + +test("cancellation keeps already invoked completion but refuses a new authorization at closure", () => { + const data = records(); + data[3].settled = null; + const state = snapshot(); + state.table.state = "closed"; + state.table.closedAt = START + 2_150; + state.subscription.status = "inactive"; + state.subscription.generation = 2; + state.subscription.expiresAt = null; + state.outbox[0].status = "cancelled"; + assert.equal( + verifyAgainstSnapshot(capture(data), state).status, + "conditionally-complete", + ); + state.table.closedAt = data[0].observedAt; + incomplete( + verifyAgainstSnapshot(capture(data), state), + "authorization-at-or-after-closure", + ); +}); + +test("zero attempts require a retained pending event and a tail ready before its commit", () => { + const state = snapshot(); + state.outbox[0].attemptCount = 0; + state.outbox[0].firstAttemptAt = null; + state.outbox[0].status = "cancelled"; + assert.equal( + verifyAgainstSnapshot(capture([]), state).status, + "conditionally-complete", + ); + const late = capture([]); + late.readyAt = START + 2_001; + incomplete( + verifyAgainstSnapshot(late, state), + "event-outside-observed-window", + ); + state.outbox = []; + incomplete(verifyAgainstSnapshot(capture([]), state), "no-retained-events"); +}); + +test("deleted rows, stale snapshots, wrong policy and changed event/message joins fail closed", () => { + const cases = [ + [ + "record-without-retained-row", + (state) => { + state.outbox = []; + }, + ], + [ + "snapshot-precedes-capture-end", + (state) => { + state.capturedAt = START + 4_000; + }, + ], + [ + "snapshot-precedes-record", + (state) => { + state.capturedAt = START + 2_100; + }, + ], + [ + "capture-policy-mismatch", + (state) => { + state.approvalRef = "different-review-1234"; + }, + ], + [ + "attempt-row-mismatch", + (state) => { + state.outbox[0].messageId = B_MEMBER; + }, + ], + [ + "event-message-mismatch", + (state) => { + state.messages[0].senderMembershipId = A_MEMBER; + }, + ], + [ + "duplicate-snapshot-identity", + (state) => { + state.outbox.push({ ...state.outbox[0] }); + }, + ], + ]; + for (const [reason, mutate] of cases) { + const state = snapshot(); + mutate(state); + incomplete(verifyAgainstSnapshot(capture(), state), reason); + } +}); + +test("complete markers cannot conceal invalid capture state", () => { + for (const mutate of [ + (value) => { + value.errors = ["source-diagnostic"]; + }, + (value) => { + value.finishReason = "source-ended"; + }, + (value) => { + value.source = "stdin"; + }, + (value) => { + value.records[0].rawSecret = SECRET_MARKER; + }, + (value) => { + value.readyAt = null; + }, + ]) { + const value = capture(); + mutate(value); + const report = verifyAgainstSnapshot(value, snapshot()); + incomplete(report, "capture-incomplete-or-invalid"); + assert.doesNotMatch(JSON.stringify(report), /SECRET_DO_NOT_KEEP/); + } +}); + +test("duplicates must be identical and each attempt retains its own generation", () => { + assert.equal( + verifyAgainstSnapshot(capture([...records(), records()[0]]), snapshot()) + .status, + "conditionally-complete", + ); + incomplete( + verifyAgainstSnapshot( + capture([...records(), { ...records()[0], observedAt: START + 2_099 }]), + snapshot(), + ), + "conflicting-duplicate-stage", + ); + const changed = records(); + changed[2].generation = 2; + const state = snapshot(); + state.outbox[0].generation = 2; + incomplete( + verifyAgainstSnapshot(capture(changed), state), + "attempt-row-mismatch", + ); +}); + +test("subscription identity and recipient readback are mandatory for retained attempt evidence", () => { + for (const mutate of [ + (state) => { + state.subscription.recipientConnectionMatches = false; + }, + (state) => { + delete state.subscription.recipientConnectionMatches; + }, + (state) => { + state.subscription = null; + }, + (state) => { + delete state.subscription; + }, + (state) => { + state.subscription.callbackUrl = SECRET_MARKER; + }, + ]) { + const state = snapshot(); + mutate(state); + const result = verifyAgainstSnapshot(capture(), state); + incomplete(result, "snapshot-invalid"); + assert.doesNotMatch(JSON.stringify(result), /SECRET_DO_NOT_KEEP/); + } + for (const mutate of [ + (state) => { + state.subscription.membershipId = B_MEMBER; + }, + (state) => { + state.subscription.subscriptionId = `sub_${"b".repeat(24)}`; + }, + ]) { + const state = snapshot(); + mutate(state); + incomplete( + verifyAgainstSnapshot(capture(), state), + "subscription-row-mismatch", + ); + } +}); + +test("terminal invalidation preserves older accepted/cancelled outbox identities without reviving pending work", () => { + for (const status of ["inactive", "expired"]) { + const state = snapshot(); + state.subscription.status = status; + state.subscription.expiresAt = null; + state.subscription.generation = 2; + assert.equal( + verifyAgainstSnapshot(capture(), state).status, + "conditionally-complete", + ); + const pending = records(); + pending[3].outcome = "transient"; + pending[3].errorCategory = "deadline"; + pending[3].settled = "pending"; + state.outbox[0].status = "pending"; + incomplete( + verifyAgainstSnapshot(capture(pending), state), + "subscription-row-mismatch", + ); + } + const refreshed = snapshot(); + refreshed.subscription.generation = 2; + assert.equal( + verifyAgainstSnapshot(capture(), refreshed).status, + "conditionally-complete", + ); +}); + +test("accepted SQL requires the final charged attempt's accepted and settled completion", () => { + for (const replacement of [ + { outcome: "transient", errorCategory: "deadline", settled: "pending" }, + { outcome: "accepted", settled: null }, + ]) { + const data = records(); + Object.assign(data[3], replacement); + incomplete( + verifyAgainstSnapshot(capture(data), snapshot()), + "outbox-state-inconsistent", + ); + } +}); + +test("final SQL readback follows the entire capture and catches a lost later retry", () => { + const data = records(); + Object.assign(data[3], { + outcome: "transient", + errorCategory: "deadline", + settled: "pending", + }); + const earlier = snapshot(); + earlier.outbox[0].status = "pending"; + earlier.outbox[0].lastErrorCategory = "deadline"; + earlier.outbox[0].nextAttemptAt = START + 4_500; + earlier.capturedAt = START + 4_000; + incomplete( + verifyAgainstSnapshot(capture(data), earlier), + "snapshot-precedes-capture-end", + ); + const final = structuredClone(earlier); + final.capturedAt = START + 6_000; + final.outbox[0].attemptCount = 2; + final.outbox[0].status = "in_flight"; + final.outbox[0].nextAttemptAt = null; + final.outbox[0].leaseDeadline = START + 34_500; + incomplete( + verifyAgainstSnapshot(capture(data), final), + "missing-attempt-stage", + ); + const baseline = verifyAgainstSnapshot(capture(), snapshot()); + assert.equal(baseline.status, "conditionally-complete"); + assert.equal(baseline.checkedThrough, START + 5_000); + assert.equal(baseline.snapshotCapturedAt, START + 6_000); + const late = snapshot(); + late.capturedAt = START + 65_001; + incomplete( + verifyAgainstSnapshot(capture(), late), + "snapshot-readback-too-late", + ); + late.capturedAt = START + 65_000; + assert.equal( + verifyAgainstSnapshot(capture(), late).status, + "conditionally-complete", + ); +}); + +test("completion and newly created events outside the finished capture remain unobserved", () => { + const data = records(); + data[3].observedAt = START + 5_001; + incomplete( + verifyAgainstSnapshot(capture(data), snapshot()), + "record-outside-capture", + ); + const state = snapshot(); + state.outbox[0].createdAt = START + 5_001; + state.messages[0].createdAt = START + 5_001; + state.outbox[0].attemptCount = 0; + state.outbox[0].firstAttemptAt = null; + state.outbox[0].status = "pending"; + incomplete( + verifyAgainstSnapshot(capture([]), state), + "event-outside-observed-window", + ); +}); + +test("retention, clocks and unavailable rows cannot manufacture a complete window", () => { + for (const mutate of [ + (state) => { + state.retention = "unavailable"; + }, + (state) => { + state.table = null; + }, + (state) => { + state.outbox[0].lastErrorCategory = "unknown"; + }, + ]) { + const state = snapshot(); + mutate(state); + incomplete(verifyAgainstSnapshot(capture(), state), "snapshot-invalid"); + } + for (const observedAt of [START - 1, START + 5_001]) { + const data = records(); + data[0].observedAt = observedAt; + incomplete( + verifyAgainstSnapshot(capture(data), snapshot()), + "record-outside-capture", + ); + } +}); + +test("other tables in the same approved capture do not alter this table's checked event list", () => { + const other = records().map((record) => ({ + ...record, + tableId: B_MEMBER, + eventId: A_MEMBER, + })); + const report = verifyAgainstSnapshot( + capture([...records(), ...other]), + snapshot(), + ); + assert.equal(report.status, "conditionally-complete"); + assert.deepEqual( + report.checkedEvents.map((row) => row.eventId), + [EVENT], + ); +}); + +test("live collection refuses production, arbitrary workers, and unbounded windows before spawn", async () => { + for (const options of [ + { worker: "agent-table-production", until: Date.now() + 10_000 }, + { worker: "another-worker", until: Date.now() + 10_000 }, + { worker: "agent-table-staging", until: Date.now() + 3 * 60 * 60_000 }, + ]) { + let spawned = false; + await assert.rejects( + collectLive({ + ...options, + spawnTail: () => { + spawned = true; + }, + }), + /Invalid bounded collection options/, + ); + assert.equal(spawned, false); + } +}); + +function fakeTail(run) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = (signal) => { + queueMicrotask(() => + child.emit("close", signal === "SIGINT" ? 0 : null, signal), + ); + return true; + }; + let options; + const spawnTail = (command, args, settings) => { + options = { command, args, settings }; + queueMicrotask(() => run(child)); + return child; + }; + return { spawnTail, settings: () => options }; +} + +test("live wrapper controls both pipes, disables Wrangler disk logs, and records explicit stop", async () => { + const signalSource = new EventEmitter(); + const fixture = fakeTail((child) => { + child.stdout.write(JSON.stringify(frame())); + signalSource.emit("SIGINT"); + }); + const result = await collectLive({ + worker: "agent-table-staging", + until: Date.now() + 10_000, + spawnTail: fixture.spawnTail, + signalSource, + }); + assert.equal(result.completeStream, true); + const { command, args, settings } = fixture.settings(); + assert.equal(command, process.execPath); + assert.equal(settings.shell, false); + assert.equal(settings.env.WRANGLER_WRITE_LOGS, "false"); + assert.equal(settings.env.WRANGLER_LOG_SANITIZE, "true"); + assert.equal(args.includes("--sampling-rate"), true); + assert.equal(result.finishReason, "requested-stop"); + assert.equal(signalSource.listenerCount("SIGINT"), 0); +}); + +test("automatic reconnect warnings and unexpected source exit cannot silently produce complete capture", async () => { + for (const sourceAction of [ + (child) => child.stderr.write(`Tail reconnecting with ${SECRET_MARKER}`), + (child) => child.emit("close", 0, null), + (child) => child.emit("error", new Error(SECRET_MARKER)), + ]) { + const fixture = fakeTail((child) => { + child.stdout.write(JSON.stringify(frame())); + sourceAction(child); + }); + const result = await collectLive({ + worker: "agent-table-staging", + until: Date.now() + 10_000, + spawnTail: fixture.spawnTail, + signalSource: new EventEmitter(), + }); + assert.equal(result.completeStream, false); + assert.doesNotMatch(JSON.stringify(result), /SECRET_DO_NOT_KEEP/); + } +}); + +test("CLI verification writes exclusive private output and refuses unsafe/unbounded input", async () => { + const directory = mkdtempSync(join(tmpdir(), "pilot-observation-test-")); + const originalLog = console.log; + const originalError = console.error; + const printed = []; + console.log = (...args) => printed.push(args.join(" ")); + console.error = (...args) => printed.push(args.join(" ")); + try { + const input = join(directory, "capture.json"); + const state = join(directory, "snapshot.json"); + const output = join(directory, "report.json"); + writeFileSync(input, JSON.stringify(capture()), { mode: 0o600 }); + writeFileSync(state, JSON.stringify(snapshot()), { mode: 0o600 }); + assert.equal( + await main([ + "verify", + "--capture", + input, + "--snapshot", + state, + "--output", + output, + ]), + 0, + ); + assert.equal(statSync(output).mode & 0o777, 0o600); + assert.equal( + JSON.parse(readFileSync(output, "utf8")).status, + "conditionally-complete", + ); + assert.equal( + await main([ + "verify", + "--capture", + input, + "--snapshot", + state, + "--output", + output, + ]), + 1, + ); + const link = join(directory, "symlink.json"); + symlinkSync(input, link); + assert.equal( + await main([ + "verify", + "--capture", + link, + "--snapshot", + state, + "--output", + join(directory, "refused.json"), + ]), + 1, + ); + writeFileSync(input, `{"secret":"${SECRET_MARKER}`); + assert.equal( + await main([ + "verify", + "--capture", + input, + "--snapshot", + state, + "--output", + join(directory, "bad.json"), + ]), + 1, + ); + assert.doesNotMatch(printed.join("\n"), /SECRET_DO_NOT_KEEP/); + } finally { + console.log = originalLog; + console.error = originalError; + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/release-authorization.mjs b/scripts/release-authorization.mjs index 722620e..b24cb49 100644 --- a/scripts/release-authorization.mjs +++ b/scripts/release-authorization.mjs @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { + activePrivatePilotDiscoveryPolicy, activePrivatePilotPolicy, PRIVATE_PILOT_PROFILE, privatePilotConfiguration, @@ -223,7 +224,10 @@ export function verifyPrivatePilotDecision(record, vars, now) { const policy = privatePilotConfiguration(vars); const deliveryEnabled = vars.ALLOW_OUTBOUND_NOTIFICATIONS === "true"; requireDecision( - policy && (!deliveryEnabled || activePrivatePilotPolicy(vars, now)), + policy && + (policy.mode === "a-only-discovery" + ? activePrivatePilotDiscoveryPolicy(vars, now) + : !deliveryEnabled || activePrivatePilotPolicy(vars, now)), "private-pilot-not-active", ); const policySha256 = createHash("sha256") @@ -231,18 +235,26 @@ export function verifyPrivatePilotDecision(record, vars, now) { .digest("hex"); const decision = record?.privatePilot; requireDecision( - decision?.mode === "a-only" && + decision?.mode === policy.mode && decision.policySha256 === policySha256 && decision.approvalRef === policy.approvalRef && decision.independentOwnerConsentsReviewed === true && - decision.callbackControlReviewed === true && - decision.nativeFetchRiskAccepted === true && + (policy.mode === "a-only-discovery" + ? decision.callbackDiscoveryReviewed === true && + decision.noOutboundReviewed === true + : decision.callbackControlReviewed === true && + decision.nativeFetchRiskAccepted === true) && + (policy.observationMode === undefined || + decision.privateObservationReviewed === true) && decision.boundedCanaryReviewed === true && decision.stopPlanReviewed === true, "private-pilot-scope-not-approved", ); return { - mode: "a-only", + mode: policy.mode, + ...(policy.observationMode === undefined + ? {} + : { observationMode: policy.observationMode }), policySha256, notBefore: policy.notBefore, expiresAt: policy.expiresAt, diff --git a/scripts/release-authorization.test.mjs b/scripts/release-authorization.test.mjs index 2149734..e6ab605 100644 --- a/scripts/release-authorization.test.mjs +++ b/scripts/release-authorization.test.mjs @@ -170,6 +170,95 @@ test("an approved private kill-switch configuration can be deployed after its se assert.equal(approval.privatePilot.deliveryEnabled, false); }); +test("discovery is separately reviewed, finite and unable to authorize outbound delivery", async () => { + const discovery = () => makePrivatePilotFixture({ mode: "a-only-discovery" }); + const { fixture, cloudflare, github } = harness(discovery()); + const approval = await authorizeRelease({ + ...fixture, + cloudflareRead: cloudflare.read, + githubRead: github.read, + now, + }); + assert.deepEqual(approval.privatePilot, { + mode: "a-only-discovery", + observationMode: "private-redacted", + policySha256: fixture.operations.privatePilot.policySha256, + notBefore: new Date(now - 30_000).toISOString(), + expiresAt: new Date(now + 60 * 60_000).toISOString(), + deliveryEnabled: false, + }); + for (const field of [ + "callbackDiscoveryReviewed", + "noOutboundReviewed", + "privateObservationReviewed", + "independentOwnerConsentsReviewed", + "boundedCanaryReviewed", + "stopPlanReviewed", + ]) { + const value = harness(discovery()); + value.fixture.operations.privatePilot[field] = false; + await assert.rejects( + authorizeRelease({ + ...value.fixture, + cloudflareRead: value.cloudflare.read, + githubRead: value.github.read, + now, + }), + /private-pilot-scope-not-approved/, + ); + } + fixture.operations.privatePilot.mode = "a-only"; + await assert.rejects( + authorizeRelease({ + ...fixture, + cloudflareRead: cloudflare.read, + githubRead: github.read, + now, + }), + /private-pilot-scope-not-approved/, + ); + for (const timestamp of [now - 31_000, now + 60 * 60_000]) { + const value = harness(discovery()); + value.fixture.operations.review.reviewedAt = new Date( + timestamp, + ).toISOString(); + for (const disclosure of Object.values( + value.fixture.operations.retention, + )) { + disclosure.reviewedAt = new Date(timestamp).toISOString(); + } + await assert.rejects( + authorizeRelease({ + ...value.fixture, + cloudflareRead: value.cloudflare.read, + githubRead: value.github.read, + now: timestamp, + }), + /private-pilot-not-active/, + ); + } +}); + +test("delivery observations require their own review while legacy unobserved policies remain valid", async () => { + const candidate = makePrivatePilotFixture({ + observationMode: "private-redacted", + }); + const { fixture, cloudflare, github } = harness(candidate); + const options = { + ...fixture, + cloudflareRead: cloudflare.read, + githubRead: github.read, + now, + }; + const approved = await authorizeRelease(options); + assert.equal(approved.privatePilot.observationMode, "private-redacted"); + delete fixture.operations.privatePilot.privateObservationReviewed; + await assert.rejects( + authorizeRelease(options), + /private-pilot-scope-not-approved/, + ); +}); + function legacyStagingHarness() { const fixture = makeFixture(); const config = JSON.parse(fixture.configText); diff --git a/scripts/release-postverify.mjs b/scripts/release-postverify.mjs index 4bd8238..76e423f 100644 --- a/scripts/release-postverify.mjs +++ b/scripts/release-postverify.mjs @@ -59,7 +59,9 @@ export async function verifyPublishedRelease({ ); if (privatePolicy) { requirePost( - approval.privatePilot?.mode === "a-only" && + approval.privatePilot?.mode === privatePolicy.mode && + approval.privatePilot.observationMode === + privatePolicy.observationMode && approval.privatePilot.policySha256 === createHash("sha256") .update(config.vars.PRIVATE_PILOT_POLICY) diff --git a/scripts/release-postverify.test.mjs b/scripts/release-postverify.test.mjs index 01f5cb4..41810ca 100644 --- a/scripts/release-postverify.test.mjs +++ b/scripts/release-postverify.test.mjs @@ -36,7 +36,10 @@ async function harness(candidate = config, peerConfig = peer) { ...(policy ? { privatePilot: { - mode: "a-only", + mode: policy.mode, + ...(policy.observationMode === undefined + ? {} + : { observationMode: policy.observationMode }), policySha256: hash(candidate.vars.PRIVATE_PILOT_POLICY), notBefore: policy.notBefore, expiresAt: policy.expiresAt, @@ -159,6 +162,52 @@ test("private staging postverify binds exact policy and exposes only safe releas } }); +test("discovery postverify binds its observation-only mode and checks both provider surfaces", async () => { + const candidate = JSON.parse( + makePrivatePilotFixture({ mode: "a-only-discovery" }).configText, + ); + const { options } = await harness(candidate); + assert.equal((await verifyPublishedRelease(options)).verified, true); + for (const patch of [ + { mode: "a-only" }, + { observationMode: undefined }, + { observationMode: "raw" }, + { deliveryEnabled: true }, + ]) { + const changed = await harness(candidate); + Object.assign(changed.options.approval.privatePilot, patch); + let reads = 0; + changed.options.cloudflareRead = async () => { + reads++; + }; + await assert.rejects( + verifyPublishedRelease(changed.options), + /post-deploy-private-pilot-approval-mismatch/, + ); + assert.equal(reads, 0); + } + for (const surface of ["settings", "version"]) { + const changed = await harness(candidate); + const path = [...changed.provider.routes.keys()].find((key) => + key.endsWith( + surface === "settings" + ? "/agent-table-staging/settings" + : "/agent-table-staging/versions/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + ), + ); + const record = changed.provider.routes.get(path); + const bindings = + surface === "settings" ? record.bindings : record.resources.bindings; + bindings.find( + (binding) => binding.name === "ALLOW_OUTBOUND_NOTIFICATIONS", + ).text = "true"; + await assert.rejects( + verifyPublishedRelease(changed.options), + /provider-version-config-unverified/, + ); + } +}); + test("postverify refuses mismatched approval, profile or exact configuration bytes before any request", async () => { const changes = [ (options) => { @@ -271,6 +320,7 @@ test("exact deployment readback refuses an orphan policy after a requested manua test("postverify refuses changed private activation bounds despite matching config approval", async () => { for (const [field, value] of [ ["mode", "bidirectional"], + ["observationMode", "private-redacted"], ["policySha256", "b".repeat(64)], ["notBefore", "2026-11-10T00:00:00.000Z"], ["expiresAt", "2026-11-12T00:00:00.000Z"], diff --git a/scripts/release-preflight.test.mjs b/scripts/release-preflight.test.mjs index caf8463..f2b0c21 100644 --- a/scripts/release-preflight.test.mjs +++ b/scripts/release-preflight.test.mjs @@ -87,6 +87,56 @@ test("private policy cannot be smuggled into manual or production release profil /invalid-private-pilot-configuration/, ); }); + +test("discovery preflight accepts only explicit redacted observation with zero outbound", () => { + const discovery = () => makePrivatePilotFixture({ mode: "a-only-discovery" }); + const candidate = discovery(); + assert.equal( + validateRelease({ ...candidate, secrets: candidate.inventory }) + .preflightValid, + true, + ); + for (const mutate of [ + (config) => { + config.vars.ALLOW_OUTBOUND_NOTIFICATIONS = "true"; + }, + (config) => { + const policy = JSON.parse(config.vars.PRIVATE_PILOT_POLICY); + delete policy.observationMode; + config.vars.PRIVATE_PILOT_POLICY = JSON.stringify(policy); + }, + (config) => { + const policy = JSON.parse(config.vars.PRIVATE_PILOT_POLICY); + policy.callbackTargets = [ + { origin: "https://receiver.example.com", pathPrefix: "/" }, + ]; + config.vars.PRIVATE_PILOT_POLICY = JSON.stringify(policy); + }, + ]) { + const value = discovery(); + const config = JSON.parse(value.configText); + mutate(config); + value.configText = JSON.stringify(config); + assert.throws( + () => validateRelease({ ...value, secrets: value.inventory }), + /invalid-private-pilot-configuration/, + ); + } + const production = discovery(); + const stage = JSON.parse(production.configText); + const target = JSON.parse(production.peerText); + target.vars.RELEASE_PROFILE = production.profile; + target.vars.PRIVATE_PILOT_POLICY = stage.vars.PRIVATE_PILOT_POLICY; + production.environment = "production"; + production.configText = JSON.stringify(target); + production.peerText = JSON.stringify(stage); + production.inventory.environment = "production"; + production.inventory.workerName = "agent-table-production"; + assert.throws( + () => validateRelease({ ...production, secrets: production.inventory }), + /invalid-private-pilot-configuration/, + ); +}); const makeConfig = (environment, overrides = {}) => ({ name: `agent-table-${environment}`, account_id: "f".repeat(32), diff --git a/scripts/require-release-approval.mjs b/scripts/require-release-approval.mjs index 3b0511c..c9bc30e 100644 --- a/scripts/require-release-approval.mjs +++ b/scripts/require-release-approval.mjs @@ -25,17 +25,26 @@ try { (profile === "manual" || (profile === PRIVATE_PILOT_PROFILE && environment === "staging" && - manifest.privatePilot?.mode === "a-only" && + ["a-only", "a-only-discovery"].includes( + manifest.privatePilot?.mode, + ) && + (manifest.privatePilot.observationMode === undefined || + manifest.privatePilot.observationMode === "private-redacted") && /^[a-f0-9]{64}$/.test(manifest.privatePilot.policySha256) && Date.parse(manifest.privatePilot.notBefore) < Date.parse(manifest.privatePilot.expiresAt) && Date.parse(manifest.privatePilot.expiresAt) - Date.parse(manifest.privatePilot.notBefore) <= PRIVATE_PILOT_MAX_WINDOW_MS && - (manifest.privatePilot.deliveryEnabled === false || - (manifest.privatePilot.deliveryEnabled === true && + (manifest.privatePilot.mode === "a-only-discovery" + ? manifest.privatePilot.observationMode === "private-redacted" && + manifest.privatePilot.deliveryEnabled === false && Date.parse(manifest.privatePilot.notBefore) <= Date.now() && - Date.parse(manifest.privatePilot.expiresAt) > Date.now())))) && + Date.parse(manifest.privatePilot.expiresAt) > Date.now() + : manifest.privatePilot.deliveryEnabled === false || + (manifest.privatePilot.deliveryEnabled === true && + Date.parse(manifest.privatePilot.notBefore) <= Date.now() && + Date.parse(manifest.privatePilot.expiresAt) > Date.now())))) && /^[1-9]\d*$/.test(manifest.workflowRunId) && /^[1-9]\d*$/.test(manifest.workflowRunAttempt) && /^[1-9]\d*$/.test(manifest.ciRunId) && diff --git a/scripts/require-release-approval.test.mjs b/scripts/require-release-approval.test.mjs index 3c36156..fc1fefb 100644 --- a/scripts/require-release-approval.test.mjs +++ b/scripts/require-release-approval.test.mjs @@ -148,6 +148,7 @@ test("private gate binds the explicit staging profile and rechecks its finite se { expiresAt: new Date(current - 30_000).toISOString() }, { expiresAt: new Date(current + 3 * 60 * 60_000).toISOString() }, { deliveryEnabled: "false" }, + { observationMode: "raw" }, ]) { assert.equal( check({ @@ -168,6 +169,31 @@ test("private gate binds the explicit staging profile and rechecks its finite se }), 0, ); + const discovery = { + ...manifest, + privatePilot: { + ...manifest.privatePilot, + mode: "a-only-discovery", + observationMode: "private-redacted", + deliveryEnabled: false, + }, + }; + assert.equal(check(discovery), 0); + for (const patch of [ + { deliveryEnabled: true }, + { observationMode: undefined }, + { observationMode: "raw" }, + { notBefore: new Date(current + 30_000).toISOString() }, + { expiresAt: new Date(current - 30_000).toISOString() }, + ]) { + assert.equal( + check({ + ...discovery, + privatePilot: { ...discovery.privatePilot, ...patch }, + }), + 1, + ); + } }); test("current offline validator never manufactures a deployment approval", async () => { diff --git a/src/evidence/private-pilot.ts b/src/evidence/private-pilot.ts new file mode 100644 index 0000000..375f184 --- /dev/null +++ b/src/evidence/private-pilot.ts @@ -0,0 +1,148 @@ +import type { + CallbackTransportObserver, + CallbackTransportOutcome, +} from "../notifications/egress"; +import { sanitizedCallbackOrigin } from "../notifications/egress"; +import { classifyAttemptOutcome } from "../notifications/outbox"; +import { + activePrivatePilotDiscoveryPolicy, + activePrivatePilotPolicy, + PRIVATE_PILOT_MAX_MESSAGE_SEQ, + privatePilotRecipientMatches, +} from "../shared/private-pilot"; + +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SUBSCRIPTION = /^sub_[A-Za-z0-9_-]{16,64}$/; +const SCHEMA = "agent-table.private-pilot.v1"; + +/** This opt-in stream is separate from routine categorical telemetry. */ +function emit(record: Record): void { + try { + console.info(JSON.stringify(record)); + } catch { + // A failed/lost observation must be treated as incomplete by the collector. + } +} + +export function observePrivateCallbackOrigin( + env: Env, + actor: { guestId: string; connectionId: string }, + tableId: string, + membershipId: string, + origin: string, +): boolean { + const now = Date.now(); + const policy = activePrivatePilotDiscoveryPolicy(env, now); + if ( + !policy || + !privatePilotRecipientMatches(policy, actor, tableId) || + !UUID.test(membershipId) || + sanitizedCallbackOrigin(origin) !== origin + ) + return false; + emit({ + schema: SCHEMA, + kind: "callback-origin", + buildSha: policy.buildSha, + approvalRef: policy.approvalRef, + tableId, + membershipId, + observedAt: now, + callbackOrigin: origin, + }); + return true; +} + +export interface PilotAttemptIdentity { + tableId: string; + eventId: string; + messageId: string; + seq: number; + membershipId: string; + subscriptionId: string; + generation: number; + attempt: number; + createdAt: number; + guestId: string; + connectionId: string; +} + +export interface PilotAttemptObserver extends CallbackTransportObserver { + completed( + outcome: CallbackTransportOutcome, + settled: "accepted" | "pending" | "failed" | null, + at: number, + ): void; +} + +/** Called only after the final grant, local lifecycle and lease checks. */ +export function observePrivateAttempt( + env: Env, + identity: PilotAttemptIdentity, + authorizedAt: number, +): PilotAttemptObserver | undefined { + const policy = activePrivatePilotPolicy(env, authorizedAt); + if ( + policy?.observationMode !== "private-redacted" || + !privatePilotRecipientMatches(policy, identity, identity.tableId) || + ![identity.eventId, identity.messageId, identity.membershipId].every((id) => + UUID.test(id), + ) || + !SUBSCRIPTION.test(identity.subscriptionId) || + !Number.isSafeInteger(identity.seq) || + identity.seq < 1 || + identity.seq > PRIVATE_PILOT_MAX_MESSAGE_SEQ || + !Number.isSafeInteger(identity.generation) || + identity.generation < 1 || + !Number.isSafeInteger(identity.attempt) || + identity.attempt < 1 || + identity.attempt > 8 || + identity.createdAt < Date.parse(policy.notBefore) + ) + return undefined; + // Construct the fixed projection explicitly. Never spread an RPC/SQL input. + const common = { + schema: SCHEMA, + buildSha: policy.buildSha, + approvalRef: policy.approvalRef, + tableId: identity.tableId, + eventId: identity.eventId, + messageId: identity.messageId, + seq: identity.seq, + membershipId: identity.membershipId, + subscriptionId: identity.subscriptionId, + generation: identity.generation, + attempt: identity.attempt, + }; + let invoked = false; + let responded = false; + emit({ ...common, kind: "attempt-authorized", observedAt: authorizedAt }); + return { + transportInvoked(at) { + invoked = true; + emit({ ...common, kind: "transport-invoked", observedAt: at }); + }, + responseHeaders(at, status) { + responded = true; + emit({ ...common, kind: "response-headers", observedAt: at, status }); + }, + completed(outcome, settled, at) { + const classified = classifyAttemptOutcome(outcome); + // Keep completion after expiry/cancellation. This context was captured + // at authorization, and must never recreate or reauthorize an SQL row. + emit({ + ...common, + kind: "attempt-completed", + observedAt: at, + outcome: classified.kind, + ...(classified.kind === "accepted" + ? {} + : { errorCategory: classified.category }), + settled, + transportInvoked: invoked, + responseObserved: responded, + }); + }, + }; +} diff --git a/src/evidence/staging.ts b/src/evidence/staging.ts index 7432a5f..547cc5f 100644 --- a/src/evidence/staging.ts +++ b/src/evidence/staging.ts @@ -1,4 +1,9 @@ /** A temporary, staging-only, read-only Durable Objects SQLite diagnostic. */ +import { + privatePilotConfiguration, + privatePilotRecipientMatches, +} from "../shared/private-pilot"; + const STAGING_ORIGIN = "https://agent-table-test.outliner-online.com"; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -7,6 +12,7 @@ type InspectionRequest = { guestId: string; tableId?: string; connectionId?: string; + view?: "private-pilot"; }; export function stagingEvidenceEnabled(env: Env): boolean { @@ -49,7 +55,7 @@ function inspectionRequest(value: unknown): InspectionRequest | null { const input = value as Record; if ( Object.keys(input).some( - (key) => !["guestId", "tableId", "connectionId"].includes(key), + (key) => !["guestId", "tableId", "connectionId", "view"].includes(key), ) || typeof input.guestId !== "string" || !UUID_V4.test(input.guestId) || @@ -57,7 +63,11 @@ function inspectionRequest(value: unknown): InspectionRequest | null { (typeof input.tableId !== "string" || !UUID_V4.test(input.tableId))) || (input.connectionId !== undefined && (typeof input.connectionId !== "string" || - !UUID_V4.test(input.connectionId))) + !UUID_V4.test(input.connectionId))) || + (Object.hasOwn(input, "view") && + (input.view !== "private-pilot" || + input.tableId === undefined || + input.connectionId === undefined)) ) { return null; } @@ -76,7 +86,8 @@ function jsonResponse(body: unknown, status = 200): Response { /** * Disabled by default and locked to the exact staging origin. Response contains - * no raw identifiers, messages, guest identity, table title, or bearer credentials. + * The default view has no raw identifiers or content. An explicitly approved + * private-pilot view adds only scoped random IDs and delivery metadata. */ export async function handleStagingEvidence( request: Request, @@ -115,6 +126,25 @@ export async function handleStagingEvidence( const guestId = parameters.guestId.toLowerCase(); const tableId = parameters.tableId?.toLowerCase(); const connectionId = parameters.connectionId?.toLowerCase(); + if (parameters.view === "private-pilot") { + const policy = privatePilotConfiguration(env); + if ( + policy?.observationMode !== "private-redacted" || + !tableId || + !connectionId || + !privatePilotRecipientMatches(policy, { guestId, connectionId }, tableId) + ) + return jsonResponse({ error: "not_found" }, 404); + try { + return jsonResponse( + await env.TABLES.get( + env.TABLES.idFromName(tableId), + ).readPrivatePilotEvidence(tableId, guestId, connectionId), + ); + } catch { + return jsonResponse({ error: "unavailable" }, 503); + } + } const guest = env.GUESTS.get(env.GUESTS.idFromName(guestId)); const [guestSqlite, tableSqlite] = await Promise.all([ guest.readStagingEvidence(guestId, tableId, connectionId), diff --git a/src/mcp/connection-identity.ts b/src/mcp/connection-identity.ts new file mode 100644 index 0000000..83f8a82 --- /dev/null +++ b/src/mcp/connection-identity.ts @@ -0,0 +1,106 @@ +import { + authorizeGuestConnection, + CONNECTION_SCOPES, + type ConnectionScope, + isConnectionScope, +} from "../shared/auth"; +import { isRestoreQuarantined } from "../shared/release-quarantine"; +import type { AuthenticatedMcpIdentity } from "./server"; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export interface OwnConnectionIdentity { + guestId: string; + connectionId: string; + scopes: ConnectionScope[]; + /** Current request authority ends at the earlier token or grant deadline. */ + expiresAt: string; + grantExpiresAt: string; + tokenExpiresAt: string; +} + +function futureDeadline(value: number | null, now: number): value is number { + return ( + value !== null && + Number.isSafeInteger(value) && + value > now && + Number.isFinite(new Date(value).getTime()) + ); +} + +/** + * Inspect only the OAuth-authenticated caller, never an identity supplied in + * tool arguments. Scope observations authorize neither future calls nor table + * membership; every operation still performs its own fresh authority check. + */ +export async function getOwnConnectionIdentity( + env: Env, + identity: AuthenticatedMcpIdentity, +): Promise { + if ( + isRestoreQuarantined(env) || + !UUID_V4.test(identity.guestId) || + !UUID_V4.test(identity.connectionId) || + !futureDeadline(identity.tokenExpiresAtMs, Date.now()) || + !Array.isArray(identity.scopes) || + identity.scopes.length === 0 || + identity.scopes.length > CONNECTION_SCOPES.length || + !identity.scopes.every(isConnectionScope) || + new Set(identity.scopes).size !== identity.scopes.length + ) { + return null; + } + + const delegated = CONNECTION_SCOPES.filter((scope) => + identity.scopes.includes(scope), + ); + const decisions = await Promise.all( + delegated.map((scope) => + authorizeGuestConnection( + env, + identity.guestId, + identity.connectionId, + scope, + ), + ), + ); + const now = Date.now(); + if (!futureDeadline(identity.tokenExpiresAtMs, now)) return null; + + const scopes: ConnectionScope[] = []; + let grantDeadline: number | undefined; + for (const [index, decision] of decisions.entries()) { + if (!decision.allowed) { + // Only scope narrowing may yield a smaller result. Missing, revoked, + // expired, inconsistent or unavailable authority discloses no identity. + if (decision.reason === "insufficient-scope") continue; + return null; + } + const { actor } = decision; + if ( + actor.kind !== "verified-connection" || + actor.guestId !== identity.guestId || + actor.connectionId !== identity.connectionId || + actor.scope !== delegated[index] || + !futureDeadline(actor.expiresAt, now) || + (grantDeadline !== undefined && grantDeadline !== actor.expiresAt) + ) { + return null; + } + grantDeadline = actor.expiresAt; + scopes.push(actor.scope); + } + if (grantDeadline === undefined || scopes.length === 0) return null; + + return { + guestId: identity.guestId, + connectionId: identity.connectionId, + scopes, + expiresAt: new Date( + Math.min(grantDeadline, identity.tokenExpiresAtMs), + ).toISOString(), + grantExpiresAt: new Date(grantDeadline).toISOString(), + tokenExpiresAt: new Date(identity.tokenExpiresAtMs).toISOString(), + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f172ac5..a75b125 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -30,8 +30,9 @@ import { validTableId } from "../shared/invitations"; import { CREATION_LIMITS, TABLE_LIMITS } from "../shared/limits"; import { PRIVATE_PILOT_PROFILE, - privatePilotRecipientAllowed, + privatePilotDiscoveryAllowed, } from "../shared/private-pilot"; +import { getOwnConnectionIdentity } from "./connection-identity"; import { handleOwnedInboxListen, isListenRequest, @@ -173,7 +174,7 @@ function newServer( capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: liveSubscriptions }, - ...(privateEvents && privatePilotRecipientAllowed(env, identity) + ...(privateEvents && privatePilotDiscoveryAllowed(env, identity) ? { events: {} } : {}), }, @@ -202,7 +203,7 @@ function newServer( if ( !actor || (env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE && - !privatePilotRecipientAllowed(env, actor)) + !privatePilotDiscoveryAllowed(env, actor)) ) throw eventError("denied"); return { events: [MESSAGE_EVENT_DEFINITION] }; @@ -231,6 +232,27 @@ function newServer( }, ); + server.registerTool( + "get_connection", + { + description: + "Inspect only your authenticated guest and connection IDs, currently authorized scopes, and token/grant expiry. This grants no table membership or future authority.", + inputSchema: z.strictObject({}), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const connection = await getOwnConnectionIdentity(env, identity); + return connection + ? toolResult(connection) + : toolResult({ error: { code: "NOT_AUTHORIZED" } }, true); + }, + ); + server.registerTool( "create_table", { @@ -498,7 +520,7 @@ export async function handleMcp( ) ).allowed; const privateEvents = - privatePilotRecipientAllowed(env, identity) && + privatePilotDiscoveryAllowed(env, identity) && identity.scopes.includes("events:subscribe") && identity.tokenExpiresAtMs !== null && identity.tokenExpiresAtMs > Date.now() && diff --git a/src/notifications/egress.ts b/src/notifications/egress.ts index 1d81163..e865e92 100644 --- a/src/notifications/egress.ts +++ b/src/notifications/egress.ts @@ -208,6 +208,21 @@ export function validateReviewedCallbackDestination( return { ok: true, url }; } +/** Syntax inspection only. Returning an origin never permits outbound traffic. */ +export function sanitizedCallbackOrigin(raw: unknown): string | null { + if (typeof raw !== "string") return null; + try { + const origin = new URL(raw).origin; + return validateReviewedCallbackDestination(raw, [ + { origin, pathPrefix: "/" }, + ]).ok + ? origin + : null; + } catch { + return null; + } +} + const ALLOWED_HEADER_NAMES = new Set([ "content-type", "webhook-id", @@ -395,6 +410,21 @@ export interface SendVerifiedHttpsOptions { * Diagnostic / probe worker use only; defaults to false in production. */ diagnostics?: boolean; + /** Synchronous private observations; no URL, header or body is supplied. */ + observer?: CallbackTransportObserver; +} + +export interface CallbackTransportObserver { + transportInvoked(at: number): void; + responseHeaders(at: number, status: number): void; +} + +function observe(callback: (() => void) | undefined): void { + try { + callback?.(); + } catch { + // Missing observations make evidence incomplete, never change transport. + } } /** @@ -432,6 +462,9 @@ export async function sendVerifiedHttps( }, PLANNED_CALLBACK_TIMEOUT_MS); }); try { + // This point is after every synchronous preflight and immediately before + // native fetch invocation. It does not claim that bytes reached the peer. + observe(() => options?.observer?.transportInvoked(Date.now())); const response = await Promise.race([ fetch(destination.url.toString(), { method: "POST", @@ -442,6 +475,10 @@ export async function sendVerifiedHttps( }), deadline, ]); + // Headers can be 2xx even if the later bounded body read fails. + observe(() => + options?.observer?.responseHeaders(Date.now(), response.status), + ); if ( response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400) diff --git a/src/objects/table.ts b/src/objects/table.ts index fe21546..b52551a 100644 --- a/src/objects/table.ts +++ b/src/objects/table.ts @@ -1,4 +1,8 @@ import { DurableObject } from "cloudflare:workers"; +import { + observePrivateAttempt, + observePrivateCallbackOrigin, +} from "../evidence/private-pilot"; import { requestFingerprint, stagingEvidenceEnabled, @@ -11,9 +15,11 @@ import { } from "../mcp/subscriptions"; import { type CallbackRequest, + type CallbackTransportObserver, type CallbackTransportOutcome, callbackTransportReadiness, reviewedCallbackTargets, + sanitizedCallbackOrigin, sendVerifiedHttps, validateReviewedCallbackDestination, } from "../notifications/egress"; @@ -66,6 +72,7 @@ import type { } from "../shared/lifecycle"; import { TABLE_LIMITS } from "../shared/limits"; import { + activePrivatePilotDiscoveryPolicy, activePrivatePilotPolicy, PRIVATE_PILOT_MAX_MESSAGE_SEQ, PRIVATE_PILOT_PROFILE, @@ -339,6 +346,133 @@ export class TableDO extends DurableObject { }; } + /** + * Operator-only projection reached through the existing staging secret gate. + * Policy identity is independent of a revoked guest grant, so cancellation + * can still be inspected. No credential, URL, lease token or text is returned. + */ + readPrivatePilotEvidence( + tableId: string, + guestId: string, + connectionId: string, + ) { + const policy = privatePilotConfiguration(this.env); + if ( + !stagingEvidenceEnabled(this.env) || + isRestoreQuarantined(this.env) || + !policy || + policy.observationMode !== "private-redacted" || + !privatePilotRecipientMatches( + policy, + { guestId, connectionId }, + tableId, + ) || + !this.ctx.id.equals(this.env.TABLES.idFromName(tableId)) + ) + throw new Error("Private pilot evidence is disabled"); + const table = this.metadataRow(); + const member = this.memberFor(guestId); + const common = { + schema: "agent-table.private-pilot-snapshot.v1", + buildSha: policy.buildSha, + approvalRef: policy.approvalRef, + capturedAt: Date.now(), + }; + if (!table || !member || table.table_id !== tableId) + return { + ...common, + table: null, + subscription: null, + messages: [], + outbox: [], + retention: "unavailable", + }; + const subscription = this.eventSubscription(member.membership_id); + const messages = this.ctx.storage.sql + .exec<{ + messageId: string; + seq: number; + senderMembershipId: string; + createdAt: number; + replyToMessageId: string | null; + }>( + `SELECT message_id AS messageId, seq, + sender_membership_id AS senderMembershipId, + created_at AS createdAt, reply_to_message_id AS replyToMessageId + FROM table_messages WHERE seq <= ? ORDER BY seq LIMIT ?`, + PRIVATE_PILOT_MAX_MESSAGE_SEQ, + PRIVATE_PILOT_MAX_MESSAGE_SEQ, + ) + .toArray(); + const outbox = this.ctx.storage.sql + .exec<{ + eventId: string; + messageId: string; + seq: number; + membershipId: string; + subscriptionId: string; + generation: number; + status: string; + attemptCount: number; + firstAttemptAt: number | null; + nextAttemptAt: number | null; + leaseDeadline: number | null; + createdAt: number; + expiresAt: number; + lastErrorCategory: string | null; + }>( + `SELECT event_id AS eventId, message_id AS messageId, + message_seq AS seq, recipient_membership_id AS membershipId, + subscription_id AS subscriptionId, + subscription_generation AS generation, status, + attempt_count AS attemptCount, first_attempt_at AS firstAttemptAt, + next_attempt_at AS nextAttemptAt, lease_deadline AS leaseDeadline, + created_at AS createdAt, expires_at AS expiresAt, + CASE + WHEN last_error_category IN ( + 'transport-error', 'deadline', 'rate-limited', 'server-error', + 'receiver-gone', 'payload-rejected', 'authorization-revoked', + 'retry-exhausted' + ) THEN last_error_category + WHEN last_error_category IS NULL THEN NULL + ELSE 'unknown' + END AS lastErrorCategory + FROM table_event_outbox + WHERE recipient_membership_id = ? AND message_seq <= ? + ORDER BY message_seq LIMIT ?`, + member.membership_id, + PRIVATE_PILOT_MAX_MESSAGE_SEQ, + PRIVATE_PILOT_MAX_MESSAGE_SEQ, + ) + .toArray(); + return { + ...common, + table: { + tableId, + state: table.state, + createdAt: table.created_at, + expiresAt: table.expires_at, + closedAt: table.closed_at, + lastSeq: table.last_seq, + }, + subscription: subscription + ? { + membershipId: member.membership_id, + subscriptionId: subscription.subscription_id, + generation: subscription.generation, + status: subscription.status, + expiresAt: subscription.expires_at, + recipientConnectionMatches: + subscription.guest_id === guestId && + subscription.connection_id === connectionId, + } + : null, + messages, + outbox, + retention: "retained", + }; + } + private metadataRow(): MetadataRow | undefined { return this.ctx.storage.sql .exec( @@ -841,6 +975,75 @@ export class TableDO extends DurableObject { return policy ? Date.parse(policy.expiresAt) : 0; } + /** + * Inspect a genuine host registration without persisting its destination or + * secret, creating a candidate/subscription, or invoking any network sender. + */ + async observeEventCallback( + actor: VerifiedConnectionActor, + input: unknown, + ): Promise>> { + const parsed = parseEventSubscriptionParams(input, "subscribe"); + if (!parsed) return { ok: false, code: "INVALID_REQUEST" }; + const policy = activePrivatePilotDiscoveryPolicy(this.env); + if ( + !policy || + actor?.kind !== "verified-connection" || + actor.scope !== "events:subscribe" || + !privatePilotRecipientMatches(policy, actor, parsed.arguments.table_id) + ) + return { ok: false, code: "TABLE_NOT_FOUND" }; + const origin = sanitizedCallbackOrigin(parsed.delivery.url); + if (!origin) return { ok: false, code: "INVALID_REQUEST" }; + try { + // Reuse the existing durable rate authority, with a dedicated namespace. + // One observation/minute across all policy tables bounds a two-hour + // discovery window to at most 120 accepted requests, across restarts. + const key = `pilot-origin-v1:${policy.buildSha}:${policy.approvalRef}:${policy.recipientGuestId}:${policy.recipientConnectionId}`; + const admission = await this.env.ADMISSION.get( + this.env.ADMISSION.idFromName(key), + ).consume("application", 1); + if (!admission.allowed) + return { ok: false, code: "TEMPORARILY_UNAVAILABLE" }; + const fresh = await this.freshEventActor(actor); + const currentPolicy = activePrivatePilotDiscoveryPolicy(this.env); + const table = this.metadataRow(); + const member = fresh ? this.memberFor(fresh.guestId) : undefined; + const now = Date.now(); + if ( + !fresh || + !currentPolicy || + !privatePilotRecipientMatches( + currentPolicy, + fresh, + parsed.arguments.table_id, + ) || + !table || + !member || + table.table_id !== parsed.arguments.table_id || + !this.ctx.id.equals(this.env.TABLES.idFromName(table.table_id)) + ) + return { ok: false, code: "TABLE_NOT_FOUND" }; + if ( + fresh.expiresAt <= now || + table.expires_at <= now || + (table.state !== "waiting" && table.state !== "open") + ) + return { ok: false, code: "TABLE_TERMINAL" }; + return observePrivateCallbackOrigin( + this.env, + fresh, + table.table_id, + member.membership_id, + origin, + ) + ? { ok: true, value: {} } + : { ok: false, code: "TABLE_NOT_FOUND" }; + } catch { + return { ok: false, code: "TEMPORARILY_UNAVAILABLE" }; + } + } + /** * Reserve a fenced, encrypted candidate without changing an existing active * subscription. Network verification is deliberately outside this method. @@ -2755,6 +2958,7 @@ export class TableDO extends DurableObject { private sendOutboxWebhook( destination: string, request: CallbackRequest, + observer?: CallbackTransportObserver, ): Promise { const pilot = activePrivatePilotPolicy(this.env); if (this.env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE && !pilot) @@ -2767,6 +2971,7 @@ export class TableDO extends DurableObject { pilot?.callbackTargets ?? reviewedCallbackTargets(this.env.EVENTS_REVIEWED_CALLBACK_TARGETS), request, + { observer }, ); } @@ -2957,6 +3162,23 @@ export class TableDO extends DurableObject { continue; } // No await between local authority checks, signing, and transport start. + const observation = observePrivateAttempt( + this.env, + { + tableId: work.table_id, + eventId: work.event_id, + messageId: work.message_id, + seq: work.message_seq, + membershipId: work.membership_id, + subscriptionId: work.subscription_id as string, + generation: work.generation, + attempt: lease.attemptCount, + createdAt: work.created_at, + guestId: work.guest_id as string, + connectionId: work.connection_id as string, + }, + now, + ); let outcome: CallbackTransportOutcome; try { const signed = signWebhook( @@ -2975,10 +3197,11 @@ export class TableDO extends DurableObject { ); recordFutureMetric({ name: "outbox.attempt", value: 1 }); try { - outcome = await this.sendOutboxWebhook(work.callback_url as string, { - method: "POST", - ...signed, - }); + outcome = await this.sendOutboxWebhook( + work.callback_url as string, + { method: "POST", ...signed }, + observation, + ); } catch { // Ambiguous transport failures keep their persisted attempt charge. outcome = { ok: false, reason: "transport-error" }; @@ -2987,6 +3210,7 @@ export class TableDO extends DurableObject { // Invalid provider serialization/signing is a permanent payload failure. outcome = { ok: false, reason: "invalid-headers" }; } + const completedAt = Date.now(); const settled = this.ctx.storage.transactionSync(() => settleOutboxAttempt( this.ctx.storage.sql, @@ -2995,6 +3219,7 @@ export class TableDO extends DurableObject { classifyAttemptOutcome(outcome), ), ); + observation?.completed(outcome, settled, completedAt); if (settled === "pending") recordFutureMetric({ name: "outbox.retry", value: 1 }); if (settled === "failed") diff --git a/src/services/events.ts b/src/services/events.ts index fc41d5c..44ffc9a 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -8,6 +8,7 @@ import { } from "../notifications/openai-events"; import type { VerifiedConnectionActor } from "../shared/auth"; import { + activePrivatePilotDiscoveryPolicy, activePrivatePilotPolicy, PRIVATE_PILOT_PROFILE, privatePilotConfiguration, @@ -22,7 +23,8 @@ export type EventServiceFailure = | "unavailable" | "callback_destination_rejected" | "callback_challenge_failed" - | "callback_transport_failed"; + | "callback_transport_failed" + | "callback_operator_review_required"; export type EventServiceResult = | { ok: true; value: T } @@ -61,6 +63,25 @@ export async function subscribeEvent( > { const parsed = parseEventSubscriptionParams(input, "subscribe"); if (!parsed) return { ok: false, reason: "invalid" }; + const discovery = activePrivatePilotDiscoveryPolicy(env); + if (discovery) { + if ( + !privatePilotRecipientMatches(discovery, actor, parsed.arguments.table_id) + ) + return { ok: false, reason: "denied" }; + try { + const result = await env.TABLES.get( + env.TABLES.idFromName(parsed.arguments.table_id), + ).observeEventCallback(actor, input); + // Observation never acknowledges an active subscription or sends a + // challenge. A separate reviewed delivery configuration is required. + return result.ok + ? { ok: false, reason: "callback_operator_review_required" } + : failed(result.code); + } catch { + return { ok: false, reason: "unavailable" }; + } + } const privateProfile = env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE; const pilot = activePrivatePilotPolicy(env); if ( diff --git a/src/shared/private-pilot.ts b/src/shared/private-pilot.ts index e1cb7ba..1da54fd 100644 --- a/src/shared/private-pilot.ts +++ b/src/shared/private-pilot.ts @@ -14,7 +14,8 @@ const REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/; /** Nonsecret policy delivered only through the protected staging release. */ export interface PrivatePilotPolicy { schemaVersion: 1; - mode: "a-only"; + mode: "a-only" | "a-only-discovery"; + observationMode?: "private-redacted"; buildSha: string; approvedAt: string; notBefore: string; @@ -78,10 +79,14 @@ export function parsePrivatePilotPolicy( "callbackTargets", ]; if ( - Object.keys(row).length !== keys.length || + Object.keys(row).some( + (key) => !keys.includes(key) && key !== "observationMode", + ) || !keys.every((key) => Object.hasOwn(row, key)) || row.schemaVersion !== 1 || - row.mode !== "a-only" || + (row.mode !== "a-only" && row.mode !== "a-only-discovery") || + (Object.hasOwn(row, "observationMode") && + row.observationMode !== "private-redacted") || typeof row.buildSha !== "string" || !SHA.test(row.buildSha) || typeof row.approvalRef !== "string" || @@ -108,13 +113,24 @@ export function parsePrivatePilotPolicy( ) ) return null; - const callbackTargets = reviewedCallbackTargets( - JSON.stringify(row.callbackTargets), - ); - if (!callbackTargets.length) return null; + const discovery = row.mode === "a-only-discovery"; + const callbackTargets = discovery + ? [] + : reviewedCallbackTargets(JSON.stringify(row.callbackTargets)); + if ( + discovery + ? row.observationMode !== "private-redacted" || + !Array.isArray(row.callbackTargets) || + row.callbackTargets.length !== 0 + : !callbackTargets.length + ) + return null; return { schemaVersion: 1, - mode: "a-only", + mode: row.mode, + ...(row.observationMode === "private-redacted" + ? { observationMode: "private-redacted" as const } + : {}), buildSha: row.buildSha, approvedAt: row.approvedAt as string, notBefore: row.notBefore as string, @@ -141,7 +157,12 @@ export function privatePilotConfiguration( ) return null; const policy = parsePrivatePilotPolicy(env.PRIVATE_PILOT_POLICY); - return policy && policy.buildSha === env.RELEASE_BUILD_SHA ? policy : null; + return policy && + policy.buildSha === env.RELEASE_BUILD_SHA && + (policy.mode !== "a-only-discovery" || + env.ALLOW_OUTBOUND_NOTIFICATIONS === "false") + ? policy + : null; } export function activePrivatePilotPolicy( @@ -150,6 +171,7 @@ export function activePrivatePilotPolicy( ): PrivatePilotPolicy | null { const policy = privatePilotConfiguration(env); return policy && + policy.mode === "a-only" && env.ALLOW_OUTBOUND_NOTIFICATIONS === "true" && Number.isFinite(now) && Date.parse(policy.notBefore) <= now && @@ -158,6 +180,34 @@ export function activePrivatePilotPolicy( : null; } +/** Discovery accepts a real host request but never authorizes a callback. */ +export function activePrivatePilotDiscoveryPolicy( + env: PrivatePilotBindings, + now = Date.now(), +): PrivatePilotPolicy | null { + const policy = privatePilotConfiguration(env); + return policy && + policy.mode === "a-only-discovery" && + Number.isFinite(now) && + Date.parse(policy.notBefore) <= now && + now < Date.parse(policy.expiresAt) + ? policy + : null; +} + +/** Advertisement is separate from the delivery authorization predicate. */ +export function privatePilotDiscoveryAllowed( + env: PrivatePilotBindings, + actor: { guestId: string; connectionId: string }, + tableId?: string, + now = Date.now(), +): boolean { + const policy = + activePrivatePilotPolicy(env, now) ?? + activePrivatePilotDiscoveryPolicy(env, now); + return !!policy && privatePilotRecipientMatches(policy, actor, tableId); +} + export function privatePilotRecipientMatches( policy: PrivatePilotPolicy, actor: { guestId: string; connectionId: string }, diff --git a/test/e2e/mcp.spec.ts b/test/e2e/mcp.spec.ts index 785be9b..215e08a 100644 --- a/test/e2e/mcp.spec.ts +++ b/test/e2e/mcp.spec.ts @@ -170,6 +170,7 @@ test("official SDK negotiates against the authenticated live Wrangler HTTP serve "create_invite", "create_table", "delete_table", + "get_connection", "get_table", "join_table", "post_message", diff --git a/test/helpers/release-fixtures.mjs b/test/helpers/release-fixtures.mjs index 3fff9ce..4ebe3ce 100644 --- a/test/helpers/release-fixtures.mjs +++ b/test/helpers/release-fixtures.mjs @@ -137,12 +137,18 @@ export function makeFixture(environment = "staging") { }; } -export function makePrivatePilotFixture() { +export function makePrivatePilotFixture({ + mode = "a-only", + observationMode = mode === "a-only-discovery" + ? "private-redacted" + : undefined, +} = {}) { const fixture = makeFixture("staging"); const config = JSON.parse(fixture.configText); const policy = { schemaVersion: 1, - mode: "a-only", + mode, + ...(observationMode === undefined ? {} : { observationMode }), buildSha, approvedAt: new Date(now - 60_000).toISOString(), notBefore: new Date(now - 30_000).toISOString(), @@ -151,13 +157,20 @@ export function makePrivatePilotFixture() { recipientGuestId: "11111111-1111-4111-8111-111111111111", recipientConnectionId: "22222222-2222-4222-8222-222222222222", tableIds: ["33333333-3333-4333-8333-333333333333"], - callbackTargets: [ - { origin: "https://receiver.example.com", pathPrefix: "/mcp-events" }, - ], + callbackTargets: + mode === "a-only-discovery" + ? [] + : [ + { + origin: "https://receiver.example.com", + pathPrefix: "/mcp-events", + }, + ], }; config.vars.RELEASE_PROFILE = "private-pilot-a-only"; config.vars.PRIVATE_PILOT_POLICY = JSON.stringify(policy); - config.vars.ALLOW_OUTBOUND_NOTIFICATIONS = "true"; + config.vars.ALLOW_OUTBOUND_NOTIFICATIONS = + mode === "a-only-discovery" ? "false" : "true"; fixture.profile = config.vars.RELEASE_PROFILE; fixture.configText = JSON.stringify(config); const configSha256 = createHash("sha256") @@ -167,14 +180,18 @@ export function makePrivatePilotFixture() { fixture.operations.configSha256 = configSha256; fixture.operations.profile = fixture.profile; fixture.operations.privatePilot = { - mode: "a-only", + mode, policySha256: createHash("sha256") .update(config.vars.PRIVATE_PILOT_POLICY) .digest("hex"), approvalRef: policy.approvalRef, independentOwnerConsentsReviewed: true, - callbackControlReviewed: true, - nativeFetchRiskAccepted: true, + ...(mode === "a-only-discovery" + ? { callbackDiscoveryReviewed: true, noOutboundReviewed: true } + : { callbackControlReviewed: true, nativeFetchRiskAccepted: true }), + ...(observationMode === undefined + ? {} + : { privateObservationReviewed: true }), boundedCanaryReviewed: true, stopPlanReviewed: true, }; diff --git a/test/integration/connection-identity.test.ts b/test/integration/connection-identity.test.ts new file mode 100644 index 0000000..144e09c --- /dev/null +++ b/test/integration/connection-identity.test.ts @@ -0,0 +1,203 @@ +import { evictDurableObject, runInDurableObject } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { getOwnConnectionIdentity } from "../../src/mcp/connection-identity"; +import type { AuthenticatedMcpIdentity } from "../../src/mcp/server"; +import { CONNECTION_SCOPES, type ConnectionScope } from "../../src/shared/auth"; + +async function connection( + grantScopes: ConnectionScope[] = [...CONNECTION_SCOPES], +) { + const guestId = crypto.randomUUID(); + const guest = env.GUESTS.get(env.GUESTS.idFromName(guestId)); + const session = await guest.createSession(guestId); + const grant = await guest.createConnection( + session.sessionId, + session.secret, + grantScopes, + Date.now() + 3_600_000, + ); + if (!grant) throw new Error("Missing synthetic connection grant"); + const identity: AuthenticatedMcpIdentity = { + guestId, + connectionId: grant.connectionId, + scopes: [...grantScopes], + tokenExpiresAtMs: Date.now() + 60_000, + }; + return { guest, session, grant, identity }; +} + +// These checks use actual GuestDO grant authority and retained SQLite rows. +// The OAuth-to-tool boundary is separately exercised with the real MCP SDK. +describe("authenticated own connection inspection", () => { + it("returns only the authenticated connection and bounds current authority", async () => { + const f = await connection(); + const other = await f.guest.createConnection( + f.session.sessionId, + f.session.secret, + ["tables:read"], + Date.now() + 120_000, + ); + expect(other).not.toBeNull(); + const result = await getOwnConnectionIdentity(env, f.identity); + expect(result).toEqual({ + guestId: f.identity.guestId, + connectionId: f.grant.connectionId, + scopes: [...CONNECTION_SCOPES], + expiresAt: new Date(f.identity.tokenExpiresAtMs as number).toISOString(), + grantExpiresAt: new Date(f.grant.expiresAt).toISOString(), + tokenExpiresAt: new Date( + f.identity.tokenExpiresAtMs as number, + ).toISOString(), + }); + expect(JSON.stringify(result)).not.toContain(other?.connectionId); + expect(JSON.stringify(result)).not.toContain(f.session.sessionId); + expect(JSON.stringify(result)).not.toContain(f.session.secret); + }); + + it("intersects token delegation with the current grant without adding scopes", async () => { + const f = await connection(["tables:read", "tables:write"]); + expect( + await getOwnConnectionIdentity(env, { + ...f.identity, + scopes: ["tables:write"], + }), + ).toMatchObject({ scopes: ["tables:write"] }); + expect( + await getOwnConnectionIdentity(env, { + ...f.identity, + scopes: [...CONNECTION_SCOPES], + }), + ).toMatchObject({ scopes: ["tables:read", "tables:write"] }); + expect( + await getOwnConnectionIdentity(env, { + ...f.identity, + scopes: ["events:subscribe"], + }), + ).toBeNull(); + }); + + it("uses a shortened persisted grant deadline after reconstruction", async () => { + const f = await connection(); + const deadline = Date.now() + 30_000; + await runInDurableObject(f.guest, async (_instance, state) => { + state.storage.sql.exec( + "UPDATE guest_connections SET expires_at = ? WHERE connection_id = ?", + deadline, + f.grant.connectionId, + ); + }); + await evictDurableObject(f.guest); + expect(await getOwnConnectionIdentity(env, f.identity)).toMatchObject({ + expiresAt: new Date(deadline).toISOString(), + grantExpiresAt: new Date(deadline).toISOString(), + tokenExpiresAt: new Date( + f.identity.tokenExpiresAtMs as number, + ).toISOString(), + }); + }); + + it("does not cache an earlier positive result after browser revocation", async () => { + const f = await connection(); + expect(await getOwnConnectionIdentity(env, f.identity)).not.toBeNull(); + expect( + await f.guest.revokeConnection( + f.session.sessionId, + f.session.secret, + f.grant.connectionId, + ), + ).toMatchObject({ status: "revoked" }); + expect(await getOwnConnectionIdentity(env, f.identity)).toBeNull(); + }); + + for (const terminal of ["expired", "deleted"] as const) { + it(`refuses a ${terminal} persisted connection`, async () => { + const f = await connection(); + await runInDurableObject(f.guest, async (_instance, state) => { + if (terminal === "expired") { + state.storage.sql.exec( + "UPDATE guest_connections SET expires_at = ? WHERE connection_id = ?", + Date.now() - 1, + f.grant.connectionId, + ); + } else { + state.storage.sql.exec( + "DELETE FROM guest_connections WHERE connection_id = ?", + f.grant.connectionId, + ); + } + }); + await evictDurableObject(f.guest); + expect(await getOwnConnectionIdentity(env, f.identity)).toBeNull(); + }); + } + + it("refuses foreign connections, missing identities and quarantine", async () => { + const f = await connection(); + const foreign = await connection(); + expect( + await getOwnConnectionIdentity(env, { + ...f.identity, + connectionId: foreign.grant.connectionId, + }), + ).toBeNull(); + expect( + await getOwnConnectionIdentity(env, { + ...f.identity, + guestId: crypto.randomUUID(), + }), + ).toBeNull(); + expect( + await getOwnConnectionIdentity( + { ...env, RESTORE_QUARANTINE: "true" }, + f.identity, + ), + ).toBeNull(); + }); + + it("refuses unknown, expired or out-of-range token authority", async () => { + const f = await connection(); + for (const tokenExpiresAtMs of [ + null, + 0, + Date.now() - 1, + Math.floor(Date.now() / 1_000), + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER, + ]) { + expect( + await getOwnConnectionIdentity(env, { + ...f.identity, + tokenExpiresAtMs, + }), + ).toBeNull(); + } + for (const scopes of [ + [], + ["tables:read", "tables:read"], + [...CONNECTION_SCOPES, "tables:read"], + ["unrecognized-scope"], + ]) { + expect( + await getOwnConnectionIdentity(env, { + ...f.identity, + scopes: scopes as ConnectionScope[], + }), + ).toBeNull(); + } + }); + + it("fails closed when the current authority cannot be read", async () => { + const f = await connection(); + const unavailable = { + ...env, + GUESTS: { + idFromName: () => { + throw new Error("private-authority-error-sentinel"); + }, + } as unknown as Env["GUESTS"], + }; + expect(await getOwnConnectionIdentity(unavailable, f.identity)).toBeNull(); + }); +}); diff --git a/test/integration/private-pilot-discovery.test.ts b/test/integration/private-pilot-discovery.test.ts new file mode 100644 index 0000000..41ee2b9 --- /dev/null +++ b/test/integration/private-pilot-discovery.test.ts @@ -0,0 +1,527 @@ +import { + createExecutionContext, + evictDurableObject, + runInDurableObject, +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { handleMcp } from "../../src/mcp/server"; +import type { VerifiedConnectionActor } from "../../src/shared/auth"; +import { inboxStreamFixture } from "../helpers/inbox-stream-fixture"; + +const CALLBACK_ORIGIN = "https://events.provider.example.org"; +const CALLBACK = `${CALLBACK_ORIGIN}/CALLBACK_PATH_SENTINEL?secret=CALLBACK_QUERY_SENTINEL`; +const SECRET = `whsec_${btoa("S".repeat(32))}`; +const SCHEMA = "agent-table.private-pilot.v1"; + +function parameters(tableId: string, url = CALLBACK) { + return { + name: "table.message.created", + arguments: { table_id: tableId }, + delivery: { mode: "webhook", url, secret: SECRET }, + cursor: null, + _meta: { private: "PROTOCOL_METADATA_SENTINEL" }, + }; +} + +async function fixture(configureInitially = true) { + const f = await inboxStreamFixture(env); + const now = Date.now(); + const policy = { + schemaVersion: 1, + mode: "a-only-discovery", + observationMode: "private-redacted", + buildSha: "a".repeat(40), + approvedAt: new Date(now - 2_000).toISOString(), + notBefore: new Date(now - 1_000).toISOString(), + expiresAt: new Date(now + 120_000).toISOString(), + approvalRef: "synthetic-discovery-review", + recipientGuestId: f.owner.id, + recipientConnectionId: f.owner.grant.connectionId, + tableIds: [f.tableId], + callbackTargets: [], + }; + const overlay: Env = { + ...env, + RELEASE_ENVIRONMENT: "staging", + RELEASE_PROFILE: "private-pilot-a-only", + RELEASE_BUILD_SHA: policy.buildSha, + RESTORE_QUARANTINE: "false", + ENABLE_MCP_SUBSCRIPTIONS: "false", + ENABLE_OPENAI_EVENTS: "false", + ALLOW_OUTBOUND_NOTIFICATIONS: "false", + PRIVATE_PILOT_POLICY: JSON.stringify(policy), + }; + const configure = async (patch: Partial = {}, reconstruct = false) => { + if (reconstruct) await evictDurableObject(f.table); + await runInDurableObject(f.table, (instance) => { + const harness = instance as unknown as { env: Env }; + harness.env = { ...harness.env, ...overlay, ...patch }; + }); + }; + if (configureInitially) await configure(); + const rows = () => + runInDurableObject(f.table, (_instance, state) => ({ + candidates: state.storage.sql + .exec("SELECT * FROM table_event_candidates") + .toArray(), + subscriptions: state.storage.sql + .exec("SELECT * FROM table_event_subscriptions") + .toArray(), + outbox: state.storage.sql + .exec("SELECT * FROM table_event_outbox") + .toArray(), + })); + return { + ...f, + actor: f.owner.actor("events:subscribe"), + policy, + overlay, + configure, + rows, + }; +} + +type Fixture = Awaited>; + +function capture() { + const logs = vi.spyOn(console, "info").mockImplementation(() => undefined); + const network = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + throw new Error("Discovery must never initiate an outbound request"); + }); + const records = () => + logs.mock.calls.flatMap(([value]) => { + try { + const parsed = JSON.parse(String(value)); + return parsed.schema === SCHEMA ? [parsed] : []; + } catch { + return []; + } + }); + return { logs, network, records }; +} + +async function wire( + f: Fixture, + actor: VerifiedConnectionActor, + method: string, + params: Record = {}, +) { + // This exercises the real MCP HTTP handler and fresh GuestDO authority. + // The fixture identity stands in for already-validated OAuth middleware; + // separate OAuth/MCP integration tests exercise that outer token boundary. + const response = await handleMcp( + new Request(`${env.PUBLIC_ORIGIN}/mcp`, { + method: "POST", + headers: { + Host: new URL(env.PUBLIC_ORIGIN).host, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": method, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: crypto.randomUUID(), + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }), + f.overlay, + createExecutionContext(), + { + guestId: actor.guestId, + connectionId: actor.connectionId, + scopes: ["tables:read", "tables:write", "events:subscribe"], + tokenExpiresAtMs: Date.now() + 60_000, + }, + ); + expect(response.status, await response.clone().text()).toBe(200); + return response.json() as Promise<{ + result?: { + capabilities?: Record; + events?: Array<{ name: string }>; + }; + error?: { code: number; message: string; data?: { reason: string } }; + }>; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("private staging callback discovery without delivery", () => { + it("records only the scoped callback origin and persists no subscription work", async () => { + const f = await fixture(); + const c = capture(); + const before = Date.now(); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: true, + value: {}, + }); + expect(c.records()).toEqual([ + { + schema: SCHEMA, + kind: "callback-origin", + buildSha: f.policy.buildSha, + approvalRef: f.policy.approvalRef, + tableId: f.tableId, + membershipId: f.created.value.ownMembershipId, + observedAt: expect.any(Number), + callbackOrigin: CALLBACK_ORIGIN, + }, + ]); + expect(c.records()[0].observedAt).toBeGreaterThanOrEqual(before); + expect(c.records()[0].observedAt).toBeLessThanOrEqual(Date.now()); + for (const forbidden of [ + CALLBACK, + "CALLBACK_PATH_SENTINEL", + "CALLBACK_QUERY_SENTINEL", + "PROTOCOL_METADATA_SENTINEL", + SECRET, + f.owner.id, + f.owner.grant.connectionId, + f.owner.session.secret, + "Internal hints", + ]) { + expect(JSON.stringify(c.logs.mock.calls)).not.toContain(forbidden); + } + expect(await f.rows()).toEqual({ + candidates: [], + subscriptions: [], + outbox: [], + }); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("advertises Events only to A and returns a review error without subscribing", async () => { + const f = await fixture(); + const c = capture(); + const a = await wire(f, f.actor, "server/discover"); + const b = await wire( + f, + f.peer.actor("events:subscribe"), + "server/discover", + ); + expect(a.result?.capabilities?.events).toEqual({}); + expect(b.result?.capabilities?.events).toBeUndefined(); + expect( + (await wire(f, f.actor, "events/list")).result?.events, + ).toMatchObject([{ name: "table.message.created" }]); + expect( + (await wire(f, f.peer.actor("events:subscribe"), "events/list")).error, + ).toMatchObject({ code: -32001 }); + const subscription = await wire( + f, + f.actor, + "events/subscribe", + parameters(f.tableId), + ); + expect(subscription.result).toBeUndefined(); + expect(subscription.error).toMatchObject({ + code: -32015, + data: { reason: "operator_review_required" }, + }); + expect(c.records()).toHaveLength(1); + expect(await f.rows()).toEqual({ + candidates: [], + subscriptions: [], + outbox: [], + }); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("refuses foreign scope, connection and table arguments before logging", async () => { + const f = await fixture(); + const c = capture(); + for (const actor of [ + f.peer.actor("events:subscribe"), + f.owner.actor("tables:read"), + { ...f.actor, connectionId: crypto.randomUUID() }, + ]) { + expect( + await f.table.observeEventCallback(actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TABLE_NOT_FOUND", + }); + } + expect( + await f.table.observeEventCallback( + f.actor, + parameters(crypto.randomUUID()), + ), + ).toEqual({ ok: false, code: "TABLE_NOT_FOUND" }); + expect(c.records()).toEqual([]); + expect(await f.rows()).toEqual({ + candidates: [], + subscriptions: [], + outbox: [], + }); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("requires actual membership even when the policy names the actor and table", async () => { + const f = await fixture(); + const outsider = await inboxStreamFixture(env); + await f.configure({ + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + recipientGuestId: outsider.owner.id, + recipientConnectionId: outsider.owner.grant.connectionId, + }), + }); + const c = capture(); + expect( + await f.table.observeEventCallback( + outsider.owner.actor("events:subscribe"), + parameters(f.tableId), + ), + ).toEqual({ ok: false, code: "TABLE_NOT_FOUND" }); + expect(c.records()).toEqual([]); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("rejects malformed parameters and unsafe destinations without echoing input", async () => { + const f = await fixture(); + const c = capture(); + for (const url of [ + "http://events.provider.example.org/callback", + "https://127.0.0.1/callback", + "https://[::1]/callback", + "https://localhost/callback", + "https://internal.local/callback", + "https://events.provider.example.org:8443/callback", + "https://user:URL_PASSWORD_SENTINEL@events.provider.example.org/callback", + `${CALLBACK_ORIGIN}/callback#fragment`, + ]) { + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId, url)), + ).toEqual({ + ok: false, + code: "INVALID_REQUEST", + }); + } + expect( + await f.table.observeEventCallback(f.actor, { + ...parameters(f.tableId), + headers: { Authorization: "PRIVATE_HEADER_SENTINEL" }, + }), + ).toEqual({ ok: false, code: "INVALID_REQUEST" }); + expect(c.records()).toEqual([]); + expect(await f.rows()).toEqual({ + candidates: [], + subscriptions: [], + outbox: [], + }); + expect(c.network).not.toHaveBeenCalled(); + }); + + it.each([ + ["production", { RELEASE_ENVIRONMENT: "production" }], + ["quarantine", { RESTORE_QUARANTINE: "true" }], + ["outbound enabled", { ALLOW_OUTBOUND_NOTIFICATIONS: "true" }], + ["public Events", { ENABLE_OPENAI_EVENTS: "true" }], + ])("refuses %s configuration", async (_name, patch) => { + const f = await fixture(); + await f.configure(patch); + const c = capture(); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TABLE_NOT_FOUND", + }); + expect(c.records()).toEqual([]); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("refuses expired discovery and a stopped delivery configuration", async () => { + const f = await fixture(); + const c = capture(); + const now = Date.now(); + await f.configure({ + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + approvedAt: new Date(now - 120_000).toISOString(), + notBefore: new Date(now - 119_000).toISOString(), + expiresAt: new Date(now - 1).toISOString(), + }), + }); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TABLE_NOT_FOUND", + }); + await f.configure({ + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + mode: "a-only", + callbackTargets: [{ origin: CALLBACK_ORIGIN, pathPrefix: "/" }], + }), + }); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TABLE_NOT_FOUND", + }); + expect(c.records()).toEqual([]); + expect(c.network).not.toHaveBeenCalled(); + }); + + it.each(["closed", "expired"] as const)( + "refuses a table in the %s state", + async (tableState) => { + const f = await fixture(); + if (tableState === "closed") { + expect( + await f.table.closeTable( + f.owner.actor("tables:write"), + crypto.randomUUID(), + ), + ).toMatchObject({ ok: true }); + } else { + await runInDurableObject(f.table, (_instance, state) => { + state.storage.sql.exec( + "UPDATE table_metadata SET expires_at = ?", + Date.now() - 1, + ); + }); + } + const c = capture(); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TABLE_TERMINAL", + }); + expect(c.records()).toEqual([]); + expect(c.network).not.toHaveBeenCalled(); + }, + ); + + it("rechecks the real guest grant after awaiting durable quota admission", async () => { + const f = await fixture(); + let revocationStatus: string | undefined; + await f.configure({ + ADMISSION: { + idFromName: (name: string) => env.ADMISSION.idFromName(name), + get: (id: DurableObjectId) => ({ + consume: async (category: "application", maximum: number) => { + const admitted = await env.ADMISSION.get(id).consume( + category, + maximum, + ); + const guest = env.GUESTS.get(env.GUESTS.idFromName(f.owner.id)); + revocationStatus = ( + await guest.revokeConnection( + f.owner.session.sessionId, + f.owner.session.secret, + f.owner.grant.connectionId, + ) + ).status; + return admitted; + }, + }), + } as unknown as Env["ADMISSION"], + }); + const c = capture(); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TABLE_NOT_FOUND", + }); + expect(revocationStatus).toBe("revoked"); + expect(c.records()).toEqual([]); + expect(await f.rows()).toEqual({ + candidates: [], + subscriptions: [], + outbox: [], + }); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("preserves the one-per-minute admission charge across object reconstruction", async () => { + const f = await fixture(); + const c = capture(); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toMatchObject({ ok: true }); + const key = `pilot-origin-v1:${f.policy.buildSha}:${f.policy.approvalRef}:${f.owner.id}:${f.owner.grant.connectionId}`; + const quota = env.ADMISSION.get(env.ADMISSION.idFromName(key)); + await evictDurableObject(quota); + await f.configure({}, true); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TEMPORARILY_UNAVAILABLE", + }); + expect( + await runInDurableObject(quota, (_instance, state) => + state.storage.sql.exec("SELECT category FROM admission_hits").toArray(), + ), + ).toEqual([{ category: "application" }]); + expect(c.records()).toHaveLength(1); + expect(await f.rows()).toEqual({ + candidates: [], + subscriptions: [], + outbox: [], + }); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("fails closed when quota authority is unavailable", async () => { + const f = await fixture(); + await f.configure({ + ADMISSION: { + idFromName: () => { + throw new Error("PRIVATE_QUOTA_ERROR_SENTINEL"); + }, + } as unknown as Env["ADMISSION"], + }); + const c = capture(); + expect( + await f.table.observeEventCallback(f.actor, parameters(f.tableId)), + ).toEqual({ + ok: false, + code: "TEMPORARILY_UNAVAILABLE", + }); + expect(c.records()).toEqual([]); + expect(c.network).not.toHaveBeenCalled(); + }); + + it("cannot reserve or commit a normal subscription under discovery authority", async () => { + const f = await fixture(false); + const candidate = await f.table.beginEventSubscription( + f.actor, + parameters(f.tableId), + ); + if (!candidate.ok) throw new Error("Missing local setup candidate"); + await f.configure(); + const before = await f.rows(); + const c = capture(); + expect( + await f.table.beginEventSubscription(f.actor, parameters(f.tableId)), + ).toMatchObject({ ok: false }); + expect( + await f.table.commitVerifiedEventSubscription( + f.actor, + candidate.value.candidateId, + ), + ).toMatchObject({ ok: false }); + expect(await f.rows()).toEqual(before); + expect(before.subscriptions).toMatchObject([{ status: "inactive" }]); + expect(c.records()).toEqual([]); + expect(c.network).not.toHaveBeenCalled(); + }); +}); diff --git a/test/integration/private-pilot-observations.test.ts b/test/integration/private-pilot-observations.test.ts new file mode 100644 index 0000000..0082911 --- /dev/null +++ b/test/integration/private-pilot-observations.test.ts @@ -0,0 +1,588 @@ +import { evictDurableObject, runInDurableObject } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { Webhook } from "standardwebhooks"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PLANNED_MAX_CALLBACK_RESPONSE_BYTES } from "../../src/notifications/egress"; +import { authorizeGuestConnection } from "../../src/shared/auth"; +import { sealSecret } from "../../src/shared/secret-envelope"; + +const SECRET = `whsec_${btoa("P".repeat(32))}`; +const SUBSCRIPTION = "sub_syntheticObservedAttempt1234"; +const CALLBACK = + "https://events.provider.example.org/callback/PRIVATE_CALLBACK_PATH?token=PRIVATE_CALLBACK_QUERY"; +const BODY = "PRIVATE_TRANSCRIPT_SENTINEL"; +const RESPONSE_HEADER = "PRIVATE_RESPONSE_HEADER"; +const SCHEMA = "agent-table.private-pilot.v1"; +const cleanups: Array<() => Promise> = []; + +type Observation = { + schema: string; + kind: string; + tableId: string; + eventId: string; + messageId: string; + membershipId: string; + subscriptionId: string; + generation: number; + attempt: number; + seq: number; + observedAt: number; + status?: number; + outcome?: string; + errorCategory?: string; + settled?: string | null; + transportInvoked?: boolean; + responseObserved?: boolean; +}; + +function observations(calls: readonly (readonly unknown[])[]): Observation[] { + return calls.flatMap(([text]) => { + if (typeof text !== "string") return []; + try { + const value = JSON.parse(text); + return value?.schema === SCHEMA ? [value as Observation] : []; + } catch { + return []; + } + }); +} + +/** Real grant, encrypted credentials and retained SQLite; only HTTP is synthetic. */ +async function fixture() { + const guestId = crypto.randomUUID(); + const guest = env.GUESTS.get(env.GUESTS.idFromName(guestId)); + const session = await guest.createSession(guestId); + const grant = await guest.createConnection( + session.sessionId, + session.secret, + ["events:subscribe", "tables:write", "tables:read"], + Date.now() + 3_600_000, + ); + if (!grant) throw new Error("Missing synthetic connection"); + const authorized = await authorizeGuestConnection( + env, + guestId, + grant.connectionId, + "tables:write", + ); + if (!authorized.allowed) throw new Error("Missing synthetic authority"); + const tableId = crypto.randomUUID(); + const membershipId = crypto.randomUUID(); + const senderMembershipId = crypto.randomUUID(); + const messageId = crypto.randomUUID(); + const eventId = crypto.randomUUID(); + const stub = env.TABLES.get(env.TABLES.idFromName(tableId)); + const started = Date.now(); + const policy = { + schemaVersion: 1, + mode: "a-only", + observationMode: "private-redacted", + buildSha: "a".repeat(40), + approvedAt: new Date(started - 20_000).toISOString(), + notBefore: new Date(started - 10_000).toISOString(), + expiresAt: new Date(started + 600_000).toISOString(), + approvalRef: "synthetic-observed-delivery", + recipientGuestId: guestId, + recipientConnectionId: grant.connectionId, + tableIds: [tableId], + callbackTargets: [ + { + origin: "https://events.provider.example.org", + pathPrefix: "/callback", + }, + ], + }; + const overlay: Env = { + ...env, + PUBLIC_ORIGIN: "https://agent-table-test.outliner-online.com", + RELEASE_ENVIRONMENT: "staging", + RELEASE_PROFILE: "private-pilot-a-only", + RELEASE_BUILD_SHA: policy.buildSha, + RESTORE_QUARANTINE: "false", + ENABLE_MCP_SUBSCRIPTIONS: "false", + ENABLE_OPENAI_EVENTS: "false", + ALLOW_OUTBOUND_NOTIFICATIONS: "true", + STAGING_EVIDENCE_MODE: "read-only", + PRIVATE_PILOT_POLICY: JSON.stringify(policy), + }; + const envelope = await sealSecret( + "callback-credential", + SECRET, + { recordId: `event:${SUBSCRIPTION}` }, + env, + ); + await runInDurableObject(stub, (_instance, state) => { + const sql = state.storage.sql; + sql.exec( + "INSERT INTO table_metadata (singleton, table_id, creation_request_id, request_digest, creator_guest_id, title, state, created_at, expires_at, last_seq) VALUES (1, ?, ?, ?, ?, 'PRIVATE_TABLE_TITLE', 'open', ?, ?, 1)", + tableId, + crypto.randomUUID(), + "a".repeat(64), + guestId, + started - 2000, + started + 3_600_000, + ); + sql.exec( + "INSERT INTO table_memberships (membership_id, table_id, guest_id, seat, role, joined_at) VALUES (?, ?, ?, 1, 'creator', ?)", + membershipId, + tableId, + guestId, + started - 2000, + ); + sql.exec( + "INSERT INTO table_memberships (membership_id, table_id, guest_id, seat, role, joined_at) VALUES (?, ?, ?, 2, 'invitee', ?)", + senderMembershipId, + tableId, + crypto.randomUUID(), + started - 1500, + ); + sql.exec( + "INSERT INTO table_event_subscriptions (membership_id, generation, fence, status, guest_id, connection_id, subscription_id, callback_url, secret_envelope, expires_at) VALUES (?, 1, 1, 'active', ?, ?, ?, ?, ?, ?)", + membershipId, + guestId, + grant.connectionId, + SUBSCRIPTION, + CALLBACK, + envelope, + started + 600_000, + ); + sql.exec( + "INSERT INTO table_messages (seq, message_id, sender_membership_id, body, body_bytes, created_at) VALUES (1, ?, ?, ?, ?, ?)", + messageId, + senderMembershipId, + BODY, + BODY.length, + started - 1000, + ); + sql.exec( + "INSERT INTO table_event_outbox (event_id, message_id, message_seq, recipient_membership_id, subscription_id, subscription_generation, created_at, expires_at, next_attempt_at) VALUES (?, ?, 1, ?, ?, 1, ?, ?, ?)", + eventId, + messageId, + membershipId, + SUBSCRIPTION, + started - 1000, + started + 600_000, + started - 1, + ); + }); + const configure = (patch: Partial = {}) => + runInDurableObject(stub, (instance) => { + const harness = instance as unknown as { env: Env }; + harness.env = { ...overlay, ...patch }; + }); + await configure(); + cleanups.push(async () => { + await configure({ ALLOW_OUTBOUND_NOTIFICATIONS: "false" }); + await runInDurableObject(stub, (_instance, state) => + state.storage.deleteAlarm(), + ); + }); + const snapshot = ( + requestedTable = tableId, + requestedGuest = guestId, + requestedConnection = grant.connectionId, + ) => + stub.readPrivatePilotEvidence( + requestedTable, + requestedGuest, + requestedConnection, + ); + const alarm = () => runInDurableObject(stub, (instance) => instance.alarm()); + const refusesSnapshot = ( + requestedTable = tableId, + requestedGuest = guestId, + requestedConnection = grant.connectionId, + ) => + runInDurableObject(stub, (instance) => { + // Catch inside the DO to avoid the Vitest bridge's duplicate RPC rejection. + expect(() => + instance.readPrivatePilotEvidence( + requestedTable, + requestedGuest, + requestedConnection, + ), + ).toThrow("Private pilot evidence is disabled"); + }); + return { + stub, + guest, + session, + grant, + actor: authorized.actor, + tableId, + guestId, + membershipId, + senderMembershipId, + messageId, + eventId, + policy, + envelope, + configure, + snapshot, + alarm, + refusesSnapshot, + }; +} + +function assertRedacted( + serialized: string, + f: Awaited>, + lease?: string, +) { + for (const value of [ + SECRET, + CALLBACK, + "PRIVATE_CALLBACK_PATH", + "PRIVATE_CALLBACK_QUERY", + BODY, + RESPONSE_HEADER, + "PRIVATE_TABLE_TITLE", + f.envelope, + f.guestId, + f.grant.connectionId, + ...(lease ? [lease] : []), + ]) + expect(serialized).not.toContain(value); + expect(serialized).not.toMatch( + /secret_envelope|callback_url|lease_token|webhook-signature|x-mcp-signature/, + ); +} + +beforeEach(() => { + // No fixture or recovery alarm may contact the network outside its mock. + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("Synthetic HTTP not configured"), + ); +}); + +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup(); + vi.restoreAllMocks(); +}); + +describe("private pilot observation projection (synthetic HTTP, not host acceptance)", () => { + it("keeps observations disabled for a valid delivery policy without the explicit opt-in", async () => { + const f = await fixture(); + await f.configure({ + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + observationMode: undefined, + }), + }); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const fetch = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => new Response(null, { status: 204 })); + await f.alarm(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(observations(info.mock.calls)).toEqual([]); + await runInDurableObject(f.stub, (_instance, state) => { + expect( + state.storage.sql + .exec<{ status: string; attempt_count: number }>( + "SELECT status, attempt_count FROM table_event_outbox", + ) + .one(), + ).toEqual({ status: "accepted", attempt_count: 1 }); + }); + await f.refusesSnapshot(); + }); + + it("joins four actual dispatch observations to the persisted attempt without leaking payload or credentials", async () => { + const f = await fixture(); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + let lease = ""; + const fetch = vi.spyOn(globalThis, "fetch"); + await runInDurableObject(f.stub, async (instance, state) => { + fetch.mockImplementation(async (url, init) => { + expect(String(url)).toBe(CALLBACK); + const verified = new Webhook(SECRET).verify( + new TextDecoder().decode(init?.body as ArrayBuffer), + Object.fromEntries(new Headers(init?.headers)), + ); + expect(verified).toMatchObject({ + eventId: f.eventId, + data: { message_id: f.messageId, seq: 1 }, + }); + const rows = state.storage.sql + .exec<{ lease_token: string; attempt_count: number }>( + "SELECT lease_token, attempt_count FROM table_event_outbox", + ) + .one(); + lease = rows.lease_token; + expect(rows.attempt_count).toBe(1); + expect(lease).toBeTruthy(); + return new Response(null, { + status: 204, + headers: { "X-Private-Sentinel": RESPONSE_HEADER }, + }); + }); + await instance.alarm(); + }); + expect(fetch).toHaveBeenCalledTimes(1); + const records = observations(info.mock.calls); + expect(records.map((record) => record.kind)).toEqual([ + "attempt-authorized", + "transport-invoked", + "response-headers", + "attempt-completed", + ]); + const snapshot = await f.snapshot(); + expect(snapshot.outbox).toHaveLength(1); + expect(snapshot.outbox[0]).toMatchObject({ + eventId: f.eventId, + messageId: f.messageId, + membershipId: f.membershipId, + subscriptionId: SUBSCRIPTION, + generation: 1, + attemptCount: 1, + seq: 1, + status: "accepted", + nextAttemptAt: null, + leaseDeadline: null, + }); + expect(snapshot.messages).toEqual([ + { + messageId: f.messageId, + seq: 1, + senderMembershipId: f.senderMembershipId, + createdAt: snapshot.outbox[0].createdAt, + replyToMessageId: null, + }, + ]); + const commonKeys = [ + "schema", + "buildSha", + "approvalRef", + "tableId", + "eventId", + "messageId", + "seq", + "membershipId", + "subscriptionId", + "generation", + "attempt", + "kind", + "observedAt", + ]; + const extraKeys = [ + [], + [], + ["status"], + ["outcome", "settled", "transportInvoked", "responseObserved"], + ]; + for (const [index, record] of records.entries()) { + expect(Object.keys(record).sort()).toEqual( + [...commonKeys, ...extraKeys[index]].sort(), + ); + expect(record).toMatchObject({ + tableId: f.tableId, + eventId: f.eventId, + messageId: f.messageId, + membershipId: f.membershipId, + subscriptionId: SUBSCRIPTION, + generation: 1, + attempt: snapshot.outbox[0].attemptCount, + seq: 1, + }); + expect(record.observedAt).toBeGreaterThanOrEqual( + index === 0 + ? (snapshot.outbox[0].firstAttemptAt ?? 0) + : records[index - 1].observedAt, + ); + } + expect(records[2].status).toBe(204); + expect(records[3]).toMatchObject({ + outcome: "accepted", + settled: "accepted", + transportInvoked: true, + responseObserved: true, + }); + assertRedacted(JSON.stringify({ records, snapshot }), f, lease); + }); + + it("retains the retry identity and charged attempt count across reconstruction", async () => { + const f = await fixture(); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(null, { status: 503, headers: { "Retry-After": "1" } }), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + await f.alarm(); + const before = await f.snapshot(); + expect(before.outbox[0]).toMatchObject({ + eventId: f.eventId, + status: "pending", + attemptCount: 1, + lastErrorCategory: "server-error", + }); + expect(before.outbox[0].nextAttemptAt).toBeGreaterThan( + before.outbox[0].firstAttemptAt ?? 0, + ); + await evictDurableObject(f.stub); + await f.configure(); + expect((await f.snapshot()).outbox).toEqual(before.outbox); + await runInDurableObject(f.stub, (_instance, state) => { + // Advance only the synthetic due time; retain identity, budget and horizon. + state.storage.sql.exec( + "UPDATE table_event_outbox SET next_attempt_at = ? WHERE status = 'pending'", + Date.now() - 1, + ); + }); + await f.alarm(); + expect(fetch).toHaveBeenCalledTimes(2); + const snapshot = await f.snapshot(); + expect(snapshot.outbox[0]).toMatchObject({ + eventId: f.eventId, + status: "accepted", + attemptCount: 2, + firstAttemptAt: before.outbox[0].firstAttemptAt, + }); + const records = observations(info.mock.calls); + expect(records).toHaveLength(8); + expect(records.every((record) => record.eventId === f.eventId)).toBe(true); + expect(records.map((record) => record.attempt)).toEqual([ + 1, 1, 1, 1, 2, 2, 2, 2, + ]); + expect(records[3]).toMatchObject({ + outcome: "transient", + errorCategory: "server-error", + settled: "pending", + }); + expect(records[7]).toMatchObject({ + outcome: "accepted", + settled: "accepted", + }); + assertRedacted(JSON.stringify({ records, snapshot }), f); + }); + + it("keeps completion after closure cancels an already invoked attempt", async () => { + const f = await fixture(); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const fetch = vi.spyOn(globalThis, "fetch"); + await runInDurableObject(f.stub, async (instance) => { + fetch.mockImplementation(async () => { + const closed = await instance.closeTable(f.actor, crypto.randomUUID()); + expect(closed.ok).toBe(true); + expect( + instance.readPrivatePilotEvidence( + f.tableId, + f.guestId, + f.grant.connectionId, + ).outbox[0].status, + ).toBe("cancelled"); + return new Response(null, { status: 204 }); + }); + await instance.alarm(); + }); + expect(fetch).toHaveBeenCalledTimes(1); + const records = observations(info.mock.calls); + expect(records).toHaveLength(4); + expect(records[3]).toMatchObject({ + kind: "attempt-completed", + outcome: "accepted", + settled: null, + transportInvoked: true, + responseObserved: true, + }); + const snapshot = await f.snapshot(); + expect(snapshot.table).toMatchObject({ + tableId: f.tableId, + state: "closed", + }); + expect(snapshot.table?.closedAt).not.toBeNull(); + expect(snapshot.subscription).toMatchObject({ status: "inactive" }); + expect(snapshot.outbox[0]).toMatchObject({ + status: "cancelled", + attemptCount: 1, + nextAttemptAt: null, + leaseDeadline: null, + }); + expect(records[1].observedAt).toBeLessThanOrEqual( + snapshot.table?.closedAt ?? 0, + ); + expect(records[3].observedAt).toBeGreaterThanOrEqual( + snapshot.table?.closedAt ?? 0, + ); + await f.alarm(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(observations(info.mock.calls)).toHaveLength(4); + assertRedacted(JSON.stringify({ records, snapshot }), f); + }); + + it("records accepted response headers separately from a later oversized-body failure", async () => { + const f = await fixture(); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array(PLANNED_MAX_CALLBACK_RESPONSE_BYTES + 1), { + status: 200, + headers: { "X-Private-Sentinel": RESPONSE_HEADER }, + }), + ); + await f.alarm(); + const records = observations(info.mock.calls); + expect(records).toHaveLength(4); + expect(records[2]).toMatchObject({ kind: "response-headers", status: 200 }); + expect(records[3]).toMatchObject({ + kind: "attempt-completed", + outcome: "permanent", + errorCategory: "payload-rejected", + settled: "failed", + responseObserved: true, + }); + const snapshot = await f.snapshot(); + expect(snapshot.outbox[0]).toMatchObject({ + status: "failed", + attemptCount: 1, + lastErrorCategory: "payload-rejected", + }); + assertRedacted(JSON.stringify({ records, snapshot }), f); + }); + + it("reads retained state when delivery is paused or policy expired but refuses unapproved scope", async () => { + const f = await fixture(); + const original = await f.snapshot(); + await f.configure({ ALLOW_OUTBOUND_NOTIFICATIONS: "false" }); + expect((await f.snapshot()).outbox).toEqual(original.outbox); + const now = Date.now(); + await f.configure({ + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + approvedAt: new Date(now - 90_000).toISOString(), + notBefore: new Date(now - 60_000).toISOString(), + expiresAt: new Date(now - 30_000).toISOString(), + }), + }); + expect((await f.snapshot()).outbox).toEqual(original.outbox); + for (const patch of [ + { + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + observationMode: undefined, + }), + }, + { RESTORE_QUARANTINE: "true" }, + { RELEASE_ENVIRONMENT: "production" }, + { STAGING_EVIDENCE_MODE: "off" }, + { PUBLIC_ORIGIN: "https://unrelated.example.org" }, + ]) { + await f.configure(patch); + await f.refusesSnapshot(); + } + await f.configure(); + await f.refusesSnapshot(f.tableId, crypto.randomUUID()); + await f.refusesSnapshot(f.tableId, f.guestId, crypto.randomUUID()); + const otherTable = crypto.randomUUID(); + await f.configure({ + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + tableIds: [f.tableId, otherTable], + }), + }); + await f.refusesSnapshot(otherTable); + assertRedacted(JSON.stringify(original), f); + }); +}); diff --git a/test/integration/public-table-tools.test.ts b/test/integration/public-table-tools.test.ts index 5e2abca..0bf61dc 100644 --- a/test/integration/public-table-tools.test.ts +++ b/test/integration/public-table-tools.test.ts @@ -382,6 +382,70 @@ describe("public SDK → OAuth Worker → GuestDO → TableDO composition", () = await second.close(); } }); + it("inspects the actual OAuth connection without accepting identity arguments", async () => { + const owner = await tokenForGuest("tables:write"); + const client = await sdk(owner.token); + try { + const tool = (await client.listTools()).tools.find( + (entry) => entry.name === "get_connection", + ); + expect(tool).toMatchObject({ + inputSchema: { type: "object", additionalProperties: false }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }); + const result = await client.callTool({ + name: "get_connection", + arguments: {}, + }); + expect(result.isError).not.toBe(true); + const identity = output(result); + expect(identity).toMatchObject({ + guestId: owner.guestId, + connectionId: owner.connectionId, + scopes: ["tables:write"], + }); + expect(Object.keys(identity).sort()).toEqual([ + "connectionId", + "expiresAt", + "grantExpiresAt", + "guestId", + "scopes", + "tokenExpiresAt", + ]); + expect(Date.parse(String(identity.expiresAt))).toBeGreaterThan( + Date.now(), + ); + expect(Date.parse(String(identity.expiresAt))).toBe( + Math.min( + Date.parse(String(identity.grantExpiresAt)), + Date.parse(String(identity.tokenExpiresAt)), + ), + ); + for (const secret of [ + owner.token, + owner.refreshToken, + owner.browserCookie, + ]) { + expect(JSON.stringify(identity)).not.toContain(secret); + } + const forged = await client.callTool({ + name: "get_connection", + arguments: { + guestId: crypto.randomUUID(), + connectionId: crypto.randomUUID(), + }, + }); + expect(forged.isError).toBe(true); + } finally { + await client.close(); + } + }); + it("creates exactly one waiting table, checks membership, and rejects unexpected fields", async () => { const owner = await tokenForGuest("tables:read tables:write"); const client = await sdk( @@ -405,6 +469,7 @@ describe("public SDK → OAuth Worker → GuestDO → TableDO composition", () = "create_invite", "create_table", "delete_table", + "get_connection", "get_table", "join_table", "post_message", diff --git a/test/unit/egress-policy.test.ts b/test/unit/egress-policy.test.ts index 4ab6d86..d6edace 100644 --- a/test/unit/egress-policy.test.ts +++ b/test/unit/egress-policy.test.ts @@ -542,3 +542,97 @@ describe("callback egress preflight", () => { ).toEqual({ ok: false, reason: "destination-rejected" }); }); }); + +describe("private transport observations", () => { + it("observes only native invocation and response headers after all preflights", async () => { + const sequence: string[] = []; + const observer = { + transportInvoked: vi.fn((at: number) => { + expect(Number.isSafeInteger(at)).toBe(true); + sequence.push("invoked"); + }), + responseHeaders: vi.fn((at: number, status: number) => { + expect(Number.isSafeInteger(at)).toBe(true); + expect(status).toBe(204); + sequence.push("headers"); + }), + }; + const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + sequence.push("fetch"); + return new Response(null, { status: 204 }); + }); + try { + for (const request of [ + { + method: "POST" as const, + body: new Uint8Array(), + headers: { Host: "unsafe" }, + }, + { + method: "POST" as const, + body: new Uint8Array(MAX_CALLBACK_REQUEST_BYTES + 1), + }, + ]) { + expect( + ( + await sendVerifiedHttps( + `${reviewed[0].origin}/callback`, + reviewed, + request, + { observer }, + ) + ).ok, + ).toBe(false); + } + expect( + ( + await sendVerifiedHttps( + "https://127.0.0.1/callback", + reviewed, + { method: "POST", body: new Uint8Array() }, + { observer }, + ) + ).ok, + ).toBe(false); + expect(sequence).toEqual([]); + expect(spy).not.toHaveBeenCalled(); + expect( + ( + await sendVerifiedHttps( + `${reviewed[0].origin}/callback`, + reviewed, + { method: "POST", body: new Uint8Array() }, + { observer }, + ) + ).ok, + ).toBe(true); + expect(sequence).toEqual(["invoked", "fetch", "headers"]); + } finally { + spy.mockRestore(); + } + }); + + it("does not change delivery when an observation sink fails", async () => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 204 })); + const fail = () => { + throw new Error("Synthetic observation failure"); + }; + try { + expect( + ( + await sendVerifiedHttps( + `${reviewed[0].origin}/callback`, + reviewed, + { method: "POST", body: new Uint8Array() }, + { observer: { transportInvoked: fail, responseHeaders: fail } }, + ) + ).ok, + ).toBe(true); + expect(spy).toHaveBeenCalledOnce(); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/test/unit/private-pilot.test.ts b/test/unit/private-pilot.test.ts index aaf2c1d..f78e035 100644 --- a/test/unit/private-pilot.test.ts +++ b/test/unit/private-pilot.test.ts @@ -3,10 +3,12 @@ import { callbackTransportReadiness } from "../../src/notifications/egress"; import { subscribeEvent } from "../../src/services/events"; import type { VerifiedConnectionActor } from "../../src/shared/auth"; import { + activePrivatePilotDiscoveryPolicy, activePrivatePilotPolicy, PRIVATE_PILOT_PROFILE, parsePrivatePilotPolicy, privatePilotConfiguration, + privatePilotDiscoveryAllowed, privatePilotRecipientAllowed, } from "../../src/shared/private-pilot"; import { isRestoreQuarantined } from "../../src/shared/release-quarantine"; @@ -38,6 +40,116 @@ const bindings = () => ({ PRIVATE_PILOT_POLICY: JSON.stringify(policy), }); +describe("private callback discovery policy", () => { + const discoveryPolicy = { + ...policy, + mode: "a-only-discovery", + observationMode: "private-redacted", + callbackTargets: [], + }; + const discoveryBindings = () => ({ + ...bindings(), + ALLOW_OUTBOUND_NOTIFICATIONS: "false", + PRIVATE_PILOT_POLICY: JSON.stringify(discoveryPolicy), + }); + const actor = { + guestId: policy.recipientGuestId, + connectionId: policy.recipientConnectionId, + }; + + it("advertises only the selected identity without authorizing delivery", () => { + const env = discoveryBindings(); + expect(parsePrivatePilotPolicy(env.PRIVATE_PILOT_POLICY)).toEqual( + discoveryPolicy, + ); + expect(activePrivatePilotDiscoveryPolicy(env, now)).toEqual( + discoveryPolicy, + ); + expect( + privatePilotDiscoveryAllowed(env, actor, policy.tableIds[0], now), + ).toBe(true); + expect( + privatePilotDiscoveryAllowed( + env, + { ...actor, connectionId: "other-connection" }, + policy.tableIds[0], + now, + ), + ).toBe(false); + expect( + privatePilotDiscoveryAllowed( + env, + { ...actor, guestId: "other-guest" }, + policy.tableIds[0], + now, + ), + ).toBe(false); + expect(privatePilotDiscoveryAllowed(env, actor, "other-table", now)).toBe( + false, + ); + expect(activePrivatePilotPolicy(env, now)).toBeNull(); + expect( + privatePilotRecipientAllowed(env, actor, policy.tableIds[0], now), + ).toBe(false); + expect(isRestoreQuarantined(env)).toBe(false); + expect(callbackTransportReadiness().enabled).toBe(false); + }); + + it("stops discovery at the window boundary while preserving ordinary access", () => { + const env = discoveryBindings(); + for (const outside of [ + Date.parse(policy.notBefore) - 1, + Date.parse(policy.expiresAt), + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + expect(activePrivatePilotDiscoveryPolicy(env, outside)).toBeNull(); + expect( + privatePilotDiscoveryAllowed(env, actor, policy.tableIds[0], outside), + ).toBe(false); + } + expect(privatePilotConfiguration(env)).not.toBeNull(); + const stopped = { ...bindings(), ALLOW_OUTBOUND_NOTIFICATIONS: "false" }; + expect(activePrivatePilotDiscoveryPolicy(stopped, now)).toBeNull(); + expect(privatePilotDiscoveryAllowed(stopped, actor, undefined, now)).toBe( + false, + ); + expect(isRestoreQuarantined(stopped)).toBe(false); + }); + + it.each([ + { observationMode: undefined }, + { observationMode: "off" }, + { callbackTargets: policy.callbackTargets }, + { callbackTargets: null }, + { callbackTargets: {} }, + { unexpected: "unreviewed-field" }, + ])("rejects broadened or implicit discovery authority %#", (patch) => { + expect( + parsePrivatePilotPolicy(JSON.stringify({ ...discoveryPolicy, ...patch })), + ).toBeNull(); + }); + + it.each([ + { RELEASE_ENVIRONMENT: "production" }, + { RELEASE_ENVIRONMENT: "local" }, + { RELEASE_BUILD_SHA: "b".repeat(40) }, + { RELEASE_PROFILE: "manual" }, + { RESTORE_QUARANTINE: "true" }, + { ENABLE_MCP_SUBSCRIPTIONS: "true" }, + { ENABLE_OPENAI_EVENTS: "true" }, + { ALLOW_OUTBOUND_NOTIFICATIONS: "true" }, + ])("rejects a mismatched discovery configuration %#", (patch) => { + const env = { ...discoveryBindings(), ...patch }; + expect(privatePilotConfiguration(env)).toBeNull(); + expect(activePrivatePilotDiscoveryPolicy(env, now)).toBeNull(); + expect(activePrivatePilotPolicy(env, now)).toBeNull(); + expect(privatePilotDiscoveryAllowed(env, actor, undefined, now)).toBe( + false, + ); + }); +}); + describe("private staging pilot policy", () => { it("allows only the exact A guest, connection and table without public readiness", () => { const env = bindings(); diff --git a/test/unit/staging-evidence.test.ts b/test/unit/staging-evidence.test.ts index ae961e7..cb31a51 100644 --- a/test/unit/staging-evidence.test.ts +++ b/test/unit/staging-evidence.test.ts @@ -181,3 +181,91 @@ describe("staging-only SQLite evidence inspection", () => { ); }); }); + +describe("explicit private pilot evidence view", () => { + function pilot() { + const now = Date.now(); + const policy = { + schemaVersion: 1, + mode: "a-only", + observationMode: "private-redacted", + buildSha: "a".repeat(40), + approvedAt: new Date(now - 3000).toISOString(), + notBefore: new Date(now - 2000).toISOString(), + expiresAt: new Date(now + 60_000).toISOString(), + approvalRef: "synthetic-observation-review", + recipientGuestId: GUEST, + recipientConnectionId: CONNECTION, + tableIds: [TABLE], + callbackTargets: [ + { origin: "https://receiver.example.org", pathPrefix: "/" }, + ], + }; + const read = vi.fn(async () => ({ + schema: "agent-table.private-pilot-snapshot.v1", + table: { tableId: TABLE }, + })); + return { + read, + policy, + env: { + ...bindings, + RELEASE_PROFILE: "private-pilot-a-only", + PRIVATE_PILOT_POLICY: JSON.stringify(policy), + TABLES: { + idFromName: (id: string) => id, + get: () => ({ readPrivatePilotEvidence: read }), + }, + } as unknown as Env, + }; + } + const input = { + guestId: GUEST, + tableId: TABLE, + connectionId: CONNECTION, + view: "private-pilot", + }; + + it("requires the existing secret plus the exact observation policy and explicit view", async () => { + const f = pilot(); + expect((await probe(f.env, input, null)).status).toBe(401); + expect((await probe(bindings, input)).status).toBe(404); + expect( + (await probe(f.env, { ...input, connectionId: crypto.randomUUID() })) + .status, + ).toBe(404); + expect( + (await probe(f.env, { ...input, tableId: crypto.randomUUID() })).status, + ).toBe(404); + expect( + ( + await probe( + { + ...f.env, + PRIVATE_PILOT_POLICY: JSON.stringify({ + ...f.policy, + observationMode: undefined, + }), + }, + input, + ) + ).status, + ).toBe(404); + expect(f.read).not.toHaveBeenCalled(); + const response = await probe(f.env, input); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(f.read).toHaveBeenCalledExactlyOnceWith(TABLE, GUEST, CONNECTION); + }); + + it("fails closed on incomplete selectors or diagnostic failures without echoing errors", async () => { + const f = pilot(); + expect( + (await probe(f.env, { guestId: GUEST, view: "private-pilot" })).status, + ).toBe(400); + f.read.mockRejectedValueOnce(new Error("SENSITIVE CALLBACK/SECRET")); + const response = await probe(f.env, input); + expect(response.status).toBe(503); + expect(await response.text()).toBe('{"error":"unavailable"}'); + }); +});