diff --git a/.changeset/strict-ask-plan-guards.md b/.changeset/strict-ask-plan-guards.md new file mode 100644 index 00000000000..9f886644a00 --- /dev/null +++ b/.changeset/strict-ask-plan-guards.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent Ask and Plan modes, including saved or allow-all approvals, from editing files before an explicit implementation step. diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index c5a4894d67d..9953321fc6c 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -2,25 +2,22 @@ import { Permission } from "@/permission" import { NamedError } from "@opencode-ai/shared/util/error" import { Glob } from "@opencode-ai/shared/util/glob" -import { Truncate } from "../../tool" +import * as Truncate from "../../tool/truncate" import { Config } from "../../config" import { Instance } from "../../project/instance" import { makeRuntime } from "@/effect/run-service" -import { Global } from "@/global" import { Telemetry } from "@kilocode/kilo-telemetry" import z from "zod" import path from "path" +import { Global } from "@/global" import PROMPT_DEBUG from "../../agent/prompt/debug.txt" import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt" import PROMPT_ASK from "../../agent/prompt/ask.txt" import PROMPT_EXPLORE from "../../agent/prompt/explore.txt" -// Safe bash commands that don't need user approval. -// Only commands that cannot execute arbitrary code or subprocesses. export const bash: Record = { "*": "ask", - // read-only / informational "cat *": "allow", "head *": "allow", "tail *": "allow", @@ -41,7 +38,6 @@ export const bash: Record = { "whoami *": "allow", "printenv *": "allow", "man *": "allow", - // text processing "grep *": "allow", "rg *": "allow", "ag *": "allow", @@ -50,26 +46,20 @@ export const bash: Record = { "cut *": "allow", "tr *": "allow", "jq *": "allow", - // file operations "touch *": "allow", "mkdir *": "allow", "cp *": "allow", "mv *": "allow", - // compilers (no script execution) "tsc *": "allow", "tsgo *": "allow", - // archive "tar *": "allow", "unzip *": "allow", "gzip *": "allow", "gunzip *": "allow", } -// Read-only bash commands for ask/plan agents. -// Unknown commands are DENIED (not "ask") because these agents must never modify the filesystem. export const readOnlyBash: Record = { "*": "deny", - // read-only / informational "cat *": "allow", "head *": "allow", "tail *": "allow", @@ -90,7 +80,6 @@ export const readOnlyBash: Record = { "whoami *": "allow", "printenv *": "allow", "man *": "allow", - // text processing (stdout only, no file modification) "grep *": "allow", "rg *": "allow", "ag *": "allow", @@ -99,7 +88,6 @@ export const readOnlyBash: Record = { "cut *": "allow", "tr *": "allow", "jq *": "allow", - // git — allowlist of read-only subcommands, deny everything else "git *": "deny", "git log *": "allow", "git show *": "allow", @@ -121,8 +109,86 @@ export const readOnlyBash: Record = { "git branch -a *": "allow", "git branch -r *": "allow", "git remote -v *": "allow", - // gh — require user approval since commands vary widely "gh *": "ask", + "*\n*": "deny", + "*<(*": "deny", + "*|*": "deny", + "*;*": "deny", + "*&&*": "deny", + "*&*": "deny", + "*$(*": "deny", + "*`*": "deny", + "*>*": "deny", + "* > *": "deny", + "*>>*": "deny", + "* >> *": "deny", + "*>|*": "deny", + "* >| *": "deny", + "sort -o *": "deny", + "sort * -o *": "deny", + "sort --output*": "deny", + "sort * --output*": "deny", +} + +function askGuard(mcp: Record = {}) { + return Permission.fromConfig({ + "*": "deny", + bash: readOnlyBash, + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + grep: "allow", + glob: "allow", + list: "allow", + question: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + codebase_search: "allow", + semantic_search: "allow", + external_directory: { + [Truncate.GLOB]: "allow", + }, + ...mcp, + }) +} + +function planGuard(mcp: Record = {}) { + return Permission.fromConfig({ + "*": "deny", + question: "allow", + suggest: "allow", + plan_exit: "allow", + bash: readOnlyBash, + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + grep: "allow", + glob: "allow", + list: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + codebase_search: "allow", + semantic_search: "allow", + external_directory: { + [Truncate.GLOB]: "allow", + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "deny", + [path.join(".kilo", "plans", "*.md")]: "allow", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + }, + ...mcp, + }) } // Generate per-server MCP wildcard rules that allow MCP tools with user approval. @@ -232,27 +298,12 @@ export function patchAgents( if (agents.plan) { agents.plan = { ...agents.plan, - description: "Plan mode. Only allows editing plan files; asks before editing anything else.", + description: "Plan mode. Can only edit plan files; all other filesystem mutations are denied.", permission: Permission.merge( defaults, - Permission.fromConfig({ - question: "allow", - suggest: "allow", // kilocode_change - plan_exit: "allow", - bash: readOnlyBash, - ...kilo.mcpRules, - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, - edit: { - "*": "ask", - [path.join(".kilo", "plans", "*.md")]: "allow", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", - }, - semantic_search: "allow", - }), user, + planGuard(kilo.mcpRules), + user.filter((r: Permission.Rule) => r.action === "deny"), ), } } @@ -355,29 +406,7 @@ export function patchAgents( permission: Permission.merge( defaults, user, // user before ask-specific so ask's deny+allowlist wins - Permission.fromConfig({ - "*": "deny", - bash: readOnlyBash, - read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", - }, - grep: "allow", - glob: "allow", - list: "allow", - question: "allow", - webfetch: "allow", - websearch: "allow", - codesearch: "allow", - codebase_search: "allow", - semantic_search: "allow", - external_directory: { - [Truncate.GLOB]: "allow", - }, - ...kilo.mcpRules, - }), + askGuard(kilo.mcpRules), user.filter((r: Permission.Rule) => r.action === "deny"), // re-apply user denies so explicit MCP blocks win over mcpRules ), mode: "primary", diff --git a/packages/opencode/src/kilocode/permission/drain.ts b/packages/opencode/src/kilocode/permission/drain.ts index f91df26b5a1..df35c90b4fc 100644 --- a/packages/opencode/src/kilocode/permission/drain.ts +++ b/packages/opencode/src/kilocode/permission/drain.ts @@ -6,6 +6,7 @@ import { ConfigProtection } from "@/kilocode/permission/config-paths" interface PendingEntry { info: Permission.Request ruleset: Permission.Ruleset + hardRuleset?: Permission.Ruleset deferred: Deferred.Deferred } @@ -25,9 +26,12 @@ export function drainCovered( if (id === exclude) continue // Never auto-resolve config file edit permissions if (ConfigProtection.isRequest(entry.info)) continue - const actions = entry.info.patterns.map((pattern: string) => - Permission.evaluate(entry.info.permission, pattern, entry.ruleset, approved), - ) + const actions = entry.info.patterns.map((pattern: string) => { + const rule = Permission.evaluate(entry.info.permission, pattern, entry.ruleset, approved) + const hard = entry.hardRuleset ? Permission.evaluate(entry.info.permission, pattern, entry.hardRuleset) : undefined + if (hard?.action === "deny") return hard + return rule + }) const denied = actions.some((r: Permission.Rule) => r.action === "deny") const allowed = !denied && actions.every((r: Permission.Rule) => r.action === "allow") if (!denied && !allowed) continue diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 0a4c7398c8f..d939e9c704d 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -9,6 +9,7 @@ import { Session } from "@/session" import { Flag } from "@/flag/flag" import { PlanFollowup } from "@/kilocode/plan-followup" import { KiloSession } from "@/kilocode/session" +import { Permission } from "@/permission" import { environmentDetails, type EditorContext } from "@/kilocode/editor-context" import { Identifier } from "@/id/id" import { Filesystem } from "@/util" @@ -16,6 +17,8 @@ import PROMPT_PLAN from "@/session/prompt/plan.txt" import CODE_SWITCH from "@/session/prompt/code-switch.txt" export namespace KiloSessionPrompt { + const modes = ["ask", "plan"] + /** * Determines whether the plan follow-up prompt should be shown. * Checks if the plan_exit tool was called in the last assistant turn. @@ -54,6 +57,20 @@ export namespace KiloSessionPrompt { return PlanFollowup.abort(sessionID) } + export function guardPermissions(input: { + agent: { name: string; permission: Permission.Ruleset } + session: Pick + }) { + const rules = input.session.permission ?? [] + if (!modes.includes(input.agent.name)) return rules + return Permission.merge(rules, input.agent.permission, rules.filter((rule) => rule.action === "deny")) + } + + export function hardPermissions(input: { agent: { name: string; permission: Permission.Ruleset } }) { + if (!modes.includes(input.agent.name)) return + return input.agent.permission + } + /** * Mutable cache for environment details, keyed by user message ID * so it recomputes when a new user message arrives. diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 63908bdcb22..6e1516b4691 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -118,6 +118,7 @@ export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(PermissionID), ruleset: Ruleset, + hardRuleset: Schema.optional(Ruleset), // kilocode_change }) .annotate({ identifier: "PermissionAskInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -157,6 +158,7 @@ export interface Interface { interface PendingEntry { info: Request ruleset: Ruleset // kilocode_change + hardRuleset?: Ruleset // kilocode_change deferred: Deferred.Deferred } @@ -171,6 +173,17 @@ export function evaluate(permission: string, pattern: string, ...rulesets: Rules return evalRule(permission, pattern, ...rulesets) } +// kilocode_change start +function veto(permission: string, pattern: string, ruleset?: Ruleset) { + if (!ruleset) return false + return evaluate(permission, pattern, ruleset).action === "deny" +} + +function subset(permission: string, ruleset: Ruleset) { + return ruleset.filter((rule) => Wildcard.match(permission, rule.permission)) +} +// kilocode_change end + export class Service extends Context.Service()("@opencode/Permission") {} export const layer = Layer.effect( @@ -203,7 +216,7 @@ export const layer = Layer.effect( const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { const { approved, pending } = yield* InstanceState.get(state) - const { ruleset, ...request } = input + const { ruleset, hardRuleset, ...request } = input // kilocode_change const s = yield* InstanceState.get(state) // kilocode_change const local = s.session[request.sessionID] ?? [] // kilocode_change let needsAsk = false @@ -215,9 +228,14 @@ export const layer = Layer.effect( for (const pattern of request.patterns) { const rule = evaluate(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules log.info("evaluated", { permission: request.permission, pattern, action: rule }) + // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials + if (veto(request.permission, pattern, hardRuleset)) { + return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) }) + } + // kilocode_change end if (rule.action === "deny") { return yield* new DeniedError({ - ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + ruleset: subset(request.permission, ruleset), // kilocode_change }) } // kilocode_change start — override "allow" to "ask" for config paths @@ -242,7 +260,7 @@ export const layer = Layer.effect( log.info("asking", { id, permission: info.permission, patterns: info.patterns }) const deferred = yield* Deferred.make() - pending.set(id, { info, ruleset, deferred }) // kilocode_change + pending.set(id, { info, ruleset, hardRuleset, deferred }) // kilocode_change yield* bus.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), @@ -300,9 +318,13 @@ export const layer = Layer.effect( for (const [id, item] of pending.entries()) { if (item.info.sessionID !== existing.info.sessionID) continue - const ok = item.info.patterns.every( - (pattern) => evaluate(item.info.permission, pattern, item.ruleset, approved).action === "allow", // kilocode_change — include original ruleset - ) + if (ConfigProtection.isRequest(item.info)) continue // kilocode_change + // kilocode_change start + const ok = item.info.patterns.every((pattern) => { + if (veto(item.info.permission, pattern, item.hardRuleset)) return false + return evaluate(item.info.permission, pattern, item.ruleset, approved).action === "allow" + }) + // kilocode_change end if (!ok) continue pending.delete(id) yield* bus.publish(Event.Replied, { @@ -390,7 +412,10 @@ export const layer = Layer.effect( if (input.requestID) { const entry = s.pending.get(PermissionID.make(input.requestID)) - if (entry && (!input.sessionID || entry.info.sessionID === input.sessionID)) { + const ok = entry + ? entry.info.patterns.every((pattern) => !veto(entry.info.permission, pattern, entry.hardRuleset)) + : false // kilocode_change + if (entry && ok && (!input.sessionID || entry.info.sessionID === input.sessionID)) { s.pending.delete(PermissionID.make(input.requestID)) yield* bus.publish(Event.Replied, { sessionID: entry.info.sessionID, @@ -404,6 +429,7 @@ export const layer = Layer.effect( for (const [id, entry] of s.pending) { if (input.sessionID && entry.info.sessionID !== input.sessionID) continue if (ConfigProtection.isRequest(entry.info)) continue + if (entry.info.patterns.some((pattern) => veto(entry.info.permission, pattern, entry.hardRuleset))) continue // kilocode_change s.pending.delete(id) yield* bus.publish(Event.Replied, { sessionID: entry.info.sessionID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ec6d6eaa580..4f096022cac 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -404,7 +404,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the ...req, sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), + // kilocode_change start - reapply Ask/Plan mode guards after session permissions + ruleset: Permission.merge( + input.agent.permission, + KiloSessionPrompt.guardPermissions({ agent: input.agent, session: input.session }), + ), + hardRuleset: KiloSessionPrompt.hardPermissions({ agent: input.agent }), + // kilocode_change end }) .pipe(Effect.orDie), }) @@ -629,8 +635,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the permission .ask({ ...req, + // kilocode_change start - reapply Ask/Plan subagent guards after session permissions sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), + ruleset: Permission.merge( + taskAgent.permission, + KiloSessionPrompt.guardPermissions({ agent: taskAgent, session }), + ), + hardRuleset: KiloSessionPrompt.hardPermissions({ agent: taskAgent }), + // kilocode_change end }) .pipe(Effect.orDie), }) @@ -1400,12 +1412,31 @@ NOTE: At any point in time through this workflow you should feel free to ask the const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, ) + // kilocode_change start - keep provider-executed tools from forcing a re-loop // Some providers return "stop" even when the assistant message contains tool calls. // Keep the loop running so tool results can be sent back to the model. // Skip provider-executed tool parts — those were fully handled within the // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop. const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false + // kilocode_change end + + // kilocode_change start - plan_exit is a hard stop before another model call + if ( + lastAssistant?.finish && + hasToolCalls && + lastAssistant.parentID === lastUser.id && + lastUser.id < lastAssistant.id && + KiloSessionPrompt.shouldAskPlanFollowup({ messages: msgs, abort: AbortSignal.any([]) }) + ) { + const action = yield* Effect.promise((signal) => + KiloSessionPrompt.askPlanFollowup({ sessionID, messages: msgs, abort: signal }), + ) + if (action === "continue") continue + yield* slog.info("exiting loop") + break + } + // kilocode_change end if ( lastAssistant?.finish && @@ -1579,11 +1610,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the ]) const system = [...env, ...(skills ? [skills] : []), ...instructions] const format = lastUser.format ?? { type: "text" as const } - if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) - const result = yield* handle.process({ + if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) // kilocode_change + const result = yield* handle.process({ // kilocode_change + // kilocode_change start - keep Ask/Plan tool filtering hardened against session allows user: lastUser, agent, - permission: session.permission, + permission: KiloSessionPrompt.guardPermissions({ agent, session }), + // kilocode_change end sessionID, parentSessionID: session.parentID, system, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index fe60caf7d0f..544d3e9ebf3 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -119,20 +119,36 @@ test("ask agent denies edit/write/bash even when user config adds a specific edi // kilocode_change end // kilocode_change start -test("plan agent asks before edits except .kilo/plans/* and .opencode/plans/*", async () => { +test("plan agent denies edits except .kilo/plans/* and .opencode/plans/*", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const plan = await load(tmp.path, (svc) => svc.get("plan")) expect(plan).toBeDefined() - // Wildcard requires permission - expect(evalPerm(plan, "edit")).toBe("ask") - // kilocode_change start - // .kilo/plans/ is the primary allowed path + expect(evalPerm(plan, "edit")).toBe("deny") + expect(Permission.evaluate("edit", "src/index.ts", plan!.permission).action).toBe("deny") + expect(Permission.evaluate("edit", ".kilo/plans/foo.md", plan!.permission).action).toBe("allow") + expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") + }, + }) +}) + +test("plan agent user config allows cannot re-enable non-plan edits", async () => { + await using tmp = await tmpdir({ + config: { + permission: { + edit: { "src/output.log": "allow" }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const plan = await load(tmp.path, (svc) => svc.get("plan")) + expect(plan).toBeDefined() + expect(Permission.evaluate("edit", "src/output.log", plan!.permission).action).toBe("deny") expect(Permission.evaluate("edit", ".kilo/plans/foo.md", plan!.permission).action).toBe("allow") - // kilocode_change end - // .opencode/plans/ is also allowed as backward compat fallback expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") }, }) diff --git a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts index eeb128eef56..a75d862d122 100644 --- a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts +++ b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts @@ -1,65 +1,6 @@ import { test, expect, describe } from "bun:test" import { Permission } from "../../src/permission" - -// Reconstruct the Ask agent's readOnlyBash allowlist (mirrors kilocode/agent/index.ts) -// Uses an allow-list approach for git: deny by default, allow specific read-only subcommands. -const readOnlyBash: Record = { - "*": "deny", - // read-only / informational - "cat *": "allow", - "head *": "allow", - "tail *": "allow", - "less *": "allow", - "ls *": "allow", - "tree *": "allow", - "pwd *": "allow", - "echo *": "allow", - "wc *": "allow", - "which *": "allow", - "type *": "allow", - "file *": "allow", - "diff *": "allow", - "du *": "allow", - "df *": "allow", - "date *": "allow", - "uname *": "allow", - "whoami *": "allow", - "printenv *": "allow", - "man *": "allow", - // text processing (stdout only, no file modification) - "grep *": "allow", - "rg *": "allow", - "ag *": "allow", - "sort *": "allow", - "uniq *": "allow", - "cut *": "allow", - "tr *": "allow", - "jq *": "allow", - // git — allowlist of read-only subcommands, deny everything else - "git *": "deny", - "git log *": "allow", - "git show *": "allow", - "git diff *": "allow", - "git status *": "allow", - "git blame *": "allow", - "git rev-parse *": "allow", - "git rev-list *": "allow", - "git ls-files *": "allow", - "git ls-tree *": "allow", - "git ls-remote *": "allow", - "git shortlog *": "allow", - "git describe *": "allow", - "git cat-file *": "allow", - "git name-rev *": "allow", - "git stash list *": "allow", - "git tag -l *": "allow", - "git branch --list *": "allow", - "git branch -a *": "allow", - "git branch -r *": "allow", - "git remote -v *": "allow", - // gh — require user approval since commands vary widely - "gh *": "ask", -} +import { readOnlyBash } from "../../src/kilocode/agent" /** Build the Ask agent ruleset without MCP servers */ function askRuleset() { @@ -166,6 +107,32 @@ describe("Ask agent bash permissions", () => { } }) + describe("denied output redirection and writer flags", () => { + const denied = [ + "echo hi > file", + "echo hi >> file", + "echo hi | tee file", + "echo hi; touch file", + "echo hi && touch file", + "echo $(touch file)", + "echo `touch file`", + "cat a > b", + "jq . a.json > b.json", + "sort names.txt -o names.txt", + "sort --output=names.txt names.txt", + "sort names.txt --output=names.txt", + "echo ok\ntouch ask-bypass.txt", + "cat <(touch ask-bypass.txt)", + ] + + for (const cmd of denied) { + test(`"${cmd}" → deny`, () => { + const result = Permission.evaluate("bash", cmd, ruleset) + expect(result.action).toBe("deny") + }) + } + }) + describe("denied git write commands", () => { const denied = [ "git commit -m 'test'", diff --git a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts index a972f5865fa..d1fafbbb36c 100644 --- a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts +++ b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts @@ -239,6 +239,76 @@ describe("saveAlwaysRules", () => { ), ) + it.live("saved always approval does not override hard deny ruleset", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const asking = yield* ask({ + id: PermissionID.make("permission_hard_deny_seed"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf seed"], + metadata: {}, + always: ["printf *"], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("permission_hard_deny_seed"), reply: "always" }) + yield* Fiber.join(asking) + + const exit = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["printf bypass > ask-saved-bypass.txt"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + hardRuleset: [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "printf *", action: "allow" }, + { permission: "bash", pattern: "*>*", action: "deny" }, + { permission: "bash", pattern: "* > *", action: "deny" }, + ], + }).pipe(Effect.exit) + expectFailure(exit, Permission.DeniedError) + }), + ), + ) + + it.live("saved always approval still works when hard ruleset does not deny", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const asking = yield* ask({ + id: PermissionID.make("permission_hard_ask_seed"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["gh issue list"], + metadata: {}, + always: ["gh *"], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("permission_hard_ask_seed"), reply: "always" }) + yield* Fiber.join(asking) + + const result = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["gh pr list"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + hardRuleset: [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "gh *", action: "ask" }, + ], + }) + expect(result).toBeUndefined() + }), + ), + ) + it.live("accepts hierarchy patterns from metadata.rules", () => withDir({ git: true }, () => Effect.gen(function* () {