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
68 changes: 67 additions & 1 deletion agentic/harness/__tests__/gating.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ConfirmGate, ConfirmGateDeps, createConfirmGate, GateHost } from '../src/gating/confirm-gate';
import { createDeclineGuard } from '../src/gating/decline-guard';
import { buildConfirmPrompt } from '../src/gating/prompts';
import { buildConfirmPrompt, MUTATING_DB_TOOLS } from '../src/gating/prompts';

type Harness = {
gate: ConfirmGate;
Expand Down Expand Up @@ -218,6 +218,72 @@ describe('confirm gate: declines are respected', () => {
});
});

describe('confirm gate: the gated-tool set is injectable', () => {
it('gates the default MUTATING_DB_TOOLS set when none is injected', async () => {
const { gate, host, confirmCalls } = createHarness({ isProjectRunnable: async () => true });
for (const toolName of MUTATING_DB_TOOLS) {
if (toolName === 'add_records' || toolName === 'create_api_key') continue; // tokenless: skipped
const result = await gate.onToolCall(call(toolName, `tc-${toolName}`, {}), host, CWD);
expect(result?.block).toBe(true);
}
expect(confirmCalls()).toBe(MUTATING_DB_TOOLS.size - 2);
});

it('gates only the injected set: a custom tool is gated, a default one is not', async () => {
const { gate, host, confirmCalls } = createHarness({
isProjectRunnable: async () => true,
gatedTools: new Set(['bash']),
});

const gated = await gate.onToolCall(call('bash', 'tc-1', { command: 'rm -rf /' }), host, CWD);
expect(gated?.block).toBe(true);
expect(gated?.reason).toMatch(/declined it/);
expect(confirmCalls()).toBe(1);

// delete_table is in MUTATING_DB_TOOLS but not in the injected set.
const ungated = await gate.onToolCall(
call('delete_table', 'tc-2', { table_name: 'users' }),
host,
CWD
);
expect(ungated).toBeUndefined();
expect(confirmCalls()).toBe(1);
});

it('applies the decline guard and headless block to injected tools too', async () => {
const { gate, host, confirmCalls, skipNotices } = createHarness({
isProjectRunnable: async () => true,
gatedTools: new Set(['bash']),
});
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/);
expect(confirmCalls()).toBe(1);
expect(skipNotices()).toEqual(['tc-2']);

const headless: GateHost = {
hasUI: false,
confirmTool: async () => true,
notifyToolSkipped: () => undefined,
};
const blocked = await gate.onToolCall(call('bash', 'tc-3', {}), headless, CWD);
expect(blocked?.reason).toMatch(/no confirmation UI/);
});

it('gates nothing when an empty set is injected', async () => {
const { gate, host, confirmCalls } = createHarness({
isProjectRunnable: async () => true,
gatedTools: new Set<string>(),
});
expect(
await gate.onToolCall(call('provision_database', 'tc-1', {}), host, CWD)
).toBeUndefined();
expect(confirmCalls()).toBe(0);
});
});

describe('decline guard canonicalization', () => {
it('treats nested key order as equivalent and clears per run', () => {
const guard = createDeclineGuard();
Expand Down
10 changes: 9 additions & 1 deletion agentic/harness/src/gating/confirm-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export type ConfirmGateDeps = {
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>;
};

export type ConfirmGate = {
Expand All @@ -60,6 +67,7 @@ export type ConfirmGate = {

export function createConfirmGate(deps: ConfirmGateDeps): ConfirmGate {
const declineGuard = createDeclineGuard();
const gatedTools = deps.gatedTools ?? MUTATING_DB_TOOLS;

async function confirmOrDecline(
event: GateToolCallEvent,
Expand Down Expand Up @@ -89,7 +97,7 @@ export function createConfirmGate(deps: ConfirmGateDeps): ConfirmGate {
onAgentStart: () => declineGuard.clear(),

onToolCall: async (event, host, cwd) => {
if (!MUTATING_DB_TOOLS.has(event.toolName)) return;
if (!gatedTools.has(event.toolName)) return;

const input = event.input;

Expand Down
3 changes: 3 additions & 0 deletions agentic/pi/src/confirm-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type ConfirmGateDeps = {
resolveProjectContext: typeof resolveProjectContext;
resolveDataToken: typeof resolveDataToken;
createTemplatePreviewTables: typeof createTemplatePreviewTables;
/** Tool names to gate; defaults to the harness's `MUTATING_DB_TOOLS`. */
gatedTools?: ReadonlySet<string>;
};

export type ConfirmGate = {
Expand All @@ -47,6 +49,7 @@ export type ConfirmGate = {

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;
Expand Down
12 changes: 0 additions & 12 deletions pnpm-policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,22 +94,10 @@ exceptions:
versions: ["8.66.0"]
reason: ^8.66.0 floor matches the newest release; nothing older satisfies it
until: 2026-08-17
- package: "@playwright/test"
versions: ["1.62.1"]
reason: ^1.62.1 floor matches the newest release; nothing older satisfies it
until: 2026-08-13
- package: "@types/pg"
versions: ["8.20.4"]
reason: ^8.20.4 floor matches the newest release; nothing older satisfies it
until: 2026-08-18
- package: "@types/react"
versions: ["19.2.18"]
reason: ^19.2.18 floor matches the newest release; nothing older satisfies it
until: 2026-08-13
- package: "@types/react-dom"
versions: ["19.2.4"]
reason: ^19.2.4 floor matches the newest release; nothing older satisfies it
until: 2026-08-13
- package: "@types/semver"
versions: ["7.8.0"]
reason: ^7.8.0 floor matches the newest release; nothing older satisfies it
Expand Down
5 changes: 1 addition & 4 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ packages:
# Most malicious releases are found and yanked well inside that window.
minimumReleaseAge: 2880

# Exempt from the wait: 6 scope glob(s), 41 first-party package(s), 7 exception(s).
# Exempt from the wait: 6 scope glob(s), 41 first-party package(s), 4 exception(s).
# First-party membership comes from the inventory.
minimumReleaseAgeExclude:
- "@constructive-db/*"
Expand Down Expand Up @@ -67,10 +67,7 @@ minimumReleaseAgeExclude:
- yanse
- "@typescript-eslint/eslint-plugin@8.66.0" # ^8.66.0 floor matches the newest release; nothing older satisfies it (expires 2026-08-17)
- "@typescript-eslint/parser@8.66.0" # ^8.66.0 floor matches the newest release; nothing older satisfies it (expires 2026-08-17)
- "@playwright/test@1.62.1" # ^1.62.1 floor matches the newest release; nothing older satisfies it (expires 2026-08-13)
- "@types/pg@8.20.4" # ^8.20.4 floor matches the newest release; nothing older satisfies it (expires 2026-08-18)
- "@types/react@19.2.18" # ^19.2.18 floor matches the newest release; nothing older satisfies it (expires 2026-08-13)
- "@types/react-dom@19.2.4" # ^19.2.4 floor matches the newest release; nothing older satisfies it (expires 2026-08-13)
- "@types/semver@7.8.0" # ^7.8.0 floor matches the newest release; nothing older satisfies it (expires 2026-08-16)

# Off: transitive dependencies may resolve from git or a URL.
Expand Down
Loading