Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions packages/opencode/src/permission/diagnostic.ts
Original file line number Diff line number Diff line change
@@ -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 <path>` 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 <path>` if available.",
},
{
platform,
applicability: "retryable",
text: "If `gio` is unavailable, check `command -v trash-put` and use `trash-put <path>` 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}`)
}
Comment thread
Astro-Han marked this conversation as resolved.

lines.push("", "Recommended next step:", ...diagnostic.suggestions.map((suggestion) => suggestion.text))
return lines.join("\n")
}
32 changes: 29 additions & 3 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -96,8 +97,10 @@ export namespace Permission {

export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("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)}`
}
}
Expand Down Expand Up @@ -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))
Comment thread
Astro-Han marked this conversation as resolved.
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()
Expand Down
Loading
Loading