/** * Tool Execution Pipeline Benchmark * * Measures the overhead of each phase in the permission/security pipeline: * 1. classifyRisk — risk classification * 2. check — trust rule matching (both no-rule fallback and matched-rule paths) * 3. scanText — secret scanning on output * 4. ToolExecutor.execute() — full pipeline overhead with noop/slow tools * * Target ranges: * - p50 pipeline overhead (classifyRisk + check) < 20ms for pre-approved tools * - p95 pipeline overhead < 50ms * - Overhead is constant regardless of tool execution time * - Secret scanning < 5ms for short outputs (< 1KB) * - Secret scanning < 50ms for large outputs (100KB) * - ToolExecutor overhead < 20ms regardless of tool execution time */ import { beforeAll, describe, expect, mock, test } from "bun:test"; import * as realPlatform from "../util/platform.js"; import { installThresholdReaderMock } from "./gateway-threshold-reader-mock.js"; // Local registry for ToolExecutor tests — the mock delegates to this map // so that registerTool/getTool/getAllTools work for our benchmark tools. const localRegistry = new Map(); mock.module("../config/skills.js", () => ({ resolveSkillSelector: () => ({ skill: null }), loadSkillCatalog: () => [], })); mock.module("../tools/registry.js", () => ({ getTool: (name: string) => localRegistry.get(name), resolveTool: (name: string) => localRegistry.get(name), getAllTools: () => Array.from(localRegistry.values()), registerTool: (tool: import("../tools/types.js").Tool) => { localRegistry.set(tool.name, tool); }, })); mock.module("../config/assistant-feature-flags.js", () => ({ isAssistantFeatureFlagEnabled: () => false, })); mock.module("../config/env-registry.js", () => ({ getIsContainerized: () => false, })); mock.module("../util/platform.js", () => ({ ...realPlatform, getWorkspaceDir: () => "/tmp", getProtectedDir: () => "/tmp/protected", getWorkspaceHooksDir: () => "/tmp/hooks", getDeprecatedDir: () => "/tmp/deprecated", })); // Mock defaults: no threshold, and no cell at any cascade level — the // benchmark measures the pipeline, not the channel matrix. installThresholdReaderMock(); const realWorkspacePolicy = await import("../permissions/workspace-policy.js"); mock.module("../permissions/workspace-policy.js", () => ({ ...realWorkspacePolicy, isWorkspaceScopedInvocation: () => false, isOutOfWorkspaceFileInvocation: () => false, isPathWithinWorkspaceRoot: () => false, })); mock.module("../tools/network/url-safety.js", () => ({ looksLikeHostPortShorthand: () => false, looksLikePathOnlyInput: () => false, })); mock.module("../skills/path-classifier.js", () => ({ getSkillRoots: () => [], normalizeFilePath: (p: string) => p, })); mock.module("../skills/include-graph.js", () => ({ indexCatalogById: () => new Map(), })); mock.module("../skills/transitive-version-hash.js", () => ({ computeTransitiveSkillVersionHash: () => undefined, })); mock.module("../skills/version-hash.js", () => ({ computeSkillVersionHash: () => "mock-version-hash", })); // Gateway IPC mock — classifyRisk delegates to the gateway via IPC. // In CI there's no gateway, so we return a sensible default. mock.module("../ipc/gateway-client.js", () => ({ ipcClassifyRisk: async (): Promise< import("@vellumai/gateway-client").ClassifyRiskIpcResponse > => ({ risk: "low", reason: "mock", matchType: "registry" as const, scopeOptions: [], }), ipcCall: async () => undefined, ipcCallPersistent: async () => undefined, resetPersistentClient: () => {}, })); import { check, classifyRisk } from "../permissions/checker.js"; import { PermissionPrompter } from "../permissions/prompter.js"; import { RiskLevel } from "../permissions/types.js"; import { scanText } from "../security/secret-scanner.js"; import { ToolExecutor } from "../tools/executor.js"; import type { Tool, ToolContext, ToolExecutionResult } from "../tools/types.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function percentile(values: number[], p: number): number { const sorted = [...values].sort((a, b) => a - b); const idx = Math.ceil((p / 100) * sorted.length) - 1; return sorted[Math.max(0, idx)]; } async function benchmarkAsync( fn: () => Promise, iterations: number, ): Promise<{ timings: number[]; results: T[] }> { const timings: number[] = []; const results: T[] = []; for (let i = 0; i < iterations; i++) { const start = performance.now(); const result = await fn(); timings.push(performance.now() - start); results.push(result); } return { timings, results }; } function benchmarkSync( fn: () => T, iterations: number, ): { timings: number[]; results: T[] } { const timings: number[] = []; const results: T[] = []; for (let i = 0; i < iterations; i++) { const start = performance.now(); const result = fn(); timings.push(performance.now() - start); results.push(result); } return { timings, results }; } function generateLargeOutput(sizeBytes: number): string { // Generate realistic-looking tool output with varied content const lines: string[] = []; const words = [ "function", "const", "let", "return", "import", "export", "class", "interface", "type", "async", "await", "Promise", "string", "number", "boolean", "undefined", "null", "void", ]; let currentSize = 0; while (currentSize < sizeBytes) { const lineWords: string[] = []; for (let w = 0; w < 10; w++) { lineWords.push(words[Math.floor(Math.random() * words.length)]); } const line = lineWords.join(" "); lines.push(line); currentSize += line.length + 1; // +1 for newline } return lines.join("\n").slice(0, sizeBytes); } // --------------------------------------------------------------------------- // Benchmark suite // --------------------------------------------------------------------------- const ITERATIONS = 100; const WARMUP = 5; describe("Tool execution pipeline benchmark", () => { // Warm up the parser/modules beforeAll(async () => { for (let i = 0; i < WARMUP; i++) { await classifyRisk("file_read", { path: "/tmp/test.ts" }, "/tmp"); await check("file_read", { path: "/tmp/test.ts" }, "/tmp"); scanText("no secrets here"); } }); test("classifyRisk: low-risk tool (file_read) is fast", async () => { const { timings } = await benchmarkAsync( () => classifyRisk("file_read", { path: "/tmp/test.ts" }, "/tmp"), ITERATIONS, ); const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); expect(p50).toBeLessThan(5); expect(p95).toBeLessThan(10); }); test("classifyRisk: bash command classification", async () => { const { timings, results } = await benchmarkAsync( () => classifyRisk("bash", { command: "ls -la /tmp" }, "/tmp"), ITERATIONS, ); const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); // Bash classification involves shell parsing so it is slower expect(p50).toBeLessThan(15); expect(p95).toBeLessThan(40); // Verify correctness: ls should be low risk expect(results[0].level).toBe(RiskLevel.Low); }); test("classifyRisk: medium-risk tool (file_write)", async () => { const { timings, results } = await benchmarkAsync( () => classifyRisk("file_write", { path: "/tmp/out.txt" }, "/tmp"), ITERATIONS, ); const p50 = percentile(timings, 50); expect(p50).toBeLessThan(5); expect(results[0].level).toBe(RiskLevel.Low); }); test("check: full permission check for low-risk tool", async () => { const { timings, results } = await benchmarkAsync( () => check("file_read", { path: "/tmp/test.ts" }, "/tmp"), ITERATIONS, ); const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); // Full check includes classifyRisk + trust rule lookup expect(p50).toBeLessThan(10); expect(p95).toBeLessThan(20); // Low-risk with no matching rule should auto-allow expect(results[0].decision).toBe("allow"); }); test("check: full permission check for bash command", async () => { const { timings, results } = await benchmarkAsync( () => check("bash", { command: "git status" }, "/tmp"), ITERATIONS, ); const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); // Bash involves shell parsing + trust rule lookup expect(p50).toBeLessThan(20); expect(p95).toBeLessThan(50); // git status is low risk, should auto-allow expect(results[0].decision).toBe("allow"); }); test("check: permission cost is stable across different input paths", async () => { // Verify that the permission check cost doesn't vary with input path length/complexity. // Actual tool-execution-time independence is tested in the ToolExecutor section below. const shortPathTimings: number[] = []; const longPathTimings: number[] = []; for (let i = 0; i < ITERATIONS; i++) { const start1 = performance.now(); await check("file_read", { path: "/tmp/fast.ts" }, "/tmp"); shortPathTimings.push(performance.now() - start1); const start2 = performance.now(); await check( "file_read", { path: "/tmp/slow-complex-deeply-nested-file.ts" }, "/tmp", ); longPathTimings.push(performance.now() - start2); } const shortP50 = percentile(shortPathTimings, 50); const longP50 = percentile(longPathTimings, 50); // Permission check cost should be roughly the same regardless of path length const ratio = Math.max(shortP50, longP50) / Math.max(Math.min(shortP50, longP50), 0.001); expect(ratio).toBeLessThan(5); }); test("scanText: short output (< 1KB) completes quickly", () => { const shortOutput = "Build succeeded. 42 tests passed, 0 failed.\nTime: 1.23s"; const { timings } = benchmarkSync(() => scanText(shortOutput), ITERATIONS); const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); expect(p50).toBeLessThan(5); expect(p95).toBeLessThan(10); }); test("scanText: large output (100KB) within budget", () => { const largeOutput = generateLargeOutput(100 * 1024); const { timings } = benchmarkSync(() => scanText(largeOutput), ITERATIONS); const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); expect(p50).toBeLessThan(50); expect(p95).toBeLessThan(100); }); test("scanText: output with secrets is detected without excessive overhead", () => { // Build fake secrets programmatically to avoid pre-commit hook false positives const fakeGhToken = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"; const fakeConnStr = "postgres://" + "user:s3cret@db.host.example.com:5432/mydb"; const outputWithSecrets = [ "Deploying to production...", `Using API key: ${fakeGhToken}`, `Connection: ${fakeConnStr}`, "Build complete.", ].join("\n"); const { timings, results } = benchmarkSync( () => scanText(outputWithSecrets), ITERATIONS, ); const p50 = percentile(timings, 50); expect(p50).toBeLessThan(5); // Verify detection correctness expect(results[0].length).toBeGreaterThanOrEqual(2); const types = results[0].map((m) => m.type); expect(types).toContain("GitHub Token"); expect(types).toContain("Database Connection String"); }); test("combined pipeline overhead (classifyRisk + check + scanText) stays under budget", async () => { const timings: number[] = []; for (let i = 0; i < ITERATIONS; i++) { const start = performance.now(); // Phase 1: Risk classification await classifyRisk("bash", { command: "git diff HEAD" }, "/tmp"); // Phase 2: Permission check await check("bash", { command: "git diff HEAD" }, "/tmp"); // Phase 3: Secret scanning on output scanText( "diff --git a/file.ts b/file.ts\n+const x = 42;\n-const x = 41;", ); timings.push(performance.now() - start); } const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); // Combined pipeline overhead for a pre-approved tool expect(p50).toBeLessThan(20); expect(p95).toBeLessThan(50); }); // ------------------------------------------------------------------------- // ToolExecutor end-to-end overhead benchmarks // ------------------------------------------------------------------------- describe("ToolExecutor overhead", () => { const SLEEP_MS = 50; // Fewer iterations for slow-tool tests to avoid timeouts (50ms * 30 = 1.5s) const SLOW_ITERATIONS = 30; let executor: ToolExecutor; const toolContext: ToolContext = { workingDir: "/tmp", conversationId: "bench-conv", trustClass: "guardian", }; function makeTool(name: string, sleepMs: number): Tool { return { name, description: `Benchmark tool (${sleepMs}ms)`, category: "benchmark", defaultRiskLevel: RiskLevel.Low, executionTarget: "sandbox", input_schema: { type: "object" as const, properties: {} }, execute: async (): Promise => { if (sleepMs > 0) { await new Promise((r) => setTimeout(r, sleepMs)); } return { content: "ok", isError: false }; }, }; } beforeAll(() => { // Auto-allow prompter (never called for low-risk tools, but required by constructor) const prompter = new PermissionPrompter(() => {}); executor = new ToolExecutor(prompter); const noopTool = makeTool("bench_noop", 0); const slowTool = makeTool("bench_slow", SLEEP_MS); localRegistry.set(noopTool.name, noopTool); localRegistry.set(slowTool.name, slowTool); }); test("ToolExecutor with noop tool: pipeline overhead < 20ms", async () => { // Warmup for (let i = 0; i < WARMUP; i++) { await executor.execute("bench_noop", {}, toolContext); } const { timings } = await benchmarkAsync( () => executor.execute("bench_noop", {}, toolContext), ITERATIONS, ); const p50 = percentile(timings, 50); const p95 = percentile(timings, 95); // Full pipeline overhead for a noop tool should be minimal expect(p50).toBeLessThan(20); expect(p95).toBeLessThan(50); }); test("ToolExecutor with slow tool (50ms): overhead is constant", async () => { // Warmup for (let i = 0; i < WARMUP; i++) { await executor.execute("bench_slow", {}, toolContext); } const { timings } = await benchmarkAsync( () => executor.execute("bench_slow", {}, toolContext), SLOW_ITERATIONS, ); const p50 = percentile(timings, 50); // Total time should be ~50ms + overhead. Pipeline overhead (total - sleep) // should be similar to the noop case. expect(p50).toBeGreaterThanOrEqual(SLEEP_MS); // Total should not exceed sleep + generous overhead budget expect(p50).toBeLessThan(SLEEP_MS + 30); }, 10_000); test("overhead subtraction: slow tool overhead matches noop overhead", async () => { // Run both tools and compare pipeline overhead const noopTimings: number[] = []; const slowTimings: number[] = []; for (let i = 0; i < SLOW_ITERATIONS; i++) { const s1 = performance.now(); await executor.execute("bench_noop", {}, toolContext); noopTimings.push(performance.now() - s1); const s2 = performance.now(); await executor.execute("bench_slow", {}, toolContext); slowTimings.push(performance.now() - s2); } const noopP50 = percentile(noopTimings, 50); const slowP50 = percentile(slowTimings, 50); // Overhead = slow_duration - sleep_time. Should be close to noop_duration. const slowOverhead = slowP50 - SLEEP_MS; // The overhead portion of the slow tool should be within 10ms of the noop total expect(Math.abs(slowOverhead - noopP50)).toBeLessThan(10); }, 10_000); }); });