feat(safety): gate sensitive tools behind elevation and immutable denylist

This commit is contained in:
William Valentin
2026-02-17 23:51:04 -08:00
parent 9345a864f4
commit 540f6780e6
10 changed files with 279 additions and 3 deletions
+83 -1
View File
@@ -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) {