From fc9c44827b649a29e77a9a35b9baa256867861d5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 15:02:35 -0700 Subject: [PATCH 1/3] Runtime-assert the credential cipher at mint and hub boot (CL-7222) The composition root comments that every secret-at-rest seam shares one real cipher; that was comment-only. tagCredentialCipher now inspects the object at tag construction, and hub boot tags the process-wide cipher. Missing or wrong-shape input fails closed. Persist paths do not re-assert. --- apps/hub/src/index.ts | 19 ++- apps/hub/test/credential-cipher.test.ts | 34 +++- packages/chat/src/platform-adapter.ts | 18 +- packages/chat/test/platform-adapter.test.ts | 155 +++++++++++++----- packages/chat/test/relaunch-close.test.ts | 4 + .../src/persist-credential.test.ts | 10 ++ .../src/credential-cipher-tag.test.ts | 50 ++++++ .../folded-runs/src/credential-cipher-tag.ts | 28 ++++ packages/folded-runs/src/index.ts | 1 + packages/folded-runs/src/types.ts | 14 +- 10 files changed, 273 insertions(+), 60 deletions(-) create mode 100644 packages/folded-runs/src/credential-cipher-tag.test.ts create mode 100644 packages/folded-runs/src/credential-cipher-tag.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 1eb2d13e6..92df6b79b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -119,6 +119,7 @@ import { createCryptoProviderCache, findFoldedRunByAddress, lookupFoldedRunReconnectKey, + tagCredentialCipher, } from "@corbits/folded-runs"; import { createTopLevelRunRoutes } from "@corbits/run-scope"; import { @@ -513,6 +514,18 @@ export function credentialCipherFrom( ); } +/** + * Hub-boot mint of the process-wide credential cipher: build from + * config, then runtime-tag the result. Missing or wrong-shape input + * fails closed — the hub does not boot. + */ +export function hubCredentialCipher( + config: HubConfig, + log: ReturnType, +): CredentialCipher { + return tagCredentialCipher(credentialCipherFrom(config, log)); +} + /** * Instantiates one configured sidecar-provisioner backend. This is the * extension point named in `.env.example` and `apps/hub/src/config.ts`: @@ -571,9 +584,9 @@ export async function createHub(config: HubConfig) { bus: mailboxBus, }); const log = getLogger(["hub", "auth"]); - // Built once and shared by every secret-at-rest seam in this - // composition root — see `credentialCipherFrom`'s own doc comment. - const credentialCipher = credentialCipherFrom(config, log); + // Built once, tagged, and shared by every secret-at-rest seam in this + // composition root — see `hubCredentialCipher`. + const credentialCipher = hubCredentialCipher(config, log); const auth = betterAuth({ baseURL: config.baseUrl, diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index b5f75c8d1..acd3f9f36 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -6,8 +6,9 @@ import { describe, expect, test } from "bun:test"; import { getLogger } from "@intx/log"; +import { tagCredentialCipher } from "@corbits/folded-runs"; import type { HubConfig } from "../src/config.ts"; -import { credentialCipherFrom } from "../src/index.ts"; +import { credentialCipherFrom, hubCredentialCipher } from "../src/index.ts"; const log = getLogger(["hub", "test"]); @@ -75,3 +76,34 @@ describe("credentialCipherFrom", () => { expect(encrypted).not.toBe("secret-value"); }); }); + +describe("hubCredentialCipher", () => { + test("boot tags the cipher built from a configured key", async () => { + const cipher = hubCredentialCipher( + { + ...baseConfig, + credentialEncryptionKeyHex: "a".repeat(64), + }, + log, + ); + expect(cipher).toBe(tagCredentialCipher(cipher)); + const encrypted = await cipher.encrypt("secret-value", "aad"); + expect(encrypted).not.toBe("secret-value"); + }); + + test("boot tags the noop cipher when ALLOW_PLAINTEXT_SECRETS is set", async () => { + const cipher = hubCredentialCipher( + { ...baseConfig, allowPlaintextSecrets: true }, + log, + ); + expect(cipher).toBe(tagCredentialCipher(cipher)); + const encrypted = await cipher.encrypt("secret-value", "aad"); + expect(encrypted).toBe("secret-value"); + }); + + test("boot still hard-fails when the key is missing and plaintext is not opted in", () => { + expect(() => hubCredentialCipher(baseConfig, log)).toThrow( + /CREDENTIAL_ENCRYPTION_KEY/, + ); + }); +}); diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 20b65dbc1..9b9198f5c 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -27,6 +27,7 @@ import { resolveNewestProjectedDefinition, sendFoldedMail, wakeFoldedRun, + tagCredentialCipher, type FoldedRunMode, type FoldedRunsDeps, type SendFoldedMailParams, @@ -62,7 +63,7 @@ import { withTimeout } from "./with-timeout"; import { wrapWakeInferenceError } from "./model-unavailable"; import type { EventCollectorRegistry, SidecarRouter } from "@intx/hub-sessions"; import type { InferencePreference } from "@intx/agent"; -import { formatRunAddress } from "@intx/types"; +import { formatRunAddress, type CredentialCipher } from "@intx/types"; import type { FoldedBody } from "@intx/workflow-deploy"; import { AgentUnreachableError, @@ -87,13 +88,11 @@ export type CreateHubChatPlatformDeps = { /** * Decrypts credential secrets when an invited agent's launch resolves * inference sources against the tenant catalog — see - * `@corbits/folded-runs`' `FoldedRunsDeps.credentialCipher`; every - * invited-agent launch and wake needs it. - * Omitted, `resolveDefinitionSources` falls back to a noop cipher and - * hands the raw stored secret to the provider unchanged — correct only - * when the credential was itself written unencrypted. + * `@corbits/folded-runs`' `FoldedRunsDeps.credentialCipher`. Tagged at + * construction: missing or wrong-shape input fails closed and the + * platform is not minted. */ - credentialCipher?: FoldedRunsDeps["credentialCipher"]; + credentialCipher: CredentialCipher; /** * Every caller of `createHubChatPlatform` builds this via * `createEventCollectorRegistry` and passes it through — without it, @@ -240,6 +239,7 @@ export type HubChatPlatform = ChatPlatform & { export function createHubChatPlatform( deps: CreateHubChatPlatformDeps, ): HubChatPlatform { + const credentialCipher = tagCredentialCipher(deps.credentialCipher); const foldedRunsDeps: FoldedRunsDeps = { db: deps.db, sessionService: deps.sessionService, @@ -247,9 +247,7 @@ export function createHubChatPlatform( sidecarRouter: deps.sidecarRouter, eventCollectors: deps.eventCollectors, toolGrantsForPins: deps.toolGrantsForPins, - ...(deps.credentialCipher !== undefined - ? { credentialCipher: deps.credentialCipher } - : {}), + credentialCipher, ...(deps.mcpCredentialBindingsFor !== undefined ? { mcpCredentialBindingsFor: deps.mcpCredentialBindingsFor } : {}), diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index 4ad0ffc7c..6eb743f4d 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -120,6 +120,27 @@ function createHubChatPlatform( }); } +const PASSTHROUGH_CIPHER = { + encrypt: async (plaintext: string) => plaintext, + decrypt: async (blob: string) => blob, +}; + +function createPlatform( + deps: Omit< + Parameters[0], + "credentialCipher" + > & { + credentialCipher?: Parameters< + typeof createHubChatPlatform + >[0]["credentialCipher"]; + }, +) { + return createHubChatPlatform({ + ...deps, + credentialCipher: deps.credentialCipher ?? PASSTHROUGH_CIPHER, + }); +} + type SelectChain = PromiseLike & { where(...args: unknown[]): SelectChain; orderBy(...args: unknown[]): SelectChain; @@ -821,7 +842,7 @@ describe("createHubChatPlatform", () => { const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); const eventCollectors = createFakeEventCollectors(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -899,7 +920,7 @@ describe("createHubChatPlatform", () => { const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); const eventCollectors = createFakeEventCollectors(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -965,7 +986,7 @@ describe("createHubChatPlatform", () => { const sessionService = createFakeSessionService(); const sidecarRouter = createFakeSidecarRouter(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -1120,7 +1141,7 @@ describe("createHubChatPlatform", () => { sessionService.sendUserMessage = () => new Promise(() => {}); const sidecarRouter = createFakeSidecarRouter(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -1181,7 +1202,7 @@ describe("createHubChatPlatform", () => { const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); const eventCollectors = createFakeEventCollectors(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -1258,7 +1279,7 @@ describe("createHubChatPlatform", () => { wfd_echo: inertProjection({ id: "wfd_echo" }), }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1348,7 +1369,7 @@ describe("createHubChatPlatform", () => { decrypt: async (blob: string) => blob, }; - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -1372,6 +1393,63 @@ describe("createHubChatPlatform", () => { }); }); + test("refuses to mint FoldedRunsDeps when credentialCipher is missing", () => { + expect(() => + createHubChatPlatform({ + toolGrantsForPins: () => [], + db: {} as never, + sessionService: {} as never, + assetService: {} as never, + sidecarRouter: {} as never, + eventCollectors: createFakeEventCollectors(), + credentialCipher: undefined as never, + }), + ).toThrow(/missing or has the wrong shape/); + }); + + test("refuses to mint FoldedRunsDeps when credentialCipher has the wrong shape", () => { + expect(() => + createHubChatPlatform({ + toolGrantsForPins: () => [], + db: {} as never, + sessionService: {} as never, + assetService: {} as never, + sidecarRouter: {} as never, + eventCollectors: createFakeEventCollectors(), + credentialCipher: {} as never, + }), + ).toThrow(/missing or has the wrong shape/); + }); + + // A mismatched cipher (right keys, wrong value types) must fail closed at + // tag construction — launchInvite never starts, so ciphertext cannot reach + // a provider as an API key. + test("mismatched credentialCipher fails loudly before launchInvite", async () => { + resolveDefinitionSourcesCalls.length = 0; + await expect( + (async () => { + const platform = createHubChatPlatform({ + toolGrantsForPins: () => [], + db: {} as never, + sessionService: {} as never, + assetService: {} as never, + sidecarRouter: {} as never, + eventCollectors: createFakeEventCollectors(), + credentialCipher: { + encrypt: async () => "", + decrypt: "not-a-function", + } as never, + }); + return platform.launchInvite({ + tenantId: "ten_1", + creatorPrincipalId: "prin_creator", + definitionId: "wfd_echo", + }); + })(), + ).rejects.toThrow(/missing or has the wrong shape/); + expect(resolveDefinitionSourcesCalls).toHaveLength(0); + }); + test("launchInvite fails loud when the definition is not deployed", async () => { const db = createFakeDb({ assetRow: { @@ -1389,7 +1467,7 @@ describe("createHubChatPlatform", () => { }, tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1442,7 +1520,7 @@ describe("createHubChatPlatform", () => { // projection, and there is no other sibling to fall back to. }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1515,7 +1593,7 @@ describe("createHubChatPlatform", () => { }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1586,7 +1664,7 @@ describe("createHubChatPlatform", () => { }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1616,7 +1694,7 @@ describe("createHubChatPlatform", () => { workflowDefinitionRow: undefined, tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1664,7 +1742,7 @@ describe("createHubChatPlatform", () => { wfd_echo: inertProjection({ id: "wfd_echo" }), }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1730,7 +1808,7 @@ describe("createHubChatPlatform", () => { tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, wireProjectionsByDefinitionId: { wfd_echo: NO_MODEL_PROJECTION }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1784,7 +1862,7 @@ describe("createHubChatPlatform", () => { tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, wireProjectionsByDefinitionId: { wfd_echo: NO_MODEL_PROJECTION }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1844,7 +1922,7 @@ describe("createHubChatPlatform", () => { }, ], }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -1878,7 +1956,7 @@ describe("createHubChatPlatform", () => { const assetService = createFakeAssetService(); const sidecarRouter = createFakeSidecarRouter(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -1977,7 +2055,7 @@ describe("createHubChatPlatform", () => { assetBlob: new TextEncoder().encode(WORKBENCH_WORKFLOW_JSON), }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -2073,7 +2151,7 @@ describe("createHubChatPlatform", () => { assetBlob: new TextEncoder().encode(WORKBENCH_WORKFLOW_JSON), }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -2139,7 +2217,7 @@ describe("createHubChatPlatform", () => { busyAddresses: new Set([address]), }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -2204,7 +2282,7 @@ describe("createHubChatPlatform", () => { busyAddresses: new Set(), }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -2271,7 +2349,7 @@ describe("createHubChatPlatform", () => { busyAddresses: new Set(), }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -2315,7 +2393,7 @@ describe("createHubChatPlatform", () => { }, definitionId: "wfd_workbench1", }); - createHubChatPlatform({ + createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -2349,7 +2427,7 @@ describe("createHubChatPlatform", () => { }, definitionId: "wfd_workbench1", }); - createHubChatPlatform({ + createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -2396,7 +2474,7 @@ describe("createHubChatPlatform", () => { }, }); const sessionService = createFakeSessionService(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -2458,7 +2536,7 @@ describe("createHubChatPlatform", () => { }); const sessionService = createFakeSessionService(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -2519,7 +2597,7 @@ describe("createHubChatPlatform", () => { }); const sessionService = createFakeSessionService(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -2543,7 +2621,7 @@ describe("createHubChatPlatform", () => { }, definitionId: "wfd_workbench1", }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -2646,7 +2724,7 @@ describe("createHubChatPlatform", () => { return result; }; - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -2748,7 +2826,7 @@ describe("createHubChatPlatform", () => { test("recomputes and persists the folded body from the definition's current projection", async () => { const db = buildRefreshableDb(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -2793,7 +2871,7 @@ describe("createHubChatPlatform", () => { values: { id: "ses_agent1", principalId: "prin_agent1" }, }); const sessionService = createFakeSessionService(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -2893,7 +2971,7 @@ describe("createHubChatPlatform", () => { wfd_agent1: NEW_PROJECTION, }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -3019,7 +3097,7 @@ describe("createHubChatPlatform stale-definition reconciliation", () => { const sidecarRouter = createFakeSidecarRouter({ routableAddresses: opts.routable ? ["run_stale@ten1.workbench.test"] : [], }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -3097,7 +3175,7 @@ describe("createHubChatPlatform stale-definition reconciliation", () => { values: { id: "ses_stale", principalId: "prin_room1" }, }); const sessionService = createFakeSessionService(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -3184,7 +3262,7 @@ describe("createHubChatPlatform stale-definition reconciliation", () => { }), }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -3253,7 +3331,7 @@ describe("createHubChatPlatform relaunch sweep", () => { const sessionService = createFakeSessionService(); const assetService = createFakeAssetService(); const notices: unknown[] = []; - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService, @@ -3476,7 +3554,7 @@ describe("createHubChatPlatform inference-source rotation reconciliation", () => }), }, }); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -3718,7 +3796,7 @@ describe("createHubChatPlatform pinned-tool-package connect reconciliation", () }, }); const assetService = createFakeAssetService(); - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService: createFakeSessionService(), @@ -3727,7 +3805,6 @@ describe("createHubChatPlatform pinned-tool-package connect reconciliation", () routableAddresses: ["run_live@ten1.workbench.test"], }), eventCollectors: createFakeEventCollectors(), - credentialCipher: {} as never, pinnedPackageCredentialBindingsFor: async () => [MANUS_BINDING], }); return { db, platform, assetService }; diff --git a/packages/chat/test/relaunch-close.test.ts b/packages/chat/test/relaunch-close.test.ts index e3a6a7b1e..0d1e72b74 100644 --- a/packages/chat/test/relaunch-close.test.ts +++ b/packages/chat/test/relaunch-close.test.ts @@ -117,6 +117,10 @@ function createPlatform(db: never) { sidecarRouter: { getRoutableAddresses: () => [] } as never, eventCollectors: {} as never, cryptoProviders: createCryptoProviderCache(), + credentialCipher: { + encrypt: async (plaintext: string) => plaintext, + decrypt: async (blob: string) => blob, + }, }); } diff --git a/packages/connections/src/persist-credential.test.ts b/packages/connections/src/persist-credential.test.ts index 8a6a22fd2..ce9558a26 100644 --- a/packages/connections/src/persist-credential.test.ts +++ b/packages/connections/src/persist-credential.test.ts @@ -13,6 +13,7 @@ import type { ConnectorDescriptor } from "./descriptor"; import { isInferenceProvider, persistConnectorCredential, + type PersistConnectorCredentialArgs, } from "./persist-credential"; /** Minimal fixtures standing in for the real connector set a build's own * `templates/connectors.ts` carries — this package holds no concrete @@ -266,3 +267,12 @@ test("a refresh secret rides into the credential row alongside oauth_token typin refreshSecret: "1//refresh", }); }); + +test("persist does not take a credentialCipher — wiring is asserted at tag construction and hub boot, not per persist", () => { + type PersistHasCipher = + "credentialCipher" extends keyof PersistConnectorCredentialArgs + ? true + : false; + const persistHasCipher: PersistHasCipher = false; + expect(persistHasCipher).toBe(false); +}); diff --git a/packages/folded-runs/src/credential-cipher-tag.test.ts b/packages/folded-runs/src/credential-cipher-tag.test.ts new file mode 100644 index 000000000..2660df3fa --- /dev/null +++ b/packages/folded-runs/src/credential-cipher-tag.test.ts @@ -0,0 +1,50 @@ +// Tag construction is the one mint that runtime-checks a CredentialCipher's +// shape. Missing or wrong-shape input fails closed — the tag is not minted. +// Persist paths do not re-assert; they consume a tag already minted here +// (or at hub boot). +import { describe, expect, test } from "bun:test"; +import { tagCredentialCipher } from "./credential-cipher-tag"; + +const validCipher = { + encrypt: async (plaintext: string) => plaintext, + decrypt: async (blob: string) => blob, +}; + +describe("tagCredentialCipher", () => { + test("mints the same object when encrypt and decrypt are functions", () => { + expect(tagCredentialCipher(validCipher)).toBe(validCipher); + }); + + test("refuses to mint a tag when the cipher is missing", () => { + expect(() => tagCredentialCipher(undefined)).toThrow( + /missing or has the wrong shape/, + ); + expect(() => tagCredentialCipher(null)).toThrow( + /missing or has the wrong shape/, + ); + }); + + test("refuses to mint a tag when the cipher has the wrong shape", () => { + expect(() => tagCredentialCipher({})).toThrow( + /missing or has the wrong shape/, + ); + expect(() => tagCredentialCipher({ encrypt: async () => "" })).toThrow( + /missing or has the wrong shape/, + ); + expect(() => tagCredentialCipher({ decrypt: async () => "" })).toThrow( + /missing or has the wrong shape/, + ); + expect(() => + tagCredentialCipher({ + encrypt: "not-a-function", + decrypt: async () => "", + }), + ).toThrow(/missing or has the wrong shape/); + expect(() => + tagCredentialCipher({ + encrypt: async () => "", + decrypt: "not-a-function", + }), + ).toThrow(/missing or has the wrong shape/); + }); +}); diff --git a/packages/folded-runs/src/credential-cipher-tag.ts b/packages/folded-runs/src/credential-cipher-tag.ts new file mode 100644 index 000000000..cfa5facf4 --- /dev/null +++ b/packages/folded-runs/src/credential-cipher-tag.ts @@ -0,0 +1,28 @@ +// Runtime-checked mint of a CredentialCipher. The composition root +// comments that every secret-at-rest seam shares one real cipher; that +// guarantee is comment-only unless something actually inspects the +// object. This is that inspection: missing or wrong-shape input fails +// closed and the tag is not minted. Callers that persist secrets do not +// re-assert — they take a cipher already tagged here or at hub boot. +import type { CredentialCipher } from "@intx/types"; + +function isCredentialCipher(value: unknown): value is CredentialCipher { + if (value === null || typeof value !== "object") return false; + if (!("encrypt" in value) || !("decrypt" in value)) return false; + return ( + typeof value.encrypt === "function" && typeof value.decrypt === "function" + ); +} + +/** + * Mints a tagged `CredentialCipher` after a runtime shape check. + * Missing or wrong-shape input throws — the tag is not produced. + */ +export function tagCredentialCipher(cipher: unknown): CredentialCipher { + if (!isCredentialCipher(cipher)) { + throw new Error( + "credentialCipher is missing or has the wrong shape; refusing to mint a tagged cipher", + ); + } + return cipher; +} diff --git a/packages/folded-runs/src/index.ts b/packages/folded-runs/src/index.ts index 9fb8471d3..5302d0f51 100644 --- a/packages/folded-runs/src/index.ts +++ b/packages/folded-runs/src/index.ts @@ -59,5 +59,6 @@ export { type SendFoldedMailAttemptResult, type ListFoldedMailParams, } from "./mail"; +export { tagCredentialCipher } from "./credential-cipher-tag"; export { foldedRunsSchema, foldedRun } from "./schema"; export { lookupFoldedRunReconnectKey } from "./reconnect"; diff --git a/packages/folded-runs/src/types.ts b/packages/folded-runs/src/types.ts index 5ff0efeff..5e4546dfb 100644 --- a/packages/folded-runs/src/types.ts +++ b/packages/folded-runs/src/types.ts @@ -117,13 +117,13 @@ export type FoldedRunsDeps = { /** * Decrypts credential secrets when a launch resolves inference sources * against the tenant catalog (`resolveDefinitionSources`, called from - * `deployAtHead`). Optional: omitted, `resolveDefinitionSources` falls - * back to a noop cipher that returns a stored secret unchanged — correct - * only when the secret was itself written unencrypted. The composition - * root (`apps/hub`) must supply the same real cipher its credential - * write route encrypts with, or every folded-run launch (workbench hosts, - * invited agents, routines, tasks) decrypts nothing and hands the raw - * ciphertext to the provider as its API key. + * `deployAtHead`). The composition root tags this at hub boot + * (`hubCredentialCipher`) and again when minting a `FoldedRunsDeps` + * bag (`tagCredentialCipher`); missing or wrong-shape input fails + * closed rather than falling through to a noop cipher that would + * hand ciphertext to the provider as an API key. Optional only for + * callers that never resolve catalog secrets (a mint-only path, a + * test double that does not decrypt). Persist paths do not re-assert. */ credentialCipher?: CredentialCipher; /** See `ToolGrantsForPins`'s own doc. */ From 8b4cb0819cb01265283bfcf6cc5c50d8d0b5e871 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 15:47:37 -0700 Subject: [PATCH 2/3] Pass the tagged credential cipher into webhook launch (CL-7222) Webhook-triggered folded runs resolve catalog secrets at deploy. Omitting the boot-tagged cipher handed ciphertext to the provider as an API key. launchWebhookTrigger now tags at entry and fails closed if the cipher is missing or the wrong shape. --- apps/hub/src/index.ts | 1 + packages/webhook-triggers/src/launch.ts | 35 ++++++++++---- packages/webhook-triggers/test/launch.test.ts | 48 +++++++++++++++++++ 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 92df6b79b..b0c82d77d 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -2251,6 +2251,7 @@ export async function createHub(config: HubConfig) { assetService, sidecarRouter, eventCollectors, + credentialCipher, toolGrantsForPins, mcpCredentialBindingsFor, pinnedPackageCredentialBindingsFor, diff --git a/packages/webhook-triggers/src/launch.ts b/packages/webhook-triggers/src/launch.ts index 4fd29d365..066dfebe7 100644 --- a/packages/webhook-triggers/src/launch.ts +++ b/packages/webhook-triggers/src/launch.ts @@ -26,6 +26,7 @@ import { readDefinitionProjection, readFoldedBody, sendFoldedMailWithRetry, + tagCredentialCipher, type FoldedRunMode, type FoldedRunsDeps, type LaunchFoldedRunParams, @@ -34,7 +35,7 @@ import { import type { DB } from "@intx/db"; import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; import { generateId } from "@intx/hub-common"; -import { formatRunAddress } from "@intx/types"; +import { formatRunAddress, type CredentialCipher } from "@intx/types"; import { renderInputTemplate } from "./mapping"; import type { WebhookTriggerRow } from "./schema"; @@ -42,6 +43,14 @@ import type { WebhookTriggerRow } from "./schema"; export type LaunchWebhookTriggerDeps = FoldedRunsDeps & { db: DB["db"]; cryptoProviderCache: CryptoProviderCache; + /** + * Decrypts catalog secrets when the launched run resolves inference + * sources. Required: a webhook launch always goes through + * `launchFoldedRun`, and omitting the cipher would hand ciphertext to + * the provider as an API key. Tagged at entry — missing or wrong-shape + * input fails closed before any launch work. + */ + credentialCipher: CredentialCipher; /** * The shape the launched run deploys as — the host wires this to * `@corbits/chat`'s `AGENT_SECTION_MODE`, the same `onTrigger` @@ -97,7 +106,10 @@ export async function launchWebhookTrigger( trigger: WebhookTriggerRow, payload: unknown, ): Promise { - const definitionRow = await deps.db.query.workflowDefinition.findFirst({ + const credentialCipher = tagCredentialCipher(deps.credentialCipher); + const launchDeps = { ...deps, credentialCipher }; + + const definitionRow = await launchDeps.db.query.workflowDefinition.findFirst({ where: and( eq(workflowDefinition.id, trigger.workflowDefinitionId), eq(workflowDefinition.tenantId, trigger.tenantId), @@ -122,14 +134,17 @@ export async function launchWebhookTrigger( ); } - const tenantRow = await deps.db.query.tenant.findFirst({ + const tenantRow = await launchDeps.db.query.tenant.findFirst({ where: eq(tenantTable.id, trigger.tenantId), }); if (tenantRow === undefined) { throw new Error(`no tenant "${trigger.tenantId}"`); } - const projection = await readDefinitionProjection(deps.db, definitionRow); + const projection = await readDefinitionProjection( + launchDeps.db, + definitionRow, + ); const foldedBody = readFoldedBody( projection, definitionRow.grantRequirements, @@ -144,21 +159,21 @@ export async function launchWebhookTrigger( const instanceId = generateId("workflowRun"); const triggerAddress = formatRunAddress(instanceId, tenantRow.domain); - const launched = await launchFoldedRun(deps, { + const launched = await launchFoldedRun(launchDeps, { tenantId: trigger.tenantId, instanceId, triggerAddress, definitionId: trigger.workflowDefinitionId, foldedBody, launchLabel: `webhook trigger "${trigger.name}"`, - mode: deps.launchMode, - persistExtra: deps.persistLaunch({ + mode: launchDeps.launchMode, + persistExtra: launchDeps.persistLaunch({ tenantId: trigger.tenantId, instanceId, foldedBody, }), }); - await deps.recordLaunchSources({ + await launchDeps.recordLaunchSources({ instanceId, sourcesDigest: launched.sourcesDigest, }); @@ -167,8 +182,8 @@ export async function launchWebhookTrigger( // Keyed by the launched run's instance id (`generateId("workflowRun")`), // the same string shape chat uses for workbench ids — share the host's // process-wide cache so those lookups cannot mint different keys. - const cryptoProvider = await deps.cryptoProviderCache.get(instanceId); - const result = await sendFoldedMailWithRetry(deps, { + const cryptoProvider = await launchDeps.cryptoProviderCache.get(instanceId); + const result = await sendFoldedMailWithRetry(launchDeps, { tenantId: trigger.tenantId, sessionId: launched.sessionId, agentAddress: triggerAddress, diff --git a/packages/webhook-triggers/test/launch.test.ts b/packages/webhook-triggers/test/launch.test.ts index d92b0410c..551f00238 100644 --- a/packages/webhook-triggers/test/launch.test.ts +++ b/packages/webhook-triggers/test/launch.test.ts @@ -91,6 +91,11 @@ const TRIGGER = { lastFiredAt: null, }; +const taggedCipher = { + encrypt: async (plaintext: string) => plaintext, + decrypt: async (blob: string) => blob, +}; + let persistLaunchCalls: unknown[] = []; let recordLaunchSourcesCalls: unknown[] = []; const persistedLaunchExtra = async () => {}; @@ -103,6 +108,7 @@ function baseDeps() { sidecarRouter: {} as never, toolGrantsForPins: () => [], eventCollectors: {} as never, + credentialCipher: taggedCipher, cryptoProviderCache: { get: async () => ({}) as never }, launchMode: { kind: "section" as const, turnTimeoutMs: 60_000 }, persistLaunch: (input: unknown) => { @@ -237,4 +243,46 @@ describe("launchWebhookTrigger", () => { { instanceId: result.instanceId, sourcesDigest: "digest_run1" }, ]); }); + + // Catalog secrets are encrypted at rest. Without the boot-tagged cipher + // on this path, launchFoldedRun would hand ciphertext to the provider as + // an API key. Tag construction is the fail-closed gate. + test("threads the tagged credentialCipher into launchFoldedRun", async () => { + launchFoldedRunCalls = []; + sendFoldedMailWithRetryCalls = []; + sendFoldedMailWithRetryResult = { ok: true, mail: { id: "m_1" } }; + + await launchWebhookTrigger(baseDeps(), TRIGGER, { status: "ok" }); + + expect(launchFoldedRunCalls).toHaveLength(1); + const [foldedDeps] = launchFoldedRunCalls[0] as [ + { credentialCipher: unknown }, + unknown, + ]; + expect(foldedDeps.credentialCipher).toBe(taggedCipher); + }); + + test("refuses to launch when credentialCipher is missing", async () => { + launchFoldedRunCalls = []; + await expect( + launchWebhookTrigger( + { ...baseDeps(), credentialCipher: undefined as never }, + TRIGGER, + { status: "ok" }, + ), + ).rejects.toThrow(/missing or has the wrong shape/); + expect(launchFoldedRunCalls).toHaveLength(0); + }); + + test("refuses to launch when credentialCipher has the wrong shape", async () => { + launchFoldedRunCalls = []; + await expect( + launchWebhookTrigger( + { ...baseDeps(), credentialCipher: {} as never }, + TRIGGER, + { status: "ok" }, + ), + ).rejects.toThrow(/missing or has the wrong shape/); + expect(launchFoldedRunCalls).toHaveLength(0); + }); }); From e2ad34466f083800d9d76c64764502ee23a9fb42 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 23:08:40 -0700 Subject: [PATCH 3/3] Fix test to use default-cipher helper after crypto-provider-cache rebase Rebasing onto main's shared cryptoProviders cache (#620) left one platform-adapter test calling the raw createHubChatPlatform wrapper instead of createPlatform, so it no longer got a default credentialCipher and failed to mint. --- packages/chat/test/platform-adapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index 6eb743f4d..7c223fc03 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -1073,7 +1073,7 @@ describe("createHubChatPlatform", () => { }, }; - const platform = createHubChatPlatform({ + const platform = createPlatform({ toolGrantsForPins: () => [], db: db as never, sessionService,