- TypeScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .gitignore | ||
| index.test.ts | ||
| index.ts | ||
| package.json | ||
| README.md | ||
| rpc.ts | ||
| tsconfig.json | ||
| tui.ts | ||
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
-
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
redactoption. The data reaches the judge as one pretty-printed JSON document under aREQUEST DATA (JSON):header, with the fieldsaction,resources,user_request,agent,tool_input,workspace, andrecent_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…. -
The judge answers in a fixed format:
<verdict>ALLOW</verdict> <reason>running tests is safe workspace work</reason> -
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 likeDISALLOW— falls back to asking you. -
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.
modelsentries 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
rulesnarrows judging to matching asks;excludekeeps 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, orfallback-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, itsoutcome(errorwith the error message, orunparseable) andlatency_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