moving
This commit is contained in:
@@ -2,10 +2,12 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { executeToolCall } from '../src/tool-executor.js';
|
||||
import { LoopGuard } from '../src/loop-guard.js';
|
||||
import { HookRegistry } from '../src/hooks.js';
|
||||
import { classifyGatedToolRisk, needsConfirmation, needsConfirmationWithAutonomy } from '../src/confirmation.js';
|
||||
import type { ToolDefinition } from '../src/tools.js';
|
||||
import type { RiskLevel } from '@waggle/shared';
|
||||
|
||||
/**
|
||||
* SEC-GATE — defense-in-depth critical floor (tool-executor.ts step 4b).
|
||||
* SEC-GATE — defense-in-depth state-change approval floor (step 4b).
|
||||
*
|
||||
* The confirmation-bypass let a spawn path constructed with `hooks: undefined`
|
||||
* execute CRITICAL_NEVER_AUTOPASS commands (rm -rf ~, sudo, git push --force
|
||||
@@ -17,12 +19,13 @@ import type { ToolDefinition } from '../src/tools.js';
|
||||
* behaviour: no hooks ⇒ skip all gate logic), so these tests fail pre-fix.
|
||||
*/
|
||||
|
||||
function tool(name: string, output: string, spy?: () => void): ToolDefinition {
|
||||
function tool(name: string, output: string, spy?: () => void, riskLevel?: RiskLevel): ToolDefinition {
|
||||
return {
|
||||
name,
|
||||
description: `test tool ${name}`,
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: async () => { spy?.(); return output; },
|
||||
riskLevel,
|
||||
} as unknown as ToolDefinition;
|
||||
}
|
||||
|
||||
@@ -33,7 +36,7 @@ function call(name: string, args: Record<string, unknown> = {}) {
|
||||
const CRITICAL_BASH = { command: 'rm -rf ~' };
|
||||
const CRITICAL_FORCE_PUSH = { command: 'git push --force origin main' };
|
||||
|
||||
describe('tool-executor critical-destructive hard floor (SEC-GATE step 4b)', () => {
|
||||
describe('tool-executor state-change approval floor (SEC-GATE step 4b)', () => {
|
||||
it('DENIES a critical bash command when no hook and no approval callback are wired', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['bash', tool('bash', 'BASH_RAN', spy)]]);
|
||||
@@ -59,11 +62,54 @@ describe('tool-executor critical-destructive hard floor (SEC-GATE step 4b)', ()
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS a critical command when a pre:tool approval hook is wired (main-loop path)', async () => {
|
||||
it('DENIES a critical command when the hook registry has no approval result', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['bash', tool('bash', 'BASH_RAN', spy)]]);
|
||||
const hooks = new HookRegistry();
|
||||
hooks.on('pre:tool', () => { /* approve — no cancel */ });
|
||||
const r = await executeToolCall(call('bash', CRITICAL_BASH), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
hooks,
|
||||
});
|
||||
expect(r.content).toContain('[BLOCKED]');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DENIES a critical command when only a non-authorizing hook runs', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['bash', tool('bash', 'BASH_RAN', spy)]]);
|
||||
const hooks = new HookRegistry();
|
||||
hooks.on('pre:tool', () => undefined);
|
||||
const r = await executeToolCall(call('bash', CRITICAL_BASH), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
hooks,
|
||||
});
|
||||
expect(r.content).toContain('[BLOCKED]');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DENIES a critical command when the approval hook throws', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['bash', tool('bash', 'BASH_RAN', spy)]]);
|
||||
const hooks = new HookRegistry();
|
||||
hooks.on('pre:tool', () => {
|
||||
throw new Error('approval gate unavailable');
|
||||
});
|
||||
const r = await executeToolCall(call('bash', CRITICAL_BASH), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
hooks,
|
||||
});
|
||||
expect(r.content).toContain('[BLOCKED]');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS a critical command with explicit pre:tool authorization (main-loop path)', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['bash', tool('bash', 'BASH_RAN', spy)]]);
|
||||
const hooks = new HookRegistry();
|
||||
hooks.on('pre:tool', () => ({ authorize: true }));
|
||||
const r = await executeToolCall(call('bash', CRITICAL_BASH), { toolMap, guard: new LoopGuard(), hooks });
|
||||
expect(r.content).toContain('BASH_RAN');
|
||||
expect(r.countedAsUsed).toBe(true);
|
||||
@@ -101,12 +147,175 @@ describe('tool-executor critical-destructive hard floor (SEC-GATE step 4b)', ()
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT block non-critical commands with no gate (floor is surgical)', async () => {
|
||||
it('DENIES a non-critical state-changing bash command when no approval gate is wired', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['bash', tool('bash', 'LISTING', spy)]]);
|
||||
const r = await executeToolCall(call('bash', { command: 'ls -la' }), { toolMap, guard: new LoopGuard() });
|
||||
expect(r.content).toContain('LISTING');
|
||||
expect(r.countedAsUsed).toBe(true);
|
||||
const toolMap = new Map([['bash', tool('bash', 'DIRECTORY_CREATED', spy)]]);
|
||||
const r = await executeToolCall(call('bash', { command: 'mkdir work-output' }), { toolMap, guard: new LoopGuard() });
|
||||
expect(r.content).toContain('[BLOCKED]');
|
||||
expect(r.countedAsUsed).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['write_file', { path: 'report.md', content: 'unsafe write' }, undefined],
|
||||
['opaque_connector_write', { task: 'create' }, 'medium' as RiskLevel],
|
||||
['connector_database_drop_table', { table: 'users' }, undefined],
|
||||
['connector_drive_remove', { id: 'shared-file' }, 'low' as RiskLevel],
|
||||
['git_pull', { remote: 'origin', branch: 'main' }, undefined],
|
||||
['git_branch', { action: 'create', name: 'feature/new' }, undefined],
|
||||
['git_branch', { action: 'switch', name: 'feature/next' }, undefined],
|
||||
['git_branch', { action: 'delete', name: 'feature/old' }, undefined],
|
||||
['git_stash', { action: 'save', message: 'work' }, undefined],
|
||||
['git_stash', { action: 'pop' }, undefined],
|
||||
['git_stash', { action: 'drop' }, undefined],
|
||||
])('DENIES confirmation-required %s when no approval gate is wired', async (name, args, riskLevel) => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([[name, tool(name, 'MUTATION_RAN', spy, riskLevel)]]);
|
||||
|
||||
const result = await executeToolCall(call(name, args), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
});
|
||||
|
||||
expect(result.content).toContain('[BLOCKED]');
|
||||
expect(result.countedAsUsed).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS a confirmation-required write after explicit hook authorization', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['write_file', tool('write_file', 'FILE_WRITTEN', spy)]]);
|
||||
const hooks = new HookRegistry();
|
||||
hooks.on('pre:tool', () => ({ authorize: true }));
|
||||
|
||||
const result = await executeToolCall(call('write_file', {
|
||||
path: 'report.md',
|
||||
content: 'approved write',
|
||||
}), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
hooks,
|
||||
});
|
||||
|
||||
expect(result.content).toContain('FILE_WRITTEN');
|
||||
expect(result.countedAsUsed).toBe(true);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ALLOWS a critical connector action after explicit hook authorization', async () => {
|
||||
const spy = vi.fn();
|
||||
const name = 'connector_database_drop_table';
|
||||
const toolMap = new Map([[name, tool(name, 'TABLE_DROPPED', spy, 'low')]]);
|
||||
const hooks = new HookRegistry();
|
||||
hooks.on('pre:tool', () => ({ authorize: true }));
|
||||
|
||||
const result = await executeToolCall(call(name, { table: 'approved_archive' }), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
hooks,
|
||||
});
|
||||
|
||||
expect(result.content).toContain('TABLE_DROPPED');
|
||||
expect(result.countedAsUsed).toBe(true);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['git_branch', { action: 'list' }],
|
||||
['git_stash', { action: 'list' }],
|
||||
])('still ALLOWS read-only %s list operations without an approval gate', async (name, args) => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([[name, tool(name, 'LISTING', spy)]]);
|
||||
|
||||
const result = await executeToolCall(call(name, args), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
});
|
||||
|
||||
expect(result.content).toContain('LISTING');
|
||||
expect(result.countedAsUsed).toBe(true);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('still ALLOWS a read-only tool when no approval gate is wired', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([['read_file', tool('read_file', 'CONTENTS', spy)]]);
|
||||
|
||||
const result = await executeToolCall(call('read_file', { path: 'report.md' }), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
});
|
||||
|
||||
expect(result.content).toContain('CONTENTS');
|
||||
expect(result.countedAsUsed).toBe(true);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trusted ToolDefinition risk metadata at the pre:tool boundary', () => {
|
||||
it('forwards metadata to the confirmation hook and blocks an opaque medium-risk tool', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([
|
||||
['opaque_plugin_action', tool('opaque_plugin_action', 'PLUGIN_RAN', spy, 'medium')],
|
||||
]);
|
||||
const hooks = new HookRegistry();
|
||||
let observedRisk: unknown;
|
||||
hooks.on('pre:tool', (ctx) => {
|
||||
observedRisk = ctx.riskLevel;
|
||||
if (ctx.toolName && needsConfirmationWithAutonomy(
|
||||
ctx.toolName,
|
||||
ctx.args,
|
||||
'normal',
|
||||
ctx.riskLevel as RiskLevel | undefined,
|
||||
)) {
|
||||
return { cancel: true, reason: 'trusted risk requires approval' };
|
||||
}
|
||||
});
|
||||
|
||||
const result = await executeToolCall(call('opaque_plugin_action', {
|
||||
riskLevel: 'low',
|
||||
_riskLevel: 'low',
|
||||
}), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
hooks,
|
||||
});
|
||||
|
||||
expect(observedRisk).toBe('medium');
|
||||
expect(result.content).toContain('[BLOCKED]');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fail-closes an opaque high-risk tool when no approval gate is wired', async () => {
|
||||
const spy = vi.fn();
|
||||
const toolMap = new Map([
|
||||
['opaque_plugin_action', tool('opaque_plugin_action', 'PLUGIN_RAN', spy, 'high')],
|
||||
]);
|
||||
|
||||
const result = await executeToolCall(call('opaque_plugin_action'), {
|
||||
toolMap,
|
||||
guard: new LoopGuard(),
|
||||
});
|
||||
|
||||
expect(result.content).toContain('[BLOCKED]');
|
||||
expect(result.countedAsUsed).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps high-risk metadata gated at YOLO and reports its stronger risk class', () => {
|
||||
expect(needsConfirmationWithAutonomy('opaque_plugin_action', {}, 'yolo', 'high')).toBe(true);
|
||||
expect(classifyGatedToolRisk('opaque_plugin_action', {}, 'high')).toEqual({
|
||||
riskLevel: 'high',
|
||||
approvalClass: 'critical',
|
||||
});
|
||||
});
|
||||
|
||||
it('never lets low metadata downgrade name-based policy', () => {
|
||||
expect(needsConfirmation('connector_composio_execute_action', {}, 'low')).toBe(true);
|
||||
expect(classifyGatedToolRisk('connector_composio_execute_action', {}, 'low')).toEqual({
|
||||
riskLevel: 'high',
|
||||
approvalClass: 'critical',
|
||||
});
|
||||
expect(needsConfirmation('opaque_read_action', {}, 'low')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user