opencode-permission-reviewer
Health Warn
- License — License: Apache-2.0
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 6 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Policy-aware AI permission reviewer for OpenCode. Evaluates tool and command requests, then allows once, denies with feedback, or escalates to the user.
OpenCode Permission Reviewer
[!NOTE]
This is an unofficial community plugin for OpenCode. It is not affiliated
with or endorsed by Anomaly.
A tool-free AI reviewer for every
askpermission. It reads the request,
your policy, and the session context, then allows once, denies with
feedback, or escalates to you — so safe actions don't wait for a keystroke,
and genuinely risky ones still get blocked or surfaced.
OpenCode pauses on every ask permission and waits for a keystroke — even
for safe, routine actions. This plugin adds a Codex-Guardian-style reviewer: a
dedicated, tool-free model session reads the pending request, bounded
transcript evidence, recovered user intent, and a tenant policy you control,
then allows once, denies with rationale, or escalates to you.
Critical risk is never allowed, and anything broken or uncertain
fails safe to manual review.
- Preserves your policy —
allowcontinues,denystays blocked; neither
ever reaches the reviewer. - Isolated, tool-free reviewer session — the reviewer runs in a scratch
directory outside your project (noAGENTS.md, project instructions, or
project MCP servers) with every tool denied through a wildcard session
permission rule, so it can neither call tools (MCP included) nor request
permissions recursively. - Read-only enrichment — bounded, sanitized SSH / local-script / Git
evidence for the reviewer; the filesystem is never modified. - Auditable — one JSONL record per review, with remote commands stored as
SHA-256, not plaintext. - Optional TUI overlay — shows review state and gets out of the way of your
native approval controls.
Policy adapted from OpenAI Codex Guardian
— the reviewer policy text in this project derives from Codex's auto-review
policy (Apache-2.0). The implementation is independent. SeeNOTICE.
Quickstart
Requirements
- Bun ≥ 1.3.0 (CI runs 1.3.0 and 1.3.5)
- OpenCode ≥ 1.18.11 and < 2 (tested with 1.18.25)
gitonPATH(only used for read-only Git-state enrichment; missing git
degrades gracefully)- A model provider configured in OpenCode, exposing a model that follows JSON
schemas reliably (see Choosing the reviewer model) - A permission policy with at least one
askrule — if nothing isask, the
plugin never activates (everything is alreadyallow/deny).
See Supported versions for the full matrix.
Install
The package is published to npm. Install it as a dependency, or clone and
build when you want to run from a checkout:
# From npm
bun add opencode-permission-reviewer # or: npm install opencode-permission-reviewer
# From a checkout (development)
git clone https://github.com/Warc0s/opencode-permission-reviewer.git
cd opencode-permission-reviewer
bun install && bun run build
What ships in dist/:
- Server —
main/./server→dist/index.js(bundled). - TUI overlay —
./tui→dist/tui/tui.tsx(raw TSX, not a JS bundle).
OpenCode's host compiles that entry with its own Solid/OpenTUI pipeline and
rewritessolid-js/@opentui/*onto the host runtime. A prebundleddist/tui.jsloads but never paints the overlay. - CLI —
./cli/bin→dist/explain.js.
The CLI can register the plugin for you (--tui also writes tui.json;--npm emits an npm package name instead of a path; it never clobbers an
existing entry):
bunx opencode-permission-reviewer init --npm --tui --yes
Configure
Register the plugin in your opencode.json (project or~/.config/opencode/opencode.json). Use an absolute path to a checkout, or the
npm package name after bun add / npm install:
// opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"/absolute/path/to/opencode-permission-reviewer",
// or: "opencode-permission-reviewer"
{
"model": "openai/gpt-5.6-luna", // default reviewer; override with any provider/model
"variant": "max",
"timeoutMs": 120000,
},
],
],
"permission": {
"bash": "ask", // at least one ask rule, or the plugin is a no-op
},
}
For the optional TUI overlay, register the same plugin block in yourtui.json (~/.config/opencode/tui.json). Keep model, variant, andtimeoutMs identical in both files so the watchdog and server agree:
// tui.json
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"/absolute/path/to/opencode-permission-reviewer",
{ "model": "openai/gpt-5.6-luna", "variant": "max", "timeoutMs": 120000 },
],
],
}
Restart OpenCode fully after install or rebuild. The host imports the plugin
once at startup; a live session keeps the previous code in memory and will not
show a rebuilt overlay.
Then ask the agent to run something safe, e.g. printf hello. An auto-approvedask resolves itself with once and the tool runs normally — without
injecting rationale into the agent context. Denials still return a short reason
the agent can act on.
Cost note: every ask action now spawns one extra child-session model
call (up to timeoutMs). Your model spend scales with how much your policyasks. Lower the reasoning variant or raise confidenceThreshold to taste.
Choosing the reviewer model
The reviewer is a normal OpenCode model invocation (every tool denied at the
session-permission level), so it can
be any model from any provider you have configured. Three options,
identical in both config files:
model— inprovider/modelform. Must match a configured provider and
a model that provider exposes.variant— reasoning effort the model supports (max,high,medium,low,none). Passed straight through to OpenCode.outputFormat— how the reviewer returns its decision:json_schema
(default; uses OpenCode's structured output, needs provider support) ortext(ask the model to emit JSON in plain text and parse it locally). Usetextfor models that reject thejson_schemaformat, e.g.opencode-go/deepseek-v4-flash.timeoutMs— review timeout; must match across files.
The default reviewer is openai/gpt-5.6-luna (max reasoning) — a real
model that follows JSON schemas well. Override model to use any other
provider/model you have configured; whichever you pick should follow structured
output reliably, because weaker models just produce more escalations (safe, but
noisier). Higher reasoning variants give better safety judgments at higher
cost/latency.
Reviewer models without structured-output support
Some models (for example opencode-go/deepseek-v4-flash) do not support
OpenCode's json_schema structured-output format and fail with a format error
when it is requested. For those, set "outputFormat": "text" so the reviewer
asks the model to emit its decision as plain JSON and parses it locally. This
needs the flag set identically in both opencode.json and tui.json:
{
"model": "opencode-go/deepseek-v4-flash",
"variant": "high",
"outputFormat": "text",
"timeoutMs": 120000,
}
Text mode is safe but noisier: without host-side schema enforcement the
plugin re-prompts the reviewer once if the response is unparseable (mirroring
the auto-retry that json_schema mode gets from OpenCode), and a response that
is still invalid escalates to a human rather than being auto-approved. Parsing
is deliberately strict and fail-closed: the entire response must be exactly one
JSON object (optionally wrapped in a single Markdown code fence). Prose around
the object, multiple objects, multiple fences, or any other ambiguity escalates
to a human — the parser never guesses which candidate the model meant. Every
parsed decision still passes the same strict parseDecision validation andenforceDecision invariants (critical risk is never approved, etc.), so text
mode cannot approve anything that structured mode would not.
One caveat applies to any output format: the deterministic gates check the
decision's consistency, not its semantic correctness. A reviewer model that
misclassifies an unsafe action as low risk can produce an unsafe allow in
either mode, so pick as strong a reviewer model as your budget allows.
All configuration options
Every option is optional. Numeric/string options are clamped to safe bounds.
| Option | Default | Bounds / type | Description |
|---|---|---|---|
model |
openai/gpt-5.6-luna |
provider/model |
Reviewer model (override with any provider/model) |
variant |
max |
non-empty string | Reasoning variant passed to OpenCode |
outputFormat |
json_schema |
json_schema / text |
How the reviewer returns its decision (text for models without structured output) |
timeoutMs |
120000 |
5000–600000 |
Review timeout (match in both files) |
confidenceThreshold |
0.7 |
0.5–1 |
Minimum confidence to auto-act; below it escalates |
maxContextChars |
32000 |
4000–200000 |
Total transcript evidence budget |
maxPartChars |
8000 |
500–50000 |
Per-message-part budget |
maxEnrichmentChars |
24000 |
1000–100000 |
SSH / script / Git enrichment budget |
maxIntentChars |
8000 |
1000–50000 |
User-intent history budget |
transcriptMessages |
12 |
1–100 |
Recent messages shown to the reviewer |
intentMessages |
8 |
1–50 |
Genuine user intents kept |
historyMessages |
200 |
20–500 |
Messages fetched to recover intent |
retainReviewSessions |
false |
boolean | Keep reviewer child sessions (debug only; see below) |
audit |
true |
boolean | Append one JSONL audit record per review |
auditPath |
~/.local/share/opencode/permission-reviewer-audit.jsonl |
path | Audit file location |
policy |
built-in default | string | Full local override of the tenant policy text |
debug |
false |
boolean | Verbose logs to stderr |
enforcementMode |
observe |
observe / enforce |
enforce applies declarative policy routes; observe audits them only |
escalationMode |
manual |
manual / deny |
How final escalations are disposed (manual = human; deny = fail-closed reject) |
maxSessionDepth |
8 |
1–32 |
Parent-session lineage walk depth |
maxParentSessions |
8 |
0–32 |
Max parent sessions resolved for actor context |
actorProfiles |
{} |
name → profile map | Trusted agent name → profile (read-only, validation, workspace, …) |
riskPolicy |
built-in conservative matrix | object | Override allow cells per risk level and failure modes (onInvalidDecision, …) |
repositoryTrust |
unknown |
trusted / untrusted / unknown |
Repository trust level used by the policy engine |
policyRules |
[] |
array | Declarative rules (most-restrictive wins); project rules combine with trusted ones |
askDecisions |
true |
boolean | Show the reviewer what the user answered in agent ask dialogs (scoped authorization evidence) |
Config is layered: built-in defaults ← global~/.config/opencode/permission-reviewer.jsonc ← project.opencode/permission-reviewer.jsonc ← inline plugin options (later wins).
The project layer crosses a trust boundary: it can only tighten
security-sensitive fields, and its hardening survives even when a trusted layer
set the same field. The project layer cannot choose the reviewer model or
replace the policy text (both decide where code/context travels and what the
reviewer enforces), cannot redirect auditPath, grant actorProfiles, setrepositoryTrust: "trusted", downgrade a global enforcementMode: "enforce",
or relax a trusted escalationMode: "deny" / failure-mode deny knob /confidenceThreshold / riskPolicy. Project values of the wrong type
(including null) are ignored, never normalized back to defaults.
A config file that exists but cannot be honored fails CLOSED on the trusted
side: a malformed or unreadable global config, or trusted policyRules
dropped by validation, marks the run degraded — reviews still run, but
automatic approval stays off (everything escalates) until the file is fixed,
and the degradation is reported on stderr. A malformed project file is
reported and ignored (the untrusted layer adds nothing anyway).
In declarative policyRules, a when condition with an unknown key (a typo),
a false flag, or an empty object drops the whole rule — a mistyped rule must
never degrade into a universal match. Catch-all rules are spelled explicitly:
omit when entirely, or use "when": { "always": true } (valid only alone).
When a catch-all comes from the trusted global config it simply matches
everything; project-sourced allow rules are still rejected outright.
Interactive vs autonomous
| Mode | Config | Behavior |
|---|---|---|
| Interactive (default / 1.0) | escalationMode: "manual" |
Uncertainty escalates to you; OpenCode's native approval UI takes over |
| Autonomous / fail-closed | escalationMode: "deny" |
Every final escalation becomes a reject with rationale; no human prompt |
For unattended agents, set fail-closed in global config (not in the repo):
// ~/.config/opencode/permission-reviewer.jsonc
{
"escalationMode": "deny",
}
Optional fine-grained hardening under interactive mode (only their own cases):
{
"riskPolicy": {
"onInvalidDecision": "deny", // invalid structured output → reject
"onReviewerFailure": "deny", // timeout / transport failure → reject
},
}
escalationMode: "deny" hardens every escalate path globally. Restrictive
settings can only block more, never relax security.
audit defaults to true. Each completed review appends one JSON object to
the audit path with mode 0600 (schemaVersion: 2): outcome, decision source,
rationale, risk, authorization, confidence, per-phase latency, reviewer model,
optional reviewerOutcome / escalationDisposition (to distinguish an explicit
deny from fail-closed escalate→deny), and a bounded SSH summary. Remote commands
are stored as SHA-256, never in clear text. Set audit: false to disable.
What you'll see
✓ Review approved · bash · rm -rf /tmp/scratch-cache
Narrowly scoped temp cleanup; matches user intent.
While reviewing, the optional TUI overlay covers the native approval controls
with Reviewing this permission and No action needed, plus the action,
reviewer model, and elapsed time. Once resolved, the overlay becomes a compact
status strip below the session: one line for the result and a second for its
rationale, with long text truncated. The review keymap is released immediately
so you can resume typing while the result stays visible for 5 s. OpenCode still
hides its editor while a permission is pending. On a technical failure or
escalation, the overlay is removed and OpenCode's native approval controls
become available with a manual review required warning. A broken TUI
transport never changes the safety decision.
How it works
- OpenCode emits
permission.askedfor anyask-classified action. - A deterministic emergency brake rejects unmistakable root destruction and
direct credential export before any model call. It is wrapper-aware
(sudo,doas,env,command,nice,nohup, …), sosudo rm -rf /,env VAR=x rm -rf /,/bin/rm -rf /,sh -c 'rm -rf /',ssh host rm -rf /,
andbusybox rm -rf /are all caught. - The plugin builds bounded evidence: recent transcript, recovered user
intent, and optional read-only enrichment for SSH commands, local
interpreter scripts, and Git state. Intent attribution uses a single origin
rule: synthetic/host-flagged parts are never human intent, and in a
delegated (subagent) session no user-role message counts as human
authorization — the initial briefing and every latertask_idfollow-up
are agent-authored and surface only as labeled delegation context.
Common credential formats are always redacted from this evidence
(Bearer, AWS / GitHub / OpenAI / Anthropic / Slack / Google / Stripe /
GitLab keys, JWTs, private keys, URL userinfo, cookies, and
credential-bearing assignments) so a secret you once pasted into the
session never travels to the reviewer's provider. - An isolated, tool-free reviewer session runs the reviewer model with a
strict JSON schema and returns{ outcome, risk_level, user_authorization, rationale, confidence }. The session is created in a scratch directory
outside your project so the host does not prepend repository instructions
(AGENTS.md, project configinstructions, project MCP context) to the
reviewer's system prompt — only your trusted global instructions remain.
Tool denial is a wildcard session permission rule, which takes precedence
over agent-config allows and therefore also covers MCP tools and MCP
resource tools. If the host refuses the isolated directory, the review is
not run in the project directory: it escalates to the human as a reviewer
failure instead, so isolation is never silently degraded. - Decisions are enforced with invariants: critical risk is never approved,
high risk with low/unknown authorization is escalated, medium risk
with unknown authorization is escalated, low confidence is escalated,
invalid output is escalated, errors and timeouts are escalated. Two
deterministic blocks also apply regardless of model confidence: a degraded
trusted config (see above) and evidence where a material part of the action
itself was elided or truncated — neither can auto-approve. A single
enforcement boundary then disposes every internalescalateaccording toescalationMode(manual→ human;deny→ reject with the original reason). - Approved actions get
once(neveralways) and execute silently — the
tool output is not annotated, so approval rationale never contaminates the
primary agent context (rationale still lands in audit, TUI, and debug logs).
Denials return a short actionable rationale as tool feedback. A manual reply
that arrives mid-review supersedes the automatic one (no double reply).
By default everything fails safe to manual review. WithescalationMode: "deny", uncertainty fails closed to a reject with reason
instead — suitable for non-interactive agents.
Evidence enrichment
The reviewer never sees the raw filesystem — only bounded, sanitized evidence.
Enrichment is deliberately conservative and never makes an approval decision
by itself (one narrow deterministic exception exists for SSH, below).
- SSH commands are parsed into destination, options, remote command,
environment/mutation/secret/stdin signals, and bounded stdin content for the
commoncat script | ssh ... python -pattern. Sensitive paths,
credential-like literal content, binary files, unresolved shell expressions,
and symlinks escaping approved roots are excluded. - Local interpreter commands (Python, Node, Bun, shell, Ruby, Perl, and
compound commands that first activate an environment) get the same bounded
inspection when they name an explicit script. Inline code, modules, stdin
programs, dynamic paths, and remote-only SSH arguments are not misidentified
as local files. - Git operations (
add,commit,checkout,restore,rm) get a
read-only pre-command snapshot: current branch, files already staged before
the command, unstaged/untracked files, planned targets, unresolved
shell-expanded paths, and a bounded numstat for changes that would be
discarded. Snapshots use fixed non-interactive Git queries with locking and
hooks disabled, a two-second timeout, and bounded output. The repository is
never modified. Repository-configured conversion filters (clean,smudge,process) and difftextconvdrivers are enumerated before every
snapshot and neutralized with config overrides (including dotted names); if
the configuration cannot be fully verified — too many filters, or the config
scan itself fails — the snapshot is withheld rather than risk executing
repository-configured commands. Verification and inspection are still two
distinct moments: a filter configured between them is a residual race the
snapshot does not claim to eliminate.
Only regular text files inside the working directory, the worktree, or/tmp/opencode can be included. Missing, blocked, and truncated executable
stdin is explicitly identified so the reviewer fails safe.
The only deterministic SSH preflight rejection is an executable stdin file
that still does not exist after a 100 ms recheck — the primary agent gets an
actionable instruction to create it and retry. Every other SSH case (sensitive,
binary, blocked, or truncated evidence) remains a reviewer decision.
Safety properties
- Responds only to
permission.asked. - Critical-risk actions cannot be approved, even if model output says
allow. - High-risk actions with low or unknown authorization, and medium-risk
actions with unknown authorization, are deterministically escalated — the
model cannot auto-approve them by labeling a contradictory combination. - Invalid, low-confidence, or inconsistent output is escalated to the user.
- Common credential formats are always redacted from the evidence before
reaching the reviewer, so credentials never leak to the reviewer's provider. - Reviewer sessions cannot request permissions recursively; every tool is
denied by a wildcard session permission rule that also covers MCP tools and
takes precedence over agent-config allows. - The reviewer session runs outside the project directory, so repository
instructions (AGENTS.mdand project-configinstructions) are not part of
its system prompt; if the isolated directory cannot be established, the
review escalates to the human as a reviewer failure rather than running
with degraded isolation. - A narrow deterministic emergency brake rejects unmistakable root destruction
(including privilege-prefixed and command-string forms such assudo rm -rf /,sh -c 'rm -rf /',ssh host rm -rf /) and direct
credential-file export before any model call. - A manual reply that arrives while a review is in flight supersedes it: the
reviewer stops without replying or resurrecting a UI state. - Approvals are silent to the primary agent (no tool-result annotation); denials
return the rationale as feedback. Rationale remains in audit/TUI/debug. - SSH commands and executable stdin receive bounded, untrusted action
enrichment; enrichment never makes an approval decision on its own. - Long-session user intent is recovered separately from recent operational
context; later explicit requests supersede conflicting older ones. - Synthetic compaction/control messages are excluded from authorization
evidence. - Audit failures never affect or relax the safety decision.
- UI status messages are versioned, request-scoped, bounded, and transported
through OpenCode's own workspace TUI event channel.
Supported versions
| Component | Supported | Notes |
|---|---|---|
| OpenCode | >=1.18.11 <2 |
Declared in engines.opencode; verified with 1.18.25 |
@opencode-ai/plugin |
>=1.18.11 <2 |
Peer dependency for the server transport |
| Bun | >=1.3.0 |
Declared in engines.bun; CI runs 1.3.0 and 1.3.5 |
| TUI overlay | OpenCode V1 | Needs the host Solid/OpenTUI plugin pipeline (raw TSX entry) |
| OS | macOS / Linux | On Windows, SSH/Git enrichment degrade to fail-safe manual |
- TUI overlay ships as raw TSX (
dist/tui/tui.tsx). The host compiles
it against its embedded Solid/OpenTUI runtime. A prebundled TUI entry loads
but never paints. The server half does not depend on the overlay. - Server half replies through an isolated transport chosen once at startup:
public SDK reply with feedbackmessage→ public reply plus a separate
feedback channel → authenticated raw HTTP
(/permission/{requestID}/replyviainput.client._client.post) → refuse
startup. On OpenCode 1.18.x the message-bearing reply is only reachable via
the raw transport, so the chain resolves there. That raw field is not part
of OpenCode's public plugin API and can change without notice. If startup
fails with "authenticated SDK transport is unavailable", file an issue
rather than downgrading. - OpenCode v2-generation hosts are detected at startup and refused until
their reply contract is verified. - Full enrichment assumes a Unix-like system (macOS/Linux). On Windows, SSH and
Git enrichment degrade gracefully toward fail-safe manual review. retainReviewSessions: keep itfalsein normal use. Settrueonly to
debug the knownjson_schemastructured-output serialization bug in OpenCode
1.18.11 — it keeps the child session on disk so you can inspect the malformed
response; it does not fix the bug.- Run
opencode-permission-reviewer doctorto compare installed versions
against the ranges above.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Every ask escalates after a long wait |
Reviewer model not found / provider not configured | Ensure the model's provider is set up in OpenCode and the model ID is valid in both config files |
| Plugin does nothing | No ask rule in your permission policy |
Add e.g. "bash": "ask" |
| TUI overlay never appears | Not in tui.json; mismatched timeoutMs; stale process; or host without Solid/OpenTUI pipeline |
Register the same block in tui.json with matching timeoutMs. Overlay is raw TSX (dist/tui/tui.tsx); a prebundled dist/tui.js does not render. Fully restart OpenCode after rebuilds |
| Startup error: "authenticated SDK transport…" | OpenCode outside >=1.18.11 <2, or an SDK change that hides the raw transport |
Upgrade OpenCode and @opencode-ai/plugin into the supported range; report the version in an issue |
| Startup error: "Detected an OpenCode v2…" | OpenCode v2-generation host | Run an OpenCode 1.18.x host (v2 is not supported yet) |
| Reviews always time out | timeoutMs too low for the model |
Raise timeoutMs (up to 600000) |
GIT_STATE_ANALYSIS shows spawn git ENOENT |
git not on PATH |
Install git; Git enrichment degrades safely until then |
| Want a version check | Host/SDK outside the supported range | Run opencode --version and opencode-permission-reviewer doctor |
| Want to turn it off | — | Remove the plugin entry from both opencode.json and tui.json |
Enable "debug": true for verbose stderr logs while investigating. TUI load
errors ([tui.plugin] …) are printed on the TUI process console, not in~/.local/share/opencode/log/opencode.log.
Development
bun install
bun run check # format + lint + typecheck + tests + build (must pass before any push)
bun run test:stress # stress suite only
bun run test:package # npm pack ship-set smoke (raw TUI + server bundle)
bun run build bundles the server/CLI with tsup, then copies the slim TUI
source graph into dist/tui/ as raw TSX (scripts/copy-tui.ts). Do not add a
prebundled TUI entry — it will not render on the host.
The live end-to-end harness in tests/live-harness.ts runs against a real
OpenCode server + model and is not part of bun test; seeCONTRIBUTING.md.
Attribution
The reviewer policy and prompt text in src/policy.ts are adapted from
OpenAI Codex Guardian
(Apache-2.0). See NOTICE for full attribution and license details.
License
Apache License 2.0 © 2026 Warc0s
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found