summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-13 19:15:01 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-13 19:15:01 +0900
commit36ae1a4cba791ec633a81b38b1d8345af8487276 (patch)
tree91cef96efac7eb8c2754658ce5af5181b1a15898
parent76fdcb43585a4c4d288c1026d318a0ddbe3e26ab (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.
-rw-r--r--README.md377
-rw-r--r--config/agent.local.example.yaml6
-rw-r--r--config/agent.yaml5
-rw-r--r--config/locales/en.json59
-rw-r--r--config/locales/ja.json59
-rw-r--r--doc/AuditReport.md2
-rw-r--r--doc/Design.md55
-rw-r--r--doc/Specifications.md43
-rw-r--r--plans/ImplementationPlan.md26
-rw-r--r--plans/RequirementTraceability.md13
-rw-r--r--scripts/E2EClient/Program.cs2
-rw-r--r--scripts/Operational-E2E.ps134
-rw-r--r--scripts/Publish-Release.ps14
-rw-r--r--scripts/Restart-PublishedServer.ps12
-rw-r--r--scripts/Run-Final-ElevatedVerification.ps14
-rw-r--r--scripts/Verify-PublishedHttp.ps115
-rw-r--r--scripts/Verify-Readme.ps1261
-rw-r--r--src/Agent.App/Cli/CommandLine.cs110
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs11
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs13
-rw-r--r--src/Agent.App/Program.cs247
-rw-r--r--src/Agent.App/Sandbox/AppliedSandboxConfiguration.cs267
-rw-r--r--src/Agent.App/Sandbox/SandboxServices.cs333
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs4
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs216
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs32
26 files changed, 883 insertions, 1317 deletions
diff --git a/README.md b/README.md
index e4e286b..94e9fb3 100644
--- a/README.md
+++ b/README.md
@@ -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);
}