From 82e7890bbb3e787e791ea4cf5fddf431069ef66e Mon Sep 17 00:00:00 2001 From: Nathan Beddoe Date: Thu, 10 Sep 2026 17:52:42 +0200 Subject: [PATCH] Define shared capability approval policy and execution guard --- .github/workflows/ci.yml | 1 + docs/runtime.md | 9 ++ package.json | 1 + shared/capability-policy.ts | 61 ++++++++ shared/diagnostics.ts | 7 + shared/mcp-catalog.ts | 3 +- shared/mcp-tools.ts | 55 ++++--- tests/capability-policy.test.mjs | 161 +++++++++++++++++++++ tests/fixtures/capability-policy-worker.ts | 88 +++++++++++ tests/mcp-tools.test.mjs | 24 +++ worker/capability-policy.ts | 118 +++++++++++++++ worker/diagnostics.ts | 14 ++ worker/mcp-capability.ts | 60 ++++++++ worker/mcp-connections.ts | 7 +- worker/personal-agent.ts | 25 +++- 15 files changed, 607 insertions(+), 27 deletions(-) create mode 100644 shared/capability-policy.ts create mode 100644 tests/capability-policy.test.mjs create mode 100644 tests/fixtures/capability-policy-worker.ts create mode 100644 worker/capability-policy.ts create mode 100644 worker/mcp-capability.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ca9bf7..6f00922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: - run: pnpm test:mcp-runtime - run: pnpm test:mcp-health - run: pnpm test:mcp-tools + - run: pnpm test:capability-policy - run: pnpm test:mcp-oauth - run: pnpm test:mcp-headers - run: pnpm test:config diff --git a/docs/runtime.md b/docs/runtime.md index 611a449..3094abd 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -763,3 +763,12 @@ Native manager failures flatten their cause to a string. Application attempts ca Tools start disabled, including read-only tools: remote annotations never grant local access. Kumo Settings can enable or disable one tool or the currently displayed server catalog. Choices are persisted atomically with their own revision, independently of the live transport revision. A command must match every selected capability fingerprint and the current choices revision; stale, missing or malformed targets reject the entire update. Refresh preserves choices only for identical capability fingerprints. Newly discovered or changed tools require an explicit new choice. Disabling the server retains choices but removes all of its definitions from `mcpToolDefinitions`; connecting, unhealthy and malformed tools are also excluded. The internal definition boundary is ready for FLA-42's Think invocation bridge, which follows approval policy and approval-request implementation in FLA-40/41. These choices alone do not start tool execution. + + +### Shared capability approval policy + +MCP tools and Skill scripts use the same Allow / Ask / Never policy contract. The default is Ask. Owner configuration is stored in native SQLite with revision checks and can provide an override for an entire MCP server or Skill. A source override takes precedence over a capability choice; an explicit capability Never remains prohibited even under a broader Allow. Without an override, a capability choice takes precedence over the installation default. A null choice returns to inheritance. + +`runCapability` accepts only a strict source/identity/fingerprint reference from a caller. Trusted server code resolves current provenance, action summary, enablement and policy immediately before admitting the operation. Ask fails with approval required; Never, disabled, missing and changed capabilities cannot invoke the supplied operation. FLA-41 adds durable owner approval handling, and FLA-42 connects the guarded operation to native MCP invocation. No caller-provided policy or claimed approval can authorize execution. + +Owner RPCs `getCapabilityPolicy` and `setCapabilityPolicy` manage defaults and source overrides. MCP choices share the existing atomic `setMcpToolSelection` command, which accepts enablement, approval, or both. Model definitions exclude effective Never choices. Each runtime admission records a scoped diagnostic decision with hashed capability/source identifiers, source kind, effective policy scope and configuration revision; names, action text, arguments and results are excluded. diff --git a/package.json b/package.json index 62c8714..f1e95f7 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "test:mcp-catalog": "node --test tests/mcp-catalog.test.mjs", "test:mcp-model": "node --test tests/mcp-model.test.mjs", + "test:capability-policy": "node --test tests/capability-policy.test.mjs", "test:mcp-tools": "node --test tests/mcp-tools.test.mjs tests/mcp-tool-races.test.mjs", "test:mcp-health": "node --test tests/mcp-health.test.mjs tests/mcp-health-outcomes.test.mjs", "test:mcp-runtime": "node --test tests/mcp-runtime.test.mjs", diff --git a/shared/capability-policy.ts b/shared/capability-policy.ts new file mode 100644 index 0000000..993549b --- /dev/null +++ b/shared/capability-policy.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +export const approvalPolicy = z.enum(["allow", "ask", "never"]); +export type ApprovalPolicy = z.infer; +export const capabilitySource = z.strictObject({ + kind: z.enum(["mcp", "skill"]), + id: z.string().min(1).max(200), +}); +export const capabilityReference = z.strictObject({ + source: capabilitySource, + id: z.string().regex(/^[a-f0-9]{64}$/), + fingerprint: z.string().regex(/^[a-f0-9]{64}$/), +}); +export type CapabilityReference = z.infer; +export interface CapabilityMetadata extends CapabilityReference { + name: string; + sourceName: string; + actionSummary: string; + enabled: boolean; + approval?: ApprovalPolicy; +} +export const capabilityPolicyConfiguration = z.strictObject({ + defaultPolicy: approvalPolicy, + sourceOverrides: z + .array(z.strictObject({ source: capabilitySource, policy: approvalPolicy })) + .max(128) + .refine( + (items) => + new Set(items.map((item) => capabilitySourceKey(item.source))).size === + items.length, + ), +}); +export type CapabilityPolicyConfiguration = z.infer< + typeof capabilityPolicyConfiguration +>; +export interface CapabilityPolicySnapshot extends CapabilityPolicyConfiguration { + revision: number; +} +export const capabilityPolicyUpdate = capabilityPolicyConfiguration.extend({ + revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), +}); +export function capabilitySourceKey(source: CapabilityReference["source"]) { + return JSON.stringify([source.kind, source.id]); +} +export function effectiveCapabilityPolicy( + metadata: CapabilityMetadata, + configuration: CapabilityPolicyConfiguration, +) { + const override = configuration.sourceOverrides.find( + (item) => + capabilitySourceKey(item.source) === capabilitySourceKey(metadata.source), + ); + // A Never choice cannot be relaxed by a broader Allow. Source overrides let + // an owner require Ask (or Never) across a server/Skill in one operation. + if (metadata.approval === "never") + return { policy: "never", source: "capability" } as const; + if (override) return { policy: override.policy, source: "source" } as const; + if (metadata.approval) + return { policy: metadata.approval, source: "capability" } as const; + return { policy: configuration.defaultPolicy, source: "default" } as const; +} diff --git a/shared/diagnostics.ts b/shared/diagnostics.ts index 58d8d7f..cc7e5f2 100644 --- a/shared/diagnostics.ts +++ b/shared/diagnostics.ts @@ -9,6 +9,7 @@ export const DIAGNOSTIC_LIMITS = { maxExportBytes: 600 * 1024, } as const; export type DiagnosticKind = + | "approval" | "turn" | "model" | "model-attempt" @@ -42,6 +43,12 @@ export interface DiagnosticEvent { | "unknown"; durationMs: number | null; details: { + capabilityId?: string | null; + capabilitySourceId?: string | null; + capabilitySourceKind?: "mcp" | "skill"; + approvalDecision?: "allow" | "ask" | "never" | "unavailable"; + policyRevision?: number; + policySource?: "capability" | "source" | "default"; provider?: ModelProvider | "unknown"; model?: string | null; httpStatus?: number | null; diff --git a/shared/mcp-catalog.ts b/shared/mcp-catalog.ts index ce9594c..5d57d2d 100644 --- a/shared/mcp-catalog.ts +++ b/shared/mcp-catalog.ts @@ -1,3 +1,4 @@ +import { approvalPolicy } from "./capability-policy.ts"; import { z } from "zod"; export const MAX_MCP_CAPABILITIES = 500; @@ -55,7 +56,7 @@ export const mcpCapability = z.strictObject({ settings: z .strictObject({ enabled: z.boolean(), - approval: z.enum(["allow", "ask", "never"]), + approval: approvalPolicy.optional(), }) .optional(), }); diff --git a/shared/mcp-tools.ts b/shared/mcp-tools.ts index 30b0b4e..2bd5d09 100644 --- a/shared/mcp-tools.ts +++ b/shared/mcp-tools.ts @@ -1,23 +1,33 @@ +import { approvalPolicy } from "./capability-policy.ts"; import { z } from "zod"; import type { McpConnection } from "./mcp.ts"; import { MAX_MCP_CAPABILITIES, type McpCapability } from "./mcp-catalog.ts"; -export const mcpToolSelection = z.strictObject({ - settingsRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - enabled: z.boolean(), - tools: z - .array( - z.strictObject({ - id: z.string().regex(/^[0-9a-f]{64}$/), - fingerprint: z.string().regex(/^[0-9a-f]{64}$/), - }), - ) - .min(1) - .max(MAX_MCP_CAPABILITIES) - .refine( - (tools) => new Set(tools.map((tool) => tool.id)).size === tools.length, - ), -}); +export const mcpToolSelection = z + .strictObject({ + settingsRevision: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER), + enabled: z.boolean().optional(), + approval: approvalPolicy.nullable().optional(), + tools: z + .array( + z.strictObject({ + id: z.string().regex(/^[0-9a-f]{64}$/), + fingerprint: z.string().regex(/^[0-9a-f]{64}$/), + }), + ) + .min(1) + .max(MAX_MCP_CAPABILITIES) + .refine( + (tools) => new Set(tools.map((tool) => tool.id)).size === tools.length, + ), + }) + .refine( + (value) => value.enabled !== undefined || value.approval !== undefined, + ); export type McpToolSelection = z.infer; export type SelectableMcpTool = McpCapability & { @@ -45,12 +55,13 @@ export function selectableMcpTool( export function enabledMcpTools(connections: McpConnection[]) { return connections.flatMap((connection) => connection.enabled && connection.state === "ready" - ? connection.capabilities.filter( - (tool) => - selectableMcpTool(tool) && - tool.settings?.enabled === true && - tool.settings.approval !== "never", - ) + ? connection.capabilities + .filter(selectableMcpTool) + .filter( + (tool) => + tool.settings?.enabled === true && + tool.settings.approval !== "never", + ) : [], ); } diff --git a/tests/capability-policy.test.mjs b/tests/capability-policy.test.mjs new file mode 100644 index 0000000..802a39a --- /dev/null +++ b/tests/capability-policy.test.mjs @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { build } from "esbuild"; +import { Miniflare, convertV4MiniflareOptions } from "miniflare"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("native capability policy enforces owner decisions, persists defaults and records safe audit events", async () => { + const bundle = await build({ + entryPoints: ["tests/fixtures/capability-policy-worker.ts"], + alias: { path: "node:path" }, + bundle: true, + write: false, + format: "esm", + platform: "neutral", + conditions: ["workerd", "worker", "browser"], + mainFields: ["module", "main"], + external: ["cloudflare:*", "node:*"], + }); + const persistence = await mkdtemp(join(tmpdir(), "flarebot-policy-")); + const options = { + ...convertV4MiniflareOptions({ + modules: true, + script: bundle.outputFiles[0].text, + compatibilityDate: "2026-09-04", + compatibilityFlags: ["nodejs_compat"], + durableObjects: { + MODEL: { className: "CapabilityPolicyFixture", useSQLite: true }, + }, + }), + resourcePersistencePath: persistence, + }; + let worker = new Miniflare(options); + const call = async (action, value) => + ( + await worker.dispatchFetch("https://fixture.example", { + method: "POST", + body: JSON.stringify({ action, value }), + }) + ).json(); + const reference = { + source: { kind: "mcp", id: "private-server-id" }, + id: "a".repeat(64), + fingerprint: "b".repeat(64), + }; + let metadata = { + ...reference, + name: "private-tool-name", + sourceName: "private-server-name", + actionSummary: "private-action-summary", + enabled: true, + }; + const run = (value) => call("execute", value ?? reference); + try { + await call("metadata", metadata); + let config = await call("policy"); + assert.equal(config.defaultPolicy, "ask"); + assert.equal((await run()).decision, "ask"); + assert.equal((await call("count")).executions, 0); + assert.equal( + (await run({ ...reference, approval: "allow" })).decision, + "invalid", + ); + metadata = { ...metadata, approval: "never" }; + await call("metadata", metadata); + config = await call("configure", { ...config, defaultPolicy: "allow" }); + assert.equal((await run()).decision, "never"); + assert.equal((await call("count")).executions, 0); + metadata = { ...metadata, approval: "allow" }; + await call("metadata", metadata); + assert.equal((await run()).ok, true); + assert.equal((await call("count")).executions, 1); + config = await call("configure", { + ...config, + sourceOverrides: [{ source: reference.source, policy: "ask" }], + }); + assert.equal( + (await run()).decision, + "ask", + "broader source policy can require approval for an allowed tool", + ); + const stale = config; + config = await call("configure", { + ...config, + sourceOverrides: [{ source: reference.source, policy: "never" }], + }); + assert.equal((await run()).decision, "never"); + assert.equal( + ( + await call("configure", { + ...stale, + defaultPolicy: "allow", + sourceOverrides: [{ source: reference.source, policy: "allow" }], + sourceOverrides: [], + }) + ).ok, + false, + ); + await worker.dispose(); + worker = new Miniflare(options); + assert.deepEqual(await call("policy"), config); + assert.equal((await run()).decision, "never"); + config = await call("configure", { ...config, sourceOverrides: [] }); + metadata = { ...metadata, enabled: false }; + await call("metadata", metadata); + assert.equal((await run()).decision, "unavailable"); + metadata = { ...metadata, enabled: true, fingerprint: "c".repeat(64) }; + await call("metadata", metadata); + assert.equal((await run()).decision, "unavailable"); + assert.equal( + ( + await run({ + ...reference, + source: { kind: "skill", id: reference.source.id }, + }) + ).decision, + "unavailable", + ); + assert.equal( + (await call("count")).executions, + 1, + "denied and forged calls never invoke the operation", + ); + const diagnostics = await call("diagnostics"); + const decisions = diagnostics.events.filter( + (event) => event.kind === "approval", + ); + assert.ok( + decisions.some((event) => event.details.approvalDecision === "allow"), + ); + assert.ok( + decisions.some((event) => event.details.approvalDecision === "ask"), + ); + assert.ok( + decisions.some((event) => event.details.approvalDecision === "never"), + ); + assert.ok( + decisions.some( + (event) => event.details.approvalDecision === "unavailable", + ), + ); + assert.ok( + decisions.every((event) => + Number.isInteger(event.details.policyRevision), + ), + ); + for (const sentinel of [ + "private-server-id", + "private-tool-name", + "private-server-name", + "private-action-summary", + "private-conversation", + "private-tool-call", + ]) + assert.ok(!JSON.stringify(diagnostics).includes(sentinel), sentinel); + } finally { + await worker.dispose(); + await rm(persistence, { recursive: true, force: true }); + } +}); diff --git a/tests/fixtures/capability-policy-worker.ts b/tests/fixtures/capability-policy-worker.ts new file mode 100644 index 0000000..9de0426 --- /dev/null +++ b/tests/fixtures/capability-policy-worker.ts @@ -0,0 +1,88 @@ +import { Agent, getAgentByName } from "agents"; +import * as Effect from "effect/Effect"; +import { + CapabilityDenied, + CapabilityPolicyStore, + runCapability, +} from "../../worker/capability-policy"; +import { DiagnosticStore, diagnosticId } from "../../worker/diagnostics"; +import type { CapabilityMetadata } from "../../shared/capability-policy"; + +// Execution is a local fixture effect; production guard, native SQLite policy, +// validation and diagnostic storage are exercised without external side effects. +export class CapabilityPolicyFixture extends Agent { + private readonly policy = new CapabilityPolicyStore( + this.sql.bind(this), + this.ctx.storage, + ); + private readonly diagnostics = new DiagnosticStore(this.sql.bind(this)); + onStart() { + this.policy.initialize(); + this + .sql`CREATE TABLE IF NOT EXISTS fixture_capability (id INTEGER PRIMARY KEY, document TEXT, executions INTEGER)`; + } + async onRequest(request: Request) { + const input = await request.json<{ action: string; value: unknown }>(); + try { + if (input.action === "policy") return Response.json(this.policy.read()); + if (input.action === "configure") + return Response.json(this.policy.update(input.value)); + if (input.action === "metadata") { + this + .sql`INSERT INTO fixture_capability VALUES (1, ${JSON.stringify(input.value)}, 0) ON CONFLICT(id) DO UPDATE SET document = excluded.document`; + return Response.json({ ok: true }); + } + if (input.action === "diagnostics") + return Response.json( + this.diagnostics.snapshot(diagnosticId("private-conversation")), + ); + if (input.action === "count") + return Response.json( + this.sql`SELECT executions FROM fixture_capability WHERE id = 1`[0], + ); + const value = await Effect.runPromise( + runCapability( + { + policy: this.policy, + diagnostics: this.diagnostics, + conversationId: "private-conversation", + toolCallId: "private-tool-call", + resolve: () => { + const [row] = this.sql<{ + document: string; + }>`SELECT document FROM fixture_capability WHERE id = 1`; + return row + ? (JSON.parse(row.document) as CapabilityMetadata) + : undefined; + }, + }, + input.value, + (metadata) => + Effect.sync(() => { + this + .sql`UPDATE fixture_capability SET executions = executions + 1 WHERE id = 1`; + return { + source: metadata.source, + summary: metadata.actionSummary, + }; + }), + ), + ); + return Response.json({ ok: true, value }); + } catch (error) { + return Response.json({ + ok: false, + decision: + error instanceof CapabilityDenied ? error.decision : "invalid", + }); + } + } +} +export default { + async fetch( + request: Request, + env: { MODEL: DurableObjectNamespace }, + ) { + return (await getAgentByName(env.MODEL, "policy")).fetch(request); + }, +}; diff --git a/tests/mcp-tools.test.mjs b/tests/mcp-tools.test.mjs index 23b6f70..fe1b8f2 100644 --- a/tests/mcp-tools.test.mjs +++ b/tests/mcp-tools.test.mjs @@ -163,6 +163,30 @@ test( ); item = await call("tools", selection(item, true)); assert.deepEqual(await names(), ["new_write", "search", "write"]); + const writeTool = item.capabilities.find((tool) => tool.name === "write"); + item = await call("tools", { + id: item.id, + value: { + settingsRevision: item.settingsRevision, + tools: [{ id: writeTool.id, fingerprint: writeTool.fingerprint }], + approval: "never", + }, + }); + assert.equal( + item.capabilities.find((tool) => tool.name === "write").settings + .enabled, + true, + ); + assert.deepEqual(await names(), ["new_write", "search"]); + item = await call("tools", { + id: item.id, + value: { + settingsRevision: item.settingsRevision, + tools: [{ id: writeTool.id, fingerprint: writeTool.fingerprint }], + approval: null, + }, + }); + assert.deepEqual(await names(), ["new_write", "search", "write"]); await worker.dispose(); worker = new Miniflare(options); item = await ready(); diff --git a/worker/capability-policy.ts b/worker/capability-policy.ts new file mode 100644 index 0000000..ff26349 --- /dev/null +++ b/worker/capability-policy.ts @@ -0,0 +1,118 @@ +import type { Agent } from "agents"; +import * as Effect from "effect/Effect"; +import { + capabilityPolicyUpdate, + capabilityReference, + effectiveCapabilityPolicy, + type CapabilityMetadata, + type CapabilityPolicySnapshot, + type CapabilityReference, +} from "../shared/capability-policy"; +import { AgentFailure, agentValidation } from "./agent-io"; +import { diagnosticId, type DiagnosticStore } from "./diagnostics"; + +export class CapabilityPolicyStore { + constructor( + private readonly sql: Agent["sql"], + private readonly storage: Pick, + ) {} + initialize() { + this + .sql`CREATE TABLE IF NOT EXISTS flarebot_capability_policy (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), document TEXT NOT NULL)`; + this + .sql`INSERT OR IGNORE INTO flarebot_capability_policy VALUES (1, ${JSON.stringify({ revision: 0, defaultPolicy: "ask", sourceOverrides: [] })})`; + } + read(): CapabilityPolicySnapshot { + const [row] = this.sql<{ + document: string; + }>`SELECT document FROM flarebot_capability_policy WHERE singleton = 1`; + return capabilityPolicyUpdate.parse(JSON.parse(row.document)); + } + update(value: unknown): CapabilityPolicySnapshot { + const input = capabilityPolicyUpdate.parse(value); + return this.storage.transactionSync(() => { + if (this.read().revision !== input.revision) + throw new Error("Capability policy changed. Reload before saving."); + const updated = capabilityPolicyUpdate.parse({ + ...input, + revision: input.revision + 1, + }); + this + .sql`UPDATE flarebot_capability_policy SET document = ${JSON.stringify(updated)} WHERE singleton = 1`; + return updated; + }); + } +} + +export class CapabilityDenied extends AgentFailure { + constructor(readonly decision: "ask" | "never" | "unavailable") { + super({ + message: + decision === "ask" + ? "Owner approval is required for this capability" + : decision === "never" + ? "This capability is prohibited by owner policy" + : "This capability changed or is unavailable", + }); + } +} + +// The resolver and operation are trusted server code. The caller supplies only +// an identity/fingerprint, never policy, provenance, approval or executable code. +export function runCapability( + context: { + policy: CapabilityPolicyStore; + resolve: (reference: CapabilityReference) => CapabilityMetadata | undefined; + diagnostics: DiagnosticStore; + conversationId?: string; + toolCallId?: string; + }, + value: unknown, + operation: (metadata: CapabilityMetadata) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const reference = yield* agentValidation(() => + capabilityReference.parse(value), + ); + const metadata = context.resolve(reference); + const configuration = context.policy.read(); + const current = + metadata?.enabled && + metadata.id === reference.id && + metadata.fingerprint === reference.fingerprint && + metadata.source.kind === reference.source.kind && + metadata.source.id === reference.source.id; + const effective = current + ? effectiveCapabilityPolicy(metadata, configuration) + : null; + const decision = effective?.policy ?? "unavailable"; + context.diagnostics.record({ + kind: "approval", + timestamp: Date.now(), + id: diagnosticId(context.toolCallId), + conversationId: diagnosticId(context.conversationId), + requestId: null, + phase: "transition", + status: + decision === "allow" + ? "succeeded" + : decision === "ask" + ? "pending" + : "skipped", + durationMs: null, + details: { + capabilityId: diagnosticId(reference.id), + capabilitySourceId: diagnosticId(reference.source.id), + capabilitySourceKind: reference.source.kind, + approvalDecision: decision, + policyRevision: configuration.revision, + policySource: effective?.source, + }, + }); + if (decision !== "allow" || !metadata) + return yield* Effect.fail( + new CapabilityDenied(decision === "allow" ? "unavailable" : decision), + ); + return yield* operation(metadata); + }); +} diff --git a/worker/diagnostics.ts b/worker/diagnostics.ts index c63e385..76f9c86 100644 --- a/worker/diagnostics.ts +++ b/worker/diagnostics.ts @@ -48,6 +48,19 @@ const toolNames = [ ]; const detailsSchema = z .object({ + capabilityId: id.optional(), + capabilitySourceId: id.optional(), + capabilitySourceKind: z.enum(["mcp", "skill"]).optional(), + approvalDecision: z + .enum(["allow", "ask", "never", "unavailable"]) + .optional(), + policyRevision: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .optional(), + policySource: z.enum(["capability", "source", "default"]).optional(), provider: z .enum([ ...(Object.keys(MODEL_PROVIDERS) as ModelProvider[]), @@ -130,6 +143,7 @@ const detailsSchema = z const eventSchema = z .object({ kind: z.enum([ + "approval", "turn", "model", "model-attempt", diff --git a/worker/mcp-capability.ts b/worker/mcp-capability.ts new file mode 100644 index 0000000..37233fc --- /dev/null +++ b/worker/mcp-capability.ts @@ -0,0 +1,60 @@ +import type { + CapabilityMetadata, + CapabilityReference, + CapabilityPolicyConfiguration, +} from "../shared/capability-policy"; +import { effectiveCapabilityPolicy } from "../shared/capability-policy"; +import { + selectableMcpTool, + enabledMcpTools, + type SelectableMcpTool, +} from "../shared/mcp-tools"; +import type { McpConnection } from "../shared/mcp"; +import type { McpConnections } from "./mcp-connections"; + +function metadata( + connection: McpConnection, + tool: SelectableMcpTool, +): CapabilityMetadata { + return { + id: tool.id, + fingerprint: tool.fingerprint, + source: { kind: "mcp", id: connection.id }, + name: tool.name, + sourceName: connection.name, + actionSummary: `Run ${tool.name} from ${connection.name}`, + enabled: + connection.enabled && + connection.state === "ready" && + tool.settings?.enabled === true, + approval: tool.settings?.approval, + }; +} +export function resolveMcpCapability( + connections: McpConnections, + reference: CapabilityReference, +): CapabilityMetadata | undefined { + if (reference.source.kind !== "mcp") return; + const connection = connections.find(reference.source.id); + const tool = connection?.capabilities.find( + (tool) => tool.id === reference.id, + ); + if (!connection || !tool || !selectableMcpTool(tool)) return; + return metadata(connection, tool); +} +export function mcpDefinitions( + connections: McpConnection[], + policy: CapabilityPolicyConfiguration, +) { + const sources = new Map( + connections.map((connection) => [connection.id, connection]), + ); + return enabledMcpTools(connections).filter((tool) => { + const connection = sources.get(tool.serverId); + return ( + connection && + effectiveCapabilityPolicy(metadata(connection, tool), policy).policy !== + "never" + ); + }); +} diff --git a/worker/mcp-connections.ts b/worker/mcp-connections.ts index 1792fe1..74d5a33 100644 --- a/worker/mcp-connections.ts +++ b/worker/mcp-connections.ts @@ -152,8 +152,11 @@ export class McpConnections { return { ...tool, settings: { - enabled: input.data.enabled, - approval: tool.settings?.approval ?? ("ask" as const), + enabled: input.data.enabled ?? tool.settings?.enabled ?? false, + approval: + input.data.approval === null + ? undefined + : (input.data.approval ?? tool.settings?.approval), }, }; }); diff --git a/worker/personal-agent.ts b/worker/personal-agent.ts index 6f17a89..ec870b6 100644 --- a/worker/personal-agent.ts +++ b/worker/personal-agent.ts @@ -1,4 +1,5 @@ -import { enabledMcpTools } from "../shared/mcp-tools"; +import { CapabilityPolicyStore } from "./capability-policy"; +import { mcpDefinitions } from "./mcp-capability"; import { MCP_HEALTH_INTERVAL_SECONDS } from "../shared/mcp-health"; import { handleAttachmentRequest } from "./attachment-http"; import { deleteConversationAttachmentObjects } from "./attachment-objects"; @@ -110,6 +111,10 @@ export class PersonalAgent extends Agent { this.mcpHeaders, (enabled) => this.configureMcpMaintenance(enabled), ); + private readonly capabilityPolicy = new CapabilityPolicyStore( + this.sql.bind(this), + this.ctx.storage, + ); protected readonly diagnostics = new DiagnosticStore(this.sql.bind(this)); observability = this.diagnostics.receiver(); protected readonly tasks = new TaskStore( @@ -249,6 +254,7 @@ export class PersonalAgent extends Agent { this.memory.initialize(); this.mcpConnections.initialize(); + this.capabilityPolicy.initialize(); yield* this.extensions.initializeCredentials(); this.extensions.recover(); @@ -302,6 +308,18 @@ export class PersonalAgent extends Agent { return this.mcpConnections.list(); } + @callable() + getCapabilityPolicy() { + return this.capabilityPolicy.read(); + } + + @callable() + setCapabilityPolicy(value: unknown) { + return operationResult( + agentValidation(() => this.capabilityPolicy.update(value)), + ); + } + @callable() setMcpToolSelection(id: string, value: unknown) { return operationResult( @@ -312,7 +330,10 @@ export class PersonalAgent extends Agent { // Internal parent-to-conversation definition boundary; invocation is added by // the Think bridge after approval policy is in place. mcpToolDefinitions() { - return enabledMcpTools(this.mcpConnections.list()); + return mcpDefinitions( + this.mcpConnections.list(), + this.capabilityPolicy.read(), + ); } @callable() -- 2.51.2