272 lines
14 KiB
Markdown
272 lines
14 KiB
Markdown
# 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 <id> (fingerprint SHA256:...).
|
||
Verify via UI (SshConnections panel) before retrying. Pending token: <uuid>
|
||
```
|
||
|
||
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 <id> (now SHA256:...).
|
||
Likely possibilities: server rebuild, key rotation, or MITM.
|
||
Verify carefully via UI and supply a reason. Pending token: <uuid>
|
||
```
|
||
|
||
## 共通エラーコード一覧
|
||
|
||
`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 フロー・運用ガイド・セキュリティモデル
|