fix: permissions observability rollout (#1028)

This commit is contained in:
Charles Packer
2026-02-18 19:59:51 -08:00
committed by GitHub
parent dc25ce5573
commit 943c20a495
4 changed files with 555 additions and 86 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>;
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);
}
/**

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}
});