Skip to content

permissions: ask effect is fail-open for shell commands (command runs, client reply gets 404) #56470

Open

Description

@ExNihil14

Summary

A permission rule with effect: "ask" does not pause execution. The matching shell command is spawned immediately, while a permission prompt is published to the client; the client's reply is then rejected with HTTP 404, so the prompt can never be applied. The gate is effectively fail-open.

Environment

  • opencode version: 2.0.32 (latest on npm at the time of writing)
  • OS: Linux 6.17.0-PRoot-Distro, aarch64 (Android 16 phone; Termux + PRoot-Distro Ubuntu 26.04)
  • Terminal: web client served by opencode serve, accessed from a mobile browser; TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/bash
  • Install/channel: npm global @opencode/cli@2.0.32, service started as opencode.exe serve --service
  • Active plugins: none found in ~/.config/opencode/opencode.json

Reproduction

  1. Add a permission rule to ~/.config/opencode/opencode.json:
    "permissions": [
      { "action": "shell", "resource": "rm *", "effect": "ask" }
    ]
    (Config is picked up live, without restarting the service — verified in the same session.)
  2. Ask the agent to run a shell command matching the pattern, e.g. rm -f /tmp/opencode/perm-test-does-not-exist.
  3. Observe: the command executes; the client shows a permission prompt; choosing "Allow once" produces an error notification; the server log records repeated POST /api/session/<sessionID>/permission/<permissionID>/reply → 404.

Expected Behavior

With effect: "ask", the shell command should not run until the client's decision is received and accepted. The reply endpoint should accept a decision for a pending permission request.

Actual Behavior

  • The command is spawned and completes before any client decision: server log spawning process at 17:46:44.050Z, first client reply attempt at 17:46:46.839Z.
  • Every reply attempt fails with 404, so the prompt cannot be answered (UI shows a "cannot apply" error): 9 attempts between 17:46:46.839Z and 17:46:52.355Z.
  • Reproduced twice: on the second run the command executed again with no reply attempts reaching the server at all.
  • Contrast — hard denies work correctly and instantly: deny permission rules and policies (tool.use, e.g. shell:dd *) block execution with Blocked by configuration policy.

Additional Context

Server log excerpt (~/.local/share/opencode/log/opencode.log):

timestamp=2026-10-17T17:46:44.050Z level=INFO message="spawning process" command=/usr/bin/bash args="[\"-c\",\"rm -f /tmp/opencode/perm-test-does-not-exist && echo ...\"]"
timestamp=2026-10-17T17:46:46.839Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:49.486Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:50.069Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:50.218Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:50.313Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:50.417Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:50.620Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:50.719Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404
timestamp=2026-10-17T17:46:52.355Z level=INFO message="Sent HTTP response" http.method=POST http.url=/api/session/ses_&lt;redacted&gt;/permission/per_&lt;redacted&gt;/reply http.status=404

Config in effect (rules ordered as documented):

"permissions": [
  { "action": "shell", "resource": "rm *", "effect": "ask" },
  ...
]
"policies": [
  { "action": "tool.use", "resource": "shell:dd *", "effect": "deny" },
  ...
]

Additional notes:

  • The session ID in the reply URL matches the session that issued the tool call (verified against the local session database), so this is not a session/ID mismatch.
  • Second reproduction had no reply attempts logged; the command simply executed, which suggests the request may already be resolved (auto-allowed) before the client can answer.
  • Impact: any workflow relying on ask as a human-in-the-loop confirmation gate is fail-open in this version; irreversible commands execute without an effective approval step.
  • Local workaround: enforce policies (tool.use hard-denies) plus permissions deny for irreversible operations, and confirm dangerous actions in chat before running them.

