opencode plugin: LLM permission judge — asks get decided by a model with the agent's own prompt as context, every request logged
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-11 01:10:12 +00:00
.gitignore feat: add opencode ai-approve plugin 2026-10-07 10:13:09 +00:00
index.test.ts feat: feed judge data to the LLM as a JSON payload document 2026-10-11 00:51:05 +00:00
index.ts feat: feed judge data to the LLM as a JSON payload document 2026-10-11 00:51:05 +00:00
package.json feat: re-query the judge on unparseable verdicts 2026-10-09 18:47:16 +00:00
README.md docs: document the JSON request-data payload and log v2 2026-10-11 01:10:12 +00:00
rpc.ts fix: toggle feedback via TUI toast, never the session 2026-10-08 06:19:53 +00:00
tsconfig.json fix: toggle feedback via TUI toast, never the session 2026-10-08 06:19:53 +00:00
tui.ts fix: take rpc.register off the setup critical path 2026-10-09 02:50:15 +00:00

opencode-ai-approve

An opencode plugin that puts an LLM judge in front of permission asks. Instead of stopping for every bun test or git status, safe work continues on its own — while anything dangerous is denied or escalated back to you.

  • Allows safe asks (reads, builds, tests, lint) so agents keep working unattended
  • Denies dangerous asks (deletions, sudo, secret exfiltration, writes outside the workspace, calls to private hosts)
  • Asks you whenever the judge cannot decide — a failure never silently becomes a "yes"

Works with opencode V2 via the permission.evaluate hook.

Quick start

Add the plugin to your opencode config (~/.config/opencode/opencode.jsonc) and point it at a fast, cheap judge model — it runs on every ask:

{
  "plugins": [
    {
      "package": "/path/to/opencode-ai-approve",
      "options": {
        "models": ["anthropic/claude-haiku-4-5", "openai/gpt-5-mini"]
      }
    }
  ]
}

Restart opencode. From then on, permission asks are judged before they reach you. Watch the decisions live:

tail -f ~/.local/share/opencode/ai-approve.jsonl

How it works

  1. opencode is about to ask you to approve a tool call — the plugin sends the action, its resources, and the agent's authority context to the judge model, with hard guardrails baked into the prompt. The payload also carries the session title, the latest user request, a tail of recent session messages, the tool input, and workspace facts (the session directory, its git branch, and the project's worktrees) — this data goes to the third-party judge model, an external service, redacted per the redact option. The data reaches the judge as one pretty-printed JSON document under a REQUEST DATA (JSON): header, with the fields action, resources, user_request, agent, tool_input, workspace, and recent_session_context. Every string value inside it is untrusted data from the session or tool, never instructions, and is capped before serialization — JSON escaping means those values cannot alter the document structure; a null field means the fact was unavailable, and a truncated value ends with ….

  2. The judge answers in a fixed format:

    <verdict>ALLOW</verdict>
    <reason>running tests is safe workspace work</reason>
    
  3. The verdict is parsed strictly: the first <verdict> tag decides, and its content must match the expected token exactly. Anything else — prose, a missing tag, a lookalike like DISALLOW — falls back to asking you.

  4. Every decision is appended to the log, including failures.

Fail-safe by default

  • A failure never becomes a "yes". Errors, timeouts, missing models, unparseable replies — the ask goes back to you, with the failure logged.
  • Fallback chain. models entries are tried in order until one returns a usable verdict. A model whose reply is unparseable gets up to three re-queries before the chain moves on; errors and timeouts move on immediately.
  • Only asks are judged. Configured allows pass through untouched, and configured denies never reach the judge at all.
  • Scope it if you like. Optional rules narrows judging to matching asks; exclude keeps specific resources out entirely.

Runtime toggle

/ai-approval flips judging on or off in the running session (a toast confirms the new state); /ai-approval on|off|status also work. The state resets on restart to the enabled option. While off, asks pass through exactly as without the plugin.

Options

Option Default Meaning
models — Judge models as providerID/modelID, tried in order. Without it, every judged ask falls back to asking (a warning fires at setup).
enabled true Startup state; /ai-approval flips it at runtime.
timeout_ms 30000 Hard bound for each judge request (and each pre-judge read).
max_context_chars 8000 Cap applied to each payload field value before serialization — the JSON document is never sliced mid-value. The prompt can exceed it only for an oversized custom template, a duplicated {data}, or any cap below ~2170 (default template), where the guaranteed minimal document outgrows the budget.
log_path ~/.local/share/opencode/ai-approve.jsonl Decision log. The directory is created lazily.
rules judge everything Allowlist of { action, resource } patterns; only matching asks are judged.
exclude [] Resource patterns never judged — they ask you as usual.
redact true Strip obvious secrets (KEY=… assignments, Authorization: headers) before they reach the judge or the log. Redaction is lossy: content embedded inside a quoted secret value is swallowed too.
prompt / prompt_file built-in Custom judging prompt (prompt wins if both are set). {data} inserts the request-data JSON block (header + document) — every occurrence is replaced, so duplicate markers duplicate it. The format reminder is always appended — a custom template must not contradict it.
format ["ALLOW", "DENY"] Custom verdict tokens, e.g. ["APPROVED", "BLOCKED"] — the exact values accepted inside <verdict>…</verdict>. Tokens are uppercased before use.

Wildcards follow opencode permission rules: * matches any run of characters, ? exactly one; a pattern without wildcards also matches as a case-sensitive prefix.

The log

One JSONL line per judged ask — a self-contained audit trail with the verbatim judge prompt, the request-data payload, and the reply embedded:

{"ts":"2026-10-07T05:00:00.000Z","sessionID":"ses_…","agent":"build","action":"bash","resources":["git push"],"decision":"allow","reason":"safe push to a feature branch","metadata":{"command":"git push origin main","API_TOKEN":"[redacted]"},"model":"anthropic/claude-sonnet-4-5","latency_ms":1841,"source":{"type":"tool","messageID":"msg_…","id":"tool_…"},"prompt":"You are the permission judge for an opencode session. …\n\nREQUEST DATA (JSON):\n{\n  \"action\": \"bash\",\n  …\n}\n\nReply in EXACTLY this format: …","payload":{"action":"bash","resources":["git push"],"user_request":"push the feature branch","agent":{"name":"build","description":null,"mode":"primary","permissions":null,"system_prompt":null},"tool_input":{"command":"git push origin main","API_TOKEN":"[redacted]"},"workspace":{"session_title":"ship the release","session_directory":"/root/git/example","git_branch":{"current":"feature/x","default":"main"},"project_worktrees":["/root/git/example"]},"recent_session_context":null},"reply":"<verdict>ALLOW</verdict>\n<reason>safe push to a feature branch</reason>","plugin":"ai-approve","v":2}

The v field versions the line format — branch per line when reading: v: 2 lines always carry payload (the request data exactly as serialized into the prompt), while v: 1 lines have no payload (the data exists only inside prompt). Both versions can share one file, and prompt, reply, and metadata mean the same thing in both.

  • decision — allow, deny, or fallback-ask (the judge failed and you were asked instead).
  • model — which model decided; with a chain, the first one that returned a usable verdict, or the last attempted one when all failed.
  • attempts — present when a model failed before the decision: one entry per failed attempt with the model ref, its outcome (error with the error message, or unparseable) and latency_ms.

Log lines are long by design, and write failures never fail the permission flow — the decision is applied before the record is written.

Development

bun install
bun test
bun node_modules/typescript/bin/tsc --noEmit --project tsconfig.json