From 9c15d348fa93059fbcb80917af5b259077b7aa64 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:53:42 -0500 Subject: [PATCH] feat: consume credential leases in tool calls (#969) (#972) --- AGENTS.md | 5 +- CHANGELOG.md | 7 + docs/AGENT-PROVIDERS.md | 5 +- docs/API-REFERENCE.md | 7 +- docs/CREDENTIAL-BROKER.md | 14 +- docs/FEATURES.md | 6 +- docs/architecture/TOOL-CONTROL-PLANE-V1.md | 17 +- .../tool-control-plane-service.test.ts | 273 +++++++++++++++++- server/src/routes/tool-control-plane.ts | 7 +- .../src/services/credential-broker-service.ts | 27 +- .../services/tool-control-plane-service.ts | 235 ++++++++++++--- shared/src/types/credential-broker.types.ts | 4 + 12 files changed, 534 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index daba5ce8..f8f756a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,8 +134,9 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise, - Credential-bound tool servers persist only exact definition/scope digests and safe target names in `run-tool-catalog/v1`. Discovery strips their source environment/header values, native provider injection omits them, and - mediated invocation remains blocked until exact-action lease consumption is - available. + mediated invocation issues exact-action leases using the server-owned launch + manifest digest. Credential-bound sessions are one-shot and raw values may + exist only inside the controlled downstream dispatch callback. - Classify launch credentials through `run-launch-credential-plan/v1`. Provider boot authentication, task integration definition IDs, and explicit high-risk environment passthrough are separate classes. Task integration diff --git a/CHANGELOG.md b/CHANGELOG.md index a703b03c..22bb3ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added exact-action credential lease consumption to mediated run-tool calls. + The server binds each operation to the active launch manifest, catalog, + server, tool, arguments digest, approval, and operation ID; resolves values + only inside a one-shot downstream MCP session; rejects replay, drift, + approval mismatch, unavailable sources, and credential-bearing results; and + relies on the existing terminal lifecycle reconciliation to revoke leases + (#969). - Added value-free credential boundary evidence to `run-tool-catalog/v1`. Credential-bound definitions now require enabled MCP-scoped broker definitions, exact source targets, and immutable definition/scope digests. diff --git a/docs/AGENT-PROVIDERS.md b/docs/AGENT-PROVIDERS.md index a8e39592..20fb94e4 100644 --- a/docs/AGENT-PROVIDERS.md +++ b/docs/AGENT-PROVIDERS.md @@ -761,8 +761,9 @@ This core does not make an uncontrolled provider process broker-capable. Credential-bound tool definitions compile only when enabled broker definitions, MCP scopes, source targets, and immutable catalog evidence match. They are omitted from native provider MCP configuration and provider environment -passthrough. Mediated calls remain blocked until #969 consumes exact-action -leases, and system-owned provider bridge injection remains under #970. +passthrough. Mediated calls consume exact-action leases using the server-owned +launch-manifest digest and one-shot downstream sessions. System-owned provider +bridge injection remains under #970. Model-provider boot authentication and explicit `env-passthrough` compatibility remain separate, high-risk paths and are never labeled as brokered. See [Credential Broker](CREDENTIAL-BROKER.md). diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index efee05d8..89c0d4e4 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -3807,8 +3807,11 @@ native provider configuration omits credential-bound entries. New launch manifests classify a task reference as a brokered tool-control-plane boundary only when the exact run catalog covers it. -Uncovered references remain blocked. Credential-bound calls remain disabled -until #969 adds exact-action lease consumption. +Uncovered references remain blocked. Credential-bound calls derive the exact +MCP action and receive the active launch-manifest digest from the server, not +the request body. Values are resolved only inside a one-shot downstream +dispatch callback; replays, stale evidence, approval mismatch, unavailable +sources, and credential-bearing results fail closed. See [Tool Control Plane v1](architecture/TOOL-CONTROL-PLANE-V1.md). --- diff --git a/docs/CREDENTIAL-BROKER.md b/docs/CREDENTIAL-BROKER.md index 2f726c40..35b413da 100644 --- a/docs/CREDENTIAL-BROKER.md +++ b/docs/CREDENTIAL-BROKER.md @@ -17,11 +17,11 @@ The v6 foundation includes: authentication, task integration references, and high-risk compatibility passthrough without storing values. -The tool control plane can now compile value-free credential boundary evidence -into a run catalog. A handle in a prompt or provider environment is still not a -security boundary: credential-bound native server injection is omitted, and -mediated calls stay blocked until exact-action lease consumption lands in -#969. +The tool control plane compiles value-free credential boundary evidence into a +run catalog and consumes leases only inside mediated tool calls. A handle in a +prompt or provider environment is still not a security boundary: +credential-bound native server injection remains omitted, and automatic +system-owned provider bridge injection is tracked by #970. ## Credential classes @@ -157,7 +157,9 @@ consumption belongs to the run-scoped egress gateway; controlled MCP/tool consumption belongs to the tool-server control plane. A task reference is reported as brokered only when an immutable run catalog contains the matching credential-definition and scope digests. Uncovered references still block -launch. Catalog evidence alone never resolves a value or enables dispatch. +launch. During a mediated call, the server-owned manifest digest and exact +catalog action issue a lease; its source value exists only inside the +downstream dispatch callback. ## Rotation and revocation diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5ed7f467..1e810c26 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -373,8 +373,10 @@ persists an immutable `run-tool-catalog/v1` digest in the launch manifest. - Credential-bound entries compile only with enabled, scope-compatible broker definitions. Their definition/scope digests and safe target names enter the immutable catalog, discovery receives no source values, and native provider - injection omits them. Mediated calls remain blocked until exact-action lease - consumption lands in #969. + injection omits them. Mediated calls issue exact-action leases using the + server-owned launch manifest, deliver values only inside one-shot downstream + sessions, and reject replay, drift, approval mismatch, source failure, or + credential-bearing results. See [Tool Control Plane v1](architecture/TOOL-CONTROL-PLANE-V1.md). diff --git a/docs/architecture/TOOL-CONTROL-PLANE-V1.md b/docs/architecture/TOOL-CONTROL-PLANE-V1.md index 943c9033..db31ea8a 100644 --- a/docs/architecture/TOOL-CONTROL-PLANE-V1.md +++ b/docs/architecture/TOOL-CONTROL-PLANE-V1.md @@ -144,10 +144,19 @@ configuration and from provider environment passthrough. The launch credential plan reports `brokerState: supported` only when every selected sandbox credential reference has this exact tool-control-plane evidence. -This slice does not resolve a source value. Mediated invocation remains blocked -until #969 adds exact-action lease consumption, and system-owned provider -bridge injection remains under #970. Credential-shaped command arguments and -recognizable credential literals remain forbidden. +Mediated invocation now derives one canonical MCP credential action from the +exact server, tool, arguments, and catalog digest. The route supplies the +active launch-manifest digest internally. Each catalog binding issues and +consumes a unique run lease inside nested controlled callbacks, opens a +one-shot downstream MCP session with the resolved environment/header values, +and closes it before the callback returns. Results are checked for credential +material before they leave the broker. + +Approval-required definitions reuse the durable approval for the same +operation and credential-action fingerprint. Replayed operations, caller +manifest overrides, stale run bindings, changed definitions/scopes, mismatched +approvals, unavailable sources, and credential-bearing results fail closed. +System-owned provider bridge injection remains under #970. ## Operator Surfaces diff --git a/server/src/__tests__/tool-control-plane-service.test.ts b/server/src/__tests__/tool-control-plane-service.test.ts index aafcded5..767132b0 100644 --- a/server/src/__tests__/tool-control-plane-service.test.ts +++ b/server/src/__tests__/tool-control-plane-service.test.ts @@ -12,9 +12,14 @@ import type { ToolServerDefinitionInput, } from '@veritas-kanban/shared'; import { ToolControlPlaneService } from '../services/tool-control-plane-service.js'; +import { + CredentialBrokerService, + EnvironmentCredentialSecretSource, +} from '../services/credential-broker-service.js'; import type { RunApprovalBrokerService } from '../services/run-approval-broker-service.js'; import { RunEventJournalService } from '../services/run-event-journal-service.js'; import { FileRunEventRepository } from '../storage/run-event-repository.js'; +import { InMemoryCredentialBrokerRepository } from '../storage/credential-broker-repository.js'; import { FileToolControlPlaneRepository, InMemoryToolControlPlaneRepository, @@ -24,6 +29,8 @@ import { SqliteToolControlPlaneRepository } from '../storage/sqlite/tool-control import { calculateCredentialDefinitionDigest } from '../utils/credential-broker-digest.js'; const DIGEST = `sha256:${'a'.repeat(64)}`; +const MANIFEST_DIGEST = `sha256:${'f'.repeat(64)}`; +const CREDENTIAL_SECRET = 'credential-sensitive-value'; const roots: string[] = []; afterEach(async () => { @@ -68,10 +75,10 @@ function credentialDefinition( scope: { dispatchTypes: ['mcp'], hosts: [], - tools: ['fixture/search'], + tools: ['search'], destinations: [], methods: [], - actions: ['fixture/search'], + actions: ['fixture.search'], pathPrefixes: [], }, lease: { @@ -103,6 +110,10 @@ function fixture( environment?: NodeJS.ProcessEnv; journal?: RunEventJournalService; credentialDefinitions?: CredentialDefinition[]; + credentialBroker?: Pick< + CredentialBrokerService, + 'getDefinition' | 'issueLease' | 'withCredential' + >; } = {} ) { const requests: Array<{ method: string; params: Record }> = []; @@ -164,12 +175,25 @@ function fixture( runtime: { open }, journal: options.journal ?? ({ append } as unknown as RunEventJournalService), approvals: { request: requestApproval } as unknown as RunApprovalBrokerService, - credentialBroker: { - getDefinition: vi.fn(async (id: string) => { - const credential = options.credentialDefinitions?.find((candidate) => candidate.id === id); - return credential ? structuredClone(credential) : null; - }), - }, + credentialBroker: + options.credentialBroker ?? + ({ + getDefinition: vi.fn(async (id: string) => { + const credential = options.credentialDefinitions?.find( + (candidate) => candidate.id === id + ); + return credential ? structuredClone(credential) : null; + }), + issueLease: vi.fn(async () => { + throw new Error('Credential lease fixture was not configured.'); + }), + withCredential: vi.fn(async () => { + throw new Error('Credential use fixture was not configured.'); + }), + } as unknown as Pick< + CredentialBrokerService, + 'getDefinition' | 'issueLease' | 'withCredential' + >), now: () => new Date('2026-07-24T12:00:00.000Z'), environment: options.environment ?? {}, }); @@ -554,7 +578,234 @@ describe('ToolControlPlaneService', () => { }, 'agent-a' ) - ).rejects.toThrow('mediated lease consumption'); + ).rejects.toThrow('server-owned launch manifest digest'); + }); + + it('issues, consumes, dispatches, and revokes a credential lease inside one mediated call', async () => { + const repository = new InMemoryCredentialBrokerRepository(); + let handleSequence = 0; + const credentialEnvironment: NodeJS.ProcessEnv = { + FIXTURE_TOKEN: CREDENTIAL_SECRET, + }; + const broker = new CredentialBrokerService({ + repository, + secretSources: [new EnvironmentCredentialSecretSource(credentialEnvironment)], + runBindings: { + read: vi.fn(async () => ({ + taskId: 'task-tools', + attemptId: 'attempt-tools', + status: 'running' as const, + runLaunchManifestDigest: MANIFEST_DIGEST, + credentialReferences: ['github-token'], + })), + }, + audit: vi.fn(async () => undefined), + createHandle: () => `vkcred_tool_bridge_fixture_${++handleSequence}`, + now: () => new Date('2026-07-24T12:00:00.000Z'), + }); + const { + schemaVersion: _schemaVersion, + digest: _digest, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...credentialInput + } = credentialDefinition({ + lease: { ttlSeconds: 60, maxUses: 2, renewable: false }, + }); + await broker.createDefinition(credentialInput); + const { service, open, append } = fixture({ credentialBroker: broker }); + const runCatalog = await catalog(service, { + transport: { + kind: 'stdio', + command: '/usr/bin/fixture', + args: [], + environmentKeys: ['FIXTURE_TOKEN'], + credentialReferences: ['github-token'], + }, + }); + if (!runCatalog) throw new Error('Expected a run tool catalog.'); + + const result = await service.invoke( + { + taskId: 'task-tools', + attemptId: 'attempt-tools', + serverId: 'fixture', + tool: 'search', + arguments: { query: 'kanban' }, + operationId: 'brokered-operation', + }, + 'agent-a', + '/tmp/worktree', + MANIFEST_DIGEST + ); + + expect(result).toMatchObject({ + serverId: 'fixture', + tool: 'search', + isError: false, + }); + expect(open.mock.calls.at(-1)?.[2]).toEqual({ + environment: { FIXTURE_TOKEN: CREDENTIAL_SECRET }, + headers: {}, + }); + expect(append).toHaveBeenCalledTimes(2); + expect(JSON.stringify({ runCatalog, result })).not.toContain(CREDENTIAL_SECRET); + expect(await broker.listLeases()).toEqual([ + expect.objectContaining({ + definitionId: 'github-token', + state: 'active', + uses: 1, + runLaunchManifestDigest: MANIFEST_DIGEST, + }), + ]); + await expect( + service.invoke( + { + taskId: 'task-tools', + attemptId: 'attempt-tools', + serverId: 'fixture', + tool: 'search', + arguments: { query: 'changed' }, + operationId: 'brokered-operation', + }, + 'agent-a', + '/tmp/worktree', + MANIFEST_DIGEST + ) + ).rejects.toThrow('already dispatched'); + await expect( + service.invoke( + { + taskId: 'task-tools', + attemptId: 'attempt-tools', + serverId: 'fixture', + tool: 'search', + arguments: { query: 'kanban' }, + operationId: 'stale-manifest-operation', + }, + 'agent-a', + '/tmp/worktree', + `sha256:${'e'.repeat(64)}` + ) + ).rejects.toThrow('run or launch manifest binding is stale'); + delete credentialEnvironment.FIXTURE_TOKEN; + await expect( + service.invoke( + { + taskId: 'task-tools', + attemptId: 'attempt-tools', + serverId: 'fixture', + tool: 'search', + arguments: { query: 'kanban' }, + operationId: 'source-unavailable-operation', + }, + 'agent-a', + '/tmp/worktree', + MANIFEST_DIGEST + ) + ).rejects.toThrow('source is unavailable'); + + await broker.revokeRun({ + taskId: 'task-tools', + attemptId: 'attempt-tools', + runLaunchManifestDigest: MANIFEST_DIGEST, + reason: 'run-completed', + }); + expect(await broker.listLeases()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ state: 'revoked', terminalReason: 'run-completed' }), + expect.objectContaining({ state: 'blocked', terminalReason: 'source-unavailable' }), + ]) + ); + }); + + it('correlates credential approval with the exact durable tool action', async () => { + const verifyApproval = vi.fn( + async ({ + actionFingerprint, + approvalId, + operationId, + }: { + actionFingerprint: string; + approvalId?: string; + operationId?: string; + }) => ({ + approved: approvalId === 'runapproval_fixture001' && operationId === 'approval-operation', + approvalId, + actionFingerprint, + }) + ); + const broker = new CredentialBrokerService({ + repository: new InMemoryCredentialBrokerRepository(), + secretSources: [ + new EnvironmentCredentialSecretSource({ + FIXTURE_TOKEN: CREDENTIAL_SECRET, + }), + ], + runBindings: { + read: vi.fn(async () => ({ + taskId: 'task-tools', + attemptId: 'attempt-tools', + status: 'running' as const, + runLaunchManifestDigest: MANIFEST_DIGEST, + credentialReferences: ['github-token'], + })), + }, + approvals: { verify: verifyApproval }, + audit: vi.fn(async () => undefined), + createHandle: () => 'vkcred_tool_approval_fixture', + now: () => new Date('2026-07-24T12:00:00.000Z'), + }); + const { + schemaVersion: _schemaVersion, + digest: _digest, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...credentialInput + } = credentialDefinition({ approval: 'required' }); + await broker.createDefinition(credentialInput); + const { service } = fixture({ credentialBroker: broker, approval: 'approved' }); + await catalog(service, { + transport: { + kind: 'stdio', + command: '/usr/bin/fixture', + args: [], + environmentKeys: ['FIXTURE_TOKEN'], + credentialReferences: ['github-token'], + }, + }); + const invocation = { + taskId: 'task-tools', + attemptId: 'attempt-tools', + serverId: 'fixture', + tool: 'search', + arguments: { query: 'kanban' }, + operationId: 'approval-operation', + }; + + await expect( + service.invoke( + { ...invocation, approvalId: 'wrong-approval' }, + 'agent-a', + '/tmp/worktree', + MANIFEST_DIGEST + ) + ).rejects.toThrow('Approval identity does not match'); + await expect( + service.invoke( + { ...invocation, approvalId: 'runapproval_fixture001' }, + 'agent-a', + '/tmp/worktree', + MANIFEST_DIGEST + ) + ).resolves.toMatchObject({ isError: false }); + expect(verifyApproval).toHaveBeenCalledWith( + expect.objectContaining({ + approvalId: 'runapproval_fixture001', + operationId: 'approval-operation', + actionFingerprint: expect.stringMatching(/^sha256:/), + }) + ); }); it('fails closed when credential evidence is missing, disabled, out of scope, or unmapped', async () => { @@ -587,8 +838,8 @@ describe('ToolControlPlaneService', () => { credentialDefinition({ scope: { ...credentialDefinition().scope, - tools: ['fixture/other'], - actions: ['fixture/other'], + tools: ['other'], + actions: ['fixture.other'], }, }), ], diff --git a/server/src/routes/tool-control-plane.ts b/server/src/routes/tool-control-plane.ts index c3c273c2..7949b9ea 100644 --- a/server/src/routes/tool-control-plane.ts +++ b/server/src/routes/tool-control-plane.ts @@ -77,7 +77,12 @@ router.post( throw new ConflictError('Active launch evidence does not match the run tool catalog.'); } res.json( - await service.invoke(input, actorId(req as AuthenticatedRequest), task.git?.worktreePath) + await service.invoke( + input, + actorId(req as AuthenticatedRequest), + task.git?.worktreePath, + task.attempt.runLaunchManifest.digest + ) ); }) ); diff --git a/server/src/services/credential-broker-service.ts b/server/src/services/credential-broker-service.ts index f9c13a7f..6cd0beea 100644 --- a/server/src/services/credential-broker-service.ts +++ b/server/src/services/credential-broker-service.ts @@ -41,6 +41,7 @@ import { import { parseRunLaunchManifest } from '../schemas/run-launch-manifest-schemas.js'; import { TaskService } from './task-service.js'; import { auditLog } from './audit-service.js'; +import { getRunApprovalBrokerService } from './run-approval-broker-service.js'; const CREDENTIAL_PLACEHOLDER_PATTERN = /\{\{vk-credential:(vkcred_[A-Za-z0-9_-]{3,})\}\}/g; const MAX_AUDIT_EVENTS = 5000; @@ -89,6 +90,8 @@ export interface CredentialApprovalVerifier { binding: CredentialRunBinding; action: CredentialAction; actionFingerprint: string; + approvalId?: string; + operationId?: string; }): Promise<{ approved: boolean; approvalId?: string; @@ -145,9 +148,23 @@ class TaskCredentialRunBindingReader implements CredentialRunBindingReader { } } -const denyRequiredApproval: CredentialApprovalVerifier = { - async verify({ actionFingerprint }) { - return { approved: false, actionFingerprint }; +const durableRunApprovalVerifier: CredentialApprovalVerifier = { + async verify({ binding, actionFingerprint, approvalId, operationId }) { + if (!approvalId || !operationId) return { approved: false, actionFingerprint }; + try { + const approval = await getRunApprovalBrokerService().get(approvalId); + return { + approved: + approval.status === 'approved' && + approval.taskId === binding.taskId && + approval.attemptId === binding.attemptId && + approval.providerRequestId === `tool:${operationId}:${actionFingerprint}`, + approvalId: approval.id, + actionFingerprint, + }; + } catch { + return { approved: false, actionFingerprint }; + } }, }; @@ -164,7 +181,7 @@ export class CredentialBrokerService { this.repository = options.repository ?? new FileCredentialBrokerRepository(); this.secretSources = options.secretSources ?? [new EnvironmentCredentialSecretSource()]; this.runBindings = options.runBindings ?? new TaskCredentialRunBindingReader(); - this.approvals = options.approvals ?? denyRequiredApproval; + this.approvals = options.approvals ?? durableRunApprovalVerifier; this.auditSink = options.audit ?? (async (event) => { @@ -419,6 +436,8 @@ export class CredentialBrokerService { binding, action, actionFingerprint, + approvalId: request.approvalId, + operationId: request.operationId, }) .catch(() => ({ approved: false, diff --git a/server/src/services/tool-control-plane-service.ts b/server/src/services/tool-control-plane-service.ts index 249027b2..4c980365 100644 --- a/server/src/services/tool-control-plane-service.ts +++ b/server/src/services/tool-control-plane-service.ts @@ -8,6 +8,7 @@ import { TOOL_SERVER_DEFINITION_SCHEMA_VERSION, type ExecutableAgentProvider, type AcpMcpServer, + type CredentialAction, type CredentialDefinition, type RunToolCatalog, type RunToolCatalogEntry, @@ -39,7 +40,10 @@ import { calculateToolDiscoveryDigest, calculateToolServerDefinitionDigest, } from '../utils/tool-control-plane-digest.js'; -import { calculateCredentialScopeDigest } from '../utils/credential-broker-digest.js'; +import { + calculateCredentialActionFingerprint, + calculateCredentialScopeDigest, +} from '../utils/credential-broker-digest.js'; import { digestRunLaunchValue } from '../utils/run-launch-manifest-digest.js'; import { RunApprovalBrokerService } from './run-approval-broker-service.js'; import { RunEventJournalService } from './run-event-journal-service.js'; @@ -65,7 +69,16 @@ interface RpcSession { } interface ToolControlPlaneRuntime { - open(definition: ToolServerDefinition, cwd?: string): Promise; + open( + definition: ToolServerDefinition, + cwd?: string, + credentials?: ToolCredentialDelivery + ): Promise; +} + +interface ToolCredentialDelivery { + environment: Record; + headers: Record; } export interface PrepareRunToolCatalogInput { @@ -86,7 +99,10 @@ export interface ToolControlPlaneServiceOptions { runtime?: ToolControlPlaneRuntime; journal?: RunEventJournalService; approvals?: RunApprovalBrokerService; - credentialBroker?: Pick; + credentialBroker?: Pick< + CredentialBrokerService, + 'getDefinition' | 'issueLease' | 'withCredential' + >; now?: () => Date; environment?: NodeJS.ProcessEnv; } @@ -105,7 +121,10 @@ export class ToolControlPlaneService { private readonly runtime: ToolControlPlaneRuntime; private readonly journal: RunEventJournalService; private readonly approvals: RunApprovalBrokerService; - private readonly credentialBroker: Pick; + private readonly credentialBroker: Pick< + CredentialBrokerService, + 'getDefinition' | 'issueLease' | 'withCredential' + >; private readonly now: () => Date; private readonly environment: NodeJS.ProcessEnv; private readonly sessions = new Map>(); @@ -388,7 +407,8 @@ export class ToolControlPlaneService { async invoke( request: ToolInvocationRequest, actorId: string, - cwd?: string + cwd?: string, + runLaunchManifestDigest?: string ): Promise { const catalog = await this.getRunCatalog(request.taskId, request.attemptId); const entry = catalog.entries.find((candidate) => candidate.serverId === request.serverId); @@ -410,18 +430,21 @@ export class ToolControlPlaneService { }); } const definition = await this.getCatalogDefinition(entry); - if ((entry.credentialBindings?.length ?? 0) > 0) { - throw new ConflictError( - 'Credential-bound tool calls require mediated lease consumption through the Veritas bridge.', - { - serverId: entry.serverId, - catalogDigest: catalog.digest, - remediation: 'Complete #969 before invoking this credential-bound tool.', - } - ); - } + const credentialBound = (entry.credentialBindings?.length ?? 0) > 0; + const credentialAction = credentialBound + ? this.credentialAction(catalog, entry, tool.name, request.arguments) + : undefined; + const credentialActionFingerprint = credentialAction + ? calculateCredentialActionFingerprint(credentialAction) + : undefined; + const credentialApprovalRequired = + credentialBound && (await this.credentialApprovalRequired(entry)); + let approvedRequestId: string | undefined; - if (tool.decision === 'approval') { + if (tool.decision === 'approval' || credentialApprovalRequired) { + const providerRequestId = credentialActionFingerprint + ? `tool:${request.operationId}:${credentialActionFingerprint}` + : `tool:${request.operationId}`; const approval = await this.approvals.request({ taskId: request.taskId, attemptId: request.attemptId, @@ -435,11 +458,12 @@ export class ToolControlPlaneService { tool: tool.name, arguments: request.arguments, catalogDigest: catalog.digest, + ...(credentialActionFingerprint ? { credentialActionFingerprint } : {}), }, resourceScope: [entry.serverId, tool.name], riskClass: 'high', evidenceRevision: catalog.digest, - providerRequestId: `tool:${request.operationId}`, + providerRequestId, mobileSafe: false, }); if (request.approvalId && request.approvalId !== approval.id) { @@ -453,6 +477,7 @@ export class ToolControlPlaneService { actionHash: approval.actionHash, }); } + approvedRequestId = approval.id; } const started = await this.journal.append({ @@ -484,17 +509,46 @@ export class ToolControlPlaneService { let sessionPromise: Promise | undefined; let session: RpcSession | undefined; try { - sessionPromise = this.sessions.get(sessionKey); - if (!sessionPromise) { - sessionPromise = this.openSession(definition, cwd); - this.sessions.set(sessionKey, sessionPromise); + let response: unknown; + if (credentialBound && credentialAction) { + if (!runLaunchManifestDigest) { + throw new ConflictError( + 'Credential-bound tool calls require the server-owned launch manifest digest.' + ); + } + response = await this.withCredentialDelivery({ + request, + entry, + action: credentialAction, + runLaunchManifestDigest, + approvalId: approvedRequestId, + dispatch: async (credentials) => { + session = await this.openSession(definition, cwd, credentials); + try { + return await session.request( + 'tools/call', + { name: tool.name, arguments: request.arguments }, + definition.toolTimeoutMs + ); + } finally { + await session.close().catch(() => undefined); + session = undefined; + } + }, + }); + } else { + sessionPromise = this.sessions.get(sessionKey); + if (!sessionPromise) { + sessionPromise = this.openSession(definition, cwd); + this.sessions.set(sessionKey, sessionPromise); + } + session = await sessionPromise; + response = await session.request( + 'tools/call', + { name: tool.name, arguments: request.arguments }, + definition.toolTimeoutMs + ); } - session = await sessionPromise; - const response = await session.request( - 'tools/call', - { name: tool.name, arguments: request.arguments }, - definition.toolTimeoutMs - ); assertBoundedJson(response, MAX_RPC_BYTES, 'Tool result'); const resultRecord = response && typeof response === 'object' && !Array.isArray(response) @@ -744,8 +798,97 @@ export class ToolControlPlaneService { return validator; } - private async openSession(definition: ToolServerDefinition, cwd?: string): Promise { - const session = await this.runtime.open(definition, cwd); + private credentialAction( + catalog: RunToolCatalog, + entry: RunToolCatalogEntry, + tool: string, + argumentsValue: Record + ): CredentialAction { + return { + dispatchType: 'mcp', + tool, + action: `${entry.serverId}.${tool}`, + argumentsDigest: digestRunLaunchValue({ + catalogDigest: catalog.digest, + serverId: entry.serverId, + tool, + arguments: argumentsValue, + }), + }; + } + + private async credentialApprovalRequired(entry: RunToolCatalogEntry): Promise { + for (const binding of entry.credentialBindings ?? []) { + const definition = await this.credentialBroker.getDefinition(binding.credentialReference); + if ( + !definition || + !definition.enabled || + definition.digest !== binding.credentialDefinitionDigest + ) { + throw new ConflictError('Credential definition changed before approval evaluation.', { + credentialReference: binding.credentialReference, + serverId: entry.serverId, + }); + } + if (definition.approval === 'required') return true; + } + return false; + } + + private async withCredentialDelivery(input: { + request: ToolInvocationRequest; + entry: RunToolCatalogEntry; + action: CredentialAction; + runLaunchManifestDigest: string; + approvalId?: string; + dispatch: (credentials: ToolCredentialDelivery) => Promise; + }): Promise { + const bindings = input.entry.credentialBindings ?? []; + const delivery: ToolCredentialDelivery = { environment: {}, headers: {} }; + const consume = async (index: number): Promise => { + const binding = bindings[index]; + if (!binding) return input.dispatch(delivery); + const issued = await this.credentialBroker.issueLease({ + definitionId: binding.credentialReference, + taskId: input.request.taskId, + attemptId: input.request.attemptId, + runLaunchManifestDigest: input.runLaunchManifestDigest, + action: input.action, + approvalId: input.approvalId, + operationId: input.request.operationId, + }); + const leaseOperationId = digestRunLaunchValue({ + operationId: input.request.operationId, + credentialReference: binding.credentialReference, + }); + return this.credentialBroker.withCredential( + { + handle: issued.handle, + operationId: leaseOperationId, + taskId: input.request.taskId, + attemptId: input.request.attemptId, + runLaunchManifestDigest: input.runLaunchManifestDigest, + action: input.action, + }, + async (credential) => { + if (binding.target.kind === 'environment') { + delivery.environment[binding.target.name] = credential; + } else { + delivery.headers[binding.target.name] = credential; + } + return consume(index + 1); + } + ); + }; + return consume(0); + } + + private async openSession( + definition: ToolServerDefinition, + cwd?: string, + credentials?: ToolCredentialDelivery + ): Promise { + const session = await this.runtime.open(definition, cwd, credentials); try { await initializeSession(session, definition.startupTimeoutMs, definition.version); return session; @@ -855,6 +998,7 @@ export class ToolControlPlaneService { } for (const tool of discovery.tools) { const qualifiedName = `${definition.id}/${tool.name}`; + const credentialAction = `${definition.id}.${tool.name}`; if ( scope.tools.length > 0 && !scope.tools.includes(tool.name) && @@ -869,14 +1013,14 @@ export class ToolControlPlaneService { if ( scope.actions.length > 0 && !scope.actions.includes(tool.name) && - !scope.actions.includes(qualifiedName) + !scope.actions.includes(credentialAction) ) { throw new ConflictError( 'Credential definition does not accept every discovered MCP action.', { credentialReference: credential.id, serverId: definition.id, - action: qualifiedName, + action: credentialAction, } ); } @@ -1008,10 +1152,14 @@ export function getToolControlPlaneService(): ToolControlPlaneService { class McpJsonRpcRuntime implements ToolControlPlaneRuntime { constructor(private readonly environment: NodeJS.ProcessEnv) {} - async open(definition: ToolServerDefinition, cwd?: string): Promise { + async open( + definition: ToolServerDefinition, + cwd?: string, + credentials?: ToolCredentialDelivery + ): Promise { return definition.transport.kind === 'stdio' - ? new StdioRpcSession(definition, this.environment, cwd) - : new HttpRpcSession(definition, this.environment); + ? new StdioRpcSession(definition, this.environment, cwd, credentials?.environment) + : new HttpRpcSession(definition, this.environment, credentials?.headers); } } @@ -1030,9 +1178,17 @@ class StdioRpcSession implements RpcSession { private stderr = ''; private closed = false; - constructor(definition: ToolServerDefinition, environment: NodeJS.ProcessEnv, cwd?: string) { + constructor( + definition: ToolServerDefinition, + environment: NodeJS.ProcessEnv, + cwd?: string, + credentialEnvironment: Record = {} + ) { if (definition.transport.kind !== 'stdio') throw new Error('Expected stdio definition.'); - const env = minimalProcessEnvironment(environment, definition.transport.environmentKeys); + const env = { + ...minimalProcessEnvironment(environment, definition.transport.environmentKeys), + ...credentialEnvironment, + }; this.child = spawn(definition.transport.command, definition.transport.args, { cwd, env, @@ -1141,7 +1297,8 @@ class HttpRpcSession implements RpcSession { constructor( private readonly definition: ToolServerDefinition, - private readonly environment: NodeJS.ProcessEnv + private readonly environment: NodeJS.ProcessEnv, + private readonly credentialHeaders: Record = {} ) {} async request( @@ -1182,7 +1339,7 @@ class HttpRpcSession implements RpcSession { }; if (this.protocolVersion) headers['mcp-protocol-version'] = this.protocolVersion; for (const header of this.definition.transport.headers) { - const value = this.environment[header.environmentKey]; + const value = this.credentialHeaders[header.name] ?? this.environment[header.environmentKey]; if (value) headers[header.name] = value; } const response = await fetch(this.definition.transport.url, { @@ -1209,7 +1366,7 @@ class HttpRpcSession implements RpcSession { if (this.sessionId) headers['mcp-session-id'] = this.sessionId; if (this.protocolVersion) headers['mcp-protocol-version'] = this.protocolVersion; for (const header of this.definition.transport.headers) { - const value = this.environment[header.environmentKey]; + const value = this.credentialHeaders[header.name] ?? this.environment[header.environmentKey]; if (!value) throw new Error(`Required tool server header ${header.name} is unavailable.`); headers[header.name] = value; } diff --git a/shared/src/types/credential-broker.types.ts b/shared/src/types/credential-broker.types.ts index 74b3872e..459aa3d6 100644 --- a/shared/src/types/credential-broker.types.ts +++ b/shared/src/types/credential-broker.types.ts @@ -153,6 +153,10 @@ export interface CredentialLeaseIssueRequest { attemptId: string; runLaunchManifestDigest: string; action: CredentialAction; + /** Exact durable tool approval selected for this operation, when required. */ + approvalId?: string; + /** Caller-stable operation identity used only for approval correlation. */ + operationId?: string; } export interface CredentialLeaseUseRequest {