diff --git a/agentic/harness/__tests__/gating.test.ts b/agentic/harness/__tests__/gating.test.ts index 1e3ff954b..79589e65c 100644 --- a/agentic/harness/__tests__/gating.test.ts +++ b/agentic/harness/__tests__/gating.test.ts @@ -1,5 +1,6 @@ import { ConfirmGate, ConfirmGateDeps, createConfirmGate, GateHost } from '../src/gating/confirm-gate'; import { createDeclineGuard } from '../src/gating/decline-guard'; +import type { GatePolicy } from '../src/gating/policy'; import { buildConfirmPrompt, MUTATING_DB_TOOLS } from '../src/gating/prompts'; type Harness = { @@ -284,6 +285,75 @@ describe('confirm gate: the gated-tool set is injectable', () => { }); }); +describe('confirm gate: a host can supply its own policy', () => { + // What a remote coding host wants: gate the shell, and only when the command + // is destructive. Nothing Constructive-specific, no project context. + function createShellHostHarness() { + let confirmed: { title: string; message: string } | undefined; + const host: GateHost = { + hasUI: true, + confirmTool: async (_toolCallId, title, message) => { + confirmed = { title, message }; + return false; + }, + notifyToolSkipped: () => undefined, + }; + const policy: GatePolicy = { + isGated: (event) => event.toolName === 'bash', + resolvePrompt: async (event) => { + const command = typeof event.input?.command === 'string' ? event.input.command : ''; + if (!/rm -rf|push --force/.test(command)) return null; + return { title: 'Run a destructive command?', message: `Allow \`${command}\`?` }; + }, + }; + return { gate: createConfirmGate({ policy }), host, confirmed: () => confirmed }; + } + + it('gates the host\'s tool and ignores the Constructive default set', async () => { + const { gate, host, confirmed } = createShellHostHarness(); + + const gated = await gate.onToolCall(call('bash', 'tc-1', { command: 'rm -rf /src' }), host, CWD); + expect(gated?.block).toBe(true); + expect(gated?.reason).toMatch(/declined it/); + expect(confirmed()?.title).toBe('Run a destructive command?'); + expect(confirmed()?.message).toContain('rm -rf /src'); + + // delete_table is in MUTATING_DB_TOOLS, but this host's policy never gates it. + expect( + await gate.onToolCall(call('delete_table', 'tc-2', { table_name: 'users' }), host, CWD) + ).toBeUndefined(); + }); + + it('lets the policy wave a gated tool through on its arguments', async () => { + const { gate, host, confirmed } = createShellHostHarness(); + expect( + await gate.onToolCall(call('bash', 'tc-1', { command: 'ls -la' }), host, CWD) + ).toBeUndefined(); + expect(confirmed()).toBeUndefined(); + }); + + it('still applies the decline memory and the headless block', async () => { + const { gate, host } = createShellHostHarness(); + const input = { command: 'git push --force' }; + + await gate.onToolCall(call('bash', 'tc-1', input), host, CWD); + const retry = await gate.onToolCall(call('bash', 'tc-2', input), host, CWD); + expect(retry?.reason).toMatch(/already declined/); + + const headless: GateHost = { + hasUI: false, + confirmTool: async () => true, + notifyToolSkipped: () => undefined, + }; + const blocked = await gate.onToolCall( + call('bash', 'tc-3', { command: 'rm -rf /src' }), + headless, + CWD + ); + expect(blocked?.reason).toMatch(/no confirmation UI/); + }); +}); + describe('decline guard canonicalization', () => { it('treats nested key order as equivalent and clears per run', () => { const guard = createDeclineGuard(); diff --git a/agentic/harness/src/gating/confirm-gate.ts b/agentic/harness/src/gating/confirm-gate.ts index e725a941a..faa602812 100644 --- a/agentic/harness/src/gating/confirm-gate.ts +++ b/agentic/harness/src/gating/confirm-gate.ts @@ -1,20 +1,18 @@ +import type { ConstructiveGateDeps } from './constructive-policy'; +import { createConstructiveGatePolicy } from './constructive-policy'; import { buildDeclineReason, createDeclineGuard } from './decline-guard'; +import type { GatePolicy, GateToolCallEvent } from './policy'; import type { ConfirmPreview } from './preview'; -import { buildConfirmPrompt, MUTATING_DB_TOOLS } from './prompts'; /** - * Host-neutral confirm gate for mutating db tools. Structurally mirrors the - * pi extension `tool_call` hook so a pi adapter is a thin mapping, but takes - * every host capability (confirm UI, skip notification, context/token/preview - * resolvers) as injected deps — no Electron, no pi imports. + * Host-neutral confirm gate. It owns the mechanics of asking a human — + * decline memory, auto-skipping a declined retry, refusing gated calls on a + * headless host — and takes both the host's capabilities (confirm UI, skip + * notification) and its policy (which calls are gated, what the prompt says) + * as injected dependencies. Structurally mirrors the pi extension `tool_call` + * hook so a pi adapter is a thin mapping: no Electron, no pi imports. */ -export type GateToolCallEvent = { - toolName: string; - toolCallId: string; - input?: Record; -}; - /** `block: true` stops the tool call; `reason` is surfaced to the agent. */ export type GateResult = { block: true; reason: string } | undefined; @@ -32,79 +30,37 @@ export type GateHost = { notifyToolSkipped(toolCallId: string): void; }; -export type ConfirmGateDeps = { - /** - * Whether the project is provisioned/runnable at `cwd`. Unrunnable calls - * skip the confirm so the tool can return its clean "provision first" - * message instead of making the user approve something that fails. - */ - isProjectRunnable(cwd: string): Promise; - /** Whether a data-plane token is available (gates `add_records`). */ - hasDataToken(cwd: string): Promise; - /** - * Resolve the preview for `create_template` (its tables live in the - * blueprint it copies, not in the tool input). Return undefined when a - * preview can't be built. - */ - resolveTemplatePreview( - cwd: string, - blueprintName: string | undefined, - displayName: string - ): Promise; - /** - * Tool names that require a human decision. The policy belongs to the host, - * not the harness: Desktop/CLI gate Constructive's mutating db tools, a - * remote coding host gates a different set. Defaults to - * `MUTATING_DB_TOOLS`. - */ - gatedTools?: ReadonlySet; -}; +/** + * The Constructive database deps, from which the gate derives the default + * `GatePolicy`. Kept as the name every existing host passes. + */ +export type ConfirmGateDeps = ConstructiveGateDeps; + +/** + * Either hand the gate the Constructive deps and get its database policy, or + * hand it a policy of your own — a remote coding host gating `bash` has no + * project context or data token to speak of. + */ +export type ConfirmGateOptions = ConfirmGateDeps | { policy: GatePolicy }; export type ConfirmGate = { onAgentStart: () => void; onToolCall: (event: GateToolCallEvent, host: GateHost, cwd: string) => Promise; }; -export function createConfirmGate(deps: ConfirmGateDeps): ConfirmGate { +export function createConfirmGate(options: ConfirmGateOptions): ConfirmGate { const declineGuard = createDeclineGuard(); - const gatedTools = deps.gatedTools ?? MUTATING_DB_TOOLS; - - async function confirmOrDecline( - event: GateToolCallEvent, - host: GateHost, - input: Record | undefined, - preview?: ConfirmPreview - ): Promise { - const { title, message, preview: basePreview } = buildConfirmPrompt(event.toolName, input); - const approved = await host.confirmTool( - event.toolCallId, - title, - message, - preview ?? basePreview - ); - if (!approved) { - declineGuard.recordDecline(event.toolName, input); - return { block: true, reason: buildDeclineReason(event.toolName) }; - } - // An approved mutation changes database state, so earlier declines may no - // longer describe the same effect (e.g. a create_template preview derives - // from the blueprint, not the input) — let them re-prompt with fresh eyes. - declineGuard.clear(); - return undefined; - } + const policy: GatePolicy = + 'policy' in options ? options.policy : createConstructiveGatePolicy(options); return { onAgentStart: () => declineGuard.clear(), onToolCall: async (event, host, cwd) => { - if (!gatedTools.has(event.toolName)) return; + if (!policy.isGated(event)) return; const input = event.input; - // manage_entity_types multiplexes read + write actions behind one tool - // name; its read action is not a mutation, so it skips the gate. - if (event.toolName === 'manage_entity_types' && input?.action === 'list') return; - const retryBlock = declineGuard.checkRetry(event.toolName, input); if (retryBlock) { if (host.hasUI) { @@ -120,32 +76,24 @@ export function createConfirmGate(deps: ConfirmGateDeps): ConfirmGate { }; } - // provision_database is the tool that CREATES the project context, so it - // can't gate on an existing one — confirm it directly. - if (event.toolName === 'provision_database') { - return confirmOrDecline(event, host, input); - } - - if (!(await deps.isProjectRunnable(cwd))) return; - // Tools that need an app sign-in skip the confirm when no data token - // exists — the tool returns its sign-in prompt instead of making the - // user approve something that fails. - if ( - (event.toolName === 'add_records' || event.toolName === 'create_api_key') && - !(await deps.hasDataToken(cwd)) - ) { - return; - } - - let resolvedPreview: ConfirmPreview | undefined; - if (event.toolName === 'create_template') { - const blueprintName = - typeof input?.blueprintName === 'string' ? input.blueprintName : undefined; - const displayName = typeof input?.displayName === 'string' ? input.displayName : ''; - resolvedPreview = await deps.resolveTemplatePreview(cwd, blueprintName, displayName); + const prompt = await policy.resolvePrompt(event, cwd); + if (!prompt) return; + + const approved = await host.confirmTool( + event.toolCallId, + prompt.title, + prompt.message, + prompt.preview + ); + if (!approved) { + declineGuard.recordDecline(event.toolName, input); + return { block: true, reason: buildDeclineReason(event.toolName) }; } - - return confirmOrDecline(event, host, input, resolvedPreview); + // An approved mutation changes state, so earlier declines may no longer + // describe the same effect (e.g. a create_template preview derives from + // the blueprint, not the input) — let them re-prompt with fresh eyes. + declineGuard.clear(); + return undefined; }, }; } diff --git a/agentic/harness/src/gating/constructive-policy.ts b/agentic/harness/src/gating/constructive-policy.ts new file mode 100644 index 000000000..206353f9f --- /dev/null +++ b/agentic/harness/src/gating/constructive-policy.ts @@ -0,0 +1,84 @@ +import type { GatePolicy, GateToolCallEvent } from './policy'; +import type { ConfirmPreview } from './preview'; +import { buildConfirmPrompt, MUTATING_DB_TOOLS } from './prompts'; + +/** + * Host capabilities the Constructive database policy needs. These are + * Constructive-specific — a policy for another host asks for whatever *its* + * rules depend on instead. + */ +export type ConstructiveGateDeps = { + /** + * Whether the project is provisioned/runnable at `cwd`. Unrunnable calls + * skip the confirm so the tool can return its clean "provision first" + * message instead of making the user approve something that fails. + */ + isProjectRunnable(cwd: string): Promise; + /** Whether a data-plane token is available (gates `add_records`). */ + hasDataToken(cwd: string): Promise; + /** + * Resolve the preview for `create_template` (its tables live in the + * blueprint it copies, not in the tool input). Return undefined when a + * preview can't be built. + */ + resolveTemplatePreview( + cwd: string, + blueprintName: string | undefined, + displayName: string + ): Promise; + /** + * Tool names that require a human decision. Defaults to + * `MUTATING_DB_TOOLS`; a host that wants a different set entirely is better + * served by its own `GatePolicy`. + */ + gatedTools?: ReadonlySet; +}; + +/** + * The gate policy for Constructive's database tools, as the Desktop and CLI + * hosts want it: gate every mutating db tool, and skip the confirm when the + * call is going to bounce off a missing project or a missing sign-in anyway. + */ +export function createConstructiveGatePolicy(deps: ConstructiveGateDeps): GatePolicy { + const gatedTools = deps.gatedTools ?? MUTATING_DB_TOOLS; + + return { + isGated: (event: GateToolCallEvent) => { + if (!gatedTools.has(event.toolName)) return false; + // manage_entity_types multiplexes read + write actions behind one tool + // name; its read action is not a mutation, so it skips the gate. + if (event.toolName === 'manage_entity_types' && event.input?.action === 'list') return false; + return true; + }, + + resolvePrompt: async (event, cwd) => { + const input = event.input; + const prompt = buildConfirmPrompt(event.toolName, input); + + // provision_database is the tool that CREATES the project context, so it + // can't gate on an existing one — confirm it directly. + if (event.toolName === 'provision_database') return prompt; + + if (!(await deps.isProjectRunnable(cwd))) return null; + // Tools that need an app sign-in skip the confirm when no data token + // exists — the tool returns its sign-in prompt instead of making the + // user approve something that fails. + if ( + (event.toolName === 'add_records' || event.toolName === 'create_api_key') && + !(await deps.hasDataToken(cwd)) + ) { + return null; + } + + if (event.toolName === 'create_template') { + const blueprintName = + typeof input?.blueprintName === 'string' ? input.blueprintName : undefined; + const displayName = typeof input?.displayName === 'string' ? input.displayName : ''; + const preview = await deps.resolveTemplatePreview(cwd, blueprintName, displayName); + if (preview) return { ...prompt, preview }; + } + + return prompt; + }, + }; +} diff --git a/agentic/harness/src/gating/policy.ts b/agentic/harness/src/gating/policy.ts new file mode 100644 index 000000000..649549eb4 --- /dev/null +++ b/agentic/harness/src/gating/policy.ts @@ -0,0 +1,38 @@ +import type { ConfirmPrompt } from './preview'; + +/** + * What the gate knows about a tool call. Hosts map their agent's event shape + * onto this (pi's `ToolCallEvent`, an MCP request, …). + */ +export type GateToolCallEvent = { + toolName: string; + toolCallId: string; + input?: Record; +}; + +/** + * The host's answer to "which calls need a human decision, and what do we show + * the human?". Everything tool-specific lives here — which tool names are + * gated, which arguments turn a gated name into a harmless read, and the + * wording of the confirmation — so the gate itself stays host-neutral and only + * owns the mechanics (decline memory, headless blocking, prompting). + * + * `createConstructiveGatePolicy` is the Constructive database policy the + * Desktop/CLI hosts use; a remote coding host supplies its own. + */ +export type GatePolicy = { + /** + * Whether this call needs a human decision at all. Cheap and synchronous: + * it runs before the decline memory and the headless block, so it must + * decide from the tool name and arguments alone — no I/O. + */ + isGated(event: GateToolCallEvent): boolean; + /** + * The prompt to put in front of the user, or `null` to let an already-gated + * call through without one. Runs only for gated calls on a host with a + * confirm surface, so it may do I/O to build a richer preview or to check a + * precondition that makes the confirm pointless (e.g. the call is going to + * fail with "sign in first" anyway). + */ + resolvePrompt(event: GateToolCallEvent, cwd: string): Promise; +}; diff --git a/agentic/harness/src/gating/preview.ts b/agentic/harness/src/gating/preview.ts index 558388333..370a8b465 100644 --- a/agentic/harness/src/gating/preview.ts +++ b/agentic/harness/src/gating/preview.ts @@ -23,3 +23,6 @@ export type ConfirmPreview = | { kind: 'template'; displayName: string; blueprintName?: string; tables: ConfirmPreviewTable[] } | { kind: 'policies'; tableName: string; policies: string[] } | { kind: 'field'; tableName: string; field: ConfirmPreviewField }; + +/** What a host puts in front of the user for one gated call. */ +export type ConfirmPrompt = { title: string; message: string; preview?: ConfirmPreview }; diff --git a/agentic/harness/src/gating/prompts.ts b/agentic/harness/src/gating/prompts.ts index 7c446ab99..aab677466 100644 --- a/agentic/harness/src/gating/prompts.ts +++ b/agentic/harness/src/gating/prompts.ts @@ -18,9 +18,12 @@ export const MUTATING_DB_TOOLS = new Set([ ]); import { filterInternalPolicies } from '../blueprint/internal-policies'; -import type { ConfirmPreview, ConfirmPreviewField, ConfirmPreviewTable } from './preview'; - -export type ConfirmPrompt = { title: string; message: string; preview?: ConfirmPreview }; +import type { + ConfirmPreview, + ConfirmPreviewField, + ConfirmPreviewTable, + ConfirmPrompt, +} from './preview'; function str(input: Record | undefined, key: string): string | undefined { const value = input?.[key]; diff --git a/agentic/harness/src/index.ts b/agentic/harness/src/index.ts index 5f8dbe164..2ef8f2064 100644 --- a/agentic/harness/src/index.ts +++ b/agentic/harness/src/index.ts @@ -4,7 +4,9 @@ export * from './blueprint/field-type'; export * from './blueprint/internal-policies'; export * from './blueprint/policy-provisioning'; export * from './gating/confirm-gate'; +export * from './gating/constructive-policy'; export * from './gating/decline-guard'; +export * from './gating/policy'; export * from './gating/preview'; export * from './gating/prompts'; export * from './skills/default-source'; diff --git a/agentic/pi/src/confirm-gate.ts b/agentic/pi/src/confirm-gate.ts index 32613dd93..8d0f83799 100644 --- a/agentic/pi/src/confirm-gate.ts +++ b/agentic/pi/src/confirm-gate.ts @@ -3,6 +3,7 @@ import { type ConfirmGate as HarnessConfirmGate, createConfirmGate as createHarnessConfirmGate, type GateHost, + type GatePolicy, } from '@agentic-kit/harness'; import type { ExtensionContext, @@ -39,6 +40,13 @@ export type ConfirmGateDeps = { gatedTools?: ReadonlySet; }; +/** + * Constructive's database deps, or a `GatePolicy` of the host's own — a pi + * host with no Constructive project (a remote coding job gating `bash`) has + * nothing to give the former. + */ +export type ConfirmGateOptions = ConfirmGateDeps | { policy: GatePolicy }; + export type ConfirmGate = { onAgentStart: () => void; onToolCall: ( @@ -47,32 +55,36 @@ export type ConfirmGate = { ) => Promise; }; -export function createConfirmGate(deps: ConfirmGateDeps): ConfirmGate { - const gate: HarnessConfirmGate = createHarnessConfirmGate({ - gatedTools: deps.gatedTools, - isProjectRunnable: async (cwd) => { - const resolved = await deps.resolveProjectContext(cwd); - return resolved.context !== null; - }, - hasDataToken: async (cwd) => { - const resolved = await deps.resolveProjectContext(cwd); - if (!resolved.context) return false; - const token = await deps.resolveDataToken(resolved.context); - return Boolean(token.token); - }, - resolveTemplatePreview: async (cwd, blueprintName, displayName) => { - const resolved = await deps.resolveProjectContext(cwd); - if (!resolved.context) return undefined; - const result = await deps.createTemplatePreviewTables(resolved.context, blueprintName); - if (result.tables.length === 0) return undefined; - return { - kind: 'template', - displayName, - blueprintName: result.blueprintName || undefined, - tables: result.tables, - }; - }, - }); +export function createConfirmGate(options: ConfirmGateOptions): ConfirmGate { + const gate: HarnessConfirmGate = createHarnessConfirmGate( + 'policy' in options + ? options + : { + gatedTools: options.gatedTools, + isProjectRunnable: async (cwd) => { + const resolved = await options.resolveProjectContext(cwd); + return resolved.context !== null; + }, + hasDataToken: async (cwd) => { + const resolved = await options.resolveProjectContext(cwd); + if (!resolved.context) return false; + const token = await options.resolveDataToken(resolved.context); + return Boolean(token.token); + }, + resolveTemplatePreview: async (cwd, blueprintName, displayName) => { + const resolved = await options.resolveProjectContext(cwd); + if (!resolved.context) return undefined; + const result = await options.createTemplatePreviewTables(resolved.context, blueprintName); + if (result.tables.length === 0) return undefined; + return { + kind: 'template', + displayName, + blueprintName: result.blueprintName || undefined, + tables: result.tables, + }; + }, + }, + ); return { onAgentStart: gate.onAgentStart, diff --git a/agentic/pi/src/index.ts b/agentic/pi/src/index.ts index b7d02bf7c..e7eeb83ec 100644 --- a/agentic/pi/src/index.ts +++ b/agentic/pi/src/index.ts @@ -57,7 +57,12 @@ export function createDbTools(host: PiToolsHost): ExtensionFactory { return dbTools; } -export { type ConfirmGate, type ConfirmGateDeps, createConfirmGate } from './confirm-gate'; +export { + type ConfirmGate, + type ConfirmGateDeps, + type ConfirmGateOptions, + createConfirmGate, +} from './confirm-gate'; export { deriveSubdomainEndpoint, type ModulesClient,