やってみた 2026年10月18日

opencode v2をモックのLLMにつないで試したら、planエージェントでもshell経由の書き込みは確認なしで通った

XECIN TypeScriptAIエージェントopencode権限

GitHubのトレンドを眺めていたら、anomalyco/opencode が3位にいました。説明は「The open source coding agent.」の一文だけ。確認した時点でスターは 215,926 です。

ターミナルで動くコーディングエージェントは、CI に組み込んで使う話もよく聞くようになりました。自分が気になったのは賢さよりも「どこまで勝手にやるのか」です。README には、読み取り専用の plan エージェントについて「Denies file edits by default」「Asks permission before running bash commands」と書いてあります。公式にはこう書いてあるんですが、実際に動かすとどうなるのか。そこを確かめたくて触ってみました。

APIキーは使っていません。コンテナの中に OpenAI 互換のモックサーバを自分で立てて、opencode からはそれが LLM に見えるようにしました。モックなら「何が送られたか」を全部保存できますし、モデルに好きなツール呼び出しをさせられます。指示を守らないモデルの役もできる、というのが今回のミソです。

試した環境

  • 使い捨ての Docker コンテナ(node:22-bookworm、Debian 12.14、Node v22.23.1)。メモリ4GB・CPU2の制限つき
  • opencode v2.0.32(README の npm i -g @opencode/cli@latest で導入)
  • ソースの確認用に anomalyco/opencode を浅く clone(HEAD 214fb7fd、2026-10-18 時点)
  • LLM 役: コンテナ内 127.0.0.1:4000 の自作モック。台本どおりに、文章かツール呼び出しを1つだけ返す

最初の小さな驚きはインストールでした。README の npm コマンドで入るのは @opencode/cli の 2.0.32 です。一方、もう一つの opencode-ai パッケージの最新は 1.19.3 で、GitHub の最新リリース(v1.19.3)もこちらと同じでした。README どおりに入れると v2 系、リリースページを見ると v1 系、という状態だったりします。以下の結果はすべて v2.0.32 のものです。

# 導入(README の手順そのまま)
$ npm i -g @opencode/cli@latest
added 5 packages in 6s                # 実測 6,431ms
$ opencode --version
opencode v2.0.32
$ ls -la /usr/local/lib/node_modules/@opencode/cli/bin/
-rwxr-xr-x 2 root root 211842528 Oct 18 12:06 opencode.exe    # 中身は Linux の ELF。名前だけ .exe

# 「こんにちは」を1回だけ送る
$ opencode run "こんにちは"
こんにちは。モックです。
# rc=0 elapsed_ms=1050

# モックが受け取った要求(自作モックのアクセスログ)
{"n":1,"url":"/v1/chat/completions","bytes":2376,"stream":true,"model":"mock-1","n_tools":0,"n_msgs":2,"last_role":"user","action":"text"}
{"n":2,"url":"/v1/chat/completions","bytes":22163,"stream":true,"model":"mock-1","n_tools":12,"n_msgs":2,"last_role":"user","action":"text"}

モックへの向け先は、設定ファイルで provider を1つ足しただけです。

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "mock": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Mock",
      "options": { "baseURL": "http://127.0.0.1:4000/v1", "apiKey": "dummy-not-a-key" },
      "models": { "mock-1": { "name": "Mock 1" } }
    }
  },
  "model": "mock/mock-1"
}

正直なところ、ここは自分の使い方次第で詰め切れていない部分があります。今回は ~/.config/opencode/opencode.json に provider を書いただけで、権限(permission)の設定は一切足していません。後で書く plan の挙動は「何も設定しない既定のまま」の結果です。設定で plan の shell にも規則を足せば止まるはずだと思っていますが、書き方も含めて今回は試していません。

「こんにちは」1回で、要求は2回・24,539バイト

1回目の要求はツールが0個で、システムプロンプトの書き出しは「You are a title generator.」。会話に題名を付けるための要求でした。本体は2回目で、こちらに12個のツール定義が付いています。中身をバイト数で分けると次のとおりです。

要求中身バイト数
1回目(題名づけ)システムプロンプト 2,119 + 入力「こんにちは」15 ほか2,376
2回目(本体)システムプロンプト8,216
2回目(本体)ツール定義12個(edit / glob / grep / question / read / shell / skill / subagent / webfetch / websearch / write / execute)13,521
2回目(本体)入力「こんにちは」15
2回目(本体)合計(max_completion_tokens: 32000 などのパラメータを含む)22,163

ツール定義だけで本体の約6割を占めます。いちばん大きいのは subagent の 2,696 バイトで、edit の 1,550、shell の 1,529 と続きます。README の plan の説明は「bash commands」ですが、v2.0.32 のツール名は shell でした。

システムプロンプトには作業ディレクトリ(/work/proj)、git リポジトリかどうか、Platform: linux、Today's date: Sun Oct 18 2026 が入っていました。プロジェクトのファイル一覧は入っておらず、置いておいた math.js の名前はどこにも出てきません。中身は必要になったらツールで読みに行く作りですね。

想定外その1: run が終わっても常駐プロセスが残る

opencode run は答えを返して終了コード 0 で終わったのに、ps を見ると opencode.exe serve --service が動き続けていました。次のケースを始める前(56秒後)にもまだ残っていたので、以降はケースごとに自分で止めています。

今思えば、opencode --help の最初に「—standalone Run with a private server instead of the background service」と書いてあったんですよね。v2 の run は、既定で裏の常駐サービスに乗る作りです。--standalone を付けた回は、終了後に何も残りませんでした。opencode service には start / status / stop などのサブコマンドもありますが、stop は今回試していません。CI のジョブや使い捨てのコンテナで回すなら、--standalone を付けておくのが無難だと思います。

