LeastGrant

Generated from compatibility/opencode.json, the same record leastgrant doctor reads. Last verified 2026-09-03.

all agents

OpenCode

Not yet UNVERIFIED 1.18.26 win32

no adapter ships, or nothing has been established about it.

How it attaches

None. A plugin would attach through tool.execute.before and the permission reply endpoint; neither is sufficient. See deferredBecause.

No adapter ships for this agent. Deferred, and the reason is now measured rather than read. Three execution paths were driven against a running 1.18.26 instance, each with a control proving the plugin was loaded and blocking on the equivalent normal path. What works is better than previously recorded. Throwing from tool.execute.before is a genuine unraceable boundary: Plugin.trigger awaits every hook sequentially and routes rejections through Effect.promise, which makes them unrecoverable defects, so the throw always precedes execution. It held against an explicit permission: {bash: "allow"} and it covers every agent-initiated tool call, MCP included. What defeats it is that the agent tool layer is not the only way to execute. POST /session/{id}/shell ran a command and created its marker file while the plugin threw unconditionally. Only shell.env fired, and that hook is handed {cwd, sessionID, callID} with no command string, so it cannot make a content-based decision even though it is awaited. The result is then recorded in session history as a part of type "tool" named "bash" — so a layer auditing the transcript sees something indistinguishable from an approved tool call. Command-template interpolation executes before the only hook that could stop it. In a /command file, a backticked shell section ran at 23:50:59.304Z and command.execute.before fired at 23:50:59.319Z — 15ms too late, and by then the hook’s own input already contains the command’s OUTPUT rather than the command, so it cannot even see what ran. Making that hook throw returned HTTP 500 and aborted the command, and the marker file had already been written. $ARGUMENTS is substituted BEFORE the backtick extraction, so caller-supplied arguments are shell-interpreted: an argument containing && executed a second, separate command with no pre-execution hook at all. GET /file/content returned file bytes with zero hooks and zero permission events, where the read tool on the same file was intercepted and blocked. Absolute paths and ../ are rejected, but the caller-supplied directory parameter relocates the root, which was used to read a file outside the session’s project directory. All three are reachable over a server that is unauthenticated by default — 127.0.0.1:4096, with HTTP Basic available only if OPENCODE_SERVER_PASSWORD is set, and a startup warning when it is not. Anything on loopback can drive them. Two structural facts say this is a boundary rather than a gap. PermissionV2.Source is a union with exactly one member, {type:"tool", messageID, callID} — a permission request can only be provenanced to a tool call, so non-tool execution is not unchecked but unrepresentable, and no external component can widen that from outside. And the plugin loader itself sits above the policy layer: an agent that can write .opencode/plugin/*.js, add a plugin dependency, or register a stdio MCP server obtains code execution at startup alongside and outranking any adapter LeastGrant ships. Also measured: the TUI’s auto mode auto-replies "once" to every permission request through the same unauthenticated route, and permission replies are first-writer-wins, so a local process can answer before the human sees the prompt. A scoped adapter is conceivable — "agent tool calls are mediated, HTTP shell, command templates and server-side reads are not" — but an autonomous agent on this machine can reach all three, which is the case the scoped outcome explicitly excludes. What would make support safe upstream: a hook that the /shell and /command paths also traverse, a permission source that can be provenanced to something other than a tool call, and authentication on the local server by default.

What a verdict does here

The three verdicts do not travel equally well. This is the table that decides whether LeastGrant is a prompt, a veto, or a suggestion on this agent.

verdictlands?what actually happens
allowpartialprobeOnly through the reply channel, and only by winning the race. tool.execute.before can refuse but cannot approve.
asknoneprobepermission.ask is declared in the plugin types and never fired by the shipped binary — confirmed three ways, including a live plugin exporting it against a real permission request that was raised and resolved without it. The official docs have quietly stopped listing it.
denypartialprobeTwo channels, both real, neither complete. Throwing from tool.execute.before is synchronous and unraceable and held against an explicit allow policy — but POST /session/{id}/shell bypasses it entirely. Replying reject to a pending permission works but is a race the policy can lose.

Interactive and unattended

This agent does not tell the hook which mode it is in, so LeastGrant cannot distinguish an attended session from an unattended one and treats every session as unattended.

When the hook breaks

The question nobody asks until it matters: if LeastGrant crashes, times out, or cannot start, does the tool call still run?

  • On crash — the call runs anyway. (probe) Depends where it crashes, and the case that matters fails open: a plugin that fails to load — module throws, bad export, failed npm install — is silently skipped and the agent then runs unpoliced with no visible warning.
  • On timeout — not established. There is no timeout and none is configurable. Plugin.trigger awaits each hook serially with no deadline, so a hung hook hangs the agent indefinitely. Verified live.
  • Can it be made to fail closed? — no.

What it can see

A verdict is only worth as much as the set of actions it is asked about. gated means LeastGrant is consulted before the thing happens; seen after means it is told afterwards and can at best withhold the result; not covered means the action happens with LeastGrant never hearing about it.

tool classcoveragedetail
shell commandspartialprobeThe bash tool is gated and a plugin throw blocks it unraceably, measured against an explicit allow policy. POST /session/{id}/shell runs a command on the same session with the hook never called, and command-template interpolation runs before the only hook that covers it.
file readspartialprobeThe read tool is intercepted and a throw blocks it. GET /file/content returns the same file’s bytes over the unauthenticated local server with no hook and no permission event, and its directory parameter relocates the root outside the session’s project.
file writesseen aftersource
deletionsseen aftersource
MCP callsseen aftersource
subagent spawnunknownunknown
network / webunknownunknown

What decides this later

The highest-value write on your machine is not any single tool call. It is the file that settles what the agent may do next time — its hook configuration, its standing permission grants, the servers it is wired to, the memory a later session is handed as context. LeastGrant floors an agent write to every path below, so it always reaches a person, and test/control-files.test.ts reads this same list and fails the build if any of them stops being floored.

The list is longer than the install path on purpose. Walking it across every agent, rather than reading it, turned up three paths that were not floored at all — and the one that mattered most was Antigravity's config.json, which holds that host's own “Always allow” grants and outlives uninstalling LeastGrant entirely.

pathkindwhat it decides
~/.config/opencode/opencode.jsonsettingsthe permission configuration, including any rule an evaluate-hook would run after
<repo>/opencode.jsonsettingsthe project-scoped version of the same

What has actually been run

Four different things, deliberately not collapsed into one badge. Reproducing an agent's invocation is not running the agent; a passing conformance suite says our side is right and nothing about whether the host ever calls us.

  • Live agent test — not done. No adapter ships, so there is nothing to run inside the real agent. Support is deferred for the reasons in deferredBecause: the one unraceable channel is bypassed by a documented HTTP endpoint, and the other is a race the policy can lose. The product itself was driven live — that is where those measurements came from — but LeastGrant was not in the loop.
  • Real transport probed — not done. No adapter ships, so there is no invocation to reproduce. The shape is known and is the least of the problems here: a plugin is imported in-process from .opencode/plugin and called directly, with no subprocess, no quoting layer and no wire format between it and the host. Nothing about this deferral is about transport.
  • Contract read from the shipped build — Driven against a running 1.18.26 instance with a deterministic local model, an isolated XDG config and data root, and a plugin whose load was proven before and after the run. Confirmed at runtime: tool.execute.before blocks unraceably against an explicit allow; POST /session/{id}/shell executes with the hook never called; command-template interpolation runs 15ms before command.execute.before and survives its refusal; $ARGUMENTS is substituted before shell extraction; GET /file/content returns bytes with no hook. Each with a control on the equivalent gated path. 1.18.26 · win32 · 2026-09-03
  • Conformance suite — not done. No adapter ships, so the conformance suite has nothing to drive. It would arrive with the adapter rather than after it — the suite fails a build where an adapter exists without a conformance shape, which is how Cursor was caught having never been tested.

Not exercised on darwin, linux.

What it cannot do

Not a disclaimer. The point of everything above is that this list exists and is specific.

  • permission.ask is declared in the plugin types and dispatched nowhere — trigger("permission.ask" occurs zero times in the shipped bundle. Building on it would produce an adapter that appears installed and enforces nothing.
  • There is no ctx.permission.hook("evaluate") in 1.18.26 or in the published 1.18.27. The v2 PluginContext exposes no permission domain, and no documentation page mentions one. The internal evaluate is a pure function over a ruleset with no extension point.
  • POST /session/{id}/shell executes a shell command without calling tool.execute.before or any permission gate, and records the result in session history as a tool part named "bash".
  • Command-template interpolation executes before command.execute.before, which therefore cannot prevent it and cannot even see the command — by the time it fires, its input holds the command’s output.
  • $ARGUMENTS is substituted into a command template before the shell section is extracted, so caller-supplied arguments are shell-interpreted.
  • GET /file/content returns file bytes with no hook and no permission event, and its directory parameter relocates the root outside the session’s project directory.
  • The local server is unauthenticated by default. HTTP Basic is available only when OPENCODE_SERVER_PASSWORD is set; without it the process prints a warning and serves anyway.
  • PermissionV2.Source admits exactly one shape, {type:"tool"}. Execution that is not a tool call cannot be expressed as a permission request at all, so the boundary cannot be widened from outside.
  • Plugins are loaded at startup from the config directory and run above the policy layer, so an agent able to write a plugin file, add a plugin dependency, or register a stdio MCP server outranks any adapter.
  • The TUI’s auto mode auto-replies "once" to every permission request, and replies are first-writer-wins over the same unauthenticated route.
  • Two permission stacks ship in one binary. PermissionV2 has its own assert/ask/reply with sqlite-persisted grants while the V1 path still serves ordinary sessions, and a "always" reply re-evaluates and silently resolves other pending requests in the session.
  • No adapter ships. This file records a completed evaluation, not a supported integration.
  • Revisit if OpenCode wires permission.ask to the permission engine, which would make a faithful adapter straightforward.