diff --git a/agentic/harness/__tests__/gating.test.ts b/agentic/harness/__tests__/gating.test.ts index e7a05c930..1e3ff954b 100644 --- a/agentic/harness/__tests__/gating.test.ts +++ b/agentic/harness/__tests__/gating.test.ts @@ -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; @@ -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(), + }); + 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(); diff --git a/agentic/harness/src/gating/confirm-gate.ts b/agentic/harness/src/gating/confirm-gate.ts index 07d2a2ed5..e725a941a 100644 --- a/agentic/harness/src/gating/confirm-gate.ts +++ b/agentic/harness/src/gating/confirm-gate.ts @@ -51,6 +51,13 @@ export type ConfirmGateDeps = { 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; }; export type ConfirmGate = { @@ -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, @@ -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; diff --git a/agentic/pi/src/confirm-gate.ts b/agentic/pi/src/confirm-gate.ts index 94f270bd4..32613dd93 100644 --- a/agentic/pi/src/confirm-gate.ts +++ b/agentic/pi/src/confirm-gate.ts @@ -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; }; export type ConfirmGate = { @@ -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; diff --git a/pnpm-policy.yaml b/pnpm-policy.yaml index beafd2691..9beda0985 100644 --- a/pnpm-policy.yaml +++ b/pnpm-policy.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cf3372bf3..3cc7d4157 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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/*" @@ -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.