feat(safety): gate sensitive tools behind elevation and immutable denylist
This commit is contained in:
@@ -348,6 +348,7 @@ describe('ToolExecutor', () => {
|
||||
const result = await executor.execute('shell.exec', { command: 'rm -rf /' }, {
|
||||
untrustedContent: true,
|
||||
executionEnvironment: 'host',
|
||||
sensitiveMode: 'confirm_without_elevation',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('blocked');
|
||||
@@ -388,6 +389,7 @@ describe('ToolExecutor', () => {
|
||||
skillPermissions: { execution_environment: 'sandbox' },
|
||||
executionEnvironment: 'host',
|
||||
autonomyLevel: 'autonomous',
|
||||
sensitiveMode: 'confirm_without_elevation',
|
||||
});
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.error).toContain('execution_environment=host');
|
||||
@@ -399,6 +401,7 @@ describe('ToolExecutor', () => {
|
||||
elevatedHostUntilMs: Date.now() + 60_000,
|
||||
elevatedHostId: 'e1',
|
||||
autonomyLevel: 'autonomous',
|
||||
sensitiveMode: 'confirm_without_elevation',
|
||||
});
|
||||
const pending = hooks.getPendingConfirmations();
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -435,4 +438,74 @@ describe('ToolExecutor', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('sandbox-out');
|
||||
});
|
||||
|
||||
it('denies sensitive host tools without elevation in deny_without_elevation mode', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register({
|
||||
name: 'shell.exec',
|
||||
description: 'shell',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
execute: async () => ({ success: true, output: 'ok' }),
|
||||
});
|
||||
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
|
||||
const executor = new ToolExecutor(registry, hooks, { sensitiveMode: 'deny_without_elevation' });
|
||||
|
||||
const result = await executor.execute('shell.exec', { command: 'echo hi' }, {
|
||||
executionEnvironment: 'host',
|
||||
autonomyLevel: 'autonomous',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('requires /elevate');
|
||||
});
|
||||
|
||||
it('allows sensitive host tools after elevation and requires confirmation', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register({
|
||||
name: 'shell.exec',
|
||||
description: 'shell',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
execute: async () => ({ success: true, output: 'ok' }),
|
||||
});
|
||||
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
|
||||
const executor = new ToolExecutor(registry, hooks, { sensitiveMode: 'deny_without_elevation' });
|
||||
|
||||
const pendingResult = executor.execute('shell.exec', { command: 'echo hi' }, {
|
||||
executionEnvironment: 'host',
|
||||
autonomyLevel: 'autonomous',
|
||||
elevatedHostUntilMs: Date.now() + 60_000,
|
||||
elevatedHostId: 'elev-1',
|
||||
});
|
||||
const pending = hooks.getPendingConfirmations();
|
||||
expect(pending).toHaveLength(1);
|
||||
hooks.resolveConfirmation(pending[0].id, { approved: true });
|
||||
|
||||
const result = await pendingResult;
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('enforces immutable denylist even during elevation', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register({
|
||||
name: 'shell.exec',
|
||||
description: 'shell',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
execute: async () => ({ success: true, output: 'ok' }),
|
||||
});
|
||||
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
|
||||
const executor = new ToolExecutor(registry, hooks, {
|
||||
sensitiveMode: 'deny_without_elevation',
|
||||
immutableDenylist: [
|
||||
{ tool: 'shell.exec', argsPattern: 'git reset --hard', reason: 'blocked by policy' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executor.execute('shell.exec', { command: 'git reset --hard HEAD~1' }, {
|
||||
executionEnvironment: 'host',
|
||||
elevatedHostUntilMs: Date.now() + 60_000,
|
||||
elevatedHostId: 'elev-2',
|
||||
autonomyLevel: 'autonomous',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('blocked by policy');
|
||||
});
|
||||
});
|
||||
|
||||
+83
-1
@@ -1,7 +1,7 @@
|
||||
import type { ToolResult } from './types.js';
|
||||
import type { ToolRegistry } from './registry.js';
|
||||
import type { HookEngine } from '../hooks/engine.js';
|
||||
import type { ToolPolicyContext } from './policy.js';
|
||||
import type { ImmutableDenyRule, SensitiveMode, ToolPolicyContext } from './policy.js';
|
||||
import { resolveAutonomy } from '../hooks/autonomy.js';
|
||||
import { auditLogger } from '../audit/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -13,6 +13,8 @@ import { createSandboxedProcessStartTool, createSandboxedShellTool } from '../sa
|
||||
export interface ToolExecutorConfig {
|
||||
defaultTimeoutMs?: number;
|
||||
maxOutputBytes?: number;
|
||||
sensitiveMode?: SensitiveMode;
|
||||
immutableDenylist?: ImmutableDenyRule[];
|
||||
}
|
||||
|
||||
export interface ToolExecutionObserverEvent {
|
||||
@@ -27,6 +29,8 @@ export class ToolExecutor {
|
||||
private hooks: HookEngine;
|
||||
private defaultTimeoutMs: number;
|
||||
private maxOutputBytes: number;
|
||||
private sensitiveMode: SensitiveMode;
|
||||
private immutableDenylist: ImmutableDenyRule[];
|
||||
private sandboxManager?: SandboxManager;
|
||||
private executionObserver?: (event: ToolExecutionObserverEvent) => void;
|
||||
|
||||
@@ -35,6 +39,8 @@ export class ToolExecutor {
|
||||
this.hooks = hooks;
|
||||
this.defaultTimeoutMs = config?.defaultTimeoutMs ?? 30_000;
|
||||
this.maxOutputBytes = config?.maxOutputBytes ?? 51_200;
|
||||
this.sensitiveMode = config?.sensitiveMode ?? 'deny_without_elevation';
|
||||
this.immutableDenylist = config?.immutableDenylist ?? [];
|
||||
}
|
||||
|
||||
setSandboxManager(manager?: SandboxManager): void {
|
||||
@@ -79,6 +85,21 @@ export class ToolExecutor {
|
||||
|
||||
const argsRedaction = redactForAudit(args);
|
||||
|
||||
const immutableDenyReason = this.evaluateImmutableDenylist(tool.name, args, context);
|
||||
if (immutableDenyReason) {
|
||||
auditLogger?.toolDenied({
|
||||
tool_name: tool.name,
|
||||
reason: immutableDenyReason,
|
||||
denial_type: 'policy',
|
||||
execution_id: executionId,
|
||||
execution_environment: executionEnvironment,
|
||||
skill_name: skillName,
|
||||
redactions_applied: argsRedaction.redactions,
|
||||
session_id: context?.sessionId,
|
||||
});
|
||||
return { success: false, output: '', error: `Tool '${tool.name}' denied: ${immutableDenyReason}` };
|
||||
}
|
||||
|
||||
// Secret scope enforcement
|
||||
const requiredScopes = tool.requiredSecretScopes ?? [];
|
||||
const allowedScopes = this.resolveAllowedSecretScopes(context);
|
||||
@@ -132,6 +153,22 @@ export class ToolExecutor {
|
||||
return { success: false, output: '', error: `Tool '${tool.name}' blocked: ${guard}` };
|
||||
}
|
||||
|
||||
if (this.shouldDenyWithoutElevation(tool.name, executionEnvironment, context)) {
|
||||
const mode = context?.sensitiveMode ?? this.sensitiveMode;
|
||||
const reason = `sensitive tool requires /elevate before host execution (mode=${mode})`;
|
||||
auditLogger?.toolDenied({
|
||||
tool_name: tool.name,
|
||||
reason,
|
||||
denial_type: 'policy',
|
||||
execution_id: executionId,
|
||||
execution_environment: executionEnvironment,
|
||||
skill_name: skillName,
|
||||
redactions_applied: argsRedaction.redactions,
|
||||
session_id: context?.sessionId,
|
||||
});
|
||||
return { success: false, output: '', error: `Tool '${tool.name}' denied: ${reason}` };
|
||||
}
|
||||
|
||||
// Policy check (defense in depth — tools should also be filtered at listing time)
|
||||
const policy = this.registry.getPolicy();
|
||||
if (policy) {
|
||||
@@ -375,6 +412,51 @@ export class ToolExecutor {
|
||||
].includes(toolName);
|
||||
}
|
||||
|
||||
private isSensitiveTool(toolName: string): boolean {
|
||||
if (toolName === 'shell.exec' || toolName === 'process.start' || toolName === 'process.kill') {
|
||||
return true;
|
||||
}
|
||||
if (toolName.startsWith('browser.')) {
|
||||
return true;
|
||||
}
|
||||
return ['message.send', 'cron.create', 'cron.delete'].includes(toolName);
|
||||
}
|
||||
|
||||
private shouldDenyWithoutElevation(toolName: string, executionEnvironment: 'host' | 'sandbox', context?: ToolPolicyContext): boolean {
|
||||
const mode = context?.sensitiveMode ?? this.sensitiveMode;
|
||||
if (mode !== 'deny_without_elevation') {
|
||||
return false;
|
||||
}
|
||||
if (executionEnvironment !== 'host') {
|
||||
return false;
|
||||
}
|
||||
if (!this.isSensitiveTool(toolName)) {
|
||||
return false;
|
||||
}
|
||||
return !this.isElevationActive(context);
|
||||
}
|
||||
|
||||
private evaluateImmutableDenylist(toolName: string, args: unknown, context?: ToolPolicyContext): string | null {
|
||||
const rules = context?.immutableDenylist ?? this.immutableDenylist;
|
||||
if (!rules || rules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serializedArgs = JSON.stringify(args ?? {}).toLowerCase();
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!matchesAnyPattern(toolName, [rule.tool])) {
|
||||
continue;
|
||||
}
|
||||
if (rule.argsPattern && !serializedArgs.includes(rule.argsPattern.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
return rule.reason ?? `blocked by immutable denylist rule (${rule.tool}${rule.argsPattern ? ` / ${rule.argsPattern}` : ''})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private checkCapabilityConstraints(toolName: string, args: unknown, context: ToolPolicyContext | undefined, effectiveEnv: 'host' | 'sandbox'): string | null {
|
||||
const perms = context?.skillPermissions;
|
||||
if (!perms) {
|
||||
|
||||
@@ -150,6 +150,17 @@ function matchesAnyPattern(toolName: string, patterns: string[]): boolean {
|
||||
// ── Policy context ──────────────────────────────────────────────────
|
||||
|
||||
/** Identifies the runtime context for tool policy resolution. */
|
||||
export type SensitiveMode = 'deny_without_elevation' | 'confirm_without_elevation';
|
||||
|
||||
export interface ImmutableDenyRule {
|
||||
/** Tool name glob pattern (e.g. shell.exec, process.*). */
|
||||
tool: string;
|
||||
/** Optional case-insensitive substring matched against serialized args. */
|
||||
argsPattern?: string;
|
||||
/** Optional human-readable denial reason. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ToolPolicyContext {
|
||||
/** Model tier name (e.g. 'fast', 'default', 'complex', 'local'). */
|
||||
agent?: string;
|
||||
@@ -186,6 +197,11 @@ export interface ToolPolicyContext {
|
||||
elevatedHostReason?: string;
|
||||
/** Correlation id for elevation window. */
|
||||
elevatedHostId?: string;
|
||||
|
||||
/** Sensitive operation mode for host-executed sensitive tools. */
|
||||
sensitiveMode?: SensitiveMode;
|
||||
/** Immutable denylist enforced before hooks/autonomy checks. */
|
||||
immutableDenylist?: ImmutableDenyRule[];
|
||||
}
|
||||
|
||||
function resolveSkillAllowedNames(allToolNames: string[], permissions?: SkillPermissions): Set<string> | null {
|
||||
|
||||
Reference in New Issue
Block a user