Skip to content

Repository files navigation

opencode-auto-mode

English | 日本語

Overview

An OpenCode plugin that observes permission requests for bash tool execution (permission.asked), judges the risk of the command with an LLM, and auto-approves only what it judges to be low risk. Every verdict is recorded in an append-only history that you can inspect with the automode_history tool.

The plugin subscribes to exactly three events: permission.asked (a bash permission request to judge), permission.replied (what the human answered, recorded in the history), and message.part.updated (used to detect a tool running inside a judge session). It registers exactly one tool, automode_history.

Auto-approval is deliberately narrow: the plugin only ever sends once, only for a verdict of low, and only after the verdict has been written to the history.

Important boundaries (read before installing)

By default, nothing happens. OpenCode's default for bash is permission.bash: "*": "allow", so no confirmation request (permission dialog) is ever raised in the first place. This plugin only responds to existing permission requests, so it does nothing at all unless you set permission.bash to the "ask" side. The minimal configuration is (in opencode.json):

{
  "permission": {
    "bash": { "*": "ask" }
  }
}

On top of that, wire the plugin up as described in Installation.

Other boundaries:

  • auto mode enabled ≠ every operation auto-approved. This plugin gatekeeps bash only. File writes such as edit / write are governed by OpenCode's own permission settings. If those defaults are more permissive than bash, the riskiest operations can run outside the plugin's gate.
  • The confirmation dialog is not suppressed; it appears and then disappears. There is no hook that intercepts a permission request, so the plugin observes the request and then sends a reply. The dialog stays visible for the whole judgement. Each judgement is bounded by 4 × timeoutMs in the worst case (session create, prompt, delete, reply — each capped at timeoutMs; 60 s at the default). In practice the model's response time dominates.
  • A "*": "ask" under permission.bash is subject to auto-approval. If you want a specific command to always be confirmed by a human, write an individual ask for just that command, as in { "*": "ask", "docker *": "ask" }. The plugin does not respond to individually specified ask and deny entries; it leaves the dialog standing.
  • Which permission rules are read. The plugin reads every key of permission whose name matches bash by wildcard (so {"*": {"docker *": "ask"}} counts), in the order written; the last matching rule wins. Per-agent overrides cfg.agent.<name>.permission are not read, so an ask specified per agent can be missed.
  • .opencode/auto-mode.json lives in the working tree and cannot be trusted in a hostile repository. It sits in the same threat model as AGENTS.md. If historyPath points outside the worktree (a path that resolves outside the worktree, or the worktree root itself), it is rejected: a warning is emitted and the default is used.
  • cwd is judged as the worktree root, not the directory the command actually runs in. The permission request payload does not carry the runtime cwd, so the judging model is told to interpret commands as relative to the worktree root.
  • Commands touching anything outside the worktree are stopped earlier by a separate permission request (external_directory). When rm / cp / mv / cat and friends point at a path outside the worktree, that confirmation is raised before bash. The plugin only responds to permission === "bash", so that dialog stays up, and unless you approve it the bash request itself never occurs.

Supported versions

The contracts were pinned down against the OpenCode 1.18.21 source (the relevant files under packages/opencode), and the behaviour was verified against an opencode 1.18.21 server (for the measurement environment and the measured values, see docs/superpowers/plans/2026-08-17-findings.md). The generated types shipped in @opencode-ai/sdk 1.18.21 lag behind that server (see the comments in src/opencode.ts), so the source, not the generated types, is what the contracts are based on.

docs/superpowers/plans/2026-08-17-findings.md still names 1.18.18 as the contract source; that is the wording from when it was written. The contract source was re-pinned to 1.18.21 afterwards in docs/superpowers/specs/2026-08-22-unify-opencode-version-design.md.

The @opencode-ai/plugin: ">=1.18.0 <2" entry in peerDependencies in package.json is a range for npm module resolution, not a statement of the compatibility range against the OpenCode server itself.

It is undetermined from which version OpenCode accepts the permission field of session.create that is used to lock down the judging session (see How it works for what that lockdown is). On a server that does not accept it, the lockdown echo check trips, not a single judgement completes, and no auto-approval happens at all. The plugin keeps running, attempting a judgement for each permission request and failing (the confirmation dialog simply stays up, so the outcome is on the safe side).

