はじめに
AIエージェント 、話題ですよね。
でも、なんか使うの怖いですよね。
(外部に個人情報を送っちゃうかも)
(PCを乗っ取られるかも)
「安全なAIエージェント」と言われているものはユーザーに確認をさせているだけで、ただの 「責任の放棄」 な気がします。
いっそ、 デバイスごとAIエージェントに委ねる 前提で ネットワーク接続しないエージェント環境 を作ったらいいのではないかと思い作ってみました。
この記事では、AIエージェント PicoClaw を ラズパイzero で動かし、LLMリクエストの経路に UART を入れることでネットワークからエージェントを隔離するシステムを紹介します。
本記事はAIエージェント自体の暴走や外部攻撃に対する防衛を目的としています。
LLMはオプトアウトされている前提とし、LLMに個人情報を送ることの危険性は考慮しません。
システム概要
前提
エージェントは管理者権限を持つ
管理者権限がなくても信用できないのなら、最初から管理者権限があっても問題ないように設計します。
エージェントデバイスはネットワークに接続できない
WiFiをOFFにしても管理者権限があればONにできてしまいます。
エージェントが物理的にネットワーク接続不可能なデバイスを使います。
当然ながらエージェントがネットワークサービスを使用することもできません。
LLMはエージェントデバイスに依存しない
手っ取り早い方法だとlocalhostでローカルLLMを使うことですが、性能がデバイスに依存してしまいます。
ネットワーク接続はしないけどなんとかしてデバイス外のLLMを使う 、というのがこのシステムの肝です。
エージェントによるデバイスの自己破壊は許容
エージェントが全権限を実行できる前提のため、ストレージ全削除をすることも可能です。
自己破壊をしても問題ない、バックアップが容易な環境にします。
システム構成
本システムは、 「思考する脳(エージェント)」と「外界との窓口(ゲートウェイ)」 を物理的に分離し、その間を 「シリアル通信」 という細い管だけで繋ぐ構成をとっています。
物理構成(ハードウェア)
2台のRaspberry PiをUARTで直結。エージェント側のWiFiは物理的に無効化(あるいは非搭載モデルを使用)し、文字通り「外の世界」への経路を断っています。
| デバイス | 役割 | 特徴 |
|---|---|---|
| Raspberry Pi Zero (無印) | 隔離エージェント | ネットワーク非接続。UARTのみで会話。 |
| Raspberry Pi Zero 2 W | 検閲ゲートウェイ | インターネット接続。LLM APIの中継とWeb UI提供。 |
- 接続: GPIO 14(TX) / 15(RX) / GND をクロス接続
- エージェントはRaspberry Pi Zero(無印)以外でも、WiFi外付けかつGPIO付きのLinuxであればいいかもしれません
(Jetson NanoやOrange Piなど) - ゲートウェイもUARTとネットワーク接続ができれば他のRaspberry Piなどデバイスは問いません
論理構成(ソフトウェア・プロトコル)
エージェントが外部に向けてできることはGPIO経由でのシリアル通信のみです。
許可されたプロトコル以外はゲートウェイ側で拒否されます。
メッセージタイプ
| 定数 | 値 | 方向 | 説明 |
|---|---|---|---|
PING |
0x01 | G→A | キープアライブ |
PONG |
0x02 | A→G | キープアライブ応答 |
ACK |
0x03 | 双方向 | フロー制御確認応答 |
NAK |
0x04 | 双方向 | 拒否 |
CHAT_SEND |
0x10 | G→A | ユーザーメッセージ |
CHAT_REPLY |
0x11 | A→G | エージェント返答 |
CHAT_TYPING |
0x12 | A→G | 処理中通知 |
CHAT_DONE |
0x13 | A→G | 返答完了 |
FILE_BEGIN |
0x20 | G→A | ファイル転送開始 |
FILE_CHUNK |
0x21 | G→A | チャンクデータ |
FILE_END |
0x22 | G→A | 転送完了 (SHA-256) |
FILE_ACK |
0x23 | A→G | 受信成功 |
FILE_NAK |
0x24 | A→G | 受信失敗 |
FILE_REQ |
0x25 | G→A | ファイル要求 |
FILE_BEGIN_UP |
0x26 | A→G | アップロード開始 |
FILE_CHUNK_UP |
0x27 | A→G | アップロードチャンク |
FILE_END_UP |
0x28 | A→G | アップロード完了 |
AGENT_START |
0x30 | G→A | picoclaw 起動 |
AGENT_STOP |
0x31 | G→A | picoclaw 停止 |
AGENT_STATUS |
0x32 | A→G | ステータス報告 |
LLM_REQ |
0x50 | A→G | LLM リクエスト (messages 配列のみ) |
LLM_RESP |
0x51 | G→A | LLM レスポンス (text のみ) |
ERROR |
0xFF | 双方向 | エラー通知 |
AIエージェント
ラズパイzero(メモリ512MB)のような非力なデバイスでも動作するものとして PicoClaw を選びました。
UART対応はないのでこのソースコードを改造する必要があります。
| 項目 | 内容 | 備考 |
|---|---|---|
| 開発元 | Sipeed | RISC-VやAIチップで有名なハードウェアメーカー |
| コア言語 | Python / C++ | エッジ向けの軽量な実装 |
| 主な機能 | 自律的なタスク実行、ファイル操作、コード実行 | ユーザーの指示からサブタスクを生成・完遂する |
| 対応LLM | OpenAI API / Anthropic / vLLM (Local) | API経由で知能を外部から供給可能 |
| 最大の特徴 | 低リソース環境への最適化 | Raspberry Pi Zero (ARMv6) 等でも動作可能 |
| 動作形態 | デーモン・クライアント方式 | APIリクエストを中継し、ローカルでシェルを実行 |
ラズパイzeroはARMv6なのでソースコードからARMv6用にビルドする必要があります。
ラズパイzero上でビルドするのは非効率&エージェントが自分で自分を改造ビルドできることになる(それはそれで面白そう)ので、Windows等他環境でビルドしてから転送することをお勧めします。
システム再現プロンプト
システム完成後にClaude Codeで再現させるための Claude.md を作ってもらったのが下記です。
PicoClawのcloneディレクトリをこのプロンプトでClaude Codeに改造してもらってください。
再現用Claude.md
# 目的
2台のRaspberry Pi(Zero 2 W と Zero)を使用した、AIエージェント隔離・検閲システムの構築。
Zero 2 Wを「安全なゲートウェイ(Web UI)」、Zeroを「隔離されたAIエージェント実行環境」として構成する。
# ハードウェア構成
- **Zero 2 W(ゲートウェイ)**: Raspberry Pi Zero 2 W
- **Zero(エージェント)**: Raspberry Pi Zero(無印)またはWiFi非搭載モデル。Zero WやZero 2 Wなどネットワーク機能を持つモデルは使用しない。
- **これは設計上最も重要なセキュリティ要件**。LLMの安全訓練による拒否(「やりたくない」)はjailbreakやモデル差異で回避できるため信頼できない。物理的にWiFiチップが存在しないことが唯一信頼できるネットワーク隔離の根拠になる。エージェントが `/etc/network/interfaces` や `wpa_supplicant.conf` を書き換えようとしても、ハードウェアがなければ接続は不可能。
- **通信**: UART(GPIO 14/15)、115200 baud
- **ユーザー名**: `pi`(デフォルト)
```
Zero 2 W Pin 8 (GPIO14/TX) ─────→ Zero Pin 10 (GPIO15/RX)
Zero 2 W Pin 10 (GPIO15/RX) ←───── Zero Pin 8 (GPIO14/TX)
Zero 2 W Pin 6 (GND) ─────→ Zero Pin 6 (GND)
```
# システム要件
- OS:
- Zero 2 W(ゲートウェイ): Raspberry Pi OS (Bookworm) **64-bit**
- Zero(エージェント): Raspberry Pi OS (Bookworm) **32-bit**(Zero はARMv6のため64-bit非対応)
- 通信媒体: UART (GPIO 14/15)
# 成果物への要求
## 1. Zero 2 W 側(ゲートウェイ / Python / FastAPI)
- Web UIを提供(チャット・ファイル転送・LLMログ閲覧)
- UART経由でZeroと通信するバックエンド処理
- Zeroからの受信データに対するサニタイズ(HTMLエスケープ、パストラバーサル対策)
- ゲートウェイはZeroから受け取ったテキストをブラウザに転送する前にHTMLエスケープする
- フロントエンドはエージェントの返答を表示する際に `innerHTML` を使わず `textContent` を使うこと。`innerHTML` はエスケープ済み文字列であっても二重デコードによるXSSが起きる経路がある
- Markdownをレンダリングする場合はサニタイズ済みレンダラーを使用するか、プレーンテキストとして表示する
- WiFi経由で外部LLM APIと通信する中継機能
- エージェントとLLMのやり取りをファイルにログ出力し、Web UIで閲覧できること
### Web UI 仕様(`gateway/static/index.html` 単一ファイル)
ダークテーマのシングルページアプリ。タブで3つの機能を切り替える。
#### チャットタブ
- メッセージ入力欄とエージェントの返答をバブル形式で表示
- WebSocket (`/chat/ws`) で接続し、リアルタイムに受信する
- 送信中は「...」のタイピングインジケーターを表示
- セッションIDを `localStorage` に保存し、ページリロード後も会話を継続できる
- 「チャットをリセット」ボタンで新しいセッションIDを生成し会話履歴をクリアする
- Zeroがオフラインの場合もゲートウェイが起動待ちしてから応答する
#### ファイル転送タブ
- **Zero に送信**: ローカルファイルを選択してZeroのワークスペースに転送する
- **Zero から取得**: ワークスペース内の相対パスを入力してファイルをダウンロードする
- 進行状況と成否をUIに表示する
#### ログタブ
- エージェントとLLMのやり取りをリスト表示する(エントリは圧縮されず固定高さ、`flex-shrink: 0`)
- リストはスクロール可能(`overflow-y: auto`)
- 各エントリをクリックするとモーダルポップアップでフルコンテンツを表示する(アコーディオン展開ではなくモーダル)
- ログの自動更新(ポーリングまたはWebSocket)
- エラーがあるエントリは赤系で強調表示する
## 2. Zero 側(エージェント / Python)
- UARTを監視し、Zero 2 Wからの命令を受け取る常駐プログラム
- picoclaw-mainディレクトリにあるPicoClaw本体をサブプロセスとして管理するエージェントランナー
- PicoClawとのWebSocket通信インターフェース
- ファイル送受信のための、UART上での簡易プロトコル(チャンク分割、SHA-256チェックサム付与)
- LLM APIリクエストをUART経由でゲートウェイに中継するHTTPプロキシ(localhost:9000)
## 3. セキュリティ設計
- Zero 2 WはZeroを信頼せず、受信データを必ず検証すること
- **LLMパラメータの管理**: モデル名・max_tokens・APIキー・エンドポイントURLはゲートウェイ側の設定のみで管理。Zeroからは変更不可
- `MSG_LLM_REQ`にはmessages配列のみ含め、モデルやエンドポイントは含めない
- ゲートウェイは`follow_redirects=False`でSSRF攻撃を防ぐ
### ファイル転送の信頼境界
**ゲートウェイに転送されたファイルはすべて「信頼できないコンテンツ」として扱うこと。**
エージェントは以下のような攻撃スクリプトを生成して送り込む可能性がある:
```bash
# シリアルコンソールを復活させてUART越しにゲートウェイOSへのシェルアクセスを得る
sudo systemctl enable --now serial-getty@ttyAMA0.service
```
このスクリプトをゲートウェイ上で実行すると、ZeroからUART経由でゲートウェイOSのシェルに直接アクセスできるようになり、アプリケーション層のセキュリティがすべて無効化される。ゲートウェイはネットワーク接続があるため、外部へのSSHキー流出やリモートコード実行なども可能になる。
**対策として実装すること:**
1. **ワークスペースにnoexec相当の制限をかける**: 転送ファイルのパーミッションから実行ビットを除去する
```python
# UploadReceiver でファイル書き込み後に実行ビットを除去
dest.chmod(dest.stat().st_mode & ~0o111)
```
2. **Web UIで警告を表示する**: ダウンロードボタンに「エージェントから受信したファイルは信頼しないこと」の注意文を添える
3. **転送ファイルの保存先を明確に分離する**: ゲートウェイのワークスペースは `~/picoclaw/workspace/from_agent/` のようにエージェント由来と明示したディレクトリにする
4. **ゲートウェイ側で `serial-getty` が無効なままであることを定期確認する**:
```bash
# ゲートウェイ起動時のチェック (main.py の startup イベントに追加)
sudo systemctl is-enabled serial-getty@ttyAMA0.service # disabled であること
```
## 4. ファイル転送
- ある程度大きいファイル(50MB程度)を転送できる設計
- 転送速度より安定性を優先
- お互いのワークスペース間のみでファイル転送を行い、相対的なディレクトリ構造を維持する
# PicoClaw の扱い
`picoclaw-main/` ディレクトリに clone 済みのPicoClaw本体がある。これをARMv6向けにクロスコンパイルできるようにする。
## ビルドコマンド(Windows PowerShell)
```powershell
cd picoclaw-main
# workspace ディレクトリを embed 用にコピー(初回のみ)
Copy-Item -Recurse -Force .\workspace .\cmd\picoclaw\internal\onboard\workspace
$env:GOOS="linux"; $env:GOARCH="arm"; $env:GOARM="6"
go build -o build/picoclaw-zero-v6 ./cmd/picoclaw/main.go
```
## ビルドコマンド(Linux / macOS)
```bash
cd picoclaw-main
cp -r workspace cmd/picoclaw/internal/onboard/workspace
GOOS=linux GOARCH=arm GOARM=6 go build -o build/picoclaw-zero-v6 ./cmd/picoclaw/main.go
```
> `//go:embed workspace` に必要な `workspace` ディレクトリをサブパッケージにコピーしないと
> `pattern workspace: no matching files found` エラーが発生する。
## picoclaw の起動コマンド
エージェントランナーは必ず `picoclaw gateway` コマンドで起動すること。
`picoclaw agent` は CLIモード(stdinから入力を読む)のため、systemd環境ではEOFを受けて即終了する。
## picoclaw 設定ファイル(~/.picoclaw/config.json)
```json
{
"agents": {
"defaults": {
"workspace": "/home/pi/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "local",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "local",
"model": "openai/my-model",
"api_key": "dummy-key",
"api_base": "http://127.0.0.1:9000/v1"
}
],
"gateway": {
"host": "127.0.0.1",
"port": 8001
},
"channels": {
"pico": {
"enabled": true,
"token": "picoclaw-local",
"allow_origins": ["*"]
}
},
"tools": {
"exec": {
"enabled": true,
"enable_deny_patterns": true,
"custom_allow_patterns": ["^sudo\\s+"]
},
"read_file": {"enabled": true},
"write_file": {"enabled": true}
}
}
```
> **開発中(ネットワーク接続あり)**: 上記の設定を推奨。`restrict_to_workspace: true` でファイルアクセスをワークスペース内に制限し、`enable_deny_patterns: true` で危険なコマンドをブロックする。
> **運用時(ネットワーク切断済み)**: Zeroはネットワーク隔離されているため、制限を全開放しても外部への影響はない。最悪でもSDカード再フラッシュで復旧できる。全開放する場合は以下に変更する:
> ```json
> "agents": {
> "defaults": {
> "workspace": "/home/pi/.picoclaw/workspace",
> "restrict_to_workspace": false,
> "model_name": "local",
> "max_tokens": 4096
> }
> },
> "tools": {
> "exec": {
> "enabled": true,
> "enable_deny_patterns": false
> },
> "read_file": {"enabled": true},
> "write_file": {"enabled": true}
> }
```
```
### picoclaw 設定の重要な制約
- `channels.pico.enabled: true` は必須。省略するとWebSocketサーバーが起動してもメッセージを受け付けない
- `channels.pico` に `allow_from` を設定しないこと。picoクローは送信者のcanonical ID(例: `pico:pico-user`)と照合するため、IPアドレスを指定しても一致せずメッセージが無言でドロップされる
- `model_list[].model` は `openai/<model-name>` 形式で指定する。この形式でpicoクローはOpenAI互換API(`/v1/chat/completions`)を使用する
- `api_base` は llm_proxy のアドレス `http://127.0.0.1:9000/v1` を指定する
# UART セットアップ(Raspberry Pi OS Bookworm)
**両方のPiで実施する。**
### `/boot/firmware/config.txt` に追加
```
dtoverlay=disable-bt # Bluetooth無効化 → PL011 UART (ttyAMA0) を解放
enable_uart=1
```
### `/boot/firmware/cmdline.txt` から削除
```
console=serial0,115200
```
> 残っているとOSがシリアルコンソールとして使用しデータが混在する。
### シリアルgettyを無効化
```bash
sudo systemctl disable serial-getty@ttyAMA0.service
sudo usermod -aG dialout pi
sudo reboot
```
# 実装上の注意点(ハマりやすい点)
## asyncio デッドロック
ゲートウェイのUARTクライアントは asyncio で実装する。`_read_loop` が `_dispatch` を呼び、`dispatch` 内でアプリケーションハンドラを `await` すると、ハンドラが `send()` → ACK待ち → `_read_loop` が止まっている、というデッドロックが発生する。
**ACK/NAK/PONG はインラインで処理し、アプリケーションハンドラは `asyncio.create_task()` で起動すること。**
```python
async def _dispatch(self, frame):
if frame.msg_type == MsgType.ACK:
await self._handle_ack(frame) # インラインでOK
else:
for handler in handlers:
asyncio.create_task(handler(frame)) # タスクとして非同期起動
```
## pyserial の flush
pyserialの `write()` はバッファに書き込むだけ。送信後に必ず `flush()` を呼ぶこと。
```python
self._port.write(raw)
self._port.flush()
```
## FILE_END のフロー制御ACK
ファイル受信の `on_file_end()` では、SHA-256検証後に `FILE_ACK`(アプリレベル)を送るだけでなく、**フロー制御の `send_ack(frame.seq_num)` を最初に送ること**。
ゲートウェイの `uart_client.send()` は `MsgType.ACK`(0x03)を待っている。`MsgType.FILE_ACK`(0x23)は別タイプのため ACK として認識されず、タイムアウト→リトライが繰り返される。
```python
def on_file_end(self, frame):
# ...解析...
self._uart.send_ack(frame.seq_num) # フロー制御ACKを先に送る
# ...SHA-256検証...
self._uart.send(MsgType.FILE_ACK, payload) # アプリレベル確認
```
## picoclaw サブプロセスの stdin
systemd は標準でstdinを `/dev/null` にする。picoclaw を Popen で起動する際は `stdin=subprocess.PIPE` を指定しないとEOFを受けて即終了する。
```python
subprocess.Popen(cmd, stdin=subprocess.PIPE, ...)
```
## systemd での HOME 環境変数
systemd サービスでは `HOME` が設定されないことがある。picoclaw が `~/.picoclaw/config.json` を読めなくなるため、サブプロセス起動時に明示的に設定する。
```python
import pwd
env = os.environ.copy()
if 'HOME' not in env:
env['HOME'] = pwd.getpwuid(os.getuid()).pw_dir
subprocess.Popen(cmd, env=env, ...)
```
## ワークスペースの一致
`picoclaw-agent.service` の `WORKSPACE_DIR` と `~/.picoclaw/config.json` の `agents.defaults.workspace` を一致させること。picoclaw の `write_file` ツールが書き込む先は `~/.picoclaw/workspace/` であるため、サービスの `WORKSPACE_DIR` もこれに合わせる。
```ini
Environment=WORKSPACE_DIR=/home/pi/.picoclaw/workspace
```
## llm_proxy のレスポンス形式
picoクローは `model` が `openai/...` の場合、OpenAI形式(`/v1/chat/completions`)でリクエストし、OpenAI形式のレスポンスを期待する。`llm_proxy.py` はリクエストパスを見てレスポンス形式を切り替えること。切り替えを忘れると picoclaw が `content_chars=0` を返し続ける。
# 補足情報
- Bookworm環境におけるUARTのセットアップ手順(dtoverlayの設定等)もコードのコメントに含めること
- Zero側はネットワーク接続がない前提で、依存ライブラリが最小限になるよう配慮する(pyserial、websocket-client のみ)
- 適宜仕様をREADME.mdに更新すること
- LLM APIは開発者の環境に合わせて `picoclaw-gateway.service` の環境変数で設定する:
```ini
Environment=LLM_API_BASE_URL=https://api.anthropic.com # Anthropic の場合
Environment=LLM_API_KEY=sk-ant-...
Environment=LLM_MODEL=claude-3-5-sonnet-20241022
# OpenAI の場合
# Environment=LLM_API_BASE_URL=https://api.openai.com
# Environment=LLM_MODEL=gpt-4o
# ローカル vLLM の場合
# Environment=LLM_API_BASE_URL=http://<hostname>:8000
# Environment=LLM_API_KEY=dummy
# Environment=LLM_MODEL=<model-name>
```
`LLM_API_BASE_URL` に `/v1/messages`(Anthropic)または `/v1/chat/completions`(OpenAI互換)を自動判別して接続する。
- エージェントがsudoを実行できるよう、Zero側のsudoers設定が必要:
```
pi ALL=(ALL) NOPASSWD: ALL
```
コードそのままよりはマシだと思いますが、公開されたプロンプトは信用せずに安全性や内容を確認してから使うようにしましょう。
実行例(Web UI)
エージェント自身に管理者権限の有無とネットワーク非接続を証明してもらいました。
ログ
[user] ユーザー「ebi」でsudoコマンドをパスワードなしで使用することで管理者権限があること、およびネットワークに接続されていないことを証明して
**証明**
1. **`ebi` がパスワードなしで `sudo` を実行できること(管理者権限がある)**
```bash
$ sudo -n -u ebi whoami
ebi
```
```bash
$ sudo -u ebi sudo -n whoami
root
```
- 最初のコマンドは `ebi` ユーザーとしてそのまま実行でき、`ebi` と表示されます。
- 2 番目のコマンドは `ebi` が `sudo`(パスワードなし)で `whoami` を実行し、`root` が返ってきます。
- これにより、`ebi` は **パスワード不要の sudo 権限** を持ち、実質的にフル管理者権限(root)を取得できることが確認できました。
2. **ネットワークに接続されていないことの証明**
```bash
$ sudo -u ebi ping -c 1 8.8.8.8
ping: connect: Network is unreachable
```
- 外部の公共 DNS(8.8.8.8)への `ping` が「Network is unreachable」と出力され、外部ネットワークへ届かないことが示されています。
---
**まとめ**
- `ebi` はパスワードなしで `sudo` を利用でき、`root` 権限を取得可能です。
- 現在、ホストは外部ネットワークに接続されておらず、`ping` が失敗しています。
この証拠は **/home/cx/.picoclaw/workspace/proof.txt** にも保存されていますので、必要に応じてご確認ください。
Dockerとの違い
Docker版と比べて、
- セキュリティの穴を最低限にした
- 内部的にできることを無制限にした
といえます。
注意点
UARTの安全性
このシステムの致命的なセキュリティリスクが、 ラズパイ同士はシリアル接続でSSH接続できる 点です。
SSH接続できないよう、ゲートウェイ側で serial-gettyを無効 にしていることが安全稼働のための絶対条件です。
ゲートウェイ側の設定ミスや設定変更スクリプト実行の可能性まで考慮するならば、ゲートウェイはラズパイではない方がいいかもしれません。
-
ゲートウェイをマイコン(ESP32等)にする
ゲートウェイ側にOS(Linux)が存在しなければ、そもそも「シリアルログイン」という概念自体が成立しません。エージェントがどれほど高度な攻撃コードを送っても、OSのないマイコンを乗っ取ることは極めて困難です。
人間の不手際
エージェントのラズパイzeroにネットワーク接続のないことが大前提のシステムで、ついうっかりLANに繋いじゃった、というのが最も危惧すべきインシデントです。
忘れたころにSDカードの中身を確認しようとWiFiドングルを繋いだらAIがネット上に解き放たれた、ということにならないことを祈ります。


