diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 041a199..5e57f02 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -219,6 +219,18 @@ Checked ancestors must be owned by root or the current user and not writable by On macOS, mounts that ignore ownership are refused with `preset_ownership_unenforced`; an unavailable or unverifiable mount query is refused with `preset_mount_unverifiable`. Mode bits alone are not treated as proof that ownership is enforced. On Linux, ACLs on ancestors are also refused, as are filesystems whose ACL state cannot be verified. After an interrupted management command, inspect the current configuration because the change may already be visible; directory-sync failures are not silently accepted. +### Unreleased v1.4.0 candidate: MCP presets (#359) + +Candidate input signatures: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +MCP preset lookup and selection use an explicit operator allowlist and the existing secure snapshot reader; they add no CLI preset-management commands or automatic selection. See [MCP preset policy and limits](mcp.md). MCP selection expands an approved route instead of combining CLI `--preset` with explicit routing flags. Existing CLI output, revision and filesystem-security contracts remain unchanged. + +For MCP `list_sessions`, `preflight_send` and `send_message`, optional `preset` and `preset_revision` inputs require both the existing action permission and `presets`. Actual sends require the current `dp1:` revision and an explicit target. Wake and Reply-To URI combinations are rejected. A revision is checked during lookup, not as a target generation or authority, and later changes cannot cancel a route already expanded for invocation. + +The new 64 KiB tool-result budget applies only to the `list_presets` metadata query. A preset is only a route selector for existing actions, with the same action authorization and exact policy-route matching. Successful `list_sessions` and `send_message` responses keep the existing CLI schema and native payload, without preset metadata, a new command or an added data projection. Existing native evidence and transport limits remain intact; real sends gain no aggregate 64 KiB limit. Inspect revisions separately with `list_presets`. Selected `preflight_send` may include only safe preset metadata: `name`, `agent`, `transport` and `revision`. + +If any approved item is missing or its route mismatches policy, the whole metadata lookup is refused atomically. Fixed selector refusal codes include `preset_platform_unsupported`, `preset_route_unsupported`, `preset_revision_mismatch` and `preset_storage_unavailable`; no management or filesystem repair is attempted. Metadata-query failures precede invocation. After invocation, I/O or actual-send decode failure may follow submission: report `outcome_unknown` with `retryAllowed=false` and never retry automatically. Preserve any known native queued/submitted evidence. + ### Recipient addressbook (v1.3.0) diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 00ea065..ef87ab9 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,13 +15,13 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "c63c044b4f52300394427782c5aecc47b533ca3ef6998d4303c5d057dbbf94c8", + "docs/cli-reference.md": "86f292fbbd9aa46d44c62051875105b21d465aeb8e0c438436fab7457548a3bf", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "a537300d3fe2b60273b6f4bd84e0ae0944364fda3881d7fb7e32ce665dc2c724", "docs/handoff-runtime.md": "a236e48392c91eca0f7c3734a6008a991955ae66d16fd71f97691aac5bf0091f", - "docs/mcp.md": "af00a34033ac0e953c2e0b328567ca98f20c91541fe4c4f15c426d08142f477f", + "docs/mcp.md": "bc98e35b1d2584cc3628ea73387e2e388e7e93427030aaecbddf7a95852e3349", "docs/multi-home-list.md": "f8d8e3adf727e4842ea7c4d45e8f6523b11d0513bf2509d4d1204645875e6489", "docs/paired-devices.md": "1c6cf45187fdbac5a21e43968bc0cc39aee05af19904b4d176f1dd2a70065303", "docs/relay-auth.md": "e6da5291ce6a9da31e79e66ceeff7e9fef7e95046359acbf04d708c96ffab62f", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 63f3f80..b873d10 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -209,6 +209,18 @@ UUID は現在の一覧で確認した値に、ハッシュは選択したプリ macOS で所有権を無視するマウントは `preset_ownership_unenforced`、マウント照会が利用できない、または検証できない場合は `preset_mount_unverifiable` で拒否します。モードビットだけを所有権が強制される証拠とは扱いません。Linux では上位経路の ACL も、ACL 状態を検証できないファイルシステムも拒否します。管理コマンドを中断した後は、変更が既に見えている可能性があるため現在の設定を確認してください。ディレクトリの同期失敗を黙って成功とは扱いません。 +### 未リリースの v1.4.0 候補: MCP プリセット (#359) + +候補版の入力シグネチャ: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +MCP プリセットの参照と選択では、オペレーターの明示的な許可リストと既存の安全なスナップショット読み取り関数を使います。CLI のプリセット管理コマンドや自動選択は追加しません。[MCP プリセットのポリシーと制限](mcp.md)を参照してください。MCP の選択では、CLI の `--preset` と明示的な経路フラグを組み合わせず、承認済みの経路を展開します。既存の CLI 出力、リビジョン、ファイルシステムの安全性に関する契約は変わりません。 + +MCP の `list_sessions`、`preflight_send`、`send_message` で任意の `preset`、`preset_revision` を指定するには、既存の操作権限と `presets` の両方が必要です。実際の送信には、現在の `dp1:` リビジョンと明示的な対象が必要です。wake や Reply-To URI との組み合わせは拒否します。リビジョンは参照時に確認するもので、対象の世代や権限ではありません。その後の変更で、呼び出し用にすでに展開された経路を取り消すことはできません。 + +新しい 64 KiB のツール結果制限は `list_presets` のメタデータ参照だけに適用されます。プリセットは既存の操作の経路選択だけを担い、同じ操作権限とポリシー経路との完全一致を要求します。成功した `list_sessions` と `send_message` の応答では、プリセットのメタデータ、新しいコマンド名、追加のデータ射影を加えず、既存の CLI スキーマとネイティブのペイロードを維持します。既存のネイティブ証拠と通信上の制限を保ち、実際の送信に合計 64 KiB の制限を新設しません。リビジョンは `list_presets` で別途確認してください。選択済みの `preflight_send` に追加できるプリセットのメタデータは、安全な `name`、`agent`、`transport`、`revision` のみです。 + +承認済みの項目が一つでも欠落しているか、経路がポリシーと一致しない場合、メタデータ参照全体を原子的に拒否します。固定の選択拒否コードには `preset_platform_unsupported`、`preset_route_unsupported`、`preset_revision_mismatch`、`preset_storage_unavailable` があり、管理操作やファイルシステムの修復は行いません。メタデータ参照のエラーは呼び出し前に発生します。呼び出し後の入出力エラーや実際の送信応答のデコード失敗は、送信後に起きる場合もあるため、`outcome_unknown` と `retryAllowed=false` を報告し、自動再試行しないでください。確認済みのネイティブのキュー登録・送信証拠は保持します。 + ### 受信先アドレス帳(v1.3.0) diff --git a/docs/ja/mcp.md b/docs/ja/mcp.md index dc7f630..b00ef27 100644 --- a/docs/ja/mcp.md +++ b/docs/ja/mcp.md @@ -69,6 +69,22 @@ Python 3.10 以降でインストールします: `pipx install 'session-peer[mc `tcpAccept`、`tls`、`peerAuthentication` の段階は `not_checked` のままです。ここでの `peerAuthentication` はペアリングされたデバイスの認証を指し、順方向 SSH 認証とは別です。`sshFailure` は `authentication_failed` や `host_key_failed` など、サニタイズされた順方向 SSH 失敗の分類だけを報告します。TCP、TLS、ペアリングされたデバイス、返信先ホストへのプローブは追加しません。 +## 未リリースの v1.4.0 候補: MCP プリセット (#359) + +候補版の入力シグネチャ: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +`list_presets(destination)` は承認済みの経路メタデータだけを読みます。`schemaVersion` 2 のポリシーで `presets` を明示的に許可し、宛先ごとの `presets` リストに `[a-z][a-z0-9_-]{0,63}` に一致する承認済みの名前を 1 件以上、128 件以下で指定する必要があります。バージョン 1 は新しいケーパビリティとフィールドを拒否します。ストアを読む前に権限を確認します。結果には宛先と一致する承認済みの `name`、`agent`、`transport`、`revision` と既存の `dp1:` トークンだけが含まれ、実際のホスト、ホーム、ストアのパスは省略されます。保存済み経路の参照では、セッションの探索、対象の選択、SSH・ネイティブ・バージョン確認のプローブ、データの更新を行いません。バージョン付きのテキストと構造化されたツール結果は、合計 64 KiB に制限されます。 + +`list_sessions`、`preflight_send`、`send_message` は任意の `preset`、`preset_revision` 入力を受け付けます。選択には既存の操作権限と `presets` の両方が必要です。保存済み経路やリビジョンが権限を与えることはありません。送信には明示的な対象が必要で、実際の送信には現在のリビジョンが必要です。プリセットを wake や Reply-To URI の対象と組み合わせることはできません。macOS/Linux の既存の Claude Code・Codex のローカルまたは SSH 経路だけをサポートし、Windows、デバイス、アドレス帳、handoff、プリセット管理のサポートは追加しません。 + +アダプターは既存の `XDG_CONFIG_HOME` の選択と安全な読み取り関数を使い、オペレーターの起動環境のストアから検証済みのスナップショットを一度だけ読みます。64 KiB・128 件の制限と、所有者、ACL、シンボリックリンク、マウントの検査を維持し、存在しないストアや安全でないストアを初期化、修復、置換しません。モデルはストアのパスを指定できません。選択した経路は、宛先のホスト、許可されたエージェント、固定された Codex ホームと完全に一致する必要があります。DNS 上の同一性や代替経路への切り替えは認めません。CLI の `--preset` と明示的な経路フラグを組み合わせず、承認済み経路を一つの実行計画に展開し、呼び出し前にポリシーを再確認します。 + +リビジョンは名前と経路の正確な値を表し、同じ値に戻すと同じトークンを再利用できます。参照時に検査するもので、対象の世代、呼び出し元の身元、ストレージの証明、ACK ではありません。その後の変更によって、すでに展開された呼び出しが遡って取り消されることはありません。プリセットを選択しない場合、既存のツール応答は変わりません。 + +新しい 64 KiB のツール結果制限は `list_presets` のメタデータ参照だけに適用されます。プリセットは既存の操作の経路選択だけを担い、同じ操作権限とポリシー経路との完全一致を要求します。成功した `list_sessions` と `send_message` の応答では、プリセットのメタデータ、新しいコマンド名、追加のデータ射影を加えず、既存の CLI スキーマとネイティブのペイロードを維持します。既存のネイティブ証拠と通信上の制限を保ち、実際の送信に合計 64 KiB の制限を新設しません。リビジョンは `list_presets` で別途確認してください。選択済みの `preflight_send` に追加できるプリセットのメタデータは、安全な `name`、`agent`、`transport`、`revision` のみです。 + +承認済みの項目が一つでも欠落しているか、経路がポリシーと一致しない場合、メタデータ参照全体を原子的に拒否します。固定の選択拒否コードには `preset_platform_unsupported`、`preset_route_unsupported`、`preset_revision_mismatch`、`preset_storage_unavailable` があり、管理操作やファイルシステムの修復は行いません。メタデータ参照のエラーは呼び出し前に発生します。呼び出し後の入出力エラーや実際の送信応答のデコード失敗は、送信後に起きる場合もあるため、`outcome_unknown` と `retryAllowed=false` を報告し、自動再試行しないでください。確認済みのネイティブのキュー登録・送信証拠は保持します。 + ## ツールとパーミッション - `list_sessions(destination="local", agent=null, include_inactive=false)` は CLI の構造化された一覧を返します。Agent は `claude` または `codex` です。省略した場合は許可されたエージェントを一覧表示します。 diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 6a71121..33f58ec 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -209,6 +209,18 @@ UUID는 현재 목록에서 확인한 값으로, 해시는 선택한 프리셋 Mac에서 소유권을 무시하는 마운트는 `preset_ownership_unenforced`로, 마운트 조회를 수행하거나 검증할 수 없으면 `preset_mount_unverifiable`로 거부합니다. 파일 모드만으로 소유권 강제를 증명했다고 취급하지 않습니다. Linux에서는 상위 경로의 ACL도 거부하며 ACL 상태를 검증할 수 없는 파일시스템도 거부합니다. 관리 명령을 중단했을 때는 변경이 이미 보일 수 있으므로 현재 설정을 확인하세요. 디렉터리 동기화 실패를 조용히 성공으로 처리하지 않습니다. +### 미출시 v1.4.0 후보: MCP 프리셋 (#359) + +후보 버전 입력 시그니처: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +MCP 프리셋 조회와 선택은 운영자의 명시적 허용 목록과 기존 보안 스냅샷 읽기 함수를 사용하며, CLI 프리셋 관리 명령이나 자동 선택을 추가하지 않습니다. [MCP 프리셋 정책과 제한](mcp.md)을 참고하십시오. MCP 선택은 CLI `--preset`과 명시적 경로 플래그를 함께 사용하는 대신 승인된 경로를 확장합니다. 기존 CLI 출력, 리비전 및 파일 시스템 보안 규약은 유지됩니다. + +MCP `list_sessions`, `preflight_send`, `send_message`의 선택적 `preset`, `preset_revision` 입력에는 기존 작업 권한과 `presets`가 모두 필요합니다. 실제 전송에는 현재 `dp1:` 리비전과 명시적인 대상이 필요합니다. wake 및 Reply-To URI와의 조합은 거부합니다. 리비전은 조회 시 확인하며 대상 세대나 권한을 뜻하지 않고, 이후의 변경도 호출을 위해 이미 확장된 경로를 취소할 수 없습니다. + +새 64 KiB 도구 결과 제한은 `list_presets` 메타데이터 조회에만 적용됩니다. 프리셋은 기존 작업의 경로 선택자일 뿐이며, 동일한 작업 권한과 정책 경로의 정확한 일치를 요구합니다. 성공한 `list_sessions`와 `send_message` 응답은 프리셋 메타데이터, 새 명령 이름 또는 추가 데이터 투영 없이 기존 CLI 스키마와 네이티브 페이로드를 그대로 유지합니다. 기존 네이티브 증거와 전송 제한을 보존하며 실제 전송에 합계 64 KiB 제한을 새로 적용하지 않습니다. 리비전은 `list_presets`로 별도 확인하십시오. 프리셋을 선택한 `preflight_send`에는 안전한 프리셋 메타데이터 `name`, `agent`, `transport`, `revision`만 추가될 수 있습니다. + +승인된 항목 하나라도 없거나 경로가 정책과 일치하지 않으면 메타데이터 조회 전체를 원자적으로 거부합니다. 고정된 선택 거부 코드의 예는 `preset_platform_unsupported`, `preset_route_unsupported`, `preset_revision_mismatch`, `preset_storage_unavailable`이며, 관리 작업이나 파일 시스템 복구는 시도하지 않습니다. 메타데이터 조회 오류는 호출 전에 발생합니다. 호출 후의 입출력 오류나 실제 전송 응답의 디코딩 실패는 이미 제출된 뒤에도 발생할 수 있으므로 `outcome_unknown`과 `retryAllowed=false`를 보고하고 자동으로 재시도하지 마십시오. 확인된 네이티브 대기열 등록·제출 증거는 유지합니다. + ### 수신 대상 주소록 (v1.3.0) diff --git a/docs/ko/mcp.md b/docs/ko/mcp.md index 9a4298a..81f1c00 100644 --- a/docs/ko/mcp.md +++ b/docs/ko/mcp.md @@ -69,6 +69,22 @@ Python 3.10 이상에서 설치하십시오: `pipx install 'session-peer[mcp]'`( `tcpAccept`, `tls`, `peerAuthentication` 단계는 `not_checked`로 유지됩니다. 여기서 `peerAuthentication`은 페어링된 기기의 인증을 뜻하며 정방향 SSH 인증과 별개입니다. `sshFailure`는 `authentication_failed` 또는 `host_key_failed`처럼 정제된 정방향 SSH 실패 범주만 보고합니다. TCP, TLS, 페어링된 기기 또는 회신 호스트 검사는 추가하지 않습니다. +## 미출시 v1.4.0 후보: MCP 프리셋 (#359) + +후보 버전 입력 시그니처: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +`list_presets(destination)`는 승인된 경로 메타데이터만 읽습니다. `schemaVersion` 2 정책은 `presets`를 명시적으로 허용하고, 목적지별 `presets` 목록에 `[a-z][a-z0-9_-]{0,63}` 형식의 승인된 이름을 1개 이상 128개 이하로 지정해야 합니다. 버전 1은 새 기능과 필드를 거부합니다. 저장소를 읽기 전에 권한을 확인합니다. 결과는 목적지와 일치하는 승인된 `name`, `agent`, `transport`, `revision` 값과 기존 `dp1:` 토큰만 노출하며 실제 호스트, 홈 및 저장소 경로는 생략합니다. 저장된 경로 조회는 세션을 검색하거나 대상을 고르거나 SSH·네이티브·버전 확인 검사를 실행하거나 데이터를 새로 고치지 않습니다. 버전이 지정된 텍스트와 구조화된 도구 결과를 합친 크기는 64 KiB로 제한됩니다. + +`list_sessions`, `preflight_send`, `send_message`는 선택적 `preset`, `preset_revision` 입력을 받습니다. 선택하려면 기존 작업 권한과 `presets`가 모두 필요하며, 저장된 경로나 리비전이 권한을 부여하지는 않습니다. 전송에는 명시적인 대상이 필요하며 실제 전송에는 현재 리비전이 필요합니다. 프리셋은 wake 및 Reply-To URI 대상과 함께 사용할 수 없습니다. macOS/Linux의 기존 Claude Code·Codex 로컬 또는 SSH 경로만 지원하며, Windows, 기기, 주소록, handoff 및 프리셋 관리 지원은 추가하지 않습니다. + +어댑터는 기존 `XDG_CONFIG_HOME` 선택과 보안 읽기 함수를 사용하여 운영자의 실행 환경 저장소에서 검증된 스냅샷을 한 번 읽습니다. 64 KiB·128개 항목 제한과 소유자, ACL, 심볼릭 링크 및 마운트 검사를 유지하며, 없거나 안전하지 않은 저장소를 초기화·복구·대체하지 않습니다. 모델은 저장소 경로를 지정할 수 없습니다. 선택한 경로는 목적지의 호스트, 허용된 에이전트 및 고정된 Codex 홈과 정확히 일치해야 합니다. DNS상 같은 호스트라는 이유로 허용하거나 대체 경로로 전환하지 않습니다. 승인된 경로를 하나의 실행 계획으로 확장하며 CLI `--preset`과 명시적 경로 플래그를 함께 사용하지 않습니다. 호출 전에 정책을 다시 확인합니다. + +리비전은 이름과 경로의 정확한 값을 나타내며, 같은 값을 복원하면 같은 토큰을 다시 사용할 수 있습니다. 리비전은 조회 시 확인되며 대상 세대, 호출자 신원, 저장소 증명 또는 ACK가 아닙니다. 이후의 변경은 이미 확장된 호출을 소급하여 취소하지 않습니다. 프리셋을 선택하지 않으면 기존 도구 응답은 그대로 유지됩니다. + +새 64 KiB 도구 결과 제한은 `list_presets` 메타데이터 조회에만 적용됩니다. 프리셋은 기존 작업의 경로 선택자일 뿐이며, 동일한 작업 권한과 정책 경로의 정확한 일치를 요구합니다. 성공한 `list_sessions`와 `send_message` 응답은 프리셋 메타데이터, 새 명령 이름 또는 추가 데이터 투영 없이 기존 CLI 스키마와 네이티브 페이로드를 그대로 유지합니다. 기존 네이티브 증거와 전송 제한을 보존하며 실제 전송에 합계 64 KiB 제한을 새로 적용하지 않습니다. 리비전은 `list_presets`로 별도 확인하십시오. 프리셋을 선택한 `preflight_send`에는 안전한 프리셋 메타데이터 `name`, `agent`, `transport`, `revision`만 추가될 수 있습니다. + +승인된 항목 하나라도 없거나 경로가 정책과 일치하지 않으면 메타데이터 조회 전체를 원자적으로 거부합니다. 고정된 선택 거부 코드의 예는 `preset_platform_unsupported`, `preset_route_unsupported`, `preset_revision_mismatch`, `preset_storage_unavailable`이며, 관리 작업이나 파일 시스템 복구는 시도하지 않습니다. 메타데이터 조회 오류는 호출 전에 발생합니다. 호출 후의 입출력 오류나 실제 전송 응답의 디코딩 실패는 이미 제출된 뒤에도 발생할 수 있으므로 `outcome_unknown`과 `retryAllowed=false`를 보고하고 자동으로 재시도하지 마십시오. 확인된 네이티브 대기열 등록·제출 증거는 유지합니다. + ## 도구 및 권한 - `list_sessions(destination="local", agent=null, include_inactive=false)`는 CLI의 구조화된 목록을 반환합니다. Agent는 `claude` 또는 `codex`일 수 있으며, 생략 시 허용된 에이전트를 나열합니다. diff --git a/docs/mcp.md b/docs/mcp.md index 071f842..497b90b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -69,6 +69,22 @@ Representative allowlisted codes include `sessions_dir_missing`, `inbox_present` The stages `tcpAccept`, `tls` and `peerAuthentication` remain `not_checked`. Here `peerAuthentication` refers to paired-device authentication, separate from forward SSH authentication. `sshFailure` reports only a sanitized forward SSH failure category, such as `authentication_failed` or `host_key_failed`. No TCP, TLS, paired-device or return-host probe is added. +## Unreleased v1.4.0 candidate: MCP presets (#359) + +Candidate input signatures: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +`list_presets(destination)` reads only approved route metadata. A `schemaVersion` 2 policy must explicitly grant `presets` and provide a nonempty per-destination `presets` list of at most 128 approved names matching `[a-z][a-z0-9_-]{0,63}`. Version 1 rejects the new capability and field. Authorization precedes any store read. The result exposes only matching approved `name`, `agent`, `transport` and `revision` values, with the existing `dp1:` token; raw hosts, homes and store paths are omitted. Saved-route lookup does not discover sessions, choose a target, run SSH/native/version probes or refresh data. The versioned text and structured tool result together are bounded to 64 KiB. + +`list_sessions`, `preflight_send` and `send_message` accept optional `preset` and `preset_revision` inputs. Selection requires both the existing action permission and `presets`; a stored route or revision grants no authority. Sends require an explicit target, and an actual send requires the current revision. Wake and Reply-To URI targets cannot be combined with a preset. Only the existing Claude Code/Codex local or SSH routes on macOS/Linux are supported; this adds no Windows, device, addressbook, handoff or preset-management support. + +The adapter reads one validated snapshot from the operator's launch-environment store, using the existing `XDG_CONFIG_HOME` selection and secure reader. Its 64 KiB/128-entry bounds and owner, ACL, symlink and mount checks remain intact; missing or unsafe storage is not initialized, repaired or replaced. The model cannot supply a store path. Each selected route must exactly match the destination's host, permitted agent and pinned Codex home. DNS equivalence and fallback are not accepted. The approved route is expanded into one execution plan without combining CLI `--preset` with explicit routing flags, and policy is rechecked before invocation. + +A revision covers the exact name and route values; restoring identical values can reuse it. It is checked at lookup, not as a target generation, caller identity, storage attestation or ACK. Later mutation does not retroactively cancel an already expanded invocation. Without a selected preset, existing tool responses are unchanged. + +The new 64 KiB tool-result budget applies only to the `list_presets` metadata query. A preset is only a route selector for existing actions, with the same action authorization and exact policy-route matching. Successful `list_sessions` and `send_message` responses keep the existing CLI schema and native payload, without preset metadata, a new command or an added data projection. Existing native evidence and transport limits remain intact; real sends gain no aggregate 64 KiB limit. Inspect revisions separately with `list_presets`. Selected `preflight_send` may include only safe preset metadata: `name`, `agent`, `transport` and `revision`. + +If any approved item is missing or its route mismatches policy, the whole metadata lookup is refused atomically. Fixed selector refusal codes include `preset_platform_unsupported`, `preset_route_unsupported`, `preset_revision_mismatch` and `preset_storage_unavailable`; no management or filesystem repair is attempted. Metadata-query failures precede invocation. After invocation, I/O or actual-send decode failure may follow submission: report `outcome_unknown` with `retryAllowed=false` and never retry automatically. Preserve any known native queued/submitted evidence. + ## Tools and permissions - `list_sessions(destination="local", agent=null, include_inactive=false)` returns the CLI's structured listing. Agent may be `claude` or `codex`; omission lists permitted agents. diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index 04c8169..1724b5c 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -164,6 +164,18 @@ session-peer preset remove work-mac --expect-revision 'dp1:' 在 macOS 上,忽略所有权的挂载会以 `preset_ownership_unenforced` 被拒绝,无法查询或验证挂载状态时则以 `preset_mount_unverifiable` 被拒绝。不会仅凭模式位断定所有权已强制执行。Linux 也会拒绝上级路径中的 ACL,以及无法验证 ACL 状态的文件系统。中断管理命令后请检查当前配置,因为更改可能已经可见;目录同步失败不会被静默当作成功。 +### 尚未发布的 v1.4.0 候选版本:MCP 预设 (#359) + +候选版本输入签名: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +MCP 预设查询和选择使用操作员的显式允许列表及现有安全快照读取函数,不新增 CLI 预设管理命令或自动选择。参见 [MCP 预设策略与限制](mcp.md)。MCP 选择会展开已批准的路径,而不会将 CLI `--preset` 与显式路由选项组合。现有 CLI 输出、修订令牌和文件系统安全约定保持不变。 + +对于 MCP `list_sessions`、`preflight_send` 和 `send_message`,可选的 `preset`、`preset_revision` 输入既需要现有操作权限,也需要 `presets`。实际发送必须提供当前 `dp1:` 修订令牌和明确目标。与 wake 或 Reply-To URI 的组合会被拒绝。修订令牌在查询时检查,不代表目标代次或权限;之后的修改也不能取消已为调用展开的路径。 + +新增的 64 KiB 工具结果限制仅适用于 `list_presets` 元数据查询。预设仅作为现有操作的路由选择器,仍要求相同的操作授权及与策略路由完全匹配。成功的 `list_sessions` 和 `send_message` 响应保留现有 CLI 模式与原生载荷,不增加预设元数据、新命令名或额外的数据投影。现有原生证据和传输限制保持不变,实际发送不会新增合计 64 KiB 的限制。请通过 `list_presets` 单独检查修订令牌。选择预设的 `preflight_send` 只能额外包含安全的预设元数据:`name`、`agent`、`transport` 和 `revision`。 + +任一已批准项缺失或其路由与策略不匹配时,整个元数据查询都会被原子拒绝。固定的选择拒绝代码包括 `preset_platform_unsupported`、`preset_route_unsupported`、`preset_revision_mismatch` 和 `preset_storage_unavailable`;不会尝试管理操作或文件系统修复。元数据查询错误发生在调用之前。调用后的 I/O 错误或实际发送响应的解码失败可能发生在提交之后,因此必须报告 `outcome_unknown` 和 `retryAllowed=false`,切勿自动重试。已确认的原生排队或提交证据必须保留。 + ### 接收者地址簿(v1.3.0) diff --git a/docs/zh-CN/mcp.md b/docs/zh-CN/mcp.md index 3c093f7..2c7f652 100644 --- a/docs/zh-CN/mcp.md +++ b/docs/zh-CN/mcp.md @@ -69,6 +69,22 @@ `tcpAccept`、`tls` 和 `peerAuthentication` 阶段保持 `not_checked`。这里的 `peerAuthentication` 指配对设备身份验证,与正向 SSH 身份验证不同。`sshFailure` 仅报告经过脱敏的正向 SSH 失败类别,例如 `authentication_failed` 或 `host_key_failed`。不会新增 TCP、TLS、配对设备或回复主机探测。 +## 尚未发布的 v1.4.0 候选版本:MCP 预设 (#359) + +候选版本输入签名: `list_sessions(destination="local", agent=null, include_inactive=false, preset=null, preset_revision=null)`; `send_message(destination, target, message, dry_run=false, wake=false, wake_timeout=30, preset=null, preset_revision=null)`. + +`list_presets(destination)` 仅读取已批准的路径元数据。`schemaVersion` 2 策略必须显式授予 `presets`,并在每个目的地的 `presets` 列表中提供 1 至 128 个符合 `[a-z][a-z0-9_-]{0,63}` 的已批准名称。版本 1 拒绝新增功能和字段。任何存储读取之前都先检查授权。结果仅公开与目的地匹配的已批准 `name`、`agent`、`transport` 和 `revision` 值及现有 `dp1:` 令牌,省略实际主机、主目录和存储路径。查询已保存的路径不会发现会话、选择目标、执行 SSH/原生/版本探测或刷新数据。带版本的文本与结构化工具结果合计限制为 64 KiB。 + +`list_sessions`、`preflight_send` 和 `send_message` 接受可选的 `preset`、`preset_revision` 输入。选择预设既需要现有操作权限,也需要 `presets`;保存的路径或修订令牌不会授予权限。发送必须指定明确目标,实际发送还必须提供当前修订令牌。预设不能与 wake 或 Reply-To URI 目标组合使用。仅支持 macOS/Linux 上现有的 Claude Code/Codex 本地或 SSH 路径;不新增 Windows、设备、地址簿、handoff 或预设管理支持。 + +适配器使用现有 `XDG_CONFIG_HOME` 选择规则和安全读取函数,从操作员启动环境中的存储读取一次经过验证的快照。64 KiB/128 条目的限制以及所有者、ACL、符号链接和挂载检查保持不变;不会初始化、修复或替换缺失或不安全的存储。模型不能指定存储路径。所选路径必须与目的地的主机、允许的代理和固定的 Codex 主目录完全匹配。不接受 DNS 等价性,也不会回退到其他路径。适配器将已批准的路径展开为单个执行计划,不将 CLI `--preset` 与显式路由选项组合,并在调用前重新检查策略。 + +修订令牌涵盖名称和路径的精确值;恢复相同值时可再次使用相同令牌。令牌在查询时检查,不是目标代次、调用者身份、存储证明或 ACK。之后的修改不会追溯取消已经展开的调用。未选择预设时,现有工具响应保持不变。 + +新增的 64 KiB 工具结果限制仅适用于 `list_presets` 元数据查询。预设仅作为现有操作的路由选择器,仍要求相同的操作授权及与策略路由完全匹配。成功的 `list_sessions` 和 `send_message` 响应保留现有 CLI 模式与原生载荷,不增加预设元数据、新命令名或额外的数据投影。现有原生证据和传输限制保持不变,实际发送不会新增合计 64 KiB 的限制。请通过 `list_presets` 单独检查修订令牌。选择预设的 `preflight_send` 只能额外包含安全的预设元数据:`name`、`agent`、`transport` 和 `revision`。 + +任一已批准项缺失或其路由与策略不匹配时,整个元数据查询都会被原子拒绝。固定的选择拒绝代码包括 `preset_platform_unsupported`、`preset_route_unsupported`、`preset_revision_mismatch` 和 `preset_storage_unavailable`;不会尝试管理操作或文件系统修复。元数据查询错误发生在调用之前。调用后的 I/O 错误或实际发送响应的解码失败可能发生在提交之后,因此必须报告 `outcome_unknown` 和 `retryAllowed=false`,切勿自动重试。已确认的原生排队或提交证据必须保留。 + ## 工具与权限 - `list_sessions(destination="local", agent=null, include_inactive=false)` 返回 CLI 的结构化列表。Agent 可以是 `claude` 或 `codex`;省略则列出允许的代理。 diff --git a/session_peer_mcp.py b/session_peer_mcp.py index 4cd243f..4a2b7ba 100644 --- a/session_peer_mcp.py +++ b/session_peer_mcp.py @@ -191,13 +191,29 @@ def load_policy(path: str | None) -> dict: for name, entry in destinations.items(): if not isinstance(name, str) or not name or not isinstance(entry, dict): raise PolicyError("Invalid destination") - if set(entry) - {"host", "codexHome", "agents", "capabilities"}: + settings = {"host", "codexHome", "agents", "capabilities"} + if raw["schemaVersion"] == 2: + settings.add("presets") + if set(entry) - settings: raise PolicyError(f"Unknown settings for {name}") - capabilities = {"list", "send", "wake"} | ({"preflight", "diagnostics", "diagnostics_probe"} if raw["schemaVersion"] == 2 else set()) + capabilities = {"list", "send", "wake"} | ({"preflight", "diagnostics", "diagnostics_probe", "presets"} if raw["schemaVersion"] == 2 else set()) for key, allowed in (("agents", set(core.AGENTS.names())), ("capabilities", capabilities)): values = entry.get(key) if not isinstance(values, list) or not values or any(not isinstance(v, str) or v not in allowed for v in values): raise PolicyError(f"Invalid {key} for {name}") + approved = entry.get("presets") + if "presets" in entry["capabilities"]: + if (not isinstance(approved, list) or not approved or len(approved) > core.PRESET_MAX_ENTRIES + or any(not isinstance(name, str) for name in approved) + or len(set(approved)) != len(approved)): + raise PolicyError("Invalid approved presets") + for preset_name in approved: + try: + core._preset_name(preset_name) + except core.CcPeerError: + raise PolicyError("Invalid approved presets") from None + elif "presets" in entry: + raise PolicyError("Approved presets require the presets capability") host = entry.get("host") if host is not None: if not isinstance(host, str) or not host or any(c.isspace() for c in host): @@ -246,6 +262,102 @@ def authorize(self, destination: str, capability: str, agent: str | None) -> dic raise PolicyError("Agent is not allowed at this destination") return entry + def authorize_preset(self, destination, name=None, *, capability=None, agent=None): + # These checks deliberately precede even platform/store/path inspection. + if capability: + self.authorize(destination, capability, agent) + entry = self.authorize(destination, "presets", agent) + approved = entry.get("presets") + if not isinstance(approved, list) or not approved or len(approved) > core.PRESET_MAX_ENTRIES: + raise PolicyError("Invalid approved presets") + if name is not None: + try: + core._preset_name(name) + except core.CcPeerError: + raise PolicyError("Invalid preset name", "preset_invalid_name") from None + if name not in approved: + raise PolicyError("Preset is not approved", "preset_not_approved") + return entry + + def check_preset_route(self, destination, name, route, *, capability=None, agent=None): + entry = self.authorize_preset(destination, name, capability=capability, agent=agent) + # Exact strings only. A stored alias and a revision grant no authority. + if (route["agent"] not in entry["agents"] or (agent is not None and route["agent"] != agent) + or route["transport"] != ("ssh" if entry.get("host") else "local") + or route.get("host") != entry.get("host") + or (route["agent"] == "codex" and route["codexHome"] != entry.get("codexHome"))): + raise PolicyError("Preset route does not match destination policy", "preset_policy_mismatch") + + @staticmethod + def preset_platform(): + try: + core._preset_platform() + except core.CcPeerError: + raise PolicyError("Preset platform unsupported", "preset_platform_unsupported") from None + + def resolve_preset(self, destination, name, revision, *, capability, agent=None): + self.authorize_preset(destination, name, capability=capability, agent=agent) + if revision is not None and (not isinstance(revision, str) + or core.re.fullmatch(r"dp1:[0-9a-f]{64}", revision) is None): + raise PolicyError("Invalid preset revision", "preset_invalid_revision") + # Exactly one secure read. No model-selected file, fallback or repair. + self.preset_platform() + try: + route, current = core.preset_lookup(name) + except Exception: + raise PolicyError("Preset storage unavailable", "preset_storage_unavailable") from None + route = core.preset_validate_entry(route) + if current != core.preset_revision(name, route): + raise PolicyError("Invalid preset snapshot", "preset_storage_unavailable") + # Authorize the route before revealing whether a supplied digest + # matches it; off-policy route values must not become a hash oracle. + self.check_preset_route(destination, name, route, capability=capability, agent=agent) + if revision is not None and revision != current: + raise PolicyError("Preset revision changed", "preset_revision_mismatch") + return {"name": name, "revision": current, "route": route} + + def recheck_preset(self, destination, plan, *, capability, agent): + # Recheck current policy at the CLI boundary. The store is intentionally + # not read again: later mutation cannot cancel an already expanded plan. + if plan is not None: + self.check_preset_route(destination, plan["name"], plan["route"], + capability=capability, agent=agent) + + @staticmethod + def preset_metadata(plan): + return {"name": plan["name"], "revision": plan["revision"], + "agent": plan["route"]["agent"], "transport": plan["route"]["transport"]} + + async def list_presets(self, destination: str) -> dict: + entry = self.authorize_preset(destination) + if (not isinstance(destination, str) or not destination or not destination.isprintable() + or len(destination.encode("utf-8", errors="replace")) > DESTINATION_ALIAS_BYTES + or any(0xD800 <= ord(ch) <= 0xDFFF for ch in destination)): + raise PolicyError("Invalid destination metadata identifier") + names = tuple(entry["presets"]) + self.preset_platform() + try: + entries = core.preset_read() + except Exception: + raise PolicyError("Preset storage unavailable", "preset_storage_unavailable") from None + rows = [] + for name in sorted(names): + if name not in entries: + raise PolicyError("Approved preset is unavailable", "preset_storage_unavailable") + route = core.preset_validate_entry(entries[name]) + self.check_preset_route(destination, name, route) + rows.append(self.preset_metadata({"name": name, "route": route, + "revision": core.preset_revision(name, route)})) + # Revision projection is pure today; retain an explicit policy check at + # the completed metadata boundary as well as before each projection. + for name in names: + self.check_preset_route(destination, name, entries[name]) + return {"ok": True, "schemaVersion": 1, "command": "mcp.presets", + "destination": destination, "presets": rows, + "limitations": {"readiness": "not_checked", "callerIdentity": "unavailable", + "submission": "not_attempted", "consumption": "not_checked", + "ack": "not_checked", "revision": "route_values_only"}} + def route(self, entry: dict) -> list[str]: argv = [] if entry.get("host"): @@ -454,9 +566,17 @@ async def diagnose_destination(self, destination: str = "local", agent: str | No return value async def list_sessions(self, destination: str = "local", agent: str | None = None, - include_inactive: bool = False) -> dict: + include_inactive: bool = False, preset: str | None = None, + preset_revision: str | None = None) -> dict: entry = self.authorize(destination, "list", agent) - if (agent is None and len(entry["agents"]) > 1 + if preset is None and preset_revision is not None: + raise PolicyError("Preset revision requires a preset") + plan = None + if preset is not None: + plan = self.resolve_preset(destination, preset, preset_revision, capability="list", agent=agent) + entry = plan["route"] + agent = entry["agent"] + if (plan is None and agent is None and len(entry["agents"]) > 1 and set(entry["agents"]) != set(core.AGENTS.names())): # Do not discover newly registered adapters outside this allowlist. parts = [await self.list_sessions(destination, name, include_inactive) @@ -470,19 +590,32 @@ async def list_sessions(self, destination: str = "local", agent: str | None = No combined["error"] = "; ".join(errors) return combined argv = ["list", *self.route(entry)] - if agent is None and len(entry["agents"]) == 1: + if plan is None and agent is None and len(entry["agents"]) == 1: agent = entry["agents"][0] if agent: argv += ["--agent", agent] if include_inactive: argv.append("--all") - return await self.invoke(argv) + self.recheck_preset(destination, plan, capability="list", agent=agent) + value = await self.invoke(argv) + return value def prepare_send(self, destination: str, target: str, message: str, *, - capability: str, dry_run: bool, wake: bool, wake_timeout: int): + capability: str, dry_run: bool, wake: bool, wake_timeout: int, + preset: str | None = None, preset_revision: str | None = None): address = core.parse_reply_address(target) agent = address["agent"] if address else core.AGENTS.for_target(target).name entry = self.authorize(destination, capability, agent) + if preset is None and preset_revision is not None: + raise PolicyError("Preset revision requires a preset") + plan = None + if preset is not None: + if wake or address is not None or agent not in ("claude", "codex"): + raise PolicyError("Unsupported preset send route", "preset_route_unsupported") + if capability == "send" and not dry_run and preset_revision is None: + raise PolicyError("Actual preset sends require a revision", "preset_revision_required") + plan = self.resolve_preset(destination, preset, preset_revision, capability=capability, agent=agent) + entry = plan["route"] if wake: self.authorize(destination, "wake", agent) if not core.AGENTS.get(agent).capabilities.wake: @@ -516,27 +649,48 @@ def prepare_send(self, destination: str, target: str, message: str, *, argv.append("--dry-run") if wake: argv += ["--wake", "--wake-timeout", str(wake_timeout)] - return argv, agent + return argv, agent, plan async def send_message(self, destination: str, target: str, message: str, - dry_run: bool = False, wake: bool = False, wake_timeout: int = 30) -> dict: - argv, _ = self.prepare_send(destination, target, message, capability="send", - dry_run=dry_run, wake=wake, wake_timeout=wake_timeout) + dry_run: bool = False, wake: bool = False, wake_timeout: int = 30, + preset: str | None = None, preset_revision: str | None = None) -> dict: + argv, agent, plan = self.prepare_send(destination, target, message, capability="send", + dry_run=dry_run, wake=wake, wake_timeout=wake_timeout, preset=preset, preset_revision=preset_revision) if wake: # Startup context is an operator-owned cost hint, not caller identity. try: argv += core.wake_chain_options(core.wake_chain_context(argparse.Namespace())) except core.CcPeerError as exc: return {"ok": False, "error": str(exc), **exc.details} - return await self.invoke(argv, "From: session-peer MCP (caller session unavailable)\n\n" + message) + self.recheck_preset(destination, plan, capability="send", agent=agent) + try: + value = await self.invoke(argv, "From: session-peer MCP (caller session unavailable)\n\n" + message) + except Exception: + if plan is None: + raise + value = {"ok": False, "status": "unknown", "submitted": None, + "reason": "outcome_unknown", "retryAllowed": False} + if plan and not isinstance(value, dict): + value = {"ok": False, "status": "unknown", "submitted": None, + "reason": "outcome_unknown", "retryAllowed": False} + if plan and value.get("reason") in ("invalid_cli_response", "cli_failed", "outcome_unknown"): + value = {**value, "retryAllowed": False} + known_submission = (value.get("submitted") is True or value.get("consumptionConfirmed") is True + or value.get("status") in ("posted", "queued", "submitted")) + if not known_submission: + value = {**value, "status": "unknown", "submitted": None, + "reason": "outcome_unknown", "retryAllowed": False} + return value async def preflight_send(self, destination: str, target: str, message: str, - wake: bool = False, wake_timeout: int = 30) -> dict: - argv, agent = self.prepare_send(destination, target, message, capability="preflight", - dry_run=True, wake=wake, wake_timeout=wake_timeout) + wake: bool = False, wake_timeout: int = 30, + preset: str | None = None, preset_revision: str | None = None) -> dict: + argv, agent, plan = self.prepare_send(destination, target, message, capability="preflight", + dry_run=True, wake=wake, wake_timeout=wake_timeout, preset=preset, preset_revision=preset_revision) if wake: argv += core.wake_chain_options(core.wake_chain_context(argparse.Namespace())) # Do not reuse compatibility JSON: SSH shaping may erase native uncertainty. + self.recheck_preset(destination, plan, capability="preflight", agent=agent) try: value = await self.invoke([*argv, "--explain-json"], "From: session-peer MCP (caller session unavailable)\n\n" + message) @@ -588,6 +742,8 @@ async def preflight_send(self, destination: str, target: str, message: str, if not valid: output["reason"] = ("outcome_unknown" if value.get("reason") == "outcome_unknown" else "preflight_response_unavailable") + if plan: + output["preset"] = self.preset_metadata(plan) return output @@ -620,6 +776,8 @@ async def result(call, *, command=None): value = await call except (PolicyError, core.CcPeerError) as exc: value = {"ok": False, "reason": "request_rejected", "error": str(exc)} + if isinstance(exc, PolicyError) and exc.reason.startswith("preset_"): + value.update(reason=exc.reason, retryAllowed=False) if command == "send.preflight": reason = exc.reason if isinstance(exc, PolicyError) else "request_rejected" value = preflight_rejection(reason, getattr(exc, "details", None)) @@ -627,7 +785,7 @@ async def result(call, *, command=None): value.update(schemaVersion=1, command=command) elif command: value = {"ok": False, "schemaVersion": 1, "command": command, - "reason": "request_rejected", "retryAllowed": False, + "reason": value["reason"], "retryAllowed": False, "operatorActions": ["review_policy_and_request"]} except (ValueError, OSError): if not command: @@ -653,6 +811,16 @@ async def result(call, *, command=None): structuredContent=value, isError=True) return envelope + @server.tool(annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, + idempotentHint=True, openWorldHint=False)) + async def list_presets(destination: str): + """Read only approved route preset metadata; never discover or probe. + + Revisions describe saved route values, never identity or authorization. + This tool never initializes or repairs preset storage. + """ + return await result(adapter.list_presets(destination), command="mcp.presets") + @server.tool(annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)) async def list_destinations(): @@ -679,28 +847,36 @@ async def diagnose_destination(destination: str = "local", agent: str | None = N @server.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) async def list_sessions(destination: str = "local", agent: str | None = None, - include_inactive: bool = False): - """List sessions on an operator-configured destination; agent must be permitted by the destination policy.""" - return await result(adapter.list_sessions(destination, agent, include_inactive)) + include_inactive: bool = False, preset: str | None = None, + preset_revision: str | None = None): + """List sessions on an authorized route; preset selection requires additional policy permission.""" + return await result(adapter.list_sessions(destination, agent, include_inactive, preset, preset_revision), + command=None) @server.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True)) async def preflight_send(destination: str, target: str, message: str, - wake: bool = False, wake_timeout: int = 30): + wake: bool = False, wake_timeout: int = 30, + preset: str | None = None, preset_revision: str | None = None): """Validate once without submitting. Requires explicit preflight policy permission. Existing dry-run checks may run local probes and operator-configured SSH. Passing grants no host approval and reserves no route or later send. """ - return await result(adapter.preflight_send(destination, target, message, wake, wake_timeout), + return await result(adapter.preflight_send(destination, target, message, wake, wake_timeout, preset, preset_revision), command="send.preflight") @server.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True)) async def send_message(destination: str, target: str, message: str, - dry_run: bool = False, wake: bool = False, wake_timeout: int = 30): - """Send once to a configured destination. Submission does not confirm consumption.""" - return await result(adapter.send_message(destination, target, message, dry_run, wake, wake_timeout)) + dry_run: bool = False, wake: bool = False, wake_timeout: int = 30, + preset: str | None = None, preset_revision: str | None = None): + """Send once. Submission does not confirm consumption. + + An actual preset send requires its current route revision and an explicit target. + """ + return await result(adapter.send_message(destination, target, message, dry_run, wake, wake_timeout, preset, preset_revision), + command=None) return server diff --git a/tests/mcp_integration/test_mcp.py b/tests/mcp_integration/test_mcp.py index 9891547..4d0d42d 100644 --- a/tests/mcp_integration/test_mcp.py +++ b/tests/mcp_integration/test_mcp.py @@ -305,7 +305,7 @@ async def test_stdio_tools_and_policy_errors(self): async with ClientSession(read, write) as client: await client.initialize() tools = (await client.list_tools()).tools - self.assertEqual({t.name for t in tools}, {'list_destinations','list_sessions','send_message','preflight_send','diagnose_destination'}) + self.assertEqual({t.name for t in tools}, {'list_destinations','list_sessions','send_message','preflight_send','diagnose_destination','list_presets'}) inventory = next(t for t in tools if t.name == 'list_destinations') self.assertTrue(inventory.annotations.readOnlyHint) self.assertTrue(inventory.annotations.idempotentHint) diff --git a/tests/mcp_integration/test_preset_real_stdio.py b/tests/mcp_integration/test_preset_real_stdio.py new file mode 100644 index 0000000..d118791 --- /dev/null +++ b/tests/mcp_integration/test_preset_real_stdio.py @@ -0,0 +1,278 @@ +"""Real preset-reader/stdin protocol integration; synthetic owned Claude inbox only. + +The positive secure-store cell may skip locally for verified external ancestor +or ownership-enforcement limitations. Supported CI cells must run it. No owner, +ACL, mount or symlink guard is patched. +Separate race cells explicitly use a validated synthetic snapshot and make no +claim that such a snapshot establishes private-store acceptance. +""" +import asyncio +import contextlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest.mock import patch + +import session_peer as core +import session_peer_mcp as peer + +LOCAL_ROUTE = {'transport': 'local', 'agent': 'claude'} +STORE_DOCUMENT = {'schemaVersion': 1, 'presets': {'work': LOCAL_ROUTE}} +SUPPORTED = os.name == 'posix' and sys.platform in ('darwin', 'linux') + + +@unittest.skipUnless(importlib.util.find_spec('mcp') and SUPPORTED, + 'optional MCP SDK and macOS/Linux Unix inbox required') +class RealPresetStdio(unittest.IsolatedAsyncioTestCase): + def setUp(self): + folder = tempfile.TemporaryDirectory(prefix='codex-359-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) + self.addCleanup(folder.cleanup) + self.root = Path(folder.name).resolve() + self.root.chmod(0o700) + self.home = self.root / 'h' + self.home.mkdir(mode=0o700) + self.xdg = self.root / 'x' + self.xdg.mkdir(mode=0o700) + self.app = self.xdg / 'session-peer' + self.store = self.app / 'destinations.json' + self.claude = self.root / 'c' + self.claude.mkdir(mode=0o700) + self.sessions = self.claude / 'sessions' + self.sessions.mkdir(mode=0o700) + self.policy = self.root / 'policy.json' + self.policy.write_text(json.dumps({'schemaVersion': 2, 'destinations': {'local': { + 'agents': ['claude'], 'capabilities': ['presets', 'list', 'preflight', 'send'], + 'presets': ['work']}}})) + self.policy.chmod(0o600) + self.environment = {**os.environ, 'HOME': str(self.home), 'XDG_CONFIG_HOME': str(self.xdg), + 'CLAUDE_CONFIG_DIR': str(self.claude)} + # Do not let the synthetic source CLI inherit another agent's identity + # or a collection of additional Claude profiles from this workstation. + for key in ('CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'CLAUDE_SESSION_ID', + 'CLAUDE_CODE_SESSION_ID', 'SESSION_PEER_REPLY_HOST', 'CC_PEER_REPLY_HOST'): + self.environment.pop(key, None) + + def write_store(self): + self.app.mkdir(mode=0o700) + self.store.write_text(json.dumps(STORE_DOCUMENT)) + self.store.chmod(0o600) + + def read_real_store(self): + with patch.dict(os.environ, {'XDG_CONFIG_HOME': str(self.xdg)}): + return core.preset_read() + + @contextlib.asynccontextmanager + async def client(self, wrapper=None): + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + args = ([str(Path(peer.__file__).resolve()), '--config', str(self.policy)] if wrapper is None + else [str(wrapper), str(self.policy)]) + params = StdioServerParameters(command=sys.executable, args=args, + cwd=str(self.root), env=self.environment) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as client: + await client.initialize() + yield client + + @contextlib.asynccontextmanager + async def inbox(self): + received = [] + arrived = asyncio.Event() + async def accept(reader, writer): + try: + received.append(json.loads(await reader.readline())) + arrived.set() + finally: + writer.close() + await writer.wait_closed() + path = self.root / 'i' + server = await asyncio.start_unix_server(accept, str(path)) + record = self.sessions / (str(os.getpid()) + '.json') + record.write_text(json.dumps({'pid': os.getpid(), 'name': 'preset-fixture', + 'messagingSocketPath': str(path)})) + record.chmod(0o600) + try: + yield received, arrived + finally: + server.close() + await server.wait_closed() + + @staticmethod + def storage_snapshot(root): + # Never follow links while checking preservation of fixture storage. + result = {} + for path in [root, *root.rglob('*')]: + info = path.lstat() + value = (stat.S_IMODE(info.st_mode), info.st_ino, info.st_mtime_ns) + if stat.S_ISLNK(info.st_mode): + value += (os.readlink(path),) + elif stat.S_ISREG(info.st_mode): + value += (path.read_bytes(),) + result[str(path.relative_to(root))] = value + return result + + async def test_real_secure_store_and_stdio_selection_preserve_native_json_and_post_once(self): + self.write_store() + try: + snapshot = self.read_real_store() + except core.CcPeerError as error: + # First prove that the private fixture itself meets the contract. + # A local-volume classification never excuses a malformed fixture. + for path in (self.root, self.xdg, self.app): + info = path.lstat() + self.assertTrue(stat.S_ISDIR(info.st_mode)) + self.assertEqual(stat.S_IMODE(info.st_mode), 0o700) + self.assertEqual(info.st_uid, os.geteuid()) + info = self.store.lstat() + self.assertTrue(stat.S_ISREG(info.st_mode)) + self.assertEqual(stat.S_IMODE(info.st_mode), 0o600) + self.assertEqual(info.st_uid, os.geteuid()) + # Supported CI must exercise the real positive path; unexpected + # native guard refusals fail there rather than becoming green skips. + if os.environ.get('GITHUB_ACTIONS', '').lower() != 'true': + reason = error.details.get('reason') + if reason == 'preset_ownership_unenforced': + self.skipTest('local real secure-store positive unavailable: volume ownership disabled; guards unchanged') + if reason == 'preset_unsafe_storage': + for ancestor in self.root.parents: + metadata = ancestor.stat() + unsafe = metadata.st_mode & 0o022 + sticky_root = metadata.st_uid == 0 and metadata.st_mode & stat.S_ISVTX + if unsafe and not sticky_root: + self.skipTest('local real secure-store positive unavailable: external ancestor mode ' + + oct(stat.S_IMODE(metadata.st_mode)) + ' is group/other-writable; guards unchanged') + raise + self.assertEqual(snapshot, {'work': LOCAL_ROUTE}) + revision = core.preset_revision('work', LOCAL_ROUTE) + before = self.storage_snapshot(self.xdg) + async with self.inbox() as (received, arrived), self.client() as client: + metadata = await client.call_tool('list_presets', {'destination': 'local'}) + self.assertFalse(metadata.isError) + self.assertEqual(metadata.structuredContent['presets'], [{'name': 'work', 'revision': revision, + 'transport': 'local', 'agent': 'claude'}]) + listing = await client.call_tool('list_sessions', {'destination': 'local', 'preset': 'work'}) + self.assertFalse(listing.isError) + self.assertEqual(listing.structuredContent['command'], 'list') + self.assertEqual(listing.structuredContent['schemaVersion'], 1) + self.assertNotIn('preset', listing.structuredContent) + self.assertTrue(any(row.get('pid') == os.getpid() for row in listing.structuredContent['sessions'])) + preflight = await client.call_tool('preflight_send', {'destination': 'local', 'preset': 'work', + 'target': 'preset-fixture', 'message': 'synthetic preflight body'}) + self.assertFalse(preflight.isError) + self.assertEqual(preflight.structuredContent['preset']['revision'], revision) + self.assertEqual(received, []) + sent = await client.call_tool('send_message', {'destination': 'local', 'preset': 'work', + 'preset_revision': revision, 'target': 'preset-fixture', 'message': 'synthetic actual inbox body'}) + self.assertFalse(sent.isError) + self.assertEqual(sent.structuredContent['command'], 'send') + self.assertEqual(sent.structuredContent['schemaVersion'], 1) + self.assertNotIn('preset', sent.structuredContent) + await asyncio.wait_for(arrived.wait(), 2) + self.assertEqual(len(received), 1) + self.assertIn('synthetic actual inbox body', received[0]['message']['content']) + self.assertEqual(self.storage_snapshot(self.xdg), before) + + async def test_real_negative_storage_refusals_preserve_missing_unsafe_and_symlink_cases(self): + # An external ancestor or mount guard may refuse before the later + # file-mode/symlink guards. Report the actual reason without claiming + # those later guards were reached; internal CI can exercise them. + observations = {} + for case in ('missing', 'unsafe_file_mode', 'symlink'): + with self.subTest(case=case): + if self.app.exists(): + if self.store.is_symlink() or self.store.exists(): + self.store.unlink() + self.app.rmdir() + target = self.root / 'target.json' + if case != 'missing': + self.app.mkdir(mode=0o700) + if case == 'unsafe_file_mode': + self.store.write_text(json.dumps(STORE_DOCUMENT)) + self.store.chmod(0o644) + else: + target.write_text(json.dumps(STORE_DOCUMENT)) + target.chmod(0o600) + self.store.symlink_to(target) + before = self.storage_snapshot(self.xdg) + target_before = target.read_bytes() if target.exists() else None + try: + self.assertEqual(self.read_real_store(), {}) + self.assertEqual(case, 'missing', 'unsafe fixture must never be accepted') + observations[case] = 'missing_snapshot' + except core.CcPeerError as error: + observations[case] = error.details.get('reason') + self.assertIn(observations[case], ('preset_ownership_unenforced', 'preset_unsafe_storage', + 'preset_unsafe_acl', 'preset_acl_unverifiable', 'preset_mount_unverifiable')) + async with self.client() as client: + value = await client.call_tool('list_presets', {'destination': 'local'}) + self.assertTrue(value.isError) + self.assertEqual(value.structuredContent['reason'], 'preset_storage_unavailable') + self.assertNotIn(str(self.xdg), value.content[0].text) + self.assertNotIn(str(target), value.content[0].text) + self.assertEqual(self.storage_snapshot(self.xdg), before) + self.assertFalse((self.app / 'destinations.lock').exists()) + if target_before is not None: + self.assertEqual(target.read_bytes(), target_before) + for case, reason in observations.items(): + detail = ('ownership-disabled mount refused; later file-specific guard not reached' + if reason == 'preset_ownership_unenforced' else + 'observed ' + str(reason) + '; file preservation verified, internal refusal stage not asserted') + print('preset integration ' + case + ': ' + detail, file=sys.stderr) + + def race_wrapper(self, *, revoke_policy): + wrapper = self.root / 'race.py' + # Validated synthetic read ONLY for race semantics on any volume. The + # real secure-store positive/negative cells above never patch this path. + wrapper.write_text('import sys, os\n' + + 'sys.path.insert(0, ' + repr(str(Path(peer.__file__).resolve().parent)) + ')\n' + + 'import session_peer_mcp as peer\n' + + 'document = ' + repr(STORE_DOCUMENT) + '\n' + + 'def snapshot():\n return peer.core._preset_decode(peer.json.dumps(document).encode())\n' + + 'peer.core.preset_read = snapshot\n' + + 'original_lookup = peer.core.preset_lookup\n' + + 'adapter = peer.Adapter(peer.load_policy(sys.argv[1]))\n' + + 'def lookup(name):\n result = original_lookup(name)\n' + + (' adapter.destinations["local"]["capabilities"].remove("send")\n' if revoke_policy else + ' document["presets"]["work"] = {"transport":"ssh", "agent":"claude", "host":"never-connect.invalid"}\n') + + ' return result\n' + + 'peer.core.preset_lookup = lookup\n' + + ('async def forbidden(*a, **k):\n os._exit(94)\nadapter.invoke = forbidden\n' if revoke_policy else + 'original_invoke = adapter.invoke\nasync def local_only(argv, message=None):\n' + ' if "--host" in argv or "--preset" in argv:\n os._exit(94)\n' + ' return await original_invoke(argv, message)\nadapter.invoke = local_only\n') + + 'peer.create_server(adapter).run(transport="stdio")\n') + wrapper.chmod(0o600) + return wrapper + + async def test_stdio_policy_revocation_after_snapshot_denies_before_any_invoke(self): + wrapper = self.race_wrapper(revoke_policy=True) + async with self.client(wrapper) as client: + value = await client.call_tool('send_message', {'destination': 'local', 'preset': 'work', + 'preset_revision': core.preset_revision('work', LOCAL_ROUTE), 'target': 'preset-fixture', + 'message': 'must never invoke'}) + self.assertTrue(value.isError) + self.assertFalse(value.structuredContent['ok']) + self.assertEqual(value.structuredContent['reason'], 'request_rejected') + # The forbidden invoker exits the process; this second successful + # exchange proves no invoke occurred and no process was replaced. + inventory = await client.call_tool('list_destinations', {}) + self.assertFalse(inventory.isError) + + async def test_stdio_later_snapshot_mutation_does_not_cancel_expanded_local_route(self): + wrapper = self.race_wrapper(revoke_policy=False) + async with self.inbox() as (received, arrived), self.client(wrapper) as client: + sent = await client.call_tool('send_message', {'destination': 'local', 'preset': 'work', + 'preset_revision': core.preset_revision('work', LOCAL_ROUTE), 'target': 'preset-fixture', + 'message': 'expanded local snapshot body'}) + self.assertFalse(sent.isError) + self.assertEqual(sent.structuredContent['command'], 'send') + self.assertEqual(sent.structuredContent['schemaVersion'], 1) + self.assertNotIn('preset', sent.structuredContent) + await asyncio.wait_for(arrived.wait(), 2) + self.assertEqual(len(received), 1) + self.assertIn('expanded local snapshot body', received[0]['message']['content']) diff --git a/tests/mcp_integration/test_presets.py b/tests/mcp_integration/test_presets.py new file mode 100644 index 0000000..46be237 --- /dev/null +++ b/tests/mcp_integration/test_presets.py @@ -0,0 +1,446 @@ +"""Approved MCP presets: synthetic secure snapshots, no real private store.""" +import argparse +import asyncio +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import AsyncMock, patch + +import session_peer as core +import session_peer_mcp as peer + +THREAD = '01900000-0000-7000-8000-000000000001' +PRIVATE_MARKER = 'nonreflected-fixture-value' +LOCAL = {'agent': 'claude', 'transport': 'local'} +REMOTE = {'agent': 'codex', 'transport': 'ssh', 'host': 'user@approved-host', 'codexHome': '/approved/home'} + + +def policy(route=LOCAL, *, capabilities=('presets', 'list', 'preflight', 'send'), names=('work',)): + return {'agents': [route['agent']], 'capabilities': list(capabilities), 'presets': list(names), + **({'host': route['host']} if 'host' in route else {}), + **({'codexHome': route['codexHome']} if 'codexHome' in route else {})} + + +def preview(agent='claude'): + args = argparse.Namespace(to='target' if agent == 'claude' else agent + ':target', wake=False) + return core.structured_delivery_preflight(args, {'ok': True, 'dryRun': True}) + + +class PresetPolicy(unittest.TestCase): + def test_explicit_version_two_bounded_names_and_capability(self): + with tempfile.TemporaryDirectory(prefix='codex-preset-policy-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as folder: + path = Path(folder) / 'policy.json' + cases = [(1, policy(), False), (2, policy(), True), + (1, policy(capabilities=('list',)), False), + (2, policy(capabilities=('list',)), False), + (2, policy(names=()), False), (2, policy(names=('work', 'work')), False), + (2, policy(names=('INVALID',)), False), (2, policy(names=('a' * 65,)), False), + (2, policy(names=(PRIVATE_MARKER + '\n',)), False), + (2, policy(names=(None,)), False), + (2, policy(names=tuple('p' + str(i) for i in range(128))), True), + (2, policy(names=tuple('p' + str(i) for i in range(129))), False)] + for version, entry, accepted in cases: + with self.subTest(version=version, entry=entry): + path.write_text(json.dumps({'schemaVersion': version, 'destinations': {'local': entry}})) + if accepted: + self.assertEqual(peer.load_policy(str(path))['local'], entry) + else: + with self.assertRaises((peer.PolicyError, core.CcPeerError)) as caught: + peer.load_policy(str(path)) + self.assertNotIn(PRIVATE_MARKER, str(caught.exception)) + self.assertNotIn('presets', peer.load_policy(None)['local']['capabilities']) + + +@unittest.skipUnless(os.name == 'posix' and sys.platform in ('darwin', 'linux'), + 'Preset storage is macOS/Linux only') +class PresetSelection(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.adapter = peer.Adapter({'local': policy(), 'remote': policy(REMOTE)}) + self.adapter.invoke = AsyncMock(return_value={'ok': True, 'command': 'list', 'sessions': []}) + self.lookup = patch.object(core, 'preset_lookup', side_effect=lambda name: (dict(LOCAL), core.preset_revision(name, LOCAL))) + self.mock_lookup = self.lookup.start() + self.addCleanup(self.lookup.stop) + self.read = patch.object(core, 'preset_read', return_value={'work': dict(LOCAL)}) + self.mock_read = self.read.start() + self.addCleanup(self.read.stop) + + async def test_denied_names_capabilities_and_agents_never_read_store_or_invoke(self): + for entry in (policy(capabilities=('list',)), policy(capabilities=('presets',)), + policy(capabilities=('presets', 'list'), names=('other',))): + self.adapter.destinations['local'] = entry + with self.assertRaises(peer.PolicyError): + await self.adapter.list_sessions('local', preset='work') + self.adapter.destinations['local'] = policy() + with self.assertRaises(peer.PolicyError): + await self.adapter.list_sessions('local', agent='codex', preset='work') + with self.assertRaises(peer.PolicyError): + await self.adapter.list_presets('unknown') + self.mock_read.assert_not_called() + self.mock_lookup.assert_not_called() + self.adapter.invoke.assert_not_called() + + async def test_metadata_only_approved_names_and_safe_route_projection(self): + self.mock_read.return_value = {'work': dict(LOCAL), 'private': { + 'agent': 'codex', 'transport': 'ssh', 'host': PRIVATE_MARKER, 'codexHome': '/' + PRIVATE_MARKER}} + with patch.object(core, 'preset_mutate', side_effect=AssertionError('write')), \ + patch.object(core.subprocess, 'Popen', side_effect=AssertionError('child')): + value = await self.adapter.list_presets('local') + self.assertEqual(value['presets'], [{'name': 'work', 'agent': 'claude', 'transport': 'local', + 'revision': core.preset_revision('work', LOCAL)}]) + self.assertEqual(value['schemaVersion'], 1) + self.assertNotIn(PRIVATE_MARKER, json.dumps(value)) + self.mock_read.assert_called_once_with() + self.mock_lookup.assert_not_called() + self.adapter.invoke.assert_not_called() + + async def test_metadata_missing_or_mismatched_approved_route_refuses_atomically(self): + for snapshot in ({}, {'work': REMOTE}, {'work': {'agent': 'codex', 'transport': 'local', 'codexHome': '/other'}}): + self.mock_read.return_value = snapshot + with self.assertRaises((peer.PolicyError, core.CcPeerError)): + await self.adapter.list_presets('local') + self.adapter.invoke.assert_not_called() + + async def test_listing_expands_one_approved_route_never_cli_preset(self): + value = await self.adapter.list_sessions('local', include_inactive=True, preset='work') + self.mock_lookup.assert_called_once_with('work') + self.mock_read.assert_not_called() + self.assertEqual(self.adapter.invoke.call_args.args[0], ['list', '--agent', 'claude', '--all']) + self.assertEqual(value, self.adapter.invoke.return_value) + + async def test_remote_preflight_expands_exact_route_and_keeps_revision_metadata(self): + self.mock_lookup.side_effect = lambda name: (dict(REMOTE), core.preset_revision(name, REMOTE)) + self.adapter.invoke.return_value = preview('codex') + result = await self.adapter.preflight_send('remote', 'codex:' + THREAD, 'fixture message', preset='work') + argv = self.adapter.invoke.call_args.args[0] + self.assertTrue(result['ok']) + self.assertEqual(argv[:5], ['send', '--host', REMOTE['host'], '--codex-home', REMOTE['codexHome']]) + self.assertIn('--dry-run', argv) + self.assertIn('--explain-json', argv) + self.assertNotIn('--preset', argv) + self.assertNotIn(REMOTE['host'], json.dumps(result)) + self.assertNotIn(REMOTE['codexHome'], json.dumps(result)) + self.mock_lookup.assert_called_once_with('work') + + async def test_actual_send_requires_current_revision_and_explicit_target(self): + with self.assertRaises(peer.PolicyError): + await self.adapter.send_message('local', 'worker', 'fixture', preset='work') + self.mock_lookup.assert_not_called() + for revision in ('invalid', 'dp1:' + '0' * 64): + with self.assertRaises(peer.PolicyError): + await self.adapter.send_message('local', 'worker', 'fixture', preset='work', preset_revision=revision) + self.adapter.invoke.assert_not_called() + revision = core.preset_revision('work', LOCAL) + value = await self.adapter.send_message('local', 'worker', 'fixture', preset='work', preset_revision=revision) + argv = self.adapter.invoke.call_args.args[0] + self.assertEqual(argv[argv.index('--to') + 1], 'worker') + self.assertNotIn('--preset', argv) + self.assertNotIn('--dry-run', argv) + self.assertEqual(value, self.adapter.invoke.return_value) + + async def test_off_policy_route_cannot_be_probed_through_revision_errors(self): + off_policy = dict(REMOTE, host='nonreflected-off-policy-host') + current = core.preset_revision('work', off_policy) + self.mock_lookup.side_effect = lambda name: (off_policy, current) + for candidate in (current, 'dp1:' + '0' * 64): + with self.assertRaises(peer.PolicyError) as caught: + await self.adapter.list_sessions('remote', preset='work', preset_revision=candidate) + self.assertEqual(caught.exception.reason, 'preset_policy_mismatch') + self.assertNotIn(off_policy['host'], str(caught.exception)) + self.adapter.invoke.assert_not_called() + + async def test_dryrun_can_omit_revision_and_absent_selector_keeps_old_result(self): + value = await self.adapter.send_message('local', 'worker', 'fixture', dry_run=True, preset='work') + self.assertIn('--dry-run', self.adapter.invoke.call_args.args[0]) + self.assertEqual(value, self.adapter.invoke.return_value) + self.mock_lookup.reset_mock() + self.mock_read.reset_mock() + ordinary = await self.adapter.list_sessions('local') + self.assertEqual(ordinary, self.adapter.invoke.return_value) + self.mock_lookup.assert_not_called() + self.mock_read.assert_not_called() + + async def test_selected_send_post_invocation_failure_is_unknown_without_retry(self): + revision = core.preset_revision('work', LOCAL) + self.adapter.invoke.side_effect = OSError(PRIVATE_MARKER) + value = await self.adapter.send_message('local', 'worker', 'fixture', + preset='work', preset_revision=revision) + self.assertEqual(value['status'], 'unknown') + self.assertIsNone(value['submitted']) + self.assertFalse(value['retryAllowed']) + self.assertNotIn(PRIVATE_MARKER, json.dumps(value)) + self.adapter.invoke.assert_awaited_once() + + async def test_decode_failure_is_unknown_but_complete_submission_proof_is_preserved(self): + revision = core.preset_revision('work', LOCAL) + for payload in (peer.Adapter.decode(b'[]', 0), peer.Adapter.decode(b'invalid', 1), + {'ok': False, 'reason': 'cli_failed'}): + self.adapter.invoke.return_value = payload + value = await self.adapter.send_message('local', 'worker', 'fixture', + preset='work', preset_revision=revision) + self.assertEqual(value['status'], 'unknown') + self.assertEqual(value['reason'], 'outcome_unknown') + self.assertIsNone(value['submitted']) + self.assertFalse(value['retryAllowed']) + native = {'ok': False, 'reason': 'cli_failed', 'submitted': True, + 'status': 'queued', 'queueId': 'fixture-queue'} + self.adapter.invoke.return_value = native + value = await self.adapter.send_message('local', 'worker', 'fixture', + preset='work', preset_revision=revision) + for key in ('submitted', 'status', 'queueId'): + self.assertEqual(value[key], native[key]) + self.assertFalse(value['retryAllowed']) + + async def test_exact_alias_home_transport_and_agent_intersection_no_dns_or_fallback(self): + cases = [dict(REMOTE, host='approved-host'), dict(REMOTE, codexHome='/approved/home/'), + {'agent': 'codex', 'transport': 'local', 'codexHome': REMOTE['codexHome']}, dict(LOCAL)] + for route in cases: + self.mock_lookup.side_effect = lambda name, selected=route: (dict(selected), core.preset_revision(name, selected)) + with self.subTest(route=route), patch.object(core, 'resolve_ssh_destination', side_effect=AssertionError('DNS')): + with self.assertRaises(peer.PolicyError): + await self.adapter.list_sessions('remote', preset='work') + self.adapter.invoke.assert_not_called() + + async def test_policy_is_rechecked_after_resolution_and_immediately_before_effect(self): + revision = core.preset_revision('work', LOCAL) + def change_policy(_): + self.adapter.destinations['local']['capabilities'].remove('presets') + return dict(LOCAL), revision + self.mock_lookup.side_effect = change_policy + with self.assertRaises(peer.PolicyError): + await self.adapter.list_sessions('local', preset='work') + self.adapter.invoke.assert_not_called() + self.adapter.destinations['local'] = policy() + self.mock_lookup.side_effect = lambda name: (dict(LOCAL), core.preset_revision(name, LOCAL)) + for mutate in (lambda e: e['capabilities'].remove('send'), + lambda e: e['presets'].clear(), lambda e: e.update(host='changed-host'), + lambda e: e.update(agents=['codex'])): + self.adapter.destinations['local'] = policy() + with patch.object(core, 'check_message', side_effect=lambda *a, **kw: mutate(self.adapter.destinations['local'])): + with self.assertRaises(peer.PolicyError): + await self.adapter.send_message('local', 'worker', 'fixture', preset='work', preset_revision=revision) + self.adapter.invoke.assert_not_called() + + async def test_snapshot_copy_survives_later_store_mutation_without_second_lookup(self): + stored = dict(REMOTE) + revision = core.preset_revision('work', stored) + self.mock_lookup.side_effect = lambda name: (stored, revision) + def mutate_store(*args, **kwargs): + stored['host'] = 'changed-after-expansion' + with patch.object(core, 'check_message', side_effect=mutate_store): + value = await self.adapter.send_message('remote', 'codex:' + THREAD, 'fixture', + preset='work', preset_revision=revision) + argv = self.adapter.invoke.call_args.args[0] + self.assertEqual(argv[argv.index('--host') + 1], REMOTE['host']) + self.assertEqual(value, self.adapter.invoke.return_value) + self.mock_lookup.assert_called_once() + + async def test_wake_uri_revision_without_name_and_other_agent_rejected_before_lookup(self): + for flags in ({'wake': True, 'preset': 'work'}, {'preset_revision': 'dp1:' + '0' * 64}): + with self.assertRaises(peer.PolicyError): + await self.adapter.send_message('local', 'worker', 'fixture', dry_run=True, **flags) + uri = 'session-peer://v1/reply?agent=claude&session=42&transport=local' + with self.assertRaises(peer.PolicyError): + await self.adapter.send_message('local', uri, 'fixture', dry_run=True, preset='work') + self.adapter.destinations['local']['agents'].append('antigravity') + with self.assertRaises(peer.PolicyError): + await self.adapter.send_message('local', 'antigravity:' + THREAD, 'fixture', dry_run=True, preset='work') + self.mock_lookup.assert_not_called() + self.adapter.invoke.assert_not_called() + + async def test_platform_is_not_broadened_even_with_mocked_store(self): + with patch.object(core, 'IS_WINDOWS', True): + for operation in (self.adapter.list_presets('local'), self.adapter.list_sessions('local', preset='work')): + with self.assertRaises(peer.PolicyError) as caught: + await operation + self.assertEqual(caught.exception.reason, 'preset_platform_unsupported') + self.mock_lookup.assert_not_called() + self.mock_read.assert_not_called() + + async def test_core_reader_guard_failures_are_not_repaired_or_replaced(self): + for failure in (core._preset_error('preset_ownership_unenforced'), RuntimeError(PRIVATE_MARKER)): + self.mock_lookup.side_effect = failure + with patch.object(core, 'preset_mutate', side_effect=AssertionError('repair')): + with self.assertRaises(peer.PolicyError) as caught: + await self.adapter.list_sessions('local', preset='work') + self.assertNotIn(PRIVATE_MARKER, str(caught.exception)) + self.adapter.invoke.assert_not_called() + + async def test_cancellation_propagates_without_retry_or_store_repair(self): + self.adapter.invoke.side_effect = asyncio.CancelledError + with patch.object(core, 'preset_mutate', side_effect=AssertionError('write')): + with self.assertRaises(asyncio.CancelledError): + await self.adapter.list_sessions('local', preset='work') + self.mock_lookup.assert_called_once() + self.adapter.invoke.assert_awaited_once() + + async def test_actual_selected_send_cancellation_propagates_without_retry(self): + self.adapter.invoke.side_effect = asyncio.CancelledError + with self.assertRaises(asyncio.CancelledError): + await self.adapter.send_message('local', 'worker', 'fixture', + preset='work', preset_revision=core.preset_revision('work', LOCAL)) + self.mock_lookup.assert_called_once() + self.adapter.invoke.assert_awaited_once() + + +@unittest.skipUnless(os.name == 'posix' and sys.platform in ('darwin', 'linux'), + 'Preset storage is macOS/Linux only') +class SharedSecureReader(unittest.IsolatedAsyncioTestCase): + async def test_missing_core_store_does_not_initialize_or_lock(self): + adapter = peer.Adapter({'local': policy()}) + with patch.object(core, '_preset_directory', return_value=None) as directory, \ + patch.object(core.os, 'mkdir', side_effect=AssertionError('initialize')), \ + patch.object(core, '_preset_lock', side_effect=AssertionError('lock')), \ + patch.object(core, 'preset_mutate', side_effect=AssertionError('repair')): + with self.assertRaises(peer.PolicyError): + await adapter.list_presets('local') + directory.assert_called_once_with() + + async def test_actual_core_lookup_preserves_snapshot_revision_once(self): + adapter = peer.Adapter({'local': policy()}) + adapter.invoke = AsyncMock(return_value={'ok': True}) + with patch.object(core, 'preset_read', return_value={'work': dict(LOCAL)}) as read: + value = await adapter.list_sessions('local', preset='work') + self.assertEqual(value, adapter.invoke.return_value) + read.assert_called_once_with() + + +@unittest.skipUnless(importlib.util.find_spec('mcp'), 'optional MCP SDK') +@unittest.skipUnless(os.name == 'posix' and sys.platform in ('darwin', 'linux'), + 'Preset storage is macOS/Linux only') +class PresetProtocol(unittest.IsolatedAsyncioTestCase): + async def run_case(self, folder, entry, *, snapshot=None, deny_read=False, tiny_budget=False, + native_result=None): + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + import contextlib + root = Path(folder) + path = root / 'policy.json' + path.write_text(json.dumps({'schemaVersion': 2, 'destinations': {'local': entry}})) + wrapper = root / 'fixture.py' + # A test-only validated snapshot replaces ownership I/O. This does NOT + # establish private-store acceptance on an OwnersDisabled volume. Every + # unexpected reader/invoker can terminate only this synthetic subprocess. + wrapper.write_text('import sys, os\n' + + 'sys.path.insert(0, ' + repr(str(Path(peer.__file__).resolve().parent)) + ')\n' + + 'import session_peer_mcp as peer\n' + + ('def snapshot():\n os._exit(95)\n' if deny_read else + 'def snapshot():\n return peer.core._preset_decode(' + repr(json.dumps( + {'schemaVersion': 1, 'presets': snapshot or {}}).encode()) + ')\n') + + 'peer.core.preset_read = snapshot\n' + + 'async def forbidden(*a, **k):\n os._exit(94)\n' + + ('peer.Adapter.invoke = forbidden\n' if native_result is None else + 'async def native_result(self, *a, **k):\n return ' + repr(native_result) + '\n' + + 'peer.Adapter.invoke = native_result\n') + + ('peer.DESTINATION_METADATA_BYTES = 1\n' if tiny_budget else '') + + 'raise SystemExit(peer.main())\n') + params = StdioServerParameters(command=sys.executable, args=[str(wrapper), '--config', str(path)]) + @contextlib.asynccontextmanager + async def session(): + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as client: + await client.initialize() + yield client + return session() + + async def test_real_stdio_query_is_versioned_filtered_and_has_explicit_selection_schema(self): + with tempfile.TemporaryDirectory(prefix='codex-preset-stdio-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as folder: + session = await self.run_case(folder, policy(capabilities=('presets',)), snapshot={ + 'work': dict(LOCAL), 'private': {'transport': 'ssh', 'agent': 'codex', + 'host': PRIVATE_MARKER, 'codexHome': '/' + PRIVATE_MARKER}}) + async with session as client: + tools = {tool.name: tool for tool in (await client.list_tools()).tools} + self.assertIn('Submission does not confirm consumption.', tools['send_message'].description) + self.assertTrue(tools['list_presets'].annotations.readOnlyHint) + self.assertFalse(tools['list_presets'].annotations.openWorldHint) + self.assertEqual(tools['list_presets'].inputSchema['required'], ['destination']) + for name in ('list_sessions', 'send_message', 'preflight_send'): + self.assertIn('preset', tools[name].inputSchema['properties']) + self.assertIn('preset_revision', tools[name].inputSchema['properties']) + result = await client.call_tool('list_presets', {'destination': 'local'}) + self.assertFalse(result.isError) + self.assertEqual(result.structuredContent['schemaVersion'], 1) + self.assertEqual(result.structuredContent['command'], 'mcp.presets') + self.assertEqual(len(result.structuredContent['presets']), 1) + self.assertEqual(result.structuredContent['presets'][0]['revision'], core.preset_revision('work', LOCAL)) + self.assertEqual(json.loads(result.content[0].text), result.structuredContent) + self.assertNotIn(PRIVATE_MARKER, result.content[0].text) + self.assertNotIn(folder, result.content[0].text) + for tool, args in (('list_sessions', {'preset': 'work'}), + ('send_message', {'preset': 'work', 'preset_revision': core.preset_revision('work', LOCAL), + 'target': 'worker', 'message': 'fixture'}), + ('preflight_send', {'preset': 'work', 'target': 'worker', 'message': 'fixture'})): + denied = await client.call_tool(tool, {'destination': 'local', **args}) + self.assertTrue(denied.isError) + if tool == 'preflight_send': + self.assertEqual(denied.structuredContent['schemaVersion'], 1) + else: + self.assertNotIn('schemaVersion', denied.structuredContent) + self.assertEqual(denied.structuredContent['reason'], 'policy_denied') + + async def test_real_stdio_denial_happens_before_store_or_cli(self): + with tempfile.TemporaryDirectory(prefix='codex-preset-deny-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as folder: + entry = {'agents': ['claude'], 'capabilities': ['list', 'send', 'preflight']} + session = await self.run_case(folder, entry, deny_read=True) + async with session as client: + for name, args in (('list_presets', {}), ('list_sessions', {'preset': 'work'}), + ('send_message', {'preset': 'work', 'preset_revision': 'dp1:' + '0' * 64, + 'target': 'worker', 'message': PRIVATE_MARKER}), + ('preflight_send', {'preset': 'work', 'target': 'worker', 'message': PRIVATE_MARKER})): + value = await client.call_tool(name, {'destination': 'local', **args}) + self.assertTrue(value.isError) + if name in ('list_presets', 'preflight_send'): + self.assertEqual(value.structuredContent['schemaVersion'], 1) + else: + self.assertNotIn('schemaVersion', value.structuredContent) + self.assertNotIn(PRIVATE_MARKER, value.content[0].text) + + async def test_real_stdio_route_mismatch_stale_revision_and_result_budget(self): + with tempfile.TemporaryDirectory(prefix='codex-preset-race-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as folder: + session = await self.run_case(folder, policy(), snapshot={'work': dict(REMOTE)}) + async with session as client: + for name, args in (('list_presets', {}), ('list_sessions', {'preset': 'work'})): + value = await client.call_tool(name, {'destination': 'local', **args}) + self.assertTrue(value.isError) + self.assertNotIn(REMOTE['host'], value.content[0].text) + self.assertNotIn(REMOTE['codexHome'], value.content[0].text) + session = await self.run_case(folder, policy(), snapshot={'work': dict(LOCAL)}) + async with session as client: + value = await client.call_tool('send_message', {'destination': 'local', 'preset': 'work', + 'preset_revision': 'dp1:' + '0' * 64, 'target': 'worker', 'message': 'fixture'}) + self.assertTrue(value.isError) + self.assertEqual(value.structuredContent['reason'], 'preset_revision_mismatch') + session = await self.run_case(folder, policy(), snapshot={'work': dict(LOCAL)}, tiny_budget=True) + async with session as client: + value = await client.call_tool('list_presets', {'destination': 'local'}) + self.assertTrue(value.isError) + self.assertEqual(value.structuredContent['command'], 'mcp.presets') + self.assertEqual(value.structuredContent['schemaVersion'], 1) + self.assertNotIn('presets', value.structuredContent) + self.assertLessEqual(len(value.model_dump_json().encode()), 64 * 1024) + + async def test_maximum_approved_metadata_is_bounded_as_complete_tool_result(self): + names = tuple('p' + 'x' * 60 + str(i).zfill(3) for i in range(128)) + with tempfile.TemporaryDirectory(prefix='codex-preset-max-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as folder: + session = await self.run_case(folder, policy(names=names), snapshot={name: dict(LOCAL) for name in names}) + async with session as client: + value = await client.call_tool('list_presets', {'destination': 'local'}) + self.assertFalse(value.isError) + self.assertEqual(len(value.structuredContent['presets']), 128) + self.assertLessEqual(len(value.model_dump_json().encode()), 64 * 1024) + + async def test_large_native_submission_keeps_evidence_instead_of_size_refusal(self): + with tempfile.TemporaryDirectory(prefix='codex-preset-proof-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as folder: + native = {'ok': True, 'status': 'queued', 'submitted': True, + 'queueId': 'fixture-queue', 'target': {'pid': 42}, 'detail': 'x' * 40000} + session = await self.run_case(folder, policy(), snapshot={'work': dict(LOCAL)}, native_result=native) + async with session as client: + value = await client.call_tool('send_message', {'destination': 'local', + 'preset': 'work', 'preset_revision': core.preset_revision('work', LOCAL), + 'target': 'worker', 'message': 'fixture'}) + self.assertFalse(value.isError) + for key in ('submitted', 'status', 'queueId', 'target'): + self.assertEqual(value.structuredContent[key], native[key])