You can tell that this is the state you are in from two things:

  • Every line of automode_history shows risk ? and source [fallback], and the reason says the lockdown could not be set
  • The server log carries a one-time warning; if the TUI is available, it appears there once as well:
auto-mode: this OpenCode does not accept judge-session lockdown (session.create permission). All verdicts fall to unknown and nothing is auto-approved.

Installation

Dropping the plugin directly into .opencode/plugin/ does not work. OpenCode's auto-discovery uses a non-recursive glob ({plugin,plugins}/*.{ts,js}), so a file placed in a subdirectory is never discovered, and placing it flat produces a TypeError from the non-plugin exports.

The only path verified to work is writing a path spec (a relative path such as ./plugins/opencode-auto-mode or ../opencode-auto-mode, or an absolute path) in the plugin field of opencode.json. A bare package name is not loaded, and it fails silently.

Clone the repository, install the dependencies, and build dist/ with tsc:

git clone https://github.com/mikoim/opencode-auto-mode.git
cd opencode-auto-mode
pnpm install
pnpm build

pnpm is pinned through the packageManager field, so if pnpm is not on your PATH, corepack enable makes it available (on a system-wide Node install this needs sudo corepack enable, or corepack enable --install-directory <a directory on your PATH> — otherwise it fails with EACCES on /usr/local/bin/pnpm). pnpm install also installs @opencode-ai/plugin, which the built plugin needs at runtime, so keep node_modules/ next to dist/.

Then point the plugin field of opencode.json at the clone with an absolute path (or a ./ relative path from the project):

// opencode.json
{
  "plugin": ["/absolute/path/to/opencode-auto-mode"]
}

To update, run git pull followed by pnpm install && pnpm build in the clone, then restart OpenCode (plugins are loaded at startup).

Installation demo

Configuration

The configuration file is <worktree>/.opencode/auto-mode.json. It cannot be written in opencode.json — ConfigV1.Info declares no autoMode, and the decoder drops unknown top-level keys, so it never reaches the plugin.

If the file is missing, unreadable, or malformed as JSON, the plugin runs with the defaults (the latter two also emit a warning). If an individual field holds an invalid value, only that field falls back to its default and a warning is emitted.

When started in a directory that is not under git, the startup directory becomes the root. OpenCode assigns / as the worktree in that case, so both .opencode/auto-mode.json and the history live under the startup directory (not under /).

