diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-13 19:15:01 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-13 19:15:01 +0900 |
| commit | 36ae1a4cba791ec633a81b38b1d8345af8487276 (patch) | |
| tree | 91cef96efac7eb8c2754658ce5af5181b1a15898 | |
| parent | 76fdcb43585a4c4d288c1026d318a0ddbe3e26ab (diff) | |
feat!: unify sandbox configuration and permission application
Replace the split executable approval and sandbox resolution workflows with schema_version 2 and a YAML-driven execution.entries model. Resolve workspace, mount, executable, runtime, network, and resource settings into one config-scoped Applied Configuration snapshot protected by a user-only DACL and written with atomic replacement.
Reduce the public CLI to implicit server startup, show, and apply. Remove serve/check, executable and sandbox subcommands, and configuration override flags including --config, --lang, --transport, --path, and --runtime-root. Add migration diagnostics for every removed schema and command surface, and keep server preflight mandatory for both stdio and HTTP.
Update effective-policy compilation to validate the applied fingerprint, resolved paths, and executable hashes before startup. Preserve fail-closed behavior while allowing execution entries and runtime roots inside a development workspace to inherit that workspace capability without creating an impossible narrower ACL boundary.
Rewrite the README around the consolidated permission matrix and document configuration, application, store location, startup, transport-specific elevation, Python 3.14, PowerShell, and cmd usage. Align specifications, design, implementation plan, traceability, audit report, localization, publish scripts, and operational verification with the breaking schema.
Verification: Release build completed with zero warnings and errors; Agent.Core.Tests 104/104 and Agent.Planner.Tests 10/10 passed; administrator Windows Security/Recovery 56/56 and README operational 19/19 passed with no skips; published HTTP MCP completed a cmd/curl/venv-Python task with network deny and zero ACL, WFP, journal, backup, or process residue.
26 files changed, 883 insertions, 1317 deletions
@@ -1,366 +1,217 @@ # AI Agent Sandbox MCP Server -Windows上でローカルのOllamaを利用し、AIエージェントのファイル操作とプロセス実行を、利用者が確認した範囲に閉じ込めるMCP Serverです。 +Windows上でOllamaを利用し、AIエージェントのファイル操作とプロセス実行を、利用者がYAMLで指定した範囲へ閉じ込めるMCP Serverです。MCP Clientには`agent_run`と`agent_status`だけを公開します。 -MCP Clientには `agent_run` と `agent_status` だけを公開します。エージェントが内部で行うファイル操作・プロセス実行は、Workspace、Mount、承認済みExecutable、Network、Resourceの設定とWindowsの強制機構によって制限されます。 +## 機能 -## 主な機能 - -- Ollamaモデルによる、計画・コード作成・ファイル編集・コマンド実行の自動化 -- MCPのstdioとStreamable HTTP(stateless)への対応 -- MCP Tasks Extensionによる非同期Taskの取得・キャンセル -- WorkspaceとMountを分けた、読み取り・書き込み・削除・実行の権限設定 -- 実行前に人間が確認するExecutable AliasとSHA-256承認 -- LPAC、NTFS ACL、Job Object、WFPによるSandbox Process Treeの制限 -- 日本語・英語のメッセージ、監査ログ、異常終了後の自動Recovery -- `agent.yaml`と、環境ごとの差分を置ける`agent.local.yaml`のマージ +- 計画、ファイル検索・読取・編集、プロセス実行を伴うAgent Task +- MCP stdio/Streamable HTTP(stateless)とMCP Tasks Extension +- LPAC、NTFS ACL、Job Object、WFPによるSandbox Process Treeの強制 +- Workspace、Mount、Entry Executable、Network、Resourceの一括設定・確認 +- 日本語/英語、監査ログ、異常終了後のRecovery ## 動作環境 | 項目 | 要件 | | --- | --- | | OS | Windows 11 x64 | -| 権限 | 管理者権限のPowerShell。TaskごとのWFP設定に必要です。 | -| Windowsサービス | Base Filtering Engine(BFE)が起動していること | -| LLM | Ollama。Release既定モデルは`deepseek-v4-flash:cloud`です。 | -| MCP Client | stdioまたはStreamable HTTPを利用できるClient | - -Ollamaへの通信はBroker側で行われます。Sandbox内プロセスのNetwork許可とは別に設定されます。 - -## インストール - -Release Bundleを任意のディレクトリへ展開します。以降の例では、展開先を`<install-dir>`と表記します。 - -```text -<install-dir>/ - agent-mcp.exe - agent.yaml - prompts/ - skills/ -``` - -`agent.yaml`は配布時の共通設定です。利用者固有の設定は、同じディレクトリに`agent.local.yaml`を作成して記載してください。publishやupgradeは`agent.local.yaml`、`local/`、`logs/`、`state/`、ユーザーごとの承認・確認情報を上書きしません。 - -## 初回セットアップ +| 実行権限 | 管理者権限(WFP操作に必要) | +| Windows Service | Base Filtering Engine(BFE) | +| LLM | Ollama。配布設定の既定モデルは`deepseek-v4-flash:cloud` | +| Client | stdioまたはStreamable HTTP対応MCP Client | -### 1. Ollamaを準備する +Ollamaへの通信はBrokerが行います。Sandbox内プロセスのNetwork設定とは別です。 -Ollamaをインストール後、モデルを取得して起動します。 - -```powershell -ollama pull deepseek-v4-flash:cloud -ollama serve -``` +## 設定 -既にOllama Serviceまたは`ollama serve`が起動している場合、重複して起動する必要はありません。 +`agent.yaml`がbase設定です。同じディレクトリに任意の`agent.local.yaml`があれば自動的に上書きマージします。mapは再帰merge、scalarと配列はlocal側で置換されます。相対pathはbase設定のディレクトリ基準です。 -### 2. ローカル設定を作成する - -`agent.yaml`と同じディレクトリへ`agent.local.yaml`を作成します。最小例は次のとおりです。 +権限の希望状態はYAMLだけに記載します。CLIでExecutableやACLを個別登録する必要はありません。 ```yaml +schema_version: 2 + application: language: ja -agent: - user_system_prompt: local/prompts/user-system.md - -skills: - directory: local/skills - -sandbox: - profile: development - workspaces: - - C:/work/project - mounts: [] - execution: - executables: [python] - shells: [] -``` - -`agent.local.yaml`は、共通設定から変更したい項目だけを書きます。mapは再帰的にマージされ、scalarと配列はlocal側で置換されます。そのため、`workspaces`や`mounts`をlocal側に書く場合は、最終的に必要な要素をすべて列挙してください。 - -相対パスは`agent.yaml`のあるディレクトリを基準に解決されます。マージ後の設定全体に対して検証が行われるため、未知のキーや矛盾した設定は起動前にエラーになります。 +mcp: + transport: http + http: + host: 127.0.0.1 + port: 8080 -### 3. Sandboxの対象を確認する +ollama: + endpoint: http://127.0.0.1:11434 + model: deepseek-v4-flash:cloud -管理者としてPowerShellを起動し、展開先へ移動します。 - -```powershell -cd <install-dir> -.\agent-mcp.exe sandbox resolve --config .\agent.yaml -.\agent-mcp.exe sandbox confirm --config .\agent.yaml -.\agent-mcp.exe sandbox show --format text --config .\agent.yaml -``` - -`resolve`は設定されたパスを実体パスへ解決して表示するだけです。内容を確認した後、`confirm`で確認済み結果をユーザーごとのStoreへ保存します。Workspace、Mount、設定内容またはリンクの解決先が変わると、再確認するまで起動はfail-closedになります。 - -### 4. 使用するExecutableを承認する - -設定したAliasごとに、実行ファイルの候補を確認し、使用する実体を明示して承認します。 - -```powershell -.\agent-mcp.exe executable resolve python --config .\agent.yaml -.\agent-mcp.exe executable approve python ` - --path C:\Python314\python.exe ` - --runtime-root C:\Python314 ` - --config .\agent.yaml -.\agent-mcp.exe executable list --config .\agent.yaml -``` - -承認時にはResolved PathとSHA-256が保存されます。実行ファイルのパスまたはハッシュが変わった場合は、自動では更新されず、再承認が必要です。 - -最後に起動前診断を実行します。 - -```powershell -.\agent-mcp.exe check --config .\agent.yaml --lang ja -``` - -## Sandbox設定 - -`schema_version: 1`の設定では、Sandboxを利用者が理解できる単位で指定します。未指定の権限は許可されません。 - -```yaml sandbox: profile: development - workspaces: - - C:/work/project - + - C:/tmp mounts: - path: C:/data/reference access: read_only - execution: - executables: [python, git] - shells: [powershell] - + entries: + - alias: python + path: C:/tmp/.venv/Scripts/python.exe + runtime_root: C:/Users/example/AppData/Roaming/uv/python/cpython-3.14-windows-x86_64-none + - alias: powershell + path: C:/Program Files/PowerShell/7/pwsh.exe + - alias: cmd + path: C:/Windows/System32/cmd.exe network: access: local local_endpoints: - port: 11434 restricted_endpoints: [] - resources: profile: normal - # memory_mb: 4096 - # process_limit: 16 - # execution_timeout_seconds: 1800 ``` -### WorkspaceとMount +実環境用path、Prompt、Skillは`agent.local.yaml`から指定できます。publish/upgradeは`agent.local.yaml`、`local/`、`logs/`、`state/`、適用済み設定Storeを上書きしません。 -| 設定 | 用途 | 実行 | -| --- | --- | --- | -| `workspaces` | エージェントが作業するプロジェクト領域 | `development`ではWorkspace内の成果物をEntry Processとして実行できます。 | -| `mounts` | 参照データや追加の入出力領域 | Mount上のExecutableは直接起動できません。 | +### 権限マトリックス -`profile`には次を指定します。 +| YAML項目 | `readonly` | `development` | Entry Processとして起動 | +| --- | --- | --- | --- | +| `workspaces` | read | read/write/delete/execute | developmentのWorkspace Artifactのみ可 | +| `mounts: read_only` | read | read | 不可 | +| `mounts: read_write` | 設定不可 | read/write | 不可 | +| `mounts: read_write_delete` | 設定不可 | read/write/delete | 不可 | +| `execution.entries` | file read/execute | file read/execute | Alias、Resolved Path、SHA-256一致時に可 | +| `runtime_root` | read/execute | read/execute | Runtime Componentは子孫として利用可 | +| Resolved System32 | read/execute | read/execute | 未登録System32 Executableの直接Entry起動は不可 | -| Profile | Workspaceの権限 | Workspace ArtifactのEntry実行 | -| --- | --- | --- | -| `readonly` | read | 不可 | -| `development` | read / write / delete | 可 | +Entry Processの子孫には個別のExecutable登録を要求しません。同じLPAC identity、ACL、WFP、Job Objectを継承し、Process Tree全体が同じ制限内に残ります。 -Mountの`access`には`read_only`、`read_write`、`read_write_delete`を指定します。`readonly` Profileでは`read_only`だけが使用できます。 +### Network -### ExecutableとShell +| `access` | 動作 | +| --- | --- | +| `none` | localhostを含む全通信を拒否 | +| `local` | `local_endpoints`のportについて`127.0.0.1`と`::1`のみ許可 | +| `restricted` | `restricted_endpoints`のhost/portをTask開始時にIPへ固定して許可 | -`execution.executables`と`execution.shells`には、実行を許可したいAliasを記載します。Aliasを設定しただけでは実行できず、前節の`executable approve`で実体パスとハッシュを承認する必要があります。 +### Resource profile -- Executable: 例 `python`、`git` -- Shell: `cmd.exe`、`powershell.exe`、`pwsh.exe`に対応するAlias +| Profile | メモリ | Process数 | timeout | +| --- | ---: | ---: | ---: | +| constrained | 1024 MiB | 4 | 300秒 | +| normal | 4096 MiB | 16 | 1800秒 | +| extended | 8192 MiB | 32 | 3600秒 | -承認は最初にBrokerが直接起動するEntry Processにだけ適用されます。Entry Processから生成される子孫Processには個別承認を要求しませんが、同じLPAC、ACL、WFP、Job Objectの制限を継承します。未知の外部ExecutableやMount上のExecutableは、Brokerから直接起動できません。 +`memory_mb`、`process_limit`、`execution_timeout_seconds`は正数で個別上書きできます。 -#### cmd.exeを有効にする例 +## 初回適用と起動 -`agent.local.yaml`のShell一覧へAliasを追加します。配列はlocal側で置換されるため、ほかのShellも使う場合はすべて列挙してください。 +管理者PowerShellで展開先へ移動します。まずYAMLから計算された権限マトリックスを確認します。 -```yaml -sandbox: - profile: development - workspaces: - - C:/tmp - execution: - executables: - - python - shells: - - cmd +```powershell +.\agent-mcp.exe show .\agent.yaml ``` -管理者として起動したPowerShellで、cmdの実体を確認して承認します。cmdはWindowsのOS Runtime Rootを利用するため、追加Runtime Rootを登録しない`--no-runtime`を指定します。 +内容が正しければ設定全体を一度だけ適用します。Workspace/Mountの解決先、Executableの解決先とSHA-256、設定fingerprintが一つのスナップショットとして保存されます。 ```powershell -cd <install-dir> -.\agent-mcp.exe executable resolve cmd ` - --path C:\Windows\System32\cmd.exe ` - --config .\agent.yaml -.\agent-mcp.exe executable approve cmd ` - --path C:\Windows\System32\cmd.exe ` - --no-runtime ` - --config .\agent.yaml -.\agent-mcp.exe executable list --config .\agent.yaml -.\agent-mcp.exe check --config .\agent.yaml --lang ja +.\agent-mcp.exe apply .\agent.yaml ``` -非対話で承認する場合だけ`approve`へ`--yes`を付けます。この操作はAuditへ記録されます。 - -cmdから子Processを呼ぶ場合は、`cmd.exe`、`curl.exe`、`.venv\Scripts\python.exe`のようにPATHで解決できる名前またはWorkspace相対パスを使います。`%ComSpec%`や`%SystemRoot%\System32\curl.exe`のようなドライブ修飾絶対パスは、cmd自身が事前にvolume情報へアクセスするため、LPACではOSに拒否されることがあります。これは子Process生成の禁止ではありません。 +CIなど対話入力がない場合だけ`--yes`を使用します。 -### Network +```powershell +.\agent-mcp.exe apply .\agent.yaml --yes +``` -| `access` | 許可内容 | -| --- | --- | -| `none` | localhostを含むすべてのネットワーク通信を拒否します。 | -| `local` | `local_endpoints`に列挙したportへの`127.0.0.1`および`::1`だけを許可します。 | -| `restricted` | `restricted_endpoints`に列挙したhost/portをTask開始時にIPへ固定して許可します。 | +起動にはcommand名がありません。transport、languageなどはすべてYAMLから読みます。 -空のendpoint設定やmodeと矛盾する設定は拒否されます。 +```powershell +.\agent-mcp.exe .\agent.yaml +``` -### Resource +設定pathを省略すると、実行ファイルと同じディレクトリの`agent.yaml`を使います。 -`resources.profile`の既定値は以下です。個別の正数指定で上書きできます。 +```powershell +.\agent-mcp.exe +``` -| Profile | メモリ | Process数 | 実行時間 | -| --- | ---: | ---: | ---: | -| `constrained` | 1024 MiB | 4 | 300秒 | -| `normal` | 4096 MiB | 16 | 1800秒 | -| `extended` | 8192 MiB | 32 | 3600秒 | +YAML、解決先、Executable hashのいずれかが変化すると起動はfail-closedになります。`show`で差分を確認してから`apply`し直してください。YAMLからEntryを削除して再適用すると、その権限も同じスナップショットから削除されます。 ## CLIリファレンス -すべてのCLI commandは`agent-mcp.exe`から実行します。`serve`と`check`では`--config`を省略でき、その場合は実行ファイルと同じディレクトリの`agent.yaml`を使用します。`executable`および`sandbox`の管理commandでは`--config`が必須です。 +| 構文 | 引数/option | 変更 | 用途 | +| --- | --- | --- | --- | +| `agent-mcp [<config-path>]` | 任意の設定path | Task中の一時Sandbox Resourceのみ | 設定を検証し、管理者権限・BFE・WFPをpreflightしてServer起動 | +| `agent-mcp show [<config-path>]` | 任意の設定path | なし | 現在の希望状態、Resolved Path、hash、適用一致状態を表示 | +| `agent-mcp apply [<config-path>] [--yes]` | 任意の設定path、任意の`--yes` | Applied Configuration Storeをatomic置換 | マトリックスを一括確認して適用 | -| Command | 必須の位置引数 | 使用できるoption | 用途 | 設定・Storeの変更 | -| --- | --- | --- | --- | --- | -| `[serve]` | なし | `--config <file>`、`--lang <locale>`、`--transport <stdio\|http>` | MCP Serverを起動します。commandを省略した場合、または最初の引数がoptionの場合も`serve`として扱います。 | なし。ただしTask実行中には一時的なSandbox Resourceを適用します。 | -| `check` | なし | `--config <file>`、`--lang <locale>` | 設定、Resolution、Approval、管理者権限、BFE、WFP操作を診断します。 | なし | -| `executable resolve` | `<alias>` | `--path <path>`、`--config <file>`、`--lang <locale>` | Aliasに対応するPATH候補、または指定pathを解決して表示します。 | なし | -| `executable approve` | `<alias>` | `--path <path>`、`--runtime-root <path>`または`--no-runtime`、`--yes`、`--config <file>`、`--lang <locale>` | ExecutableまたはShellの実体パスとSHA-256を承認します。 | Approval Storeを更新 | -| `executable list` | なし | `--config <file>`、`--lang <locale>` | 承認済みExecutableを一覧表示します。 | なし | -| `executable revoke` | `<alias>` | `--yes`、`--config <file>`、`--lang <locale>` | AliasのExecutable Approvalを取り消します。 | Approval Storeを更新 | -| `sandbox resolve` | なし | `--config <file>`、`--lang <locale>` | WorkspaceとMountのLogical Pathを実体パスへ解決し、確認候補を表示します。 | なし | -| `sandbox confirm` | なし | `--yes`、`--config <file>`、`--lang <locale>` | 現在のSandbox Environment Resolutionを確認済みとして保存します。 | Resolution Storeを更新 | -| `sandbox show` | なし | `--format <text\|json>`、`--config <file>`、`--lang <locale>` | 現在のResolution、確認状態、Effective Sandbox Policyを表示します。 | なし | +`serve`、`check`、`executable ...`、`sandbox ...`はschema v2で廃止されました。`--config`、`--lang`、`--transport`、`--path`、`--runtime-root`、`--no-runtime`、`--format`も廃止されています。値はYAMLへ書き、設定pathだけを位置引数で渡します。 -### 引数とoptionの意味 +## Applied Configuration Store -| 引数・option | 値 | 説明 | -| --- | --- | --- | -| `<alias>` | 設定したAlias名 | `sandbox.execution.executables`または`sandbox.execution.shells`に宣言した名前です。Alias自体は承認を意味しません。 | -| `--config <file>` | YAMLファイルのpath | base設定ファイルを指定します。同じディレクトリの`agent.local.yaml`は自動的にマージされます。 | -| `--lang <locale>` | 例: `ja`、`en` | CLIおよび起動時の利用者向けメッセージの言語を、その実行に限って上書きします。 | -| `--transport <stdio\|http>` | `stdio`または`http` | `serve`時だけ使用できます。設定ファイルの`mcp.transport`をその起動に限って上書きします。 | -| `--path <path>` | Executable fileのpath | `executable resolve`では候補検索対象、`executable approve`では承認対象を明示します。候補が曖昧な場合は必須です。 | -| `--runtime-root <path>` | Runtime directoryのpath | 承認するEntry ExecutableのRuntime Rootを明示します。存在するディレクトリでなければなりません。 | -| `--no-runtime` | 値なし | Runtime Rootを登録しないことを明示します。`executable approve`では`--runtime-root`とのどちらか一方が必要です。 | -| `--yes` | 値なし | `approve`、`revoke`、`confirm`の対話確認を省略します。非対話操作としてAudit Logに記録されます。 | -| `--format <text\|json>` | `text`または`json` | `sandbox show`の出力形式です。既定値は`text`です。 | +適用済みスナップショットはYAMLではなく、起動ユーザー専用DACLを持つ次の場所に保存されます。 -`resolve`、`list`、`show`、`check`はread-onlyです。`approve`、`revoke`、`confirm`は差分表示後に確認を求めます。標準入力が対話的でない場合は、内容を確認したうえで明示的に`--yes`を指定してください。 - -## 起動と接続 +```text +%LOCALAPPDATA%\AgentSandboxMcp\applied-configurations\<config-path-hash>.json +``` -`serve`は起動前に管理者権限、BFE、WFP、設定、Resolution、Executable Approvalを検査します。いずれかが失敗すると待受を開始せず、エラーと対処方法を表示します。 +YAMLは希望状態、Storeは利用者が確認した実体path・hashを含む適用状態です。`apply`は設定全体をatomic replaceするため、個別承認と個別confirmの不整合は発生しません。`show`はread-onlyで、ACLやWFPを変更しません。 -### stdio +## MCP Client設定 -stdioを使う場合は、MCP Client自体を管理者権限で起動してください。 +stdioではMCP ClientがServerを起動するため、MCP Clientを管理者権限で起動します。 ```json { "mcpServers": { "agent-sandbox": { "command": "C:\\tools\\agent-sandbox-mcp\\agent-mcp.exe", - "args": [ - "serve", - "--config", "C:\\tools\\agent-sandbox-mcp\\agent.yaml", - "--lang", "ja", - "--transport", "stdio" - ] + "args": ["C:\\tools\\agent-sandbox-mcp\\agent.yaml"] } } } ``` -### Streamable HTTP - -管理者PowerShellでServerを起動します。 - -```powershell -.\agent-mcp.exe serve --config .\agent.yaml --lang ja --transport http -``` - -既定endpointは`http://127.0.0.1:8080/mcp`です。bind先には`127.0.0.1`または`::1`だけを設定できます。HTTPはstatelessであり、`Mcp-Session-Id`や旧SSE endpoint(`/sse`、`/message`)は使用しません。 - -`serve`は既定commandなので、次も同じです。 - -```powershell -.\agent-mcp.exe --config .\agent.yaml -``` - -## MCPでの利用 +HTTPではagent-mcp.exeを管理者権限で起動しますが、接続するMCP Clientの管理者権限は不要です。既定endpointは`http://127.0.0.1:8080/mcp`です。 -公開されるMCP Toolは次の2つです。 +## MCP Tool | Tool | 用途 | | --- | --- | -| `agent_run` | 自然言語の依頼を新規または既存のAgent Sessionへ登録します。非同期でTask IDとSession IDを返します。 | -| `agent_status` | Taskの状態、進捗、token実績、最終結果または利用者向けエラーを取得します。 | - -例えば、`agent_run`には「Workspace内にPythonプログラムとテストを作成し、実行して検証して」のような依頼を渡します。複数Taskは受付順にqueuedとなり、planning/runningとSandbox Resourceの適用は一度に1件だけです。 - -Tasks Extension対応Clientでは、`agent_run`の結果をMCP Taskとして受け取り、`tasks/get`と`tasks/cancel`で追跡できます。非対応Clientでは、返された`task_id`を指定して`agent_status`を呼び出します。 +| `agent_run` | 自然言語の依頼をAgent Taskとして登録 | +| `agent_status` | 状態、進捗、token実績、結果またはエラーを取得 | ## PromptとSkill -System Promptは`agent.user_system_prompt`、Skill directoryは`skills.directory`で指定します。Skillは次の形式で配置します。 - -```text -skills/ - <skill-name>/ - SKILL.md -``` - -SkillはLLMに与える補助指示であり、Sandboxの権限を増やしません。Skillの記述に実行コマンドが含まれていても、Executable Alias、Approval、Filesystem権限、Network権限は通常どおり必要です。 +System Promptは`agent.user_system_prompt`、Skill directoryは`skills.directory`で指定します。Skillは`skills/<skill-name>/SKILL.md`形式です。PromptやSkillはSandbox権限を増やしません。 -## ログ、監査、復旧 +## ログとRecovery | 場所 | 内容 | | --- | --- | -| `logs/application.log` | 起動、診断、アプリケーションイベント | -| `logs/llm.jsonl` | Ollama call、token使用量、Internal Tool結果 | -| `logs/audit.jsonl` | ファイル・プロセス・Policy・ACL・WFP・Recoveryの監査イベント | -| `logs/http.jsonl` | HTTPリクエストの接続情報と処理結果 | -| `state/journals/` | 異常終了からの復旧用Journal | -| `state/backups/` | 大容量ファイル編集のtransaction backup | - -ログは起動ユーザーだけがアクセスできるDACLで保護されます。HTTPログにはAuthorization、Cookie、request body、prompt、画像、Base64を記録しません。 - -LLM/Audit JSONLを絞り込むには、付属のread-only scriptを使えます。 +| `logs/application.log` | 起動とアプリケーションイベント | +| `logs/llm.jsonl` | Ollama call、token、Internal Tool結果 | +| `logs/audit.jsonl` | ファイル、Process、Policy、ACL、WFP、Recovery、非対話apply | +| `logs/http.jsonl` | HTTP接続と処理結果 | +| `state/journals/` | crash recovery用Journal | +| `state/backups/` | 大容量編集のtransaction backup | ```powershell .\scripts\Read-AgentLog.ps1 -Path .\logs\llm.jsonl -TaskId <task-id> -Tail 100 .\scripts\Read-AgentLog.ps1 -Path .\logs\audit.jsonl -Event policy.deny -Tail 100 ``` -起動時には未完了のJournalを復旧してからMCP受付を開始します。再起動時に未完了だったTaskは自動再実行されず、`failed/server_restarted`になります。 - ## よくある問題 | 状況 | 対処 | | --- | --- | -| 管理者権限またはWFP probeの失敗 | 管理者PowerShellから起動し、`Get-Service BFE`でBFEを確認します。 | -| Ollama接続失敗 | `ollama list`、Ollama endpoint、model名を確認します。 | -| Resolution未確認または変更 | `sandbox resolve`で差分を確認し、`sandbox confirm`を実行します。 | -| Alias不明、Approval不足、hash変更 | `executable resolve`で候補を確認してから`executable approve`を実行します。 | -| Filesystem拒否 | 対象を適切なWorkspaceまたはMountとして設定し、Resolutionを再確認します。 | -| rollback拒否 | 外部Processが同じファイルを変更している可能性があります。Audit LogとJournalを確認し、手動で内容を確認します。 | +| `sandbox.configuration_not_applied` | `show`で全体を確認して`apply` | +| Executable更新後に起動できない | hash変更は自動承認しないため、`show`後に`apply` | +| 管理者権限/WFP preflight失敗 | 管理者PowerShellで起動し、`Get-Service BFE`を確認 | +| Filesystem拒否 | WorkspaceまたはMountをYAMLへ追加し、再度`apply` | +| Network拒否 | `network.access`とendpointを見直し、再度`apply` | -## 開発者向け - -ソースからbuild・test・releaseする場合は以下を実行します。 +## 開発 ```powershell dotnet restore AgentSandboxMcp.slnx @@ -369,10 +220,8 @@ dotnet test AgentSandboxMcp.slnx -c Release --no-build --no-restore .\scripts\Publish-Release.ps1 ``` -build成果物は`build/`、ローカル試験の証跡は`artifacts/`に作成されます。どちらもGit管理外です。 - -詳細な仕様と設計は[仕様書](doc/Specifications.md)、[設計書](doc/Design.md)を参照してください。 +詳細は[仕様書](doc/Specifications.md)と[設計書](doc/Design.md)を参照してください。 ## ライセンス -MIT +MIT
\ No newline at end of file diff --git a/config/agent.local.example.yaml b/config/agent.local.example.yaml index 4961428..7e1c4d6 100644 --- a/config/agent.local.example.yaml +++ b/config/agent.local.example.yaml @@ -12,5 +12,7 @@ sandbox: - C:/work/project mounts: [] execution: - executables: [python] - shells: [] + entries: + - alias: python + path: C:/path/to/python.exe + runtime_root: C:/path/to/python-runtime diff --git a/config/agent.yaml b/config/agent.yaml index b38e693..357a616 100644 --- a/config/agent.yaml +++ b/config/agent.yaml @@ -1,4 +1,4 @@ -schema_version: 1 +schema_version: 2 application: language: en mcp: @@ -33,8 +33,7 @@ sandbox: workspaces: [] mounts: [] execution: - executables: [] - shells: [] + entries: [] network: access: none local_endpoints: [] diff --git a/config/locales/en.json b/config/locales/en.json index 3e9a5fa..c8ec474 100644 --- a/config/locales/en.json +++ b/config/locales/en.json @@ -1,15 +1,36 @@ { "app.starting": "Starting agent.", - "app.preflight_failed": "Sandbox preflight failed before bind: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}. Start an elevated terminal, ensure the Base Filtering Engine service is running, and run 'agent-mcp check'.", + "app.preflight_failed": "Sandbox preflight failed before bind: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}. Start an elevated terminal and ensure the Base Filtering Engine service is running.", "app.ready.http": "Agent is ready for MCP requests. endpoint={endpoint}; protocol={protocol}; stateless={stateless}; legacy_initialize_fallback={fallback}; pid={pid}; logs={logs}", "app.ready.stdio": "Agent stdio transport is ready. protocol={protocol}; pid={pid}; logs={logs}", - "check.success": "Configuration and runtime checks passed.", - "check.failure": "Check failed: {message}", - "check.wfp_admin_required": "Windows administrator elevation is required for WFP task isolation. Start agent-mcp.exe from an elevated terminal.", "config.invalid": "Invalid configuration: {message}", "locale.invalid": "Invalid locale: {message}", "cli.invalid": "Invalid command line: {message}", "app.io_error": "Startup I/O failed: {message}", + "show.configuration": "Configuration: {path}", + "show.applied": "Applied: {value}", + "show.fingerprint": "Fingerprint: {value}", + "show.yes": "yes", + "show.no": "no", + "show.not_applied": "not-applied", + "show.matched": "matched", + "show.changed": "changed", + "show.platform": "Platform: not probed; startup performs the full privileged preflight", + "show.filesystem": "FILESYSTEM", + "show.filesystem_columns": "TYPE\tLOGICAL\tRESOLVED\tACCESS\tSTATUS", + "show.execution": "EXECUTION", + "show.execution_columns": "ALIAS\tTYPE\tRESOLVED\tRUNTIME\tSHA-256\tSTATUS", + "show.network": "NETWORK", + "show.resources": "RESOURCES", + "show.status.active": "active", + "show.status.added": "added", + "show.status.changed": "changed", + "show.status.removed": "removed-on-apply", + "apply.no_changes": "No applied configuration changes.", + "apply.replace_snapshot": "The complete configuration snapshot will be replaced.", + "apply.prompt": "Apply this configuration?", + "apply.success": "Configuration applied atomically.", + "apply.store_unreadable": "Existing Applied Configuration Store is unreadable and will be replaced only after confirmation: {message}", "mcp.task_queued": "Task {task} was queued in session {session}.", "mcp.task_not_found": "Task {task} was not found.", "mcp.status.queued": "The task is queued.", @@ -27,30 +48,10 @@ "runtime.policy_replan_exhausted": "The sandbox policy denial remained unresolved after {message} replans.", "runtime.policy_repeated": "The same sandbox policy denial occurred again, so replanning was stopped.", "runtime.replan_unchanged": "Replanning did not change the incomplete task plan.", - "runtime.policy_resolution.filesystem": "Review the Workspace or Mount and its required Filesystem Capability, then confirm the Sandbox Environment Resolution.", - "runtime.policy_resolution.process": "Use a configured Alias or an allowed Runtime Component or development Workspace Artifact; re-approve a changed Executable identity.", + "runtime.policy_resolution.filesystem": "Review the Workspace or Mount and its required Filesystem Capability in YAML, then run agent-mcp apply.", + "runtime.policy_resolution.process": "Use an Alias declared in execution.entries, an allowed Runtime Component, or a development Workspace Artifact; review and apply changed identity in YAML.", "runtime.policy_resolution.network": "Review the selected Network access mode and endpoint list; the Agent cannot widen it during a Task.", - "runtime.policy_resolution.default": "Review the User Sandbox Configuration and confirmed Effective Sandbox Policy.", - "management.approval_diff": "Alias: {alias}\nClassification: {classification}\nResolved Path: {path}\nSHA-256: {sha256}\nRuntime Root: {runtime_root}", - "management.approve_prompt": "Approve this Executable Alias?", - "management.revoke_diff": "Revoke {alias}: {path}", - "management.revoke_prompt": "Revoke this Executable Alias?", - "management.sandbox_confirm_prompt": "Confirm this Sandbox Environment Resolution?", - "management.resolution_header": "Configuration: {config}\nFingerprint: {fingerprint}", - "management.confirmed": "Confirmed: {value}", - "management.yes": "yes", - "management.no": "no", - "management.ambiguous_candidates": "Multiple candidates were found. No candidate was selected; re-run with --path <path>.", - "sandbox.error.resolution_unconfirmed": "The Sandbox Environment Resolution is not confirmed.", - "sandbox.error.resolution_unconfirmed.resolution": "Review sandbox resolve, then run sandbox confirm.", - "sandbox.error.resolution_changed": "The Sandbox Environment Resolution changed after confirmation.", - "sandbox.error.resolution_changed.resolution": "Review the changed Logical and Resolved Paths, then confirm again.", - "sandbox.error.approval_missing": "The Executable Alias is not approved: {target}", - "sandbox.error.approval_missing.resolution": "Resolve the Alias, review one candidate, and explicitly approve it.", - "sandbox.error.executable_resolution_changed": "The Executable Resolved Path changed: {target}", - "sandbox.error.executable_resolution_changed.resolution": "Verify the new identity and explicitly re-approve it.", - "sandbox.error.executable_hash_changed": "The Executable SHA-256 changed: {target}", - "sandbox.error.executable_hash_changed.resolution": "Verify the updated file and explicitly re-approve it.", - "sandbox.error.shell_unsupported": "The selected file is not a supported Shell: {target}", - "sandbox.error.shell_unsupported.resolution": "Select cmd.exe, powershell.exe, or pwsh.exe." + "runtime.policy_resolution.default": "Review the YAML permission matrix with agent-mcp show, then run agent-mcp apply.", + "sandbox.error.configuration_not_applied": "The configuration has not been applied or its resolved identity changed: {target}", + "sandbox.error.configuration_not_applied.resolution": "Review the complete permission matrix with agent-mcp show, then run agent-mcp apply." } diff --git a/config/locales/ja.json b/config/locales/ja.json index 7d51334..708bf47 100644 --- a/config/locales/ja.json +++ b/config/locales/ja.json @@ -1,15 +1,36 @@ { "app.starting": "エージェントを起動しています。", - "app.preflight_failed": "待受開始前のSandbox診断に失敗しました: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}。管理者権限のターミナルで起動し、Base Filtering Engineサービスを確認してから 'agent-mcp check' を実行してください。", + "app.preflight_failed": "待受開始前のSandbox診断に失敗しました: operation={operation}; error_code={error_code}; administrator={administrator}; BFE={bfe}; WFP transaction={transaction}; WFP add/delete={filters}。管理者権限のターミナルで起動し、Base Filtering Engineサービスを確認してください。", "app.ready.http": "MCPリクエストを受け付けられます。endpoint={endpoint}; protocol={protocol}; stateless={stateless}; legacy_initialize_fallback={fallback}; pid={pid}; logs={logs}", "app.ready.stdio": "stdio transportでMCPリクエストを受け付けられます。protocol={protocol}; pid={pid}; logs={logs}", - "check.success": "設定と実行環境の確認に成功しました。", - "check.failure": "確認に失敗しました: {message}", - "check.wfp_admin_required": "WFPによるTask分離にはWindows管理者権限が必要です。管理者として起動したターミナルからagent-mcp.exeを起動してください。", "config.invalid": "設定が不正です: {message}", "locale.invalid": "ロケールが不正です: {message}", "cli.invalid": "コマンドラインが不正です: {message}", "app.io_error": "起動時のI/O処理に失敗しました: {message}", + "show.configuration": "設定: {path}", + "show.applied": "適用済み: {value}", + "show.fingerprint": "fingerprint: {value}", + "show.yes": "はい", + "show.no": "いいえ", + "show.not_applied": "未適用", + "show.matched": "一致", + "show.changed": "変更あり", + "show.platform": "Windows preflight: 未実行(Server起動時に管理者権限で完全検査)", + "show.filesystem": "FILESYSTEM権限", + "show.filesystem_columns": "種別\t論理PATH\t解決済みPATH\tアクセス\t状態", + "show.execution": "EXECUTION権限", + "show.execution_columns": "ALIAS\t種別\t解決済みPATH\tRUNTIME\tSHA-256\t状態", + "show.network": "NETWORK権限", + "show.resources": "RESOURCE制限", + "show.status.active": "適用中", + "show.status.added": "追加予定", + "show.status.changed": "変更予定", + "show.status.removed": "apply時に削除", + "apply.no_changes": "適用済み設定に変更はありません。", + "apply.replace_snapshot": "設定スナップショット全体を置換します。", + "apply.prompt": "この設定を適用しますか?", + "apply.success": "設定全体をatomicに適用しました。", + "apply.store_unreadable": "既存のApplied Configuration Storeを読み取れません。確認後に限り置換します: {message}", "mcp.task_queued": "タスク {task} をセッション {session} に登録しました。", "mcp.task_not_found": "タスク {task} が見つかりません。", "mcp.status.queued": "タスクは待機中です。", @@ -27,30 +48,10 @@ "runtime.policy_replan_exhausted": "Sandbox Policyによる拒否を{message}回の再計画で解消できませんでした。", "runtime.policy_repeated": "同一のSandbox Policy拒否が再発したため、再計画を停止しました。", "runtime.replan_unchanged": "再計画後も未完了のタスク計画が変更されませんでした。", - "runtime.policy_resolution.filesystem": "WorkspaceまたはMountと必要なFilesystem Capabilityを確認し、Sandbox Environment Resolutionを再確認してください。", - "runtime.policy_resolution.process": "設定済みAlias、許可されたRuntime Component、またはdevelopment Workspace Artifactを使用し、identityが変わったExecutableは再承認してください。", + "runtime.policy_resolution.filesystem": "YAMLのWorkspaceまたはMountと必要なFilesystem Capabilityを確認し、agent-mcp applyを実行してください。", + "runtime.policy_resolution.process": "execution.entriesのAlias、許可されたRuntime Component、またはdevelopment Workspace Artifactを使用し、変更されたidentityはYAMLで確認してapplyしてください。", "runtime.policy_resolution.network": "Network access modeとendpoint一覧を確認してください。AgentはTask中に許可範囲を拡張できません。", - "runtime.policy_resolution.default": "User Sandbox Configurationと確認済みEffective Sandbox Policyを確認してください。", - "management.approval_diff": "Alias: {alias}\n分類: {classification}\n解決済みpath: {path}\nSHA-256: {sha256}\nRuntime Root: {runtime_root}", - "management.approve_prompt": "このExecutable Aliasを承認しますか?", - "management.revoke_diff": "承認取消 {alias}: {path}", - "management.revoke_prompt": "このExecutable Aliasの承認を取り消しますか?", - "management.sandbox_confirm_prompt": "このSandbox Environment Resolutionを確認済みにしますか?", - "management.resolution_header": "設定: {config}\nfingerprint: {fingerprint}", - "management.confirmed": "確認済み: {value}", - "management.yes": "はい", - "management.no": "いいえ", - "management.ambiguous_candidates": "複数の候補が見つかりました。自動選択していません。--path <path>を指定して再実行してください。", - "sandbox.error.resolution_unconfirmed": "Sandbox Environment Resolutionが未確認です。", - "sandbox.error.resolution_unconfirmed.resolution": "sandbox resolveの差分を確認してからsandbox confirmを実行してください。", - "sandbox.error.resolution_changed": "確認後にSandbox Environment Resolutionが変更されました。", - "sandbox.error.resolution_changed.resolution": "Logical PathとResolved Pathの変更を確認してから、再度confirmしてください。", - "sandbox.error.approval_missing": "Executable Aliasが未承認です: {target}", - "sandbox.error.approval_missing.resolution": "Alias候補をresolveし、1つの候補を確認して明示的にapproveしてください。", - "sandbox.error.executable_resolution_changed": "ExecutableのResolved Pathが変更されました: {target}", - "sandbox.error.executable_resolution_changed.resolution": "新しいidentityを確認し、明示的に再承認してください。", - "sandbox.error.executable_hash_changed": "ExecutableのSHA-256が変更されました: {target}", - "sandbox.error.executable_hash_changed.resolution": "更新されたfileを確認し、明示的に再承認してください。", - "sandbox.error.shell_unsupported": "選択されたfileは対応Shellではありません: {target}", - "sandbox.error.shell_unsupported.resolution": "cmd.exe、powershell.exe、pwsh.exeのいずれかを選択してください。" + "runtime.policy_resolution.default": "agent-mcp showでYAMLの権限マトリックスを確認し、agent-mcp applyを実行してください。", + "sandbox.error.configuration_not_applied": "設定が未適用、または解決された実体が変更されています: {target}", + "sandbox.error.configuration_not_applied.resolution": "agent-mcp showで権限マトリックス全体を確認し、agent-mcp applyを実行してください。" } diff --git a/doc/AuditReport.md b/doc/AuditReport.md index b176260..3cbed19 100644 --- a/doc/AuditReport.md +++ b/doc/AuditReport.md @@ -1,5 +1,7 @@ # v1再監査報告とSandbox Usability Revision状況 +> 2026-08-13 schema version 2追補: YAMLを唯一のSandbox desired stateとし、個別のExecutable Approval/Sandbox Resolution操作を廃止した。CLIはcommand省略のServer起動、`apply`、`show`に限定し、確認結果はconfig別Applied Configuration Storeへ一括保存する。本追補より前の管理者・publish証跡は旧schemaの履歴であり、schema version 2のPASS根拠ではない。最終検証はRelease build warning/error 0、Core 104件、Planner 10件、Windows Security/Recovery 56件、README operational 19件がfail/skip 0。publish HTTP MCPも`deepseek-v4-flash:cloud`でcompleted 3/3となり、cmd→cmd/curl/venv Python、Network none、終了後残留0を確認した。 + ## 基準 commit `37386b8`に対する2026-08-11の再監査結果は履歴として以下に保持する。2026-08-12に開始したSandbox Usability RevisionはUser Sandbox Configuration、Resolution、Executable Approval、Effective Sandbox Policy、LPAC Process Treeへ安全境界を改定する破壊的変更であり、旧commitのPASSを現行実装へ流用しない。 diff --git a/doc/Design.md b/doc/Design.md index d4920a0..80b5acc 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -444,7 +444,7 @@ raw chain-of-thoughtは保存・要約対象としない。 ## 10. User Sandbox Configuration・Resolution・Effective Policy設計 ### 10.1 schema概要 ```yaml -schema_version: 1 +schema_version: 2 application: language: en mcp: @@ -476,8 +476,7 @@ sandbox: workspaces: [] mounts: [] execution: - executables: [] - shells: [] + entries: [] network: access: none local_endpoints: [] @@ -507,18 +506,23 @@ base YAMLと同じdirectoryに`agent.local.yaml`が存在する場合、YAML nod - `workspaces`はLogical Path文字列のsequenceとする。`readonly`はreadのみ、`development`はread/write/delete/Artifact executeへcompileする。 - `mounts`は`path`と`access: read_only|read_write|read_write_delete`を持つ。readonly Profileではread_onlyだけを許可し、Mountにexecuteを付与しない。 - relative Logical Pathはbase YAML directory基準。実在するlocal directoryだけを許可する。 -- 同一Resolved Rootはcapabilityを統合する。親の継承capabilityより狭いnested rootは拒否する。 -### 10.3 Executable Alias・Approval Store +- 同一Resolved Rootはcapabilityを統合する。利用者が宣言したWorkspace/Mountについて、親の継承capabilityより狭いnested rootは拒否する。Execution EntryまたはRuntime Rootがdevelopment Workspace内にある場合、その派生rootは親Workspaceのcapabilityを継承するため別の制限境界とは扱わず、Alias/Resolved Path/SHA-256によるEntry identity検査は維持する。 +### 10.3 Execution Entry・Applied Configuration Store ```yaml execution: - executables: [python, git] - shells: [powershell] + entries: + - alias: python + path: C:/runtime/python.exe + runtime_root: C:/runtime + - alias: cmd + path: C:/Windows/System32/cmd.exe ``` -- AliasはApprovalではない。CLIが候補を提示し、人間が選択したResolved Executable PathとSHA-256をWindows user単位Storeへ固定する。 -- Store pathは`%LOCALAPPDATA%/AgentSandboxMcp/executable-approvals.json`。user-only DACL、WriteThrough、atomic replace、schema versionを持つ。 -- recordはAlias、Approved Entry Executable/Approved Shell Executable分類、Logical Path、Resolved Path、SHA-256、optional Runtime Root、承認日時を持つ。 -- Runtime RootはCLI提案後の明示確認対象とし、その配下のRuntime Componentは個別hashなしでEntry起動できる。 -- Aliasはcase-insensitiveに一意とし、Executable/Shell双方への重複指定を拒否する。ShellのResolved file nameは`cmd.exe|powershell.exe|pwsh.exe`だけを許可する。 +- EntryはAlias、Logical Executable Path、optional Logical Runtime RootをYAML内で一体として宣言する。Runtime Root省略はRuntime Rootなしを意味する。 +- apply時にExecutableをResolved Pathへ固定しSHA-256を計算する。Resolved file nameが`cmd.exe|powershell.exe|pwsh.exe`ならApproved Shell Executable、それ以外はApproved Entry Executableへ分類する。 +- Aliasはcase-insensitiveに一意とする。Executableは実在file、Runtime Rootは実在directoryでなければならない。 +- Store pathは`%LOCALAPPDATA%/AgentSandboxMcp/applied-configurations/<config-path-hash>.json`。user-only DACL、WriteThrough、atomic replace、schema versionを持つ。 +- Storeはconfig path、YAML fingerprint、Workspace/Mount Resolution、全Execution EntryのAlias、分類、Logical/Resolved Path、SHA-256、Runtime Root、Network/Resourceを含む一つのsnapshotとする。個別Approval StoreとResolution Storeは持たない。 +- applyは全項目を先に解決・検証して差分を一度表示し、承認後にsnapshot全体をatomic replaceする。YAMLから削除されたEntryは同じtransactionで適用状態から除去する。 ### 10.4 Network entry ```yaml network: @@ -531,14 +535,16 @@ network: `none`は全deny、`local`は列挙portを`127.0.0.1`/`::1`へ展開、`restricted`はhostをTask開始時にIP snapshot化する。mode不一致または空endpointを拒否する。 ### 10.5 Resource Profile `constrained=1024 MiB/4/300秒`、`normal=4096 MiB/16/1800秒`、`extended=8192 MiB/32/3600秒`とする。`memory_mb`、`process_limit`、`execution_timeout_seconds`の正数overrideはpreset値を置換する。 -### 10.6 Resolution Result -- Workspace/MountのLogical→Resolved mapping、config絶対path、semantic sandbox fingerprint、確認日時を`%LOCALAPPDATA%/AgentSandboxMcp/environment-resolutions/<config-path-hash>.json`へ保存する。 -- reparse chainを最終targetまで解決しcycle、missing target、非local filesystemを拒否する。確認後のfingerprintまたはResolved Path変化はserve/checkをfail-closedにする。 -- `sandbox resolve`はpreview、`sandbox confirm`だけがStoreを更新し、`sandbox show`はEffective Sandbox Policyをtext/JSON表示する。 +### 10.6 Apply・起動・表示 +- reparse chainを最終targetまで解決しcycle、missing target、非local filesystemを拒否する。 +- `agent-mcp apply [config] [--yes]`だけがApplied Configuration Storeを更新する。全解決・validation完了前、または確認前にStoreを変更しない。 +- `agent-mcp [config]`は現在のYAMLを再解決・再hashし、Applied Configuration snapshotとの完全一致とWindows preflightを検証してからMCP受付を開始する。個別の`serve`と`check`commandは持たない。 +- `agent-mcp show [config]`はdesired/resolved/appliedの権限matrixをtext表示し、StoreやACL/WFPを変更しない。管理者権限、BFE、WFP add/deleteを伴う完全なWindows preflightはServer起動時だけ実行する。 +- languageとtransportはYAMLだけから取得し、CLI overrideを持たない。 ### 10.7 Effective Sandbox Policy -確認済みResolution Resultと現在のApproval Storeをcompileし、次を由来付きで保持する。 +現在のYAMLと一致するApplied Configuration snapshotをcompileし、次を由来付きで保持する。 - Filesystem Root: `workspace`、`mount`、`approved_entry_executable`、`approved_shell_executable`、`runtime_root`、`os_runtime_root`の由来、Resolved Path、Filesystem Capability。 - Entry Executable: Alias、分類、Resolved Path、SHA-256、optional Runtime Root。 @@ -547,7 +553,7 @@ network: - Resource: Resource Profile名、override適用後のmemory/process/timeout。 - baseline capability: 固定された`registryRead`と`lpacInstrumentation`。 -同じResolved RootのFilesystem Capabilityは和集合にし、由来も併記する。親rootから継承するCapabilityより狭いnested rootは表現不能としてcompileを拒否する。`sandbox show`はこのcompile結果をtextまたはJSONで表示するが、Storeや設定を変更しない。 +同じResolved RootのFilesystem Capabilityは和集合にし、由来も併記する。親rootから継承するCapabilityより狭いnested rootは表現不能としてcompileを拒否する。`show`は現在の希望状態、解決結果、hash、適用状態をtextで表示するが、Storeや設定を変更しない。 --- ## 11. Windows強制設計 ### 11.1 Less Privileged AppContainer @@ -589,7 +595,7 @@ network: - process count / memory limitをJobへ設定する。 - `KILL_ON_JOB_CLOSE`相当を利用し、application終了時にprocess treeを残さない。 ### 11.4 Entry Process分類・Executable Approval -- AliasはApproval StoreのApproved Entry Executable/Shellへ解決し、起動直前とsuspended生成後・resume前にResolved PathとSHA-256を再検証する。 +- Aliasは現在のApplied Configuration snapshotのApproved Entry Executable/Shellへ解決し、起動直前とsuspended生成後・resume前にResolved PathとSHA-256を再検証する。 - Runtime Componentは確認済みRuntime Root containment、Workspace Artifactはdevelopment Artifact Root containmentで分類する。MountおよびExternal Unknown Executableからの直接Entry起動は拒否する。 - Descendant Processは個別分類・Approvalを行わず、LPAC/ACL/WFP/Job confinementをSecurity Boundaryとする。`PROCESS_CREATION_CHILD_PROCESS_RESTRICTED`は使用しない。 ### 11.5 WFP方針 @@ -736,7 +742,7 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio - locale baseline/placeholder - Ollama endpoint URI - Sandbox path canonicalization -- Executable Approval StoreのAlias/Logical Path/Resolved Path/hash format +- Applied Configuration Storeのconfig path/fingerprint、Filesystem Resolution、Execution EntryのAlias/Logical Path/Resolved Path/hash format - network host/port syntax - resource limit range - Fixed/User prompt file state @@ -748,7 +754,7 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio | TokenPERT | O<=M<=P<=budget、式のunit test | | TokenCPM | 既知DAGに対するcritical path unit test | | Filesystem | allow/deny/reparse point integration test | -| Process | Alias不明、Approval不足、hash/Resolved Path変更、Entry分類拒否、LPAC不一致 | +| Process | Alias不明、未適用、hash/Resolved Path変更、Entry分類拒否、LPAC不一致 | | Network | noneのLoopback、local/restrictedのPolicy外address/port | | Recovery | 各transaction stateで強制crashして再起動cleanup | | MCP | stdio purity、Streamable HTTP loopback、GET 405 | @@ -779,8 +785,8 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio | ST-20 | External Unknown/System32 Executableの直接Entry | Approvalなしでは拒否 | | ST-21 | malformed locale | 起動失敗 | | ST-22 | non-loopback HTTP bind | 起動失敗 | -| ST-23 | Approval/Resolution Store | user-only DACL、atomic replace、破損record拒否 | -| ST-24 | config fingerprint/Resolved Path/hash変更 | 再確認または再承認までfail-closed | +| ST-23 | Applied Configuration Store | user-only DACL、snapshot全体のatomic replace、破損record拒否 | +| ST-24 | config fingerprint/Resolved Path/hash変更 | 全体を再applyするまでfail-closed | | ST-25 | Entry (`PowerShell`/Python/.NET/`cmd`) × Descendant (`cmd`/PowerShell/Python/.NET/System32 utility) | 個別Approvalなしで起動し、Descendantが同じLPAC SID/Jobに残る。cmd内からはPATH解決名またはWorkspace相対pathを使用する | | ST-26 | protected DACL root/descendantと通常DACL rootのread/write/execute | protected bitを解除せず、許可ケースだけ成功。適用不能ケースは拒否し、cleanup後Security Descriptorが完全一致 | | ST-27 | baseline/Network capabilityの追加前後 | 必要な正方向ケースに加えFilesystem、Registry write、COM、Policy外Network、Job breakawayの負方向集合をすべて検証 | @@ -894,8 +900,7 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC | sandbox.profile | readonly/development Workspace preset | | sandbox.workspaces | Workspace Logical Path list | | sandbox.mounts | Mount Logical Path + semantic access | -| sandbox.execution.executables | Approved Entry Executable Alias list | -| sandbox.execution.shells | Approved Shell Executable Alias list | +| sandbox.execution.entries | Alias、Executable Logical Path、optional Runtime Rootのlist。Shell分類はResolved file nameから導出 | | sandbox.network.access | none/local/restricted | | sandbox.network.local_endpoints | Loopback port list | | sandbox.network.restricted_endpoints | host/port list | diff --git a/doc/Specifications.md b/doc/Specifications.md index 79cb653..fb1de12 100644 --- a/doc/Specifications.md +++ b/doc/Specifications.md @@ -110,8 +110,8 @@ IPA「厳密な仕様記述入門」の考え方に従い、課題・仕様・è¨ | UC-02 | MCP Client | Task状態を照会する | 状態と、完了時には最終回答をテキストで得る | | UC-03 | 利用者 | 画像付き依頼を送信する | Vision対応modelなら処理される | | UC-04 | 利用者 | 言語を指定して起動する | CLI/診断/MCP応答/LLM最終回答が指定言語になる | -| UC-05 | 利用者 | User Sandbox Configurationを設定・確認する | semantic設定からEffective Sandbox Policyが可視化され、未許可能力はdenyされる | -| UC-06 | 利用者 | Entry Executableを承認する | Alias、Resolved Path、SHA-256一致時だけBrokerから直接起動できる | +| UC-05 | 利用者 | User Sandbox Configurationを適用する | YAML全体の解決・Executable identity・権限差分を一度確認し、原子的に適用できる | +| UC-06 | 利用者 | 適用状態を確認する | YAML、Resolved Path、Executable hash、Effective Sandbox Policy、Platform状態を一つの表で確認できる | | UC-07 | Agent | 計画を作成・更新する | TaskGraphがDAGを維持しToken CPMを再計算する | | UC-08 | Agent | Internal Toolを呼ぶ | Policy検査後にのみ副作用が発生する | | UC-09 | システム | 異常終了から復旧する | 残留Task Resourceを除去してから受付開始する | @@ -176,21 +176,21 @@ IPA「厳密な仕様記述入門」の考え方に従い、課題・仕様・è¨ ### 5.6 CLI -v1のCLI surfaceは次に限定する。 +schema version 2のCLI surfaceは次に限定する。 ```text -agent-mcp [serve] [--config <path>] [--lang <locale>] [--transport <stdio|http>] -agent-mcp check [--config <path>] [--lang <locale>] -agent-mcp executable resolve <alias> [--path <path>] --config <path> -agent-mcp executable approve <alias> [--path <path>] [--runtime-root <path>|--no-runtime] [--yes] --config <path> -agent-mcp executable list --config <path> -agent-mcp executable revoke <alias> [--yes] --config <path> -agent-mcp sandbox resolve --config <path> -agent-mcp sandbox confirm [--yes] --config <path> -agent-mcp sandbox show [--format <text|json>] --config <path> +agent-mcp [<config-path>] +agent-mcp apply [<config-path>] [--yes] +agent-mcp show [<config-path>] ``` -command省略時、および先頭引数がoptionの場合は `serve` として扱う。`resolve`、`list`、`show`、`check`はread-onlyとする。`approve`、`revoke`、`confirm`は差分表示後の対話確認を既定とし、明示的な`--yes`を許可してAuditへ記録する。MCP ClientおよびLLMからこれらの変更操作を呼び出せない。 +command省略時はYAMLの`mcp.transport`と`application.language`を使用してMCP Serverを起動する。config path省略時は実行ファイルと同じdirectoryの`agent.yaml`を使用する。CLIからYAML値を上書きしてはならない。 + +`apply`はbase/local merge後のYAML全体、Workspace、Mount、Execution Entry、Network、Resourceを解決し、ExecutableのResolved PathとSHA-256を含む権限差分を一度だけ表示する。全検証成功後、利用者が承認した場合だけconfig別Applied Configuration Storeをatomic replaceする。途中失敗時は以前の適用状態を維持する。`--yes`は非対話適用だけに使用でき、Auditへ記録する。 + +`show`はread-onlyとし、desired/resolved/applied/effectiveの状態とFilesystem/Execution/Network/Resource権限マトリックスを一つのtext表示で返す。WFP filter追加削除を伴う完全なPlatform preflightは起動時に必ず実施する。MCP ClientおよびLLMから`apply`を呼び出せない。 + +旧`serve`、`check`、`executable *`、`sandbox *`、`--config`、`--lang`、`--transport`、`--path`、`--runtime-root`、`--no-runtime`、`--format`は受け付けず、schema version 2の移行診断を返す。 ### 5.7 MCP 2026-07-28とTasks Extension @@ -206,7 +206,7 @@ command省略時、および先頭引数がoptionの場合は `serve` としてæ ### 5.8 起動・HTTP観測性 -- `serve`はbind前に管理者権限、BFE、WFP transaction、filter add/deleteを検査し、不足時は具体的なerrorと対処をstderrへ出してexit code 2で終了しなければならない。 +- command省略のServer起動はbind前に管理者権限、BFE、WFP transaction、filter add/deleteを検査し、不足時は具体的なerrorと対処をstderrへ出してexit code 2で終了しなければならない。 - ready messageは実際のtransport開始後だけ出力し、HTTPではendpoint/protocol/stateless/PID/log path、stdioではprotocol/PID/log pathを含める。 - HTTP request logは実socketのremote/local endpoint、method、path、status、duration、標準MCP routing headerを記録可能とする。 - `Authorization`、`Cookie`、query、body、prompt、画像、Base64をrequest logへ記録してはならず、`X-Forwarded-For`を接続元として信用してはならない。 @@ -458,8 +458,8 @@ Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 Mi | I18N-02 | 標準localeとして `locales/en.json` と `locales/ja.json` を配布する。 | | I18N-03 | ユーザーは `zh-CN.json` 等を追加し、アプリ変更なしでlocaleを追加できる。 | | I18N-04 | locale識別子はBCP 47形式を推奨する。 | -| I18N-05 | `--lang <locale>` で起動時に選択可能。 | -| I18N-06 | 優先順位は CLI > config > built-in default(en)。 | +| I18N-05 | 起動時のlocaleは`application.language`で選択する。 | +| I18N-06 | 優先順位はconfig > built-in default(en)とし、CLI overrideを持たない。 | | I18N-07 | 選択言語はCLI、診断、MCPテキスト応答、LLM最終回答へ適用する。 | | I18N-08 | 入力言語の自動検出による切替は行わない。 | | I18N-09 | placeholderは `{task}` のようなnamed placeholderを使う。 | @@ -471,15 +471,16 @@ Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 Mi ## 15. Configuration仕様 - 主設定形式はYAML。 -- `schema_version: 1` を必須とする。本改訂前の`filesystem`および`process.approvals`形式は破壊的に廃止し、移行方法付きで拒否する。 +- `schema_version: 2` を必須とする。version 1の`execution.executables`/`execution.shells`と、それ以前の`filesystem`/`process.approvals`形式は破壊的に廃止し、移行方法付きで拒否する。 - 設定変更の反映には再起動を必須とする。 - Hot Reloadは明示的に非対応。 -- CLIの `--config` で設定ファイルpathを選択可能。 +- config pathはCLIの任意の位置引数で選択可能とし、optionによる設定上書きを持たない。 - base設定と同じdirectoryに `agent.local.yaml` が存在する場合は任意overrideとして読み込む。mapは再帰mergeし、scalarと配列はlocal側で置換する。merge後の設定全体をvalidationし、相対pathはbase設定directoryを基準に解決する。 - Release Bundleは既定設定だけを配布し、利用者のlocal設定、User System Prompt、Skillをpublishで上書きしてはならない。 -- 起動時にschema、Resolution Result、Executable Approval、locale、Effective Sandbox Policyのvalidationを行う。 -- Executable Approval StoreはWindows user単位のuser-only領域へ保存し、Release Bundleへ含めない。 -- Workspace/Mountの確認済Resolution Resultはconfig別のuser-only領域へ保存し、config fingerprintまたは解決先変更時はserveをfail-closedにする。 +- `sandbox.execution.entries`はAlias、Executable Logical Path、optional Runtime Rootを一つのrecordとして持つ。Shell分類はResolved file nameから内部で決定する。 +- 起動時にschema、Applied Configuration、locale、Effective Sandbox Policyのvalidationを行う。 +- Workspace/Mount ResolutionとExecutable identity/Approvalを分離保存せず、config別Applied Configuration Storeへ一つのsnapshotとして保存する。StoreはWindows user単位のuser-only領域に置き、Release Bundleへ含めない。 +- YAML fingerprint、Resolved Path、Executable SHA-256、Runtime Rootのいずれかが適用済みsnapshotと異なる場合、起動はfail-closedにして`agent-mcp apply`を案内する。 --- diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md index c2c348e..4ec765f 100644 --- a/plans/ImplementationPlan.md +++ b/plans/ImplementationPlan.md @@ -1,6 +1,6 @@ # AI Agent Sandbox MCP Server — 実装・監査作業計画 -> Current status (2026-08-11): 現行実装の再監査・是正・再検証結果は`RequirementTraceability.md`と`doc/AuditReport.md`を正本とする。`plans/progress/`の完了報告は各記録作成時のcommitに対する履歴であり、現行PASS根拠には使用しない。Internal Toolの構成とschemaは`Design.md`のTool catalogを参照し、件数をRequirementまたはGateにしない。 +> Current status (2026-08-13): Sandbox Configuration schema version 2を破壊的に導入した。YAMLを唯一のdesired stateとし、CLIはServer起動、`apply`、`show`へ限定する。個別Executable Approval StoreとSandbox Resolution Storeは廃止し、config別Applied Configuration Storeへ統合する。旧完了報告と旧schemaの証跡は履歴であり、schema version 2のPASS根拠には使用しない。 **文書名:** `ImplementationPlan.md` **版:** 0.1 Draft @@ -177,7 +177,8 @@ Security境界を後回しにした状態で、実際の任意Process実行をå… - `en.json` / `ja.json` - locale loader / validator - named placeholder -- CLI `--config` / `--lang` +- config path位置引数と、YAMLだけからlanguage/transportを取得するCLI +- `show`によるread-only権限matrixと`apply [--yes]`による一括atomic適用 - fallback `selected locale → en → key` - Fixed System Promptの埋め込み基盤 - User System Prompt path @@ -190,7 +191,8 @@ Security境界を後回しにした状態で、実際の任意Process実行をå… ## 6.4 必須テスト - [ ] `en` 起動 - [ ] `ja` 起動 -- [ ] `--lang` がconfigより優先 +- [ ] CLIからlanguage/transportを上書きできない +- [ ] 旧commandと旧optionが具体的な移行診断で拒否される - [ ] 追加locale fileをコード変更なしで読める - [ ] missing keyはfallbackする - [ ] unknown keyでfatal @@ -199,7 +201,7 @@ Security境界を後回しにした状態で、実際の任意Process実行をå… - [ ] invalid YAML/schemaでfatal ## 6.5 監査証跡 - locale key一覧比較結果 -- `agent-mcp check --lang en/ja` の結果 +- `agent-mcp show`、`agent-mcp apply --yes`、command省略起動の結果 - stderr/file log sample - Fixed Promptの格納場所 ### Gate G1 @@ -369,22 +371,22 @@ LLM→Internal Tool Call→Policy Engineの境界が固定され、Effective San ## 11.2 実装対象 - Application固定LPAC Profileと`ALL APPLICATION PACKAGES` opt-out - User Sandbox ConfigurationからEffective Sandbox Policyへの変換 -- Logical Pathのreparse chain解決、config別Resolution Store、変更時fail-closed +- Logical Pathのreparse chain解決、config別Applied Configuration Store、変更時fail-closed - Workspace/Mount/Runtime/Artifact/OS RuntimeのFilesystem capability - NTFS ACL(readとexecuteを分離した継承ACE、root単位snapshot、protected descendantのfail-safe維持) -- Executable Alias、Approved Entry ExecutableのResolved Path + SHA-256、user-local Approval Store +- Execution EntryのAlias、Approved Entry ExecutableのResolved Path + SHA-256を含むuser-local一括snapshot - Runtime Component/development Workspace Artifact/External Unknown Entryの分類 - Entry ProcessとDescendant ProcessへLPAC、ACL、WFP、Job Objectを継承 - breakaway禁止、process count / memory limit / timeout / KILL_ON_JOB_CLOSE - Named Pipeが必要ならtask固有DACL -## 11.3 Executable Approval +## 11.3 Execution Entryの適用identity BrokerがApproved Entry ExecutableまたはApproved Shell Executableを直接起動する条件は以下をすべて満たすこと。 ```text -Alias == approval.alias -Resolved Path == approval.resolved_path -SHA-256 == approval.sha256 +Alias == applied entry.alias +Resolved Path == applied entry.resolved_path +SHA-256 == applied entry.sha256 ``` -hash、Logical Path、Resolved Pathの変更は再承認を必要とする。Descendant Processには個別Approvalを要求せず、Sandbox Process Treeのconfinementで制御する。 +hash、Logical Path、Resolved Pathの変更は権限matrix全体の再applyを必要とする。Descendant Processには個別Entry登録を要求せず、Sandbox Process Treeのconfinementで制御する。 ## 11.4 必須Security Test - [ ] readonly Workspaceでread成功、write/delete/Artifact Entry拒否 - [ ] development Workspaceでread/write/delete/Artifact Entry成功 @@ -562,7 +564,7 @@ AT-01〜AT-20をすべて再実行する。 ## 16.2.1 Release Bundle - `dotnet publish -c Release -r win-x64 --self-contained true` を実行する。 - `agent-mcp.exe`、runtime/dependency、`agent.yaml`、en/ja locale、User Prompt、`skills/`、READMEを含むversion付きZIP、manifest、SHA-256を生成する。 -- ソースツリー外の空directoryへ展開し、Bundle内の資源だけで`agent-mcp sandbox resolve`まで実行する。`check`、stdio、HTTPは、利用者がResolutionを確認し必要なExecutableを承認したOperational配置で実行する。 +- ソースツリー外の空directoryへ展開し、Bundle内の資源だけで`agent-mcp show`まで実行する。stdio、HTTPは、利用者がYAMLの権限matrixを確認して`agent-mcp apply`したOperational配置で実行する。 ## 16.2.2 Operational E2E Test - E2E-01: Release Bundle、`deepseek-v4-flash:cloud`、Python 3.14の実測Approval、stdio MCP Clientで一時fixtureへ複雑なPython programとtestを作成し、Python subprocessを含むtest、日本語応答、Audit追跡、cleanupまで完走する。 diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md index ae32290..1aab2a2 100644 --- a/plans/RequirementTraceability.md +++ b/plans/RequirementTraceability.md @@ -1,6 +1,6 @@ # Requirement Traceability -Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8`(2026-08-11 JST)以前の結果は`plans/progress/`と本書末尾に履歴として保持するが、現行PASS判定には使用しない。 +Sandbox Configuration schema version 2後の現行実装を対象とする。2026-08-13以前の結果は`plans/progress/`と本書末尾に履歴として保持するが、schema version 2の現行PASS判定には使用しない。 状態は`PASS / PARTIAL / FAIL / NOT REVERIFIED`を使う。2026-08-12の現行working-tree管理者証跡は`artifacts/evidence/sandbox-usability-final-working-tree-2/`、2026-08-13のLPAC Descendant再監査は`artifacts/evidence/lpac-descendant-reaudit-working-tree/`に保存する。 @@ -15,8 +15,8 @@ Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8 | AGT-01〜06 | `AgentRuntime`, `InternalToolDispatcher`, MCP公開境界 | completion/replan/iteration/catalog tests、stdio/HTTP E2E | PASS | | PLN-01〜06 | `TaskGraph`, `PlannerService`, structural `task_update` | Planner 10件、structured-plan retry、Task固有Tool証跡test | PASS | | SES-01〜06 | `SessionStore`、会話/graph/result保持、structured要約 | summary成功/失敗、session/runtime tests | PASS | -| FS-01〜10 | `ResolvedPath`, `PathPolicy`, `TaskAclManager`, `SandboxEnvironmentResolver` | Resolution/fingerprint/nested root、read/execute分離、readonly/development LPAC実効果、管理者suite | PASS | -| PRC-01〜09 | `EntryExecutablePolicy`, `RestrictedProcessLauncher`, Approval Store、Job Process Tree | Alias-only Entry、hash、Runtime/Artifact分類、PowerShell/Python/.NET build/test、breakaway、process/memory/timeout、Entry×Descendant matrix | PASS — cmdを含む全9組をPATH解決名/Workspace相対pathで再検証し、publish済みHTTP MCPでもcmd→cmd/curl/venv Pythonを完走 | +| FS-01〜10 | `ResolvedPath`, `PathPolicy`, `TaskAclManager`, `AppliedSandboxConfiguration` | schema v2 apply/fingerprint/nested root、read/execute分離、readonly/development LPAC実効果 | PASS — Core 104件、管理者Security/Recovery 56件、README operational 19件 | +| PRC-01〜09 | `EntryExecutablePolicy`, `RestrictedProcessLauncher`, Applied Configuration Store、Job Process Tree | Alias/path/hash/Runtime分類、Process Tree matrix、publish HTTP MCP | PASS — PowerShell/cmd/venv Python、cmd→cmd/curl/venv Pythonをschema v2で再検証 | | REC-01〜07 | retry、timeout、cancel cleanup、`RecoveryManager` | Core lifecycle、elevated crash/recovery matrix、残留0 | PASS | | SEC-INV-01〜05 | deny-by-default、Policy非変更、Resolved Path/hash/Entry分類 | Core境界、管理者Windows suite、Operational E2E | PASS | | SEC-INV-06〜08、13 | WFP none/local/restricted、credential非継承、Process Tree confinement | credential非継承、全Network mode、Process Tree実効果 | PASS | @@ -26,7 +26,7 @@ Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8 | NFR-01〜03 | default deny、決定的Policy/Graph/PERT、journal recovery | Core/Planner、elevated recovery tests | PASS | | NFR-04〜06 | 公開MCP Tool 2個、依存管理、Windows限定設計 | MCP schema、Release build/Bundle | PASS | | NFR-07 | Task/Tool/Process/Token/deny相関log | `ObservabilityTests`、Audit event、E2E log | PASS | -| NFR-08〜09 | en/ja catalog、同一key検証、用語/設定索引 | locale/configuration tests、文書監査 | PASS | +| NFR-08〜09 | en/ja catalog、同一key検証、用語/設定索引 | locale/configuration tests、文書監査、ja `show/apply`実行 | PASS | ## Acceptance Tests @@ -57,6 +57,11 @@ Internal Tool testは件数を仕様としてassertしない。`Design.md`のToo | Verification | Date / privilege | Result | |---|---|---| +| schema v2 Release build | 2026-08-13 / non-elevated | warning 0、error 0 | +| schema v2 Agent.Core.Tests / Planner.Tests | 2026-08-13 / non-elevated | Core 104 PASS、Planner 10 PASS、fail/skip 0 | +| schema v2 Windows Security/Recovery + README operational | 2026-08-13 / administrator | 環境非依存56件と実環境19件の計75 PASS、fail/skip 0。LPAC/ACL/Job/WFP、PowerShell/cmd/venv Python、Network、ACL monkeyを含む | +| schema v2 published HTTP MCP | 2026-08-13 / administrator / `deepseek-v4-flash:cloud` | `agent_run` completed 3/3、cmd→cmd/curl/C:\tmp\.venv Python、Network none、ACL/WFP/Journal/backup/process残留0 | +| schema v2 win-x64 self-contained Bundle | 2026-08-13 / fresh展開 | `show` PASS、local設定保持、ZIP SHA-256 `9fd60acd32ef6b031378cfa8ae7b4ff18b8c74daab410a7aa2e445101e73e87f` | | Release build | 2026-08-12 / non-elevated | warning 0、error 0 | | Agent.Core.Tests | 2026-08-12 / administrator | 103 PASS、0 FAIL、0 skip | | Agent.Planner.Tests | 2026-08-12 / administrator | 10 PASS、0 FAIL、0 skip | diff --git a/scripts/E2EClient/Program.cs b/scripts/E2EClient/Program.cs index ee7bb75..8733cdf 100644 --- a/scripts/E2EClient/Program.cs +++ b/scripts/E2EClient/Program.cs @@ -13,7 +13,7 @@ IClientTransport transport = args[0] == "stdio" { Name = "agent-mcp-operational-e2e", Command = Path.GetFullPath(args[1]), - Arguments = ["serve", "--config", Path.GetFullPath(args[2]), "--lang", "ja", "--transport", "stdio"], + Arguments = [Path.GetFullPath(args[2])], WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(args[1]))! }) : new HttpClientTransport(new HttpClientTransportOptions diff --git a/scripts/Operational-E2E.ps1 b/scripts/Operational-E2E.ps1 index b253198..cea1c75 100644 --- a/scripts/Operational-E2E.ps1 +++ b/scripts/Operational-E2E.ps1 @@ -17,8 +17,7 @@ $bundleRoot = Join-Path $root 'bundle' $fixture = Join-Path $root 'fixture' $alias = 'python-e2e-' + [Guid]::NewGuid().ToString('N').Substring(0, 12) $passed = $false -$approved = $false -$resolutionStorePath = $null +$appliedStorePath = $null $server = $null New-Item -ItemType Directory -Path $bundleRoot,$fixture | Out-Null $fixtureAclBefore = (Get-Acl -LiteralPath $fixture).Sddl @@ -40,6 +39,8 @@ try { $pythonVersion = (& $python --version 2>&1 | Out-String).Trim() if ($pythonVersion -notmatch '^Python 3\.14\.') { throw "Operational E2E requires Python 3.14; resolved '$pythonVersion'." } $runtimeRoot = Split-Path $python + $pythonYaml = $pythonLogical.Replace('\','/') + $runtimeYaml = $runtimeRoot.Replace('\','/') Set-Content -LiteralPath (Join-Path $fixture 'README.md') -Encoding utf8NoBOM -Value @' # Graph metrics fixture @@ -50,7 +51,7 @@ produce a deterministic topological order, detect cycles, and calculate the weig $fixtureYaml = $fixture.Replace('\','/') $config = @" -schema_version: 1 +schema_version: 2 application: language: ja mcp: @@ -83,9 +84,10 @@ sandbox: - $fixtureYaml mounts: [] execution: - executables: - - $alias - shells: [] + entries: + - alias: $alias + path: $pythonYaml + runtime_root: $runtimeYaml network: access: none local_endpoints: [] @@ -112,15 +114,12 @@ logging: "@ Set-Content -LiteralPath $configPath -Value $config -Encoding utf8NoBOM - & $agent executable approve $alias --path $pythonLogical --runtime-root $runtimeRoot --yes --config $configPath --lang en - if ($LASTEXITCODE -ne 0) { throw 'Executable approval failed.' } - $approved = $true - & $agent sandbox confirm --yes --config $configPath --lang en - if ($LASTEXITCODE -ne 0) { throw 'Sandbox Environment Resolution confirmation failed.' } + & $agent apply $configPath --yes + if ($LASTEXITCODE -ne 0) { throw 'Applied Configuration failed.' } $normalizedConfig = [IO.Path]::GetFullPath($configPath).ToUpperInvariant() $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($normalizedConfig))) - $resolutionStorePath = Join-Path $env:LOCALAPPDATA "AgentSandboxMcp\environment-resolutions\$hash.json" - & $agent sandbox show --format json --config $configPath --lang en | Out-Null + $appliedStorePath = Join-Path $env:LOCALAPPDATA "AgentSandboxMcp\applied-configurations\$hash.json" + & $agent show $configPath | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Effective Sandbox Policy show failed.' } $request = @" @@ -138,7 +137,7 @@ README.md の読取後、2ファイルの作成、最初のテスト実行、失 if ($LASTEXITCODE -ne 0) { throw 'E2E stdio client failed.' } } else { - $server = Start-Process -FilePath $agent -ArgumentList @('serve','--config',$configPath,'--lang','ja','--transport','http') -WorkingDirectory $bundle -WindowStyle Hidden -PassThru + $server = Start-Process -FilePath $agent -ArgumentList @($configPath) -WorkingDirectory $bundle -WindowStyle Hidden -PassThru $httpAuthority = if ($HttpHost -eq '::1') { '[::1]' } else { $HttpHost } $endpoint = "http://${httpAuthority}:18080/mcp" $ready = $false @@ -183,11 +182,8 @@ catch { } finally { if ($server -and -not $server.HasExited) { $server.Kill($true); $server.WaitForExit() } - if ($approved -and (Test-Path -LiteralPath $agent)) { - & $agent executable revoke $alias --yes --config $configPath --lang en | Out-Null - } - if ($resolutionStorePath -and (Test-Path -LiteralPath $resolutionStorePath)) { - Remove-Item -LiteralPath $resolutionStorePath -Force + if ($appliedStorePath -and (Test-Path -LiteralPath $appliedStorePath)) { + Remove-Item -LiteralPath $appliedStorePath -Force } if ($passed -and (Test-Path -LiteralPath $root)) { Remove-Item -LiteralPath $root -Recurse -Force } } diff --git a/scripts/Publish-Release.ps1 b/scripts/Publish-Release.ps1 index d794bb5..41bdfa3 100644 --- a/scripts/Publish-Release.ps1 +++ b/scripts/Publish-Release.ps1 @@ -66,8 +66,8 @@ try { if (-not $SkipRuntimeCheck) { Push-Location $freshBundle try { - & .\agent-mcp.exe sandbox resolve --config .\agent.yaml --lang en - if ($LASTEXITCODE -ne 0) { throw "Bundle resolution check failed with exit code $LASTEXITCODE." } + & .\agent-mcp.exe show .\agent.yaml + if ($LASTEXITCODE -ne 0) { throw "Bundle configuration check failed with exit code $LASTEXITCODE." } } finally { Pop-Location } } diff --git a/scripts/Restart-PublishedServer.ps1 b/scripts/Restart-PublishedServer.ps1 index 58dd928..d4386f4 100644 --- a/scripts/Restart-PublishedServer.ps1 +++ b/scripts/Restart-PublishedServer.ps1 @@ -26,5 +26,5 @@ if ($StopOnly) { exit 0 } if (-not (Test-Path -LiteralPath $agent) -or -not (Test-Path -LiteralPath $configuration)) { throw 'Published agent-mcp executable or configuration is missing.' } -$started = Start-Process -FilePath $agent -ArgumentList @('serve', '--config', $configuration) -WorkingDirectory $bundle -WindowStyle Hidden -PassThru +$started = Start-Process -FilePath $agent -ArgumentList @($configuration) -WorkingDirectory $bundle -WindowStyle Hidden -PassThru Write-Output "Started published agent-mcp PID $($started.Id)." diff --git a/scripts/Run-Final-ElevatedVerification.ps1 b/scripts/Run-Final-ElevatedVerification.ps1 index 627bd65..71135c2 100644 --- a/scripts/Run-Final-ElevatedVerification.ps1 +++ b/scripts/Run-Final-ElevatedVerification.ps1 @@ -47,8 +47,8 @@ $windowsTests = Join-Path $repository 'build\Release\Agent.Windows.Tests\net10.0 $coreTests = Join-Path $repository 'build\Release\Agent.Core.Tests\net10.0-windows10.0.26100.0\Agent.Core.Tests.exe' $plannerTests = Join-Path $repository 'build\Release\Agent.Planner.Tests\net10.0\Agent.Planner.Tests.exe' -Invoke-VerificationStep 'bundle-check' { - & (Join-Path $bundle 'agent-mcp.exe') check --config (Join-Path $bundle 'agent.yaml') --lang en +Invoke-VerificationStep 'bundle-show' { + & (Join-Path $bundle 'agent-mcp.exe') show (Join-Path $bundle 'agent.yaml') } Invoke-VerificationStep 'windows-security-tests' { & $windowsTests } Invoke-VerificationStep 'core-unit-integration-tests' { & $coreTests } diff --git a/scripts/Verify-PublishedHttp.ps1 b/scripts/Verify-PublishedHttp.ps1 index a02a28b..7dd5fe2 100644 --- a/scripts/Verify-PublishedHttp.ps1 +++ b/scripts/Verify-PublishedHttp.ps1 @@ -29,6 +29,8 @@ $runRoot = Join-Path $testRootPath ('agent-mcp-http-cmd-' + [Guid]::NewGuid().To $runRootForward = $runRoot.Replace('\', '/') $endpoint = 'http://127.0.0.1:8080/mcp' $log = Join-Path $evidence 'verification.log' +$serverOut = Join-Path $evidence 'server.stdout.log' +$serverErr = Join-Path $evidence 'server.stderr.log' $server = $null New-Item -ItemType Directory -Force -Path $evidence | Out-Null New-Item -ItemType Directory -Path $runRoot | Out-Null @@ -47,14 +49,17 @@ run-probe.cmdは次を順番に実行し、どれかが失敗したら非0で終 "@ try { - & $agent check --config $config --lang ja 2>&1 | Tee-Object -FilePath $log - if ($LASTEXITCODE -ne 0) { throw "Published check failed with exit code $LASTEXITCODE." } + & $agent show $config 2>&1 | Tee-Object -FilePath $log + if ($LASTEXITCODE -ne 0) { throw "Published configuration show failed with exit code $LASTEXITCODE." } - $server = Start-Process -FilePath $agent -ArgumentList @('serve','--config',$config,'--lang','ja','--transport','http') ` - -WorkingDirectory $bundle -WindowStyle Hidden -PassThru + $server = Start-Process -FilePath $agent -ArgumentList @($config) -WorkingDirectory $bundle ` + -WindowStyle Hidden -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -PassThru $ready = $false for ($attempt = 0; $attempt -lt 100; $attempt++) { - if ($server.HasExited) { throw "Published server exited before readiness: $($server.ExitCode)" } + if ($server.HasExited) { + $diagnostic = if (Test-Path -LiteralPath $serverErr) { Get-Content -LiteralPath $serverErr -Raw } else { '' } + throw "Published server exited before readiness: $($server.ExitCode) $diagnostic" + } try { $response = Invoke-WebRequest -Uri $endpoint -Method Get -SkipHttpErrorCheck if ($response.StatusCode -eq 405) { $ready = $true; break } diff --git a/scripts/Verify-Readme.ps1 b/scripts/Verify-Readme.ps1 index f67f640..42c8b8f 100644 --- a/scripts/Verify-Readme.ps1 +++ b/scripts/Verify-Readme.ps1 @@ -1,255 +1,128 @@ param( - [Parameter(Mandatory)] - [string]$TestRoot, - [Parameter(Mandatory)] - [string]$VenvPython, + [Parameter(Mandatory)][string]$TestRoot, + [Parameter(Mandatory)][string]$VenvPython, [string]$EvidenceRoot = 'artifacts\evidence\readme-verification', [switch]$CliOnly ) $ErrorActionPreference = 'Stop' -$OutputEncoding = [Text.UTF8Encoding]::new($false) -[Console]::OutputEncoding = $OutputEncoding $repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $testRootPath = (Resolve-Path -LiteralPath $TestRoot).Path $venvPythonPath = (Resolve-Path -LiteralPath $VenvPython).Path $evidence = [IO.Path]::GetFullPath((Join-Path $repository $EvidenceRoot)) -if (-not $evidence.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { - throw 'Evidence output must remain inside the repository.' -} -$administrator = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator) -if (-not $administrator -and -not $CliOnly) { throw 'README verification must run as Windows Administrator unless -CliOnly is specified.' } -if (-not (Test-Path -LiteralPath $venvPythonPath -PathType Leaf)) { throw 'VenvPython must be an existing file.' } +if (-not $evidence.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw 'Evidence output must remain inside the repository.' } +$administrator = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $administrator -and -not $CliOnly) { throw 'README operational verification must run as Windows Administrator.' } $pwshPath = (Get-Command pwsh.exe -ErrorAction Stop).Source -$pwshRuntime = Split-Path $pwshPath $cmdPath = $env:ComSpec $venvRoot = Split-Path (Split-Path $venvPythonPath) -$pyvenv = Get-Content -LiteralPath (Join-Path $venvRoot 'pyvenv.cfg') -$pythonRuntime = (($pyvenv | Where-Object { $_ -match '^home\s*=' }) -split '=', 2)[1].Trim() -if (-not (Test-Path -LiteralPath $pythonRuntime -PathType Container)) { throw "Python Runtime Root does not exist: $pythonRuntime" } +$pythonRuntime = (((Get-Content -LiteralPath (Join-Path $venvRoot 'pyvenv.cfg')) | Where-Object { $_ -match '^home\s*=' }) -split '=', 2)[1].Trim() $basePythonPath = Join-Path $pythonRuntime 'python.exe' if (-not (Test-Path -LiteralPath $basePythonPath -PathType Leaf)) { throw "Base Python does not exist: $basePythonPath" } New-Item -ItemType Directory -Force -Path $evidence | Out-Null -$runRoot = Join-Path $testRootPath ('agent-mcp-readme-cli-' + [Guid]::NewGuid().ToString('N')) +$runRoot = Join-Path $testRootPath ('agent-mcp-readme-' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $runRoot | Out-Null $agent = Join-Path $repository 'build\Release\Agent.App\net10.0-windows10.0.26100.0\agent-mcp.exe' $windowsTests = Join-Path $repository 'build\Release\Agent.Windows.Tests\net10.0-windows10.0.26100.0\Agent.Windows.Tests.exe' $configPath = Join-Path $runRoot 'agent.yaml' $logPath = Join-Path $evidence 'verification.log' -$failed = $false -$aliases = @( - ('readme-python-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)) - ('readme-powershell-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)) - ('readme-cmd-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)) -) -$pythonAlias, $powershellAlias, $cmdAlias = $aliases -$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) -$listener.Start() -$httpPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port -$listener.Stop() -$resolutionStorePath = $null $server = $null +$failed = $false -function Invoke-Step { - param([string]$Name, [scriptblock]$Action) +function Invoke-Step([string]$Name, [scriptblock]$Action) { "START $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append try { $global:LASTEXITCODE = 0 & $Action *>&1 | Tee-Object -FilePath $logPath -Append if ($LASTEXITCODE -ne 0) { throw "$Name exited with $LASTEXITCODE" } - "PASS $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append - } - catch { - $_ | Out-String | Tee-Object -FilePath $logPath -Append - $script:failed = $true - "FAIL $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append - } + "PASS $Name" | Tee-Object -FilePath $logPath -Append + } catch { $script:failed = $true; "FAIL $Name $($_.Exception.Message)" | Tee-Object -FilePath $logPath -Append } } -function Invoke-ExpectedFailure { - param([string]$Name, [string[]]$Arguments) - "START $Name $(Get-Date -Format o)" | Tee-Object -FilePath $logPath -Append +function Invoke-ExpectedFailure([string]$Name, [string[]]$Arguments) { & $agent @Arguments *>&1 | Tee-Object -FilePath $logPath -Append - if ($LASTEXITCODE -ne 2) { - $script:failed = $true - "FAIL $Name expected_exit=2 actual_exit=$LASTEXITCODE" | Tee-Object -FilePath $logPath -Append - } - else { "PASS $Name expected_exit=2" | Tee-Object -FilePath $logPath -Append } -} - -function Start-And-ProbeServer { - param([bool]$ExplicitServe) - $arguments = if ($ExplicitServe) { - @('serve', '--config', $configPath, '--lang', 'ja', '--transport', 'http') - } else { - @('--config', $configPath, '--lang', 'ja', '--transport', 'http') - } - $script:server = Start-Process -FilePath $agent -ArgumentList $arguments -WorkingDirectory (Split-Path $agent) -WindowStyle Hidden -PassThru - $endpoint = "http://127.0.0.1:$httpPort/mcp" - for ($attempt = 0; $attempt -lt 100; $attempt++) { - if ($script:server.HasExited) { throw "Server exited before becoming ready: $($script:server.ExitCode)" } - try { - $response = Invoke-WebRequest -Uri $endpoint -Method Get -SkipHttpErrorCheck - if ($response.StatusCode -eq 405) { return } - } catch { } - Start-Sleep -Milliseconds 100 - } - throw 'Server did not become ready.' + if ($LASTEXITCODE -ne 2) { $script:failed = $true; "FAIL $Name expected=2 actual=$LASTEXITCODE" | Tee-Object -FilePath $logPath -Append } + else { "PASS $Name" | Tee-Object -FilePath $logPath -Append } } try { - $forwardRoot = $testRootPath.Replace('\', '/') - $forwardPrompt = (Join-Path $repository 'prompts\user-system.md').Replace('\', '/') - $forwardSkills = (Join-Path $repository 'skills').Replace('\', '/') - $forwardLocales = (Join-Path $repository 'config\locales').Replace('\', '/') - $forwardLogs = (Join-Path $runRoot 'logs').Replace('\', '/') + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0); $listener.Start() + $httpPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port; $listener.Stop() + $forwardRoot = $testRootPath.Replace('\','/') + $forwardPrompt = (Join-Path $repository 'prompts\user-system.md').Replace('\','/') + $forwardSkills = (Join-Path $repository 'skills').Replace('\','/') + $forwardLocales = (Join-Path $repository 'config\locales').Replace('\','/') + $forwardLogs = (Join-Path $runRoot 'logs').Replace('\','/') + $pythonYaml = $basePythonPath.Replace('\','/'); $runtimeYaml = $pythonRuntime.Replace('\','/') + $pwshYaml = $pwshPath.Replace('\','/'); $cmdYaml = $cmdPath.Replace('\','/') @" -schema_version: 1 -application: - language: en +schema_version: 2 +application: { language: en } mcp: transport: http - http: - host: 127.0.0.1 - port: $httpPort - images: - max_count: 4 - max_bytes_each: 10485760 - max_total_bytes: 20971520 - tasks: - time_to_live_hours: 24 - poll_interval_ms: 1000 -agent: - user_system_prompt: $forwardPrompt - max_iterations: 8 - same_failure_limit: 2 -ollama: - endpoint: http://127.0.0.1:11434 - model: deepseek-v4-flash:cloud - think: true - keep_alive: 5m - options: - num_ctx: 32768 - temperature: 0 - extra_options: {} -planner: - max_tasks: 8 + http: { host: 127.0.0.1, port: $httpPort } +agent: { user_system_prompt: $forwardPrompt, max_iterations: 8, same_failure_limit: 2 } +ollama: { endpoint: 'http://127.0.0.1:11434', model: 'deepseek-v4-flash:cloud', think: true } +planner: { max_tasks: 8 } sandbox: profile: development - workspaces: - - $forwardRoot + workspaces: [$forwardRoot] mounts: [] execution: - executables: - - $pythonAlias - shells: - - $powershellAlias - - $cmdAlias - network: - access: none - local_endpoints: [] - restricted_endpoints: [] - resources: - profile: constrained -file_edit: - memory_backup_limit_mb: 16 -limits: - whole_read_bytes: 1048576 - search_max_results: 1000 - tail_max_lines: 10000 - context_summary_ratio: 0.8 -localization: - directory: $forwardLocales - fallback: en -skills: - directory: $forwardSkills -logging: - directory: $forwardLogs - level: information - http_requests: true - max_file_bytes: 10485760 - retained_files: 5 + entries: + - { alias: python, path: $pythonYaml, runtime_root: $runtimeYaml } + - { alias: powershell, path: '$pwshYaml' } + - { alias: cmd, path: $cmdYaml } + network: { access: none, local_endpoints: [], restricted_endpoints: [] } + resources: { profile: constrained } +localization: { directory: $forwardLocales, fallback: en } +skills: { directory: $forwardSkills } +logging: { directory: $forwardLogs, level: information, max_file_bytes: 10485760, retained_files: 5 } "@ | Set-Content -LiteralPath $configPath -Encoding utf8NoBOM - @( - "timestamp=$(Get-Date -Format o)", - "commit=$(git -C $repository rev-parse HEAD)", - "administrator=$administrator", - "os=$([Environment]::OSVersion.VersionString)", - "venv_python=$venvPythonPath", - "venv_version=$(& $venvPythonPath --version)", - "powershell=$pwshPath", - "cmd=$cmdPath" - ) | Set-Content -LiteralPath (Join-Path $evidence 'metadata.txt') -Encoding utf8NoBOM - - Invoke-Step 'cli-sandbox-resolve' { & $agent sandbox resolve --config $configPath --lang ja } - Invoke-Step 'cli-sandbox-confirm-yes' { & $agent sandbox confirm --yes --config $configPath --lang ja } $normalizedConfig = [IO.Path]::GetFullPath($configPath).ToUpperInvariant() $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($normalizedConfig))) - $resolutionStorePath = Join-Path $env:LOCALAPPDATA "AgentSandboxMcp\environment-resolutions\$hash.json" - Invoke-Step 'cli-executable-resolve-path' { & $agent executable resolve $pythonAlias --path $basePythonPath --config $configPath --lang ja } - Invoke-Step 'cli-executable-approve-runtime-root-yes' { & $agent executable approve $pythonAlias --path $basePythonPath --runtime-root $pythonRuntime --yes --config $configPath --lang ja } - Invoke-Step 'cli-shell-resolve-powershell' { & $agent executable resolve $powershellAlias --path $pwshPath --config $configPath --lang en } - Invoke-Step 'cli-shell-approve-runtime-root-yes' { & $agent executable approve $powershellAlias --path $pwshPath --runtime-root $pwshRuntime --yes --config $configPath --lang en } - Invoke-Step 'cli-shell-resolve-cmd' { & $agent executable resolve $cmdAlias --path $cmdPath --config $configPath --lang en } - Invoke-Step 'cli-shell-approve-cmd-no-runtime' { & $agent executable approve $cmdAlias --path $cmdPath --no-runtime --yes --config $configPath --lang en } - Invoke-Step 'cli-executable-list' { & $agent executable list --config $configPath --lang ja } - Invoke-Step 'cli-sandbox-show-text-default-format' { & $agent sandbox show --config $configPath --lang en } - Invoke-Step 'cli-sandbox-show-json' { & $agent sandbox show --format json --config $configPath --lang ja } - if ($CliOnly) { - Invoke-ExpectedFailure 'cli-check-detects-non-admin' @('check', '--config', $configPath, '--lang', 'ja') - } - else { - Invoke-Step 'cli-check' { & $agent check --config $configPath --lang ja } - } + $appliedStorePath = Join-Path $env:LOCALAPPDATA "AgentSandboxMcp\applied-configurations\$hash.json" - Invoke-ExpectedFailure 'cli-invalid-transport' @('serve', '--config', $configPath, '--transport', 'invalid') - Invoke-ExpectedFailure 'cli-invalid-format' @('sandbox', 'show', '--format', 'xml', '--config', $configPath) - Invoke-ExpectedFailure 'cli-management-missing-config' @('sandbox', 'resolve') - Invoke-ExpectedFailure 'cli-mutually-exclusive-runtime-options' @('executable', 'approve', $pythonAlias, '--path', $basePythonPath, '--runtime-root', $pythonRuntime, '--no-runtime', '--config', $configPath) + Invoke-Step 'cli-show-before-apply' { & $agent show $configPath } + Invoke-Step 'cli-apply-yes' { & $agent apply $configPath --yes } + Invoke-Step 'cli-show-applied' { & $agent show $configPath } + Invoke-ExpectedFailure 'removed-serve' @('serve') + Invoke-ExpectedFailure 'removed-check' @('check') + Invoke-ExpectedFailure 'removed-config-option' @('--config', $configPath) + Invoke-ExpectedFailure 'removed-language-option' @('--lang', 'ja') + Invoke-ExpectedFailure 'removed-transport-option' @('--transport', 'http') + Invoke-ExpectedFailure 'removed-executable-command' @('executable', 'list') + Invoke-ExpectedFailure 'removed-sandbox-command' @('sandbox', 'show') if (-not $CliOnly) { - Invoke-Step 'cli-serve-explicit-http' { - Start-And-ProbeServer $true - $script:server.Kill($true) - $script:server.WaitForExit() - $script:server = $null - } - Invoke-Step 'cli-serve-implicit-option-first' { - Start-And-ProbeServer $false - $script:server.Kill($true) - $script:server.WaitForExit() - $script:server = $null + Invoke-Step 'implicit-http-start' { + $script:server = Start-Process -FilePath $agent -ArgumentList @($configPath) -WorkingDirectory (Split-Path $agent) -WindowStyle Hidden -PassThru + $endpoint = "http://127.0.0.1:$httpPort/mcp" + for ($attempt=0; $attempt -lt 100; $attempt++) { + if ($script:server.HasExited) { throw "Server exited: $($script:server.ExitCode)" } + try { $response = Invoke-WebRequest -Uri $endpoint -Method Get -SkipHttpErrorCheck; if ($response.StatusCode -eq 405) { break } } catch { } + Start-Sleep -Milliseconds 100 + } + if ($attempt -eq 100) { throw 'Server did not become ready.' } + $script:server.Kill($true); $script:server.WaitForExit(); $script:server = $null } - $env:AGENT_MCP_MONKEY_ROOT = $testRootPath $env:AGENT_MCP_TEST_VENV_PYTHON = $venvPythonPath $env:AGENT_MCP_TEST_POWERSHELL = $pwshPath - $env:AGENT_MCP_TEST_POWERSHELL_RUNTIME = $pwshRuntime + $env:AGENT_MCP_TEST_POWERSHELL_RUNTIME = Split-Path $pwshPath $env:AGENT_MCP_TEST_CMD = $cmdPath - Invoke-Step 'readme-operational-windows-tests' { - & $windowsTests -class Agent.Windows.Tests.ReadmeOperationalTests -parallel none -failSkips - } + Invoke-Step 'readme-operational-windows-tests' { & $windowsTests -class Agent.Windows.Tests.ReadmeOperationalTests -parallel none -failSkips } } - Invoke-Step 'cli-executable-revoke-python' { & $agent executable revoke $pythonAlias --yes --config $configPath --lang ja } - Invoke-Step 'cli-executable-revoke-powershell' { & $agent executable revoke $powershellAlias --yes --config $configPath --lang en } - Invoke-Step 'cli-executable-revoke-cmd' { & $agent executable revoke $cmdAlias --yes --config $configPath --lang en } -} -finally { +} finally { if ($server -and -not $server.HasExited) { $server.Kill($true); $server.WaitForExit() } - foreach ($alias in $aliases) { - & $agent executable revoke $alias --yes --config $configPath --lang en 2>$null | Out-Null - } - if ($resolutionStorePath -and (Test-Path -LiteralPath $resolutionStorePath)) { Remove-Item -LiteralPath $resolutionStorePath -Force } + if ($appliedStorePath -and (Test-Path -LiteralPath $appliedStorePath)) { Remove-Item -LiteralPath $appliedStorePath -Force } if (Test-Path -LiteralPath $runRoot) { - $resolvedRunRoot = (Resolve-Path -LiteralPath $runRoot).Path - if (-not $resolvedRunRoot.StartsWith($testRootPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { - throw 'Refusing to clean a test directory outside TestRoot.' - } - Remove-Item -LiteralPath $resolvedRunRoot -Recurse -Force + $resolved = (Resolve-Path -LiteralPath $runRoot).Path + if (-not $resolved.StartsWith($testRootPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Refusing to clean outside TestRoot.' } + Remove-Item -LiteralPath $resolved -Recurse -Force } Remove-Item Env:AGENT_MCP_MONKEY_ROOT,Env:AGENT_MCP_TEST_VENV_PYTHON,Env:AGENT_MCP_TEST_POWERSHELL,Env:AGENT_MCP_TEST_POWERSHELL_RUNTIME,Env:AGENT_MCP_TEST_CMD -ErrorAction SilentlyContinue } diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs index 3b3fde1..b2364f4 100644 --- a/src/Agent.App/Cli/CommandLine.cs +++ b/src/Agent.App/Cli/CommandLine.cs @@ -2,103 +2,47 @@ namespace Agent.App.Cli; public enum AppCommand { - Serve, - Check, - ExecutableResolve, - ExecutableApprove, - ExecutableList, - ExecutableRevoke, - SandboxResolve, - SandboxConfirm, - SandboxShow + Start, + Apply, + Show } public sealed record CommandLine( AppCommand Command, string? ConfigPath, - string? Language, - string? Transport, - string? Alias = null, - string? ExecutablePath = null, - string? RuntimeRoot = null, - bool NoRuntime = false, - bool Yes = false, - string Format = "text") + bool Yes = false) { + private static readonly HashSet<string> RemovedCommands = new(StringComparer.Ordinal) + { + "serve", "check", "executable", "sandbox" + }; + public static CommandLine Parse(string[] args) { - bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal); - int index = implicitServe ? 0 : 1; - AppCommand command; - string? alias = null; - if (implicitServe) command = AppCommand.Serve; - else if (args[0] == "serve") command = AppCommand.Serve; - else if (args[0] == "check") command = AppCommand.Check; - else if (args[0] is "executable" or "sandbox") - { - if (index >= args.Length) throw new CommandLineException($"Expected a subcommand after '{args[0]}'."); - string subcommand = args[index++]; - command = (args[0], subcommand) switch - { - ("executable", "resolve") => AppCommand.ExecutableResolve, - ("executable", "approve") => AppCommand.ExecutableApprove, - ("executable", "list") => AppCommand.ExecutableList, - ("executable", "revoke") => AppCommand.ExecutableRevoke, - ("sandbox", "resolve") => AppCommand.SandboxResolve, - ("sandbox", "confirm") => AppCommand.SandboxConfirm, - ("sandbox", "show") => AppCommand.SandboxShow, - _ => throw new CommandLineException($"Unknown subcommand '{args[0]} {subcommand}'.") - }; - if (command is AppCommand.ExecutableResolve or AppCommand.ExecutableApprove or AppCommand.ExecutableRevoke) - { - if (index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal)) - throw new CommandLineException($"Executable Alias is required for '{args[0]} {subcommand}'."); - alias = args[index++]; - } - } - else throw new CommandLineException("Expected 'serve', 'check', 'executable', or 'sandbox'."); + if (args.Length == 0) return new(AppCommand.Start, null); + if (RemovedCommands.Contains(args[0])) + throw new CommandLineException($"'{args[0]}' was removed in schema_version 2. Use 'agent-mcp', 'agent-mcp apply', or 'agent-mcp show'."); + if (args.Any(static value => value is "--config" or "--lang" or "--transport" or "--path" or "--runtime-root" or "--no-runtime" or "--format")) + throw new CommandLineException("Configuration override options were removed in schema_version 2. Put values in YAML and pass the config path as a positional argument."); + AppCommand command = args[0] switch + { + "apply" => AppCommand.Apply, + "show" => AppCommand.Show, + _ when !args[0].StartsWith("--", StringComparison.Ordinal) => AppCommand.Start, + _ => throw new CommandLineException($"Unknown option '{args[0]}'.") + }; + int index = command == AppCommand.Start ? 0 : 1; string? config = null; - string? language = null; - string? transport = null; - string? executablePath = null; - string? runtimeRoot = null; - bool noRuntime = false; bool yes = false; - string format = "text"; while (index < args.Length) { - string option = args[index++]; - switch (option) - { - case "--yes" when !yes: yes = true; break; - case "--no-runtime" when !noRuntime && runtimeRoot is null: noRuntime = true; break; - case "--config" when config is null: config = Value(option); break; - case "--lang" when language is null: language = Value(option); break; - case "--transport" when command == AppCommand.Serve && transport is null: - transport = Value(option); - if (transport is not ("stdio" or "http")) throw new CommandLineException("Transport must be stdio or http."); - break; - case "--path" when executablePath is null: executablePath = Value(option); break; - case "--runtime-root" when runtimeRoot is null && !noRuntime: runtimeRoot = Value(option); break; - case "--format" when command == AppCommand.SandboxShow: - format = Value(option); - if (format is not ("text" or "json")) throw new CommandLineException("Format must be text or json."); - break; - default: throw new CommandLineException($"Unknown, duplicate, or invalid option '{option}' for this command."); - } - } - bool management = command is not (AppCommand.Serve or AppCommand.Check); - if (management && config is null) throw new CommandLineException("--config <file> is required for executable and sandbox commands."); - if (yes && command is not (AppCommand.ExecutableApprove or AppCommand.ExecutableRevoke or AppCommand.SandboxConfirm)) - throw new CommandLineException("--yes is valid only for approve, revoke, and confirm."); - return new(command, config, language, transport, alias, executablePath, runtimeRoot, noRuntime, yes, format); - - string Value(string optionName) - { - if (index >= args.Length) throw new CommandLineException($"Missing value for '{optionName}'."); - return args[index++]; + string value = args[index++]; + if (value == "--yes" && command == AppCommand.Apply && !yes) yes = true; + else if (!value.StartsWith("--", StringComparison.Ordinal) && config is null) config = value; + else throw new CommandLineException($"Unknown, duplicate, or invalid argument '{value}' for this command."); } + return new(command, config, yes); } } diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs index b2f94a7..ef2eebc 100644 --- a/src/Agent.App/Configuration/AgentConfiguration.cs +++ b/src/Agent.App/Configuration/AgentConfiguration.cs @@ -74,7 +74,16 @@ public sealed class SandboxOptions public ResourceOptions Resources { get; set; } = new(); } public sealed class MountOptions { public string Path { get; set; } = string.Empty; public string Access { get; set; } = "read_only"; } -public sealed class ExecutionOptions { public List<string> Executables { get; set; } = []; public List<string> Shells { get; set; } = []; } +public sealed class ExecutionOptions +{ + public List<ExecutionEntryOptions> Entries { get; set; } = []; +} +public sealed class ExecutionEntryOptions +{ + public string Alias { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string? RuntimeRoot { get; set; } +} public sealed class NetworkOptions { public string Access { get; set; } = "none"; diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs index 6e15904..122c499 100644 --- a/src/Agent.App/Configuration/ConfigurationLoader.cs +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -43,7 +43,7 @@ public static partial class ConfigurationLoader public static void Validate(AgentConfiguration value, string baseDirectory) { - if (value.SchemaVersion != 1) Throw("schema_version must be 1."); + if (value.SchemaVersion != 2) Throw("schema_version must be 2. Version 1 is no longer supported; migrate sandbox.execution to entries and run 'agent-mcp apply'."); if (string.IsNullOrWhiteSpace(value.Application.Language)) Throw("application.language is required."); if (value.Mcp.Transport is not ("stdio" or "http")) Throw("mcp.transport must be stdio or http."); if (!IPAddress.TryParse(value.Mcp.Http.Host, out IPAddress? host) || !IPAddress.IsLoopback(host)) Throw("mcp.http.host must be 127.0.0.1 or ::1."); @@ -95,10 +95,12 @@ public static partial class ConfigurationLoader if (sandbox.Profile == "readonly" && mount.Access != "read_only") Throw("readonly sandbox.profile permits only read_only mounts."); } - string[] aliases = sandbox.Execution.Executables.Concat(sandbox.Execution.Shells).ToArray(); + string[] aliases = sandbox.Execution.Entries.Select(static entry => entry.Alias).ToArray(); if (aliases.Any(alias => !AliasRegex().IsMatch(alias))) Throw("Executable Alias must match ^[a-z0-9][a-z0-9._-]{0,63}$."); if (aliases.Distinct(StringComparer.OrdinalIgnoreCase).Count() != aliases.Length) - Throw("Executable Alias values must be unique across sandbox.execution.executables and shells."); + Throw("sandbox.execution.entries Alias values must be unique."); + if (sandbox.Execution.Entries.Any(static entry => string.IsNullOrWhiteSpace(entry.Path))) + Throw("sandbox.execution.entries requires path for every Alias."); if (sandbox.Network.Access is not ("none" or "local" or "restricted")) Throw("sandbox.network.access must be none, local, or restricted."); @@ -125,7 +127,10 @@ public static partial class ConfigurationLoader YamlNode root = LoadRoot(yaml, "merged configuration"); if (root is not YamlMappingNode rootMap || !TryGet(rootMap, "sandbox", out YamlNode? sandboxNode) || sandboxNode is not YamlMappingNode sandbox) return; if (TryGet(sandbox, "filesystem", out _) || TryGet(sandbox, "process", out _)) - Throw("Legacy sandbox.filesystem/sandbox.process configuration is no longer supported. Migrate to sandbox.profile, workspaces, mounts, execution, network, and resources; then run 'sandbox resolve' and 'sandbox confirm'."); + Throw("Legacy sandbox.filesystem/sandbox.process configuration is no longer supported. Migrate to schema_version 2 and sandbox.execution.entries; then run 'agent-mcp apply'."); + if (TryGet(sandbox, "execution", out YamlNode? executionNode) && executionNode is YamlMappingNode execution && + (TryGet(execution, "executables", out _) || TryGet(execution, "shells", out _))) + Throw("schema_version 1 sandbox.execution.executables/shells is no longer supported. Migrate each Alias to sandbox.execution.entries with path and optional runtime_root; then run 'agent-mcp apply'."); } private static bool TryGet(YamlMappingNode map, string name, out YamlNode? value) diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index b58c914..7372a13 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -12,7 +12,6 @@ using Agent.Core.Tools; using Agent.Windows.Recovery; using Agent.Windows.Security; using Agent.Windows.Tools; -using System.Text.Json; namespace Agent.App; @@ -27,40 +26,22 @@ internal static class Program ? Path.Combine(AppContext.BaseDirectory, "agent.yaml") : Path.GetFullPath(commandLine.ConfigPath); AgentConfiguration configuration = ConfigurationLoader.Load(configPath); - if (commandLine.Transport is not null) configuration.Mcp.Transport = commandLine.Transport; ConfigurationLoader.Validate(configuration, Path.GetDirectoryName(configPath)!); - string language = commandLine.Language ?? configuration.Application.Language ?? "en"; + string language = configuration.Application.Language ?? "en"; string baseDirectory = Path.GetDirectoryName(configPath)!; string localeDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Localization.Directory); LocaleCatalog locale = LocaleCatalog.Load(localeDirectory, language, configuration.Localization.Fallback); _ = FixedPrompt.Load(); string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory); - if (commandLine.Command is not (AppCommand.Serve or AppCommand.Check)) - return RunManagementCommand(commandLine, configuration, configPath, logDirectory, locale); + if (commandLine.Command is AppCommand.Apply or AppCommand.Show) + return RunUnifiedManagementCommand(commandLine, configuration, configPath, logDirectory, locale); string stateDirectory = Path.Combine(baseDirectory, "state"); string journalDirectory = Path.Combine(stateDirectory, "journals"); string backupDirectory = Path.Combine(stateDirectory, "backups"); - EffectiveSandboxPolicy effectiveSandboxPolicy = new SandboxEnvironmentResolver(configPath, configuration) + EffectiveSandboxPolicy effectiveSandboxPolicy = new AppliedSandboxConfiguration(configPath, configuration) .Compile(journalDirectory, backupDirectory); - if (commandLine.Command == AppCommand.Check) - { - WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run(); - Console.Out.WriteLine($"administrator={platform.IsAdministrator.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"bfe_engine_open={platform.BfeEngineOpen.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"wfp_write_transaction={platform.WfpWriteTransaction.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"wfp_filter_add_delete={platform.WfpFilterAddDelete.ToString().ToLowerInvariant()}"); - if (!platform.Success) - { - string detail = $"{locale.Get("check.wfp_admin_required")} error_code={platform.ErrorCode}; operation={platform.ErrorOperation}"; - string failure = locale.Format("check.failure", new Dictionary<string, object?> { ["message"] = detail }); - Console.Error.WriteLine(failure); - return 2; - } - Console.Out.WriteLine(locale.Get("check.success")); - return 0; - } UserOnlyDirectory.Ensure(logDirectory); using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level); @@ -130,8 +111,6 @@ internal static class Program internal static string FormatStartupError(string[] args, Exception error) { string language = "en"; - for (int i = 0; i + 1 < args.Length; i++) - if (args[i] == "--lang") language = args[i + 1]; try { string directory = Path.Combine(AppContext.BaseDirectory, "config", "locales"); @@ -158,12 +137,7 @@ internal static class Program { string? key = error.Code switch { - "sandbox.resolution_unconfirmed" => "sandbox.error.resolution_unconfirmed", - "sandbox.resolution_changed" => "sandbox.error.resolution_changed", - "executable.approval_missing" => "sandbox.error.approval_missing", - "executable.resolution_changed" => "sandbox.error.executable_resolution_changed", - "executable.hash_changed" => "sandbox.error.executable_hash_changed", - "executable.shell_unsupported" => "sandbox.error.shell_unsupported", + "sandbox.configuration_not_applied" => "sandbox.error.configuration_not_applied", _ => null }; string localized = key is null ? error.Message : locale.Format(key, new Dictionary<string, object?> { ["target"] = error.Target ?? "-" }); @@ -171,6 +145,75 @@ internal static class Program return $"code={error.Code}; category={error.Category}; target={error.Target ?? "-"}; message={localized}; resolution={resolution}"; } + private static int RunUnifiedManagementCommand(CommandLine command, AgentConfiguration configuration, + string configPath, string logDirectory, LocaleCatalog locale) + { + var appliedConfiguration = new AppliedSandboxConfiguration(configPath, configuration); + AppliedConfigurationPreview preview = appliedConfiguration.Resolve(); + AppliedConfigurationDocument? applied; + try { applied = appliedConfiguration.LoadApplied(); } + catch (ConfigurationException error) when (command.Command == AppCommand.Apply) + { + applied = null; + Console.Out.WriteLine(locale.Format("apply.store_unreadable", new Dictionary<string, object?> { ["message"] = error.Message })); + } + bool current = appliedConfiguration.IsApplied(preview, applied); + PrintAppliedState(preview, applied, current, locale); + if (command.Command == AppCommand.Show) return 0; + + Console.Out.WriteLine("\n" + locale.Get(current ? "apply.no_changes" : "apply.replace_snapshot")); + ConfirmMutation(command.Yes, locale.Get("apply.prompt")); + UserOnlyDirectory.Ensure(logDirectory); + AppliedConfigurationDocument document = appliedConfiguration.Apply(); + var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); + audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.configuration.apply", "applied", + new Dictionary<string, string> + { + ["config_fingerprint"] = document.ConfigFingerprint, + ["execution_entries"] = document.Execution.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["non_interactive"] = command.Yes.ToString() + })); + Console.Out.WriteLine("\n" + locale.Get("apply.success")); + return 0; + } + + private static void PrintAppliedState(AppliedConfigurationPreview preview, AppliedConfigurationDocument? applied, + bool current, LocaleCatalog locale) + { + Console.Out.WriteLine(locale.Format("show.configuration", new Dictionary<string, object?> { ["path"] = preview.ConfigPath })); + Console.Out.WriteLine(locale.Format("show.applied", new Dictionary<string, object?> { ["value"] = locale.Get(current ? "show.yes" : "show.no") })); + Console.Out.WriteLine(locale.Format("show.fingerprint", new Dictionary<string, object?> { ["value"] = locale.Get(applied is null ? "show.not_applied" : current ? "show.matched" : "show.changed") })); + Console.Out.WriteLine(locale.Get("show.platform")); + Console.Out.WriteLine("\n" + locale.Get("show.filesystem")); + Console.Out.WriteLine(locale.Get("show.filesystem_columns")); + foreach (ResolutionItem item in preview.Filesystem) + Console.Out.WriteLine($"{item.Kind}\t{item.LogicalPath}\t{item.ResolvedPath}\t{item.Access}\t{locale.Get("show.status." + FilesystemStatus(item, applied))}"); + foreach (ResolutionItem item in applied?.Filesystem.Except(preview.Filesystem) ?? []) + Console.Out.WriteLine($"{item.Kind}\t{item.LogicalPath}\t{item.ResolvedPath}\t{item.Access}\t{locale.Get("show.status.removed")}"); + Console.Out.WriteLine("\n" + locale.Get("show.execution")); + Console.Out.WriteLine(locale.Get("show.execution_columns")); + foreach (AppliedExecutionEntry item in preview.Execution) + Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.ResolvedRuntimeRoot ?? "-"}\t{item.Sha256}\t{locale.Get("show.status." + ExecutionStatus(item, applied))}"); + foreach (AppliedExecutionEntry item in applied?.Execution.Where(old => preview.Execution.All(now => !now.Alias.Equals(old.Alias, StringComparison.OrdinalIgnoreCase))) ?? []) + Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.ResolvedRuntimeRoot ?? "-"}\t{item.Sha256}\t{locale.Get("show.status.removed")}"); + Console.Out.WriteLine("\n" + locale.Get("show.network")); + string networkStatus = applied is not null && AppliedSandboxConfiguration.NetworkEquivalent(applied.Network, preview.Network) ? "active" : applied is null ? "added" : "changed"; + Console.Out.WriteLine($"access={preview.Network.Access} endpoints={string.Join(',', preview.Network.Endpoints.Select(item => $"{item.Host}:{string.Join('/', item.Ports)}"))} status={locale.Get("show.status." + networkStatus)}"); + Console.Out.WriteLine("\n" + locale.Get("show.resources")); + string resourceStatus = applied?.Resources == preview.Resources ? "active" : applied is null ? "added" : "changed"; + Console.Out.WriteLine($"profile={preview.Resources.Profile} memory_mb={preview.Resources.MemoryMb} process_limit={preview.Resources.ProcessLimit} timeout_seconds={preview.Resources.ExecutionTimeoutSeconds} status={locale.Get("show.status." + resourceStatus)}"); + } + + private static string FilesystemStatus(ResolutionItem item, AppliedConfigurationDocument? applied) => + applied is null ? "added" : applied.Filesystem.Contains(item) ? "active" : "changed"; + + private static string ExecutionStatus(AppliedExecutionEntry item, AppliedConfigurationDocument? applied) + { + if (applied is null) return "added"; + AppliedExecutionEntry? previous = applied.Execution.FirstOrDefault(old => old.Alias.Equals(item.Alias, StringComparison.OrdinalIgnoreCase)); + return previous is null ? "added" : previous == item ? "active" : "changed"; + } + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore = null) { Directory.CreateDirectory(effectiveSandboxPolicy.JournalDirectory); @@ -197,109 +240,6 @@ internal static class Program MaxTasks: configuration.Planner.MaxTasks, Model: configuration.Ollama.Model); - private static int RunManagementCommand(CommandLine command, AgentConfiguration configuration, string configPath, string logDirectory, LocaleCatalog locale) - { - var resolver = new SandboxEnvironmentResolver(configPath, configuration); - var approvals = new ExecutableApprovalStore(); - var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); - switch (command.Command) - { - case AppCommand.ExecutableResolve: - { - IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates(command.Alias!, ResolveCliPath(command.ExecutablePath, configPath)); - foreach (string candidate in candidates) Console.Out.WriteLine(candidate); - if (candidates.Count > 1) Console.Out.WriteLine(locale.Get("management.ambiguous_candidates")); - return 0; - } - case AppCommand.ExecutableApprove: - { - UserOnlyDirectory.Ensure(logDirectory); - EnsureConfiguredAlias(configuration, command.Alias!, out bool shell); - if (command.RuntimeRoot is null && !command.NoRuntime) - throw new CommandLineException("Specify either --runtime-root <path> or --no-runtime when approving an Executable Alias."); - IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates(command.Alias!, ResolveCliPath(command.ExecutablePath, configPath)); - if (candidates.Count != 1) - throw new ConfigurationException($"Executable Alias '{command.Alias}' is ambiguous. Re-run with --path and one of:{Environment.NewLine}{string.Join(Environment.NewLine, candidates)}"); - string resolved = candidates[0]; - if (shell && !ExecutableResolver.IsSupportedShell(resolved)) - throw new ConfigurationException($"Shell Alias '{command.Alias}' must resolve to cmd.exe, powershell.exe, or pwsh.exe.", - code: "executable.shell_unsupported", category: "sandbox", target: resolved, - resolution: "Select a supported Shell executable and approve it explicitly."); - string? runtimeRoot = command.RuntimeRoot is null ? null : ResolvedPath.Resolve(ResolveCliPath(command.RuntimeRoot, configPath)!); - if (runtimeRoot is not null && !Directory.Exists(runtimeRoot)) throw new ConfigurationException($"Runtime Root is not a directory: '{runtimeRoot}'."); - string sha256 = EntryExecutablePolicy.ComputeSha256(resolved); - Console.Out.WriteLine(locale.Format("management.approval_diff", new Dictionary<string, object?> - { - ["alias"] = command.Alias, ["classification"] = shell ? "shell" : "executable", - ["path"] = resolved, ["sha256"] = sha256, ["runtime_root"] = runtimeRoot ?? "(none)" - })); - ConfirmMutation(command.Yes, locale.Get("management.approve_prompt")); - approvals.Approve(new(command.Alias!, shell ? "shell" : "executable", command.ExecutablePath ?? resolved, - resolved, sha256, runtimeRoot, DateTimeOffset.UtcNow)); - audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "executable.approval.add", "approved", - new Dictionary<string, string> { ["alias"] = command.Alias!, ["resolved_path"] = resolved, ["non_interactive"] = command.Yes.ToString() })); - return 0; - } - case AppCommand.ExecutableList: - foreach (ExecutableApprovalRecord item in approvals.List()) - Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.Sha256}\t{item.RuntimeRoot ?? "-"}"); - return 0; - case AppCommand.ExecutableRevoke: - { - UserOnlyDirectory.Ensure(logDirectory); - ExecutableApprovalRecord record = approvals.Find(command.Alias!) ?? throw new ConfigurationException($"Executable Alias '{command.Alias}' is not approved."); - Console.Out.WriteLine(locale.Format("management.revoke_diff", new Dictionary<string, object?> { ["alias"] = record.Alias, ["path"] = record.ResolvedPath })); - ConfirmMutation(command.Yes, locale.Get("management.revoke_prompt")); - _ = approvals.Revoke(record.Alias); - audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "executable.approval.remove", "revoked", - new Dictionary<string, string> { ["alias"] = record.Alias, ["resolved_path"] = record.ResolvedPath, ["non_interactive"] = command.Yes.ToString() })); - return 0; - } - case AppCommand.SandboxResolve: - PrintResolution(resolver.Resolve(), "text", locale); - return 0; - case AppCommand.SandboxConfirm: - { - UserOnlyDirectory.Ensure(logDirectory); - SandboxResolutionPreview preview = resolver.Resolve(); - PrintResolution(preview, "text", locale); - ConfirmMutation(command.Yes, locale.Get("management.sandbox_confirm_prompt")); - SandboxResolutionDocument document = resolver.Confirm(); - audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.resolution.confirm", "confirmed", - new Dictionary<string, string> { ["config_fingerprint"] = document.ConfigFingerprint, ["non_interactive"] = command.Yes.ToString() })); - return 0; - } - case AppCommand.SandboxShow: - { - SandboxResolutionPreview preview = resolver.Resolve(); - SandboxResolutionDocument? confirmed = new SandboxResolutionStore(configPath).Load(); - bool isConfirmed = confirmed is not null && confirmed.ConfigFingerprint == preview.Fingerprint; - EffectiveSandboxPolicy? policy = isConfirmed - ? resolver.Compile(Path.Combine(Path.GetDirectoryName(configPath)!, "state", "journals"), - Path.Combine(Path.GetDirectoryName(configPath)!, "state", "backups")) - : null; - object? effective = policy is null ? null : EffectivePolicyView(policy); - if (command.Format == "json") Console.Out.WriteLine(JsonSerializer.Serialize(new { current = preview, confirmed, effective_policy = effective }, new JsonSerializerOptions { WriteIndented = true })); - else - { - PrintResolution(preview, "text", locale); - Console.Out.WriteLine(locale.Format("management.confirmed", new Dictionary<string, object?> - { ["value"] = locale.Get(isConfirmed ? "management.yes" : "management.no") })); - if (effective is not null) Console.Out.WriteLine(JsonSerializer.Serialize(effective, new JsonSerializerOptions { WriteIndented = true })); - } - return 0; - } - default: throw new CommandLineException("Unsupported management command."); - } - } - - private static void EnsureConfiguredAlias(AgentConfiguration configuration, string alias, out bool shell) - { - bool executable = configuration.Sandbox.Execution.Executables.Contains(alias, StringComparer.OrdinalIgnoreCase); - shell = configuration.Sandbox.Execution.Shells.Contains(alias, StringComparer.OrdinalIgnoreCase); - if (!executable && !shell) throw new ConfigurationException($"Executable Alias '{alias}' is not declared in sandbox.execution.executables or sandbox.execution.shells."); - } - private static void ConfirmMutation(bool yes, string prompt) { if (yes) return; @@ -308,43 +248,4 @@ internal static class Program if (!string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase)) throw new CommandLineException("Operation cancelled."); } - private static void PrintResolution(SandboxResolutionPreview preview, string format, LocaleCatalog locale) - { - if (format == "json") { Console.Out.WriteLine(JsonSerializer.Serialize(preview, new JsonSerializerOptions { WriteIndented = true })); return; } - Console.Out.WriteLine(locale.Format("management.resolution_header", new Dictionary<string, object?> - { ["config"] = preview.ConfigPath, ["fingerprint"] = preview.Fingerprint })); - foreach (ResolutionItem item in preview.Items) - Console.Out.WriteLine($"{item.Kind}\t{item.Access}\t{item.LogicalPath} -> {item.ResolvedPath}"); - } - - private static string? ResolveCliPath(string? path, string configPath) => path is null ? null : - (Path.IsPathFullyQualified(path) ? path : Path.Combine(Path.GetDirectoryName(configPath)!, path)); - - private static object EffectivePolicyView(EffectiveSandboxPolicy policy) => new - { - filesystem_roots = policy.Files.Select(item => new { resolved_path = item.Path, capabilities = item.Capabilities.ToString(), origin = item.Origin }), - approved_entry_executables = policy.EntryExecutables.Select(item => new - { - alias = item.Alias, - resolved_path = item.ResolvedPath, - sha256 = item.Sha256, - classification = item.IsShell ? "approved_shell_executable" : "approved_entry_executable", - runtime_root = item.RuntimeRoot - }), - runtime_roots = policy.RuntimeRoots, - artifact_roots = policy.ArtifactRoots, - network = new - { - access = policy.NetworkAccess, - endpoints = policy.NetworkAllowances.Select(item => new { host = item.Host, ports = item.Ports }) - }, - resources = new - { - profile = policy.ResourceProfile, - process_limit = policy.JobLimits.MaxProcesses, - memory_bytes = policy.JobLimits.MaxJobMemoryBytes, - execution_timeout_seconds = policy.ProcessTimeoutSeconds - }, - baseline_capabilities = policy.BaselineCapabilities ?? ["registryRead", "lpacInstrumentation"] - }; } diff --git a/src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs b/src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs new file mode 100644 index 0000000..d789dc3 --- /dev/null +++ b/src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs @@ -0,0 +1,267 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Agent.App.Configuration; +using Agent.Windows.Security; +using Agent.Windows.Tools; + +namespace Agent.App.Sandbox; + +public sealed record AppliedExecutionEntry( + string Alias, + string Classification, + string LogicalPath, + string ResolvedPath, + string Sha256, + string? LogicalRuntimeRoot, + string? ResolvedRuntimeRoot); + +public sealed record AppliedNetworkEndpoint(string Host, IReadOnlyList<int> Ports); +public sealed record AppliedNetworkConfiguration(string Access, IReadOnlyList<AppliedNetworkEndpoint> Endpoints); +public sealed record AppliedResourceConfiguration(string Profile, int MemoryMb, int ProcessLimit, int ExecutionTimeoutSeconds); + +public sealed record AppliedConfigurationPreview( + string ConfigPath, + string ConfigFingerprint, + IReadOnlyList<ResolutionItem> Filesystem, + IReadOnlyList<AppliedExecutionEntry> Execution, + AppliedNetworkConfiguration Network, + AppliedResourceConfiguration Resources); + +public sealed record AppliedConfigurationDocument( + int SchemaVersion, + string ConfigPath, + string ConfigFingerprint, + IReadOnlyList<ResolutionItem> Filesystem, + IReadOnlyList<AppliedExecutionEntry> Execution, + AppliedNetworkConfiguration Network, + AppliedResourceConfiguration Resources, + DateTimeOffset AppliedAt); + +public sealed class AppliedConfigurationStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + private readonly bool _protectDirectory; + public string StorePath { get; } + + public AppliedConfigurationStore(string configPath, string? storeRoot = null) : this(configPath, storeRoot, true) { } + + internal AppliedConfigurationStore(string configPath, string? storeRoot, bool protectDirectory) + { + string normalized = Path.GetFullPath(configPath).ToUpperInvariant(); + string name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))) + ".json"; + StorePath = Path.Combine(storeRoot ?? Path.Combine(UserStoreRoot(), "applied-configurations"), name); + _protectDirectory = protectDirectory; + } + + public AppliedConfigurationDocument? Load() + { + if (!File.Exists(StorePath)) return null; + AppliedConfigurationDocument? document = JsonSerializer.Deserialize<AppliedConfigurationDocument>(File.ReadAllText(StorePath), JsonOptions); + if (document is null || document.SchemaVersion != 2 || !Path.IsPathFullyQualified(document.ConfigPath) || + !Regex.IsMatch(document.ConfigFingerprint, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) || + document.Filesystem.Any(static item => item.Kind is not ("workspace" or "mount") || !Path.IsPathFullyQualified(item.ResolvedPath)) || + document.Execution.GroupBy(static item => item.Alias, StringComparer.OrdinalIgnoreCase).Any(static group => group.Count() != 1) || + document.Execution.Any(static item => !Regex.IsMatch(item.Alias, "^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant) || + item.Classification is not ("executable" or "shell") || !Path.IsPathFullyQualified(item.ResolvedPath) || + !Regex.IsMatch(item.Sha256, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) || + (item.ResolvedRuntimeRoot is not null && !Path.IsPathFullyQualified(item.ResolvedRuntimeRoot))) || + document.Network is null || document.Resources is null || + document.Network.Access is not ("none" or "local" or "restricted") || + document.Network.Endpoints.Any(static item => string.IsNullOrWhiteSpace(item.Host) || item.Ports.Count == 0 || item.Ports.Any(static port => port is < 1 or > 65535)) || + document.Resources.MemoryMb <= 0 || document.Resources.ProcessLimit <= 0 || document.Resources.ExecutionTimeoutSeconds <= 0) + throw new ConfigurationException($"Applied Configuration Store contains an invalid record: '{StorePath}'."); + return document; + } + + public void Save(AppliedConfigurationDocument document) + { + string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Applied Configuration Store path has no directory."); + if (_protectDirectory) UserOnlyDirectory.Ensure(directory); else Directory.CreateDirectory(directory); + AtomicJson.Write(StorePath, document, JsonOptions); + } + + internal static string UserStoreRoot() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AgentSandboxMcp"); +} + +public sealed class AppliedSandboxConfiguration +{ + private readonly string _configPath; + private readonly string _baseDirectory; + private readonly AgentConfiguration _configuration; + private readonly AppliedConfigurationStore _store; + + public AppliedSandboxConfiguration(string configPath, AgentConfiguration configuration, AppliedConfigurationStore? store = null) + { + _configPath = Path.GetFullPath(configPath); + _baseDirectory = Path.GetDirectoryName(_configPath)!; + _configuration = configuration; + _store = store ?? new AppliedConfigurationStore(_configPath); + } + + public AppliedConfigurationPreview Resolve() + { + var filesystem = new List<ResolutionItem>(); + foreach (string workspace in _configuration.Sandbox.Workspaces) + filesystem.Add(ResolveDirectory("workspace", workspace, + _configuration.Sandbox.Profile == "development" ? "read_write_delete_execute" : "read_only")); + foreach (MountOptions mount in _configuration.Sandbox.Mounts) + filesystem.Add(ResolveDirectory("mount", mount.Path, mount.Access)); + + var execution = new List<AppliedExecutionEntry>(); + foreach (ExecutionEntryOptions entry in _configuration.Sandbox.Execution.Entries) + { + string logicalPath = Absolute(entry.Path); + string resolvedPath = ResolvedPath.Resolve(logicalPath); + if (!File.Exists(resolvedPath)) throw Error("sandbox.resolved_executable_missing", + $"Execution Entry '{entry.Alias}' does not resolve to an existing file: '{resolvedPath}'.", resolvedPath); + string? logicalRuntime = entry.RuntimeRoot is null ? null : Absolute(entry.RuntimeRoot); + string? resolvedRuntime = logicalRuntime is null ? null : ResolvedPath.Resolve(logicalRuntime); + if (resolvedRuntime is not null && !Directory.Exists(resolvedRuntime)) throw Error("sandbox.resolved_runtime_missing", + $"Execution Entry '{entry.Alias}' Runtime Root is not a directory: '{resolvedRuntime}'.", resolvedRuntime); + string classification = ExecutableResolver.IsSupportedShell(resolvedPath) ? "shell" : "executable"; + execution.Add(new(entry.Alias, classification, entry.Path, resolvedPath, + EntryExecutablePolicy.ComputeSha256(resolvedPath), entry.RuntimeRoot, resolvedRuntime)); + } + (int memoryMb, int processes, int timeout) = ResourceLimits(); + var network = new AppliedNetworkConfiguration(_configuration.Sandbox.Network.Access, + NetworkAllowances().Select(static item => new AppliedNetworkEndpoint(item.Host, item.Ports)).ToArray()); + return new(_configPath, Fingerprint(), filesystem, execution, network, + new(_configuration.Sandbox.Resources.Profile, memoryMb, processes, timeout)); + } + + public AppliedConfigurationDocument Apply() + { + AppliedConfigurationPreview preview = Resolve(); + var document = new AppliedConfigurationDocument(2, preview.ConfigPath, preview.ConfigFingerprint, + preview.Filesystem, preview.Execution, preview.Network, preview.Resources, DateTimeOffset.UtcNow); + _store.Save(document); + return document; + } + + public AppliedConfigurationDocument? LoadApplied() => _store.Load(); + + public bool IsApplied(AppliedConfigurationPreview current, AppliedConfigurationDocument? applied) => + applied is not null && applied.ConfigPath.Equals(current.ConfigPath, StringComparison.OrdinalIgnoreCase) && + applied.ConfigFingerprint.Equals(current.ConfigFingerprint, StringComparison.Ordinal) && + applied.Filesystem.SequenceEqual(current.Filesystem) && applied.Execution.SequenceEqual(current.Execution) && + NetworkEquivalent(applied.Network, current.Network) && applied.Resources == current.Resources; + + public EffectiveSandboxPolicy Compile(string journalDirectory, string backupDirectory) + { + AppliedConfigurationPreview current = Resolve(); + AppliedConfigurationDocument? applied = _store.Load(); + if (!IsApplied(current, applied)) + throw Error("sandbox.configuration_not_applied", "Configuration is not applied or has changed.", _configPath, + "Review the consolidated permission matrix and run 'agent-mcp apply'."); + + var files = new List<FilePolicyEntry>(); + var artifactRoots = new List<string>(); + foreach (ResolutionItem item in current.Filesystem) + { + FileCapability capabilities = item.Kind == "workspace" + ? (_configuration.Sandbox.Profile == "development" + ? FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute + : FileCapability.Read) + : item.Access switch + { + "read_only" => FileCapability.Read, + "read_write" => FileCapability.Read | FileCapability.Write, + "read_write_delete" => FileCapability.Read | FileCapability.Write | FileCapability.Delete, + _ => throw new ConfigurationException($"Unsupported Mount access '{item.Access}'.") + }; + files.Add(new(item.ResolvedPath, capabilities, item.Kind)); + if (item.Kind == "workspace" && _configuration.Sandbox.Profile == "development") artifactRoots.Add(item.ResolvedPath); + } + + var approvals = new List<ApprovedEntryExecutable>(); + var runtimeRoots = new List<string>(); + foreach (AppliedExecutionEntry entry in current.Execution) + { + bool shell = entry.Classification == "shell"; + approvals.Add(new(entry.Alias, entry.ResolvedPath, entry.Sha256, shell, entry.ResolvedRuntimeRoot)); + files.Add(new(entry.ResolvedPath, FileCapability.Read | FileCapability.Execute, + shell ? "approved_shell_executable" : "approved_entry_executable")); + if (entry.ResolvedRuntimeRoot is not null) + { + runtimeRoots.Add(entry.ResolvedRuntimeRoot); + files.Add(new(entry.ResolvedRuntimeRoot, FileCapability.Read | FileCapability.Execute, "runtime_root")); + } + } + + string system32 = ResolvedPath.Resolve(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32")); + files.Add(new(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root")); + IReadOnlyList<FilePolicyEntry> normalizedFiles = NormalizeRoots(files); + (int memoryMb, int processes, int timeout) = ResourceLimits(); + return new(normalizedFiles, approvals, runtimeRoots.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), artifactRoots, + new((uint)processes, checked((ulong)memoryMb * 1024 * 1024)), _configuration.Sandbox.Network.Access, + NetworkAllowances(), journalDirectory, backupDirectory, + checked((long)_configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024), + _configuration.Limits.WholeReadBytes, _configuration.Limits.SearchMaxResults, + _configuration.Limits.TailMaxLines, timeout, _configuration.Sandbox.Resources.Profile, + ["registryRead", "lpacInstrumentation"]); + } + + private ResolutionItem ResolveDirectory(string kind, string logicalPath, string access) + { + string resolved = ResolvedPath.Resolve(Absolute(logicalPath)); + if (!Directory.Exists(resolved)) throw Error("sandbox.resolved_directory_missing", + $"{kind} Resolved Path is not a directory: '{resolved}'.", resolved); + return new(kind, logicalPath, resolved, access); + } + + private string Absolute(string path) => Path.IsPathFullyQualified(path) ? Path.GetFullPath(path) : Path.GetFullPath(path, _baseDirectory); + private string Fingerprint() => Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(_configuration))); + + private (int MemoryMb, int Processes, int Timeout) ResourceLimits() + { + (int memory, int processes, int timeout) = _configuration.Sandbox.Resources.Profile switch + { + "constrained" => (1024, 4, 300), "normal" => (4096, 16, 1800), "extended" => (8192, 32, 3600), + _ => throw new ConfigurationException($"Unsupported Resource Profile '{_configuration.Sandbox.Resources.Profile}'.") + }; + return (_configuration.Sandbox.Resources.MemoryMb ?? memory, + _configuration.Sandbox.Resources.ProcessLimit ?? processes, + _configuration.Sandbox.Resources.ExecutionTimeoutSeconds ?? timeout); + } + + private IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances() => + _configuration.Sandbox.Network.Access switch + { + "none" => [], + "local" => _configuration.Sandbox.Network.LocalEndpoints + .SelectMany(static item => new[] { ("127.0.0.1", (IReadOnlyList<int>)[item.Port]), ("::1", (IReadOnlyList<int>)[item.Port]) }).ToArray(), + "restricted" => _configuration.Sandbox.Network.RestrictedEndpoints + .Select(static item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(), + _ => throw new ConfigurationException($"Unsupported Network access '{_configuration.Sandbox.Network.Access}'.") + }; + + private static IReadOnlyList<FilePolicyEntry> NormalizeRoots(IEnumerable<FilePolicyEntry> input) + { + FilePolicyEntry[] roots = input.GroupBy(static item => ResolvedPath.Resolve(item.Path), StringComparer.OrdinalIgnoreCase) + .Select(static group => new FilePolicyEntry(group.Key, + group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities), + string.Join(',', group.Select(static item => item.Origin).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)))).ToArray(); + foreach (FilePolicyEntry parent in roots) + foreach (FilePolicyEntry child in roots) + if (!ReferenceEquals(parent, child) && IsBelow(child.Path, parent.Path) && + (parent.Capabilities & child.Capabilities) != parent.Capabilities && !IsDerivedExecutionRoot(child)) + throw new ConfigurationException($"Nested Filesystem Root '{child.Path}' is narrower than inherited capabilities from '{parent.Path}' and cannot be enforced without widening it."); + return roots; + } + + private static bool IsDerivedExecutionRoot(FilePolicyEntry entry) => entry.Origin.Split(',').All(static origin => + origin is "approved_entry_executable" or "approved_shell_executable" or "runtime_root" or "os_runtime_root"); + + private static bool IsBelow(string child, string parent) => child.StartsWith( + parent.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + + internal static bool NetworkEquivalent(AppliedNetworkConfiguration left, AppliedNetworkConfiguration right) => + left.Access == right.Access && left.Endpoints.Count == right.Endpoints.Count && + left.Endpoints.Zip(right.Endpoints).All(static pair => pair.First.Host == pair.Second.Host && pair.First.Ports.SequenceEqual(pair.Second.Ports)); + + private static ConfigurationException Error(string code, string message, string? target, string? resolution = null) => + new(message, code: code, category: "sandbox", target: target, resolution: resolution ?? "Correct the YAML and run 'agent-mcp apply'."); +} diff --git a/src/Agent.App/Sandbox/SandboxServices.cs b/src/Agent.App/Sandbox/SandboxServices.cs index 93211b8..746b95b 100644 --- a/src/Agent.App/Sandbox/SandboxServices.cs +++ b/src/Agent.App/Sandbox/SandboxServices.cs @@ -1,342 +1,13 @@ -using System.Security.Cryptography; -using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; -using Agent.App.Configuration; -using Agent.Windows.Security; -using Agent.Windows.Tools; namespace Agent.App.Sandbox; -public sealed record ExecutableApprovalRecord( - string Alias, - string Classification, - string LogicalPath, - string ResolvedPath, - string Sha256, - string? RuntimeRoot, - DateTimeOffset ApprovedAt); - -public sealed record ExecutableApprovalDocument(int SchemaVersion, IReadOnlyList<ExecutableApprovalRecord> Approvals); - -public sealed class ExecutableApprovalStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - public string StorePath { get; } - private readonly bool _protectDirectory; - - public ExecutableApprovalStore(string? storePath = null) : this(storePath, protectDirectory: true) { } - - internal ExecutableApprovalStore(string? storePath, bool protectDirectory) - { - StorePath = storePath ?? Path.Combine(UserStoreRoot(), "executable-approvals.json"); - _protectDirectory = protectDirectory; - } - - public IReadOnlyList<ExecutableApprovalRecord> List() => Load().Approvals; - - public ExecutableApprovalRecord? Find(string alias) => - Load().Approvals.SingleOrDefault(item => item.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase)); - - public void Approve(ExecutableApprovalRecord approval) - { - List<ExecutableApprovalRecord> records = Load().Approvals - .Where(item => !item.Alias.Equals(approval.Alias, StringComparison.OrdinalIgnoreCase)).ToList(); - records.Add(approval); - Save(new(1, records.OrderBy(item => item.Alias, StringComparer.OrdinalIgnoreCase).ToArray())); - } - - public bool Revoke(string alias) - { - ExecutableApprovalDocument current = Load(); - ExecutableApprovalRecord[] remaining = current.Approvals - .Where(item => !item.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase)).ToArray(); - if (remaining.Length == current.Approvals.Count) return false; - Save(new(1, remaining)); - return true; - } - - private ExecutableApprovalDocument Load() - { - if (!File.Exists(StorePath)) return new(1, []); - ExecutableApprovalDocument? document = JsonSerializer.Deserialize<ExecutableApprovalDocument>(File.ReadAllText(StorePath), JsonOptions); - if (document is null || document.SchemaVersion != 1) - throw new ConfigurationException($"Unsupported Executable Approval Store schema: '{StorePath}'."); - if (document.Approvals.GroupBy(item => item.Alias, StringComparer.OrdinalIgnoreCase).Any(group => group.Count() != 1)) - throw new ConfigurationException($"Executable Approval Store contains duplicate Alias records: '{StorePath}'."); - foreach (ExecutableApprovalRecord item in document.Approvals) - { - if (!Regex.IsMatch(item.Alias, "^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant) || - item.Classification is not ("executable" or "shell") || !Path.IsPathFullyQualified(item.ResolvedPath) || - !Regex.IsMatch(item.Sha256, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) || - (item.RuntimeRoot is not null && !Path.IsPathFullyQualified(item.RuntimeRoot))) - throw new ConfigurationException($"Executable Approval Store contains an invalid record for Alias '{item.Alias}': '{StorePath}'."); - } - return document; - } - - private void Save(ExecutableApprovalDocument document) - { - string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Executable Approval Store path has no directory."); - if (_protectDirectory) UserOnlyDirectory.Ensure(directory); else Directory.CreateDirectory(directory); - AtomicJson.Write(StorePath, document, JsonOptions); - } - - internal static string UserStoreRoot() => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AgentSandboxMcp"); -} - public sealed record ResolutionItem(string Kind, string LogicalPath, string ResolvedPath, string Access); -public sealed record SandboxResolutionDocument( - int SchemaVersion, - string ConfigPath, - string ConfigFingerprint, - IReadOnlyList<ResolutionItem> Items, - DateTimeOffset ConfirmedAt); - -public sealed class SandboxResolutionStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - public string StorePath { get; } - private readonly bool _protectDirectory; - - public SandboxResolutionStore(string configPath, string? storeRoot = null) : this(configPath, storeRoot, protectDirectory: true) { } - - internal SandboxResolutionStore(string configPath, string? storeRoot, bool protectDirectory) - { - string normalized = Path.GetFullPath(configPath).ToUpperInvariant(); - string name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))) + ".json"; - StorePath = Path.Combine(storeRoot ?? Path.Combine(ExecutableApprovalStore.UserStoreRoot(), "environment-resolutions"), name); - _protectDirectory = protectDirectory; - } - - public SandboxResolutionDocument? Load() - { - if (!File.Exists(StorePath)) return null; - SandboxResolutionDocument? document = JsonSerializer.Deserialize<SandboxResolutionDocument>(File.ReadAllText(StorePath), JsonOptions); - if (document is null || document.SchemaVersion != 1) - throw new ConfigurationException($"Unsupported Sandbox Environment Resolution Store schema: '{StorePath}'."); - if (!Path.IsPathFullyQualified(document.ConfigPath) || - !Regex.IsMatch(document.ConfigFingerprint, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) || - document.Items.Any(item => item.Kind is not ("workspace" or "mount") || !Path.IsPathFullyQualified(item.ResolvedPath))) - throw new ConfigurationException($"Sandbox Environment Resolution Store contains an invalid record: '{StorePath}'."); - return document; - } - - public void Save(SandboxResolutionDocument document) - { - string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Resolution Store path has no directory."); - if (_protectDirectory) UserOnlyDirectory.Ensure(directory); else Directory.CreateDirectory(directory); - AtomicJson.Write(StorePath, document, JsonOptions); - } -} - -public sealed record SandboxResolutionPreview(string ConfigPath, string Fingerprint, IReadOnlyList<ResolutionItem> Items); - -public sealed class SandboxEnvironmentResolver -{ - private readonly string _configPath; - private readonly string _baseDirectory; - private readonly AgentConfiguration _configuration; - private readonly SandboxResolutionStore _resolutionStore; - private readonly ExecutableApprovalStore _approvalStore; - - public SandboxEnvironmentResolver(string configPath, AgentConfiguration configuration, - SandboxResolutionStore? resolutionStore = null, ExecutableApprovalStore? approvalStore = null) - { - _configPath = Path.GetFullPath(configPath); - _baseDirectory = Path.GetDirectoryName(_configPath)!; - _configuration = configuration; - _resolutionStore = resolutionStore ?? new SandboxResolutionStore(_configPath); - _approvalStore = approvalStore ?? new ExecutableApprovalStore(); - } - - public SandboxResolutionPreview Resolve() - { - var items = new List<ResolutionItem>(); - foreach (string workspace in _configuration.Sandbox.Workspaces) - items.Add(ResolveDirectory("workspace", workspace, - _configuration.Sandbox.Profile == "development" ? "read_write_delete_execute" : "read_only")); - foreach (MountOptions mount in _configuration.Sandbox.Mounts) - items.Add(ResolveDirectory("mount", mount.Path, mount.Access)); - return new(_configPath, Fingerprint(), items); - } - - public SandboxResolutionDocument Confirm() - { - SandboxResolutionPreview preview = Resolve(); - var document = new SandboxResolutionDocument(1, preview.ConfigPath, preview.Fingerprint, preview.Items, DateTimeOffset.UtcNow); - _resolutionStore.Save(document); - return document; - } - - public EffectiveSandboxPolicy Compile(string journalDirectory, string backupDirectory) - { - SandboxResolutionPreview current = Resolve(); - SandboxResolutionDocument? confirmed = _resolutionStore.Load(); - if (confirmed is null) - throw Error("sandbox.resolution_unconfirmed", "Sandbox Environment Resolution is not confirmed.", _configPath, - "Run 'agent-mcp sandbox resolve --config <file>' and then 'agent-mcp sandbox confirm --config <file>'."); - if (!confirmed.ConfigPath.Equals(current.ConfigPath, StringComparison.OrdinalIgnoreCase) || - !confirmed.ConfigFingerprint.Equals(current.Fingerprint, StringComparison.Ordinal) || - !Equivalent(confirmed.Items, current.Items)) - throw Error("sandbox.resolution_changed", "Sandbox Environment Resolution changed after confirmation.", _configPath, - "Review 'sandbox resolve' and run 'sandbox confirm' again."); - - var files = new List<FilePolicyEntry>(); - var artifactRoots = new List<string>(); - foreach (ResolutionItem item in current.Items) - { - FileCapability capabilities = item.Kind == "workspace" - ? (_configuration.Sandbox.Profile == "development" - ? FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute - : FileCapability.Read) - : item.Access switch - { - "read_only" => FileCapability.Read, - "read_write" => FileCapability.Read | FileCapability.Write, - "read_write_delete" => FileCapability.Read | FileCapability.Write | FileCapability.Delete, - _ => throw new ConfigurationException($"Unsupported Mount access '{item.Access}'.") - }; - files.Add(new(item.ResolvedPath, capabilities, item.Kind)); - if (item.Kind == "workspace" && _configuration.Sandbox.Profile == "development") artifactRoots.Add(item.ResolvedPath); - } - - var approvals = new List<ApprovedEntryExecutable>(); - var runtimeRoots = new List<string>(); - foreach ((string alias, bool shell) in RequestedAliases()) - { - ExecutableApprovalRecord record = _approvalStore.Find(alias) ?? - throw Error("executable.approval_missing", $"Executable Alias '{alias}' has no approval.", alias, - $"Run 'agent-mcp executable resolve {alias} --config <file>' and 'executable approve'."); - if (shell != record.Classification.Equals("shell", StringComparison.Ordinal)) - throw Error("executable.classification_changed", $"Executable Alias '{alias}' approval classification does not match sandbox.execution configuration.", alias, - "Revoke and re-approve the Executable Alias using its configured classification."); - if (shell && !ExecutableResolver.IsSupportedShell(record.ResolvedPath)) - throw Error("executable.shell_unsupported", $"Executable Alias '{alias}' does not resolve to a supported Shell.", record.ResolvedPath, - "Select cmd.exe, powershell.exe, or pwsh.exe and explicitly approve it."); - string logicalExecutable = Path.IsPathFullyQualified(record.LogicalPath) - ? record.LogicalPath : Path.Combine(_baseDirectory, record.LogicalPath); - string resolved = ResolvedPath.Resolve(logicalExecutable); - if (!resolved.Equals(record.ResolvedPath, StringComparison.OrdinalIgnoreCase)) - throw Error("executable.resolution_changed", $"Executable Alias '{alias}' resolution changed from '{record.ResolvedPath}' to '{resolved}'.", resolved, - "Review the new Resolved Path and explicitly re-approve the Executable Alias."); - string hash = EntryExecutablePolicy.ComputeSha256(resolved); - if (!hash.Equals(record.Sha256, StringComparison.OrdinalIgnoreCase)) - throw Error("executable.hash_changed", $"Executable Alias '{alias}' SHA-256 changed.", resolved, - "Verify the Executable identity and explicitly re-approve it."); - string? runtimeRoot = record.RuntimeRoot is null ? null : ResolvedPath.Resolve(record.RuntimeRoot); - approvals.Add(new(alias, resolved, hash, shell, runtimeRoot)); - files.Add(new(resolved, FileCapability.Read | FileCapability.Execute, shell ? "approved_shell_executable" : "approved_entry_executable")); - if (runtimeRoot is not null) - { - runtimeRoots.Add(runtimeRoot); - files.Add(new(runtimeRoot, FileCapability.Read | FileCapability.Execute, "runtime_root")); - } - } - - string system32 = ResolvedPath.Resolve(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32")); - files.Add(new(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root")); - IReadOnlyList<FilePolicyEntry> normalizedFiles = NormalizeRoots(files); - (int memoryMb, int processes, int timeout) = ResourceLimits(); - IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> network = NetworkAllowances(); - return new(normalizedFiles, approvals, runtimeRoots.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), - artifactRoots, new((uint)processes, checked((ulong)memoryMb * 1024 * 1024)), - _configuration.Sandbox.Network.Access, network, journalDirectory, backupDirectory, - checked((long)_configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024), - _configuration.Limits.WholeReadBytes, _configuration.Limits.SearchMaxResults, - _configuration.Limits.TailMaxLines, timeout, _configuration.Sandbox.Resources.Profile, - ["registryRead", "lpacInstrumentation"]); - } - - private ResolutionItem ResolveDirectory(string kind, string logicalPath, string access) - { - string absolute = Path.IsPathFullyQualified(logicalPath) ? logicalPath : Path.Combine(_baseDirectory, logicalPath); - string resolved = ResolvedPath.Resolve(absolute); - if (!Directory.Exists(resolved)) throw Error("sandbox.resolved_directory_missing", $"{kind} Resolved Path is not a directory: '{resolved}'.", resolved, - "Correct the Logical Path or restore the directory, then confirm the Sandbox Environment Resolution again."); - return new(kind, logicalPath, resolved, access); - } - - private string Fingerprint() - { - string json = JsonSerializer.Serialize(_configuration.Sandbox, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))); - } - - private IEnumerable<(string Alias, bool Shell)> RequestedAliases() => - _configuration.Sandbox.Execution.Executables.Select(alias => (alias, false)) - .Concat(_configuration.Sandbox.Execution.Shells.Select(alias => (alias, true))); - - private (int MemoryMb, int Processes, int Timeout) ResourceLimits() - { - (int memory, int processes, int timeout) = _configuration.Sandbox.Resources.Profile switch - { - "constrained" => (1024, 4, 300), - "normal" => (4096, 16, 1800), - "extended" => (8192, 32, 3600), - _ => throw new ConfigurationException($"Unsupported Resource Profile '{_configuration.Sandbox.Resources.Profile}'.") - }; - return (_configuration.Sandbox.Resources.MemoryMb ?? memory, - _configuration.Sandbox.Resources.ProcessLimit ?? processes, - _configuration.Sandbox.Resources.ExecutionTimeoutSeconds ?? timeout); - } - - private IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances() => - _configuration.Sandbox.Network.Access switch - { - "none" => [], - "local" => _configuration.Sandbox.Network.LocalEndpoints - .SelectMany(item => new[] { ("127.0.0.1", (IReadOnlyList<int>)[item.Port]), ("::1", (IReadOnlyList<int>)[item.Port]) }).ToArray(), - "restricted" => _configuration.Sandbox.Network.RestrictedEndpoints - .Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(), - _ => throw new ConfigurationException($"Unsupported Network access '{_configuration.Sandbox.Network.Access}'.") - }; - - private static IReadOnlyList<FilePolicyEntry> NormalizeRoots(IEnumerable<FilePolicyEntry> input) - { - FilePolicyEntry[] roots = input.GroupBy(item => ResolvedPath.Resolve(item.Path), StringComparer.OrdinalIgnoreCase) - .Select(group => new FilePolicyEntry(group.Key, - group.Aggregate(FileCapability.None, (value, item) => value | item.Capabilities), - string.Join(',', group.Select(item => item.Origin).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)))).ToArray(); - foreach (FilePolicyEntry parent in roots) - foreach (FilePolicyEntry child in roots) - { - if (ReferenceEquals(parent, child) || !IsBelow(child.Path, parent.Path)) continue; - if ((parent.Capabilities & child.Capabilities) != parent.Capabilities) - throw new ConfigurationException($"Nested Filesystem Root '{child.Path}' is narrower than inherited capabilities from '{parent.Path}' and cannot be enforced without widening it."); - } - return roots; - } - - private static bool Equivalent(IReadOnlyList<ResolutionItem> left, IReadOnlyList<ResolutionItem> right) => - left.OrderBy(Key, StringComparer.OrdinalIgnoreCase).SequenceEqual(right.OrderBy(Key, StringComparer.OrdinalIgnoreCase)); - private static string Key(ResolutionItem item) => $"{item.Kind}|{item.LogicalPath}|{item.ResolvedPath}|{item.Access}"; - private static bool IsBelow(string child, string parent) => child.StartsWith(parent.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); - private static ConfigurationException Error(string code, string message, string? target, string resolution) => - new(message, code: code, category: "sandbox", target: target, resolution: resolution); -} public static class ExecutableResolver { - public static bool IsSupportedShell(string path) => Path.GetFileName(path).ToLowerInvariant() is "cmd.exe" or "powershell.exe" or "pwsh.exe"; - - public static IReadOnlyList<string> ResolveCandidates(string alias, string? explicitPath) - { - if (!string.IsNullOrWhiteSpace(explicitPath)) - { - string resolved = ResolvedPath.Resolve(explicitPath); - if (!File.Exists(resolved)) throw new ConfigurationException($"Executable Resolved Path is not a file: '{resolved}'."); - return [resolved]; - } - string fileName = Path.HasExtension(alias) ? alias : alias + ".exe"; - string[] candidates = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(directory => Path.Combine(directory, fileName)).Where(File.Exists) - .Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - if (candidates.Length == 0) throw new ConfigurationException($"No PATH candidate was found for Executable Alias '{alias}'. Use --path <path>."); - return candidates; - } + public static bool IsSupportedShell(string path) => + Path.GetFileName(path).ToLowerInvariant() is "cmd.exe" or "powershell.exe" or "pwsh.exe"; } internal static class AtomicJson diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs index 02306fd..f42569a 100644 --- a/src/Agent.Windows/Security/ExecutablePolicy.cs +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -33,7 +33,7 @@ public sealed class EntryExecutablePolicy( if (_artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.WorkspaceArtifact); throw new SandboxPolicyException("process.entry_unknown", $"External Unknown Executable cannot be started as an Entry Process: '{resolved}'.", - "process", resolved, "execute", "Configure an Executable Alias and approve it, or place the Workspace Artifact in a development Workspace."); + "process", resolved, "execute", "Add an execution.entries item to YAML and run agent-mcp apply, or place the Workspace Artifact in a development Workspace."); } public string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments, string? baseDirectory = null) @@ -77,7 +77,7 @@ public sealed class EntryExecutablePolicy( string actualHash = ComputeSha256(resolved); if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approval.Sha256), Convert.FromHexString(actualHash))) throw new SandboxPolicyException("process.hash_denied", $"Approved Entry Executable SHA-256 changed: '{resolved}'.", - "process", resolved, "execute", $"Re-run executable approve for Alias '{approval.Alias}'."); + "process", resolved, "execute", $"Review Alias '{approval.Alias}' in YAML and run agent-mcp apply."); return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias); } diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs index 954792b..0e5ec61 100644 --- a/tests/Agent.Core.Tests/ConfigurationTests.cs +++ b/tests/Agent.Core.Tests/ConfigurationTests.cs @@ -16,7 +16,7 @@ public sealed class ConfigurationTests using var directory = new TestDirectory(); string config = CreateValidConfiguration(directory); AgentConfiguration result = ConfigurationLoader.Load(config); - Assert.Equal(1, result.SchemaVersion); + Assert.Equal(2, result.SchemaVersion); Assert.Equal("model", result.Ollama.Model); Assert.Equal("none", result.Sandbox.Network.Access); } @@ -35,7 +35,7 @@ public sealed class ConfigurationTests { using var directory = new TestDirectory(); string config = CreateValidConfiguration(directory); - System.IO.File.WriteAllText(config, System.IO.File.ReadAllText(config).Replace("schema_version: 1", "schema_version: 2", StringComparison.Ordinal)); + System.IO.File.WriteAllText(config, System.IO.File.ReadAllText(config).Replace("schema_version: 2", "schema_version: 1", StringComparison.Ordinal)); Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); } @@ -70,20 +70,23 @@ public sealed class ConfigurationTests } [Fact] - public void CommandLineLanguageOverrideIsCaptured() + public void Removed_commands_and_configuration_overrides_are_rejected() { - CommandLine result = CommandLine.Parse(["serve", "--lang", "ja", "--transport", "http"]); - Assert.Equal("ja", result.Language); - Assert.Equal("http", result.Transport); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["serve"])); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["check"])); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["executable", "list"])); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["sandbox", "show"])); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["--lang", "ja"])); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["--transport", "http"])); } [Fact] - public void CommandLine_defaults_to_serve_when_command_is_omitted() + public void CommandLine_starts_server_when_command_is_omitted() { - Assert.Equal(AppCommand.Serve, CommandLine.Parse([]).Command); - CommandLine withOption = CommandLine.Parse(["--lang", "ja"]); - Assert.Equal(AppCommand.Serve, withOption.Command); - Assert.Equal("ja", withOption.Language); + Assert.Equal(AppCommand.Start, CommandLine.Parse([]).Command); + CommandLine withPath = CommandLine.Parse(["custom.yaml"]); + Assert.Equal(AppCommand.Start, withPath.Command); + Assert.Equal("custom.yaml", withPath.ConfigPath); } [Fact] @@ -130,7 +133,7 @@ public sealed class ConfigurationTests ConfigurationException error = Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); Assert.Contains("Legacy sandbox.filesystem/sandbox.process", error.Message, StringComparison.Ordinal); - Assert.Contains("sandbox resolve", error.Message, StringComparison.Ordinal); + Assert.Contains("agent-mcp apply", error.Message, StringComparison.Ordinal); } [Fact] @@ -155,38 +158,17 @@ public sealed class ConfigurationTests } [Fact] - public void Management_commands_require_config_and_capture_options() + public void Apply_and_show_accept_only_a_positional_config_path_and_apply_yes() { - Assert.Throws<CommandLineException>(() => CommandLine.Parse(["sandbox", "resolve"])); - CommandLine approve = CommandLine.Parse(["executable", "approve", "python", "--path", "C:/python.exe", "--no-runtime", "--yes", "--config", "agent.yaml"]); - Assert.Equal(AppCommand.ExecutableApprove, approve.Command); - Assert.Equal("python", approve.Alias); - Assert.True(approve.NoRuntime); - Assert.True(approve.Yes); - } - - [Fact] - public void Executable_alias_ambiguity_is_reported_without_automatic_selection() - { - using var directory = new TestDirectory(); - string first = Path.Combine(directory.Path, "first"); - string second = Path.Combine(directory.Path, "second"); - Directory.CreateDirectory(first); - Directory.CreateDirectory(second); - File.WriteAllText(Path.Combine(first, "fixture.exe"), "first"); - File.WriteAllText(Path.Combine(second, "fixture.exe"), "second"); - string? original = Environment.GetEnvironmentVariable("PATH"); - try - { - Environment.SetEnvironmentVariable("PATH", string.Join(Path.PathSeparator, first, second)); - - IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates("fixture", null); - - Assert.Equal(2, candidates.Count); - Assert.Contains(candidates, item => item.Equals(Path.Combine(first, "fixture.exe"), StringComparison.OrdinalIgnoreCase)); - Assert.Contains(candidates, item => item.Equals(Path.Combine(second, "fixture.exe"), StringComparison.OrdinalIgnoreCase)); - } - finally { Environment.SetEnvironmentVariable("PATH", original); } + CommandLine apply = CommandLine.Parse(["apply", "agent.yaml", "--yes"]); + Assert.Equal(AppCommand.Apply, apply.Command); + Assert.Equal("agent.yaml", apply.ConfigPath); + Assert.True(apply.Yes); + CommandLine show = CommandLine.Parse(["show", "agent.yaml"]); + Assert.Equal(AppCommand.Show, show.Command); + Assert.False(show.Yes); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["show", "--yes"])); + Assert.Throws<CommandLineException>(() => CommandLine.Parse(["apply", "a.yaml", "b.yaml"])); } [Fact] @@ -198,10 +180,10 @@ public sealed class ConfigurationTests } [Fact] - public void Resolution_confirmation_is_invalidated_by_config_fingerprint_change() + public void Applied_snapshot_is_invalidated_by_configuration_change() { using var directory = new TestDirectory(); - string configPath = directory.File("agent.yaml", "schema_version: 1"); + string configPath = directory.File("agent.yaml", "schema_version: 2"); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string stores = Path.Combine(directory.Path, "stores"); @@ -209,72 +191,95 @@ public sealed class ConfigurationTests { Sandbox = new SandboxOptions { Profile = "development", Workspaces = [workspace] } }; - var resolutionStore = new SandboxResolutionStore(configPath, stores, protectDirectory: false); - var approvalStore = new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false); - var resolver = new SandboxEnvironmentResolver(configPath, configuration, resolutionStore, approvalStore); - _ = resolver.Confirm(); + var store = new AppliedConfigurationStore(configPath, stores, protectDirectory: false); + var resolver = new AppliedSandboxConfiguration(configPath, configuration, store); + _ = resolver.Apply(); _ = resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")); configuration.Sandbox.Resources.Profile = "extended"; ConfigurationException error = Assert.Throws<ConfigurationException>(() => resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); - Assert.Contains("changed after confirmation", error.Message, StringComparison.Ordinal); + Assert.Equal("sandbox.configuration_not_applied", error.Code); } [Fact] - public void Approval_store_never_refreshes_a_changed_executable_identity() + public void Applied_snapshot_never_refreshes_a_changed_executable_identity() { using var directory = new TestDirectory(); - string configPath = directory.File("agent.yaml", "schema_version: 1"); + string configPath = directory.File("agent.yaml", "schema_version: 2"); string executable = directory.File("fixture.exe", "first"); string stores = Path.Combine(directory.Path, "stores"); var configuration = new AgentConfiguration(); - configuration.Sandbox.Execution.Executables.Add("fixture"); - var resolutionStore = new SandboxResolutionStore(configPath, stores, protectDirectory: false); - var approvalStore = new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false); - approvalStore.Approve(new("fixture", "executable", executable, ResolvedPath.Resolve(executable), - EntryExecutablePolicy.ComputeSha256(executable), null, DateTimeOffset.UtcNow)); - var resolver = new SandboxEnvironmentResolver(configPath, configuration, resolutionStore, approvalStore); - _ = resolver.Confirm(); + configuration.Sandbox.Execution.Entries.Add(new() { Alias = "fixture", Path = executable }); + var store = new AppliedConfigurationStore(configPath, stores, protectDirectory: false); + var resolver = new AppliedSandboxConfiguration(configPath, configuration, store); + _ = resolver.Apply(); File.WriteAllText(executable, "second"); ConfigurationException error = Assert.Throws<ConfigurationException>(() => resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); - Assert.Contains("SHA-256 changed", error.Message, StringComparison.Ordinal); - Assert.Single(approvalStore.List()); - Assert.True(approvalStore.Revoke("fixture")); - Assert.Empty(approvalStore.List()); + Assert.Equal("sandbox.configuration_not_applied", error.Code); + Assert.Single(store.Load()!.Execution); Assert.Empty(Directory.EnumerateFiles(stores, "*.tmp")); } [Fact] - public void Approval_store_rejects_malformed_identity_records() + public void Reapply_replaces_the_complete_snapshot_and_removes_deleted_entries() + { + using var directory = new TestDirectory(); + string configPath = directory.File("agent.yaml", "schema_version: 2"); + string executable = directory.File("fixture.exe", "first"); + string stores = Path.Combine(directory.Path, "stores"); + var configuration = new AgentConfiguration(); + configuration.Sandbox.Execution.Entries.Add(new() { Alias = "fixture", Path = executable }); + var store = new AppliedConfigurationStore(configPath, stores, protectDirectory: false); + var applied = new AppliedSandboxConfiguration(configPath, configuration, store); + + _ = applied.Apply(); + Assert.Single(store.Load()!.Execution); + configuration.Sandbox.Execution.Entries.Clear(); + _ = applied.Apply(); + + AppliedConfigurationDocument document = store.Load()!; + Assert.Empty(document.Execution); + Assert.Equal("none", document.Network.Access); + Assert.Equal(4096, document.Resources.MemoryMb); + } + + [Fact] + public void Applied_store_rejects_malformed_records() { using var directory = new TestDirectory(); - string storePath = directory.File("stores/approvals.json", """ + string configPath = directory.File("agent.yaml", "schema_version: 2"); + var store = new AppliedConfigurationStore(configPath, Path.Combine(directory.Path, "stores"), protectDirectory: false); + Directory.CreateDirectory(Path.GetDirectoryName(store.StorePath)!); + File.WriteAllText(store.StorePath, """ { - "schemaVersion": 1, - "approvals": [{ + "schemaVersion": 2, + "configPath": "relative/agent.yaml", + "configFingerprint": "not-a-hash", + "filesystem": [], + "execution": [{ "alias": "Python With Spaces", "classification": "unknown", "logicalPath": "python.exe", "resolvedPath": "relative/python.exe", "sha256": "not-a-hash", - "runtimeRoot": null, - "approvedAt": "2026-08-12T00:00:00Z" - }] + "logicalRuntimeRoot": null, + "resolvedRuntimeRoot": null + }], + "appliedAt": "2026-08-12T00:00:00Z" } """); - var store = new ExecutableApprovalStore(storePath, protectDirectory: false); - Assert.Throws<ConfigurationException>(() => store.List()); + Assert.Throws<ConfigurationException>(() => store.Load()); } [Fact] public void Narrow_nested_mount_is_rejected_instead_of_silently_widened() { using var directory = new TestDirectory(); - string configPath = directory.File("agent.yaml", "schema_version: 1"); + string configPath = directory.File("agent.yaml", "schema_version: 2"); string workspace = Path.Combine(directory.Path, "workspace"); string nested = Path.Combine(workspace, "read-only-data"); Directory.CreateDirectory(nested); @@ -288,10 +293,9 @@ public sealed class ConfigurationTests Mounts = [new MountOptions { Path = nested, Access = "read_only" }] } }; - var resolver = new SandboxEnvironmentResolver(configPath, configuration, - new SandboxResolutionStore(configPath, stores, protectDirectory: false), - new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); - _ = resolver.Confirm(); + var resolver = new AppliedSandboxConfiguration(configPath, configuration, + new AppliedConfigurationStore(configPath, stores, protectDirectory: false)); + _ = resolver.Apply(); ConfigurationException error = Assert.Throws<ConfigurationException>(() => resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); @@ -299,10 +303,36 @@ public sealed class ConfigurationTests } [Fact] + public void Execution_entry_inside_development_workspace_inherits_workspace_capability() + { + using var directory = new TestDirectory(); + string configPath = directory.File("agent.yaml", "schema_version: 2"); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string executable = Path.Combine(workspace, "tool.exe"); + File.WriteAllText(executable, "fixture"); + var configuration = new AgentConfiguration + { + Sandbox = new SandboxOptions { Profile = "development", Workspaces = [workspace] } + }; + configuration.Sandbox.Execution.Entries.Add(new() { Alias = "tool", Path = executable }); + var applied = new AppliedSandboxConfiguration(configPath, configuration, + new AppliedConfigurationStore(configPath, Path.Combine(directory.Path, "stores"), protectDirectory: false)); + _ = applied.Apply(); + + Agent.Windows.Tools.EffectiveSandboxPolicy policy = applied.Compile( + Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")); + + Assert.Contains(policy.EntryExecutables, item => item.Alias == "tool"); + Assert.Contains(policy.Files, item => item.Path.Equals(workspace, StringComparison.OrdinalIgnoreCase) && + item.Capabilities.HasFlag(FileCapability.Execute)); + } + + [Fact] public void Profile_network_and_resource_configuration_compile_to_effective_policy() { using var directory = new TestDirectory(); - string configPath = directory.File("agent.yaml", "schema_version: 1"); + string configPath = directory.File("agent.yaml", "schema_version: 2"); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string stores = Path.Combine(directory.Path, "stores"); @@ -316,10 +346,9 @@ public sealed class ConfigurationTests Resources = new ResourceOptions { Profile = "constrained", ProcessLimit = 6, ExecutionTimeoutSeconds = 450 } } }; - var resolver = new SandboxEnvironmentResolver(configPath, configuration, - new SandboxResolutionStore(configPath, stores, protectDirectory: false), - new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); - _ = resolver.Confirm(); + var resolver = new AppliedSandboxConfiguration(configPath, configuration, + new AppliedConfigurationStore(configPath, stores, protectDirectory: false)); + _ = resolver.Apply(); Agent.Windows.Tools.EffectiveSandboxPolicy policy = resolver.Compile( Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")); @@ -338,15 +367,14 @@ public sealed class ConfigurationTests public void Readonly_profile_never_creates_an_artifact_root_or_execute_capability() { using var directory = new TestDirectory(); - string configPath = directory.File("agent.yaml", "schema_version: 1"); + string configPath = directory.File("agent.yaml", "schema_version: 2"); string workspace = Path.Combine(directory.Path, "workspace"); Directory.CreateDirectory(workspace); string stores = Path.Combine(directory.Path, "stores"); var configuration = new AgentConfiguration { Sandbox = new SandboxOptions { Profile = "readonly", Workspaces = [workspace] } }; - var resolver = new SandboxEnvironmentResolver(configPath, configuration, - new SandboxResolutionStore(configPath, stores, protectDirectory: false), - new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); - _ = resolver.Confirm(); + var resolver = new AppliedSandboxConfiguration(configPath, configuration, + new AppliedConfigurationStore(configPath, stores, protectDirectory: false)); + _ = resolver.Apply(); Agent.Windows.Tools.EffectiveSandboxPolicy policy = resolver.Compile( Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")); @@ -380,15 +408,15 @@ public sealed class ConfigurationTests [Fact] public void Startup_errors_follow_the_cli_locale() { - string message = Program.FormatStartupError(["--lang", "ja"], new CommandLineException("bad option")); - Assert.Contains("コマンドラインが不正です", message, StringComparison.Ordinal); + string message = Program.FormatStartupError([], new CommandLineException("bad option")); + Assert.Contains("Invalid command line", message, StringComparison.Ordinal); Assert.Contains("bad option", message, StringComparison.Ordinal); - string sandbox = Program.FormatStartupError(["--lang", "ja"], new ConfigurationException( - "internal", code: "executable.hash_changed", category: "sandbox", target: "C:/Python/python.exe", + string sandbox = Program.FormatStartupError([], new ConfigurationException( + "internal", code: "sandbox.configuration_not_applied", category: "sandbox", target: "C:/config/agent.yaml", resolution: "internal resolution")); - Assert.Contains("SHA-256が変更されました", sandbox, StringComparison.Ordinal); - Assert.Contains("明示的に再承認", sandbox, StringComparison.Ordinal); + Assert.Contains("not been applied", sandbox, StringComparison.Ordinal); + Assert.Contains("agent-mcp apply", sandbox, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("internal resolution", sandbox, StringComparison.Ordinal); } @@ -447,7 +475,7 @@ public sealed class ConfigurationTests Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "config/locales")); Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills")); return directory.File("agent.yaml", """ - schema_version: 1 + schema_version: 2 application: language: en agent: diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs index d0c32a8..6dbc9ab 100644 --- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -17,19 +17,20 @@ namespace Agent.Windows.Tests; public sealed class SecurityPolicyTests { [Fact] - public void Approval_store_uses_atomic_replace_and_a_user_only_dacl() + public void Applied_configuration_store_uses_atomic_replace_and_a_user_only_dacl() { using TestDirectory directory = new(); - string storePath = Path.Combine(directory.Path, "protected", "executable-approvals.json"); - var store = new ExecutableApprovalStore(storePath); - string executable = Environment.ProcessPath!; - store.Approve(new("self", "executable", executable, executable, - EntryExecutablePolicy.ComputeSha256(executable), null, DateTimeOffset.UtcNow)); - - Assert.Single(store.List()); - Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(storePath)!, "*.tmp")); + string configPath = Path.Combine(directory.Path, "agent.yaml"); + File.WriteAllText(configPath, "schema_version: 2"); + string storeRoot = Path.Combine(directory.Path, "protected"); + var store = new AppliedConfigurationStore(configPath, storeRoot); + store.Save(new(2, Path.GetFullPath(configPath), new string('A', 64), [], [], + new("none", []), new("normal", 4096, 16, 1800), DateTimeOffset.UtcNow)); + + Assert.NotNull(store.Load()); + Assert.Empty(Directory.EnumerateFiles(storeRoot, "*.tmp")); DirectorySecurity security = FileSystemAclExtensions.GetAccessControl( - new DirectoryInfo(Path.GetDirectoryName(storePath)!), AccessControlSections.Access); + new DirectoryInfo(storeRoot), AccessControlSections.Access); Assert.True(security.AreAccessRulesProtected); using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var rules = security.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier)) @@ -147,23 +148,22 @@ public sealed class SecurityPolicyTests Directory.CreateDirectory(secondTarget); CreateJunction(junctionPath, firstTarget); string configPath = Path.Combine(directory.Path, "agent.yaml"); - File.WriteAllText(configPath, "schema_version: 1"); + File.WriteAllText(configPath, "schema_version: 2"); string stores = Path.Combine(directory.Path, "stores"); var configuration = new AgentConfiguration { Sandbox = new SandboxOptions { Profile = "development", Workspaces = [junctionPath] } }; - var resolver = new SandboxEnvironmentResolver(configPath, configuration, - new SandboxResolutionStore(configPath, stores, protectDirectory: false), - new ExecutableApprovalStore(Path.Combine(stores, "approvals.json"), protectDirectory: false)); - _ = resolver.Confirm(); + var resolver = new AppliedSandboxConfiguration(configPath, configuration, + new AppliedConfigurationStore(configPath, stores, protectDirectory: false)); + _ = resolver.Apply(); Directory.Delete(junctionPath); CreateJunction(junctionPath, secondTarget); ConfigurationException error = Assert.Throws<ConfigurationException>(() => resolver.Compile( Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"))); - Assert.Equal("sandbox.resolution_changed", error.Code); + Assert.Equal("sandbox.configuration_not_applied", error.Code); Directory.Delete(junctionPath); } |
