Skip to content

[FEATURE]: Dynamic permission scoping via plugin state (permission.ask + structured DeniedError reason) #22558

Closed as not planned

Description

@gotgenes

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:

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:

  1. 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.

  2. A structured reason on DeniedError so the agent understands why it was denied and can self-correct. Today DeniedError dumps matching rules as JSON, which is opaque. PR fix(opencode): add permission.ask plugin hook back #19453 partially addresses this by threading a message from the hook output through as a CorrectedError, but DeniedError itself 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 file src/services/foo.ts during RED phase. Expected next: commit the failing test, then write-impl."

Proposed behavior

  1. Prerequisite: Wire the permission.ask hook (per permission.ask plugin 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).

  2. Extend the hook output type to include an optional reason field:

    "permission.ask"?: (
      input: Permission,
      output: { status: "ask" | "deny" | "allow"; reason?: string }
    ) => Promise<void>
  3. Thread reason through DeniedError so 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)}`
      }
    }
  4. 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

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 15, 2026
  2. gotgenes commented on Apr 15, 2026

    @gotgenes
    Author

    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.defaultLayer is a const that evaluates at module load time. The naive approach — import { Plugin } from "@/plugin" and layer.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.defaultLayer can be undefined when Permission's module body runs.

    This doesn't surface in tests because test files use dynamic await import() for Plugin, which defers evaluation past the initialization window.

    What worked

    • Adding reason to DeniedError — trivial, no dependency issues
    • Adding reason to the permission.ask hook output type in the plugin SDK — trivial
    • Calling plugin.trigger("permission.ask", ...) inside Permission.ask — works at runtime (the Layer.effect generator is lazy)

    What didn't work

    • Permission.defaultLayer = layer.pipe(Layer.provide(Plugin.defaultLayer)) — fails at module load time due to initialization order
    • Layer.lazy(() => ...) — doesn't exist in this Effect version
    • Effect.serviceOption with Context.GenericTag — GenericTag doesn't exist in this Effect version either

    Options that need design input

    1. Remove Plugin from defaultLayer, push composition to callers. Permission.defaultLayer stays as layer.pipe(Layer.provide(Bus.layer)) and leaks Plugin.Service as 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 to defaultLayer's contract.

    2. Optional plugin access at runtime. Use the Effect version's equivalent of optional service lookup inside the layer effect so Plugin.Service isn't a hard requirement of defaultLayer. The hook fires when Plugin is available, is a no-op otherwise.

    3. Restructure the module relationship. Extract the hook trigger into a separate integration layer (e.g., Permission.withPlugin) that callers opt into, keeping Permission.defaultLayer unchanged.

    Impact assessment

    The DeniedError.reason and hook type changes are straightforward and could land independently. The wiring of plugin.trigger into Permission.ask requires a design decision about module/layer architecture — it's a question for the core team about how they want Permission and Plugin to relate.

    This is worth noting in the context of the other open PRs (#19453, #19470, #20634) — they likely face the same initialization order issue.

  3. assigned and unassigned on Apr 26, 2026
  4. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  5. github-actions commented on Jul 3, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions