diff --git a/.github/workflows/_deploy-reviewed.yml b/.github/workflows/_deploy-reviewed.yml index e256ff6..bf6f2ad 100644 --- a/.github/workflows/_deploy-reviewed.yml +++ b/.github/workflows/_deploy-reviewed.yml @@ -1,4 +1,4 @@ -name: Reviewed manual deployment (reusable) +name: Reviewed deployment (reusable) on: workflow_call: @@ -9,6 +9,10 @@ on: expected_sha: required: true type: string + release_profile: + required: false + type: string + default: manual bootstrap_staging: required: false type: boolean @@ -45,11 +49,19 @@ jobs: env: EXPECTED_SHA: ${{ inputs.expected_sha }} CHECKED_OUT_SHA: ${{ github.sha }} + TARGET_ENVIRONMENT: ${{ inputs.target_environment }} + RELEASE_PROFILE: ${{ inputs.release_profile }} + BOOTSTRAP_STAGING: ${{ inputs.bootstrap_staging }} run: | set -euo pipefail [[ "$EXPECTED_SHA" =~ ^[a-f0-9]{40}$ ]] [[ "$EXPECTED_SHA" == "$CHECKED_OUT_SHA" ]] [[ "$(git rev-parse HEAD)" == "$CHECKED_OUT_SHA" ]] + if [[ "$RELEASE_PROFILE" != "manual" ]]; then + [[ "$RELEASE_PROFILE" == "private-pilot-a-only" ]] + [[ "$TARGET_ENVIRONMENT" == "staging" ]] + [[ "$BOOTSTRAP_STAGING" == "false" ]] + fi - run: npm ci - name: Recheck application and isolated Workerd gates run: | @@ -64,6 +76,7 @@ jobs: id: candidate env: TARGET_ENVIRONMENT: ${{ inputs.target_environment }} + RELEASE_PROFILE: ${{ inputs.release_profile }} RELEASE_CONFIG_JSON: ${{ secrets.RELEASE_CONFIG_JSON }} PEER_CONFIG_JSON: ${{ secrets.PEER_CONFIG_JSON }} RELEASE_SECRET_INVENTORY_JSON: ${{ secrets.RELEASE_SECRET_INVENTORY_JSON }} @@ -88,7 +101,7 @@ jobs: printf '%s' "$RELEASE_OPERATIONS_JSON" > "$scratch/operations.json" config_sha="$(sha256sum "$config" | cut -d " " -f1)" node scripts/release-preflight.mjs \ - --environment "$TARGET_ENVIRONMENT" --profile manual \ + --environment "$TARGET_ENVIRONMENT" --profile "$RELEASE_PROFILE" \ --build-sha "$GITHUB_SHA" \ --config "$config" --peer-config "$scratch/peer.json" \ --secret-inventory "$scratch/secrets.json" \ @@ -99,6 +112,7 @@ jobs: id: gate env: TARGET_ENVIRONMENT: ${{ inputs.target_environment }} + RELEASE_PROFILE: ${{ inputs.release_profile }} EXPECTED_CONFIG_SHA: ${{ steps.candidate.outputs.config_sha }} BOOTSTRAP_STAGING: ${{ inputs.bootstrap_staging }} EXPECTED_ACTIVE_VERSION_ID: ${{ inputs.expected_active_version_id }} @@ -117,7 +131,7 @@ jobs: # The only producer of an approved manifest performs live authenticated # Cloudflare + exact-main CI reads inside the approved Environment. node scripts/release-authorization.mjs \ - --environment "$TARGET_ENVIRONMENT" --profile manual \ + --environment "$TARGET_ENVIRONMENT" --profile "$RELEASE_PROFILE" \ --build-sha "$GITHUB_SHA" --config "$config" \ --peer-config "$scratch/peer.json" \ --secret-inventory "$scratch/secrets.json" \ @@ -126,12 +140,14 @@ jobs: --bootstrap-staging "$BOOTSTRAP_STAGING" \ --expected-active-version-id "$EXPECTED_ACTIVE_VERSION_ID" > "$scratch/approval.txt" node scripts/require-release-approval.mjs \ - "$TARGET_ENVIRONMENT" "$GITHUB_SHA" "$EXPECTED_CONFIG_SHA" \ + "$TARGET_ENVIRONMENT" "$GITHUB_SHA" "$EXPECTED_CONFIG_SHA" "$RELEASE_PROFILE" \ < "$scratch/approval.txt" printf 'approved=true\nconfig_sha=%s\n' "$EXPECTED_CONFIG_SHA" >> "$GITHUB_OUTPUT" - name: Publish only the same independently approved config bytes if: steps.gate.outputs.approved == 'true' env: + TARGET_ENVIRONMENT: ${{ inputs.target_environment }} + RELEASE_PROFILE: ${{ inputs.release_profile }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_READ_TOKEN: ${{ secrets.CLOUDFLARE_READ_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -146,6 +162,12 @@ jobs: test -n "$CLOUDFLARE_ACCOUNT_ID" [[ "$EXPECTED_CONFIG_SHA" =~ ^[a-f0-9]{64}$ ]] [[ "$(sha256sum "$config" | cut -d " " -f1)" == "$EXPECTED_CONFIG_SHA" ]] + scratch="$RUNNER_TEMP/agent-table-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + # Recheck the private window immediately before the write, including + # when an earlier step completed shortly before its expiry. + node scripts/require-release-approval.mjs \ + "$TARGET_ENVIRONMENT" "$GITHUB_SHA" "$EXPECTED_CONFIG_SHA" "$RELEASE_PROFILE" \ + < "$scratch/approval.txt" # Recheck the latest main head immediately before any write. node --input-type=module -e ' import { githubReader, verifyCurrentMainHead } from "./scripts/release-authorization.mjs"; diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 5cc2174..950de2a 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -1,4 +1,4 @@ -name: Staging deployment (manual, protected) +name: Staging deployment (protected) on: workflow_dispatch: @@ -7,6 +7,14 @@ on: description: Exact reviewed main commit SHA (40 lowercase hex digits) required: true type: string + release_profile: + description: Paused manual release or separately reviewed A-only private verification + required: true + type: choice + default: manual + options: + - manual + - private-pilot-a-only bootstrap_staging: description: First-time staging identity bootstrap; disables new guest/table intake and preserves existing DO/KV type: boolean @@ -28,6 +36,7 @@ jobs: with: target_environment: staging expected_sha: ${{ inputs.expected_sha }} + release_profile: ${{ inputs.release_profile }} bootstrap_staging: ${{ inputs.bootstrap_staging }} expected_active_version_id: ${{ inputs.expected_active_version_id }} secrets: inherit diff --git a/docs/compatibility/private-pilot-activation.md b/docs/compatibility/private-pilot-activation.md new file mode 100644 index 0000000..ed58fdb --- /dev/null +++ b/docs/compatibility/private-pilot-activation.md @@ -0,0 +1,223 @@ +# Private A-only staging activation + +**Implementation candidate; no hosted pilot deployment or activation approval +is recorded by this document.** The `private-pilot-a-only` profile provides a +bounded staging trial in which one selected ChatGPT connection can subscribe +and receive peer events. A second independently authorized agent may be +manually prompted to post. This can gather the missing event-to-host-run proof +for [Issue #24](https://github.com/kitamura-tetsuo/agent-table/issues/24); it does +not require that proof before permitting a specifically approved trial. +It makes no public Events or autonomous-compatibility claim. + +Production remains `manual`. The existing protected manual release behavior, +OAuth/GuestDO authority, TableDO membership/lifecycle checks, encrypted +subscription credentials, delivery generations and durable retry accounting +remain authoritative. A policy reference alone cannot authorize a deployment, +create a guest grant or grant table membership. + +## Runtime policy + +The shared parser and eligibility predicates live in +[`src/shared/private-pilot.ts`](../../src/shared/private-pilot.ts). Configure +these bindings only in a separately reviewed staging candidate: + +| Binding | Required value or role | +| --- | --- | +| `RELEASE_ENVIRONMENT` | `staging` | +| `RELEASE_PROFILE` | `private-pilot-a-only` | +| `RELEASE_BUILD_SHA` | Exact reviewed 40-character lowercase Git SHA | +| `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 | +| `PRIVATE_PILOT_POLICY` | Exact serialized JSON string described below | + +`PRIVATE_PILOT_POLICY` has exactly these fields: + +| Field | Constraint | +| --- | --- | +| `schemaVersion`, `mode` | `1`, `a-only` | +| `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 | + +The following is **synthetic schema illustration only**, not an approved or +currently usable deployment policy: + +```json +{ + "schemaVersion": 1, + "mode": "a-only", + "buildSha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "approvedAt": "2026-11-12T11:59:00.000Z", + "notBefore": "2026-11-12T12:00:00.000Z", + "expiresAt": "2026-11-12T14:00:00.000Z", + "approvalRef": "example-owner-approval", + "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" } + ] +} +``` + +Store the serialized policy as a string in the candidate's `vars`. Keep its +exact bytes: the policy digest and full configuration digest bind the approved +candidate, and post-deploy checks compare the actual policy bytes rather than +JSON semantic equivalence. The policy contains private routing/identity +metadata even though it contains no bearer or signing secret. Retain it in the +protected operator/configuration channel; do not copy it into public receipts. + +An eligible trial requires the active window and outbound switch, exact +recipient/connection, listed table and reviewed callback. Only that +freshly authorized recipient sees private Events discovery and its own +availability. Registration and dispatch independently recheck current policy, +guest/connection scope and table/subscription authority. Another connection +of the same guest, B's connection or an unrelated guest gets no new privilege. +The current canary dispatch bound permits only message sequences up to 12 and +work created no earlier than the policy window; it does not reset table limits +or permit replay of older queued events. Ordinary authorized tools remain +subject to their existing scopes and membership checks. + +The global `callbackTransportReadiness()` result and public feature flags +remain disabled. The private branch reuses the reviewed native sender and +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. + +## Prepare a concrete, reviewable candidate + +1. Confirm two independently controlled owners/accounts and their separate + synthetic-only instructions. Select the actual receiving ChatGPT Work + surface, its supported event/run observation and Claude's tools connection. + Product names alone establish none of those permissions or capabilities. +2. Establish the synthetic memberships and receiving connection through the + independently approved manual setup. Identify the exact primary and + 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 + 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 + the existing [release runbook](../release-runbook.md). Preserve namespace, + migration, origin and secret separation. Keep the production peer manual. + Use `tee` with a quoted heredoc when creating operator text files, and store + credential values through the approved secret mechanism rather than chat. +5. Bind the owner-reviewed operations record and structural evidence to the + same candidate SHA/config bytes, then request the concrete protected staging + approval. Do not activate merely because local tests or this document pass. + +All ordinary retention, cost, schema, resource-ownership and independently +reviewed **no-restore** fields from the release process still apply. Add an +`operations.privatePilot` record containing: + +| Field | Meaning | +| --- | --- | +| `mode` | `a-only` | +| `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 | +| `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 +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. + +## Protected deployment path + +Only after the prerequisite implementation is on exact `main` with successful +required CI, use the protected **Staging deployment** workflow +([`deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)). Select +`release_profile=private-pilot-a-only`, the reviewed `expected_sha`, and +`bootstrap_staging=false`. This is not a first-time namespace bootstrap and +must not be combined with the legacy staging-identity exception. + +The reusable [reviewed workflow](../../.github/workflows/_deploy-reviewed.yml) +retains the Environment approval and stages: + +1. Independently verify the requested `main` SHA and run local gates. +2. Validate exact candidate/peer configuration bytes and dry-run the build + before exposing deployment credentials. +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. +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 + manifest cannot be reused to start the trial. +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. + +Both service settings and active-version bindings must contain the same exact +policy as the candidate. Missing, duplicated, secret-typed or divergent policy +bindings fail verification. A previous manual deployment may omit the policy +during the pre-deploy readback; the resulting private deployment must contain +it. If the reviewed return-to-manual candidate removes the policy, retaining +it on either provider surface is an error. Redacted receipts contain no raw +recipient/table IDs or callback targets. + +A post-verification success means the reviewed configuration and resource +identity were observed. It does not prove a callback was received or a model +ran. The actual B-post→A-event→A-run→authenticated-read→committed-reply trace +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 +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. + +For an operator stop, prepare a new exact candidate with outbound disabled, +retain the valid policy and identity bindings, and obtain the required +protected configuration-change approval. The authorization path permits this +stopped configuration even after its old window expires; it does not restart +its effects. For table-local termination, use the existing authorized closure +or unsubscribe path and record the actual terminal decision. Requests already +dispatched cannot be recalled; distinguish them from work newly authorized +after the decision. + +A subsequent return to `manual` must disable all notification flags and remove +the private policy in the exact reviewed configuration and both provider +readbacks. Never broaden the policy or extend its window as an uncertain retry. +New authority requires a new concrete owner decision and bound release record. + +A no-restore service restart retains the same current TableDO/GuestDO/KV state, +subscriptions, retry budgets and terminal records. It is not historical PITR +or a stale snapshot restoration. Issue #51's independent post-snapshot +reconciliation remains separate; this profile neither implements nor clears +restore quarantine. New tables, replacement grants, reset retry state or +recreated subscriptions cannot prove retained-state recovery. + +## Verification boundaries + +Focused deterministic checks cover the shared policy, scoped runtime entry +points and protected release/readback path: + +```sh +npx vitest run test/unit/private-pilot.test.ts +node --test scripts/release-preflight.test.mjs \ + scripts/release-authorization.test.mjs \ + scripts/require-release-approval.test.mjs \ + scripts/release-provider.test.mjs scripts/release-postverify.test.mjs +``` + +Run the repository's typecheck, lint, Worker integration and browser gates for +the candidate as well. Passing these tests is implementation evidence. No +account consent, callback-control success, hosted deployment or autonomous +ChatGPT response is supplied by a synthetic fixture or a passing release test. diff --git a/docs/release-preflight.md b/docs/release-preflight.md index 66e015d..847ea4b 100644 --- a/docs/release-preflight.md +++ b/docs/release-preflight.md @@ -1,6 +1,11 @@ # Release configuration preflight (Issue #22 / Issue #50) -This script is a fail-closed **offline structural preflight**, not an independent proof of evidence or permission to expose private state. The separate manual-only, protected-environment [authenticated release workflow](release-runbook.md) adds exact-main CI verification, fresh independently reviewed operator disclosures, live Cloudflare resource/secret/domain/version readback, approval and post-deploy verification. A signed-off PR or offline `PREFLIGHT_MANIFEST_VALID` cannot authorize a deployment. Production infrastructure, credentials, approvals and real-host evidence must still be supplied and executed separately; this document does not claim they exist. Reconciliation-based restore-quarantine **clearance** remains unimplemented and belongs to Issue #51. Do not use this script's output to enable Events/autonomous functionality. +This script is a fail-closed **offline structural preflight**, not an independent proof of evidence or permission to expose private state. The separate protected-environment [authenticated release workflow](release-runbook.md) adds exact-main CI verification, fresh independently reviewed operator disclosures, live Cloudflare resource/secret/domain/version readback, approval and post-deploy verification. A signed-off PR or offline `PREFLIGHT_MANIFEST_VALID` cannot authorize a deployment. Production infrastructure, credentials, approvals and real-host evidence must still be supplied and executed separately; this document does not claim they exist. Reconciliation-based restore-quarantine **clearance** remains unimplemented and belongs to Issue #51. Do not use this script's output alone to enable Events/autonomous functionality. + +The staging-only [`private-pilot-a-only` verification profile](compatibility/private-pilot-activation.md) +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. ## Boundaries @@ -62,7 +67,7 @@ The command requires the operator-supplied counterpart configuration and checks - Separate manual staging/production workflows require the exact main SHA and a selected GitHub Environment. GitHub API readback on 2026-11-11 UTC confirmed required owner reviewers and protected-branch restrictions for **both** Environments; this is historical configuration evidence, not an approval of a deployment. The [protected staging credential check #42969129958](https://github.com/kitamura-tetsuo/agent-table/actions/runs/42969129958) verified staging-only read access to settings, **not** full release resource ownership or promotion. - The protected workflow's **authenticated** `release-authorization.mjs` now emits a short-lived in-job `RELEASE_APPROVAL_VALID` only after exact-main CI provenance and live Cloudflare readback of both environments. `require-release-approval.mjs` must verify that manifest before publishing. Missing read tokens, production configuration, named secret inventory, selected domain, independently verified retention/usage disclosures, and required profile evidence remain **blocked**, rather than being replaced by fabricated metadata. See [release-runbook.md](release-runbook.md) for required Environment secret names, operator JSON schemas, current scope and the exact approval sequence. -- Only the `manual` profile is implemented in the authenticated gate. New `live`, Events and autonomous host validation and **independently reconciled restore-quarantine release** remain blocked. Future DO-only outbound producers must apply the independent dispatch-time guard too. A restored copy of an older successful operator record does not clear missing post-snapshot revocations/deletions/expiry. +- The authenticated gate supports `manual` and separately approved staging-only `private-pilot-a-only` verification. Public `live`, Events and autonomous releases and **independently reconciled restore-quarantine release** remain blocked. The private trial rechecks its exact recipient/table/callback scope, finite window and stop switch before new effects. Future DO-only outbound producers must apply the independent dispatch-time guard too. A restored copy of an older successful operator record does not clear missing post-snapshot revocations/deletions/expiry. - A separately approved protected staging bootstrap and same-source, same-config, ID-pinned rollback **did** preserve retained synthetic Durable Object data [on 2026-11-12](https://github.com/kitamura-tetsuo/agent-table/issues/50#issuecomment-6815095095). diff --git a/docs/release-runbook.md b/docs/release-runbook.md index eb2c86a..47cc3ce 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -2,7 +2,7 @@ ## Status and hard safety boundaries -The repository contains an **authenticated, manual-only release gate**. It +The repository contains an **authenticated, protected release gate**. It checks the exact `main` commit, a successful independently fetched CI run, fresh operator disclosures, the protected GitHub Environment, and actual Cloudflare Worker, SQLite Durable Object, KV, secret-name, domain and deployed @@ -21,10 +21,15 @@ release has not run**. Account-specific backup/log retention, billed storage and pricing/usage acceptance are still unresolved. These are distinct gates; the checked-in `wrangler.jsonc` is not a hosted release candidate. -The authenticated gate initially supports **`manual` only**. Every production -or staging release must have `RESTORE_QUARANTINE=false` only for a verified -non-restore release, with subscriptions, Events and outbound notifications -all explicitly `false`. `live`, `events` and `autonomous` are not reachable by +The authenticated gate supports **`manual`** in staging and production, with +subscriptions, Events and outbound notifications all explicitly `false`. +The separate staging-only [private A-only verification profile](compatibility/private-pilot-activation.md) +binds a finite trial to one receiving connection, selected synthetic tables, +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. +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 unknown post-snapshot revocations/deletions/expiries remain quarantined until diff --git a/scripts/release-authorization.mjs b/scripts/release-authorization.mjs index 6bc4244..722620e 100644 --- a/scripts/release-authorization.mjs +++ b/scripts/release-authorization.mjs @@ -1,5 +1,11 @@ +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { + activePrivatePilotPolicy, + PRIVATE_PILOT_PROFILE, + privatePilotConfiguration, +} from "../src/shared/private-pilot.ts"; import { validateRelease } from "./release-preflight.mjs"; import { cloudflareReader, @@ -212,10 +218,42 @@ export function verifyOperationalDecision( ); } +/** Bind private verification to a separately reviewed, finite policy. */ +export function verifyPrivatePilotDecision(record, vars, now) { + const policy = privatePilotConfiguration(vars); + const deliveryEnabled = vars.ALLOW_OUTBOUND_NOTIFICATIONS === "true"; + requireDecision( + policy && (!deliveryEnabled || activePrivatePilotPolicy(vars, now)), + "private-pilot-not-active", + ); + const policySha256 = createHash("sha256") + .update(vars.PRIVATE_PILOT_POLICY) + .digest("hex"); + const decision = record?.privatePilot; + requireDecision( + decision?.mode === "a-only" && + decision.policySha256 === policySha256 && + decision.approvalRef === policy.approvalRef && + decision.independentOwnerConsentsReviewed === true && + decision.callbackControlReviewed === true && + decision.nativeFetchRiskAccepted === true && + decision.boundedCanaryReviewed === true && + decision.stopPlanReviewed === true, + "private-pilot-scope-not-approved", + ); + return { + mode: "a-only", + policySha256, + notBefore: policy.notBefore, + expiresAt: policy.expiresAt, + deliveryEnabled, + }; +} + /** - * Only the protected, manual, exact-main GitHub workflow can consume the - * resulting in-job approval. Other profiles stay blocked until real-host - * evidence can be independently checked, not merely flagged as passing. + * Only the protected exact-main workflow can consume this approval. Private + * A-only staging verification gathers missing host evidence; public capability + * profiles remain blocked until their independently observed evidence exists. */ export async function authorizeRelease({ environment, @@ -253,9 +291,14 @@ export async function authorizeRelease({ "unprotected-release-context", ); requireDecision( - profile === "manual", + profile === "manual" || + (profile === PRIVATE_PILOT_PROFILE && environment === "staging"), "profile-real-host-evidence-not-verified", ); + requireDecision( + profile !== PRIVATE_PILOT_PROFILE || !bootstrapStaging, + "private-pilot-cannot-bootstrap", + ); requireDecision( !bootstrapStaging || (environment === "staging" && @@ -281,7 +324,8 @@ export async function authorizeRelease({ config.vars.RESTORE_QUARANTINE === "false" && config.vars.ENABLE_MCP_SUBSCRIPTIONS === "false" && config.vars.ENABLE_OPENAI_EVENTS === "false" && - config.vars.ALLOW_OUTBOUND_NOTIFICATIONS === "false", + (profile === PRIVATE_PILOT_PROFILE || + config.vars.ALLOW_OUTBOUND_NOTIFICATIONS === "false"), "unsupported-capability-or-quarantine-clearance", ); if (config.vars.ALLOW_NEW_GUESTS === "true") { @@ -301,6 +345,10 @@ export async function authorizeRelease({ now, bootstrapStaging, }); + const privatePilot = + profile === PRIVATE_PILOT_PROFILE + ? verifyPrivatePilotDecision(operations, config.vars, now) + : undefined; const [ciRunId, observed] = await Promise.all([ verifyCiEvidence(evidence, buildSha, githubRead), readCloudflareDeployment(config, peer, cloudflareRead, { @@ -350,6 +398,7 @@ export async function authorizeRelease({ ); return { ...offline, + ...(privatePilot ? { privatePilot } : {}), bootstrapStaging, deploymentApproved: true, approvalSource: "protected-environment-and-live-provider-readback", diff --git a/scripts/release-authorization.test.mjs b/scripts/release-authorization.test.mjs index 6421a45..2149734 100644 --- a/scripts/release-authorization.test.mjs +++ b/scripts/release-authorization.test.mjs @@ -5,6 +5,7 @@ import { buildSha, makeFixture, makeGithubMock, + makePrivatePilotFixture, makeProviderMock, now, runId, @@ -49,6 +50,126 @@ test("protected exact-main run plus independent provider and CI reads authorize ); }); +test("private A-only staging requires the same protected main and CI gates without claiming a host pass", async () => { + const { fixture, cloudflare, github } = harness(makePrivatePilotFixture()); + const approval = await authorizeRelease({ + ...fixture, + cloudflareRead: cloudflare.read, + githubRead: github.read, + now, + }); + assert.equal(approval.profile, "private-pilot-a-only"); + assert.equal(approval.deploymentApproved, true); + assert.deepEqual(approval.privatePilot, { + mode: "a-only", + policySha256: fixture.operations.privatePilot.policySha256, + notBefore: new Date(now - 30_000).toISOString(), + expiresAt: new Date(now + 60 * 60_000).toISOString(), + deliveryEnabled: true, + }); + assert.equal(approval.completeRecord, undefined); + assert.doesNotMatch( + JSON.stringify(approval), + /recipientGuestId|recipientConnectionId|callbackTargets|receiver\.example/, + ); + for (const patch of [ + { ref: "refs/heads/pilot" }, + { runAttempt: "2" }, + { eventName: "pull_request" }, + ]) { + await assert.rejects( + authorizeRelease({ + ...fixture, + workflow: { ...fixture.workflow, ...patch }, + cloudflareRead: cloudflare.read, + githubRead: github.read, + now, + }), + /unprotected-release-context|deployment-review-attempt-unverifiable/, + ); + } +}); + +test("private activation refuses missing or changed owner scope and inactive windows", async () => { + for (const mutate of [ + (value) => { + value.operations.privatePilot = undefined; + }, + (value) => { + value.operations.privatePilot.policySha256 = "b".repeat(64); + }, + (value) => { + value.operations.privatePilot.independentOwnerConsentsReviewed = false; + }, + (value) => { + value.operations.privatePilot.callbackControlReviewed = false; + }, + (value) => { + value.operations.privatePilot.nativeFetchRiskAccepted = false; + }, + (value) => { + value.operations.privatePilot.stopPlanReviewed = false; + }, + ]) { + const { fixture, cloudflare, github } = harness(makePrivatePilotFixture()); + mutate(fixture); + 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 { fixture, cloudflare, github } = harness(makePrivatePilotFixture()); + fixture.operations.review.reviewedAt = new Date(timestamp).toISOString(); + for (const disclosure of Object.values(fixture.operations.retention)) { + disclosure.reviewedAt = new Date(timestamp).toISOString(); + } + await assert.rejects( + authorizeRelease({ + ...fixture, + cloudflareRead: cloudflare.read, + githubRead: github.read, + now: timestamp, + }), + /private-pilot-not-active/, + ); + } + const { fixture, cloudflare, github } = harness(makePrivatePilotFixture()); + await assert.rejects( + authorizeRelease({ + ...fixture, + bootstrapStaging: true, + cloudflareRead: cloudflare.read, + githubRead: github.read, + now, + }), + /private-pilot-cannot-bootstrap/, + ); +}); + +test("an approved private kill-switch configuration can be deployed after its send window", async () => { + const fixture = makePrivatePilotFixture(); + const config = JSON.parse(fixture.configText); + config.vars.ALLOW_OUTBOUND_NOTIFICATIONS = "false"; + fixture.configText = JSON.stringify(config); + const digest = createHash("sha256").update(fixture.configText).digest("hex"); + fixture.evidence.configSha256 = fixture.operations.configSha256 = digest; + const cloudflare = makeProviderMock(config, JSON.parse(fixture.peerText)); + const approval = await authorizeRelease({ + ...fixture, + cloudflareRead: cloudflare.read, + githubRead: makeGithubMock().read, + now: now + 2 * 60 * 60_000, + }); + assert.equal(approval.deploymentApproved, true); + assert.equal(approval.privatePilot.deliveryEnabled, false); +}); + function legacyStagingHarness() { const fixture = makeFixture(); const config = JSON.parse(fixture.configText); diff --git a/scripts/release-postverify.mjs b/scripts/release-postverify.mjs index 94a5019..4bd8238 100644 --- a/scripts/release-postverify.mjs +++ b/scripts/release-postverify.mjs @@ -1,5 +1,10 @@ +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { + PRIVATE_PILOT_PROFILE, + privatePilotConfiguration, +} from "../src/shared/private-pilot.ts"; import { cloudflareReader, ProviderVerificationError, @@ -12,12 +17,60 @@ function requirePost(value, code) { } export async function verifyPublishedRelease({ - config, + configText, peer, approval, cloudflareRead, httpFetch = fetch, }) { + let config; + try { + requirePost(typeof configText === "string", "post-deploy-config-invalid"); + config = JSON.parse(configText); + } catch { + throw new ProviderVerificationError("post-deploy-config-invalid"); + } + requirePost( + config && + typeof config === "object" && + !Array.isArray(config) && + config.vars && + typeof config.vars === "object" && + !Array.isArray(config.vars), + "post-deploy-config-invalid", + ); + requirePost( + approval?.deploymentApproved === true && + approval.environment === config?.vars?.RELEASE_ENVIRONMENT && + approval.buildSha === config?.vars?.RELEASE_BUILD_SHA && + approval.profile === config?.vars?.RELEASE_PROFILE && + approval.configSha256 === + createHash("sha256").update(configText).digest("hex"), + "post-deploy-approval-target-mismatch", + ); + const profile = config.vars.RELEASE_PROFILE; + const privatePolicy = + profile === PRIVATE_PILOT_PROFILE + ? privatePilotConfiguration(config.vars) + : null; + requirePost( + profile === "manual" || privatePolicy, + "post-deploy-profile-unverified", + ); + if (privatePolicy) { + requirePost( + approval.privatePilot?.mode === "a-only" && + approval.privatePilot.policySha256 === + createHash("sha256") + .update(config.vars.PRIVATE_PILOT_POLICY) + .digest("hex") && + approval.privatePilot.notBefore === privatePolicy.notBefore && + approval.privatePilot.expiresAt === privatePolicy.expiresAt && + approval.privatePilot.deliveryEnabled === + (config.vars.ALLOW_OUTBOUND_NOTIFICATIONS === "true"), + "post-deploy-private-pilot-approval-mismatch", + ); + } const state = await readCloudflareDeployment(config, peer, cloudflareRead); verifyDeployedConfiguration(state.targetState, config); const before = approval.providerReceipt; @@ -103,16 +156,10 @@ async function main() { const approval = JSON.parse( approvalText.slice("RELEASE_APPROVAL_VALID ".length), ); - const config = JSON.parse(readFileSync(cliArg("config"), "utf8")); + const configText = readFileSync(cliArg("config"), "utf8"); const peer = JSON.parse(readFileSync(cliArg("peer-config"), "utf8")); - requirePost( - approval.deploymentApproved === true && - approval.environment === config.vars.RELEASE_ENVIRONMENT && - approval.buildSha === config.vars.RELEASE_BUILD_SHA, - "post-deploy-approval-target-mismatch", - ); const result = await verifyPublishedRelease({ - config, + configText, peer, approval, cloudflareRead: cloudflareReader(process.env.CLOUDFLARE_READ_TOKEN), diff --git a/scripts/release-postverify.test.mjs b/scripts/release-postverify.test.mjs index 6b61793..01f5cb4 100644 --- a/scripts/release-postverify.test.mjs +++ b/scripts/release-postverify.test.mjs @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { test } from "node:test"; import { makeConfig, + makePrivatePilotFixture, makeProviderMock, } from "../test/helpers/release-fixtures.mjs"; import { verifyPublishedRelease } from "./release-postverify.mjs"; @@ -12,49 +14,81 @@ import { const config = makeConfig("staging"); const peer = makeConfig("production"); +const hash = (text) => createHash("sha256").update(text).digest("hex"); -async function harness() { - const provider = makeProviderMock(config, peer); - const observed = await readCloudflareDeployment(config, peer, provider.read); +async function harness(candidate = config, peerConfig = peer) { + const configText = JSON.stringify(candidate); + const provider = makeProviderMock(candidate, peerConfig); + const observed = await readCloudflareDeployment( + candidate, + peerConfig, + provider.read, + ); + const policy = candidate.vars.PRIVATE_PILOT_POLICY + ? JSON.parse(candidate.vars.PRIVATE_PILOT_POLICY) + : null; + const approval = { + deploymentApproved: true, + environment: candidate.vars.RELEASE_ENVIRONMENT, + buildSha: candidate.vars.RELEASE_BUILD_SHA, + profile: candidate.vars.RELEASE_PROFILE, + configSha256: hash(configText), + ...(policy + ? { + privatePilot: { + mode: "a-only", + policySha256: hash(candidate.vars.PRIVATE_PILOT_POLICY), + notBefore: policy.notBefore, + expiresAt: policy.expiresAt, + deliveryEnabled: + candidate.vars.ALLOW_OUTBOUND_NOTIFICATIONS === "true", + }, + } + : {}), + providerReceipt: redactedProviderReceipt( + observed.targetState, + candidate.vars.RELEASE_ENVIRONMENT, + "2026-11-11T12:00:00Z", + ), + }; + const httpFetch = async () => + Response.json( + { + status: "ok", + release: { + environment: candidate.vars.RELEASE_ENVIRONMENT, + profile: candidate.vars.RELEASE_PROFILE, + buildSha: candidate.vars.RELEASE_BUILD_SHA, + }, + }, + { headers: { "Cache-Control": "no-store" } }, + ); return { provider, - approval: { - providerReceipt: redactedProviderReceipt( - observed.targetState, - "staging", - "2026-11-11T12:00:00Z", - ), + options: { + configText, + peer: peerConfig, + approval, + cloudflareRead: provider.read, + httpFetch, }, - httpFetch: async () => - Response.json( - { - status: "ok", - release: { - environment: "staging", - profile: "manual", - buildSha: config.vars.RELEASE_BUILD_SHA, - }, - }, - { headers: { "Cache-Control": "no-store" } }, - ), }; } -test("postdeploy verifies the same SQL DO/KV resources, exact flags and HTTPS build receipt", async () => { - const { provider, approval, httpFetch } = await harness(); - const result = await verifyPublishedRelease({ - config, - peer, - approval, - cloudflareRead: provider.read, - httpFetch, - }); +function privateConfig() { + return JSON.parse(makePrivatePilotFixture().configText); +} + +test("postdeploy verifies manual SQL DO/KV resources, exact config bytes and HTTPS build receipt", async () => { + const { options } = await harness(); + const result = await verifyPublishedRelease(options); assert.equal(result.verified, true); + assert.equal(result.profile, "manual"); assert.equal(result.buildSha, config.vars.RELEASE_BUILD_SHA); }); test("changed private namespace refuses success after a seemingly healthy release", async () => { - const { provider, approval, httpFetch } = await harness(); + const { provider, options } = await harness(); const path = [...provider.routes.keys()].find((x) => x.endsWith("/agent-table-staging/settings"), ); @@ -78,13 +112,7 @@ test("changed private namespace refuses success after a seemingly healthy releas (entry) => entry.script === config.name && entry.class === "TableDO", ).id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; await assert.rejects( - verifyPublishedRelease({ - config, - peer, - approval, - cloudflareRead: provider.read, - httpFetch, - }), + verifyPublishedRelease(options), /post-deploy-resource-identity-changed/, ); }); @@ -105,16 +133,160 @@ test("wrong public build, bad HTTP response or unavailable origin blocks postdep throw Error("simulated TLS refusal"); }, ]) { - const { provider, approval } = await harness(); + const { options } = await harness(); await assert.rejects( - verifyPublishedRelease({ - config, - peer, - approval, - cloudflareRead: provider.read, - httpFetch, - }), + verifyPublishedRelease({ ...options, httpFetch }), /post-deploy-build-not-observed|post-deploy-health-unverified|post-deploy-health-oversized|post-deploy-origin-unreachable/, ); } }); + +test("private staging postverify binds exact policy and exposes only safe release identity", async () => { + const candidate = privateConfig(); + const { options } = await harness(candidate); + const result = await verifyPublishedRelease(options); + assert.equal(result.verified, true); + assert.equal(result.profile, "private-pilot-a-only"); + const serialized = JSON.stringify(result); + const policy = JSON.parse(candidate.vars.PRIVATE_PILOT_POLICY); + for (const privateValue of [ + policy.recipientGuestId, + policy.recipientConnectionId, + ...policy.tableIds, + policy.callbackTargets[0].origin, + ]) { + assert.equal(serialized.includes(privateValue), false); + } +}); + +test("postverify refuses mismatched approval, profile or exact configuration bytes before any request", async () => { + const changes = [ + (options) => { + options.approval.deploymentApproved = false; + }, + (options) => { + options.approval.profile = "private-pilot-a-only"; + }, + (options) => { + options.approval.environment = "production"; + }, + (options) => { + options.approval.buildSha = "b".repeat(40); + }, + (options) => { + options.approval.configSha256 = "b".repeat(64); + }, + (options) => { + options.configText += "\n"; + }, + ]; + for (const change of changes) { + const { options } = await harness(); + change(options); + let requests = 0; + options.cloudflareRead = async () => { + requests += 1; + throw Error("must not be reached"); + }; + options.httpFetch = options.cloudflareRead; + await assert.rejects( + verifyPublishedRelease(options), + /post-deploy-approval-target-mismatch/, + ); + assert.equal(requests, 0); + } +}); + +test("the shared private policy parser rejects malformed, out-of-scope or inconsistent configurations", async () => { + const mutations = [ + (candidate) => { + candidate.vars.PRIVATE_PILOT_POLICY = "{}"; + }, + (candidate) => { + candidate.vars.ENABLE_OPENAI_EVENTS = "true"; + }, + (candidate) => { + candidate.vars.RELEASE_BUILD_SHA = "b".repeat(40); + }, + (candidate) => { + candidate.vars.RESTORE_QUARANTINE = "true"; + }, + (candidate) => { + candidate.vars.RELEASE_ENVIRONMENT = "production"; + }, + ]; + for (const mutate of mutations) { + const candidate = privateConfig(); + const { options } = await harness(candidate); + mutate(candidate); + options.configText = JSON.stringify(candidate); + options.approval.configSha256 = hash(options.configText); + options.approval.environment = candidate.vars.RELEASE_ENVIRONMENT; + options.approval.buildSha = candidate.vars.RELEASE_BUILD_SHA; + let requests = 0; + options.cloudflareRead = async () => { + requests += 1; + throw Error("must not be reached"); + }; + await assert.rejects( + verifyPublishedRelease(options), + /post-deploy-profile-unverified/, + ); + assert.equal(requests, 0); + } +}); + +test("expiry or the outbound kill switch retains configured read/health/close verification", async () => { + for (const enabled of ["true", "false"]) { + const candidate = privateConfig(); + candidate.vars.ALLOW_OUTBOUND_NOTIFICATIONS = enabled; + // The fixed fixture window is expired; configuration readback is not a claim of live effects. + const { options } = await harness(candidate); + assert.equal((await verifyPublishedRelease(options)).verified, true); + } +}); + +test("exact deployment readback refuses an orphan policy after a requested manual removal", async () => { + const { options, provider } = await harness(); + const policy = privateConfig().vars.PRIVATE_PILOT_POLICY; + const prefix = `/client/v4/accounts/${config.account_id}/workers/scripts/${config.name}`; + provider.routes.get(`${prefix}/settings`).bindings.push({ + name: "PRIVATE_PILOT_POLICY", + type: "plain_text", + text: policy, + }); + provider.routes + .get(`${prefix}/versions/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb`) + .resources.bindings.push({ + name: "PRIVATE_PILOT_POLICY", + type: "plain_text", + text: policy, + }); + await assert.rejects( + verifyPublishedRelease(options), + /provider-deployed-private-pilot-policy-mismatch/, + ); +}); + +test("postverify refuses changed private activation bounds despite matching config approval", async () => { + for (const [field, value] of [ + ["mode", "bidirectional"], + ["policySha256", "b".repeat(64)], + ["notBefore", "2026-11-10T00:00:00.000Z"], + ["expiresAt", "2026-11-12T00:00:00.000Z"], + ["deliveryEnabled", false], + ]) { + const { options } = await harness(privateConfig()); + options.approval.privatePilot[field] = value; + let requests = 0; + options.cloudflareRead = async () => { + requests += 1; + throw Error("must not be reached"); + }; + await assert.rejects( + verifyPublishedRelease(options), + /post-deploy-private-pilot-approval-mismatch/, + ); + assert.equal(requests, 0); + } +}); diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index 994f6a2..2352429 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -2,6 +2,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { isIP } from "node:net"; import { pathToFileURL } from "node:url"; +import { + PRIVATE_PILOT_PROFILE, + privatePilotConfiguration, +} from "../src/shared/private-pilot.ts"; const EXPECTED_DOS = new Map([ ["TABLES", "TableDO"], @@ -80,9 +84,18 @@ const REQUIRED_SECRETS = [ ]; const ACCOUNT_ID = /^[a-f0-9]{32}$/i; const BUILD_SHA = /^[a-f0-9]{40}$/; -const RELEASE_PROFILES = new Set(["manual", "live", "events", "autonomous"]); +const RELEASE_PROFILES = new Set([ + "manual", + "live", + "events", + "autonomous", + PRIVATE_PILOT_PROFILE, +]); const EVIDENCE_CHECKS = { manual: ["application", "security", "browser"], + // This isolated verification profile gathers real-host evidence; it does not + // advertise that the evidence already exists or authorize a public release. + [PRIVATE_PILOT_PROFILE]: ["application", "security", "browser"], live: ["application", "security", "browser", "live_runtime"], events: [ "application", @@ -299,6 +312,12 @@ function validatePeerIsolation(config, peer, environment) { RELEASE_PROFILES.has(peer?.vars?.RELEASE_PROFILE), "peer-release-identity-missing", ); + requireCondition( + peer.vars.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE + ? peerEnvironment === "staging" && privatePilotConfiguration(peer.vars) + : peer.vars.PRIVATE_PILOT_POLICY === undefined, + "peer-private-pilot-configuration-invalid", + ); requireCondition( [ "ENABLE_MCP_SUBSCRIPTIONS", @@ -402,12 +421,24 @@ export function validateRelease({ const enabledEvents = vars.ENABLE_OPENAI_EVENTS === "true" || vars.ALLOW_OUTBOUND_NOTIFICATIONS === "true"; + if (profile === PRIVATE_PILOT_PROFILE) { + requireCondition( + environment === "staging" && privatePilotConfiguration(vars), + "invalid-private-pilot-configuration", + ); + } else { + requireCondition( + vars.PRIVATE_PILOT_POLICY === undefined, + "private-pilot-policy-outside-private-profile", + ); + } requireCondition( !enabledLive || ["live", "events", "autonomous"].includes(profile), "live-switch-without-live-evidence", ); requireCondition( - !enabledEvents || ["events", "autonomous"].includes(profile), + !enabledEvents || + ["events", "autonomous", PRIVATE_PILOT_PROFILE].includes(profile), "events-switch-without-events-evidence", ); verifyEvidence(evidence, { environment, buildSha, configSha256, profile }); diff --git a/scripts/release-preflight.test.mjs b/scripts/release-preflight.test.mjs index 8dbfe7b..caf8463 100644 --- a/scripts/release-preflight.test.mjs +++ b/scripts/release-preflight.test.mjs @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import { makePrivatePilotFixture } from "../test/helpers/release-fixtures.mjs"; import { PreflightError, prepareQuarantinedRestoreConfig, @@ -19,6 +20,73 @@ import { } from "./release-preflight.mjs"; const buildSha = "a".repeat(40); + +test("private staging preflight binds scope without claiming prior autonomous compatibility", () => { + const candidate = makePrivatePilotFixture(); + const result = validateRelease({ + ...candidate, + secrets: candidate.inventory, + }); + assert.equal(result.preflightValid, true); + assert.equal(result.deploymentApproved, false); + assert.equal(result.profile, "private-pilot-a-only"); + assert.equal(candidate.evidence.checks.real_host, undefined); + for (const mutate of [ + (config) => { + delete config.vars.PRIVATE_PILOT_POLICY; + }, + (config) => { + config.vars.ENABLE_OPENAI_EVENTS = "true"; + }, + (config) => { + config.vars.ENABLE_MCP_SUBSCRIPTIONS = "true"; + }, + (config) => { + config.vars.RESTORE_QUARANTINE = "true"; + }, + (config) => { + const policy = JSON.parse(config.vars.PRIVATE_PILOT_POLICY); + policy.buildSha = "b".repeat(40); + config.vars.PRIVATE_PILOT_POLICY = JSON.stringify(policy); + }, + ]) { + const broken = structuredClone(candidate); + const config = JSON.parse(broken.configText); + mutate(config); + broken.configText = JSON.stringify(config); + assert.throws( + () => validateRelease({ ...broken, secrets: broken.inventory }), + /invalid-private-pilot-configuration/, + ); + } +}); + +test("private policy cannot be smuggled into manual or production release profiles", () => { + const candidate = makePrivatePilotFixture(); + const config = JSON.parse(candidate.configText); + candidate.profile = config.vars.RELEASE_PROFILE = "manual"; + config.vars.ALLOW_OUTBOUND_NOTIFICATIONS = "false"; + candidate.configText = JSON.stringify(config); + assert.throws( + () => validateRelease({ ...candidate, secrets: candidate.inventory }), + /private-pilot-policy-outside-private-profile/, + ); + const production = makePrivatePilotFixture(); + const target = JSON.parse(production.peerText); + const stage = JSON.parse(production.configText); + target.vars.RELEASE_PROFILE = production.profile; + target.vars.PRIVATE_PILOT_POLICY = stage.vars.PRIVATE_PILOT_POLICY; + target.vars.ALLOW_OUTBOUND_NOTIFICATIONS = "true"; + 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/release-provider.mjs b/scripts/release-provider.mjs index 718e3fd..1e80961 100644 --- a/scripts/release-provider.mjs +++ b/scripts/release-provider.mjs @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { PRIVATE_PILOT_PROFILE } from "../src/shared/private-pilot.ts"; // All provider calls are GETs to the fixed Cloudflare API origin. The raw // responses, which may include secrets, never enter logs or persisted receipts. @@ -257,6 +258,34 @@ export async function inspectWorker( "provider-version-config-unverified", ); } + // Older manual deployments legitimately omit the private policy. If either + // surface retains it, verify its exact bytes and unique nonsecret type on both. + // Do not require the new candidate's policy during a pre-deploy manual readback. + const policyName = "PRIVATE_PILOT_POLICY"; + const policyPresent = + settings.bindings.some((binding) => binding?.name === policyName) || + versionBindings.some((binding) => binding?.name === policyName); + if (policyPresent) { + const settingsPolicy = readBinding( + settings.bindings, + policyName, + "plain_text", + ); + const versionPolicy = readBinding( + versionBindings, + policyName, + "plain_text", + ); + ensure( + typeof settingsPolicy.text === "string" && + settingsPolicy.text === versionPolicy.text, + "provider-private-pilot-policy-unverified", + ); + } + ensure( + textBindings.RELEASE_PROFILE !== PRIVATE_PILOT_PROFILE || policyPresent, + "provider-private-pilot-policy-unverified", + ); if (bootstrapStaging) { ensure( config.name === "agent-table-staging" && @@ -307,6 +336,14 @@ export function verifyDeployedConfiguration(observed, config) { "provider-deployed-config-mismatch", ); } + const policy = config.vars.PRIVATE_PILOT_POLICY; + ensure( + (policy === undefined || typeof policy === "string") && + observed.textBindings.PRIVATE_PILOT_POLICY === policy && + (config.vars.RELEASE_PROFILE !== PRIVATE_PILOT_PROFILE || + typeof policy === "string"), + "provider-deployed-private-pilot-policy-mismatch", + ); } export function redactedProviderReceipt(observed, environment, timestamp) { diff --git a/scripts/release-provider.test.mjs b/scripts/release-provider.test.mjs index ed148d1..e25ed78 100644 --- a/scripts/release-provider.test.mjs +++ b/scripts/release-provider.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { makeConfig, + makePrivatePilotFixture, makeProviderMock, } from "../test/helpers/release-fixtures.mjs"; import { @@ -411,3 +412,173 @@ test("network reader is GET-only, fixed-origin, bounded and never echoes secrets /provider-body-oversized/, ); }); + +function privateProvider() { + const fixture = makePrivatePilotFixture(); + const candidate = JSON.parse(fixture.configText); + const peerConfig = JSON.parse(fixture.peerText); + const mock = makeProviderMock(candidate, peerConfig); + const prefix = `/client/v4/accounts/${candidate.account_id}/workers/scripts/${candidate.name}`; + return { + candidate, + peerConfig, + mock, + settings: mock.routes.get(`${prefix}/settings`), + version: mock.routes.get( + `${prefix}/versions/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb`, + ), + }; +} + +test("private policy readback matches active version and settings without entering the receipt", async () => { + const { candidate, peerConfig, mock } = privateProvider(); + const { targetState } = await readCloudflareDeployment( + candidate, + peerConfig, + mock.read, + ); + verifyDeployedConfiguration(targetState, candidate); + assert.equal( + targetState.textBindings.PRIVATE_PILOT_POLICY, + candidate.vars.PRIVATE_PILOT_POLICY, + ); + const serialized = JSON.stringify( + redactedProviderReceipt(targetState, "staging", "2026-11-11T12:00:00Z"), + ); + const policy = JSON.parse(candidate.vars.PRIVATE_PILOT_POLICY); + for (const privateValue of [ + policy.recipientGuestId, + policy.recipientConnectionId, + ...policy.tableIds, + policy.callbackTargets[0].origin, + policy.callbackTargets[0].pathPrefix, + ]) { + assert.equal(serialized.includes(privateValue), false); + } +}); + +test("missing, divergent, duplicated or secret-typed private policy bindings fail closed", async () => { + const mutations = [ + ({ settings, version }) => { + settings.bindings = settings.bindings.filter( + (binding) => binding.name !== "PRIVATE_PILOT_POLICY", + ); + version.resources.bindings = version.resources.bindings.filter( + (binding) => binding.name !== "PRIVATE_PILOT_POLICY", + ); + }, + ({ settings }) => { + settings.bindings = settings.bindings.filter( + (binding) => binding.name !== "PRIVATE_PILOT_POLICY", + ); + }, + ({ version }) => { + version.resources.bindings = version.resources.bindings.filter( + (binding) => binding.name !== "PRIVATE_PILOT_POLICY", + ); + }, + ({ version }) => { + version.resources.bindings.find( + (binding) => binding.name === "PRIVATE_PILOT_POLICY", + ).text += " "; + }, + ({ settings }) => { + const binding = settings.bindings.find( + (item) => item.name === "PRIVATE_PILOT_POLICY", + ); + settings.bindings.push(structuredClone(binding)); + }, + ({ version }) => { + const binding = version.resources.bindings.find( + (item) => item.name === "PRIVATE_PILOT_POLICY", + ); + version.resources.bindings.push(structuredClone(binding)); + }, + ({ settings }) => { + settings.bindings.find( + (binding) => binding.name === "PRIVATE_PILOT_POLICY", + ).type = "secret_text"; + }, + ({ settings, version }) => { + for (const bindings of [settings.bindings, version.resources.bindings]) { + bindings.find( + (binding) => binding.name === "PRIVATE_PILOT_POLICY", + ).text = 7; + } + }, + ]; + for (const mutate of mutations) { + const fixture = privateProvider(); + mutate(fixture); + await assert.rejects( + readCloudflareDeployment( + fixture.candidate, + fixture.peerConfig, + fixture.mock.read, + ), + /provider-private-pilot-policy-unverified|binding-mismatch/, + ); + } +}); + +test("predeploy manual readback is allowed but postdeploy requires exact policy bytes and removal", async () => { + const privateFixture = privateProvider(); + const manual = makeConfig("staging"); + const manualMock = makeProviderMock(manual, privateFixture.peerConfig); + const manualState = await readCloudflareDeployment( + privateFixture.candidate, + privateFixture.peerConfig, + manualMock.read, + ); + assert.equal( + manualState.targetState.textBindings.PRIVATE_PILOT_POLICY, + undefined, + ); + assert.throws( + () => + verifyDeployedConfiguration( + manualState.targetState, + privateFixture.candidate, + ), + /provider-deployed-config-mismatch|provider-deployed-private-pilot-policy-mismatch/, + ); + + // A manual candidate omits the policy; a retained text binding is not removal. + for (const response of manualMock.routes.values()) { + const bindings = response.bindings ?? response.resources?.bindings; + if ( + bindings?.some( + (binding) => + binding.name === "PUBLIC_ORIGIN" && + binding.text === manual.vars.PUBLIC_ORIGIN, + ) + ) { + bindings.push({ + name: "PRIVATE_PILOT_POLICY", + type: "plain_text", + text: privateFixture.candidate.vars.PRIVATE_PILOT_POLICY, + }); + } + } + const retained = await readCloudflareDeployment( + manual, + privateFixture.peerConfig, + manualMock.read, + ); + assert.throws( + () => verifyDeployedConfiguration(retained.targetState, manual), + /provider-deployed-private-pilot-policy-mismatch/, + ); + + const exact = await readCloudflareDeployment( + privateFixture.candidate, + privateFixture.peerConfig, + privateFixture.mock.read, + ); + const semanticallyEqual = structuredClone(privateFixture.candidate); + semanticallyEqual.vars.PRIVATE_PILOT_POLICY += " "; + assert.throws( + () => verifyDeployedConfiguration(exact.targetState, semanticallyEqual), + /provider-deployed-private-pilot-policy-mismatch/, + ); +}); diff --git a/scripts/require-release-approval.mjs b/scripts/require-release-approval.mjs index d87d077..3b0511c 100644 --- a/scripts/require-release-approval.mjs +++ b/scripts/require-release-approval.mjs @@ -1,8 +1,13 @@ import { readFileSync } from "node:fs"; +import { + PRIVATE_PILOT_MAX_WINDOW_MS, + PRIVATE_PILOT_PROFILE, +} from "../src/shared/private-pilot.ts"; // A successful offline preflight is not an independently approved deployment. // This gate consumes only the output of the live verifier in the protected job. -const [environment, buildSha, configSha256] = process.argv.slice(2); +const [environment, buildSha, configSha256, profile = "manual"] = + process.argv.slice(2); let approved = false; try { const line = readFileSync(0, "utf8").trim(); @@ -16,7 +21,21 @@ try { manifest.environment === environment && manifest.buildSha === buildSha && manifest.configSha256 === configSha256 && - manifest.profile === "manual" && + manifest.profile === profile && + (profile === "manual" || + (profile === PRIVATE_PILOT_PROFILE && + environment === "staging" && + manifest.privatePilot?.mode === "a-only" && + /^[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 && + 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 0ba805e..3c36156 100644 --- a/scripts/require-release-approval.test.mjs +++ b/scripts/require-release-approval.test.mjs @@ -12,10 +12,11 @@ function inspect( environment = "staging", buildSha = sha, configDigest = configSha, + profile = "manual", ) { return spawnSync( process.execPath, - [gate.pathname, environment, buildSha, configDigest], + [gate.pathname, environment, buildSha, configDigest, profile], { input, encoding: "utf8", @@ -96,6 +97,79 @@ test("release gate refuses a merely valid preflight, missing approval or mismatc } }); +test("private gate binds the explicit staging profile and rechecks its finite send window", () => { + const current = Date.now(); + const manifest = { + preflightValid: true, + deploymentApproved: true, + environment: "staging", + profile: "private-pilot-a-only", + buildSha: sha, + configSha256: configSha, + approvalSource: "protected-environment-and-live-provider-readback", + workflowRunId: "2345", + workflowRunAttempt: "1", + ciRunId: "1234", + providerReceiptSha256: "c".repeat(64), + providerReceipt: { + environment: "staging", + workerName: "agent-table-staging", + }, + privatePilot: { + mode: "a-only", + policySha256: "d".repeat(64), + notBefore: new Date(current - 60_000).toISOString(), + expiresAt: new Date(current + 60_000).toISOString(), + deliveryEnabled: true, + }, + }; + const check = ( + value, + environment = "staging", + profile = "private-pilot-a-only", + ) => + inspect( + `RELEASE_APPROVAL_VALID ${JSON.stringify(value)}\n`, + environment, + sha, + configSha, + profile, + ).status; + assert.equal(check(manifest), 0); + assert.equal(check(manifest, "staging", "manual"), 1); + assert.equal( + check({ ...manifest, environment: "production" }, "production"), + 1, + ); + for (const change of [ + { mode: "bidirectional" }, + { policySha256: "bad" }, + { notBefore: new Date(current + 30_000).toISOString() }, + { expiresAt: new Date(current - 30_000).toISOString() }, + { expiresAt: new Date(current + 3 * 60 * 60_000).toISOString() }, + { deliveryEnabled: "false" }, + ]) { + assert.equal( + check({ + ...manifest, + privatePilot: { ...manifest.privatePilot, ...change }, + }), + 1, + ); + } + assert.equal( + check({ + ...manifest, + privatePilot: { + ...manifest.privatePilot, + expiresAt: new Date(current - 30_000).toISOString(), + deliveryEnabled: false, + }, + }), + 0, + ); +}); + test("current offline validator never manufactures a deployment approval", async () => { const { validateRelease } = await import("./release-preflight.mjs"); const source = readFileSync( @@ -189,6 +263,13 @@ test("release workflows remain explicit main-only manual dispatches with environ assert.match(shared, /sha256sum "\$config"/); assert.match(shared, /wrangler deploy --config "\$config"/); assert.match(shared, /verifyCurrentMainHead/); + assert.match(shared, /"\$RELEASE_PROFILE" == "private-pilot-a-only"/); + assert.match(shared, /"\$TARGET_ENVIRONMENT" == "staging"/); + assert.match(shared, /"\$BOOTSTRAP_STAGING" == "false"/); + assert.equal( + (shared.match(/node scripts\/require-release-approval\.mjs/g) ?? []).length, + 2, + ); assert.match(shared, /CLOUDFLARE_API_TOKEN/); assert.match(shared, /if: steps\.gate\.outputs\.approved == 'true'/); assert.match(shared, /if: always\(\)/); diff --git a/src/env.d.ts b/src/env.d.ts index d4c3608..9a30ce8 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -37,6 +37,7 @@ declare global { INVITATION_RESPONSE_KEYRING?: string; CALLBACK_CREDENTIAL_KEYRING?: string; EVENTS_REVIEWED_CALLBACK_TARGETS?: string; + PRIVATE_PILOT_POLICY?: string; } interface GlobalProps { mainModule: typeof import("./index"); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 60bb879..f172ac5 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -28,6 +28,10 @@ import { import { authorizeGuestConnection, type ConnectionScope } from "../shared/auth"; import { validTableId } from "../shared/invitations"; import { CREATION_LIMITS, TABLE_LIMITS } from "../shared/limits"; +import { + PRIVATE_PILOT_PROFILE, + privatePilotRecipientAllowed, +} from "../shared/private-pilot"; import { handleOwnedInboxListen, isListenRequest, @@ -158,6 +162,7 @@ function newServer( env: Env, identity: AuthenticatedMcpIdentity, liveSubscriptions: boolean, + privateEvents = false, ) { const server = new McpServer( { name: "agent-table", version: "0.1.0" }, @@ -168,6 +173,9 @@ function newServer( capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: liveSubscriptions }, + ...(privateEvents && privatePilotRecipientAllowed(env, identity) + ? { events: {} } + : {}), }, }, ); @@ -182,15 +190,21 @@ function newServer( return checked.allowed ? checked.actor : null; }; - // These are actual MCP extension methods, not tools. Public Events - // capabilities are intentionally NOT advertised before Issue #21 delivery. + // Public Events stay unadvertised. A separately approved staging pilot + // exposes the provider profile only to its freshly authorized recipient. server.server.setRequestHandler( "events/list", { params: z.unknown() }, async (params) => { if (!validEventListParams(params)) throw new ProtocolError(-32602, "Invalid Events cursor"); - if (!(await scopedActor("events:subscribe"))) throw eventError("denied"); + const actor = await scopedActor("events:subscribe"); + if ( + !actor || + (env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE && + !privatePilotRecipientAllowed(env, actor)) + ) + throw eventError("denied"); return { events: [MESSAGE_EVENT_DEFINITION] }; }, ); @@ -483,6 +497,19 @@ export async function handleMcp( "events:subscribe", ) ).allowed; + const privateEvents = + privatePilotRecipientAllowed(env, identity) && + identity.scopes.includes("events:subscribe") && + identity.tokenExpiresAtMs !== null && + identity.tokenExpiresAtMs > Date.now() && + ( + await authorizeGuestConnection( + env, + identity.guestId, + identity.connectionId, + "events:subscribe", + ) + ).allowed; if (liveSubscriptions && (await isListenRequest(request))) { return handleOwnedInboxListen( request, @@ -495,7 +522,7 @@ export async function handleMcp( } const handler = createMcpHandler( - () => newServer(env, identity, liveSubscriptions), + () => newServer(env, identity, liveSubscriptions, privateEvents), { route: "/mcp", legacy: "reject", diff --git a/src/objects/table.ts b/src/objects/table.ts index effb691..fe21546 100644 --- a/src/objects/table.ts +++ b/src/objects/table.ts @@ -15,6 +15,7 @@ import { callbackTransportReadiness, reviewedCallbackTargets, sendVerifiedHttps, + validateReviewedCallbackDestination, } from "../notifications/egress"; import { grantedSubscriptionTtlMs, @@ -64,6 +65,14 @@ import type { PurgeTableReceipt, } from "../shared/lifecycle"; import { TABLE_LIMITS } from "../shared/limits"; +import { + activePrivatePilotPolicy, + PRIVATE_PILOT_MAX_MESSAGE_SEQ, + PRIVATE_PILOT_PROFILE, + privatePilotConfiguration, + privatePilotRecipientAllowed, + privatePilotRecipientMatches, +} from "../shared/private-pilot"; import { digestSessionSecret, randomBase64Url } from "../shared/random"; import { deferQuarantinedAlarm, @@ -675,6 +684,14 @@ export class TableDO extends DurableObject { : row.status === "expired" || (row.expires_at ?? 0) <= Date.now() ? "expired" : "active"; + const available = privatePilotRecipientAllowed( + this.env, + { + guestId: member.guest_id, + connectionId, + }, + member.table_id, + ); // A second connection belonging to the same guest must not observe this // subscription's queue or outcome. Never query callback/secret columns. if (!owned || !row?.subscription_id || status !== "active") { @@ -720,8 +737,10 @@ export class TableDO extends DurableObject { "retry-exhausted", ]); return { - available: false as const, - reason: "delivery_not_ready" as const, + available, + reason: available + ? ("private_pilot_only" as const) + : ("delivery_not_ready" as const), status, pendingCount: counts.pending_count, lastAcceptedSeq: counts.last_accepted_seq, @@ -781,6 +800,7 @@ export class TableDO extends DurableObject { private async freshEventActor( actor: VerifiedConnectionActor, ): Promise { + if (isRestoreQuarantined(this.env)) return null; if ( actor?.kind !== "verified-connection" || actor.scope !== "events:subscribe" || @@ -798,6 +818,29 @@ export class TableDO extends DurableObject { return decision.allowed ? decision.actor : null; } + private eventRegistrationAllowed( + actor: VerifiedConnectionActor, + tableId: string, + destination: string, + now = Date.now(), + ): boolean { + if (this.env.RELEASE_PROFILE !== PRIVATE_PILOT_PROFILE) return true; + const policy = activePrivatePilotPolicy(this.env, now); + return ( + !!policy && + privatePilotRecipientMatches(policy, actor, tableId) && + validateReviewedCallbackDestination(destination, policy.callbackTargets) + .ok + ); + } + + private eventPilotDeadline(): number { + if (this.env.RELEASE_PROFILE !== PRIVATE_PILOT_PROFILE) + return Number.POSITIVE_INFINITY; + const policy = activePrivatePilotPolicy(this.env); + return policy ? Date.parse(policy.expiresAt) : 0; + } + /** * Reserve a fenced, encrypted candidate without changing an existing active * subscription. Network verification is deliberately outside this method. @@ -810,6 +853,14 @@ export class TableDO extends DurableObject { if (!parsed) return { ok: false, code: "INVALID_REQUEST" }; const fresh = await this.freshEventActor(actor); if (!fresh) return { ok: false, code: "TABLE_NOT_FOUND" }; + if ( + !this.eventRegistrationAllowed( + fresh, + parsed.arguments.table_id, + parsed.delivery.url, + ) + ) + return { ok: false, code: "TABLE_NOT_FOUND" }; const table = this.metadataRow(); const member = this.memberFor(fresh.guestId); if (!table || !member || table.table_id !== parsed.arguments.table_id) @@ -836,6 +887,7 @@ export class TableDO extends DurableObject { Date.now() + EVENT_CANDIDATE_WINDOW_MS, table.expires_at, fresh.expiresAt, + this.eventPilotDeadline(), ); if (deadline <= Date.now()) return { ok: false, code: "STALE" }; try { @@ -850,6 +902,15 @@ export class TableDO extends DurableObject { currentTable.table_id !== parsed.arguments.table_id ) return { ok: false, code: "TABLE_NOT_FOUND" } as const; + if ( + !this.eventRegistrationAllowed( + fresh, + parsed.arguments.table_id, + parsed.delivery.url, + now, + ) + ) + return { ok: false, code: "TABLE_NOT_FOUND" } as const; if ( currentTable.expires_at <= now || fresh.expiresAt <= now || @@ -947,9 +1008,17 @@ export class TableDO extends DurableObject { candidate.connection_id !== fresh.connectionId ) return { ok: false, code: "STALE" }; + if ( + !this.eventRegistrationAllowed( + fresh, + table.table_id, + candidate.callback_url, + ) + ) + return { ok: false, code: "TABLE_NOT_FOUND" }; const granted = grantedSubscriptionTtlMs( candidate.requested_ttl_ms, - table.expires_at - Date.now(), + Math.min(table.expires_at, this.eventPilotDeadline()) - Date.now(), fresh.expiresAt - Date.now(), ); if (granted < 1000) return { ok: false, code: "STALE" }; @@ -985,6 +1054,15 @@ export class TableDO extends DurableObject { pending.connection_id !== fresh.connectionId ) return { ok: false, code: "STALE" } as const; + if ( + !this.eventRegistrationAllowed( + fresh, + table.table_id, + pending.callback_url, + now, + ) + ) + return { ok: false, code: "TABLE_NOT_FOUND" } as const; // A pending refresh cannot resurrect an active subscription that expired // before the external verification returned, even before alarm cleanup. if (current.status === "active" && (current.expires_at ?? 0) <= now) { @@ -1008,7 +1086,7 @@ export class TableDO extends DurableObject { return { ok: false, code: "CONFLICT" } as const; const actualTtl = grantedSubscriptionTtlMs( pending.requested_ttl_ms, - currentTable.expires_at - now, + Math.min(currentTable.expires_at, this.eventPilotDeadline()) - now, fresh.expiresAt - now, ); if (actualTtl < 1000) return { ok: false, code: "STALE" } as const; @@ -1102,6 +1180,14 @@ export class TableDO extends DurableObject { if (!parsed) return { ok: false, code: "INVALID_REQUEST" }; const fresh = await this.freshEventActor(actor); if (!fresh) return { ok: false, code: "TABLE_NOT_FOUND" }; + if (this.env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE) { + const policy = privatePilotConfiguration(this.env); + if ( + !policy || + !privatePilotRecipientMatches(policy, fresh, parsed.arguments.table_id) + ) + return { ok: false, code: "TABLE_NOT_FOUND" }; + } const table = this.metadataRow(); const member = this.memberFor(fresh.guestId); if (!table || !member || table.table_id !== parsed.arguments.table_id) @@ -1152,7 +1238,12 @@ export class TableDO extends DurableObject { ): Promise { if ( !allowOutboundNotifications(this.env) || - !callbackTransportReadiness().enabled + (!callbackTransportReadiness().enabled && + !privatePilotRecipientAllowed( + this.env, + actor, + this.metadataRow()?.table_id, + )) ) return false; const fresh = await this.freshEventActor(actor); @@ -1169,6 +1260,8 @@ export class TableDO extends DurableObject { const row = this.eventSubscription(member.membership_id); return ( !!row && + (this.env.RELEASE_PROFILE !== PRIVATE_PILOT_PROFILE || + privatePilotRecipientAllowed(this.env, fresh, table.table_id)) && row.status === "active" && row.guest_id === fresh.guestId && row.connection_id === fresh.connectionId && @@ -2644,8 +2737,13 @@ export class TableDO extends DurableObject { return committed.value; } - /** No configuration, RPC argument or stored record can override readiness. */ + /** Public readiness and the protected staging-only trial remain distinct. */ private outboxDispatchEnabled(): boolean { + if (this.env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE) { + const policy = activePrivatePilotPolicy(this.env); + const table = this.metadataRow(); + return !!policy && !!table && policy.tableIds.includes(table.table_id); + } return ( this.env.ENABLE_OPENAI_EVENTS === "true" && allowOutboundNotifications(this.env) && @@ -2658,9 +2756,16 @@ export class TableDO extends DurableObject { destination: string, request: CallbackRequest, ): Promise { + const pilot = activePrivatePilotPolicy(this.env); + if (this.env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE && !pilot) + return Promise.resolve({ + ok: false, + reason: "connection-boundary-unverified", + }); return sendVerifiedHttps( destination, - reviewedCallbackTargets(this.env.EVENTS_REVIEWED_CALLBACK_TARGETS), + pilot?.callbackTargets ?? + reviewedCallbackTargets(this.env.EVENTS_REVIEWED_CALLBACK_TARGETS), request, ); } @@ -2697,6 +2802,27 @@ export class TableDO extends DurableObject { now, ) .toArray()[0]; + if (work && this.env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE) { + const policy = activePrivatePilotPolicy(this.env, now); + if ( + !policy || + !privatePilotRecipientMatches( + policy, + { + guestId: work.guest_id ?? "", + connectionId: work.connection_id ?? "", + }, + work.table_id, + ) || + work.created_at < Date.parse(policy.notBefore) || + work.message_seq > PRIVATE_PILOT_MAX_MESSAGE_SEQ || + !validateReviewedCallbackDestination( + work.callback_url ?? "", + policy.callbackTargets, + ).ok + ) + return undefined; + } return work && Number.isSafeInteger(work.expires_at) && Number.isSafeInteger(work.table_expires_at) diff --git a/src/services/events.ts b/src/services/events.ts index cf7731a..fc41d5c 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -7,6 +7,12 @@ import { verifyCallbackControl, } from "../notifications/openai-events"; import type { VerifiedConnectionActor } from "../shared/auth"; +import { + activePrivatePilotPolicy, + PRIVATE_PILOT_PROFILE, + privatePilotConfiguration, + privatePilotRecipientMatches, +} from "../shared/private-pilot"; export type EventServiceFailure = | "invalid" @@ -55,9 +61,17 @@ export async function subscribeEvent( > { const parsed = parseEventSubscriptionParams(input, "subscribe"); if (!parsed) return { ok: false, reason: "invalid" }; - const reviewed = reviewedCallbackTargets( - env.EVENTS_REVIEWED_CALLBACK_TARGETS, - ); + const privateProfile = env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE; + const pilot = activePrivatePilotPolicy(env); + if ( + privateProfile && + (!pilot || + !privatePilotRecipientMatches(pilot, actor, parsed.arguments.table_id)) + ) + return { ok: false, reason: "denied" }; + const reviewed = + pilot?.callbackTargets ?? + reviewedCallbackTargets(env.EVENTS_REVIEWED_CALLBACK_TARGETS); if (!validateReviewedCallbackDestination(parsed.delivery.url, reviewed).ok) return { ok: false, reason: "callback_destination_rejected" }; @@ -67,9 +81,31 @@ export async function subscribeEvent( ); const candidate = await table.beginEventSubscription(actor, parsed); if (!candidate.ok) return failed(candidate.code); + let verificationTargets = reviewed; + if (privateProfile) { + // Reservation can outlive its approved window while the RPC is pending. + // Recheck before any verification bytes leave this Worker. + const currentPilot = activePrivatePilotPolicy(env); + if ( + !currentPilot || + !privatePilotRecipientMatches( + currentPilot, + actor, + parsed.arguments.table_id, + ) || + !validateReviewedCallbackDestination( + parsed.delivery.url, + currentPilot.callbackTargets, + ).ok + ) { + await table.discardEventCandidate(actor, candidate.value.candidateId); + return { ok: false, reason: "denied" }; + } + verificationTargets = currentPilot.callbackTargets; + } const checked = await verifyCallbackControl( parsed.delivery.url, - reviewed, + verificationTargets, parsed.delivery.secret as string, candidate.value.subscriptionId, ); @@ -110,6 +146,14 @@ export async function unsubscribeEvent( ): Promise>> { const parsed = parseEventSubscriptionParams(input, "unsubscribe"); if (!parsed) return { ok: false, reason: "invalid" }; + if (env.RELEASE_PROFILE === PRIVATE_PILOT_PROFILE) { + const policy = privatePilotConfiguration(env); + if ( + !policy || + !privatePilotRecipientMatches(policy, actor, parsed.arguments.table_id) + ) + return { ok: false, reason: "denied" }; + } try { const table = env.TABLES.get( env.TABLES.idFromName(parsed.arguments.table_id), diff --git a/src/shared/admission.ts b/src/shared/admission.ts index de7df8e..4302a05 100644 --- a/src/shared/admission.ts +++ b/src/shared/admission.ts @@ -1,3 +1,7 @@ +import { + activePrivatePilotPolicy, + PRIVATE_PILOT_PROFILE, +} from "./private-pilot"; import { isRestoreQuarantined } from "./release-quarantine"; /** Bounded Worker ingress and independently controlled admission switches. */ @@ -34,7 +38,10 @@ export function allowNewTables(env: Env): boolean { */ export function allowOutboundNotifications(env: Env): boolean { return ( - !isRestoreQuarantined(env) && env.ALLOW_OUTBOUND_NOTIFICATIONS === "true" + !isRestoreQuarantined(env) && + env.ALLOW_OUTBOUND_NOTIFICATIONS === "true" && + (env.RELEASE_PROFILE !== PRIVATE_PILOT_PROFILE || + !!activePrivatePilotPolicy(env)) ); } diff --git a/src/shared/private-pilot.ts b/src/shared/private-pilot.ts new file mode 100644 index 0000000..e1cb7ba --- /dev/null +++ b/src/shared/private-pilot.ts @@ -0,0 +1,181 @@ +import { + type ReviewedCallbackTarget, + reviewedCallbackTargets, +} from "../notifications/egress.ts"; + +export const PRIVATE_PILOT_PROFILE = "private-pilot-a-only"; +export const PRIVATE_PILOT_MAX_WINDOW_MS = 2 * 60 * 60_000; +export const PRIVATE_PILOT_MAX_MESSAGE_SEQ = 12; +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 = /^[a-f0-9]{40}$/; +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"; + buildSha: string; + approvedAt: string; + notBefore: string; + expiresAt: string; + approvalRef: string; + recipientGuestId: string; + recipientConnectionId: string; + tableIds: string[]; + callbackTargets: readonly ReviewedCallbackTarget[]; +} + +/** Independent of Cloudflare globals so the release scripts use the same parser. */ +export interface PrivatePilotBindings { + RELEASE_ENVIRONMENT?: string; + RELEASE_PROFILE?: string; + RELEASE_BUILD_SHA?: string; + RESTORE_QUARANTINE?: string; + ENABLE_MCP_SUBSCRIPTIONS?: string; + ENABLE_OPENAI_EVENTS?: string; + ALLOW_OUTBOUND_NOTIFICATIONS?: string; + PRIVATE_PILOT_POLICY?: string; +} + +function instant(value: unknown): number { + if ( + typeof value !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) + ) + return Number.NaN; + const parsed = Date.parse(value); + const canonical = value.includes(".") ? value : value.replace("Z", ".000Z"); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === canonical + ? parsed + : Number.NaN; +} + +/** Strict syntax and scope only; a JSON approval reference is not authorization. */ +export function parsePrivatePilotPolicy( + encoded: unknown, +): PrivatePilotPolicy | null { + if (typeof encoded !== "string" || encoded.length > 12_288) return null; + let value: unknown; + try { + value = JSON.parse(encoded); + } catch { + return null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const keys = [ + "schemaVersion", + "mode", + "buildSha", + "approvedAt", + "notBefore", + "expiresAt", + "approvalRef", + "recipientGuestId", + "recipientConnectionId", + "tableIds", + "callbackTargets", + ]; + if ( + Object.keys(row).length !== keys.length || + !keys.every((key) => Object.hasOwn(row, key)) || + row.schemaVersion !== 1 || + row.mode !== "a-only" || + typeof row.buildSha !== "string" || + !SHA.test(row.buildSha) || + typeof row.approvalRef !== "string" || + !REFERENCE.test(row.approvalRef) || + typeof row.recipientGuestId !== "string" || + !UUID.test(row.recipientGuestId) || + typeof row.recipientConnectionId !== "string" || + !UUID.test(row.recipientConnectionId) || + !Array.isArray(row.tableIds) || + row.tableIds.length < 1 || + row.tableIds.length > 3 || + !row.tableIds.every((id) => typeof id === "string" && UUID.test(id)) || + new Set(row.tableIds).size !== row.tableIds.length + ) + return null; + const approved = instant(row.approvedAt); + const start = instant(row.notBefore); + const end = instant(row.expiresAt); + if ( + !( + approved <= start && + start < end && + end - start <= PRIVATE_PILOT_MAX_WINDOW_MS + ) + ) + return null; + const callbackTargets = reviewedCallbackTargets( + JSON.stringify(row.callbackTargets), + ); + if (!callbackTargets.length) return null; + return { + schemaVersion: 1, + mode: "a-only", + buildSha: row.buildSha, + approvedAt: row.approvedAt as string, + notBefore: row.notBefore as string, + expiresAt: row.expiresAt as string, + approvalRef: row.approvalRef, + recipientGuestId: row.recipientGuestId, + recipientConnectionId: row.recipientConnectionId, + tableIds: row.tableIds as string[], + callbackTargets, + }; +} + +/** Expiry/kill pauses effects without disabling authorized reads or closure. */ +export function privatePilotConfiguration( + env: PrivatePilotBindings, +): PrivatePilotPolicy | null { + if ( + env.RELEASE_ENVIRONMENT !== "staging" || + env.RELEASE_PROFILE !== PRIVATE_PILOT_PROFILE || + env.RESTORE_QUARANTINE !== "false" || + env.ENABLE_MCP_SUBSCRIPTIONS !== "false" || + env.ENABLE_OPENAI_EVENTS !== "false" || + !["true", "false"].includes(env.ALLOW_OUTBOUND_NOTIFICATIONS ?? "") + ) + return null; + const policy = parsePrivatePilotPolicy(env.PRIVATE_PILOT_POLICY); + return policy && policy.buildSha === env.RELEASE_BUILD_SHA ? policy : null; +} + +export function activePrivatePilotPolicy( + env: PrivatePilotBindings, + now = Date.now(), +): PrivatePilotPolicy | null { + const policy = privatePilotConfiguration(env); + return policy && + env.ALLOW_OUTBOUND_NOTIFICATIONS === "true" && + Number.isFinite(now) && + Date.parse(policy.notBefore) <= now && + now < Date.parse(policy.expiresAt) + ? policy + : null; +} + +export function privatePilotRecipientMatches( + policy: PrivatePilotPolicy, + actor: { guestId: string; connectionId: string }, + tableId?: string, +): boolean { + return ( + policy.recipientGuestId === actor.guestId && + policy.recipientConnectionId === actor.connectionId && + (tableId === undefined || policy.tableIds.includes(tableId)) + ); +} + +export function privatePilotRecipientAllowed( + env: PrivatePilotBindings, + actor: { guestId: string; connectionId: string }, + tableId?: string, + now = Date.now(), +): boolean { + const policy = activePrivatePilotPolicy(env, now); + return !!policy && privatePilotRecipientMatches(policy, actor, tableId); +} diff --git a/src/shared/release-quarantine.ts b/src/shared/release-quarantine.ts index e6cb6d5..e597e5f 100644 --- a/src/shared/release-quarantine.ts +++ b/src/shared/release-quarantine.ts @@ -1,3 +1,5 @@ +import { privatePilotConfiguration } from "./private-pilot"; + /** * This independent Worker binding is outside restored Durable Object state. * A missing mode, missing quarantine flag, or unknown value fails closed. @@ -14,6 +16,7 @@ export function isRestoreQuarantined( | "ENABLE_MCP_SUBSCRIPTIONS" | "ENABLE_OPENAI_EVENTS" | "ALLOW_OUTBOUND_NOTIFICATIONS" + | "PRIVATE_PILOT_POLICY" > >, ): boolean { @@ -23,6 +26,7 @@ export function isRestoreQuarantined( env.RELEASE_ENVIRONMENT === "production"; if (!knownEnvironment || env.RESTORE_QUARANTINE !== "false") return true; if (env.RELEASE_ENVIRONMENT === "local") return false; + if (privatePilotConfiguration(env)) return false; // The initial public profile is manual-only. A feature flag or old Worker // configuration must not promote unverified live or autonomous capability. return !( diff --git a/src/shared/table.ts b/src/shared/table.ts index c6f6de1..7bb2aff 100644 --- a/src/shared/table.ts +++ b/src/shared/table.ts @@ -21,8 +21,8 @@ export type SafeTableMetadata = { ownInboxUri: string; /** Only present on the authenticated get_table MCP read. */ notifications?: { - available: false; - reason: "delivery_not_ready"; + available: boolean; + reason: "delivery_not_ready" | "private_pilot_only"; status: "active" | "expired" | "disabled"; /** Number of this connection's pending or leased deliveries, not host runs. */ pendingCount: number; diff --git a/test/helpers/release-fixtures.mjs b/test/helpers/release-fixtures.mjs index a1476bf..3fff9ce 100644 --- a/test/helpers/release-fixtures.mjs +++ b/test/helpers/release-fixtures.mjs @@ -137,6 +137,50 @@ export function makeFixture(environment = "staging") { }; } +export function makePrivatePilotFixture() { + const fixture = makeFixture("staging"); + const config = JSON.parse(fixture.configText); + const policy = { + schemaVersion: 1, + mode: "a-only", + buildSha, + approvedAt: new Date(now - 60_000).toISOString(), + notBefore: new Date(now - 30_000).toISOString(), + expiresAt: new Date(now + 60 * 60_000).toISOString(), + approvalRef: "fixture-pilot-approval", + 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" }, + ], + }; + config.vars.RELEASE_PROFILE = "private-pilot-a-only"; + config.vars.PRIVATE_PILOT_POLICY = JSON.stringify(policy); + config.vars.ALLOW_OUTBOUND_NOTIFICATIONS = "true"; + fixture.profile = config.vars.RELEASE_PROFILE; + fixture.configText = JSON.stringify(config); + const configSha256 = createHash("sha256") + .update(fixture.configText) + .digest("hex"); + fixture.evidence.configSha256 = configSha256; + fixture.operations.configSha256 = configSha256; + fixture.operations.profile = fixture.profile; + fixture.operations.privatePilot = { + mode: "a-only", + policySha256: createHash("sha256") + .update(config.vars.PRIVATE_PILOT_POLICY) + .digest("hex"), + approvalRef: policy.approvalRef, + independentOwnerConsentsReviewed: true, + callbackControlReviewed: true, + nativeFetchRiskAccepted: true, + boundedCanaryReviewed: true, + stopPlanReviewed: true, + }; + return fixture; +} + const namespace = (env, index) => `${env === "staging" ? "1" : "2"}${String(index + 1).repeat(31)}`; diff --git a/test/integration/events-subscriptions.test.ts b/test/integration/events-subscriptions.test.ts index 0d25689..8373b14 100644 --- a/test/integration/events-subscriptions.test.ts +++ b/test/integration/events-subscriptions.test.ts @@ -51,11 +51,17 @@ async function grantFor( guest: GuestStub, cookie: string, scopes: string, + registrationSource?: string, ) { const registration = await dispatch( new Request(`${ORIGIN}/oauth/register`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(registrationSource + ? { "CF-Connecting-IP": registrationSource } + : {}), + }, body: JSON.stringify({ client_name: "Events integration fixture", redirect_uris: [REDIRECT], @@ -167,12 +173,13 @@ async function grantFor( async function newPrincipal( scopes = "tables:read tables:write events:subscribe", + registrationSource?: string, ) { const guestId = crypto.randomUUID(); const guest = env.GUESTS.get(env.GUESTS.idFromName(guestId)); const session = await guest.createSession(guestId); const cookie = `__Host-agent_table_session=v1.${guestId}.${session.sessionId}.${session.secret}`; - return grantFor(guestId, guest, cookie, scopes); + return grantFor(guestId, guest, cookie, scopes, registrationSource); } function eventsOverlay(): Env { @@ -3142,3 +3149,356 @@ describe("authenticated event producer through native alarm and HTTPS sender (co } }); }); + +describe("protected private A-only staging pilot", () => { + it("uses real authority and native dispatch with scoped discovery, restart, pause, expiry, cap and closure", async () => { + // Keep this scenario's real DCR admissions in its own synthetic edge bucket. + const a = await newPrincipal(undefined, "192.0.2.24"); + const b = await newPrincipal(undefined, "192.0.2.24"); + const setupA = await clientFor(a.token); + const setupB = await clientFor(b.token); + const tables: string[] = []; + for (let index = 0; index < 2; index++) { + const tableId = await createTable(setupA); + tables.push(tableId); + const invite = toolOutput( + await setupA.callTool({ + name: "create_invite", + arguments: { table_id: tableId, request_id: crypto.randomUUID() }, + }), + ); + expect( + ( + await setupB.callTool({ + name: "join_table", + arguments: { + invitation: invite.invitation, + request_id: crypto.randomUUID(), + }, + }) + ).isError, + ).not.toBe(true); + } + await setupA.close(); + await setupB.close(); + const [tableId, closureTable] = tables; + const started = Date.now(); + const policy = { + schemaVersion: 1, + mode: "a-only", + buildSha: "a".repeat(40), + approvedAt: new Date(started - 2000).toISOString(), + notBefore: new Date(started - 1000).toISOString(), + expiresAt: new Date(started + 120_000).toISOString(), + approvalRef: "synthetic-private-pilot-review", + recipientGuestId: a.guestId, + recipientConnectionId: a.connectionId, + tableIds: tables, + callbackTargets: [ + { + origin: "https://events.provider.example.org", + pathPrefix: "/callback", + }, + ], + }; + 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: "true", + PRIVATE_PILOT_POLICY: JSON.stringify(policy), + // This unrelated legacy allowlist must not expand the approved pilot policy. + EVENTS_REVIEWED_CALLBACK_TARGETS: JSON.stringify([ + { origin: "https://other.example.org", pathPrefix: "/" }, + ]), + }; + const configure = async (id: string, reconstruct = false) => { + const stub = env.TABLES.get(env.TABLES.idFromName(id)); + if (reconstruct) await evictDurableObject(stub); + await runInDurableObject(stub, (instance) => { + const harness = instance as unknown as { env: Env }; + harness.env = { ...harness.env, ...overlay }; + }); + return stub; + }; + for (const id of tables) await configure(id); + const delivered: string[] = []; + let challenges = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + expect(String(url)).toBe(CALLBACK); + const text = new TextDecoder().decode(init?.body as ArrayBuffer); + const body = JSON.parse(text); + if (body.type === "verification") { + challenges++; + return new Response(JSON.stringify({ challenge: body.challenge })); + } + const verified = new Webhook(SECRET).verify( + text, + Object.fromEntries(new Headers(init?.headers)), + ) as { eventId: string }; + delivered.push(verified.eventId); + return new Response(null, { status: 204 }); + }); + const aClient = await clientFor(a.token, overlay); + const bClient = await clientFor(b.token, overlay); + const discovery = async (token: string) => { + const response = await dispatch( + new Request(RESOURCE, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Host: new URL(RESOURCE).host, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "server/discover", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: crypto.randomUUID(), + method: "server/discover", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }), + overlay, + ); + expect(response.status, await response.clone().text()).toBe(200); + return response.json() as Promise<{ + result: { capabilities: Record }; + }>; + }; + const rows = async (id = tableId) => + runInDurableObject( + env.TABLES.get(env.TABLES.idFromName(id)), + (_instance, state) => + state.storage.sql + .exec<{ + event_id: string; + message_seq: number; + status: string; + attempt_count: number; + }>( + "SELECT event_id, message_seq, status, attempt_count FROM table_event_outbox ORDER BY message_seq", + ) + .toArray(), + ); + const alarm = async (id = tableId, reconstruct = true) => { + const stub = await configure(id, reconstruct); + await runInDurableObject(stub, async (instance, state) => { + state.storage.sql.exec( + "UPDATE table_event_outbox SET next_attempt_at = ? WHERE status = 'pending'", + Date.now() - 1, + ); + await instance.alarm(); + }); + }; + const post = (id: string) => + bClient.callTool({ + name: "post_message", + arguments: { + table_id: id, + request_id: crypto.randomUUID(), + body: "SYNTHETIC PRIVATE PILOT", + }, + }); + try { + // The SDK client's typed capability schema strips provider extensions. + // Assert the actual discovery wire received by an Events-aware host. + expect((await discovery(a.token)).result.capabilities.events).toEqual({}); + expect( + (await discovery(b.token)).result.capabilities.events, + ).toBeUndefined(); + await expect(callEvent(bClient, "events/list")).rejects.toThrow(); + await expect( + callEvent(bClient, "events/subscribe", subscriptionParams(tableId)), + ).rejects.toThrow(); + await expect( + callEvent( + aClient, + "events/subscribe", + subscriptionParams(crypto.randomUUID()), + ), + ).rejects.toThrow(); + await expect( + callEvent( + aClient, + "events/subscribe", + subscriptionParams(tableId, "https://other.example.org/callback"), + ), + ).rejects.toThrow(); + expect(challenges).toBe(0); + await callEvent(aClient, "events/subscribe", subscriptionParams(tableId)); + await callEvent( + aClient, + "events/subscribe", + subscriptionParams(closureTable), + ); + expect(challenges).toBe(2); + await runInDurableObject( + env.TABLES.get(env.TABLES.idFromName(tableId)), + (_instance, state) => { + expect( + state.storage.sql + .exec<{ expires_at: number }>( + "SELECT expires_at FROM table_event_subscriptions WHERE status = 'active'", + ) + .one().expires_at, + ).toBeLessThanOrEqual(Date.parse(policy.expiresAt)); + }, + ); + expect((await post(tableId)).isError).not.toBe(true); + const first = (await rows())[0]; + expect(first).toMatchObject({ status: "pending", attempt_count: 0 }); + await alarm(); + expect(delivered).toEqual([first.event_id]); + expect((await rows())[0]).toMatchObject({ + status: "accepted", + attempt_count: 1, + }); + await alarm(); + expect(delivered).toEqual([first.event_id]); + const status = toolOutput( + await aClient.callTool({ + name: "get_table", + arguments: { table_id: tableId }, + }), + ); + expect(status.notifications).toMatchObject({ + available: true, + reason: "private_pilot_only", + lastAcceptedSeq: 1, + }); + expect( + toolOutput( + await bClient.callTool({ + name: "get_table", + arguments: { table_id: tableId }, + }), + ).notifications, + ).toMatchObject({ available: false }); + + expect((await post(tableId)).isError).not.toBe(true); + const pending = (await rows())[1]; + overlay.ALLOW_OUTBOUND_NOTIFICATIONS = "false"; + await alarm(); + expect(delivered).toHaveLength(1); + expect((await rows())[1]).toEqual(pending); + await expect( + callEvent(aClient, "events/subscribe", subscriptionParams(tableId)), + ).rejects.toThrow(); + overlay.ALLOW_OUTBOUND_NOTIFICATIONS = "true"; + await alarm(); + expect(delivered).toEqual([first.event_id, pending.event_id]); + expect((await rows())[1]).toMatchObject({ + attempt_count: 1, + status: "accepted", + }); + + expect((await post(tableId)).isError).not.toBe(true); + const old = (await rows())[2]; + await runInDurableObject( + env.TABLES.get(env.TABLES.idFromName(tableId)), + (_instance, state) => { + // Model a persisted preapproval event without changing its retry budget. + state.storage.sql.exec( + "UPDATE table_event_outbox SET created_at = ? WHERE event_id = ?", + Date.parse(policy.notBefore) - 1, + old.event_id, + ); + }, + ); + await alarm(); + expect((await rows())[2]).toMatchObject({ + status: "cancelled", + attempt_count: 0, + }); + expect(delivered).toHaveLength(2); + await runInDurableObject( + env.TABLES.get(env.TABLES.idFromName(tableId)), + (_instance, state) => { + // The prior immutable sequence models an already consumed canary budget. + state.storage.sql.exec("UPDATE table_metadata SET last_seq = 12"); + }, + ); + expect(toolOutput(await post(tableId)).seq).toBe(13); + await alarm(); + expect((await rows()).at(-1)).toMatchObject({ + message_seq: 13, + status: "cancelled", + attempt_count: 0, + }); + expect(delivered).toHaveLength(2); + + expect((await post(closureTable)).isError).not.toBe(true); + const terminalPending = (await rows(closureTable))[0]; + overlay.PRIVATE_PILOT_POLICY = JSON.stringify({ + ...policy, + approvedAt: new Date(started - 120_000).toISOString(), + notBefore: new Date(started - 119_000).toISOString(), + expiresAt: new Date(started - 1).toISOString(), + }); + await alarm(closureTable); + expect((await rows(closureTable))[0]).toEqual(terminalPending); + expect( + ( + await aClient.callTool({ + name: "get_table", + arguments: { table_id: closureTable }, + }) + ).isError, + ).not.toBe(true); + await expect( + callEvent( + aClient, + "events/subscribe", + subscriptionParams(closureTable), + ), + ).rejects.toThrow(); + overlay.PRIVATE_PILOT_POLICY = JSON.stringify(policy); + overlay.RESTORE_QUARANTINE = "true"; + await alarm(closureTable); + expect((await rows(closureTable))[0]).toEqual(terminalPending); + expect( + (await dispatch(new Request(`${ORIGIN}/health`), overlay)).status, + ).toBe(503); + overlay.RESTORE_QUARANTINE = "false"; + overlay.RELEASE_ENVIRONMENT = "production"; + await alarm(closureTable); + expect((await rows(closureTable))[0]).toEqual(terminalPending); + expect( + (await dispatch(new Request(`${ORIGIN}/health`), overlay)).status, + ).toBe(503); + overlay.RELEASE_ENVIRONMENT = "staging"; + await configure(closureTable); + expect( + ( + await aClient.callTool({ + name: "close_table", + arguments: { + table_id: closureTable, + request_id: crypto.randomUUID(), + }, + }) + ).isError, + ).not.toBe(true); + await alarm(closureTable); + expect((await rows(closureTable))[0]).toMatchObject({ + status: "cancelled", + attempt_count: 0, + }); + expect(delivered).toHaveLength(2); + } finally { + await aClient.close(); + await bClient.close(); + } + }); +}); diff --git a/test/unit/private-pilot.test.ts b/test/unit/private-pilot.test.ts new file mode 100644 index 0000000..aaf2c1d --- /dev/null +++ b/test/unit/private-pilot.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; +import { callbackTransportReadiness } from "../../src/notifications/egress"; +import { subscribeEvent } from "../../src/services/events"; +import type { VerifiedConnectionActor } from "../../src/shared/auth"; +import { + activePrivatePilotPolicy, + PRIVATE_PILOT_PROFILE, + parsePrivatePilotPolicy, + privatePilotConfiguration, + privatePilotRecipientAllowed, +} from "../../src/shared/private-pilot"; +import { isRestoreQuarantined } from "../../src/shared/release-quarantine"; + +const now = Date.parse("2026-11-12T00:01:00Z"); +const policy = { + schemaVersion: 1, + mode: "a-only", + buildSha: "a".repeat(40), + approvedAt: "2026-11-12T00:00:00Z", + notBefore: "2026-11-12T00:00:01Z", + expiresAt: "2026-11-12T01:00:00Z", + approvalRef: "synthetic-review-0001", + recipientGuestId: "11111111-1111-4111-8111-111111111111", + recipientConnectionId: "22222222-2222-4222-8222-222222222222", + tableIds: ["33333333-3333-4333-8333-333333333333"], + callbackTargets: [ + { origin: "https://events.provider.example.org", pathPrefix: "/callback" }, + ], +}; +const bindings = () => ({ + RELEASE_ENVIRONMENT: "staging", + RELEASE_PROFILE: PRIVATE_PILOT_PROFILE, + RELEASE_BUILD_SHA: policy.buildSha, + RESTORE_QUARANTINE: "false", + ENABLE_MCP_SUBSCRIPTIONS: "false", + ENABLE_OPENAI_EVENTS: "false", + ALLOW_OUTBOUND_NOTIFICATIONS: "true", + PRIVATE_PILOT_POLICY: JSON.stringify(policy), +}); + +describe("private staging pilot policy", () => { + it("allows only the exact A guest, connection and table without public readiness", () => { + const env = bindings(); + const actor = { + guestId: policy.recipientGuestId, + connectionId: policy.recipientConnectionId, + }; + expect( + privatePilotRecipientAllowed(env, actor, policy.tableIds[0], now), + ).toBe(true); + expect( + privatePilotRecipientAllowed( + env, + { ...actor, guestId: "other-guest" }, + policy.tableIds[0], + now, + ), + ).toBe(false); + expect( + privatePilotRecipientAllowed( + env, + { ...actor, connectionId: "other-connection" }, + policy.tableIds[0], + now, + ), + ).toBe(false); + expect(privatePilotRecipientAllowed(env, actor, "other-table", now)).toBe( + false, + ); + expect(callbackTransportReadiness().enabled).toBe(false); + }); + + it("keeps expiry and the outbound kill switch distinct from restore quarantine", () => { + const env = bindings(); + expect( + activePrivatePilotPolicy(env, Date.parse(policy.notBefore) - 1), + ).toBeNull(); + expect( + activePrivatePilotPolicy(env, Date.parse(policy.expiresAt)), + ).toBeNull(); + env.ALLOW_OUTBOUND_NOTIFICATIONS = "false"; + expect(activePrivatePilotPolicy(env, now)).toBeNull(); + expect(privatePilotConfiguration(env)).not.toBeNull(); + expect(isRestoreQuarantined(env)).toBe(false); + }); + + it("discards a reservation that expires before verification without starting outbound I/O", async () => { + const actor: VerifiedConnectionActor = { + kind: "verified-connection", + guestId: policy.recipientGuestId, + connectionId: policy.recipientConnectionId, + scope: "events:subscribe", + expiresAt: Date.parse(policy.expiresAt) + 60_000, + }; + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + const fetch = vi.spyOn(globalThis, "fetch"); + const discard = vi.fn(async () => ({ ok: true })); + const begin = vi.fn(async () => { + clock.mockReturnValue(Date.parse(policy.expiresAt)); + return { + ok: true, + value: { candidateId: "candidate", subscriptionId: "subscription" }, + }; + }); + const env = { + ...bindings(), + TABLES: { + idFromName: (id: string) => id, + get: () => ({ + beginEventSubscription: begin, + discardEventCandidate: discard, + }), + }, + } as unknown as Env; + try { + expect( + await subscribeEvent(env, actor, { + name: "table.message.created", + arguments: { table_id: policy.tableIds[0] }, + delivery: { + mode: "webhook", + url: "https://events.provider.example.org/callback/receiver", + secret: `whsec_${btoa("A".repeat(32))}`, + }, + cursor: null, + }), + ).toEqual({ ok: false, reason: "denied" }); + expect(begin).toHaveBeenCalledOnce(); + expect(discard).toHaveBeenCalledWith(actor, "candidate"); + expect(fetch).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + fetch.mockRestore(); + } + }); + + it.each([ + ["production", { RELEASE_ENVIRONMENT: "production" }], + ["restored state", { RESTORE_QUARANTINE: "true" }], + ["wrong source", { RELEASE_BUILD_SHA: "b".repeat(40) }], + ["public Events", { ENABLE_OPENAI_EVENTS: "true" }], + ["public subscriptions", { ENABLE_MCP_SUBSCRIPTIONS: "true" }], + ["missing policy", { PRIVATE_PILOT_POLICY: "" }], + ])("refuses %s at both activation and hosted ingress", (_name, patch) => { + const env = { ...bindings(), ...patch }; + expect(activePrivatePilotPolicy(env, now)).toBeNull(); + expect(isRestoreQuarantined(env)).toBe(true); + }); + + it.each([ + { expiresAt: "2026-11-12T03:00:00Z" }, + { approvedAt: "2026-11-12T00:00:02Z" }, + { + approvedAt: "2026-02-30T00:00:00Z", + notBefore: "2026-02-30T00:00:01Z", + expiresAt: "2026-02-30T01:00:00Z", + }, + { tableIds: [] }, + { tableIds: [...policy.tableIds, ...policy.tableIds] }, + { + callbackTargets: [ + { + origin: "http://events.provider.example.org", + pathPrefix: "/callback", + }, + ], + }, + { + callbackTargets: [ + { origin: "https://127.0.0.1", pathPrefix: "/callback" }, + ], + }, + { + callbackTargets: [ + { + origin: "https://events.provider.example.org", + pathPrefix: "/callback/../private", + }, + ], + }, + { unexpected: "no-extra-policy-fields" }, + ])("rejects an invalid or broadened policy %#", (patch) => { + expect( + parsePrivatePilotPolicy(JSON.stringify({ ...policy, ...patch })), + ).toBeNull(); + }); +});