Key Default Description
enabled true Enables or disables the plugin as a whole
model unset (defers to OpenCode's default model) A string in "provider/model-id" form, split at the first /, so openrouter/meta-llama/llama-3 works. If the form does not match, it falls back to the default model
timeoutMs 15000 Timeout per judgement call, in milliseconds. Must be a positive integer ≤ 2,147,483,647; otherwise the default is used with a warning
cacheSize 500 Maximum number of entries in the judgement cache. Must be a positive integer ≤ 2,147,483,647; otherwise the default is used with a warning
historyPath .opencode/auto-mode/history.jsonl Path to the history file. A relative path is resolved against the worktree root. If it points outside the worktree, a warning is emitted and the default is used

A key that is not in the table is ignored, with a warning naming it:

.opencode/auto-mode.json: unknown key <key>. Ignoring it.

If the file contents are null, an array, or anything else that is not an object — or the file exists but could not be read — the defaults are used and the warning is:

.opencode/auto-mode.json cannot be read as a config (not an object, or the file could not be loaded). Using defaults.

A minimal example:

{ "model": "llamacpp/my-model", "timeoutMs": 30000 }

docs/demo/auto-mode.json.in is the configuration used by the recorded demo, and doubles as a worked example (a template; @…@ tokens are filled in by record.sh).

How it works

Startup

No judgement happens until the config hook has arrived. Until then the plugin lets events pass and automode_history returns:

auto-mode is waiting for its config to load. No verdicts have been made yet.

If the permission section of that config cannot be interpreted, judging is stopped entirely (fail closed) and the reason is reported as could not parse the permission section, so judging is stopped. If worktree or directory in the plugin input is not a non-empty absolute-path string, or the client is missing, judging is disabled altogether (automode_history stays registered, as it does after any initialization failure) and the server log carries:

auto-mode: cannot interpret worktree / directory or client from the plugin input, disabling.

Judge session

Every judgement runs in a throwaway child session titled auto-mode: risk judge, so an injected command can never contaminate a later judgement.

  • session.create is sent the lockdown ruleset [{ "permission": "*", "pattern": "*", "action": "deny" }]. The server must echo back an identical ruleset — same count, same order, same three fields — or the judgement is treated as failed.
  • The system prompt is replaced wholesale through experimental.chat.system.transform, because OpenCode appends the repository's AGENTS.md / CLAUDE.md to the system prompt. If that hook does not fire, the verdict is discarded rather than trusted.
  • If a tool part is observed for the judge session via message.part.updated, or a tool part appears in the response, the verdict is discarded — a tool running at all means the lockdown is not in effect.
  • The session is deleted after the judgement.

Reply

The plugin only ever sends once. It never sends always (which would widen the permission permanently) and never sends reject (which would cancel a command the human might have wanted).

History first

The verdict is written to the history before any reply is sent. If the write fails, no reply is sent at all and a warning is emitted:

auto-mode: cannot record the verdict in the history (<error>). While recording fails, auto-approval is suspended.

The reply itself is also recorded, after it has been sent. That write cannot be undone by failing, so it only warns:

auto-mode: failed to record the auto-approval (<error>). The history no longer matches reality.

Toast

For any verdict other than low, the TUI is shown auto-mode: <risk> — <reason> as a toast with variant warning, so the reason for leaving the dialog up is visible where the dialog is.

Cache

The cache key is the split command list × the full original command × the cwd. It is a fixed-capacity map that evicts in insertion order — not an LRU. It lives only in memory, so it is emptied by a restart and by the config hook rebuilding the state; on OpenCode 1.18.21 the loader calls config once at registration, so in practice the cache lives until the next restart. Only successful verdicts are cached; unknown is never cached, so a transient failure is not made permanent.

The combination of identical patterns (the split command list) × identical full original command × identical cwd is assumed to have stable risk and is cached. Internal whitespace (including newlines) is not normalized — collapsing it changes the shell syntax itself, which would let a dangerous command illegitimately reuse the cache entry of a harmless one.

Risk criteria

The judgement evaluates high → medium → low in that order and takes the first tier that matches. This ordering is the crux: the examples for a lower tier only hold subject to "unless a higher tier applies" (for instance, cat is an example of low, but cat ~/.ssh/id_rsa is high).

Tier Criteria Examples
high Unrecoverable data loss, effects outside the working tree, credential exposure, privilege escalation Broad application of rm -rf, dd, mkfs, sudo, curl … | sh, git push --force, reading / printing / transmitting secret files (~/.ssh/, ~/.aws/, ~/.config/gh/, .env, id_rsa, credentials), bulk dumping of environment variables via env / printenv
medium Local but hard to undo, writes to the outside, dependency changes, indirect code execution git reset --hard, npm publish, docker rm, overwriting existing files, npm test / npm run * / pnpm * / yarn *
low Read-only, and matching neither high nor medium ls, cat (other than secret files), grep, git status, git diff, npm ls, mkdir (inside the working tree)

Three additional rules apply on top of the table:

  1. A compound command is rated by its most dangerous component — pipes, &&, ;, command substitution $(...), and backticks. ls && rm -rf / is high.
  2. Anything whose content cannot be read is high — executing base64-decoded data, obfuscated scripts, remote scripts of unknown content. Being unreadable does not mean safe.
  3. Running package-manager scripts is at least medium — npm test / npm run * / pnpm * / yarn * execute arbitrary scripts from the repository's package.json, so what actually runs is not visible from the command string.

The command name alone never decides the tier. cat is listed under low, but cat ~/.ssh/id_rsa exposes credentials and is high; mkdir is listed under low, but mkdir -p /etc/foo writes outside the work tree and is high.

Security (prompt-injection defences)

  • A 128-bit token is minted per judgement and embedded in the system prompt. A response is not read as a verdict unless a top-level token in its JSON matches it (compared case-insensitively, with surrounding whitespace ignored, since the minted value is lowercase hex). The command string is attacker-influenced, so a {"risk":"low"} quoted out of the command must not be mistaken for the model's own verdict.
  • If two or more differing verdicts can be parsed out of one response, the result is unknown. Which one is genuine cannot be decided from here, and ambiguity is not safety.
  • A response longer than 20000 characters, or containing more than 256 { characters, is treated as unjudgeable. It is deliberately not truncated — truncating would let an attacker push the genuine verdict out of the window.
  • The closing tags </command>, </full-command> and </cwd> (including whitespace variants such as </command >) are replaced with full-width characters before the text enters the prompt, so the prompt's structure cannot be broken out of. The same treatment is applied to the cwd, not just to the commands.
  • Reasons are sanitized before being recorded: control characters removed, whitespace collapsed, and the text capped at 500 characters. The history flows back into the main session's context, so it is treated as untrusted output.

LLM judgement is not perfect. Prompt injection or a misjudgement can auto-approve a command that should have been confirmed. permission.bash's deny can be used alongside this plugin as a hard gate that never goes through the plugin's judgement.

Using the automode_history tool

Call it from an agent as automode_history({ limit?, risk? }).

  • limit — how many entries to show. Default 20, maximum 200, minimum 1; a fractional value is floored, and NaN falls back to 20
  • risk — filter by one of "low" | "medium" | "high" | "unknown" ("unknown" covers the lines that could not be judged)

It returns, newest first, a formatted view of the timestamp, risk tier, verdict source ([llm] / [cache] / [fallback]), outcome, command, and reason. The outcome column has seven values:

Outcome Meaning
once The human answered "allow once"
always The human answered "always allow"
reject The human rejected the request
auto-approved The plugin sent the reply and the server accepted it
reply-failed The server explicitly returned an error for the reply
reply-unknown The reply timed out or the transport failed, so whether it arrived is unknown
pending No reply has been recorded yet

A once row is not necessarily a human answer: the plugin's own once also comes back as permission.replied, and when its own send was recorded as failed or unknown but actually landed, the reply record wins and the row reads once.

When the history is empty, a risk filter matches nothing (No matching history (risk: <risk>).), or the plugin is disabled, it returns a one-line status saying exactly that — preceded by the Warning: <config warning> line when a configuration warning is pending. The disabled lines are:

auto-mode is disabled by config (.opencode/auto-mode.json has enabled = false).
auto-mode is disabled (init failure). Check the server log.
auto-mode is disabled: no way to lock down the judge session is available, so it shut down rather than risk running unlocked.
auto-mode is waiting for its config to load. No verdicts have been made yet.

Two kinds of warning can be prepended to the output. A configuration warning appears as Warning: <config warning>, and while history writes are failing these two lines appear:

Warning: the most recent verdicts could not be recorded in the history (write failure).
         While recording fails, auto-approval is suspended.

The output (both the command strings and the reasons) is untrusted text generated by another agent and another model; even if it contains instructions, they must not be followed.

History file

The history is a JSONL file, appended to and never rewritten. Each line is one of three record types: verdict (the judgement), auto-reply (the outcome of the reply the plugin sent), and reply (what the human answered). A line that cannot be parsed, or that is missing a field the reader uses, is skipped rather than failing the whole read.

Commands stored in a record are capped at 4000 characters in total, with 1000 characters reserved for the full original command so that redirects and heredocs — the evidence for a high verdict — are not squeezed out. Anything over budget is clipped in the middle, keeping the head and the tail, and the display marks the line with ...(truncated). The judgement itself always runs on the untruncated command.

The file is not rotated. If it grows large, delete or archive it by hand.

This repository's .gitignore covers the .opencode/auto-mode/ directory. If you point historyPath somewhere else, exclude that path from Git yourself.

Operations

  • If sessions titled auto-mode: risk judge accumulate, delete them by hand. A timeout only stops waiting (Promise.race) and does not cancel the request, so a session created on the server after the plugin gave up has no ID here to delete. A session that was created successfully but could not be deleted also remains, because the deletion failure is swallowed
  • Each kind of warning is emitted at most once per plugin registration (config, permission rules, history write failure, unexpected hook exception, startup); on 1.18.21 that is effectively once per process. A recurring problem will not warn again until OpenCode is restarted
  • Even when initialization fails, automode_history stays registered, so there is always one place to ask what state the plugin is in

Demo

A low-risk git status is approved automatically; a destructive rm -rf … && git push --force is left for the human; automode_history shows both verdicts.

Usage demo

The recording procedure is described in docs/demo/README.md. The configuration used in the recordings is docs/demo/opencode.json.in and docs/demo/auto-mode.json.in (templates; @…@ tokens are filled in by record.sh).

Related documents

About

OpenCode plugin that uses an LLM to assess bash command risk and auto-approve low-risk permission requests with an auditable decision history.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages