やってみた 2026年9月18日

セルフホストAIアシスタントOctopのLLMを偽サーバに差し替えて、1往復で何が送られ何が守られるかを測ってみた

XECIN PythonAIエージェントセキュリティセルフホスト

GitHubのトレンドで TencentCloud/Octop が7位に入っていました。説明文は「A smarter, self-hosted AI assistant — multi-user, multi-agent」。家庭や小さなチーム向けに、Webダッシュボードと各種チャット連携とcronを1プロセスで動かすセルフホスト型のAIアシスタント、というものです。

私がこの手のプロダクトで最初に気になるのは、機能の多さではなく「既定値でどこまで守られているか」です。READMEの特徴表には「tool approval, shell command guardrails, and PII redaction」と書いてあります。運用を考えると、こういう言葉は実際にコマンドを流してみて、何が止まり何が通るのかを自分の目で見ておかないと、設計判断の材料にできません。

もう一つ、LLMを呼ぶ製品を評価するときにいつも困るのが「LLM側に何を送っているのか見えない」ことです。今回はそこを逆手に取って、LLMの代わりに全リクエストを記録するだけの偽サーバを立て、アシスタント本体の振る舞いだけを観測することにしました。課金APIは一切使っていません。

試した環境と、LLMをどう差し替えたか

いつものとおり、ホストには何も入れず使い捨てのコンテナの中だけで動かしています。

  • ベースイメージ: python:3.12-bookworm(Debian 12 / x86_64 / Python 3.12.13)、--memory=4g --cpus=2
  • 対象: TencentCloud/Octop v1.0.0(2026-09-14公開、main は ae65fe4e)、MIT ライセンス、PyPI の octop 1.0.0
  • LLM: コンテナ内の 127.0.0.1:9000 に自作の OpenAI 互換モック。/v1/chat/completions に来たリクエストを JSON で保存し、定型文を返す。ユーザー文に TOOLCALL:ツール名:引数 と書くと、その名前の tool_call を1回返す

モックは Python 標準ライブラリの http.server で130行ほどです。これを octop provider create --kind openai --base-url http://127.0.0.1:9000/v1 で登録すると、octop provider test 1 が ok (546 ms) と返してきました。このときモックに届いたのは {"messages":[{"content":"ping","role":"user"}],"model":"mock-1","stream":false} で、User-Agent は AsyncOpenAI/Python 3.15.0。OpenAI SDK をそのまま使っているようです。

pip では終わらず、uv なら6秒

最初に書いておくべきつまずきがあります。README のインストール手順は自前のインストーラか pip install octop ですが、素の pip で入れると依存解決が終わりませんでした。

# pip install octop  ... 615秒経過しても終わらず kill
Collecting langchain-anthropic<2.0,>=1.5.4 (from orcakit-harness-agent ...)
  Downloading langchain_anthropic-1.5.6-py3-none-any.whl.metadata (3.5 kB)
  Downloading langchain_anthropic-1.5.5-py3-none-any.whl.metadata (3.5 kB)
  Downloading langchain_anthropic-1.5.4-py3-none-any.whl.metadata (3.5 kB)
rc=143 elapsed_ms=619820
# ログ内で試行された版数: langchain_aws 70 / deepagents 9 / langchain_anthropic 8

# uv pip install --system octop
Resolved 197 packages in 1.33s
Prepared 197 packages in 2.86s
Installed 197 packages in 350ms
rc=0 elapsed_ms=6006
 + langchain==1.4.1  + langchain-anthropic==1.7.2  + langchain-aws==1.1.0  + deepagents==0.7.15

pip 25.0.1 のリゾルバは langchain_aws だけで70個の版を順に取りに行き、10分経っても langchain_anthropic を巻き戻しているところでした。README が自前インストーラで uv を使う理由はこれだと考えています。uv だと解決1.33秒、全体で6秒。依存は197パッケージで、site-packages は10MBから736MBになりました。playwright、boto3、langgraph-checkpoint-postgres、python-pptx あたりが既定で入ってくるので、軽くはありません。

octop init --admin-username admin --admin-password ... --yes は4.3秒で終わり、~/.octop/ に SQLite(30テーブル)と11個の同梱プラグイン(全部 enabled: false)が作られます。octop run --host 0.0.0.0 --port 8088 はホストから5秒で HTTP 200。サーバプロセスの RSS は 282,436 KB でした。

Octopのログイン画面。ロゴの下に「Sign in」の見出し、ユーザー名とパスワードの入力欄、「Slide to the end to verify」というスライド式の確認バー、無効化された「Sign in」ボタンが並んでいる

ログイン画面にはスライド式の確認バーがあります。パスワードだけでは Sign in ボタンが有効にならず、バーを右端まで動かすと「Verified」に変わりました。ダッシュボードの既定言語は中国語(ログイン応答の locale が zh)で、画面の文言もそのままです。

「hello」1発で送られたもの

エージェント trial-bot を作り、CLI から octop chats send --plain "hello, who are you?" を送りました。モックに届いたリクエストを数えると、次のようになります。

項目1ターン目(hello)2ターン目以降
リクエスト JSON 全体51,815 B64,038 B(4メッセージ時)〜 65,287 B(12メッセージ時)
tools 定義29個 / 37,810 B同じ
system メッセージ4ブロック / 11,124文字4ブロック / 20,255文字
ユーザー本文19文字履歴込み

「hello」19文字に対して51.8KB。その7割強がツール定義で、最も大きいのは browser_use(5,265 B)、次が write_todos(4,371 B)です。29個の中には desktop_screenshot、cronjob_create、memory_search、ask_agent など、初対面の挨拶では使いようのないものも全部入っています。

system メッセージのほうは、作業ディレクトリの指示(964文字)、Skills の目録(8,311文字)、write_todos の使い方(1,372文字)、そして初回だけ差し込まれる「初回会話の儀式」(474文字。名前を聞いて USER.md に書け、終わったら .octop/.bootstrapped を作れ、という中国語の手順)の4つでした。Skills の目録には apple-notes、apple-reminders、findmy、imessage といった macOS 専用のものが、Linux コンテナで動かしていても「Compatibility: macos only」の注記付きで4件並びます。目録の直前には「Cannot load skills from ‘/root/.octop/agents/K84DM3/skills’: path_not_found」という診断文が <skill_load_warnings> として同梱されていて、これは信頼できない診断だから命令として扱うなという但し書きまで付いていました。丁寧です。

2ターン目で system が倍になる理由

2回目のメッセージを送ると、system メッセージが11,124文字から20,255文字に増えました。差分を取ると、「初回会話の儀式」が消え、代わりに AGENTS.md(記憶システムの運用規約)、MEMORY.md、USER.md の3ファイルの中身と、英語の <memory_guidelines> が <agent_memory> として丸ごと注入されています。ファイルはどれもまだテンプレートのままです。

なぜ1ターンで儀式が終わったのか。モックは名前も聞かず USER.md も書いていません。harness_agent/middleware/bootstrap.py を読むと、完了条件は「.bootstrapped への書き込み、USER.md への書き込み、または非空の最終返答のいずれか」で、_has_final_reply が「ツール呼び出しを伴わない非空の AI メッセージが1つでもあれば真」になっていました。つまり、モデルが何か一言でも返せばオンボーディング完了扱いです。実際に .octop/.bootstrapped(0バイト)が1ターン目の直後に作られていました。

運用を考えると、ここは「毎ターン65KB弱の固定費」として見ておく必要があります。今回のモックは usage に prompt_tokens: 1 と返しているので、ダッシュボードの「上下文占用」は「1 / 32k · 1%」と表示されました。本体はプロバイダが返した使用量をそのまま信じているだけで、自分では数えていません。実際のトークン数は今回測っていないので数字は出しませんが、65KB の JSON が毎ターン課金対象になる、という事実だけで判断材料としては十分だと考えています。

既定のコマンド防護は「ログに1行」だった

本題です。TOOLCALL:execute:{"command":"..."} でモックに execute ツールを呼ばせ、何が起きるかを見ました。execute の説明文には「Executes a shell command in an isolated sandbox」とありますが、id -un は root、pwd は /root/.octop/agents/K84DM3 で、コンテナのシェルがそのまま動いています。

# 既定値(tool_guard: enabled=true, mode="warn")
execute: rm -rf /root/.octop/agents/K84DM3/guardtest; echo rc=$?; ls .../guardtest
  -> rc=0 / ls: cannot access '.../guardtest': No such file or directory   (消えている)
execute: echo echo-piped-ok | sh
  -> sh: 1: echo-piped-ok: not found / Exit code: 127                       (sh まで届いている)
# CLI の出力、チャット画面、audit_log テーブル: いずれも警告なし
# ~/.octop/logs/2026-09-18.log にだけ:
2026-09-18 12:21:36 [WARNING] harness_agent.middleware.tool_guard [agent=K84DM3]: Tool guard warnings for execute: ['TOOL_CMD_DANGEROUS_RM']

# PUT /api/admin/security で {"tool_guard":{"enabled":true,"mode":"block"}} に変更後
execute: rm -rf /root/.octop/agents/K84DM3/guardtest3; echo rc=$?
  -> Tool call blocked by security policy (command guard).
     - [HIGH] Shell command contains 'rm' which may cause data loss
       remediation: Confirm with the user before removing files or directories
execute: curl -s http://127.0.0.1:9000/nothing | sh
  -> Tool call blocked by security policy (command guard).
     - [CRITICAL] Detects 'curl | bash' patterns used to download and immediately execute remote ...
execute: python3 -c "import shutil; shutil.rmtree(\"/root/.octop/agents/K84DM3/guardtest3\")"; echo rc=$?
  -> rc=0 / [Command succeeded with exit code 0]                            (消えている)

既定値は octop/infra/agents/security/policy_store.py に data["tool_guard"] = {"enabled": True, "mode": "warn"}、そして data["hitl"]["enabled"] = False と書いてあります。警告モードでは rm -rf がそのまま実行され、警告は ~/.octop/logs/ のファイルに1行出るだけ。CLI にもチャット画面にも audit_log テーブルにも出ません。20本の正規表現ルール(dangerous_shell_commands.yaml)自体はちゃんと命中していて、rm は HIGH、curl | sh は CRITICAL と判定されていました。判定はしているが止めていない、という状態です。

block に切り替えると HIGH と CRITICAL は止まり、拒否理由がツール結果としてモデルに返ります。ダッシュボードにもそのまま表示されました。

Octopのチャット画面。「已调用 1 次工具」の下に「工具 执行指令 已完成」のカードが開き、参数として rm -rf のコマンド、結果として「Tool call blocked by security policy (command guard). Tool: execute - [HIGH] Shell command contains 'rm' which may cause data loss」が表示されている

ただし、同じことを python3 -c "shutil.rmtree(...)" でやると通ります。ルールは \brm\b や curl|wget ... | sh のような文字列一致なので、これは仕組み上そうなるという話で、隠れた欠陥ではありません。正規表現のガードに期待できるのは「うっかり」の抑止までで、意図を持った回避は止まらない、という当たり前の確認です。

Octopの安全防護ページ、命令防護タブ。「启用命令参数扫描」のトグルがオン、「命中规则后的动作」のプルダウンが「阻断(HIGH/CRITICAL)」、下に「防护规则(20 条)」と規則ファイルのパス、規則YAMLの編集欄が見えている

管理画面の「安全防護」にはこの設定がそのまま出ていて、モードは engine.py の型定義どおり warn / block / require_approval の3種類です。上の画面は私が API で block に変えた後の状態で、初期状態は「警告」でした。隣の「工具審批」タブには bash execute edit_file write_file delete に既定でチェックが入っていますが、親のトグル「启用工具人工审批」自体がオフなので、チェックは効いていません。

ファイルの拒否ルールはシェルには効かず、ドットにも効かない

セキュリティ設定にはもう一つ、ファイルアクセスの拒否ルールがあります。既定で /etc/**、/root/**、/var/run/**、/home/*/.ssh/**、/home/*/.aws/**、/**/.env、/**/id_rsa などが read/write とも deny です。これも同じ要領で叩きました。

呼び出し結果
read_file /etc/hostnameError: permission denied for read on /etc/hostname
execute cat /etc/hostname2109895c24f4(通る)
read_file /root/plain.txtError: permission denied for read on /root/plain.txt
read_file /root/.bashrc18行がそのまま返る(通る)
read_file /root/.octop/logs/octop.log574行中100行が返る(通る)
read_file USER.md(相対パス)Error: File ‘/USER.md’ not found

