From e36a5ee94f81dda06fff32f5dc085f00c3f072c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=BDilvinas=20Bliud=C5=BEius?= Date: Thu, 27 Aug 2026 11:08:47 +0300 Subject: [PATCH] feat(permissions): optimize auto-mode classifier with in-CWD fast path and two-stage classification - Add in-CWD file edit fast path: write/edit calls targeting files within ctx.cwd are auto-approved without invoking the LLM classifier, saving GPU resources. Protected paths (.git/, .env, .kimchi/, .claude/, shell configs) still go through the classifier. - Verify expanded allowlist: isReadOnlyTool() and isReadOnlyBashCommand() checks already run before the auto-mode classifier branch, so read-only MCP tools and read-only bash commands skip the classifier in auto mode. - Implement two-stage classification: Stage 1 uses a minimal prompt suffix with maxTokens=64 for a fast verdict. If Stage 1 returns safe, Stage 2 is skipped entirely. Stage 2 is the existing full reasoning call with retries and fallback. ClassifierResult gains an optional stage field. - Add comprehensive tests for all three changes (384 tests pass, build clean). Co-Authored-By: Kimchi --- src/extensions/permissions/classifier.test.ts | 156 ++++++++++--- src/extensions/permissions/classifier.ts | 105 ++++++++- src/extensions/permissions/index.test.ts | 213 ++++++++++++++++++ src/extensions/permissions/index.ts | 59 +++++ src/extensions/permissions/types.ts | 2 + 5 files changed, 508 insertions(+), 27 deletions(-) diff --git a/src/extensions/permissions/classifier.test.ts b/src/extensions/permissions/classifier.test.ts index 3b4710255..07bdab8a8 100644 --- a/src/extensions/permissions/classifier.test.ts +++ b/src/extensions/permissions/classifier.test.ts @@ -51,7 +51,7 @@ describe("classifyToolCall", () => { vi.useRealTimers() }) - it("returns safe verdict on first attempt", async () => { + it("returns safe verdict on Stage 1 without calling Stage 2", async () => { completeMock.mockResolvedValue( fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","riskScore":"low","reason":"fine"}' }), ) @@ -65,9 +65,98 @@ describe("classifyToolCall", () => { expect(result.verdict).toBe("safe") expect(result.riskScore).toBe("low") expect(result.ok).toBe(true) + expect(result.stage).toBe(1) expect(completeMock).toHaveBeenCalledTimes(1) }) + it("Stage 1 safe skips Stage 2 entirely", async () => { + completeMock.mockResolvedValueOnce( + fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","reason":"stage1"}' }), + ) + + const result = await classifyToolCall( + fakeRegistry(), + { toolName: "bash", input: { command: "ls" }, cwd: "/tmp" }, + { timeoutMs: 5000 }, + ) + + expect(result.verdict).toBe("safe") + expect(result.stage).toBe(1) + expect(completeMock).toHaveBeenCalledTimes(1) + }) + + it("Stage 1 requires-confirmation falls through to Stage 2", async () => { + completeMock + .mockResolvedValueOnce( + fakeResponse({ stopReason: "stop", content: '{"verdict":"requires-confirmation","reason":"stage1 unsure"}' }), + ) + .mockResolvedValueOnce( + fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","riskScore":"low","reason":"stage2 safe"}' }), + ) + + const result = await classifyToolCall( + fakeRegistry(), + { toolName: "bash", input: { command: "ls" }, cwd: "/tmp" }, + { timeoutMs: 5000 }, + ) + + expect(result.verdict).toBe("safe") + expect(result.stage).toBe(2) + expect(completeMock).toHaveBeenCalledTimes(2) + }) + + it("Stage 1 parse failure falls through to Stage 2", async () => { + completeMock + .mockResolvedValueOnce(fakeResponse({ stopReason: "stop", content: "garbage text" })) + .mockResolvedValueOnce( + fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","riskScore":"low","reason":"ok"}' }), + ) + + const result = await classifyToolCall( + fakeRegistry(), + { toolName: "bash", input: { command: "ls" }, cwd: "/tmp" }, + { timeoutMs: 5000 }, + ) + + expect(result.verdict).toBe("safe") + expect(result.stage).toBe(2) + expect(completeMock).toHaveBeenCalledTimes(2) + }) + + it("Stage 1 error falls through to Stage 2", async () => { + completeMock + .mockResolvedValueOnce(fakeResponse({ stopReason: "error", errorMessage: "stage1 error" })) + .mockResolvedValueOnce( + fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","riskScore":"low","reason":"ok"}' }), + ) + + const result = await classifyToolCall( + fakeRegistry(), + { toolName: "bash", input: { command: "ls" }, cwd: "/tmp" }, + { timeoutMs: 5000 }, + ) + + expect(result.verdict).toBe("safe") + expect(result.stage).toBe(2) + expect(completeMock).toHaveBeenCalledTimes(2) + }) + + it("Stage 1 uses maxTokens=64 in request options", async () => { + let capturedOptions: unknown + completeMock.mockImplementation((_model: unknown, _context: unknown, options: unknown) => { + capturedOptions = options + return fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","reason":"fine"}' }) + }) + + await classifyToolCall( + fakeRegistry(), + { toolName: "bash", input: { command: "ls" }, cwd: "/tmp" }, + { timeoutMs: 5000 }, + ) + + expect((capturedOptions as { maxTokens?: number }).maxTokens).toBe(64) + }) + it("keeps classifier tags while omitting Pi token limits for Kimchi", async () => { let sentPayload: unknown completeMock.mockImplementation((_model: unknown, _context: unknown, options: unknown) => { @@ -85,7 +174,9 @@ describe("classifyToolCall", () => { expect(sentPayload).toEqual({ tags: ["source:classifier", "existing"] }) }) - it("retries up to 3 times on abort before giving up", async () => { + it("retries up to 3 times on abort before giving up (Stage 2)", async () => { + // Stage 1 fails (aborted → non-fatal fallthrough) + // Stage 2 retries 3 times, all aborted completeMock.mockResolvedValue(fakeResponse({ stopReason: "aborted" })) const promise = classifyToolCall( @@ -101,15 +192,18 @@ describe("classifyToolCall", () => { expect(result.ok).toBe(false) expect(result.reason).toContain("classifier timeout") expect(result.reason).toContain(CLASSIFIER_PRIMARY_MODEL_ID) - expect(completeMock).toHaveBeenCalledTimes(3) + // Stage1(1) + Stage2 retries(3) = 4 + expect(completeMock).toHaveBeenCalledTimes(4) }) - it("succeeds on 2nd attempt after first abort", async () => { + it("succeeds on 2nd Stage 2 attempt after first abort", async () => { + // Stage 1 aborted (non-fatal) + Stage 2 first attempt aborted + Stage 2 second succeeds completeMock - .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 1 + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 2 attempt 1 .mockResolvedValueOnce( fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","riskScore":"low","reason":"fine"}' }), - ) + ) // Stage 2 attempt 2 const promise = classifyToolCall( fakeRegistry(), @@ -123,16 +217,18 @@ describe("classifyToolCall", () => { expect(result.verdict).toBe("safe") expect(result.riskScore).toBe("low") expect(result.ok).toBe(true) - expect(completeMock).toHaveBeenCalledTimes(2) + expect(result.stage).toBe(2) + expect(completeMock).toHaveBeenCalledTimes(3) }) - it("succeeds on 3rd attempt after two aborts", async () => { + it("succeeds on 3rd Stage 2 attempt after two aborts", async () => { completeMock - .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) - .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 1 + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 2 attempt 1 + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 2 attempt 2 .mockResolvedValueOnce( fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","riskScore":"low","reason":"fine"}' }), - ) + ) // Stage 2 attempt 3 const promise = classifyToolCall( fakeRegistry(), @@ -146,17 +242,19 @@ describe("classifyToolCall", () => { expect(result.verdict).toBe("safe") expect(result.riskScore).toBe("low") expect(result.ok).toBe(true) - expect(completeMock).toHaveBeenCalledTimes(3) + expect(result.stage).toBe(2) + expect(completeMock).toHaveBeenCalledTimes(4) }) - it("calls fallback model after 3 retryable failures and returns its result", async () => { + it("calls fallback model after 3 Stage 2 retryable failures", async () => { completeMock - .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) - .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) - .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 1 + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 2 attempt 1 + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 2 attempt 2 + .mockResolvedValueOnce(fakeResponse({ stopReason: "aborted" })) // Stage 2 attempt 3 .mockResolvedValueOnce( fakeResponse({ stopReason: "stop", content: '{"verdict":"safe","riskScore":"low","reason":"fine"}' }), - ) + ) // Fallback const promise = classifyToolCall( fakeRegistry([fakeModel(CLASSIFIER_PRIMARY_MODEL_ID), fakeModel(CLASSIFIER_FALLBACK_MODEL_ID)]), @@ -170,7 +268,7 @@ describe("classifyToolCall", () => { expect(result.verdict).toBe("safe") expect(result.riskScore).toBe("low") expect(result.ok).toBe(true) - expect(completeMock).toHaveBeenCalledTimes(4) + expect(completeMock).toHaveBeenCalledTimes(5) }) it("returns last result when no fallback model is provided", async () => { @@ -187,7 +285,8 @@ describe("classifyToolCall", () => { expect(result.verdict).toBe("requires-confirmation") expect(result.ok).toBe(false) - expect(completeMock).toHaveBeenCalledTimes(3) + // Stage1(1) + Stage2 retries(3) = 4 + expect(completeMock).toHaveBeenCalledTimes(4) }) it("returns 'classifier aborted' when signal aborts during final attempt", async () => { @@ -198,8 +297,8 @@ describe("classifyToolCall", () => { let callCount = 0 completeMock.mockImplementation(() => { callCount++ - if (callCount === 3) { - // Simulate the outer signal aborting during the final classifier call + if (callCount === 4) { + // Simulate the outer signal aborting during the final Stage 2 attempt controller.abort() } return fakeResponse({ stopReason: "aborted" }) @@ -215,7 +314,8 @@ describe("classifyToolCall", () => { await vi.runAllTimersAsync() const result = await promise - expect(completeMock).toHaveBeenCalledTimes(3) + // Stage1(1) + Stage2 retries(3) = 4 + expect(completeMock).toHaveBeenCalledTimes(4) expect(result.verdict).toBe("requires-confirmation") expect(result.ok).toBe(false) expect(result.reason).toBe("classifier aborted") @@ -236,8 +336,11 @@ describe("classifyToolCall", () => { expect(result.reason).toBe("classifier aborted") }) - it("does not retry on error and returns requires-confirmation", async () => { - completeMock.mockResolvedValue(fakeResponse({ stopReason: "error", errorMessage: "rate limit exceeded" })) + it("does not retry on error and returns requires-confirmation (Stage 2)", async () => { + // Stage 1 fails non-fatally, Stage 2 returns error + completeMock + .mockResolvedValueOnce(fakeResponse({ stopReason: "error", errorMessage: "stage1 error" })) + .mockResolvedValue(fakeResponse({ stopReason: "error", errorMessage: "rate limit exceeded" })) const result = await classifyToolCall( fakeRegistry(), @@ -245,13 +348,14 @@ describe("classifyToolCall", () => { { timeoutMs: 5000 }, ) - expect(completeMock).toHaveBeenCalledTimes(1) + expect(completeMock).toHaveBeenCalledTimes(2) expect(result.verdict).toBe("requires-confirmation") expect(result.ok).toBe(false) expect(result.reason).toContain("classifier error: rate limit exceeded") }) - it("still falls back to unparseable when text is garbage", async () => { + it("still falls back to unparseable when text is garbage (Stage 2)", async () => { + // Stage 1 garbage (non-fatal), Stage 2 also garbage completeMock.mockResolvedValue(fakeResponse({ stopReason: "stop", content: "not json at all" })) const result = await classifyToolCall( diff --git a/src/extensions/permissions/classifier.ts b/src/extensions/permissions/classifier.ts index ca490ae0d..72de7fa3d 100644 --- a/src/extensions/permissions/classifier.ts +++ b/src/extensions/permissions/classifier.ts @@ -11,6 +11,13 @@ export const CLASSIFIER_REQUEST_TAG = "source:classifier" export const CLASSIFIER_PRIMARY_MODEL_ID = "deepseek-v4-flash" export const CLASSIFIER_FALLBACK_MODEL_ID = "minimax-m3" +/** Max tokens for Stage 1 (fast) classifier — just enough for a JSON verdict. */ +const STAGE1_MAX_TOKENS = 64 + +/** Prompt suffix appended to the system prompt for Stage 1 (fast) classification. */ +const STAGE1_PROMPT_SUFFIX = + '\n\nRespond with ONLY a JSON object: {"verdict":"safe"} or {"verdict":"requires-confirmation"}. No reasoning needed. Be conservative — if unsure, return requires-confirmation.' + export interface ClassifyInput { toolName: string input: Record @@ -40,6 +47,17 @@ export async function classifyToolCall( if (signal?.aborted) return unavailable("classifier aborted") + // Stage 1 (fast): lightweight classifier call with minimal output. + // If it returns "safe", skip Stage 2 entirely — no second GPU call needed. + if (signal?.aborted) return unavailable("classifier aborted") + const stage1Result = await runClassifierFast(primaryModel, auth, call, options, signal) + if (stage1Result.ok && stage1Result.verdict === "safe") { + return { ...stage1Result, stage: 1 } + } + + // Stage 2 (full reasoning): the existing full classifier call with retries + // and fallback. Used when Stage 1 does not return safe (either + // requires-confirmation or parse failure). const maxAttempts = 3 let lastResult: InternalResult = unavailable("classifier unavailable") @@ -50,7 +68,7 @@ export async function classifyToolCall( } const result = await runClassifier(primaryModel, auth, call, options, signal) - if (result.ok) return result + if (result.ok) return { ...result, stage: 2 } if (!result.retryable) return result @@ -69,6 +87,91 @@ export async function classifyToolCall( return lastResult } +/** + * Stage 1 (fast): lightweight classifier call with minimal prompt suffix and + * low max_tokens. Returns immediately if the verdict is "safe". Falls through + * to Stage 2 on any other outcome or failure. + */ +async function runClassifierFast( + model: Model, + auth: Awaited>, + call: ClassifyInput, + options: ClassifierOptions, + signal?: AbortSignal, +): Promise { + if (!auth.ok || !auth.apiKey) return unavailable("no API key for classifier") + if (signal?.aborted) return unavailable("classifier aborted") + + const controller = new AbortController() + const timeoutHandle = setTimeout(() => controller.abort(), options.timeoutMs) + const onOuterAbort = () => controller.abort() + signal?.addEventListener("abort", onOuterAbort) + + try { + const response = await complete( + model, + { + systemPrompt: classifierSystemPrompt + STAGE1_PROMPT_SUFFIX, + messages: [ + { + role: "user", + content: [{ type: "text", text: buildUserPrompt(call) }], + timestamp: Date.now(), + }, + ], + }, + { + apiKey: auth.apiKey, + headers: auth.headers, + signal: controller.signal, + maxTokens: STAGE1_MAX_TOKENS, + onPayload: (payload: unknown) => { + if (payload && typeof payload === "object") { + const p = payload as Record + const existing = Array.isArray(p.tags) ? (p.tags as string[]) : [] + p.tags = [CLASSIFIER_REQUEST_TAG, ...existing] + } + return omitKimchiMaxTokensFromPayload(payload, model.provider) + }, + }, + ) + + if (response.stopReason === "aborted" || response.stopReason === "error") { + // Stage 1 failures are non-fatal — fall through to Stage 2. + return { + verdict: "requires-confirmation", + reason: "stage 1 failed, falling through", + ok: false, + retryable: false, + } + } + + const text = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n") + + const result = parseClassifierOutput(text) + if (result.ok && result.verdict === "safe") { + return { ...result, retryable: false } + } + + // Stage 1 returned requires-confirmation or failed to parse — fall through. + return { + verdict: "requires-confirmation", + reason: "stage 1 inconclusive, falling through", + ok: false, + retryable: false, + } + } catch (_err) { + // Stage 1 errors are non-fatal — fall through to Stage 2. + return { verdict: "requires-confirmation", reason: "stage 1 error, falling through", ok: false, retryable: false } + } finally { + clearTimeout(timeoutHandle) + signal?.removeEventListener("abort", onOuterAbort) + } +} + async function runClassifier( model: Model, auth: Awaited>, diff --git a/src/extensions/permissions/index.test.ts b/src/extensions/permissions/index.test.ts index 8e01adc34..4a414d801 100644 --- a/src/extensions/permissions/index.test.ts +++ b/src/extensions/permissions/index.test.ts @@ -1034,6 +1034,219 @@ describe("permissions workflow output tool classification", () => { }) }) +describe("permissions in-cwd file edit fast path", () => { + beforeEach(() => { + vi.mocked(classifyToolCall).mockClear() + }) + + afterEach(() => { + unregisterSessionPermissionFlagController(TEST_SESSION_ID) + Reflect.deleteProperty(process.env, `${PERMISSIONS_ENV_KEY}_${TEST_SESSION_ID}`) + vi.unstubAllEnvs() + }) + + it("auto-approves edit within cwd without invoking classifier", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + const result = await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-1", input: { file_path: "/test/src/index.ts" } }, + ctx, + ) + + expect(result).toBeUndefined() + expect(classifyToolCall).not.toHaveBeenCalled() + }) + + it("auto-approves write within cwd without invoking classifier", async () => { + const harness = createPermissionsHarness(["write"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + const result = await harness.fire( + "tool_call", + { toolName: "write", toolCallId: "tc-2", input: { file_path: "/test/src/new.ts", content: "x" } }, + ctx, + ) + + expect(result).toBeUndefined() + expect(classifyToolCall).not.toHaveBeenCalled() + }) + + it("auto-approves edit with relative path within cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + const result = await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-3", input: { file_path: "src/index.ts" } }, + ctx, + ) + + expect(result).toBeUndefined() + expect(classifyToolCall).not.toHaveBeenCalled() + }) + + it("does NOT auto-approve edits outside cwd — falls through to classifier", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + const result = await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-4", input: { file_path: "/etc/passwd" } }, + ctx, + ) + + expect(result).toBeUndefined() // classifier mock returns safe + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve edits to .git/ paths even within cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-5", input: { file_path: "/test/.git/config" } }, + ctx, + ) + + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve edits to .env files even within cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire("tool_call", { toolName: "edit", toolCallId: "tc-6", input: { file_path: "/test/.env" } }, ctx) + + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve edits to .env.* variants even within cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-7", input: { file_path: "/test/.env.local" } }, + ctx, + ) + + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve edits to .kimchi/ paths even within cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-8", input: { file_path: "/test/.kimchi/config.json" } }, + ctx, + ) + + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve edits to .claude/ paths even within cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-9", input: { file_path: "/test/.claude/settings.json" } }, + ctx, + ) + + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve edits to shell config files even within cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-10", input: { file_path: "/test/.bashrc" } }, + ctx, + ) + + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve edits with path traversal outside cwd", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-11", input: { file_path: "/test/../../etc/passwd" } }, + ctx, + ) + + expect(classifyToolCall).toHaveBeenCalledTimes(1) + }) + + it("does NOT auto-approve in default mode — fast path is auto-mode only", async () => { + const harness = createPermissionsHarness(["edit"]) + const ctx = createMockContext(["Yes — just this call"]) + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-12", input: { file_path: "/test/src/index.ts" } }, + ctx, + ) + + expect(ctx.ui.select).toHaveBeenCalledTimes(1) // prompted the user + }) + + it("emits a permission_auto_approved notification on fast path", async () => { + const harness = createPermissionsHarness(["edit"], { auto: true }) + const ctx = createClassifierContext() + ctx.cwd = "/test" + await harness.fire("session_start", {}, ctx) + + await harness.fire( + "tool_call", + { toolName: "edit", toolCallId: "tc-13", input: { file_path: "/test/src/index.ts" } }, + ctx, + ) + + const emitMock = (harness.pi as unknown as { events: { emit: ReturnType } }).events.emit + expect(emitMock).toHaveBeenCalledWith("notification", { + notification_type: "permission_auto_approved", + tool_name: "edit", + tool_use_id: "tc-13", + reason: "in-cwd file edit fast path", + }) + }) +}) + describe("permissions notification emission", () => { afterEach(() => { unregisterSessionPermissionFlagController(TEST_SESSION_ID) diff --git a/src/extensions/permissions/index.ts b/src/extensions/permissions/index.ts index bee2e0776..0b81f24b1 100644 --- a/src/extensions/permissions/index.ts +++ b/src/extensions/permissions/index.ts @@ -88,6 +88,51 @@ export function isWithinKimchiPlans(filePath: string, cwd: string): boolean { return abs.startsWith(plansDir) } +/** + * Protected path patterns that must NEVER be auto-approved by the in-CWD + * fast path, even when they resolve within ctx.cwd. These paths can contain + * secrets, modify shell behaviour, or corrupt agent/harness state. + */ +const PROTECTED_PATH_PATTERNS: readonly RegExp[] = [ + /(^|\/)\.git\//, // git internals + /(^|\/)\.env$/, // env files + /(^|\/)\.env\./, // env variants (.env.local, .env.production, etc.) + /(^|\/)\.kimchi\//, // kimchi harness state + /(^|\/)\.claude\//, // claude config + /(^|\/)\.bashrc$/, // shell config + /(^|\/)\.zshrc$/, + /(^|\/)\.profile$/, +] + +/** + * Check whether a resolved file path targets a protected location that + * should not be auto-approved by the in-CWD fast path. + */ +function isProtectedPath(resolvedPath: string): boolean { + return PROTECTED_PATH_PATTERNS.some((re) => re.test(resolvedPath)) +} + +/** + * In-CWD file edit fast path: when in auto mode and the tool is write or edit, + * check whether the target file resolves within ctx.cwd. If it does (and is + * not a protected path), auto-approve without invoking the LLM classifier. + * This avoids a GPU call for the agent's primary safe operation. + */ +function isInCwdFileEdit(toolName: string, input: Record, cwd: string): boolean { + if (toolName !== "write" && toolName !== "edit") return false + + const filePath = + typeof input.file_path === "string" ? input.file_path : typeof input.path === "string" ? input.path : "" + if (!filePath) return false + + const resolved = resolve(cwd, filePath) + const normalizedCwd = cwd.endsWith("/") ? cwd : `${cwd}/` + + if (!resolved.startsWith(normalizedCwd)) return false + if (isProtectedPath(resolved)) return false + return true +} + /** * DANGER: Bypass flag that disables ALL permission checks. * WARNING: This skips denylist, rules, classifier, and prompts. @@ -913,6 +958,20 @@ export default function permissionsExtension(pi: ExtensionAPI): void { // through the classifier; prompts without a frontend fail closed. const promptAvailable = canPrompt(ctx) if (mode === "auto" || !promptAvailable) { + // Fast path: in auto mode, file edits within ctx.cwd are the agent's + // primary safe operation and should not cost a GPU classifier call. + // Protected paths (.git/, .env, .kimchi/, etc.) still fall through + // to the classifier even when inside cwd. + if (mode === "auto" && isInCwdFileEdit(toolName, input, ctx.cwd)) { + pi.events.emit("notification", { + notification_type: "permission_auto_approved", + tool_name: event.toolName, + tool_use_id: event.toolCallId, + reason: "in-cwd file edit fast path", + }) + return undefined + } + const verdict = await classifyToolCall( ctx.modelRegistry, { toolName, input, cwd: ctx.cwd }, diff --git a/src/extensions/permissions/types.ts b/src/extensions/permissions/types.ts index 70c3f433d..10360dd12 100644 --- a/src/extensions/permissions/types.ts +++ b/src/extensions/permissions/types.ts @@ -38,6 +38,8 @@ export interface ClassifierResult { ok: boolean /** Risk score from the classifier LLM. Undefined when the classifier was not called or failed. */ riskScore?: RiskScore + /** Which classification stage produced this result: 1 = fast stage, 2 = full reasoning stage. Undefined when classifier was not called or failed before parsing. */ + stage?: 1 | 2 } export interface PermissionsConfig {