Activity

  1. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I tried to reproduce this on @opencode/cli@2.0.32 (Linux, simulated model) and couldn't:

    • With only { "action": "shell", "resource": "rm *", "effect": "ask" } in permissions, the agent's rm -f /tmp/opencode/<file> call showed a "Permission required" prompt. The file was not deleted while the prompt was open.
    • Choosing "Allow once" was accepted (no 404), and only then did the command run.
    • I got the same result when I added the rule to the config while the server was already running. That shows the command was blocked, but I didn't confirm separately that the live edit itself took effect.

    The shell tool waits for the permission decision before it starts the process, and the reply endpoint only returns 404 when the request is no longer pending. So something in your setup is probably clearing the request, or the command is reaching the shell another way. These would help narrow it down:

    1. Your full permissions list (and policies), without the .... Rules are evaluated last match wins, so a later rule that also matches (for example shell → * → allow) overrides an earlier rm * → ask.
    2. Which agent the session used, and whether the command came from the shell tool directly or through Code Mode (execute) or a subagent.
    3. The server log for the whole window around one attempt, not just the HTTP lines: everything from the tool call to the last reply, especially any lines mentioning permission, config reload or location/instance disposal.
    4. Whether it still happens after restarting the service with the rule already in place, without a live config edit.

    You can reply here or edit the issue. If you edit it, please also leave a comment so we know it's updated.

    Possibly related, most likely first:

  2. ExNihil14 commented on Oct 18, 2026

    @ExNihil14
    Author

    Thanks for the detailed follow-up — answers below, plus a control test we ran on a fresh process.

    1. Full permissions / policies (no ...)

    The revision in effect at report time — 30 permission rules + 12 policies — verbatim (written by the same session a minute before the failing tool call; the server reloaded it 31 s before the tool ran):

    {
      "permissions": [
        { "action": "shell", "resource": "rm *", "effect": "ask" },
        { "action": "shell", "resource": "dd *", "effect": "ask" },
        { "action": "shell", "resource": "mkfs*", "effect": "ask" },
        { "action": "shell", "resource": "fdisk*", "effect": "ask" },
        { "action": "shell", "resource": "parted *", "effect": "ask" },
        { "action": "shell", "resource": "fastboot *", "effect": "ask" },
        { "action": "shell", "resource": "adb * reboot*", "effect": "ask" },
        { "action": "shell", "resource": "adb * uninstall *", "effect": "ask" },
        { "action": "shell", "resource": "adb * shell rm *", "effect": "ask" },
        { "action": "shell", "resource": "adb * shell pm *", "effect": "ask" },
        { "action": "shell", "resource": "adb * shell cmd *", "effect": "ask" },
        { "action": "shell", "resource": "adb * shell settings put *", "effect": "ask" },
        { "action": "shell", "resource": "reboot*", "effect": "ask" },
        { "action": "shell", "resource": "shutdown*", "effect": "ask" },
        { "action": "shell", "resource": "poweroff*", "effect": "ask" },
        { "action": "shell", "resource": "pkill *", "effect": "ask" },
        { "action": "shell", "resource": "killall *", "effect": "ask" },
        { "action": "shell", "resource": "git push *", "effect": "ask" },
        { "action": "shell", "resource": "apt remove *", "effect": "ask" },
        { "action": "shell", "resource": "apt purge *", "effect": "ask" },
        { "action": "shell", "resource": "apt-get remove *", "effect": "ask" },
        { "action": "shell", "resource": "apt-get purge *", "effect": "ask" },
        { "action": "read", "resource": "*adbkey*", "effect": "deny" },
        { "action": "read", "resource": "*.key", "effect": "deny" },
        { "action": "read", "resource": "*id_rsa*", "effect": "deny" },
        { "action": "read", "resource": "*id_ed25519*", "effect": "deny" },
        { "action": "read", "resource": "*service.json", "effect": "deny" },
        { "action": "edit", "resource": "*adbkey*", "effect": "deny" },
        { "action": "edit", "resource": "*.key", "effect": "deny" },
        { "action": "edit", "resource": "*service.json", "effect": "deny" }
      ],
      "policies": [
        { "action": "tool.use", "resource": "read:*adbkey*", "effect": "deny" },
        { "action": "tool.use", "resource": "read:*.key", "effect": "deny" },
        { "action": "tool.use", "resource": "read:*id_rsa*", "effect": "deny" },
        { "action": "tool.use", "resource": "read:*id_ed25519*", "effect": "deny" },
        { "action": "tool.use", "resource": "read:*service.json", "effect": "deny" },
        { "action": "tool.use", "resource": "edit:*adbkey*", "effect": "deny" },
        { "action": "tool.use", "resource": "edit:*.key", "effect": "deny" },
        { "action": "tool.use", "resource": "edit:*service.json", "effect": "deny" },
        { "action": "tool.use", "resource": "shell:dd *", "effect": "deny" },
        { "action": "tool.use", "resource": "shell:mkfs*", "effect": "deny" },
        { "action": "tool.use", "resource": "shell:fdisk*", "effect": "deny" },
        { "action": "tool.use", "resource": "shell:fastboot *", "effect": "deny" }
      ]
    }

    There are no allow rules in that revision or in the current file — every entry is ask or deny, so a later rule matching with allow can be ruled out. (Also, per the docs, when no rule matches the default is still to ask — hence the dialog.)

    The current file has 35/20; every addition since the report is an unrelated deny rule for secret paths:

    permissions (+5):
    { "action": "read",  "resource": "*/.config/gh/*", "effect": "deny" }
    { "action": "edit",  "resource": "*/.config/gh/*", "effect": "deny" }
    { "action": "read",  "resource": "*agentrouter.env*", "effect": "deny" }
    { "action": "edit",  "resource": "*agentrouter.env*", "effect": "deny" }
    { "action": "shell", "resource": "*agentrouter.env*", "effect": "deny" }
    
    policies (+8):
    { "action": "tool.use", "resource": "read:*/.config/gh/*", "effect": "deny" }
    { "action": "tool.use", "resource": "read:*agentrouter.env*", "effect": "deny" }
    { "action": "tool.use", "resource": "edit:*/.config/gh/*", "effect": "deny" }
    { "action": "tool.use", "resource": "edit:*agentrouter.env*", "effect": "deny" }
    { "action": "tool.use", "resource": "shell:adb * shell rm *", "effect": "deny" }
    { "action": "tool.use", "resource": "shell:adb * shell settings put *", "effect": "deny" }
    { "action": "tool.use", "resource": "shell:adb * shell pm clear *", "effect": "deny" }
    { "action": "tool.use", "resource": "shell:*agentrouter.env*", "effect": "deny" }
    

    2. Agent and tool path

    • Agent build, model opencode/deepseek-v4.1-flash-free#max.
    • Tool: the built-in shell tool called directly by the model (content[] = { "type": "tool", "name": "shell", ... }). Not Code Mode execute, not a subagent, no custom/MCP tool.
    • Two attempts in the same session: 17:46 and 17:48 UTC on 2026-10-17.

    3. Server log around the attempts (+ tool-part timings from the session DB)

    Single opencode serve (v2.0.32); log ~/.local/share/opencode/log/opencode.log; a web client was connected throughout (its event long-poll is visible).

    Attempt 1 — rm -f /tmp/opencode/perm-test-does-not-exist && echo "…":

    time (UTC) event
    17:46:27.636 tool part created
    17:46:28.439 tool part ran
    17:46:44.050 spawning process — the command executes, 15.6 s after ran, with no decision from any client
    17:46:44.119 tool part completed
    17:46:46.839 … 17:46:52.355 client reply attempts: 9 × HTTP 404 on POST …/permission/per_…/reply
    17:46:58.158 client long-poll GET /api/event ends (200, span 184735 ms)

    Excerpt:

    17:46:44.050Z INFO run=6ed853aa message="spawning process" command=/usr/bin/bash args="[…\"rm -f /tmp/opencode/perm-test-does-not-exist …]" cwd=/root
    17:46:46.839Z INFO run=6ed853aa message="Sent HTTP response" http.span=12 … POST /api/session/ses_<redacted>/permission/per_<redacted>/reply → 404
    … 9 in total: 46.839, 49.486, 50.069, 50.218, 50.313, 50.417, 50.620, 50.719, 52.355
    

    Other facts from the same window:

    • The log contains nothing between 17:46:17.464 and 17:46:44.050 — no permission-related lines at all (for the incident, the only permission mentions in the log are the nine 404s), no location/instance-disposal or revocation lines, no config reload in that window.
    • Config activity just before: the same session live-edited opencode.json (writes 17:44:41 / 17:45:26, edit 17:45:50) and the server emitted config.updated at 17:45:21.793, 17:45:45.781 and 17:45:57.420 — 31 s before the tool ran.
    • Attempt 2 (2 min later, rm -f /tmp/opencode/perm-test-2-does-not-exist …): ran 17:48:49.067 → spawning process 17:48:51.030 (~2.0 s), again with no decision and zero reply attempts.

    4. Fresh-process control (rule in place at startup, no live edit)

    We can't restart the production server (it hosts the session this is typed from), so we started a second opencode serve on another port, same config on disk, rules present from startup, no live edits afterwards, and drove it through the same API surface the web UI uses (session.prompt → session.permission.list → session.permission.reply), with an extra event-stream client attached:

    Test Result
    rm * ask → wait, no decision pending request created; tool part running; no execution for ~168 s (2m48s) — no fail-open
    reply {"decision":"once"} accepted (no 404) → command executed ~0.9 s later
    reply {"decision":"reject"} (separate run) accepted → tool aborted ("The user declined this tool call"); command not executed
    same probe on our other long-running process (background service, no live edits in play) pending stayed up ≥60 s, no fail-open; late reject accepted; command not executed

    So on a clean process the gate behaves exactly as designed: it blocks until a decision, and replies are accepted. The fail-open occurred only on the long-running web-serving instance that had processed live config edits ~30–100 s earlier — correlation, not proof, but it makes the live-edit/instance-state path the prime suspect (and the 404-forever replies look related to #52846 / #55678).

    We'll re-test right after the next natural restart of the production server and report back; happy to script any further probes you'd like in the meantime.

    Minor observation: the permission path emits no lifecycle logs at all (request created/decided/dropped) — adding those would make this much easier to diagnose.

  3. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the complete config, the timings and the control test, that helped a lot. I still couldn't get the gate to fail on 2.0.32: with your rm * → ask rule, set at startup or added while the server was running, the command stayed blocked until a reply. That matches your fresh-process result.

    Live config reloads don't touch pending permission requests. But there is one thing in 2.0.32 that would produce exactly what your log shows:

    • The web/desktop app has an "auto-accept permissions" setting (an app-wide default in Settings → Permissions, plus a per-project override in project settings). It is stored in that client only, not in opencode.json. When it's on, that client answers every new permission request on the server with "Allow once" as soon as it sees it. It also re-checks for pending requests each time it reconnects.
    • Successful replies are not logged. opencode serve only logs HTTP responses with status ≥ 400, so an approving reply (200) would leave no trace in opencode.log. Only the 404s show up.

    That would explain both attempts. On attempt 2, an auto-accepting client approved the request right away (about 2 s), so nothing else ever replied. On attempt 1, the approval came 15.6 s later, which fits a client reconnecting and re-checking. Either way, the prompt still open on your phone was out of date, so its replies got 404 because the request had already been answered. Your second server had no app clients attached, which would explain why the gate held there.

    Could you check every client that connects to the production server: other browser tabs or browsers, other devices, the desktop app? See whether "auto-accept" is on in its settings, either as the default or for this project. If you find one, does turning it off make ask hold again?

    If none has it on, please reply here. A run with the server's log level set to DEBUG (OPENCODE_LOG_LEVEL=DEBUG) would also help.

    Related, most likely first:

    Your point about the missing permission lifecycle logs (created / answered / dropped, and which client answered) is a good one. It would have made this much easier to pin down.

  4. ExNihil14 commented on Oct 18, 2026

    @ExNihil14
    Author

    Follow-up with a controlled probe set — including a re-creation of the live-edit condition — on the same server process that produced the original failure (no restart since: run=6ed853aa, cli starting … args=["serve"…] at 2026-10-17 15:01:31Z, ~2h45m before the incident; still the same process now, uptime ≈30.5 h).

    Auto-accept: checked in the web client (Settings) — the setting is off, and no silent auto-approval occurred in any of the runs below, so the auto-accept path can be excluded for the current state.

    Probes (issued from the same session as the original report; user watching the client throughout; each probe is the same rm -f … shape as in the report):

    Run Setup Result
    P1 plain probe dialog appeared → user approved → executed (spawning process at 21:16:45.255Z)
    P2 plain probe, user deliberately did not touch the dialog dialog appeared → user waited ~10 s, then rejected → {"type":"aborted","message":"The user declined this tool call"}; no spawn in the log
    P3 live-edit replication: opencode.json edited 21:30:07Z → config.updated 21:30:08.409Z → identical probe ≈40 s after the reload (original incident gap: 31 s) dialog appeared → user waited ~10 s, then rejected → aborted; no spawn in the log

    In all runs the client's replies were accepted (approve → executed; reject → aborted); the 404-on-reply behavior from the original incident did not recur.

    Result: the fail-open does not reproduce on the original process, even with the fresh config-reload condition re-created — so it is not simply "live reload → the next rm slips through". To be explicit: we still treat the original incident as a real fail-open — a tool call that should have waited for a decision executed without one, and the client's replies were rejected with 404. We just cannot reproduce it under any condition we can re-create on the running instance. We can't restart this server right now (the session runs on it), but we will re-run the same probe set (with DEBUG) after the next natural restart and report back.

    Two notes for the record:

    1. The temp rule used for the live edit was reverted immediately; the config file is byte-identical to before the test (sha256 7ca059af…), with both reloads logged (config.updated at 21:30:08.409Z and 21:33:55.311Z).
    2. Still +1 on permission lifecycle logs (request created / decided by … / dropped / expired) — from the logs alone we cannot tell who (if anyone) answered during the original incident.
  5. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for running the probes on the original process, including the config-reload case. It's very useful to know the gate held there too, with replies accepted and no 404s, and with no spawn when you rejected.

    One thing to rule out about the auto-accept check: that setting is saved per client: per browser profile or per desktop app install, plus an optional per-project override. Checking Settings in one web client only covers that client. So the question for the original incident is whether, at 17:46 on Oct 17, any other client was connected to that server, for example:

    • another browser or browser profile (e.g. on a laptop or desktop)
    • the desktop app
    • another device

    If one of them had auto-accept on, as the default or for this project, it would have approved the request silently. The server doesn't log successful (2xx) replies, so it wouldn't appear in opencode.log. The prompt left on your phone would then be out of date, and its replies would get 404. That matches what you saw. If you're sure no other client was connected then, that's useful too: please say so.

    Otherwise, re-running the probes with OPENCODE_LOG_LEVEL=DEBUG after the next restart is the right next step. If it happens again, please post the log for the whole window from tool call to spawn, and list which clients were connected.

    Point taken on permission lifecycle logs (created, answered and by whom, dropped). Without them a case like this can't be diagnosed after the fact.

  6. ExNihil14 commented on Oct 18, 2026

    @ExNihil14
    Author

    Thanks — the per-client nuance is exactly right, and we've checked it.

    Were other clients connected at 17:46 UTC on Oct 17? From the server log: no.

    • Counting /api/event streams (end time minus span), at 17:46:27, 17:46:44 and 17:48:49 there is exactly one active stream. The whole window is a single long-poll chain with seamless handovers: 17:09:22→17:38:41, 17:38:41→17:43:53, 17:43:53→17:46:58, 17:46:58→17:52:04, … — each next poll starting the second the previous one ended.
    • The only duplicate near the incident is a 5-second stream at 17:52:04 (~5 minutes after the failed attempts) — a reconnect artifact. Multi-stream moments elsewhere in that day's log are the same class of brief handover blips / late-closed leftovers of reconnects; nothing sustained.
    • In 17:35–18:05 the only requests are the event streams and the nine permission-reply POSTs (the 404s). To answer a permission request, a client must first receive the event (which needs a stream) or fetch the pending list (an HTTP request) — neither exists. Even with 2xx replies not logged, a silent approver would have had to leave a trace to obtain the request.
    • Remote clients were impossible then: the server was reachable only on 127.0.0.1 (no LAN listener; the TCP forwarder for laptop access was set up a day later). Any client would have been on the phone itself.

    So: we're sure no other client was connected during the incident. The phone client's auto-accept is off (checked in Settings), and that client did get the dialog and tried to reply (the 404s). The silent resolution therefore can't be attributed to a client — it still points to the request being resolved server-side without a decision. Plan unchanged: DEBUG re-run after the next natural restart; if it recurs, we'll post the full window (tool call → spawn) plus a stream dump like the above.

  7. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks, that's a careful analysis. With the server bound to 127.0.0.1 only and a single event stream at 17:46, another client approving the request is much less likely.

    One caveat about using the log as proof: in 2.0.32 the server logs only responses with status ≥ 400 (packages/server/src/process.ts, errorResponseLogger). So a GET of the pending permission list or a successful POST …/reply (200) would leave no line in opencode.log, and a quiet window doesn't prove no request was made. Your event streams probably show up only because of how long-polls end. That doesn't change your conclusion much, but it does leave a gap.

    Your next-restart plan is the right step. If it happens again, these would help most:

    • OPENCODE_LOG_LEVEL=DEBUG on the server, and the full log from the tool call to spawning process
    • the tool part's states and timestamps from the session DB (as you posted before)
    • whether any Always allow was chosen earlier on that server (saved approvals are per project and add allow rules), and whether the agent or session had extra permissions set

    Without lifecycle logs for permissions (created / answered / dropped) we can't say any more from the current evidence. Your request for those logs is noted for the maintainers.

  8. samvallad33 commented on Oct 19, 2026

    @samvallad33

    Hi ExNihil14,

    Did that rm really spawn before your Allow once tap ever reached the server?

    I hit the same kind of problem where an agent kept running a command it had been allowed once. If that is what happened on your end, would you want to see something combining memory, authority, and security so each shell command is checked before it runs and an approval works as a one-use permit?

    Worth a quick try on that Termux box:
    npx @vestige/init
    It checks every command your agent tries before it runs and keeps a hash-chained receipt of each decision, so you can trace what let a command through instead of digging in server logs.

    Sam

  9. ExNihil14 commented on Oct 20, 2026

    @ExNihil14
    Author

    Thanks — I checked both points, and I want to correct one overstatement in my
    earlier comment.

    Short version: no saved “Always allow”, no session extra permissions, and the
    same 2.0.32 server process; no DEBUG yet. I have one separate timed-out approval
    case with a timestamped 200 reject, but the fail-open ordering is not proven,
    because successful 200 replies are not logged. Please log reply status + request
    id.

    1. Saved “Always allow” / extra permissions

    On the incident server (same 2.0.32 process, still run=6ed853aa, started
    2026-10-17T15:01:31Z):

    • The saved-approval table in opencode.db (permission) has 0 rows across all
      projects
      (SELECT count(*) FROM permission → 0). This is the table used for
      “Always allow”; it is not just empty for the incident project. I did not delete
      any approval rows; the probes used once/reject, never always.
    • The incident session (ses_…) has session_v2.permission = NULL; across all
      sessions, zero have a non-null permission field.
    • At incident time the config had 30 permissions + 12 policies, all ask or
      deny; there were no allow rules. Current config has 38 + 23, still no
      allow. There is no project-level .opencode override in /root.
    • The session used agent build, model
      opencode/deepseek-v4.1-flash-free#max; no per-session extra permissions.
    • The only client had auto-accept off; there was exactly one /api/event stream,
      and the server was bound to 127.0.0.1.

    So the known “Always allow” / saved-approval / session-extra-permission paths are
    excluded for the incident.

    2. DEBUG / restart

    The production server is still the same process that produced the incident:
    run=6ed853aa, serve started 2026-10-17T15:01:31.736Z; it has not been
    restarted. I cannot switch it to DEBUG retroactively, and I will not restart it
    while it hosts the active session.

    A second server with OPENCODE_LOG_LEVEL=DEBUG would only exercise a fresh
    process; that case was already covered in the control run and in P1–P3 after the
    incident. I will repeat the probe after the next natural restart and post the full
    window (tool call → spawn), tool-part timestamps, and the connected-client list.

    3. Correction: the server log does not prove “no decision”

    You are right that successful 200 replies are not logged (only responses with
    status ≥ 400 are). My earlier “no decision” conclusion was based on the client UI,
    not on the server log.

    The 15.6 s gap between tool ran (17:46:28.439Z) and spawning process
    (17:46:44.050Z) is compatible with a successful once reply at ~44.050 that
    left no log line, followed by stale/duplicate client retries that returned 404 at
    17:46:46.839–17:46:52.355. The second run (~2.0 s) has the same ambiguity. So
    I should not call the fail-open proven until I have client-side network/event
    evidence; what is proven is the stale prompt and the 404s.

    For comparison, in a separate 2.0.32 case with a local approval proxy
    (unrelated to the incident) I do have timestamps: ask at 17:21:19, 15-minute
    timeout at 17:36:19, reject reply HTTP 200 (rc=0), then the tool call
    returned permission.rejected and no spawn. This is separate corroboration
    that a 200 reply leads to rejection and no spawn in that setup; it only made the
    200 visible and did not participate in the affected environment. It does not
    settle the ordering question, which stays unproven because the client-side 200 is
    not logged. After the timeout the request disappeared from the pending API —
    consistent with the stale/404 path.

    4. Source-level check (v2.0.32)

    • shell.ts@v2.0.32#L137-L145:
      permission.assert is called before the process is spawned, so the normal
      shell path is not fire-and-forget.
    • permission.ts@v2.0.32#L231-L263:
      assert waits on a Deferred;
      reply #L265-L282
      resolves it only if the request is still in pending;
      close #L133-L146
      fails pending requests with DeclinedError and publishes Replied; a missing
      id yields 404.
    • One branch worth testing: the shell permission assertion is guarded by
      if (parsed.commands.length > 0) at
      shell.ts@v2.0.32#L137.
      If the shell scanner returns zero commands for a command that still reaches
      the shell, there is no permission.assert. Could the prompt in the incident
      have been an external_directory or other request while the rm path was
      parsed as zero commands? That is a concrete, testable bypass path worth
      checking.

    The normal path therefore requires one of: (a) evaluate returned allow;
    (b) assert was skipped; or (c) a successful 200 resolved the request. I have
    excluded (a) by config/session/DB. (b) is testable. (c) is invisible in the
    current log and needs client-side evidence.

    5. What would help

    • Permission lifecycle logs at DEBUG/always: request created (id, action,
      resource hash, session, agent), wait start, reply received (decision, status,
      client), resolved, dropped/expired, and 200 replies (currently only ≥400).
    • A client-side network/event log for the original window, specifically whether
      a reply was sent at ~17:46:44.050Z (or ~17:48:51.030Z for the second run).
    • If useful, I can run a controlled test on a second server with a shell input
      that the parser handles differently, and with a simulated client that
      deliberately does not reply. No production restart.

    +1 on #50595 / #53272: terminal permission.replied on cleanup and a client
    treating 404 as terminal. But this report still needs the ordering proof:
    was there a 200 before the spawn, or did the tool proceed without one?

  10. ExNihil14 commented on Oct 20, 2026

    @ExNihil14
    Author

    Sam, thank you for reading the report so closely — your first question is
    exactly the right one, and I appreciate you asking it rather than assuming.

    Honestly, I don’t know yet whether the rm spawned before an “Allow once”
    reached the server. The server does not log successful 200 replies; all I can
    see is the tool running, the process spawning, and then a series of 404s. There
    is a 15.6-second gap between the tool starting and the process spawning; a
    successful once in that gap would fit the evidence, and so would a
    dropped/stale request. I am trying to get client-side network/event logs to
    settle it. So the fair answer is: not established. I can’t confirm the
    fail-open ordering, and I can’t rule it out.

    In a separate 2.0.32 case where I do have timestamps — a local approval proxy
    (unrelated to the incident) in front of the same version — the ask went out at
    17:21:19, timed out 15 minutes later at 17:36:19, the reject reply returned
    HTTP 200, and the tool call then returned permission.rejected with no spawn.
    That shows the reject path working; it still doesn’t prove the reverse ordering,
    because the client-side 200 isn’t logged anywhere. It only made the 200 visible;
    it did not participate in the affected environment. So your “probably” is
    exactly my “probably”. After the timeout the request disappeared from the
    pending API, which is consistent with the stale/404 path you and others
    describe.

    Your own case — “an agent kept running a command it had been allowed once” —
    sounds like a different and very important bug: approval reuse / a one-use
    permit not being enforced. If that is what you saw, I would genuinely like to
    compare notes. A minimal reproduction with the version, config, logs, and
    whether the decision was once or always would help; if the same request or
    permit was reused, it could be related to the stale 404, but I don’t want to
    conflate the two.

    I also read the Vestige README and the installer source. The idea of a guard
    outside the model, exact-command approval, and hash-chained receipts is
    thoughtful and clearly relevant to the broader problem; it could be useful
    defense-in-depth. What it cannot do is fix OpenCode’s internal
    permission.assert / pending-request lifecycle — it would add a layer that
    could mask the server-side bug, which is why I would not treat it as a
    diagnosis.

    I can’t run npx @vestige/init on this box. This is the production
    Termux/PRoot environment, with device access and credentials; as its README and
    installer source document, it writes OpenCode config, installs binaries, starts
    an MCP server, and adds a hook. The README also says the gate refuses to install
    from inside an AI agent’s shell, and this shell is run by the agent itself. That
    is not a judgment on Vestige; it is about not installing a new guard into the
    system I am trying to observe, and about not making a system change on the
    production box.

    I would be happy to review the exact commit and security model, or test in a
    disposable environment, if one is available. If I do test it, I would use a
    disposable profile and share the exact checklist: before/after config hash,
    which MCP entries and hooks were added, reply codes, and pending-state polls.

    If you are the author/founder — I believe you are — thank you for being open
    about it; a quick note in the thread would keep things transparent. Either way,
    your question sharpened this report, and I appreciate it.

  11. opencode-agent commented on Oct 21, 2026

    @opencode-agent
    Contributor

    Thanks for all the detailed investigation, @ExNihil14. Your probe tables and log analysis made this much easier to narrow down.

    We couldn't make the ask gate fail on 2.0.32: the command stayed blocked until a reply, with the rule set at startup or added while the server was running. Your own probes on the original server process, including the config-reload case, showed the same. Without a way to trigger it again or more data from the original incident, there's nothing concrete to fix yet, so I'm closing this for now.

    If it happens again on your debug re-run after a restart, please comment here or open a new issue that links #56470, with:

    • the server log at OPENCODE_LOG_LEVEL=DEBUG from the tool call to spawning process
    • the tool part's states and timestamps from the session DB
    • which clients were connected, and whether any Always allow had been chosen on that project

    Your suggestion of permission lifecycle logs (created / answered by whom / dropped) is noted for the maintainers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions