# SSH ツール詳細ガイド (SshExec / SshUpload / SshDownload / SshListConnections) リモートサーバーで shell コマンドを実行したり、ワークスペースとリモートファイルシステムの間でファイルを転送するためのツール群。同じ前提・同じエラーモデル・同じ監査経路を共有するので、本ドキュメントに統合してある。運用者向けの設計・設定詳細は **[docs/ssh.md](../ssh.md)** を参照。 ## 4 ツールの位置づけ | ツール | 用途 | 入力 | |--------|------|------| | `SshListConnections` | この movement で使える接続の UUID + label + host 一覧を取得 | (引数なし) | | `SshExec` | リモートで shell 単一行を実行 | `connection_id`, `command`, (任意) `timeout_ms` | | `SshUpload` | workspace → リモートへファイル転送 (SFTP) | `connection_id`, `local_path`, `remote_path`, (任意) `timeout_ms` | | `SshDownload` | リモート → workspace へファイル取得 (SFTP) | `connection_id`, `remote_path`, `local_path`, (任意) `timeout_ms` | 転送系の 3 ツールは、接続側の `remote_path_prefix` 配下の絶対パスのみを受け付け、`workspace` 外への local パスは reject される。`connection_id` は piece 側の `allowed_ssh_connections` に明示されている UUID のみ使用可能。 タスク本文に `connection_id` が記されていないときは、まず `SshListConnections` を呼んで該当の host / label の UUID を取得すること。 ## 共通: 4 つの前提条件 ツール呼び出し前に以下が全て揃っている必要がある。どれか一つでも欠けると即エラー応答 (audit には `denied` で記録される)。 1. **`ssh.enabled: true`** が `config.yaml` で設定されている 2. **`MCP_ENCRYPTION_KEY`** 環境変数が 64 hex 文字 (= 32 バイト) で設定されている 3. **対象 connection の host key が verify 済**。新規作成直後は `host_key_verified_at IS NULL` 状態で SshExec/Upload/Download は `host_key_not_verified` で失敗する。SSH Connections パネル (Settings → User Folder → SSH Connections) で `/test` を実行 → 鍵 fingerprint を確認 → "Verify" ボタンで verify する 4. **piece の現在 movement で `allowed_ssh_connections` に当該 UUID が明示**されている (またはワイルドカード `*`)。空配列 `[]` は「SSH 使用するが許可なし」の deny 宣言とみなされ全 UUID が reject される 不足時のエラーメッセージ例: `SshExec error: piece "ops" movement "exec" does not list connection abcd1234... in allowed_ssh_connections.` ## SshListConnections ```js SshListConnections({}) ``` 引数なし。現在の movement の `allowed_ssh_connections` + ジョブ owner の access grant を満たす接続だけを返す (admin 無効化 / piece 除外 / grant 無しは filter out)。 戻り値 (JSON 文字列): ```json { "connections": [ { "id": "abcd1234-5678-90ab-cdef-1234567890ab", "label": "prod-aao", "host": "10.0.0.10", "port": 22, "username": "deploy", "host_key_verified": true, "host_key_pending": false } ] } ``` - `host_key_verified: false` の接続は SshExec/Upload/Download/Console* で使う前に UI から TOFU 検証する必要がある (`host_key_pending: true` ならまだ未検証で取り消し可能な状態) - `connections` が空配列の場合は admin に接続登録 / grant 発行を依頼する - 通常は **最初に呼ぶ** ことで AI が "どの host か" を発見できる。1 ターンで複数回呼ぶ必要はない (結果は安定) - 監査 action: `ssh.list_connections` (detail に `count` と `wildcard` フラグ) ## SshExec ```js SshExec({ connection_id: "abcd1234-...", command: "ls -la /srv/agent", timeout_ms: 30000 // 任意 }) ``` 戻り値 (JSON 文字列): ```json { "exit_code": 0, "stdout": "total 12\ndrwxr-xr-x 3 agent agent ...", "stderr": "", "truncated_stdout": false, "truncated_stderr": false } ``` - `exit_code` は remote プロセスの終了コード。0 でない場合も isError=false で返り、LLM が判断する - 標準出力は `config.yaml` の `ssh.max_output_bytes` (デフォルト 32 KiB) で truncate される。`truncated_stdout: true` の場合はコマンドを `head` / `tail` / `grep` で絞り込んで再試行する - 同等以上のサイズが見込まれる出力は SshDownload でファイル取得した上で `Read` で扱うこと ### command フィルタリング (2 段) - **組み込み deny-list**: `rm -rf /`, `mkfs`, `dd`, `:(){:|:&};:` 系のシステム破壊 / fork bomb 系を unconditional で reject - **接続側カスタム正規表現**: 接続作成時に `deny_patterns` / `allow_patterns` (改行区切りの正規表現リスト) を設定可能。デフォルトは空 (= 制限なし)。`allow_patterns` を設定した場合、deny を通過した後さらに全 allow パターンに合致しないと reject エラー: `SshExec error: command rejected by built-in deny-list (matched pattern: rm\s+-rf).` / `command rejected by connection deny-list.` ### timeout `timeout_ms` 未指定時は `config.yaml` の `ssh.call_timeout_seconds` (デフォルト 30 秒)。これは TCP 接続 + handshake + 認証 + コマンド実行を全て含む wall-clock。タイムアウトすると `exec_timeout` エラーで終了し、audit row は `failed` outcome + `detail.error = 'exec_timeout'` で記録される (途中で生成された stdout は破棄される)。 ## SshUpload ```js SshUpload({ connection_id: "abcd1234-...", local_path: "output/report.csv", // workspace 相対 remote_path: "/srv/agent/2026-05/report.csv" // 絶対パス、prefix 配下 }) ``` 戻り値: ```json { "ok": true, "bytes": 4096, "remote": "/srv/agent/2026-05/report.csv" } ``` - `local_path`: workspace ルートからの相対パス。シンボリックリンク経由で workspace 外を指すパスは O_NOFOLLOW + parent lstat で reject される - `remote_path`: 接続の `remote_path_prefix` (例: `/srv/agent`) 配下の絶対パスのみ。`/srv/agent/../etc/passwd` のような traversal は POSIX 正規化後に prefix 外と判定されて reject - アップロード先のディレクトリは事前に存在している必要がある (`mkdir -p` 相当を行いたければ先に `SshExec({command: "mkdir -p /srv/agent/2026-05"})` を呼ぶ) - 既存ファイルへの上書きは現状 reject せず upload する。冪等性が必要な場合は呼び出し側で確認すること ### サイズ上限 `config.yaml` の `ssh.max_upload_size_mb` (デフォルト 100 MB) を超える local ファイルは `remote_too_large` 相当で reject。 ## SshDownload ```js SshDownload({ connection_id: "abcd1234-...", remote_path: "/srv/agent/2026-05/log.txt", local_path: "input/log.txt" // workspace 相対 }) ``` 戻り値: ```json { "ok": true, "bytes": 8192, "local": "input/log.txt" } ``` - `local_path` は **既に存在するファイルへの上書きは reject** される (`local_target_exists` エラー)。新規パスを指定するか、既存ファイルを別ツールで削除してから再試行 - 親ディレクトリは呼び出し側で作成済にしておくこと。`Write` 相当の mkdir-p は行わない (e.g. `output/foo/bar.txt` を指定するなら、事前に `Bash({command: "mkdir -p output/foo"})` 等で作成) - `remote_path` の prefix 配下チェック、サイズ上限 (`ssh.max_download_size_mb`)、SSRF チェックは Upload と同じ ## Host key TOFU フロー (LLM 側で完結しない) 接続を新規作成した直後は host key が観測されていない (`host_key_b64 IS NULL`)。最初の `/test` 呼び出し (または最初の Exec/Upload/Download) で鍵を観測すると、`host_key_first_observe` エラーが返り、`host_key_b64` / `host_key_fingerprint` / `host_key_pending_token` が DB に書き込まれる。 ``` Host key first-observe on connection (fingerprint SHA256:...). Verify via UI (SshConnections panel) before retrying. Pending token: ``` LLM ではここで止め、ユーザーに **UI で fingerprint を確認 → Verify** を依頼する。Verify を完了するまで全 SSH ツールは `host_key_not_verified` で失敗する。 サーバー再構築や鍵 rotation で fingerprint が変わると `host_key_mismatch` が返る。これは **既存鍵の上書きにあたるので reason 付きで UI から明示的に replace** する必要がある (`/replace-host-key` エンドポイント)。LLM は自分で replace してはいけない。 ``` WARN: Host key MISMATCH on connection (now SHA256:...). Likely possibilities: server rebuild, key rotation, or MITM. Verify carefully via UI and supply a reason. Pending token: ``` ## 共通エラーコード一覧 `isError: true` で返るエラーメッセージは以下のいずれか。LLM は基本的に **retry せず**、メッセージに従って人に判断を仰ぐか、別の手段に切り替えること。 | code | 意味 | 対応 | |------|------|------| | `host_key_first_observe` | 初回鍵観測 | UI で verify するようユーザーに依頼 | | `host_key_mismatch` | 鍵 fingerprint が変化 | UI で replace するようユーザーに依頼 (MITM 可能性) | | `host_key_not_verified` | 鍵記録済だが未 verify | 同上、UI で verify | | `host_key_alg_not_allowed` | サーバーが禁止アルゴリズムを提示 | 接続不能、運用者に報告 | | `auth_failed` | 秘密鍵が認証拒否された | 接続設定 (key/username) を確認 | | `connect_timeout` | ハンドシェイク前に timeout | network 経路 / SSRF policy 確認 | | `exec_timeout` | コマンド実行が timeout | `timeout_ms` を増やす、コマンドを軽量化 | | `transfer_timeout` | SFTP 転送が timeout | ファイルサイズ確認、回線確認 | | `output_too_large` | stdout が `max_output_bytes` 超過 | フィルタリング、SshDownload に切替 | | `remote_too_large` | ファイルが `max_(up\|down)load_size_mb` 超過 | サイズ確認、設定変更 | | `local_target_exists` | download 先が既存 | 別パス選択 | | `forbidden_address` | SSRF policy で reject | private 接続なら `allow_private_addresses` 設定 | | `invalid_host` / `dns_failed` / `connect_failed` | 接続 / DNS 失敗 | host 設定、ネットワーク確認 | `abuse_locked` / `disabled_by_admin` 等の運用上の reject は `SshExec: access denied (...) for connection X.` 形式のエラー (isError=true) で返る。 ## abuse counter による自動 lock 連続失敗を 3 つのスコープで集計する: - **user**: 同一ユーザー × 任意接続 - **host:user**: 同一 (host, username) ペア - **host (global)**: 同一 host (global connection のみ対象) `config.yaml` の `ssh.abuse_window_minutes` (10) 以内に `ssh.abuse_failure_threshold` (5) 回失敗すると、当該スコープが `ssh.abuse_lock_minutes` (30) ロック。ロック解除は時間経過待ち、または **admin が UI から force-unlock** (理由 + 8 字以上必須、レート制限 10 回/時)。 成功すると user scope のカウンターだけクリアされる (他のスコープは時間経過で window から外れる)。 ## 監査ログ 3 ツールはすべて以下のライフサイクルを踏む: ``` audit.begin (outcome=pending) → commit (DB) ↓ remote 呼び出し ↓ audit.complete (outcome=success | failed | denied | aborted) ``` 途中でプロセスがクラッシュした場合、`pending` 行は次回起動時の recovery sweep で `aborted` に倒される (forensics 用「実行されたが結果不明」)。 action 名: - `ssh.exec` (SshExec) - `ssh.upload` (SshUpload) - `ssh.download` (SshDownload) - `ssh.connection.host_key.first_observe` / `mismatch` (TOFU 発火時) `ssh.exec` の `detail` には command そのものではなく **SHA-256 truncated hex (16 char)** が `command_hash` として記録される。command 全文は記録されない (PII / secrets 漏洩防止)。retry 検知やパターン分析は hash 比較で行う。 監査ログの参照経路: - ユーザー本人の接続: SshConnections パネルの "Audit" タブ - admin (全接続): Settings → SSH → Audit Log (フィルタ: action / outcome / connection / time range) ## Workflow Recipes ### A. リモートで生成したレポートを workspace に取り込む ```js // 1. リモートでレポート生成 SshExec({ connection_id: CONN, command: "/srv/agent/build-report.sh > /tmp/report-$(date +%Y%m%d).csv" }) // 2. 生成パスを確認 const ls = SshExec({ connection_id: CONN, command: "ls -1 /tmp/report-*.csv | tail -1" }) const remote = JSON.parse(ls.output).stdout.trim() // 3. workspace に取り込み SshDownload({ connection_id: CONN, remote_path: remote, local_path: `input/${remote.split('/').pop()}` }) ``` ### B. workspace で加工した設定ファイルを反映 ```js // 1. ワークスペースで設定を生成 Write({ file_path: "output/nginx.conf", content: "..." }) // 2. リモートにアップロード SshUpload({ connection_id: CONN, local_path: "output/nginx.conf", remote_path: "/srv/agent/nginx.conf" }) // 3. validate + reload SshExec({ connection_id: CONN, command: "nginx -t -c /srv/agent/nginx.conf && systemctl reload nginx" }) ``` ### C. 大量出力を直接受け取らずファイル経由で扱う ```js // 直接 SshExec すると max_output_bytes で truncate される // → 一度ファイルに書いてから Download する SshExec({ connection_id: CONN, command: "journalctl -u app --since '1 hour ago' > /tmp/app.log" }) SshDownload({ connection_id: CONN, remote_path: "/tmp/app.log", local_path: "input/app.log" }) Read({ file_path: "input/app.log", offset: 0, limit: 200 }) // 必要に応じて ``` ## 関連ツール - `Read` / `Write` / `Edit`: workspace 内のファイルを扱う前後で組み合わせる - `Bash`: workspace 内でのローカル処理 (mkdir, jq 加工等) ## 参考 - [docs/ssh.md](../ssh.md) — 設定・UI フロー・運用ガイド・セキュリティモデル