本題: 同じツール呼び出しを build と plan に投げる

モックに返させるツール呼び出しは3種類です。write で note.txt を作る、shell で echo from-shell > /work/proj/shell.txt && echo wrote を実行する、read で .env を読む。これをエージェント(既定の build か plan か)と --auto の有無の組み合わせで、1ケース1プロセスで回しました。--auto は、ヘルプによると「明示的に拒否されていない許可を自動で承認する」フラグです。プロジェクトは毎回作り直しています。

エージェント—autoモックが返したツール呼び出し結果終了コード
build(既定)なしwrite で note.txt を作る確認なしで作成された0
planなしwrite で note.txt を作る拒否(Plan directory の外は不可)0
planありwrite で note.txt を作る拒否(同じメッセージ)0
planなしshell で shell.txt を作る確認なしで実行され、ファイルができた0
planありshell で shell.txt を作る同上0
buildなしread で .env を読む自動で拒否。中身はモックに届かない0
buildありread で .env を読む承認され、中身がモックへの要求に入った0

plan で write が止まるのは README のとおりです。気になったのは shell のほうで、README の「Asks permission before running bash commands」とは違い、--auto なしでも確認なしに実行されて shell.txt ができました。

理由は opencode debug agents で見えました。plan の権限は8行で、build と同じ規則に「edit は全部拒否、ただし Plan directory の中だけ許可」を足したものです。shell についての規則は無く、先頭の * に対する allow がそのまま効きます。clone したソースでも、packages/core/src/plugin/plan.ts が足しているのは question の allow と edit の deny、Plan directory の allow の3つでした。

# plan + write(--auto の有無に関係なく同じ)
✗ Write note.txt failed
Error: Cannot use write to modify files outside the Plan directory: /root/.opencode/plan

# plan + shell(--auto なしでも)
$ echo from-shell > /work/proj/shell.txt && echo wrote
wrote

# opencode debug agents で見た plan の権限(8行すべて)
{"action":"*","resource":"*","effect":"allow"}
{"action":"read","resource":"*.env","effect":"ask"}
{"action":"read","resource":"*.env.*","effect":"ask"}
{"action":"read","resource":"*.env.example","effect":"allow"}
{"action":"question","resource":"*","effect":"allow"}
{"action":"edit","resource":"*","effect":"deny"}
{"action":"edit","resource":"/root/.opencode/plan/*","effect":"allow"}
{"action":"browser","resource":"*","effect":"deny"}

じゃあ plan では shell の書き込みを何が止めているのか。plan のときだけ、要求に 460 バイトの system-reminder が1通増えていました(本体の要求は 22,173 から 22,669 バイトに増加)。その中に「Do not modify any other files or ask a subagent to do so.」という一文があります。つまり shell については、権限ではなく「モデルがこの指示を守るかどうか」に預けられている作りです。

ここは公平に書いておきたいところです。今回のモックは、わざと指示を無視するように作っています。実際のモデルなら、plan の指示に従って shell で書き込まないことがほとんどだと思います(本物のモデルでは試していません)。それでも「plan だから何も変わらない」と言い切れるのは edit 系のツールまでで、shell は柵の外にある。これは知っておいて損はないと思います。

想定外その2: 「ask」は run では自動拒否、—auto で全部承認

既定の権限で ask になっているのは .env と .env.* の read だけでした。対話できない opencode run でここに当たると、「この実行ではユーザーに許可を求められないので拒否した」というエラーがツールの結果としてモデルに返ります。人に聞けないなら断る、という安全側の作りで、ここは好感が持てました。

ただし --auto を付けると話が変わります。同じ read が承認され、試しに置いておいたダミーの値が、モックへの3回目の要求の tool メッセージにそのまま入っていました。ぶっちゃけ当たり前の挙動なんですが、--auto は「ask を全部 yes にする」スイッチだと覚えておいたほうがよさそうです。

# build + read .env(--auto なし)
! permission requested: read (.env); auto-rejecting
✗ Read .env failed
Error: This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action.
# rc=0

# 同じケースを --format json で実行し、tool_use の行だけ取り出したもの
tool= read status= error error= "This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."

# build + read .env(--auto あり): モックが受け取った3回目の要求の tool メッセージ
msg[3] role=tool bytes=73 head="Read file /work/proj/.env, lines 1-1\n1: DUMMY_TOKEN=not-a-real-secret-123"

もう一つ、表の終了コードの列を見てください。拒否された回も含めて全部 0 です。スクリプトから「ツールが拒否されたか」を知りたいなら、終了コードではなく --format json の出力で tool_use の status を見る必要があります。

試してみての所感

コンテナを立ててから捨てるまで308秒。モデル抜きでも、ここまで中身が見えるとは思っていませんでした。

  • 「こんにちは」1回で要求は2回・24,539バイト。システムプロンプトより、ツール定義12個(13,521バイト)のほうが重い
  • v2 の run は既定で常駐サービスに乗り、終了後もプロセスが残る。使い捨ての環境なら --standalone
  • plan が権限で止めるのは edit 系(write を含む)まで。shell は既定の権限では止まらず、プロンプトの指示頼み
  • run では ask が自動で拒否される。--auto はそれを全部承認に変える
  • 拒否されても終了コードは 0。見るなら --format json

自分が CI で使うなら、--standalone で回して --auto は付けず、作業ディレクトリに .env を置かない形にします。plan は「うっかり編集を防ぐ柵」くらいに考えて、shell まで閉じたいときは、今回のようなコンテナを外側にもう一枚かぶせるつもりです。README と v2 の挙動のずれは、そのうち追いつくのかもしれません。もっと良い方法があったら教えてください。