fix: permissions observability rollout (#1028)
This commit is contained in:
@@ -7,6 +7,7 @@ import { runPermissionRequestHooks } from "../hooks";
|
|||||||
import { canonicalToolName, isShellToolName } from "./canonical";
|
import { canonicalToolName, isShellToolName } from "./canonical";
|
||||||
import { cliPermissions } from "./cli";
|
import { cliPermissions } from "./cli";
|
||||||
import {
|
import {
|
||||||
|
type MatcherOptions,
|
||||||
matchesBashPattern,
|
matchesBashPattern,
|
||||||
matchesFilePattern,
|
matchesFilePattern,
|
||||||
matchesToolPattern,
|
matchesToolPattern,
|
||||||
@@ -16,14 +17,38 @@ import { isMemoryDirCommand, isReadOnlyShellCommand } from "./readOnlyShell";
|
|||||||
import { sessionPermissions } from "./session";
|
import { sessionPermissions } from "./session";
|
||||||
import type {
|
import type {
|
||||||
PermissionCheckResult,
|
PermissionCheckResult,
|
||||||
|
PermissionCheckTrace,
|
||||||
PermissionDecision,
|
PermissionDecision,
|
||||||
|
PermissionEngine,
|
||||||
PermissionRules,
|
PermissionRules,
|
||||||
|
PermissionTraceEvent,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools that don't require approval within working directory
|
* 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([
|
const READ_ONLY_SHELL_TOOLS = new Set([
|
||||||
"Bash",
|
"Bash",
|
||||||
"shell",
|
"shell",
|
||||||
@@ -33,6 +58,56 @@ const READ_ONLY_SHELL_TOOLS = new Set([
|
|||||||
"run_shell_command",
|
"run_shell_command",
|
||||||
"RunShellCommand",
|
"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.
|
* Check permission for a tool execution.
|
||||||
@@ -53,168 +128,364 @@ const READ_ONLY_SHELL_TOOLS = new Set([
|
|||||||
* @param permissions - Loaded permission rules
|
* @param permissions - Loaded permission rules
|
||||||
* @param workingDirectory - Current working directory
|
* @param workingDirectory - Current working directory
|
||||||
*/
|
*/
|
||||||
type ToolArgs = Record<string, unknown>;
|
|
||||||
|
|
||||||
export function checkPermission(
|
export function checkPermission(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
toolArgs: ToolArgs,
|
toolArgs: ToolArgs,
|
||||||
permissions: PermissionRules,
|
permissions: PermissionRules,
|
||||||
workingDirectory: string = process.cwd(),
|
workingDirectory: string = process.cwd(),
|
||||||
): PermissionCheckResult {
|
): 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);
|
const canonicalTool = canonicalToolName(toolName);
|
||||||
// Build permission query string
|
const queryTool = engine === "v2" ? canonicalTool : toolName;
|
||||||
const query = buildPermissionQuery(canonicalTool, toolArgs);
|
const query = buildPermissionQuery(queryTool, toolArgs, engine);
|
||||||
|
const trace = createTrace(engine, toolName, canonicalTool, query);
|
||||||
// Get session rules
|
|
||||||
const sessionRules = sessionPermissions.getRules();
|
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) {
|
if (permissions.deny) {
|
||||||
for (const pattern of 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 {
|
return {
|
||||||
decision: "deny",
|
result: {
|
||||||
matchedRule: pattern,
|
decision: "deny",
|
||||||
reason: "Matched deny rule",
|
matchedRule: pattern,
|
||||||
|
reason: "Matched deny rule",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check CLI disallowedTools (second highest priority - overrides all allow rules)
|
|
||||||
const disallowedTools = cliPermissions.getDisallowedTools();
|
const disallowedTools = cliPermissions.getDisallowedTools();
|
||||||
for (const pattern of disallowedTools) {
|
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 {
|
return {
|
||||||
decision: "deny",
|
result: {
|
||||||
matchedRule: `${pattern} (CLI)`,
|
decision: "deny",
|
||||||
reason: "Matched --disallowedTools flag",
|
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);
|
const modeOverride = permissionMode.checkModeOverride(toolName, toolArgs);
|
||||||
if (modeOverride) {
|
if (modeOverride) {
|
||||||
const currentMode = permissionMode.getMode();
|
const currentMode = permissionMode.getMode();
|
||||||
// Include plan file path and guidance in denial message for plan mode
|
|
||||||
let reason = `Permission mode: ${currentMode}`;
|
let reason = `Permission mode: ${currentMode}`;
|
||||||
if (currentMode === "plan" && modeOverride === "deny") {
|
if (currentMode === "plan" && modeOverride === "deny") {
|
||||||
const planFilePath = permissionMode.getPlanFilePath();
|
const planFilePath = permissionMode.getPlanFilePath();
|
||||||
// planFilePath should always be set when in plan mode - they're set together
|
|
||||||
reason =
|
reason =
|
||||||
`Plan mode is active. You can only use read-only tools (Read, Grep, Glob, etc.) and write to the plan file. ` +
|
`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)"}. ` +
|
`Write your plan to: ${planFilePath || "(error: plan file path not configured)"}. ` +
|
||||||
`Use ExitPlanMode when your plan is ready for user approval.`;
|
`Use ExitPlanMode when your plan is ready for user approval.`;
|
||||||
}
|
}
|
||||||
|
traceEvent(trace, "mode-override", reason);
|
||||||
return {
|
return {
|
||||||
decision: modeOverride,
|
result: {
|
||||||
matchedRule: `${currentMode} mode`,
|
decision: modeOverride,
|
||||||
reason,
|
matchedRule: `${currentMode} mode`,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check CLI allowedTools (third priority - overrides settings but not deny rules)
|
|
||||||
const allowedTools = cliPermissions.getAllowedTools();
|
const allowedTools = cliPermissions.getAllowedTools();
|
||||||
for (const pattern of allowedTools) {
|
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 {
|
return {
|
||||||
decision: "allow",
|
result: {
|
||||||
matchedRule: `${pattern} (CLI)`,
|
decision: "allow",
|
||||||
reason: "Matched --allowedTools flag",
|
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") {
|
if (toolName === "Skill") {
|
||||||
|
traceEvent(trace, "skill-auto-allow", "Skill tool is always allowed");
|
||||||
return {
|
return {
|
||||||
decision: "allow",
|
result: {
|
||||||
reason: "Skill tool is always allowed (read-only)",
|
decision: "allow",
|
||||||
|
reason: "Skill tool is always allowed (read-only)",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (READ_ONLY_SHELL_TOOLS.has(toolName) || isShellToolName(canonicalTool)) {
|
if (READ_ONLY_SHELL_TOOLS.has(toolName) || isShellToolName(canonicalTool)) {
|
||||||
const shellCommand = extractShellCommand(toolArgs);
|
const shellCommand = extractShellCommand(toolArgs);
|
||||||
if (shellCommand && isReadOnlyShellCommand(shellCommand)) {
|
if (shellCommand && isReadOnlyShellCommand(shellCommand)) {
|
||||||
|
traceEvent(trace, "readonly-shell-auto-allow", "Read-only shell command");
|
||||||
return {
|
return {
|
||||||
decision: "allow",
|
result: {
|
||||||
reason: "Read-only shell command",
|
decision: "allow",
|
||||||
|
reason: "Read-only shell command",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Auto-approve commands that exclusively target the agent's memory directory
|
|
||||||
if (shellCommand) {
|
if (shellCommand) {
|
||||||
try {
|
try {
|
||||||
const agentId = getCurrentAgentId();
|
const agentId = getCurrentAgentId();
|
||||||
if (isMemoryDirCommand(shellCommand, agentId)) {
|
if (isMemoryDirCommand(shellCommand, agentId)) {
|
||||||
|
traceEvent(
|
||||||
|
trace,
|
||||||
|
"memory-dir-auto-allow",
|
||||||
|
"Agent memory directory operation",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
decision: "allow",
|
result: {
|
||||||
reason: "Agent memory directory operation",
|
decision: "allow",
|
||||||
|
reason: "Agent memory directory operation",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 (workingDirectoryTools.includes(queryTool)) {
|
||||||
if (WORKING_DIRECTORY_TOOLS.includes(canonicalTool)) {
|
|
||||||
const filePath = extractFilePath(toolArgs);
|
const filePath = extractFilePath(toolArgs);
|
||||||
if (
|
if (
|
||||||
filePath &&
|
filePath &&
|
||||||
isWithinAllowedDirectories(filePath, permissions, workingDirectory)
|
isWithinAllowedDirectories(filePath, permissions, workingDirectory)
|
||||||
) {
|
) {
|
||||||
|
traceEvent(
|
||||||
|
trace,
|
||||||
|
"working-directory-auto-allow",
|
||||||
|
`Allowed path: ${filePath}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
decision: "allow",
|
result: {
|
||||||
reason: "Within working directory",
|
decision: "allow",
|
||||||
|
reason: "Within working directory",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check session allow rules (higher precedence than persisted allow)
|
|
||||||
if (sessionRules.allow) {
|
if (sessionRules.allow) {
|
||||||
for (const pattern of 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 {
|
return {
|
||||||
decision: "allow",
|
result: {
|
||||||
matchedRule: `${pattern} (session)`,
|
decision: "allow",
|
||||||
reason: "Matched session allow rule",
|
matchedRule: `${pattern} (session)`,
|
||||||
|
reason: "Matched session allow rule",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check persisted allow rules
|
|
||||||
if (permissions.allow) {
|
if (permissions.allow) {
|
||||||
for (const pattern of 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 {
|
return {
|
||||||
decision: "allow",
|
result: {
|
||||||
matchedRule: pattern,
|
decision: "allow",
|
||||||
reason: "Matched allow rule",
|
matchedRule: pattern,
|
||||||
|
reason: "Matched allow rule",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check ask rules
|
|
||||||
if (permissions.ask) {
|
if (permissions.ask) {
|
||||||
for (const pattern of 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 {
|
return {
|
||||||
decision: "ask",
|
result: {
|
||||||
matchedRule: pattern,
|
decision: "ask",
|
||||||
reason: "Matched ask rule",
|
matchedRule: pattern,
|
||||||
|
reason: "Matched ask rule",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to tool defaults
|
const defaultDecision = getDefaultDecision(toolName, toolArgs);
|
||||||
|
traceEvent(trace, "default-decision", `Default: ${defaultDecision}`);
|
||||||
return {
|
return {
|
||||||
decision: getDefaultDecision(toolName, toolArgs),
|
result: {
|
||||||
reason: "Default behavior for tool",
|
decision: defaultDecision,
|
||||||
|
reason: "Default behavior for tool",
|
||||||
|
},
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +541,11 @@ function isWithinAllowedDirectories(
|
|||||||
/**
|
/**
|
||||||
* Build permission query string for a tool execution
|
* 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) {
|
switch (toolName) {
|
||||||
// File tools: "ToolName(path/to/file)"
|
// File tools: "ToolName(path/to/file)"
|
||||||
case "Read":
|
case "Read":
|
||||||
@@ -278,8 +553,24 @@ function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string {
|
|||||||
case "Edit":
|
case "Edit":
|
||||||
case "Glob":
|
case "Glob":
|
||||||
case "Grep":
|
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);
|
const filePath = extractFilePath(toolArgs);
|
||||||
return filePath ? `${toolName}(${filePath})` : toolName;
|
return filePath ? `${toolName}(${filePath})` : toolName;
|
||||||
}
|
}
|
||||||
@@ -287,13 +578,29 @@ function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string {
|
|||||||
case "Bash": {
|
case "Bash": {
|
||||||
// Bash: "Bash(command with args)"
|
// Bash: "Bash(command with args)"
|
||||||
const command =
|
const command =
|
||||||
typeof toolArgs.command === "string" ? toolArgs.command : "";
|
typeof toolArgs.command === "string"
|
||||||
|
? toolArgs.command
|
||||||
|
: Array.isArray(toolArgs.command)
|
||||||
|
? toolArgs.command.join(" ")
|
||||||
|
: "";
|
||||||
return `Bash(${command})`;
|
return `Bash(${command})`;
|
||||||
}
|
}
|
||||||
case "shell":
|
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 "run_shell_command":
|
||||||
case "RunShellCommand": {
|
case "RunShellCommand": {
|
||||||
|
if (engine === "v1") {
|
||||||
|
// Legacy behavior did not normalize this alias into Bash queries.
|
||||||
|
return toolName;
|
||||||
|
}
|
||||||
const command =
|
const command =
|
||||||
typeof toolArgs.command === "string"
|
typeof toolArgs.command === "string"
|
||||||
? toolArgs.command
|
? toolArgs.command
|
||||||
@@ -317,11 +624,6 @@ function extractShellCommand(toolArgs: ToolArgs): string | string[] | null {
|
|||||||
return 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
|
* Check if query matches a permission pattern
|
||||||
*/
|
*/
|
||||||
@@ -330,20 +632,32 @@ function matchesPattern(
|
|||||||
query: string,
|
query: string,
|
||||||
pattern: string,
|
pattern: string,
|
||||||
workingDirectory: string,
|
workingDirectory: string,
|
||||||
|
engine: PermissionEngine,
|
||||||
): boolean {
|
): 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
|
// File tools use glob matching
|
||||||
if (FILE_TOOLS.includes(canonicalTool)) {
|
if (fileTools.includes(toolForMatch)) {
|
||||||
return matchesFilePattern(query, pattern, workingDirectory);
|
return matchesFilePattern(query, pattern, workingDirectory, matcherOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bash uses prefix matching
|
// Bash uses prefix matching
|
||||||
if (canonicalTool === "Bash") {
|
const legacyShellTool =
|
||||||
return matchesBashPattern(query, pattern);
|
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
|
// 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 { minimatch } from "minimatch";
|
||||||
import { canonicalToolName } from "./canonical";
|
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.
|
* Normalize path separators to forward slashes for consistent glob matching.
|
||||||
* This is needed because:
|
* This is needed because:
|
||||||
@@ -113,6 +124,7 @@ export function matchesFilePattern(
|
|||||||
query: string,
|
query: string,
|
||||||
pattern: string,
|
pattern: string,
|
||||||
workingDirectory: string,
|
workingDirectory: string,
|
||||||
|
options?: MatcherOptions,
|
||||||
): boolean {
|
): boolean {
|
||||||
// Extract tool name and file path from query
|
// Extract tool name and file path from query
|
||||||
// Format: "ToolName(filePath)"
|
// Format: "ToolName(filePath)"
|
||||||
@@ -120,7 +132,7 @@ export function matchesFilePattern(
|
|||||||
if (!queryMatch || !queryMatch[1] || !queryMatch[2]) {
|
if (!queryMatch || !queryMatch[1] || !queryMatch[2]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const queryTool = canonicalToolName(queryMatch[1]);
|
const queryTool = toolForMatch(queryMatch[1], options);
|
||||||
// Normalize path separators for cross-platform compatibility
|
// Normalize path separators for cross-platform compatibility
|
||||||
const filePath = normalizePath(queryMatch[2]);
|
const filePath = normalizePath(queryMatch[2]);
|
||||||
|
|
||||||
@@ -129,9 +141,12 @@ export function matchesFilePattern(
|
|||||||
const patternMatch = pattern.match(/^([^(]+)\(([\s\S]+)\)$/);
|
const patternMatch = pattern.match(/^([^(]+)\(([\s\S]+)\)$/);
|
||||||
if (!patternMatch || !patternMatch[1] || !patternMatch[2]) {
|
if (!patternMatch || !patternMatch[1] || !patternMatch[2]) {
|
||||||
// Legacy fallback: allow bare tool names (for rules saved before param suffixes were added)
|
// 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) {
|
if (!patternTool) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -223,7 +238,11 @@ function extractActualCommand(command: string): string {
|
|||||||
return command;
|
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
|
// Extract the command from query
|
||||||
// Format: "Tool(actual command)" or "Tool()"
|
// Format: "Tool(actual command)" or "Tool()"
|
||||||
const queryMatch = query.match(/^([^(]+)\(([\s\S]*)\)$/);
|
const queryMatch = query.match(/^([^(]+)\(([\s\S]*)\)$/);
|
||||||
@@ -234,7 +253,7 @@ export function matchesBashPattern(query: string, pattern: string): boolean {
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (canonicalToolName(queryMatch[1]) !== "Bash") {
|
if (toolForMatch(queryMatch[1], options) !== "Bash") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const rawCommand = queryMatch[2];
|
const rawCommand = queryMatch[2];
|
||||||
@@ -249,9 +268,12 @@ export function matchesBashPattern(query: string, pattern: string): boolean {
|
|||||||
patternMatch[1] === undefined ||
|
patternMatch[1] === undefined ||
|
||||||
patternMatch[2] === 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;
|
return false;
|
||||||
}
|
}
|
||||||
const commandPattern = patternMatch[2];
|
const commandPattern = patternMatch[2];
|
||||||
@@ -278,14 +300,18 @@ export function matchesBashPattern(query: string, pattern: string): boolean {
|
|||||||
* @param toolName - The tool name
|
* @param toolName - The tool name
|
||||||
* @param pattern - The permission pattern
|
* @param pattern - The permission pattern
|
||||||
*/
|
*/
|
||||||
export function matchesToolPattern(toolName: string, pattern: string): boolean {
|
export function matchesToolPattern(
|
||||||
const canonicalTool = canonicalToolName(toolName);
|
toolName: string,
|
||||||
|
pattern: string,
|
||||||
|
options?: MatcherOptions,
|
||||||
|
): boolean {
|
||||||
|
const canonicalTool = toolForMatch(toolName, options);
|
||||||
// Wildcard matches everything
|
// Wildcard matches everything
|
||||||
if (pattern === "*") {
|
if (pattern === "*") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canonicalToolName(pattern) === canonicalTool) {
|
if (toolForMatch(pattern, options) === canonicalTool) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +323,7 @@ export function matchesToolPattern(toolName: string, pattern: string): boolean {
|
|||||||
// Check for tool name prefix (e.g., "WebFetch(...)")
|
// Check for tool name prefix (e.g., "WebFetch(...)")
|
||||||
const patternToolMatch = pattern.match(/^([^(]+)\(/);
|
const patternToolMatch = pattern.match(/^([^(]+)\(/);
|
||||||
if (patternToolMatch?.[1]) {
|
if (patternToolMatch?.[1]) {
|
||||||
return canonicalToolName(patternToolMatch[1]) === canonicalTool;
|
return toolForMatch(patternToolMatch[1], options) === canonicalTool;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ export type PermissionDecision = "allow" | "deny" | "ask";
|
|||||||
*/
|
*/
|
||||||
export type PermissionScope = "project" | "local" | "user";
|
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
|
* Result of a permission check
|
||||||
*/
|
*/
|
||||||
@@ -28,4 +52,5 @@ export interface PermissionCheckResult {
|
|||||||
decision: PermissionDecision;
|
decision: PermissionDecision;
|
||||||
matchedRule?: string;
|
matchedRule?: string;
|
||||||
reason?: 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.decision).toBe("allow");
|
||||||
expect(result.matchedRule).toBe("WriteFileGemini");
|
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