mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
parent
a21d1e9358
commit
9c15d348fa
12 changed files with 534 additions and 73 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> }> = [];
|
||||
|
|
@ -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'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<RpcSession>;
|
||||
open(
|
||||
definition: ToolServerDefinition,
|
||||
cwd?: string,
|
||||
credentials?: ToolCredentialDelivery
|
||||
): Promise<RpcSession>;
|
||||
}
|
||||
|
||||
interface ToolCredentialDelivery {
|
||||
environment: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PrepareRunToolCatalogInput {
|
||||
|
|
@ -86,7 +99,10 @@ export interface ToolControlPlaneServiceOptions {
|
|||
runtime?: ToolControlPlaneRuntime;
|
||||
journal?: RunEventJournalService;
|
||||
approvals?: RunApprovalBrokerService;
|
||||
credentialBroker?: Pick<CredentialBrokerService, 'getDefinition'>;
|
||||
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<CredentialBrokerService, 'getDefinition'>;
|
||||
private readonly credentialBroker: Pick<
|
||||
CredentialBrokerService,
|
||||
'getDefinition' | 'issueLease' | 'withCredential'
|
||||
>;
|
||||
private readonly now: () => Date;
|
||||
private readonly environment: NodeJS.ProcessEnv;
|
||||
private readonly sessions = new Map<string, Promise<RpcSession>>();
|
||||
|
|
@ -388,7 +407,8 @@ export class ToolControlPlaneService {
|
|||
async invoke(
|
||||
request: ToolInvocationRequest,
|
||||
actorId: string,
|
||||
cwd?: string
|
||||
cwd?: string,
|
||||
runLaunchManifestDigest?: string
|
||||
): Promise<ToolInvocationResult> {
|
||||
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<RpcSession> | 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<RpcSession> {
|
||||
const session = await this.runtime.open(definition, cwd);
|
||||
private credentialAction(
|
||||
catalog: RunToolCatalog,
|
||||
entry: RunToolCatalogEntry,
|
||||
tool: string,
|
||||
argumentsValue: Record<string, unknown>
|
||||
): 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<boolean> {
|
||||
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<T>(input: {
|
||||
request: ToolInvocationRequest;
|
||||
entry: RunToolCatalogEntry;
|
||||
action: CredentialAction;
|
||||
runLaunchManifestDigest: string;
|
||||
approvalId?: string;
|
||||
dispatch: (credentials: ToolCredentialDelivery) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const bindings = input.entry.credentialBindings ?? [];
|
||||
const delivery: ToolCredentialDelivery = { environment: {}, headers: {} };
|
||||
const consume = async (index: number): Promise<T> => {
|
||||
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<RpcSession> {
|
||||
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<RpcSession> {
|
||||
async open(
|
||||
definition: ToolServerDefinition,
|
||||
cwd?: string,
|
||||
credentials?: ToolCredentialDelivery
|
||||
): Promise<RpcSession> {
|
||||
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<string, string> = {}
|
||||
) {
|
||||
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<string, string> = {}
|
||||
) {}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue