From 943c20a495c3810cc0dc6ba5977aaf71e9c184c0 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 18 Feb 2026 19:59:51 -0800 Subject: [PATCH] fix: permissions observability rollout (#1028) --- src/permissions/checker.ts | 464 +++++++++++++++++++++----- src/permissions/matcher.ts | 48 ++- src/permissions/types.ts | 25 ++ src/tests/permissions-checker.test.ts | 104 ++++++ 4 files changed, 555 insertions(+), 86 deletions(-) diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index 6e63c90..49eb560 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -7,6 +7,7 @@ import { runPermissionRequestHooks } from "../hooks"; import { canonicalToolName, isShellToolName } from "./canonical"; import { cliPermissions } from "./cli"; import { + type MatcherOptions, matchesBashPattern, matchesFilePattern, matchesToolPattern, @@ -16,14 +17,38 @@ import { isMemoryDirCommand, isReadOnlyShellCommand } from "./readOnlyShell"; import { sessionPermissions } from "./session"; import type { PermissionCheckResult, + PermissionCheckTrace, PermissionDecision, + PermissionEngine, PermissionRules, + PermissionTraceEvent, } from "./types"; /** * Tools that don't require approval within working directory */ -const WORKING_DIRECTORY_TOOLS = ["Read", "Glob", "Grep", "ListDir"]; +const WORKING_DIRECTORY_TOOLS_V2 = ["Read", "Glob", "Grep", "ListDir"]; +const WORKING_DIRECTORY_TOOLS_V1 = [ + "Read", + "Glob", + "Grep", + "read_file", + "ReadFile", + "list_dir", + "ListDir", + "grep_files", + "GrepFiles", + "read_file_gemini", + "ReadFileGemini", + "glob_gemini", + "GlobGemini", + "list_directory", + "ListDirectory", + "search_file_content", + "SearchFileContent", + "read_many_files", + "ReadManyFiles", +]; const READ_ONLY_SHELL_TOOLS = new Set([ "Bash", "shell", @@ -33,6 +58,56 @@ const READ_ONLY_SHELL_TOOLS = new Set([ "run_shell_command", "RunShellCommand", ]); +const FILE_TOOLS_V2 = ["Read", "Write", "Edit", "Glob", "Grep", "ListDir"]; +const FILE_TOOLS_V1 = [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "read_file", + "ReadFile", + "list_dir", + "ListDir", + "grep_files", + "GrepFiles", + "read_file_gemini", + "ReadFileGemini", + "write_file_gemini", + "WriteFileGemini", + "glob_gemini", + "GlobGemini", + "list_directory", + "ListDirectory", + "search_file_content", + "SearchFileContent", + "read_many_files", + "ReadManyFiles", +]; + +type ToolArgs = Record; + +function envFlagEnabled(name: string): boolean { + const value = process.env[name]; + if (!value) return false; + return value === "1" || value.toLowerCase() === "true"; +} + +function isPermissionsV2Enabled(): boolean { + const value = process.env.LETTA_PERMISSIONS_V2; + if (!value) return true; + return !(value === "0" || value.toLowerCase() === "false"); +} + +function shouldAttachTrace(result: PermissionCheckResult): boolean { + if (envFlagEnabled("LETTA_PERMISSION_TRACE_ALL")) { + return true; + } + if (!envFlagEnabled("LETTA_PERMISSION_TRACE")) { + return false; + } + return result.decision === "ask" || result.decision === "deny"; +} /** * Check permission for a tool execution. @@ -53,168 +128,364 @@ const READ_ONLY_SHELL_TOOLS = new Set([ * @param permissions - Loaded permission rules * @param workingDirectory - Current working directory */ -type ToolArgs = Record; - export function checkPermission( toolName: string, toolArgs: ToolArgs, permissions: PermissionRules, workingDirectory: string = process.cwd(), ): PermissionCheckResult { + const engine: PermissionEngine = isPermissionsV2Enabled() ? "v2" : "v1"; + const primary = checkPermissionForEngine( + engine, + toolName, + toolArgs, + permissions, + workingDirectory, + ); + + let result: PermissionCheckResult = primary.result; + const includeTrace = shouldAttachTrace(primary.result); + if (includeTrace) { + result = { + ...result, + trace: primary.trace, + }; + console.error( + `[permissions] trace ${JSON.stringify({ + toolName, + engine, + decision: primary.result.decision, + matchedRule: primary.result.matchedRule, + query: primary.trace.query, + events: primary.trace.events, + })}`, + ); + } + + if (envFlagEnabled("LETTA_PERMISSIONS_DUAL_EVAL")) { + const shadowEngine: PermissionEngine = engine === "v2" ? "v1" : "v2"; + const shadow = checkPermissionForEngine( + shadowEngine, + toolName, + toolArgs, + permissions, + workingDirectory, + ); + + const mismatch = + primary.result.decision !== shadow.result.decision || + primary.result.matchedRule !== shadow.result.matchedRule; + + if (mismatch) { + console.error( + `[permissions] dual-eval mismatch ${JSON.stringify({ + toolName, + primary: { + engine, + decision: primary.result.decision, + matchedRule: primary.result.matchedRule, + }, + shadow: { + engine: shadowEngine, + decision: shadow.result.decision, + matchedRule: shadow.result.matchedRule, + }, + })}`, + ); + } + + if (includeTrace && result.trace) { + result.trace = { + ...result.trace, + shadow: { + engine: shadowEngine, + decision: shadow.result.decision, + matchedRule: shadow.result.matchedRule, + }, + }; + } + } + + return result; +} + +function createTrace( + engine: PermissionEngine, + toolName: string, + canonicalTool: string, + query: string, +): PermissionCheckTrace { + return { + engine, + toolName, + canonicalToolName: canonicalTool, + query, + events: [], + }; +} + +function traceEvent( + trace: PermissionCheckTrace, + stage: string, + message?: string, + pattern?: string, + matched?: boolean, +): void { + const event: PermissionTraceEvent = { stage }; + if (message) event.message = message; + if (pattern) event.pattern = pattern; + if (matched !== undefined) event.matched = matched; + trace.events.push(event); +} + +function checkPermissionForEngine( + engine: PermissionEngine, + toolName: string, + toolArgs: ToolArgs, + permissions: PermissionRules, + workingDirectory: string, +): { result: PermissionCheckResult; trace: PermissionCheckTrace } { const canonicalTool = canonicalToolName(toolName); - // Build permission query string - const query = buildPermissionQuery(canonicalTool, toolArgs); - - // Get session rules + const queryTool = engine === "v2" ? canonicalTool : toolName; + const query = buildPermissionQuery(queryTool, toolArgs, engine); + const trace = createTrace(engine, toolName, canonicalTool, query); const sessionRules = sessionPermissions.getRules(); + const workingDirectoryTools = + engine === "v2" ? WORKING_DIRECTORY_TOOLS_V2 : WORKING_DIRECTORY_TOOLS_V1; - // Check deny rules FIRST (highest priority - overrides everything including working directory) if (permissions.deny) { for (const pattern of permissions.deny) { - if (matchesPattern(toolName, query, pattern, workingDirectory)) { + const matched = matchesPattern( + toolName, + query, + pattern, + workingDirectory, + engine, + ); + traceEvent(trace, "deny-rule", undefined, pattern, matched); + if (matched) { return { - decision: "deny", - matchedRule: pattern, - reason: "Matched deny rule", + result: { + decision: "deny", + matchedRule: pattern, + reason: "Matched deny rule", + }, + trace, }; } } } - // Check CLI disallowedTools (second highest priority - overrides all allow rules) const disallowedTools = cliPermissions.getDisallowedTools(); for (const pattern of disallowedTools) { - if (matchesPattern(toolName, query, pattern, workingDirectory)) { + const matched = matchesPattern( + toolName, + query, + pattern, + workingDirectory, + engine, + ); + traceEvent(trace, "cli-disallow-rule", undefined, pattern, matched); + if (matched) { return { - decision: "deny", - matchedRule: `${pattern} (CLI)`, - reason: "Matched --disallowedTools flag", + result: { + decision: "deny", + matchedRule: `${pattern} (CLI)`, + reason: "Matched --disallowedTools flag", + }, + trace, }; } } - // Check permission mode (applies before CLI allow rules but after deny rules) const modeOverride = permissionMode.checkModeOverride(toolName, toolArgs); if (modeOverride) { const currentMode = permissionMode.getMode(); - // Include plan file path and guidance in denial message for plan mode let reason = `Permission mode: ${currentMode}`; if (currentMode === "plan" && modeOverride === "deny") { const planFilePath = permissionMode.getPlanFilePath(); - // planFilePath should always be set when in plan mode - they're set together reason = `Plan mode is active. You can only use read-only tools (Read, Grep, Glob, etc.) and write to the plan file. ` + `Write your plan to: ${planFilePath || "(error: plan file path not configured)"}. ` + `Use ExitPlanMode when your plan is ready for user approval.`; } + traceEvent(trace, "mode-override", reason); return { - decision: modeOverride, - matchedRule: `${currentMode} mode`, - reason, + result: { + decision: modeOverride, + matchedRule: `${currentMode} mode`, + reason, + }, + trace, }; } - // Check CLI allowedTools (third priority - overrides settings but not deny rules) const allowedTools = cliPermissions.getAllowedTools(); for (const pattern of allowedTools) { - if (matchesPattern(toolName, query, pattern, workingDirectory)) { + const matched = matchesPattern( + toolName, + query, + pattern, + workingDirectory, + engine, + ); + traceEvent(trace, "cli-allow-rule", undefined, pattern, matched); + if (matched) { return { - decision: "allow", - matchedRule: `${pattern} (CLI)`, - reason: "Matched --allowedTools flag", + result: { + decision: "allow", + matchedRule: `${pattern} (CLI)`, + reason: "Matched --allowedTools flag", + }, + trace, }; } } - // Always allow Skill tool (read-only operation that loads skills from potentially external directories) if (toolName === "Skill") { + traceEvent(trace, "skill-auto-allow", "Skill tool is always allowed"); return { - decision: "allow", - reason: "Skill tool is always allowed (read-only)", + result: { + decision: "allow", + reason: "Skill tool is always allowed (read-only)", + }, + trace, }; } if (READ_ONLY_SHELL_TOOLS.has(toolName) || isShellToolName(canonicalTool)) { const shellCommand = extractShellCommand(toolArgs); if (shellCommand && isReadOnlyShellCommand(shellCommand)) { + traceEvent(trace, "readonly-shell-auto-allow", "Read-only shell command"); return { - decision: "allow", - reason: "Read-only shell command", + result: { + decision: "allow", + reason: "Read-only shell command", + }, + trace, }; } - // Auto-approve commands that exclusively target the agent's memory directory if (shellCommand) { try { const agentId = getCurrentAgentId(); if (isMemoryDirCommand(shellCommand, agentId)) { + traceEvent( + trace, + "memory-dir-auto-allow", + "Agent memory directory operation", + ); return { - decision: "allow", - reason: "Agent memory directory operation", + result: { + decision: "allow", + reason: "Agent memory directory operation", + }, + trace, }; } } catch { - // No agent context set — skip memory dir check + traceEvent(trace, "memory-dir-check", "No agent context; skipped"); } } } - // After checking CLI overrides, check if Read/Glob/Grep within working directory - if (WORKING_DIRECTORY_TOOLS.includes(canonicalTool)) { + if (workingDirectoryTools.includes(queryTool)) { const filePath = extractFilePath(toolArgs); if ( filePath && isWithinAllowedDirectories(filePath, permissions, workingDirectory) ) { + traceEvent( + trace, + "working-directory-auto-allow", + `Allowed path: ${filePath}`, + ); return { - decision: "allow", - reason: "Within working directory", + result: { + decision: "allow", + reason: "Within working directory", + }, + trace, }; } } - // Check session allow rules (higher precedence than persisted allow) if (sessionRules.allow) { for (const pattern of sessionRules.allow) { - if (matchesPattern(toolName, query, pattern, workingDirectory)) { + const matched = matchesPattern( + toolName, + query, + pattern, + workingDirectory, + engine, + ); + traceEvent(trace, "session-allow-rule", undefined, pattern, matched); + if (matched) { return { - decision: "allow", - matchedRule: `${pattern} (session)`, - reason: "Matched session allow rule", + result: { + decision: "allow", + matchedRule: `${pattern} (session)`, + reason: "Matched session allow rule", + }, + trace, }; } } } - // Check persisted allow rules if (permissions.allow) { for (const pattern of permissions.allow) { - if (matchesPattern(toolName, query, pattern, workingDirectory)) { + const matched = matchesPattern( + toolName, + query, + pattern, + workingDirectory, + engine, + ); + traceEvent(trace, "allow-rule", undefined, pattern, matched); + if (matched) { return { - decision: "allow", - matchedRule: pattern, - reason: "Matched allow rule", + result: { + decision: "allow", + matchedRule: pattern, + reason: "Matched allow rule", + }, + trace, }; } } } - // Check ask rules if (permissions.ask) { for (const pattern of permissions.ask) { - if (matchesPattern(toolName, query, pattern, workingDirectory)) { + const matched = matchesPattern( + toolName, + query, + pattern, + workingDirectory, + engine, + ); + traceEvent(trace, "ask-rule", undefined, pattern, matched); + if (matched) { return { - decision: "ask", - matchedRule: pattern, - reason: "Matched ask rule", + result: { + decision: "ask", + matchedRule: pattern, + reason: "Matched ask rule", + }, + trace, }; } } } - // Fall back to tool defaults + const defaultDecision = getDefaultDecision(toolName, toolArgs); + traceEvent(trace, "default-decision", `Default: ${defaultDecision}`); return { - decision: getDefaultDecision(toolName, toolArgs), - reason: "Default behavior for tool", + result: { + decision: defaultDecision, + reason: "Default behavior for tool", + }, + trace, }; } @@ -270,7 +541,11 @@ function isWithinAllowedDirectories( /** * Build permission query string for a tool execution */ -function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string { +function buildPermissionQuery( + toolName: string, + toolArgs: ToolArgs, + engine: PermissionEngine, +): string { switch (toolName) { // File tools: "ToolName(path/to/file)" case "Read": @@ -278,8 +553,24 @@ function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string { case "Edit": case "Glob": case "Grep": - // Codex file tools - case "ListDir": { + case "ListDir": + case "read_file": + case "ReadFile": + case "list_dir": + case "grep_files": + case "GrepFiles": + case "read_file_gemini": + case "ReadFileGemini": + case "write_file_gemini": + case "WriteFileGemini": + case "glob_gemini": + case "GlobGemini": + case "list_directory": + case "ListDirectory": + case "search_file_content": + case "SearchFileContent": + case "read_many_files": + case "ReadManyFiles": { const filePath = extractFilePath(toolArgs); return filePath ? `${toolName}(${filePath})` : toolName; } @@ -287,13 +578,29 @@ function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string { case "Bash": { // Bash: "Bash(command with args)" const command = - typeof toolArgs.command === "string" ? toolArgs.command : ""; + typeof toolArgs.command === "string" + ? toolArgs.command + : Array.isArray(toolArgs.command) + ? toolArgs.command.join(" ") + : ""; return `Bash(${command})`; } case "shell": - case "shell_command": + case "shell_command": { + const command = + typeof toolArgs.command === "string" + ? toolArgs.command + : Array.isArray(toolArgs.command) + ? toolArgs.command.join(" ") + : ""; + return `Bash(${command})`; + } case "run_shell_command": case "RunShellCommand": { + if (engine === "v1") { + // Legacy behavior did not normalize this alias into Bash queries. + return toolName; + } const command = typeof toolArgs.command === "string" ? toolArgs.command @@ -317,11 +624,6 @@ function extractShellCommand(toolArgs: ToolArgs): string | string[] | null { return null; } -/** - * File tools that use glob matching for permissions - */ -const FILE_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "ListDir"]; - /** * Check if query matches a permission pattern */ @@ -330,20 +632,32 @@ function matchesPattern( query: string, pattern: string, workingDirectory: string, + engine: PermissionEngine, ): boolean { - const canonicalTool = canonicalToolName(toolName); + const matcherOptions: MatcherOptions = + engine === "v2" + ? { canonicalizeToolNames: true, allowBareToolFallback: true } + : { canonicalizeToolNames: false, allowBareToolFallback: false }; + const toolForMatch = engine === "v2" ? canonicalToolName(toolName) : toolName; + const fileTools = engine === "v2" ? FILE_TOOLS_V2 : FILE_TOOLS_V1; // File tools use glob matching - if (FILE_TOOLS.includes(canonicalTool)) { - return matchesFilePattern(query, pattern, workingDirectory); + if (fileTools.includes(toolForMatch)) { + return matchesFilePattern(query, pattern, workingDirectory, matcherOptions); } // Bash uses prefix matching - if (canonicalTool === "Bash") { - return matchesBashPattern(query, pattern); + const legacyShellTool = + engine === "v1" && + (toolForMatch === "Bash" || + toolForMatch === "shell" || + toolForMatch === "shell_command"); + const v2ShellTool = engine === "v2" && isShellToolName(toolName); + if (toolForMatch === "Bash" || legacyShellTool || v2ShellTool) { + return matchesBashPattern(query, pattern, matcherOptions); } // Other tools use simple name matching - return matchesToolPattern(canonicalTool, pattern); + return matchesToolPattern(toolForMatch, pattern, matcherOptions); } /** diff --git a/src/permissions/matcher.ts b/src/permissions/matcher.ts index f05f2e9..56fdcc2 100644 --- a/src/permissions/matcher.ts +++ b/src/permissions/matcher.ts @@ -5,6 +5,17 @@ import { resolve } from "node:path"; import { minimatch } from "minimatch"; import { canonicalToolName } from "./canonical"; +export interface MatcherOptions { + canonicalizeToolNames?: boolean; + allowBareToolFallback?: boolean; +} + +function toolForMatch(toolName: string, options?: MatcherOptions): string { + return options?.canonicalizeToolNames === false + ? toolName + : canonicalToolName(toolName); +} + /** * Normalize path separators to forward slashes for consistent glob matching. * This is needed because: @@ -113,6 +124,7 @@ export function matchesFilePattern( query: string, pattern: string, workingDirectory: string, + options?: MatcherOptions, ): boolean { // Extract tool name and file path from query // Format: "ToolName(filePath)" @@ -120,7 +132,7 @@ export function matchesFilePattern( if (!queryMatch || !queryMatch[1] || !queryMatch[2]) { return false; } - const queryTool = canonicalToolName(queryMatch[1]); + const queryTool = toolForMatch(queryMatch[1], options); // Normalize path separators for cross-platform compatibility const filePath = normalizePath(queryMatch[2]); @@ -129,9 +141,12 @@ export function matchesFilePattern( const patternMatch = pattern.match(/^([^(]+)\(([\s\S]+)\)$/); if (!patternMatch || !patternMatch[1] || !patternMatch[2]) { // Legacy fallback: allow bare tool names (for rules saved before param suffixes were added) - return canonicalToolName(pattern) === queryTool; + if (options?.allowBareToolFallback === false) { + return false; + } + return toolForMatch(pattern, options) === queryTool; } - const patternTool = canonicalToolName(patternMatch[1]); + const patternTool = toolForMatch(patternMatch[1], options); if (!patternTool) { return false; } @@ -223,7 +238,11 @@ function extractActualCommand(command: string): string { return command; } -export function matchesBashPattern(query: string, pattern: string): boolean { +export function matchesBashPattern( + query: string, + pattern: string, + options?: MatcherOptions, +): boolean { // Extract the command from query // Format: "Tool(actual command)" or "Tool()" const queryMatch = query.match(/^([^(]+)\(([\s\S]*)\)$/); @@ -234,7 +253,7 @@ export function matchesBashPattern(query: string, pattern: string): boolean { ) { return false; } - if (canonicalToolName(queryMatch[1]) !== "Bash") { + if (toolForMatch(queryMatch[1], options) !== "Bash") { return false; } const rawCommand = queryMatch[2]; @@ -249,9 +268,12 @@ export function matchesBashPattern(query: string, pattern: string): boolean { patternMatch[1] === undefined || patternMatch[2] === undefined ) { - return canonicalToolName(pattern) === "Bash"; + if (options?.allowBareToolFallback === false) { + return false; + } + return toolForMatch(pattern, options) === "Bash"; } - if (canonicalToolName(patternMatch[1]) !== "Bash") { + if (toolForMatch(patternMatch[1], options) !== "Bash") { return false; } const commandPattern = patternMatch[2]; @@ -278,14 +300,18 @@ export function matchesBashPattern(query: string, pattern: string): boolean { * @param toolName - The tool name * @param pattern - The permission pattern */ -export function matchesToolPattern(toolName: string, pattern: string): boolean { - const canonicalTool = canonicalToolName(toolName); +export function matchesToolPattern( + toolName: string, + pattern: string, + options?: MatcherOptions, +): boolean { + const canonicalTool = toolForMatch(toolName, options); // Wildcard matches everything if (pattern === "*") { return true; } - if (canonicalToolName(pattern) === canonicalTool) { + if (toolForMatch(pattern, options) === canonicalTool) { return true; } @@ -297,7 +323,7 @@ export function matchesToolPattern(toolName: string, pattern: string): boolean { // Check for tool name prefix (e.g., "WebFetch(...)") const patternToolMatch = pattern.match(/^([^(]+)\(/); if (patternToolMatch?.[1]) { - return canonicalToolName(patternToolMatch[1]) === canonicalTool; + return toolForMatch(patternToolMatch[1], options) === canonicalTool; } return false; diff --git a/src/permissions/types.ts b/src/permissions/types.ts index be1d7c2..a0f506d 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -21,6 +21,30 @@ export type PermissionDecision = "allow" | "deny" | "ask"; */ export type PermissionScope = "project" | "local" | "user"; +export type PermissionEngine = "v1" | "v2"; + +export interface PermissionTraceEvent { + stage: string; + matched?: boolean; + pattern?: string; + message?: string; +} + +export interface PermissionShadowComparison { + engine: PermissionEngine; + decision: PermissionDecision; + matchedRule?: string; +} + +export interface PermissionCheckTrace { + engine: PermissionEngine; + toolName: string; + canonicalToolName: string; + query: string; + events: PermissionTraceEvent[]; + shadow?: PermissionShadowComparison; +} + /** * Result of a permission check */ @@ -28,4 +52,5 @@ export interface PermissionCheckResult { decision: PermissionDecision; matchedRule?: string; reason?: string; + trace?: PermissionCheckTrace; } diff --git a/src/tests/permissions-checker.test.ts b/src/tests/permissions-checker.test.ts index 575f828..30c94f4 100644 --- a/src/tests/permissions-checker.test.ts +++ b/src/tests/permissions-checker.test.ts @@ -677,3 +677,107 @@ test("Legacy bare WriteFileGemini rule still matches write invocations", () => { expect(result.decision).toBe("allow"); expect(result.matchedRule).toBe("WriteFileGemini"); }); + +test("LETTA_PERMISSIONS_V2=0 preserves legacy alias mismatch behavior", () => { + const original = process.env.LETTA_PERMISSIONS_V2; + process.env.LETTA_PERMISSIONS_V2 = "0"; + + try { + const permissions: PermissionRules = { + allow: ["Bash(curl:*)"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "run_shell_command", + { command: "curl -s http://localhost:4321/health" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); + } finally { + if (original === undefined) { + delete process.env.LETTA_PERMISSIONS_V2; + } else { + process.env.LETTA_PERMISSIONS_V2 = original; + } + } +}); + +test("permission trace is attached for ask decisions when LETTA_PERMISSION_TRACE=1", () => { + const originalTrace = process.env.LETTA_PERMISSION_TRACE; + process.env.LETTA_PERMISSION_TRACE = "1"; + + try { + const result = checkPermission( + "Bash", + { command: "npm install" }, + { allow: [], deny: [], ask: [] }, + "/Users/test/project", + ); + + expect(result.decision).toBe("ask"); + expect(result.trace).toBeDefined(); + expect(result.trace?.engine).toBe("v2"); + expect(result.trace?.events.length).toBeGreaterThan(0); + } finally { + if (originalTrace === undefined) { + delete process.env.LETTA_PERMISSION_TRACE; + } else { + process.env.LETTA_PERMISSION_TRACE = originalTrace; + } + } +}); + +test("dual eval attaches shadow decision when enabled", () => { + const originalTrace = process.env.LETTA_PERMISSION_TRACE; + const originalTraceAll = process.env.LETTA_PERMISSION_TRACE_ALL; + const originalDual = process.env.LETTA_PERMISSIONS_DUAL_EVAL; + const originalV2 = process.env.LETTA_PERMISSIONS_V2; + delete process.env.LETTA_PERMISSIONS_V2; + process.env.LETTA_PERMISSION_TRACE = "0"; + process.env.LETTA_PERMISSION_TRACE_ALL = "1"; + process.env.LETTA_PERMISSIONS_DUAL_EVAL = "1"; + + try { + const permissions: PermissionRules = { + allow: ["Bash(curl:*)"], + deny: [], + ask: [], + }; + + const result = checkPermission( + "run_shell_command", + { command: "curl -s http://localhost:4321/health" }, + permissions, + "/Users/test/project", + ); + + expect(result.decision).toBe("allow"); + expect(result.trace?.shadow?.engine).toBe("v1"); + expect(result.trace?.shadow?.decision).toBe("ask"); + } finally { + if (originalTrace === undefined) { + delete process.env.LETTA_PERMISSION_TRACE; + } else { + process.env.LETTA_PERMISSION_TRACE = originalTrace; + } + if (originalTraceAll === undefined) { + delete process.env.LETTA_PERMISSION_TRACE_ALL; + } else { + process.env.LETTA_PERMISSION_TRACE_ALL = originalTraceAll; + } + if (originalDual === undefined) { + delete process.env.LETTA_PERMISSIONS_DUAL_EVAL; + } else { + process.env.LETTA_PERMISSIONS_DUAL_EVAL = originalDual; + } + if (originalV2 === undefined) { + delete process.env.LETTA_PERMISSIONS_V2; + } else { + process.env.LETTA_PERMISSIONS_V2 = originalV2; + } + } +});