# 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 フロー・運用ガイド・セキュリティモデル