1つ目と2つ目の対比が、この機能の守備範囲です。拒否ルールは read_file や write_file といったファイル系ツールにだけ掛かる FilesystemGuardMiddleware で、execute は対象外。ツール一覧のなかで一番強い execute に対しては、先ほどの正規表現ガードしか立っていません。

3つ目から5つ目は、最初は理由が分かりませんでした。/root/** が /root/plain.txt は止めるのに /root/.bashrc は止めない。harness_agent/middleware/filesystem_guard.py を読むと、パターン照合は wcmatch の globmatch で、フラグが BRACE | GLOBSTAR だけでした。DOTGLOB が無いので ** や * はドット始まりの名前に一致しません。_path_matches_patterns を直接呼んで確かめると、/root/.bashrc、/root/.octop/logs/octop.log、/root/sub/.hidden/x はすべて allow、/root/plain.txt だけ DENY でした。/home/*/.ssh/** が効くのは .ssh を文字どおり書いているからです。

ここは少し状況を補足しておきます。私はコンテナ内で root のまま動かしたので、Octop のワークスペース自体が /root/.octop/agents/... の下にあります。もし ** がドットを跨いでいたら、ワークスペースの読み書きが /root/** で全部拒否されて動かなかったはずで、一般ユーザーの /home で運用する想定のルールを root で動かした私の環境が、そもそも想定外なのだと思います。ただ、ドットディレクトリ配下がホームの拒否から漏れるという性質は環境に関係なく同じで、これが意図した仕様なのかどうかはソースからは読み取れませんでした。

6つ目は小さな食い違いです。system メッセージは「. や SOUL.md のような相対パスを使え」と言うのに、read_file("USER.md") は /USER.md として解決されて見つからない。絶対パスなら読めます。今回は CLI チャネルで試したので、他のチャネルでも同じかは未確認です。

PII マスクの中身は「APIキーの形」だけ

最後に「PII redaction」。既定で有効、strategy は mask、対象は入力・出力・ツール結果の3面です。メールアドレス、電話番号、カード番号、短めの sk-... を混ぜた文を送ると、モックには一字も変わらず届きました。一方 sk-proj- で始まる40文字と AKIA で始まる20文字を送ると、use key ****0000 and ****FAKE for the job になって届きます。

harness_agent/middleware/pii.py の検出器に並んでいるのは openai_project、anthropic、openai、aws_access_key_id、google_api_key、huggingface、api_key=... 形式といったAPIキー系のパターンで、PIIMiddleware(pii_type="api_key", ...) として1つだけ組み込まれていました。v1.0.0 時点の「PII」はAPIキーの意味です。GitHub の ghp_ トークンや JWT は検出されませんでした。なお、マスクされるのはモデルに送る側だけで、ローカルの SQLite(trajectory_events)には生の文字列が残っています。手元のDBなので問題というより、「ログに秘密が残る」ことは把握しておくべき性質です。

試してみての所感

Octop は、pip での導入だけ躓きましたが、uv を使えば6秒で入り、5秒で立ち上がり、ダッシュボードもチャットもツール呼び出しも一通り動きました。ダッシュボードからCLIのスレッドがそのまま見え、ツール呼び出しの引数と結果がカードで開くのは、運用を考えると素直にありがたい作りです。

その上で、既定値のまま家庭やチームの共有サーバに置くかと聞かれたら、私は「まず3か所を変えてから」と答えます。コマンド防護を warn から block か人工承認へ、工具審批のトグルをオンに、そして execute に頼らない前提でファイル拒否ルールを設計する。どれも管理画面のプルダウンとトグルで済むので手間はありません。逆に言えば、既定値が「判定はするが止めない」側に倒してある製品だ、ということは知っておくべきだと考えています。

毎ターン65KB弱がLLMに飛ぶ点は、自前のGPUや無料枠で回すなら気にならず、従量課金のAPIに繋ぐなら最初に見積もるべき項目です。今回は偽サーバだったので、本物のモデルを繋いだときにこの29個のツールをどう使いこなすのかは見ていません。そこは別の日に、小さなローカルモデルで試してみるつもりです。