Commit e2bc035
committed
fix(opencode): runAsk() actually executes ctx.ask, not just returns the Effect
THE bug behind the Discord report. User configured agent permission
bash:
"*": deny
"terraform fmt *": allow
"terraform validate *": allow
"terraform plan *": allow
"terraform apply *": ask
and the agent ran shell commands not in the whitelist. With AFT bash
removed and OpenCode native bash restored, the same config behaves
correctly. Cause: a runtime-vs-types contract mismatch.
OpenCode's ToolContext.ask signature changed across versions:
- @opencode-ai/plugin@1.2.x: ask(input): Promise<void>
- @opencode-ai/plugin@1.14.x: ask(input): Effect.Effect<void>
(see opencode-source packages/plugin/src/tool.ts)
AFT was depending on @opencode-ai/plugin@^1.2.26 (Promise types) and used
`await ctx.ask(...)` everywhere. But the user's actual installed OpenCode
runtime ships @opencode-ai/plugin@1.14.x where the runtime contract is
Effect. Effects in @effect/io do NOT implement the thenable protocol —
`await someEffect` resolves silently to the Effect object **without ever
executing it**. So the deny/allow/ask evaluator never ran, the for-loop
that pushes patterns into permissionsGranted continued, the bash retry
sent permissions_granted=[<deny patterns>], permissions_granted_cover()
in Rust returned true, and the command executed unchecked.
Empirical confirmation:
$ bun run -e 'import {Effect} from "effect";
const e = Effect.fail(new Error("deny")).pipe(Effect.orDie);
console.log(await e); // resolves silently with the Effect object
await Effect.runPromise(e); // throws "deny"'
This affected EVERY ctx.ask call site in the opencode plugin — not just
bash. read/write/edit/apply_patch shims and the bash permission loop all
silently bypassed user permission rules.
Fix:
1. Bump @opencode-ai/plugin and @opencode-ai/sdk from ^1.2.26 to ^1.14.39
so types match the runtime contract. peerDependencies updated to
>=1.14.0 to make the requirement explicit.
2. Add effect@^3.18.6 as a direct dependency.
3. New shared helper packages/opencode-plugin/src/tools/permissions.ts
`runAsk(maybe: unknown)` that:
- duck-types Promise (.then is callable) → await directly (handles
old 1.2.x runtime where ask returns Promise)
- else treats as Effect → Effect.runPromise(maybe) (handles 1.14.x+
runtime where ask returns Effect)
This works regardless of which runtime version the user has installed.
On deny, runPromise rejects with the underlying defect (DeniedError
/ RejectedError) so callers can rely on try/catch as before.
4. Wrapped all 9 ctx.ask call sites in the OpenCode plugin:
- tools/bash.ts:38 (the main bash permission loop — direct user repro)
- tools/permissions.ts:askEditPermission (used by edit/write/apply_patch)
- tools/hoisted.ts:8 sites in read/write/edit/apply_patch shims and
the legacy mode:"write" path
5. Loosened aft_edit.execute return type from Promise<string> to
Promise<ToolResult> (the upstream type that's now string | { output,
metadata? }) since 1.14.x widened ToolResult. The OpenCode runtime
handles both shapes; we delegate to the inner aftEditTool directly
for the non-shim path so we shouldn't constrain it.
Pi plugin is unaffected — Pi has no permission system and its own bash
wrapper throws if Rust ever returns permission_required (which only
happens in OpenCode mode).
Verification:
- 592 lib + 502 integration Rust tests
- 45 aft-bridge + 380 Pi + 67 aft-cli + 679 opencode-plugin TS tests
- typecheck clean against new 1.14.39 plugin types
- biome lint clean
- build clean (bundled 7 modules)
For users with the same agent permission config, this means:
- `terraform fmt ...` → allowed silently (no change)
- `terraform apply ...` → user prompted (no change, this was already "ask")
- `git status`, `ls`, `cat`, etc. → DENIED (was: silently executed)1 parent 1ec8aaa commit e2bc035
5 files changed
Lines changed: 199 additions & 71 deletions
0 commit comments