Repository navigation
Description
Feature hasn't been suggested before.
- I have verified this feature I'm about to request hasn't been suggested before.
Describe the enhancement you want to request
Context
I'm building a TDD state machine as an OpenCode custom tool (.opencode/tools/tdd-checkpoint.ts). The tool tracks Red/Green/Refactor phases and validates that an agent follows the correct operation sequence. During the RED phase, the agent should only be writing test files — not production code.
The problem: the agent eventually stops calling the checkpoint tool and writes production files directly. Advisory feedback from a custom tool isn't sufficient to enforce TDD discipline because the model treats the tool as optional and reaches for Edit/Write directly.
What I need is dynamic, state-dependent permission scoping — the ability for a plugin to deny edit permission on production files when a TDD state machine is in a testing phase, and lift that restriction when the state transitions to an implementation phase.
Relationship to existing issues
This depends on wiring the permission.ask plugin hook, which is tracked by:
permission.askplugin hook is defined but not triggered #7006 —permission.askplugin hook is defined but not triggered (bug)- [FEATURE]: Wire the permission.ask plugin hook #19469 — Feature request to wire the hook
- PR fix(opencode): add permission.ask plugin hook back #19453 — Open PR that wires the hook and adds a
messagefield to the hook output
This issue is distinct from all three. Those issues are about wiring the permission.ask hook generically so plugins can intercept permission checks. This issue is about two things that build on top of that foundation:
-
A use case that requires runtime state to influence permission decisions — the hook alone enables static policy (e.g., "always deny bash commands matching X"). Dynamic scoping means the same file path is allowed or denied depending on state that changes mid-session.
-
A structured
reasononDeniedErrorso the agent understands why it was denied and can self-correct. TodayDeniedErrordumps matching rules as JSON, which is opaque. PR fix(opencode): add permission.ask plugin hook back #19453 partially addresses this by threading amessagefrom the hook output through as aCorrectedError, butDeniedErroritself still doesn't carry a human-readable reason field. For dynamic scoping to work, the denial message needs to communicate the current state and expected next action — e.g., "Cannot edit production filesrc/services/foo.tsduring RED phase. Expected next: commit the failing test, then write-impl."
Proposed behavior
-
Prerequisite: Wire the
permission.askhook (perpermission.askplugin hook is defined but not triggered #7006 / [FEATURE]: Wire the permission.ask plugin hook #19469 / PR fix(opencode): add permission.ask plugin hook back #19453). -
Extend the hook output type to include an optional
reasonfield:"permission.ask"?: ( input: Permission, output: { status: "ask" | "deny" | "allow"; reason?: string } ) => Promise<void>
-
Thread
reasonthroughDeniedErrorso the model sees a useful message, not a rule dump:export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", { ruleset: Schema.Any, reason: Schema.optionalWith(Schema.String, { default: () => undefined }), }) { override get message() { if (this.reason) return this.reason 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)}` } }
-
Plugin-side usage (sketch):
// In a plugin — not a custom tool — because plugins have access to hooks const tddState = { phase: "START" } // shared with TDD checkpoint tool export default async () => ({ "permission.ask": async (input, output) => { if (input.permission !== "edit") return const testing = ["START", "RED", "RED-COMMITTED"].includes(tddState.phase) if (!testing) return const isProduction = input.patterns.some(p => !p.includes(".test.") && !p.includes(".spec.") && !p.startsWith("test/") ) if (!isProduction) return output.status = "deny" output.reason = `Cannot edit production file during ${tddState.phase} phase. ` + `Only test files, config files, and stubs are allowed. ` + `Complete the test-first cycle before writing implementation.` }, })
Why this matters beyond TDD
Dynamic permission scoping is a general capability. Other use cases:
- Review gates: deny edits to files under review until a reviewer subagent approves
- Progressive disclosure: unlock write access to new directories as a multi-phase plan progresses
- Safety boundaries: deny destructive operations (file deletion, schema migrations) unless a preceding validation step has passed
All of these share the pattern: permission decisions that depend on runtime state, not static configuration.
Activity
gotgenes commentedon Apr 15, 2026
Implementation findings
We attempted an implementation (PR #22619, now closed) and discovered a module initialization constraint that increases scope.
The problem: circular module initialization
Permission.defaultLayeris a const that evaluates at module load time. The naive approach —import { Plugin } from "@/plugin"andlayer.pipe(Layer.provide(Plugin.defaultLayer))— fails at runtime because module evaluation order isn't guaranteed. When the SDK build (bun run --conditions=browser ./src/index.ts generate) loads the module graph,Plugin.defaultLayercan beundefinedwhenPermission's module body runs.This doesn't surface in tests because test files use dynamic
await import()forPlugin, which defers evaluation past the initialization window.What worked
reasontoDeniedError— trivial, no dependency issuesreasonto thepermission.askhook output type in the plugin SDK — trivialplugin.trigger("permission.ask", ...)insidePermission.ask— works at runtime (theLayer.effectgenerator is lazy)What didn't work
Permission.defaultLayer = layer.pipe(Layer.provide(Plugin.defaultLayer))— fails at module load time due to initialization orderLayer.lazy(() => ...)— doesn't exist in this Effect versionEffect.serviceOptionwithContext.GenericTag—GenericTagdoesn't exist in this Effect version eitherOptions that need design input
Remove Plugin from
defaultLayer, push composition to callers.Permission.defaultLayerstays aslayer.pipe(Layer.provide(Bus.layer))and leaksPlugin.Serviceas an unsatisfied requirement. Every call site (app-runtime.ts,llm.ts,prompt.ts,processor.ts) would need to explicitly provide it. This is a breaking change todefaultLayer's contract.Optional plugin access at runtime. Use the Effect version's equivalent of optional service lookup inside the
layereffect soPlugin.Serviceisn't a hard requirement ofdefaultLayer. The hook fires when Plugin is available, is a no-op otherwise.Restructure the module relationship. Extract the hook trigger into a separate integration layer (e.g.,
Permission.withPlugin) that callers opt into, keepingPermission.defaultLayerunchanged.Impact assessment
The
DeniedError.reasonand hook type changes are straightforward and could land independently. The wiring ofplugin.triggerintoPermission.askrequires a design decision about module/layer architecture — it's a question for the core team about how they wantPermissionandPluginto relate.This is worth noting in the context of the other open PRs (#19453, #19470, #20634) — they likely face the same initialization order issue.
github-actions commentedon Jul 3, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.