diff --git a/packages/opencode/src/permission/diagnostic.ts b/packages/opencode/src/permission/diagnostic.ts new file mode 100644 index 00000000..5bf7b2b9 --- /dev/null +++ b/packages/opencode/src/permission/diagnostic.ts @@ -0,0 +1,157 @@ +import os from "os" +import type { Permission } from "./index" + +export type DenialSuggestion = { + platform?: NodeJS.Platform + applicability: "retryable" | "ask_user" + text: string +} + +export type AdditionalBlockedCommand = { + blockedCommand: string + matchedRule: Permission.Rule +} + +export type DenialDiagnostic = { + code: "permission.bash.permanent_delete_blocked" | "permission.bash.denied" + category: "permanent_delete" | "generic" + blockedCommand: string + matchedRule: Permission.Rule + reason: string + suggestions: DenialSuggestion[] + additionalBlockedCommands?: AdditionalBlockedCommand[] +} + +const PERMANENT_DELETE_RULE_PATTERNS = [ + /^rm(?:\s|$)/, + /^rmdir(?:\s|$)/, + /^unlink(?:\s|$)/, + /^find(?:\s|$).*(?:^|\s)-delete(?:\*|\s|$)/, + /^Remove-Item(?:\s|$)/i, + /^del(?:\s|$)/i, + /^erase(?:\s|$)/i, + /^rd(?:\s|$)/i, +] + +export function isPermanentDeleteRule(rule: Permission.Rule) { + const pattern = rule.pattern.trim() + return PERMANENT_DELETE_RULE_PATTERNS.some((matcher) => matcher.test(pattern)) +} + +export function fromDeniedRule(input: { + permission: string + blockedCommand: string + matchedRule: Permission.Rule + platform?: NodeJS.Platform + additionalBlockedCommands?: AdditionalBlockedCommand[] +}): DenialDiagnostic | undefined { + if (input.permission !== "bash") return undefined + + if (isPermanentDeleteRule(input.matchedRule)) { + return withAdditional(input, { + code: "permission.bash.permanent_delete_blocked", + category: "permanent_delete", + blockedCommand: input.blockedCommand, + matchedRule: input.matchedRule, + reason: "This command permanently deletes files and is not reversible.", + suggestions: permanentDeleteSuggestions(input.platform ?? os.platform()), + }) + } + + return withAdditional(input, { + code: "permission.bash.denied", + category: "generic", + blockedCommand: input.blockedCommand, + matchedRule: input.matchedRule, + reason: "This command is blocked by PawWork's safety policy.", + suggestions: [ + { + applicability: "ask_user", + text: "Do not retry with another destructive command. Explain what you were trying to do and ask the user before proceeding.", + }, + ], + }) +} + +function withAdditional( + input: { additionalBlockedCommands?: AdditionalBlockedCommand[] }, + diagnostic: DenialDiagnostic, +): DenialDiagnostic { + if (!input.additionalBlockedCommands?.length) return diagnostic + return { + ...diagnostic, + additionalBlockedCommands: input.additionalBlockedCommands, + } +} + +export function permanentDeleteSuggestions(platform: NodeJS.Platform): DenialSuggestion[] { + if (platform === "darwin") { + return [ + { + platform, + applicability: "retryable", + text: "Use a reversible trash command instead. On macOS, run `command -v trash`, then use `trash ` if available.", + }, + { + platform, + applicability: "ask_user", + text: "If no reversible trash command is available, ask the user before changing system state or deleting permanently.", + }, + ] + } + + if (platform === "linux") { + return [ + { + platform, + applicability: "retryable", + text: "Use a reversible trash command instead. On Linux, run `command -v gio`, then use `gio trash ` if available.", + }, + { + platform, + applicability: "retryable", + text: "If `gio` is unavailable, check `command -v trash-put` and use `trash-put ` if available.", + }, + { + platform, + applicability: "ask_user", + text: "If no reversible trash command is available, ask the user before changing system state or deleting permanently.", + }, + ] + } + + if (platform === "win32") { + return [ + { + platform, + applicability: "ask_user", + text: "Do not use `Remove-Item` as a reversible replacement. PowerShell has no simple built-in recycle cmdlet; ask the user or use an app-level reversible delete path. Windows Recycle Bin support exists through .NET `Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile` or `DeleteDirectory` with `RecycleOption.SendToRecycleBin`, but that is not a simple shell replacement.", + }, + ] + } + + return [ + { + applicability: "ask_user", + text: "No reversible trash command is known for this platform. Ask the user before changing system state or deleting permanently.", + }, + ] +} + +export function render(diagnostic: DenialDiagnostic): string { + const lines = [ + `Command blocked: ${diagnostic.blockedCommand}`, + `Reason: ${diagnostic.reason}`, + `Matched rule: ${diagnostic.matchedRule.permission} "${diagnostic.matchedRule.pattern}" ${diagnostic.matchedRule.action}`, + ] + + if (diagnostic.additionalBlockedCommands?.length) { + const commands = diagnostic.additionalBlockedCommands + .map((command) => command.blockedCommand) + .join(", ") + lines.push(`Additional blocked commands (${diagnostic.additionalBlockedCommands.length}): ${commands}`) + } + + lines.push("", "Recommended next step:", ...diagnostic.suggestions.map((suggestion) => suggestion.text)) + return lines.join("\n") +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index a8da1cdb..ce26e656 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" import z from "zod" +import { fromDeniedRule, isPermanentDeleteRule, render, type DenialDiagnostic } from "./diagnostic" import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" @@ -96,8 +97,10 @@ export namespace Permission { export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { ruleset: Schema.Any, + diagnostic: Schema.optional(Schema.Any), }) { override get message() { + if (this.diagnostic) return render(this.diagnostic as DenialDiagnostic) return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` } } @@ -168,19 +171,42 @@ export namespace Permission { const { approved, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input let needsAsk = false + const denied: Array<{ pattern: string; rule: Rule }> = [] for (const pattern of request.patterns) { const rule = evaluate(request.permission, pattern, ruleset, approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { - return yield* new DeniedError({ - ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), - }) + denied.push({ pattern, rule }) + continue } if (rule.action === "allow") continue needsAsk = true } + if (denied.length > 0) { + const primaryIndex = + request.permission === "bash" ? denied.findIndex((item) => isPermanentDeleteRule(item.rule)) : -1 + const primary = primaryIndex >= 0 ? denied[primaryIndex] : denied[0] + const rest = denied.filter((_, index) => index !== (primaryIndex >= 0 ? primaryIndex : 0)) + const diagnostic = primary + ? fromDeniedRule({ + permission: request.permission, + blockedCommand: primary.pattern, + matchedRule: primary.rule, + additionalBlockedCommands: rest.map((item) => ({ + blockedCommand: item.pattern, + matchedRule: item.rule, + })), + }) + : undefined + + return yield* new DeniedError({ + ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + ...(diagnostic ? { diagnostic } : {}), + }) + } + if (!needsAsk) return const id = request.id ?? PermissionID.ascending() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index c99b16d6..28604589 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -2,6 +2,7 @@ import { afterEach, test, expect } from "bun:test" import os from "os" import { Bus } from "../../src/bus" import { Permission } from "../../src/permission" +import { fromDeniedRule, isPermanentDeleteRule, permanentDeleteSuggestions } from "../../src/permission/diagnostic" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -587,6 +588,199 @@ test("ask - throws RejectedError when action is deny", async () => { }) }) +const bashDeleteCases = [ + { command: "rm file.txt", rule: "rm *" }, + { command: "rm -rf folder", rule: "rm -rf *" }, + { command: "rmdir folder", rule: "rmdir *" }, + { command: "unlink file.txt", rule: "unlink *" }, + { command: "find . -delete", rule: "find * -delete*" }, + { command: "Remove-Item file.txt", rule: "Remove-Item *" }, + { command: "Remove-Item -Recurse folder", rule: "Remove-Item -Recurse *" }, + { command: "del file.txt", rule: "del *" }, + { command: "erase file.txt", rule: "erase *" }, + { command: "rd folder", rule: "rd *" }, +] + +test.each(bashDeleteCases)( + "ask - denied bash permanent delete includes structured diagnostic for $command", + async ({ command, rule }) => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const err = await Permission.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: [command], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: rule, action: "deny" }, + ], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + if (!(err instanceof Permission.DeniedError)) return + + expect(err.ruleset).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: rule, action: "deny" }, + ]) + expect(err.diagnostic).toMatchObject({ + code: "permission.bash.permanent_delete_blocked", + category: "permanent_delete", + blockedCommand: command, + matchedRule: { permission: "bash", pattern: rule, action: "deny" }, + reason: "This command permanently deletes files and is not reversible.", + }) + expect(err.diagnostic?.suggestions.length).toBeGreaterThan(0) + expect(err.message).toContain(`Command blocked: ${command}`) + expect(err.message).toContain(`Matched rule: bash "${rule}" deny`) + expect(err.message).toContain("Recommended next step:") + expect(err.message).not.toContain("Here are some of the relevant rules") + }, + }) + }, +) + +test("isPermanentDeleteRule - excludes allowed open-mode command families", () => { + expect(isPermanentDeleteRule({ permission: "bash", pattern: "chmod *", action: "deny" })).toBe(false) + expect(isPermanentDeleteRule({ permission: "bash", pattern: "kill *", action: "deny" })).toBe(false) +}) + +const bashGenericCases = [ + { command: "dd if=/dev/zero of=disk.img", rule: "dd *" }, + { command: "mkfs.ext4 /dev/sdb1", rule: "mkfs*" }, +] + +test.each(bashGenericCases)( + "ask - denied non-delete bash command includes generic diagnostic for $command", + async ({ command, rule }) => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const err = await Permission.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: [command], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: rule, action: "deny" }, + ], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + if (!(err instanceof Permission.DeniedError)) return + + expect(err.diagnostic).toEqual({ + code: "permission.bash.denied", + category: "generic", + blockedCommand: command, + matchedRule: { permission: "bash", pattern: rule, action: "deny" }, + reason: "This command is blocked by PawWork's safety policy.", + suggestions: [ + { + applicability: "ask_user", + text: "Do not retry with another destructive command. Explain what you were trying to do and ask the user before proceeding.", + }, + ], + }) + expect(err.message).toContain(`Command blocked: ${command}`) + expect(err.message).toContain(`Matched rule: bash "${rule}" deny`) + expect(err.message).not.toContain("reversible trash command") + expect(err.message).not.toContain("Here are some of the relevant rules") + }, + }) + }, +) + +test("ask - non-bash denied permissions keep legacy message without bash diagnostic", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const err = await Permission.ask({ + sessionID: SessionID.make("session_test"), + permission: "edit", + patterns: ["secret.txt"], + metadata: {}, + always: [], + ruleset: [{ permission: "edit", pattern: "secret.txt", action: "deny" }], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + if (!(err instanceof Permission.DeniedError)) return + + expect(err.diagnostic).toBeUndefined() + expect(err.message).toContain("Here are some of the relevant rules") + }, + }) +}) + +test("permission denial diagnostic suggestions are platform-specific", () => { + expect(permanentDeleteSuggestions("darwin")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ applicability: "retryable", text: expect.stringContaining("command -v trash") }), + ]), + ) + expect(permanentDeleteSuggestions("linux")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ applicability: "retryable", text: expect.stringContaining("command -v gio") }), + expect.objectContaining({ applicability: "retryable", text: expect.stringContaining("trash-put") }), + ]), + ) + + const win32 = permanentDeleteSuggestions("win32") + expect(win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + applicability: "ask_user", + text: expect.stringContaining("PowerShell has no simple built-in recycle cmdlet"), + }), + ]), + ) + expect(win32.map((item) => item.text).join("\n")).not.toContain("Use `Remove-Item`") + + expect(permanentDeleteSuggestions("freebsd")).toEqual([ + { + applicability: "ask_user", + text: "No reversible trash command is known for this platform. Ask the user before changing system state or deleting permanently.", + }, + ]) +}) + +test("fromDeniedRule accepts explicit platform for deterministic tests", () => { + const diagnostic = fromDeniedRule({ + permission: "bash", + blockedCommand: "rm file.txt", + matchedRule: { permission: "bash", pattern: "rm *", action: "deny" }, + platform: "linux", + }) + + expect(diagnostic?.suggestions.map((item) => item.text).join("\n")).toContain("gio trash") +}) + +function expectPermanentDeleteNextStep(message: string) { + if (os.platform() === "darwin" || os.platform() === "linux") { + expect(message).toContain("trash ") + return + } + expect(message).toContain("Recommended next step:") +} + test("ask - returns pending promise when action is ask", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -1121,7 +1315,7 @@ test("reply - does nothing for unknown requestID", async () => { }) }) -test("ask - checks all patterns and stops on first deny", async () => { +test("ask - denies when any pattern matches a deny rule", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -1143,6 +1337,150 @@ test("ask - checks all patterns and stops on first deny", async () => { }) }) +test("ask - denial diagnostic uses the actual denied pattern in compound commands", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const err = await Permission.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["echo ok", "rm file.txt"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + if (!(err instanceof Permission.DeniedError)) return + + expect(err.diagnostic?.blockedCommand).toBe("rm file.txt") + expect(err.message).toContain("Command blocked: rm file.txt") + expect(err.message).not.toContain("Command blocked: echo ok") + }, + }) +}) + +test("ask - denial diagnostic keeps flags and multiple operands without rewriting command", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const err = await Permission.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["rm -rf dir other"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + if (!(err instanceof Permission.DeniedError)) return + + expect(err.diagnostic?.blockedCommand).toBe("rm -rf dir other") + expect(err.message).toContain("Command blocked: rm -rf dir other") + expectPermanentDeleteNextStep(err.message) + expect(err.message).not.toContain("trash dir other") + }, + }) +}) + +test("ask - denial diagnostic summarizes additional blocked commands", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const err = await Permission.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["rm a", "rmdir b"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + { permission: "bash", pattern: "rmdir *", action: "deny" }, + ], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + if (!(err instanceof Permission.DeniedError)) return + + expect(err.diagnostic?.blockedCommand).toBe("rm a") + expect(err.diagnostic?.additionalBlockedCommands).toEqual([ + { blockedCommand: "rmdir b", matchedRule: { permission: "bash", pattern: "rmdir *", action: "deny" } }, + ]) + expect(err.message).toContain("Command blocked: rm a") + expect(err.message).toContain("Additional blocked commands (1): rmdir b") + }, + }) +}) + +test("ask - permanent delete denial is primary when a generic denial comes first", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const err = await Permission.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["dd if=/dev/zero of=disk.img", "rm file.txt"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "dd *", action: "deny" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + if (!(err instanceof Permission.DeniedError)) return + + expect(err.diagnostic?.category).toBe("permanent_delete") + expect(err.diagnostic?.blockedCommand).toBe("rm file.txt") + expect(err.diagnostic?.additionalBlockedCommands).toEqual([ + { + blockedCommand: "dd if=/dev/zero of=disk.img", + matchedRule: { permission: "bash", pattern: "dd *", action: "deny" }, + }, + ]) + expect(err.message).toContain("Command blocked: rm file.txt") + expectPermanentDeleteNextStep(err.message) + expect(err.message).toContain("Additional blocked commands (1): dd if=/dev/zero of=disk.img") + }, + }) +}) + +test("ask - denied error remains compatible without diagnostic", () => { + const err = new Permission.DeniedError({ + ruleset: [{ permission: "bash", pattern: "rm *", action: "deny" }], + }) + + expect(err.ruleset).toEqual([{ permission: "bash", pattern: "rm *", action: "deny" }]) + expect(err.diagnostic).toBeUndefined() + expect(err.message).toContain("Here are some of the relevant rules") +}) + test("ask - allows all patterns when all match allow rules", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 6e735645..97c45792 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -5,6 +5,9 @@ import { ProviderTransform, type Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema" import { Question } from "../../src/question" +import { Permission } from "../../src/permission" +import { fromDeniedRule } from "../../src/permission/diagnostic" +import { errorMessage } from "../../src/util/error" const sessionID = SessionID.make("session") const providerID = ProviderID.make("test") @@ -632,6 +635,72 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("converts permission denial diagnostic into model-facing error text", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + const diagnostic = fromDeniedRule({ + permission: "bash", + blockedCommand: "rm file.txt", + matchedRule: { permission: "bash", pattern: "rm *", action: "deny" }, + platform: "darwin", + }) + expect(diagnostic).toBeDefined() + if (!diagnostic) return + + const denied = new Permission.DeniedError({ + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + diagnostic, + }) + const rendered = errorMessage(denied) + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "remove the file", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "error", + input: { cmd: "rm file.txt" }, + error: rendered, + time: { start: 0, end: 1 }, + metadata: {}, + }, + }, + ] as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model) + const toolResult = messages[2]?.content[0] + + expect(toolResult).toEqual({ + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "error-text", value: rendered }, + }) + expect(rendered).toContain("Command blocked: rm file.txt") + expect(rendered).toContain('Matched rule: bash "rm *" deny') + expect(rendered).not.toContain("Here are some of the relevant rules") + }) + test("forwards partial bash output for aborted tool calls", async () => { const userID = "m-user" const assistantID = "m-assistant"