fix: permissions observability rollout (#1028)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user