476 lines
13 KiB
TypeScript
476 lines
13 KiB
TypeScript
// src/permissions/checker.ts
|
|
// Main permission checking logic
|
|
|
|
import { resolve } from "node:path";
|
|
import { cliPermissions } from "./cli";
|
|
import {
|
|
matchesBashPattern,
|
|
matchesFilePattern,
|
|
matchesToolPattern,
|
|
} from "./matcher";
|
|
import { permissionMode } from "./mode";
|
|
import { isReadOnlyShellCommand } from "./readOnlyShell";
|
|
import { sessionPermissions } from "./session";
|
|
import type {
|
|
PermissionCheckResult,
|
|
PermissionDecision,
|
|
PermissionRules,
|
|
} from "./types";
|
|
|
|
/**
|
|
* Tools that don't require approval within working directory
|
|
*/
|
|
const WORKING_DIRECTORY_TOOLS = [
|
|
// Default/Anthropic toolset
|
|
"Read",
|
|
"Glob",
|
|
"Grep",
|
|
// Codex toolset
|
|
"read_file",
|
|
"ReadFile",
|
|
"list_dir",
|
|
"ListDir",
|
|
"grep_files",
|
|
"GrepFiles",
|
|
// Gemini toolset
|
|
"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",
|
|
"Shell",
|
|
"shell_command",
|
|
"ShellCommand",
|
|
"run_shell_command",
|
|
"RunShellCommand",
|
|
]);
|
|
|
|
/**
|
|
* Check permission for a tool execution.
|
|
*
|
|
* Decision logic:
|
|
* 1. Check deny rules from settings (first match wins) → DENY
|
|
* 2. Check CLI disallowedTools (--disallowedTools flag) → DENY
|
|
* 3. Check permission mode (--permission-mode flag) → ALLOW or DENY
|
|
* 4. Check CLI allowedTools (--allowedTools flag) → ALLOW
|
|
* 5. For Read/Glob/Grep within working directory → ALLOW
|
|
* 6. Check session allow rules (first match wins) → ALLOW
|
|
* 7. Check allow rules from settings (first match wins) → ALLOW
|
|
* 8. Check ask rules from settings (first match wins) → ASK
|
|
* 9. Fall back to default behavior for tool → ASK or ALLOW
|
|
*
|
|
* @param toolName - Name of the tool (e.g., "Read", "Bash", "Write")
|
|
* @param toolArgs - Tool arguments (contains file paths, commands, etc.)
|
|
* @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 {
|
|
// Build permission query string
|
|
const query = buildPermissionQuery(toolName, toolArgs);
|
|
|
|
// Get session rules
|
|
const sessionRules = sessionPermissions.getRules();
|
|
|
|
// 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)) {
|
|
return {
|
|
decision: "deny",
|
|
matchedRule: pattern,
|
|
reason: "Matched deny rule",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)) {
|
|
return {
|
|
decision: "deny",
|
|
matchedRule: `${pattern} (CLI)`,
|
|
reason: "Matched --disallowedTools flag",
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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.`;
|
|
}
|
|
return {
|
|
decision: modeOverride,
|
|
matchedRule: `${currentMode} mode`,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
// 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)) {
|
|
return {
|
|
decision: "allow",
|
|
matchedRule: `${pattern} (CLI)`,
|
|
reason: "Matched --allowedTools flag",
|
|
};
|
|
}
|
|
}
|
|
|
|
// Always allow Skill tool (read-only operation that loads skills from potentially external directories)
|
|
if (toolName === "Skill") {
|
|
return {
|
|
decision: "allow",
|
|
reason: "Skill tool is always allowed (read-only)",
|
|
};
|
|
}
|
|
|
|
if (READ_ONLY_SHELL_TOOLS.has(toolName)) {
|
|
const shellCommand = extractShellCommand(toolArgs);
|
|
if (shellCommand && isReadOnlyShellCommand(shellCommand)) {
|
|
return {
|
|
decision: "allow",
|
|
reason: "Read-only shell command",
|
|
};
|
|
}
|
|
}
|
|
|
|
// After checking CLI overrides, check if Read/Glob/Grep within working directory
|
|
if (WORKING_DIRECTORY_TOOLS.includes(toolName)) {
|
|
const filePath = extractFilePath(toolArgs);
|
|
if (
|
|
filePath &&
|
|
isWithinAllowedDirectories(filePath, permissions, workingDirectory)
|
|
) {
|
|
return {
|
|
decision: "allow",
|
|
reason: "Within working directory",
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check session allow rules (higher precedence than persisted allow)
|
|
if (sessionRules.allow) {
|
|
for (const pattern of sessionRules.allow) {
|
|
if (matchesPattern(toolName, query, pattern, workingDirectory)) {
|
|
return {
|
|
decision: "allow",
|
|
matchedRule: `${pattern} (session)`,
|
|
reason: "Matched session allow rule",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check persisted allow rules
|
|
if (permissions.allow) {
|
|
for (const pattern of permissions.allow) {
|
|
if (matchesPattern(toolName, query, pattern, workingDirectory)) {
|
|
return {
|
|
decision: "allow",
|
|
matchedRule: pattern,
|
|
reason: "Matched allow rule",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check ask rules
|
|
if (permissions.ask) {
|
|
for (const pattern of permissions.ask) {
|
|
if (matchesPattern(toolName, query, pattern, workingDirectory)) {
|
|
return {
|
|
decision: "ask",
|
|
matchedRule: pattern,
|
|
reason: "Matched ask rule",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to tool defaults
|
|
return {
|
|
decision: getDefaultDecision(toolName, toolArgs),
|
|
reason: "Default behavior for tool",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract file path from tool arguments
|
|
*/
|
|
function extractFilePath(toolArgs: ToolArgs): string | null {
|
|
// Different tools use different parameter names
|
|
if (typeof toolArgs.file_path === "string" && toolArgs.file_path.length > 0) {
|
|
return toolArgs.file_path;
|
|
}
|
|
if (typeof toolArgs.path === "string" && toolArgs.path.length > 0) {
|
|
return toolArgs.path;
|
|
}
|
|
if (
|
|
typeof toolArgs.notebook_path === "string" &&
|
|
toolArgs.notebook_path.length > 0
|
|
) {
|
|
return toolArgs.notebook_path;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if file path is within allowed directories
|
|
* (working directory + additionalDirectories)
|
|
*/
|
|
function isWithinAllowedDirectories(
|
|
filePath: string,
|
|
permissions: PermissionRules,
|
|
workingDirectory: string,
|
|
): boolean {
|
|
const absolutePath = resolve(workingDirectory, filePath);
|
|
|
|
// Check if within working directory
|
|
if (absolutePath.startsWith(workingDirectory)) {
|
|
return true;
|
|
}
|
|
|
|
// Check additionalDirectories
|
|
if (permissions.additionalDirectories) {
|
|
for (const dir of permissions.additionalDirectories) {
|
|
const resolvedDir = resolve(workingDirectory, dir);
|
|
if (absolutePath.startsWith(resolvedDir)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build permission query string for a tool execution
|
|
*/
|
|
function buildPermissionQuery(toolName: string, toolArgs: ToolArgs): string {
|
|
switch (toolName) {
|
|
// File tools: "ToolName(path/to/file)"
|
|
case "Read":
|
|
case "Write":
|
|
case "Edit":
|
|
case "Glob":
|
|
case "Grep":
|
|
// Codex file tools
|
|
case "read_file":
|
|
case "ReadFile":
|
|
case "list_dir":
|
|
case "ListDir":
|
|
case "grep_files":
|
|
case "GrepFiles":
|
|
// Gemini file tools
|
|
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;
|
|
}
|
|
|
|
case "Bash": {
|
|
// Bash: "Bash(command with args)"
|
|
const command =
|
|
typeof toolArgs.command === "string" ? toolArgs.command : "";
|
|
return `Bash(${command})`;
|
|
}
|
|
case "shell":
|
|
case "shell_command": {
|
|
const command =
|
|
typeof toolArgs.command === "string"
|
|
? toolArgs.command
|
|
: Array.isArray(toolArgs.command)
|
|
? toolArgs.command.join(" ")
|
|
: "";
|
|
return `Bash(${command})`;
|
|
}
|
|
|
|
default:
|
|
// Other tools: just the tool name
|
|
return toolName;
|
|
}
|
|
}
|
|
|
|
function extractShellCommand(toolArgs: ToolArgs): string | string[] | null {
|
|
const command = toolArgs.command;
|
|
if (typeof command === "string" || Array.isArray(command)) {
|
|
return command;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* File tools that use glob matching for permissions
|
|
*/
|
|
const FILE_TOOLS = [
|
|
// Default/Anthropic toolset
|
|
"Read",
|
|
"Write",
|
|
"Edit",
|
|
"Glob",
|
|
"Grep",
|
|
// Codex toolset
|
|
"read_file",
|
|
"ReadFile",
|
|
"list_dir",
|
|
"ListDir",
|
|
"grep_files",
|
|
"GrepFiles",
|
|
// Gemini toolset
|
|
"read_file_gemini",
|
|
"ReadFileGemini",
|
|
"write_file_gemini",
|
|
"WriteFileGemini",
|
|
"glob_gemini",
|
|
"GlobGemini",
|
|
"list_directory",
|
|
"ListDirectory",
|
|
"search_file_content",
|
|
"SearchFileContent",
|
|
"read_many_files",
|
|
"ReadManyFiles",
|
|
];
|
|
|
|
/**
|
|
* Check if query matches a permission pattern
|
|
*/
|
|
function matchesPattern(
|
|
toolName: string,
|
|
query: string,
|
|
pattern: string,
|
|
workingDirectory: string,
|
|
): boolean {
|
|
// File tools use glob matching
|
|
if (FILE_TOOLS.includes(toolName)) {
|
|
return matchesFilePattern(query, pattern, workingDirectory);
|
|
}
|
|
|
|
// Bash uses prefix matching
|
|
if (
|
|
toolName === "Bash" ||
|
|
toolName === "shell" ||
|
|
toolName === "shell_command"
|
|
) {
|
|
return matchesBashPattern(query, pattern);
|
|
}
|
|
|
|
// Other tools use simple name matching
|
|
return matchesToolPattern(toolName, pattern);
|
|
}
|
|
|
|
/**
|
|
* Subagent types that are read-only and safe to auto-approve.
|
|
* These only have access to read-only tools (Glob, Grep, Read, LS, BashOutput).
|
|
* See: src/agent/subagents/builtin/*.md for definitions
|
|
*/
|
|
const READ_ONLY_SUBAGENT_TYPES = new Set([
|
|
"explore", // Codebase exploration - Glob, Grep, Read, LS, BashOutput
|
|
"Explore",
|
|
"plan", // Planning agent - Glob, Grep, Read, LS, BashOutput
|
|
"Plan",
|
|
"recall", // Conversation history search - Skill, Bash, Read, BashOutput
|
|
"Recall",
|
|
]);
|
|
|
|
/**
|
|
* Get default decision for a tool (when no rules match)
|
|
*/
|
|
function getDefaultDecision(
|
|
toolName: string,
|
|
toolArgs?: ToolArgs,
|
|
): PermissionDecision {
|
|
// Check TOOL_PERMISSIONS to determine if tool requires approval
|
|
// Import is async so we need to do this synchronously - get the permissions from manager
|
|
// For now, use a hardcoded check that matches TOOL_PERMISSIONS configuration
|
|
const autoAllowTools = [
|
|
// Anthropic toolset - tools that don't require approval
|
|
"Read",
|
|
"Glob",
|
|
"Grep",
|
|
"TodoWrite",
|
|
"BashOutput",
|
|
"LS",
|
|
// Codex toolset (snake_case) - tools that don't require approval
|
|
"read_file",
|
|
"list_dir",
|
|
"grep_files",
|
|
"update_plan",
|
|
// Codex toolset (PascalCase) - tools that don't require approval
|
|
"ReadFile",
|
|
"ListDir",
|
|
"GrepFiles",
|
|
"UpdatePlan",
|
|
// Gemini toolset (snake_case) - tools that don't require approval
|
|
"read_file_gemini",
|
|
"list_directory",
|
|
"glob_gemini",
|
|
"search_file_content",
|
|
"write_todos",
|
|
"read_many_files",
|
|
// Gemini toolset (PascalCase) - tools that don't require approval
|
|
"ReadFileGemini",
|
|
"ListDirectory",
|
|
"GlobGemini",
|
|
"SearchFileContent",
|
|
"WriteTodos",
|
|
"ReadManyFiles",
|
|
];
|
|
|
|
if (autoAllowTools.includes(toolName)) {
|
|
return "allow";
|
|
}
|
|
|
|
// Task tool: auto-approve read-only subagent types
|
|
if (toolName === "Task" || toolName === "task") {
|
|
const subagentType =
|
|
typeof toolArgs?.subagent_type === "string" ? toolArgs.subagent_type : "";
|
|
if (READ_ONLY_SUBAGENT_TYPES.has(subagentType)) {
|
|
return "allow";
|
|
}
|
|
// Non-read-only subagent types require approval
|
|
return "ask";
|
|
}
|
|
|
|
// Everything else defaults to ask
|
|
return "ask";
|
|
}
|