Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions agentic/harness/__tests__/gating.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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();
Expand Down
136 changes: 42 additions & 94 deletions agentic/harness/src/gating/confirm-gate.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};

/** `block: true` stops the tool call; `reason` is surfaced to the agent. */
export type GateResult = { block: true; reason: string } | undefined;

Expand All @@ -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<boolean>;
/** Whether a data-plane token is available (gates `add_records`). */
hasDataToken(cwd: string): Promise<boolean>;
/**
* 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<ConfirmPreview | undefined>;
/**
* 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<string>;
};
/**
* 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<GateResult>;
};

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<string, unknown> | undefined,
preview?: ConfirmPreview
): Promise<GateResult> {
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) {
Expand All @@ -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;
},
};
}
84 changes: 84 additions & 0 deletions agentic/harness/src/gating/constructive-policy.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
/** Whether a data-plane token is available (gates `add_records`). */
hasDataToken(cwd: string): Promise<boolean>;
/**
* 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<ConfirmPreview | undefined>;
/**
* 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<string>;
};

/**
* 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;
},
};
}
38 changes: 38 additions & 0 deletions agentic/harness/src/gating/policy.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};

/**
* 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<ConfirmPrompt | null>;
};
3 changes: 3 additions & 0 deletions agentic/harness/src/gating/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Loading
Loading