From 90285a7b0ef0ea748056852f011a9a79574c95c3 Mon Sep 17 00:00:00 2001 From: td <2826079730@qq.com> Date: Wed, 13 May 2026 16:14:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(safety):=20=E6=B7=BB=E5=8A=A0=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=AE=A1=E6=89=B9=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=92=8C=E6=9D=83=E9=99=90=E6=A3=80=E6=9F=A5=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=EF=BC=8C=E6=90=AD=E5=BB=BA=E5=9F=BA=E7=A1=80=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/session.ts | 152 +++++++++++++++++++++++++++++++++++++++++- src/tools/executor.ts | 85 ++++++++++++++++++----- 2 files changed, 219 insertions(+), 18 deletions(-) diff --git a/src/session.ts b/src/session.ts index 2267bb0..17b1579 100644 --- a/src/session.ts +++ b/src/session.ts @@ -10,7 +10,12 @@ import { launchNotifyScript } from "./notify"; import { buildThinkingRequestOptions } from "./openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL } from "./prompt"; -import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; +import { ToolExecutor, type CreateOpenAIClient, type ToolCall } from "./tools/executor"; +import { + getSafetyApprovalLabels, + recordProjectAllowedApproval, + type SafetyApprovalRequest, +} from "./tools/safety-hooks"; import { logApiError } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; @@ -169,6 +174,11 @@ export type LlmStreamProgress = { phase: "start" | "update" | "end"; }; +type PendingSafetyApproval = { + request: SafetyApprovalRequest; + toolCall: ToolCall; +}; + export class SessionManager { private readonly projectRoot: string; private readonly createOpenAIClient: CreateOpenAIClient; @@ -179,6 +189,7 @@ export class SessionManager { private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); + private readonly pendingSafetyApprovals = new Map(); private readonly toolExecutor: ToolExecutor; constructor(options: SessionManagerOptions) { @@ -982,6 +993,10 @@ ${skillMd} return; } + if (await this.executeApprovedSafetyToolCall(sessionId)) { + continue; + } + const compactPromptTokenThreshold = getCompactPromptTokenThreshold(model); if (session.activeTokens > compactPromptTokenThreshold) { const message = this.buildAssistantMessage( @@ -1624,6 +1639,12 @@ ${skillMd} onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), shouldStop: () => this.isInterrupted(sessionId), + onSafetyApprovalRequested: (request, toolCall) => + this.pendingSafetyApprovals.set(sessionId, { + request, + toolCall, + }), + consumeSafetyApproval: (request) => this.consumeSafetyApproval(sessionId, request), }); if (this.isInterrupted(sessionId)) { return { waitingForUser: false }; @@ -1655,6 +1676,121 @@ ${skillMd} return { waitingForUser }; } + private consumeSafetyApproval(sessionId: string, request: SafetyApprovalRequest): "approved" | "denied" | "missing" { + const pending = this.pendingSafetyApprovals.get(sessionId); + if (!pending || !this.safetyApprovalRequestsMatch(pending.request, request)) { + return "missing"; + } + + const answer = this.findLatestUserAnswerForQuestion(sessionId, pending.request.question); + if (answer === "missing") { + return "missing"; + } + + this.pendingSafetyApprovals.delete(sessionId); + if (answer === "always_approved") { + recordProjectAllowedApproval(this.projectRoot, pending.request); + return "approved"; + } + return answer; + } + + private async executeApprovedSafetyToolCall(sessionId: string): Promise { + const pending = this.pendingSafetyApprovals.get(sessionId); + if (!pending) { + return false; + } + + const answer = this.findLatestUserAnswerForQuestion(sessionId, pending.request.question); + if (answer === "missing") { + return false; + } + + const executions = await this.toolExecutor.executeToolCalls(sessionId, [pending.toolCall], { + onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), + onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), + shouldStop: () => this.isInterrupted(sessionId), + consumeSafetyApproval: (request) => this.consumeSafetyApproval(sessionId, request), + }); + + if (this.isInterrupted(sessionId)) { + return true; + } + + const followUpMessages: SessionMessage[] = []; + for (const execution of executions) { + const toolFunction = this.findToolFunction([pending.toolCall], execution.toolCallId); + const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); + this.appendSessionMessage(sessionId, toolMessage); + this.onAssistantMessage(toolMessage, true); + + for (const followUpMessage of execution.result.followUpMessages ?? []) { + if (followUpMessage.role !== "system") { + continue; + } + followUpMessages.push( + this.buildSystemMessage(sessionId, followUpMessage.content, followUpMessage.contentParams ?? null) + ); + } + } + + for (const followUpMessage of followUpMessages) { + this.appendSessionMessage(sessionId, followUpMessage); + } + + return executions.length > 0; + } + + private safetyApprovalRequestsMatch(current: SafetyApprovalRequest, incoming: SafetyApprovalRequest): boolean { + return ( + current.id === incoming.id || + (current.toolName === incoming.toolName && + current.reason === incoming.reason && + current.command === incoming.command && + current.filePath === incoming.filePath) + ); + } + + private findLatestUserAnswerForQuestion( + sessionId: string, + question: string + ): "approved" | "always_approved" | "denied" | "missing" { + const labels = getSafetyApprovalLabels(); + const escapedQuestion = this.escapeAskUserQuestionAnswerPart(question); + const messages = this.listSessionMessages(sessionId); + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "user" || typeof message.content !== "string") { + continue; + } + + const content = message.content; + if (!content.includes(escapedQuestion)) { + continue; + } + + if (content.includes(`="${this.escapeAskUserQuestionAnswerPart(labels.allow)}"`)) { + return "approved"; + } + + if (content.includes(`="${this.escapeAskUserQuestionAnswerPart(labels.alwaysAllow)}"`)) { + return "always_approved"; + } + + if (content.includes(`="${this.escapeAskUserQuestionAnswerPart(labels.deny)}"`)) { + return "denied"; + } + + return "missing"; + } + + return "missing"; + } + + private escapeAskUserQuestionAnswerPart(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim(); + } + private buildOpenAIMessages(messages: SessionMessage[], thinkingEnabled: boolean): ChatCompletionMessageParam[] { const activeMessages = messages.filter((message) => !message.compacted); const toolPairings = this.pairToolMessages(activeMessages); @@ -1786,13 +1922,25 @@ ${skillMd} if (firstMatchingIndex == null) { firstMatchingIndex = index; } - if (!this.isInterruptedToolMessage(message)) { + if (!this.isInterruptedToolMessage(message) && !this.isSafetyApprovalToolMessage(message)) { return index; } } return firstMatchingIndex; } + private isSafetyApprovalToolMessage(message: SessionMessage): boolean { + if (typeof message.content !== "string" || !message.content.trim()) { + return false; + } + try { + const parsed = JSON.parse(message.content) as { name?: unknown; awaitUserResponse?: unknown }; + return parsed.name === "SafetyApproval" && parsed.awaitUserResponse === true; + } catch { + return false; + } + } + private getAssistantToolCalls(message: SessionMessage): unknown[] { if (message.role !== "assistant") { return []; diff --git a/src/tools/executor.ts b/src/tools/executor.ts index eec0b20..820cd88 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -1,11 +1,19 @@ import type OpenAI from "openai"; import type { ReasoningEffort } from "../settings"; -import { handleAskUserQuestionTool } from "./ask-user-question-handler"; -import { handleBashTool } from "./bash-handler"; -import { handleEditTool } from "./edit-handler"; -import { handleReadTool } from "./read-handler"; -import { handleWebSearchTool } from "./web-search-handler"; -import { handleWriteTool } from "./write-handler"; +import { canExecuteAskUserQuestionTool, handleAskUserQuestionTool } from "./ask-user-question-handler"; +import { canExecuteBashTool, handleBashTool } from "./bash-handler"; +import { canExecuteEditTool, handleEditTool } from "./edit-handler"; +import { canExecuteReadTool, handleReadTool } from "./read-handler"; +import { canExecuteWebSearchTool, handleWebSearchTool } from "./web-search-handler"; +import { canExecuteWriteTool, handleWriteTool } from "./write-handler"; +import { + buildSafetyApprovalToolResult, + buildSafetyDeniedToolResult, + loadProjectPermissionPolicy, + type PermissionContext, + type SafetyDecision, + type SafetyApprovalRequest, +} from "./safety-hooks"; export type CreateOpenAIClient = () => { client: OpenAI | null; @@ -41,6 +49,8 @@ export type ToolExecutionHooks = { onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; shouldStop?: () => boolean; + onSafetyApprovalRequested?: (request: SafetyApprovalRequest, toolCall: ToolCall) => void; + consumeSafetyApproval?: (request: SafetyApprovalRequest) => "approved" | "denied" | "missing"; }; export type ToolExecutionResult = { @@ -64,6 +74,13 @@ export type ToolHandler = ( context: ToolExecutionContext ) => Promise; +export type ToolPermissionCheck = (args: Record, context: PermissionContext) => SafetyDecision; + +type ToolDefinition = { + handler: ToolHandler; + canExecute: ToolPermissionCheck; +}; + export type ToolCallExecution = { toolCallId: string; content: string; @@ -73,7 +90,7 @@ export type ToolCallExecution = { export class ToolExecutor { private readonly projectRoot: string; private readonly createOpenAIClient?: CreateOpenAIClient; - private readonly toolHandlers = new Map(); + private readonly tools = new Map(); constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient) { this.projectRoot = projectRoot; @@ -109,12 +126,15 @@ export class ToolExecutor { } private registerToolHandlers(): void { - this.toolHandlers.set("bash", handleBashTool); - this.toolHandlers.set("read", handleReadTool); - this.toolHandlers.set("write", handleWriteTool); - this.toolHandlers.set("edit", handleEditTool); - this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); - this.toolHandlers.set("WebSearch", handleWebSearchTool); + this.tools.set("bash", { handler: handleBashTool, canExecute: canExecuteBashTool }); + this.tools.set("read", { handler: handleReadTool, canExecute: canExecuteReadTool }); + this.tools.set("write", { handler: handleWriteTool, canExecute: canExecuteWriteTool }); + this.tools.set("edit", { handler: handleEditTool, canExecute: canExecuteEditTool }); + this.tools.set("AskUserQuestion", { + handler: handleAskUserQuestionTool, + canExecute: canExecuteAskUserQuestionTool, + }); + this.tools.set("WebSearch", { handler: handleWebSearchTool, canExecute: canExecuteWebSearchTool }); } private parseToolCall(toolCall: unknown): ToolCall | null { @@ -159,8 +179,8 @@ export class ToolExecutor { hooks?: ToolExecutionHooks ): Promise { const toolName = toolCall.function.name; - const handler = this.toolHandlers.get(toolName); - if (!handler) { + const tool = this.tools.get(toolName); + if (!tool) { return { ok: false, name: toolName, @@ -177,8 +197,34 @@ export class ToolExecutor { }; } + const safetyDecision = tool.canExecute(parsedArgs.args, this.buildPermissionContext()); + if (safetyDecision.action === "block") { + return { + ok: false, + name: toolName, + error: `Blocked by safety hook: ${safetyDecision.reason}`, + metadata: { + safety_hook: { + action: "blocked", + reason: safetyDecision.reason, + }, + }, + }; + } + + if (safetyDecision.action === "confirm") { + const approvalState = hooks?.consumeSafetyApproval?.(safetyDecision.request) ?? "missing"; + if (approvalState === "denied") { + return buildSafetyDeniedToolResult(safetyDecision.request); + } + if (approvalState !== "approved") { + hooks?.onSafetyApprovalRequested?.(safetyDecision.request, toolCall); + return buildSafetyApprovalToolResult(safetyDecision.request); + } + } + try { - return await handler(parsedArgs.args, { + return await tool.handler(parsedArgs.args, { sessionId, projectRoot: this.projectRoot, toolCall, @@ -196,6 +242,13 @@ export class ToolExecutor { } } + private buildPermissionContext(): PermissionContext { + return { + projectRoot: this.projectRoot, + policy: loadProjectPermissionPolicy(this.projectRoot), + }; + } + private parseToolArguments( rawArguments: string ): { ok: true; args: Record } | { ok: false; error: string } { From a00e2f28e3e28802354f9ddb4a4ca90123384ad1 Mon Sep 17 00:00:00 2001 From: td <2826079730@qq.com> Date: Wed, 13 May 2026 16:15:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(safety):=20=E6=B7=BB=E5=8A=A0=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=85=B7=E5=AE=89=E5=85=A8=E6=80=A7=E8=AF=84?= =?UTF-8?q?=E4=BC=B0=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=85=B7=E7=9A=84=E6=89=A7=E8=A1=8C=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tools/ask-user-question-handler.ts | 8 + src/tools/bash-handler.ts | 5 + src/tools/edit-handler.ts | 5 + src/tools/read-handler.ts | 5 + src/tools/safety-hooks.ts | 1026 ++++++++++++++++++++++++ src/tools/web-search-handler.ts | 5 + src/tools/write-handler.ts | 5 + 7 files changed, 1059 insertions(+) create mode 100644 src/tools/safety-hooks.ts diff --git a/src/tools/ask-user-question-handler.ts b/src/tools/ask-user-question-handler.ts index 8608508..2f68644 100644 --- a/src/tools/ask-user-question-handler.ts +++ b/src/tools/ask-user-question-handler.ts @@ -1,4 +1,5 @@ import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { evaluateGenericToolSafety, type PermissionContext, type SafetyDecision } from "./safety-hooks"; type AskUserQuestionOption = { label: string; @@ -16,6 +17,13 @@ type AskUserQuestionMetadata = { questions: AskUserQuestionItem[]; }; +export function canExecuteAskUserQuestionTool( + args: Record, + context: PermissionContext +): SafetyDecision { + return evaluateGenericToolSafety("AskUserQuestion", args, context); +} + export async function handleAskUserQuestionTool( args: Record, _context: ToolExecutionContext diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 155d82a..c206dcd 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -1,5 +1,6 @@ import { spawn } from "child_process"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { evaluateBashToolSafety, type PermissionContext, type SafetyDecision } from "./safety-hooks"; import { buildDisableExtglobCommand, buildShellEnv, @@ -24,6 +25,10 @@ type ToolCommandResult = { startCwd?: string; }; +export function canExecuteBashTool(args: Record, context: PermissionContext): SafetyDecision { + return evaluateBashToolSafety(args, context); +} + export async function handleBashTool( args: Record, context: ToolExecutionContext diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 403f984..0b6054e 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import { z } from "zod"; import { buildThinkingRequestOptions } from "../openai-thinking"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { evaluateEditToolSafety, type PermissionContext, type SafetyDecision } from "./safety-hooks"; import { buildDiffPreview, hasFileChangedSinceState, readTextFileWithMetadata, writeTextFile } from "./file-utils"; import { executeValidatedTool, semanticBoolean } from "./runtime"; import { @@ -75,6 +76,10 @@ const editSchema = z.strictObject({ }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()), }); +export function canExecuteEditTool(args: Record, context: PermissionContext): SafetyDecision { + return evaluateEditToolSafety(args, context); +} + export async function handleEditTool( args: Record, context: ToolExecutionContext diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index 548bcfd..ca66ce7 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -3,6 +3,7 @@ import * as path from "path"; import ignore from "ignore"; import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; import { readTextFileWithMetadata } from "./file-utils"; +import { evaluateReadToolSafety, type PermissionContext, type SafetyDecision } from "./safety-hooks"; import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "./state"; const DEFAULT_LINE_LIMIT = 2000; @@ -35,6 +36,10 @@ const DEFAULT_GITIGNORE = [ "target/", ]; +export function canExecuteReadTool(args: Record, context: PermissionContext): SafetyDecision { + return evaluateReadToolSafety(args, context); +} + type PageRange = { start: number; end: number; diff --git a/src/tools/safety-hooks.ts b/src/tools/safety-hooks.ts new file mode 100644 index 0000000..6903128 --- /dev/null +++ b/src/tools/safety-hooks.ts @@ -0,0 +1,1026 @@ +import * as fs from "fs"; +import * as path from "path"; + +export type PermissionPolicy = { + rules?: PermissionRule[]; + tools?: Record; + bash?: { + defaultAction?: SafetyAction; + allowCommands?: string[]; + confirmCommands?: string[]; + restrictCommands?: string[]; + blockCommands?: string[]; + }; + filesystem?: { + outsideProject?: SafetyAction; + emptyExistingFile?: SafetyAction; + contentRemoval?: SafetyAction; + }; +}; + +export type PermissionRule = { + tool: string; + action: SafetyAction; + match?: { + command?: string; + filePath?: string; + }; + reason?: string; + scope?: "exact" | "project"; +}; + +export type PermissionContext = { + projectRoot: string; + policy: PermissionPolicy; +}; + +export type SafetyApprovalRequest = { + id: string; + toolName: string; + reason: string; + command?: string; + filePath?: string; + question: string; +}; + +export type SafetyAction = "ALLOW" | "CONFIRM" | "RESTRICT" | "DENY"; + +export type SafetyDecision = + | { action: "allow" } + | { action: "block"; reason: string } + | { action: "confirm"; request: SafetyApprovalRequest }; + +const ALLOW_LABEL = "Allow once"; +const ALWAYS_ALLOW_LABEL = "Always allow in this project"; +const DENY_LABEL = "Deny"; + +const DEFAULT_PERMISSION_POLICY: PermissionPolicy = { + tools: { + read: "ALLOW", + WebSearch: "ALLOW", + AskUserQuestion: "ALLOW", + }, + bash: { + defaultAction: "ALLOW", + }, + filesystem: { + outsideProject: "CONFIRM", + emptyExistingFile: "CONFIRM", + contentRemoval: "CONFIRM", + }, + rules: [ + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "rm" }, + reason: "The command appears to delete files or directories.", + }, + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "del" }, + reason: "The command appears to delete files or directories.", + }, + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "erase" }, + reason: "The command appears to delete files or directories.", + }, + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "rmdir" }, + reason: "The command appears to delete files or directories.", + }, + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "rd" }, + reason: "The command appears to delete files or directories.", + }, + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "unlink" }, + reason: "The command appears to delete files or directories.", + }, + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "Remove-Item" }, + reason: "The command appears to delete files or directories.", + }, + { + tool: "bash", + action: "ALLOW", + scope: "project", + match: { command: "git rm" }, + reason: "git rm deletes files from the working tree.", + }, + ], +}; + +export function loadProjectPermissionPolicy(projectRoot: string): PermissionPolicy { + const filePolicy = readPolicyFile(path.join(projectRoot, ".deepcode", "permissions.json")); + const settingsPolicy = readPolicyFromSettings(path.join(projectRoot, ".deepcode", "settings.json")); + return mergePermissionPolicies(DEFAULT_PERMISSION_POLICY, filePolicy, settingsPolicy); +} + +export function recordProjectAllowedApproval(projectRoot: string, request: SafetyApprovalRequest): void { + const permissionsPath = path.join(projectRoot, ".deepcode", "permissions.json"); + const current = readPolicyFile(permissionsPath); + const rules = Array.isArray(current.rules) ? current.rules.slice() : []; + const rule = buildAllowRuleFromApproval(projectRoot, request); + if (!rules.some((item) => permissionRuleMatchesRequest(item, request, projectRoot))) { + rules.push(rule); + } + + const nextPolicy: PermissionPolicy = { + ...pickWritablePolicyFields(current), + rules, + }; + + fs.mkdirSync(path.dirname(permissionsPath), { recursive: true }); + fs.writeFileSync(permissionsPath, `${JSON.stringify(nextPolicy, null, 2)}\n`, "utf8"); +} + +export function evaluateGenericToolSafety( + toolName: string, + _args: Record, + context: PermissionContext +): SafetyDecision { + const configuredAction = normalizeSafetyAction(context.policy.tools?.[toolName]); + if (configuredAction) { + return actionToDecisionWithAllowlist(context, configuredAction, { + toolName, + reason: `Project policy marks ${toolName} as ${configuredAction}.`, + }); + } + + if (isReadOnlyTool(toolName)) { + return { action: "allow" }; + } + + return actionToDecisionWithAllowlist(context, "CONFIRM", { + toolName, + reason: `No explicit permission policy exists for ${toolName}.`, + }); +} + +export function evaluateReadToolSafety(args: Record, context: PermissionContext): SafetyDecision { + const filePath = typeof args.file_path === "string" ? normalizePath(args.file_path) : undefined; + + const ruleDecision = findMatchingRuleDecision(context, { + toolName: "read", + filePath, + }); + if (ruleDecision) { + return ruleDecision; + } + + if (filePath && isOutsideProject(filePath, context.projectRoot)) { + return actionToDecisionWithAllowlist( + context, + normalizeSafetyAction(context.policy.filesystem?.outsideProject) ?? "CONFIRM", + { + toolName: "read", + reason: "The read targets a file outside the current project.", + filePath, + } + ); + } + + return { action: "allow" }; +} + +export function evaluateBashToolSafety(args: Record, context: PermissionContext): SafetyDecision { + const command = typeof args.command === "string" ? args.command.trim() : ""; + if (!command) { + return { action: "allow" }; + } + + const ruleDecision = findMatchingRuleDecision(context, { + toolName: "bash", + command, + }); + if (ruleDecision) { + return ruleDecision; + } + + const configuredAction = matchConfiguredCommandAction(command, context.policy.bash); + if (configuredAction) { + return actionToDecisionWithAllowlist(context, configuredAction, { + toolName: "bash", + reason: `Project bash policy marks this command as ${configuredAction}.`, + command, + }); + } + + const normalized = normalizeCommand(command); + const outsideProjectReference = findOutsideProjectReference(normalized, context.projectRoot); + if (outsideProjectReference) { + return confirmUnlessAllowed(context, { + toolName: "bash", + reason: `The command references a path outside the current project: ${outsideProjectReference}`, + command, + }); + } + + const catastrophicReason = detectCatastrophicCommand(normalized); + if (catastrophicReason) { + return { + action: "block", + reason: catastrophicReason, + }; + } + + const destructiveReason = detectDestructiveCommand(normalized); + if (destructiveReason) { + return confirmUnlessAllowed(context, { + toolName: "bash", + reason: destructiveReason, + command, + }); + } + + if (isReadOnlyCommand(normalized)) { + return { action: "allow" }; + } + + const defaultAction = normalizeSafetyAction(context.policy.bash?.defaultAction) ?? "CONFIRM"; + return actionToDecisionWithAllowlist(context, defaultAction, { + toolName: "bash", + reason: "Shell commands are not on the read-only allowlist.", + command, + }); +} + +export function evaluateEditToolSafety(args: Record, context: PermissionContext): SafetyDecision { + const oldString = typeof args.old_string === "string" ? args.old_string : ""; + const newString = typeof args.new_string === "string" ? args.new_string : ""; + const filePath = typeof args.file_path === "string" ? normalizePath(args.file_path) : undefined; + + const ruleDecision = findMatchingRuleDecision(context, { + toolName: "edit", + filePath, + }); + if (ruleDecision) { + return ruleDecision; + } + + if (oldString.trim().length > 0 && newString.trim().length === 0) { + return actionToDecisionWithAllowlist( + context, + normalizeSafetyAction(context.policy.filesystem?.contentRemoval) ?? "CONFIRM", + { + toolName: "edit", + reason: "The edit removes content by replacing text with an empty string.", + filePath, + } + ); + } + + if (filePath && isOutsideProject(filePath, context.projectRoot)) { + return actionToDecisionWithAllowlist( + context, + normalizeSafetyAction(context.policy.filesystem?.outsideProject) ?? "CONFIRM", + { + toolName: "edit", + reason: "The edit targets a file outside the current project.", + filePath, + } + ); + } + + return { action: "allow" }; +} + +export function evaluateWriteToolSafety(args: Record, context: PermissionContext): SafetyDecision { + const filePath = typeof args.file_path === "string" ? normalizePath(args.file_path) : undefined; + const content = typeof args.content === "string" ? args.content : ""; + + const ruleDecision = findMatchingRuleDecision(context, { + toolName: "write", + filePath, + }); + if (ruleDecision) { + return ruleDecision; + } + + if (filePath && fs.existsSync(filePath) && content.trim().length === 0) { + return actionToDecisionWithAllowlist( + context, + normalizeSafetyAction(context.policy.filesystem?.emptyExistingFile) ?? "CONFIRM", + { + toolName: "write", + reason: "The write would empty an existing file.", + filePath, + } + ); + } + + if (filePath && isOutsideProject(filePath, context.projectRoot)) { + return actionToDecisionWithAllowlist( + context, + normalizeSafetyAction(context.policy.filesystem?.outsideProject) ?? "CONFIRM", + { + toolName: "write", + reason: "The write targets a file outside the current project.", + filePath, + } + ); + } + + return { action: "allow" }; +} + +export function describeSafetyAction(action: SafetyAction): string { + if (action === "ALLOW") { + return "ALLOW: run directly"; + } + if (action === "CONFIRM") { + return "CONFIRM: require user approval before running"; + } + if (action === "RESTRICT") { + return "RESTRICT: run only after validating command arguments and paths"; + } + return "DENY: block without asking the user"; +} + +export function buildSafetyApprovalToolResult(request: SafetyApprovalRequest): { + ok: boolean; + name: string; + output: string; + metadata: Record; + awaitUserResponse: true; +} { + return { + ok: true, + name: "SafetyApproval", + output: [ + "Waiting for user approval before running a potentially destructive operation.", + "", + `Tool: ${request.toolName}`, + `Reason: ${request.reason}`, + request.command ? `Command: ${request.command}` : null, + request.filePath ? `File: ${request.filePath}` : null, + ] + .filter((line): line is string => line !== null) + .join("\n"), + metadata: { + kind: "ask_user_question", + safety_hook: { + id: request.id, + tool_name: request.toolName, + reason: request.reason, + command: request.command, + file_path: request.filePath, + }, + questions: [ + { + question: request.question, + options: [ + { + label: ALLOW_LABEL, + description: "Run this operation one time.", + }, + { + label: ALWAYS_ALLOW_LABEL, + description: "Run this exact operation now and allow it automatically in this project later.", + }, + { + label: DENY_LABEL, + description: "Do not run this operation.", + }, + ], + }, + ], + }, + awaitUserResponse: true, + }; +} + +export function buildSafetyDeniedToolResult(request: SafetyApprovalRequest): { + ok: false; + name: string; + error: string; + metadata: Record; +} { + return { + ok: false, + name: request.toolName, + error: `User denied approval for this operation: ${request.reason}`, + metadata: { + safety_hook: { + id: request.id, + reason: request.reason, + }, + }, + }; +} + +export function getSafetyApprovalLabels(): { allow: string; alwaysAllow: string; deny: string } { + return { allow: ALLOW_LABEL, alwaysAllow: ALWAYS_ALLOW_LABEL, deny: DENY_LABEL }; +} + +function isReadOnlyTool(toolName: string): boolean { + return toolName === "read" || toolName === "WebSearch" || toolName === "AskUserQuestion"; +} + +function detectCatastrophicCommand(command: string): string | null { + const compact = command.replace(/\s+/g, " "); + if (/\brm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\s+(?:--\s+)?\/(?:\s|$)/i.test(compact)) { + return "Refusing to recursively delete the filesystem root."; + } + + if ( + /\bremove-item\b[^;&|]*\b(?:-recurse\b[^;&|]*\b-force|-force\b[^;&|]*\b-recurse)\b[^;&|]*(?:[a-z]:\\|\/)(?:\s|$)/i.test( + compact + ) + ) { + return "Refusing to recursively delete a drive or filesystem root."; + } + + if (/\bformat(?:\.com)?\b/i.test(compact) || /\bdiskpart\b/i.test(compact)) { + return "Refusing to run disk formatting or partitioning commands."; + } + + return null; +} + +function detectDestructiveCommand(command: string): string | null { + if (/\b(?:rm|unlink|del|erase|rmdir|rd)\b/i.test(command)) { + return "The command appears to delete files or directories."; + } + + if (/\bremove-item\b/i.test(command)) { + return "The command appears to delete files or directories."; + } + + if (/\bgit\s+clean\b/i.test(command)) { + return "git clean can permanently remove untracked files."; + } + + if (/\bgit\s+reset\b[^;&|]*\s--hard\b/i.test(command)) { + return "git reset --hard can discard local changes."; + } + + if (/\bgit\s+(?:checkout|restore)\b[^;&|]*\s--\s+/i.test(command)) { + return "The git command can discard local file changes."; + } + + if (/\bgit\s+rm\b/i.test(command)) { + return "git rm deletes files from the working tree."; + } + + if (/\bpython(?:3|\.exe)?\b[\s\S]*\b(?:os\.(?:remove|unlink|rmdir)|shutil\.rmtree)\b/i.test(command)) { + return "The Python command appears to delete files or directories."; + } + + if (/\bnode(?:\.exe)?\b[\s\S]*\bfs\.(?:rmSync|unlinkSync|rmdirSync|rm|unlink|rmdir)\b/i.test(command)) { + return "The Node.js command appears to delete files or directories."; + } + + return null; +} + +function isReadOnlyCommand(command: string): boolean { + return ( + /^(?:pwd|ls|dir|echo|printf|cat|head|tail|rg|grep|find\s+[^;&|]*|-?git\s+(?:status|diff|log|show|branch))(?:\s|$)/i.test( + command + ) && + !/[;&|`$<>]/.test(command) && + !/\b(?:rm|del|erase|rmdir|rd|unlink|remove-item|mv|move|cp|copy|chmod|chown|curl|wget|npm\s+install|pip\s+install)\b/i.test( + command + ) + ); +} + +function actionToDecision( + action: SafetyAction, + input: { + toolName: string; + reason: string; + command?: string; + filePath?: string; + } +): SafetyDecision { + if (action === "ALLOW") { + return { action: "allow" }; + } + if (action === "DENY") { + return { action: "block", reason: input.reason }; + } + if (action === "RESTRICT") { + return { action: "block", reason: `Restricted operation lacks enough context to run safely: ${input.reason}` }; + } + return { + action: "confirm", + request: buildApprovalRequest(input), + }; +} + +function actionToDecisionWithAllowlist( + context: PermissionContext, + action: SafetyAction, + input: { + toolName: string; + reason: string; + command?: string; + filePath?: string; + } +): SafetyDecision { + if (action === "RESTRICT") { + return restrictedDecision(context, input); + } + if (action !== "CONFIRM") { + return actionToDecision(action, input); + } + return confirmUnlessAllowed(context, input); +} + +function restrictedDecision( + context: PermissionContext, + input: { + toolName: string; + reason: string; + command?: string; + filePath?: string; + } +): SafetyDecision { + if (input.toolName === "bash" && input.command) { + const restrictionResult = validateRestrictedBashCommand(input.command, context.projectRoot); + if (restrictionResult.type === "block") { + return { + action: "block", + reason: restrictionResult.reason, + }; + } + if (restrictionResult.type === "confirm") { + return confirmUnlessAllowed(context, { + toolName: input.toolName, + reason: restrictionResult.reason, + command: input.command, + }); + } + return { action: "allow" }; + } + + if (input.filePath && isOutsideProject(normalizePath(input.filePath), context.projectRoot)) { + return confirmUnlessAllowed(context, { + toolName: input.toolName, + reason: "Restricted file operation targets a path outside the current project.", + filePath: input.filePath, + }); + } + + return { action: "allow" }; +} + +function validateRestrictedBashCommand( + command: string, + projectRoot: string +): { type: "allow" } | { type: "confirm"; reason: string } | { type: "block"; reason: string } { + const normalized = normalizeCommand(command); + if (/[;&|`$<>]/.test(normalized) || /\n/.test(normalized)) { + return { + type: "block", + reason: + "Restricted bash commands cannot use shell control operators, expansion, redirection, or multiple commands.", + }; + } + + const catastrophicReason = detectCatastrophicCommand(normalized); + if (catastrophicReason) { + return { type: "block", reason: catastrophicReason }; + } + + const destructiveReason = detectDestructiveCommand(normalized); + if (destructiveReason) { + return { + type: "block", + reason: `Restricted bash command is destructive: ${destructiveReason}`, + }; + } + + for (const candidatePath of extractAbsolutePaths(normalized)) { + if (isOutsideProject(candidatePath, projectRoot)) { + return { + type: "confirm", + reason: `Restricted bash command references a path outside the current project: ${candidatePath}`, + }; + } + } + + return { type: "allow" }; +} + +function extractAbsolutePaths(command: string): string[] { + const paths = new Set(); + const quotedPattern = /"([^"]+)"|'([^']+)'/g; + for (const match of command.matchAll(quotedPattern)) { + const value = match[1] ?? match[2] ?? ""; + if (isAbsoluteLikePath(value)) { + paths.add(normalizePath(value)); + } + } + + for (const token of command.split(/\s+/)) { + const cleaned = token.replace(/^["']|["']$/g, ""); + if (isAbsoluteLikePath(cleaned)) { + paths.add(normalizePath(cleaned)); + } + } + + return Array.from(paths); +} + +function isAbsoluteLikePath(value: string): boolean { + return path.isAbsolute(value) || /^[a-z]:[\\/]/i.test(value); +} + +function confirmUnlessAllowed( + context: PermissionContext, + input: { + toolName: string; + reason: string; + command?: string; + filePath?: string; + } +): SafetyDecision { + const request = buildApprovalRequest(input); + if (isApprovalAllowedByProjectPolicy(context.policy, request, context.projectRoot)) { + return { action: "allow" }; + } + return { + action: "confirm", + request, + }; +} + +function findMatchingRuleDecision( + context: PermissionContext, + input: { + toolName: string; + command?: string; + filePath?: string; + } +): SafetyDecision | null { + const rules = Array.isArray(context.policy.rules) ? context.policy.rules : []; + for (const rule of rules) { + if (!permissionRuleMatchesInput(rule, input, context.projectRoot)) { + continue; + } + const action = normalizeSafetyAction(rule.action) ?? "CONFIRM"; + return actionToDecisionWithAllowlist(context, action, { + toolName: input.toolName, + reason: rule.reason || `Project rule marks ${input.toolName} as ${action}.`, + command: input.command, + filePath: input.filePath, + }); + } + return null; +} + +function isApprovalAllowedByProjectPolicy( + policy: PermissionPolicy, + request: SafetyApprovalRequest, + projectRoot: string +): boolean { + return (policy.rules ?? []).some( + (rule) => normalizeSafetyAction(rule.action) === "ALLOW" && permissionRuleMatchesRequest(rule, request, projectRoot) + ); +} + +function buildAllowRuleFromApproval(projectRoot: string, request: SafetyApprovalRequest): PermissionRule { + const projectScopedRule = buildProjectScopedAllowRule(projectRoot, request); + if (projectScopedRule) { + return projectScopedRule; + } + + return { + tool: request.toolName, + action: "ALLOW", + match: { + command: request.command, + filePath: request.filePath, + }, + reason: request.reason, + scope: "exact", + }; +} + +function buildProjectScopedAllowRule(projectRoot: string, request: SafetyApprovalRequest): PermissionRule | null { + if (request.toolName !== "bash" || !request.command) { + return null; + } + + const normalized = normalizeCommand(request.command); + if (!detectDestructiveCommand(normalized)) { + return null; + } + + const commandKey = extractCommandKey(normalized); + if (!commandKey) { + return null; + } + + const paths = extractAbsolutePaths(normalized); + if (paths.length === 0 || paths.some((candidatePath) => isOutsideProject(candidatePath, projectRoot))) { + return null; + } + + return { + tool: request.toolName, + action: "ALLOW", + match: { + command: commandKey, + }, + reason: request.reason, + scope: "project", + }; +} + +function permissionRuleMatchesRequest( + rule: PermissionRule, + request: SafetyApprovalRequest, + projectRoot: string +): boolean { + return permissionRuleMatchesInput( + rule, + { + toolName: request.toolName, + command: request.command, + filePath: request.filePath, + }, + projectRoot + ); +} + +function permissionRuleMatchesInput( + rule: PermissionRule, + input: { + toolName: string; + command?: string; + filePath?: string; + }, + projectRoot: string +): boolean { + if (!rule || rule.tool !== input.toolName) { + return false; + } + + const scope = rule.scope ?? "exact"; + const match = rule.match ?? {}; + if (scope === "project") { + return permissionRuleMatchesProjectScope(rule, input, projectRoot); + } + + if (match.command && match.command !== input.command) { + return false; + } + if (match.filePath && normalizePath(match.filePath) !== (input.filePath ? normalizePath(input.filePath) : "")) { + return false; + } + return Boolean(match.command || match.filePath); +} + +function permissionRuleMatchesProjectScope( + rule: PermissionRule, + input: { + toolName: string; + command?: string; + filePath?: string; + }, + projectRoot: string +): boolean { + const match = rule.match ?? {}; + + if (input.toolName === "bash") { + if (!input.command || !match.command) { + return false; + } + const commandKey = extractCommandKey(normalizeCommand(input.command)); + if (commandKey !== match.command) { + return false; + } + const paths = extractAbsolutePaths(input.command); + if (paths.length > 0) { + return paths.every((candidatePath) => !isOutsideProject(candidatePath, projectRoot)); + } + return !referencesParentDirectory(input.command); + } + + if (!input.filePath) { + return false; + } + return !isOutsideProject(normalizePath(input.filePath), projectRoot); +} + +function buildApprovalRequest(input: { + toolName: string; + reason: string; + command?: string; + filePath?: string; +}): SafetyApprovalRequest { + const target = input.command ?? input.filePath ?? input.toolName; + return { + id: crypto.randomUUID(), + toolName: input.toolName, + reason: input.reason, + command: input.command, + filePath: input.filePath, + question: `Approve this ${input.toolName} operation? ${input.reason} Target: ${target}`, + }; +} + +function matchConfiguredCommandAction( + command: string, + policy: PermissionPolicy["bash"] | undefined +): SafetyAction | null { + if (!policy) { + return null; + } + for (const action of ["DENY", "CONFIRM", "RESTRICT", "ALLOW"] as const) { + const patterns = + action === "DENY" + ? policy.blockCommands + : action === "CONFIRM" + ? policy.confirmCommands + : action === "RESTRICT" + ? policy.restrictCommands + : policy.allowCommands; + if (matchesAnyPattern(command, patterns)) { + return action; + } + } + return null; +} + +function matchesAnyPattern(value: string, patterns: string[] | undefined): boolean { + if (!Array.isArray(patterns)) { + return false; + } + return patterns.some((pattern) => matchesPattern(value, pattern)); +} + +function matchesPattern(value: string, pattern: string): boolean { + if (!pattern) { + return false; + } + if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) { + try { + return new RegExp(pattern.slice(1, -1), "i").test(value); + } catch { + return false; + } + } + return value.toLowerCase().includes(pattern.toLowerCase()); +} + +function normalizeCommand(command: string): string { + return command.replace(/`/g, "").trim(); +} + +function extractCommandKey(command: string): string | null { + if (!command) { + return null; + } + const normalized = command.trim(); + const gitRmMatch = normalized.match(/^git\s+rm\b/i); + if (gitRmMatch) { + return "git rm"; + } + const removeItemMatch = normalized.match(/^remove-item\b/i); + if (removeItemMatch) { + return "Remove-Item"; + } + const firstToken = normalized.split(/\s+/, 1)[0]; + return firstToken || null; +} + +function normalizePath(filePath: string): string { + return path.resolve(filePath); +} + +function isOutsideProject(filePath: string, projectRoot: string): boolean { + const relative = path.relative(path.resolve(projectRoot), filePath); + return Boolean(relative) && (relative.startsWith("..") || path.isAbsolute(relative)); +} + +function readPolicyFile(filePath: string): PermissionPolicy { + try { + if (!fs.existsSync(filePath)) { + return {}; + } + return normalizePermissionPolicy(JSON.parse(fs.readFileSync(filePath, "utf8"))); + } catch { + return {}; + } +} + +function readPolicyFromSettings(filePath: string): PermissionPolicy { + try { + if (!fs.existsSync(filePath)) { + return {}; + } + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as { permissions?: unknown }; + return normalizePermissionPolicy(parsed.permissions); + } catch { + return {}; + } +} + +function normalizePermissionPolicy(value: unknown): PermissionPolicy { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as PermissionPolicy; +} + +function mergePermissionPolicies(...policies: PermissionPolicy[]): PermissionPolicy { + const merged: PermissionPolicy = {}; + + for (const policy of policies) { + if (!policy || typeof policy !== "object") { + continue; + } + + if (policy.tools) { + merged.tools = { + ...(merged.tools ?? {}), + ...policy.tools, + }; + } + + if (policy.bash) { + merged.bash = { + ...(merged.bash ?? {}), + ...policy.bash, + allowCommands: mergeStringLists(merged.bash?.allowCommands, policy.bash.allowCommands), + confirmCommands: mergeStringLists(merged.bash?.confirmCommands, policy.bash.confirmCommands), + restrictCommands: mergeStringLists(merged.bash?.restrictCommands, policy.bash.restrictCommands), + blockCommands: mergeStringLists(merged.bash?.blockCommands, policy.bash.blockCommands), + }; + } + + if (policy.filesystem) { + merged.filesystem = { + ...(merged.filesystem ?? {}), + ...policy.filesystem, + }; + } + + if (policy.rules) { + merged.rules = [...(merged.rules ?? []), ...policy.rules]; + } + } + + return merged; +} + +function mergeStringLists(existing: string[] | undefined, next: string[] | undefined): string[] | undefined { + if (!existing?.length && !next?.length) { + return undefined; + } + return [...(existing ?? []), ...(next ?? [])]; +} + +function pickWritablePolicyFields(policy: PermissionPolicy): PermissionPolicy { + return { + ...(policy.tools ? { tools: policy.tools } : {}), + ...(policy.bash ? { bash: policy.bash } : {}), + ...(policy.filesystem ? { filesystem: policy.filesystem } : {}), + ...(policy.rules ? { rules: policy.rules } : {}), + }; +} + +function normalizeSafetyAction(value: unknown): SafetyAction | null { + return value === "ALLOW" || value === "CONFIRM" || value === "RESTRICT" || value === "DENY" ? value : null; +} + +function findOutsideProjectReference(command: string, projectRoot: string): string | null { + for (const candidatePath of extractAbsolutePaths(command)) { + if (isOutsideProject(candidatePath, projectRoot)) { + return candidatePath; + } + } + + if (referencesParentDirectory(command)) { + return ".."; + } + + return null; +} + +function referencesParentDirectory(command: string): boolean { + return /(^|[\s"'=])\.\.(?:[\\/]|$)/.test(command); +} diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index 558271b..8c18c19 100644 --- a/src/tools/web-search-handler.ts +++ b/src/tools/web-search-handler.ts @@ -2,6 +2,7 @@ import { randomUUID } from "crypto"; import { spawn } from "child_process"; import type OpenAI from "openai"; import type { CreateOpenAIClient, ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { evaluateGenericToolSafety, type PermissionContext, type SafetyDecision } from "./safety-hooks"; const MAX_OUTPUT_CHARS = 30000; const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; @@ -30,6 +31,10 @@ type LLMClientContext = { machineId?: string; }; +export function canExecuteWebSearchTool(args: Record, context: PermissionContext): SafetyDecision { + return evaluateGenericToolSafety("WebSearch", args, context); +} + export async function handleWebSearchTool( args: Record, context: ToolExecutionContext diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index 4524e21..37704ba 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import { z } from "zod"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { evaluateWriteToolSafety, type PermissionContext, type SafetyDecision } from "./safety-hooks"; import { buildDiffPreview, ensureParentDirectory, @@ -25,6 +26,10 @@ type WriteRepairMetadata = { repair_kind: "json-stringify-content"; } | null; +export function canExecuteWriteTool(args: Record, context: PermissionContext): SafetyDecision { + return evaluateWriteToolSafety(args, context); +} + export async function handleWriteTool( args: Record, context: ToolExecutionContext