summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md120
-rw-r--r--config/agent.local.example.yaml16
-rw-r--r--config/agent.yaml23
-rw-r--r--config/locales/en.json28
-rw-r--r--config/locales/ja.json28
-rw-r--r--doc/AuditReport.md18
-rw-r--r--doc/Design.md249
-rw-r--r--doc/Specifications.md131
-rw-r--r--plans/ImplementationPlan.md86
-rw-r--r--plans/RequirementTraceability.md34
-rw-r--r--plans/progress/Phase6.md1
-rw-r--r--prompts/user-system.md5
-rw-r--r--scripts/Launch-ElevatedVerification.ps110
-rw-r--r--scripts/Operational-E2E.ps1271
-rw-r--r--scripts/Publish-Release.ps147
-rw-r--r--scripts/Restart-PublishedServer.ps130
-rw-r--r--scripts/Run-Final-ElevatedVerification.ps120
-rw-r--r--skills/python/SKILL.md4
-rw-r--r--src/Agent.App/Agent.App.csproj1
-rw-r--r--src/Agent.App/Cli/CommandLine.cs102
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs31
-rw-r--r--src/Agent.App/Configuration/ConfigurationException.cs11
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs86
-rw-r--r--src/Agent.App/Program.cs225
-rw-r--r--src/Agent.App/Sandbox/SandboxServices.cs361
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs12
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs38
-rw-r--r--src/Agent.Windows/NativeMethods.txt9
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs28
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs12
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs87
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs37
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs208
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs74
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs60
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs250
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs27
-rw-r--r--tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj1
-rw-r--r--tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs4
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs555
40 files changed, 2538 insertions, 802 deletions
diff --git a/README.md b/README.md
index 3dede12..d1bde9f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# AI Agent Sandbox MCP Server v1
-Windows 11上でlocal Ollamaを利用し、明示的に承認したFilesystem、Process、Network操作だけをAppContainer内で実行するMCP Serverです。MCP `2026-07-28`のstateless HTTPと公式Tasks Extensionに対応し、MCP公開Toolは `agent_run` と `agent_status` の2つです。
+Windows 11上でOllamaを利用し、利用者が確認したWorkspace、Mount、Executable、Network、Resourceの範囲だけをLPAC内で実行するMCP Serverです。MCP `2026-07-28`のstateless HTTPと公式Tasks Extensionに対応し、MCP公開Toolは `agent_run` と `agent_status` の2つです。
## 動作要件
@@ -8,7 +8,7 @@ Windows 11上でlocal Ollamaを利用し、明示的に承認したFilesystem、
- 管理者権限(TaskごとのWFP filter追加・削除に必要)
- Base Filtering Engine (BFE)が実行中
- Ollama。tested versionは実測した `0.32.6`。minimum supported versionは未確定です。
-- Release既定model: `deepseek-v4-flash:cloud`(native Tool calling対応)
+- Release既定model: `deepseek-v4-flash:cloud`(native Internal Tool calling対応)
サービス、常駐helper、恒久Firewall ruleは使用しません。WFP objectはdynamic sessionだけに作成されます。AppContainerからlocalhostへ接続するTaskでは、固定AppContainer SIDのloopback exemptionをTask中だけ追加し、終了時または次回起動Recoveryで削除します。
@@ -22,9 +22,14 @@ ollama pull deepseek-v4-flash:cloud
ollama serve
```
-別の管理者PowerShellで診断します。
+初回はUser Sandbox ConfigurationのLogical Pathを確認してから、別の管理者PowerShellで診断します。
```powershell
+.\agent-mcp.exe sandbox resolve --config .\agent.yaml
+.\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 sandbox confirm --config .\agent.yaml
+.\agent-mcp.exe sandbox show --format text --config .\agent.yaml
.\agent-mcp.exe check --config .\agent.yaml --lang ja
```
@@ -32,9 +37,67 @@ ollama serve
`serve`も待受開始前に同じprobeを必ず実行します。失敗時はendpointをbindせず、失敗項目・Win32 error・対処方法をstderrと`logs/application.log`へ出してexit code 2で終了します。
-## agent.yaml
+## User Sandbox Configuration
-Filesystem pathは能力ごとに指定します。未指定能力はdenyです。
+`schema_version: 1`のSandbox設定は利用者が理解できるsemantic単位で記述します。未指定capabilityはdenyです。
+
+```yaml
+sandbox:
+ profile: development
+ workspaces:
+ - C:/work/project
+ mounts:
+ - path: C:/data/reference
+ access: read_only
+ execution:
+ executables: [python, git]
+ shells: [powershell]
+ network:
+ access: local
+ local_endpoints:
+ - port: 11434
+ restricted_endpoints: []
+ resources:
+ profile: normal
+ # memory_mb: 4096
+ # process_limit: 16
+ # execution_timeout_seconds: 1800
+```
+
+`readonly` ProfileはWorkspaceをread-onlyにし、Workspace ArtifactのEntry起動を禁止します。`development`はWorkspaceのread/write/deleteとArtifact起動を許可します。Mountは`read_only`、`read_write`、`read_write_delete`から選び、Mount上のExecutableをEntry Processとして起動することはできません。
+
+Resource Profileは`constrained`(1024 MiB/4 Process/300秒)、`normal`(4096 MiB/16 Process/1800秒)、`extended`(8192 MiB/32 Process/3600秒)です。正数overrideを指定できます。
+
+Network accessは`none`、`local`、`restricted`です。`none`はLoopbackを含め全deny、`local`は列挙portの`127.0.0.1`と`::1`だけ、`restricted`はTask開始時にIP snapshot化したhost/portだけを許可します。Ollama通信はBroker側から行うため、Sandbox Process TreeのNetwork設定とは独立です。
+
+Workspace/Mountはreparse chainをResolved Pathへ解決します。`sandbox resolve`で差分を確認し、`sandbox confirm`でconfig別のResolution Storeへ保存してください。config fingerprintまたはResolved Pathが変わると`serve`は再確認までfail-closedになります。
+
+```powershell
+.\agent-mcp.exe sandbox resolve --config .\agent.yaml
+.\agent-mcp.exe sandbox confirm --config .\agent.yaml
+.\agent-mcp.exe sandbox show --format json --config .\agent.yaml
+```
+
+## Executable Approval
+
+`sandbox.execution`には人間向けAliasを記載します。PATH候補は提示だけで自動選択されません。選んだResolved PathとSHA-256をuser-local Approval Storeへ固定します。
+
+```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
+.\agent-mcp.exe executable revoke python --config .\agent.yaml
+```
+
+Approved Entry Executable/Approved Shell ExecutableはAlias、Resolved Path、SHA-256一致が必要です。確認済みRuntime Root内のRuntime Componentと、development Workspace内のWorkspace ArtifactもEntry Processにできます。その他のExternal Unknown ExecutableをBrokerから直接起動することはできません。
+
+ApprovalはEntry Processにだけ適用されます。Descendant Processは個別Approvalを要求せず、Entry Processと同じLPAC token、Filesystem ACL、WFP、Job Objectを継承します。Jobからのbreakawayは禁止され、Process数・memory・timeout・`KILL_ON_JOB_CLOSE`はProcess Tree全体へ適用されます。
+
+Sandbox Process Treeへは最小限のWindows/runtime環境変数だけを渡し、Brokerの任意token、password、SSH/API credential環境変数は継承しません。User profile等のpathが環境に含まれても、そのpathへFilesystem Capabilityは付与されません。
+
+Executable Approval StoreとResolution Storeは`%LOCALAPPDATA%\AgentSandboxMcp`にuser-only DACL、WriteThrough、atomic replaceで保存されます。publishやupgradeはこれらのStoreを上書きしません。
+
+## その他のagent.yaml設定
MCP Taskの保持とlog出力は次のtyped settingで変更できます。
@@ -62,47 +125,9 @@ logging:
Task状態は`state/mcp-tasks/`へatomic保存されます。再起動時の未完了Taskは`failed/server_restarted`となり、自動再実行されません。
-```yaml
-sandbox:
- filesystem:
- read: [C:/work/fixture]
- write: [C:/work/fixture]
- execute:
- - C:/Program Files/dotnet/dotnet.exe
- delete: [C:/work/fixture]
-```
-
-実行ファイルはcanonical absolute pathとSHA-256の両方を承認します。
-
-```powershell
-(Get-FileHash -Algorithm SHA256 -LiteralPath 'C:\Program Files\dotnet\dotnet.exe').Hash
-```
-
-```yaml
-sandbox:
- process:
- allow_shell: false
- approvals:
- - path: C:/Program Files/dotnet/dotnet.exe
- sha256: <64桁の実測値>
-```
-
-Sandbox processからの子process生成は、承認済みpathであってもOSのchild-process restrictionで拒否されます。build/testで必要な各実行ファイルは事前にpath/hash承認し、個別の `process_run` としてBrokerから起動してください。E2Eではbuild server、node reuse、shared compilationを無効化し、in-process buildまたはBrokerから直接起動できるself-testを使用します。Networkは既定でlocalhostを含めdenyです。allow時はTask開始時にhostをIPへsnapshot化します。
-
-```yaml
-sandbox:
- network:
- mode: allow
- allow:
- - host: 127.0.0.1
- ports: [11434]
-```
-
-Ollama通信は管理Broker側から行うため、Sandbox processのnetwork許可とは独立です。
-
## Agent Skills
-`skills/<skill-name>/SKILL.md`形式だけを読み込みます。legacyの`skills/*.md`は読み込みません。SkillはLLMへの補助指示であり、Filesystem/Process/Network権限は付与しません。Skillが使用する実行ファイルは、別途`agent.yaml`でabsolute pathとSHA-256を承認してください。
+`skills/<skill-name>/SKILL.md`形式だけを読み込みます。legacyの`skills/*.md`は読み込みません。SkillはLLMへの補助指示であり、Sandbox capabilityは付与しません。Skillが使うHost ExecutableはAliasを設定し、Entry起動前に承認してください。
## stdio
@@ -162,8 +187,9 @@ LLM/Audit JSONLはtask、session、event、call単位で絞り込めます。
- `error 5` / administrator=false: 管理者PowerShellから起動してください。
- BFE open失敗: `Get-Service BFE` でBase Filtering Engineを確認してください。
- Ollama接続失敗: `ollama list`、endpoint、model名を確認してください。
-- executable deny: pathをabsolute/canonicalにし、更新後のSHA-256を再取得してください。
-- file deny: 対象pathを必要なread/write/execute/delete能力へ追加し、再起動してください。
+- resolution未確認/変更: `sandbox resolve`のLogical/Resolved Path差分を確認し、`sandbox confirm`を実行してください。
+- Alias不明/Approval不足/hash変更: `executable resolve`で候補を確認し、明示したpathを`executable approve`で再承認してください。
+- Filesystem deny: 対象を適切なWorkspaceまたはMountとして設定し、Resolutionを再確認してください。
- rollback拒否: Task中に外部processが同じfileを変更しています。Audit Logとjournalを確認し、手動reviewしてください。
## ソースからのbuild/release
@@ -187,4 +213,4 @@ dotnet test AgentSandboxMcp.slnx -c Release --no-build --no-restore
`artifacts/` はローカルの試験証跡・デバッグログ用です。`artifacts/` と `build/` はどちらもGit管理外です。
-Release scriptはwin-x64 self-contained directory、version付きZIP、ZIP checksum、publish manifestを生成し、ソースツリー外のfresh temporary directoryでBundleを検査します。完全な `agent-mcp check` は管理者PowerShellで実行してください。
+Release scriptは再現可能な既定値だけを含むwin-x64 self-contained ZIP、checksum、publish manifestをstagingから生成し、ソースツリー外のfresh temporary directoryでBundleを検査します。deployment directory更新時は`agent.local.yaml`、`local/`、`logs/`、`state/`を削除・上書きしません。完全な `agent-mcp check` はResolution確認とExecutable Approval後に管理者PowerShellで実行してください。
diff --git a/config/agent.local.example.yaml b/config/agent.local.example.yaml
index f974dbe..4961428 100644
--- a/config/agent.local.example.yaml
+++ b/config/agent.local.example.yaml
@@ -7,12 +7,10 @@ agent:
skills:
directory: local/skills
sandbox:
- filesystem:
- read:
- - C:/work/project
- write:
- - C:/work/project
- execute:
- - C:/Program Files/dotnet/dotnet.exe
- delete:
- - C:/work/project
+ profile: development
+ workspaces:
+ - C:/work/project
+ mounts: []
+ execution:
+ executables: [python]
+ shells: []
diff --git a/config/agent.yaml b/config/agent.yaml
index 64c5f9d..b38e693 100644
--- a/config/agent.yaml
+++ b/config/agent.yaml
@@ -29,21 +29,18 @@ ollama:
planner:
max_tasks: 32
sandbox:
- filesystem:
- read: []
- write: []
- execute: []
- delete: []
- process:
- allow_shell: true
- approvals: []
- max_processes: 16
+ profile: readonly
+ workspaces: []
+ mounts: []
+ execution:
+ executables: []
+ shells: []
network:
- mode: deny
- allow: []
+ access: none
+ local_endpoints: []
+ restricted_endpoints: []
resources:
- memory_mb: 4096
- process_timeout_seconds: 1800
+ profile: normal
file_edit:
memory_backup_limit_mb: 16
limits:
diff --git a/config/locales/en.json b/config/locales/en.json
index 4330cb7..3e9a5fa 100644
--- a/config/locales/en.json
+++ b/config/locales/en.json
@@ -26,5 +26,31 @@
"runtime.policy_denied": "The sandbox policy denied the requested operation (code={code}, path={path}, required_permission={permission}). Resolution: {resolution}",
"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.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.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."
}
diff --git a/config/locales/ja.json b/config/locales/ja.json
index c5a6931..7d51334 100644
--- a/config/locales/ja.json
+++ b/config/locales/ja.json
@@ -26,5 +26,31 @@
"runtime.policy_denied": "要求された操作はSandbox Policyにより拒否されました(code={code}、path={path}、必要権限={permission})。解決方法: {resolution}",
"runtime.policy_replan_exhausted": "Sandbox Policyによる拒否を{message}回の再計画で解消できませんでした。",
"runtime.policy_repeated": "同一のSandbox Policy拒否が再発したため、再計画を停止しました。",
- "runtime.replan_unchanged": "再計画後も未完了のタスク計画が変更されませんでした。"
+ "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.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のいずれかを選択してください。"
}
diff --git a/doc/AuditReport.md b/doc/AuditReport.md
index c98694e..47ee218 100644
--- a/doc/AuditReport.md
+++ b/doc/AuditReport.md
@@ -1,8 +1,16 @@
-# v1再監査報告
+# v1再監査報告とSandbox Usability Revision状況
## 基準
-2026-08-11、commit `37386b8`を現行実装の検証対象とした。2026-08-09のPhase/Release証跡は履歴として扱い、現行PASS判定には流用していない。
+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を現行実装へ流用しない。
+
+## Sandbox Usability Revision
+
+- 仕様書から先に用語、安全不変条件、semantic schema、CLI、Store、LPAC/ACL/Job/WFP強制条件を改定した。
+- 設計書へUser Configuration → Environment Resolution → Approval Resolution → Effective Policy → Broker → LPAC/ACL/Job/WFPを定義した。
+- 旧`filesystem`/`process.approvals` schemaは移行診断付きで拒否する。
+- Entry ProcessだけをApproval対象とし、Descendant Processは個別Approvalなしで同じLPAC identity、ACL、WFP、Job confinementを継承する。
+- 2026-08-12の最終working-tree検証でRelease buildはwarning 0/error 0、Core 103件、Planner 10件、Windows Security/Recovery 53件がすべてPASS、skip 0。`deepseek-v4-flash:cloud` + `think:true` + Python 3.14.3によるstdio、HTTP IPv4、HTTP IPv6 Operational E2EもすべてPASSし、各経路で複雑なPython実装、子Python、独立unittestを完走した。Task終了後のJournal、backup、published process残留は0。証跡は`artifacts/evidence/sandbox-usability-final-working-tree-2/`に保存した。
## 確認した矛盾と是正
@@ -21,11 +29,13 @@
| Configuration | validationのみでruntime未接続、local overrideなし | max_tasks/num_ctx/ratio/timeoutを接続し、`agent.local.yaml` mergeを追加 |
| CLI/運用 | `serve`必須、publish後の環境値復旧が必要 | 引数なし/先頭optionをserve化。local prompt/Skill/configをBundle対象外に分離 |
| Logging/Audit | 相関field・iteration・event名が不一致 | LLM相関/rotationとAudit必須eventへ統一。read-only JSONL viewerを追加 |
+| Credential環境 | Brokerのenvironment blockをSandbox Processへ継承 | Windows/runtimeに必要な明示的Unicode environmentだけを渡し、任意token/password/SSH/API credentialを除外。実Processでsecret非継承を検証 |
+| Descendant実行 | 旧child process全面禁止がprompt/Skill/testに残存 | EntryだけをApproval対象とし、同一LPAC SID/Job内のPowerShell、Python、.NET build/test Process Treeを許容する設計へ統一 |
-## 現在の判定
+## 旧commit `37386b8`の判定(履歴)
Release buildはwarning 0/error 0。管理者suiteでCore 82件、Planner 10件、Windows Security/Recovery 42件がすべてPASSし、skipは0だった。self-contained Bundleのruntime check、stdio、HTTP IPv4/IPv6 Operational E2Eも`deepseek-v4-flash:cloud`でPASSした。各E2Eでは実file read/edit、in-process build、独立self-test、Task進捗、ACL/WFP/temp cleanupを確認した。
MCP conformance runnerはapplication failure 0だった。runner専用fixtureを本番MCP Toolとして公開しないため実施不能なcheckと、Windows runner終了時のlibuv assertionはrunner limitationとして分離し、PASSには数えていない。Requirementの受入根拠は製品のCore/MCP integration、Windows suite、Operational E2Eで満たしている。詳細と現行証跡は`plans/RequirementTraceability.md`を正本とする。
-既存の仕様語彙で表現できない独立概念は実装へ追加していない。local override、status診断field、ログ相関field、Task固有Tool証跡はSpecifications/Designの責務に従って記録した。
+この節の結果はSandbox Usability Revision後の現行実装のPASS根拠ではない。
diff --git a/doc/Design.md b/doc/Design.md
index 9ad4ae3..8f76ca6 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -69,21 +69,35 @@ Agent Runtime ─── Session Store (memory)
├── Prompt Composer ── Skill Selector/Loader
├── Ollama Client ───── Ollama localhost
├── Token Planner ───── TaskGraph / PERT / CPM
- └── Internal Tool Dispatcher
- │
- ▼
- Policy Engine
+ └── Internal Tool Dispatcher
+ │
+ ▼
+ User Sandbox Configuration
+ │
+ ▼
+ Sandbox Environment Resolution
+ ├── Workspace / Mount / Reparse Resolution
+ ├── Executable Alias / Runtime Resolution
+ ├── Executable Approval Resolution
+ ├── Network Resolution
+ └── Resource Resolution
+ │
+ ▼
+ Effective Sandbox Policy
+ │
+ ▼
+ Policy Engine
│
▼
Sandbox Broker
- ├── AppContainer
+ ├── Less Privileged AppContainer (LPAC)
├── NTFS ACL
├── Job Object
├── WFP
└── Named Pipe
│
▼
- Sandboxed Process Tree
+ Sandbox Process Tree
Audit Writer / Recovery Manager は各副作用境界を横断して記録・復旧する。
```
### 3.1 信頼境界
@@ -252,14 +266,14 @@ overrideはTask objectにcopyし、global configurationを変更しない。Sess
- mime_type、Base64妥当性、設定上限size/countを事前検査する。
- 既定上限は4画像、1画像10 MiB、request合計20 MiBとする。
- model capability確認でVision非対応と判定した場合はOllama呼出し前にlocalized error。能力確認不能時は呼出しを行い、Ollama errorを正規化して返す。
-### 6.5 Tool calling
-Ollamaに公開する現行Internal Tool catalogは次のとおりとする。個々のOS command (`grep.exe`等) をtoolとして増殖させない。catalogの構成は設計事項であり、仕様上の固定数として扱わない。
-| Tool | 責務 | 主なoperation |
+### 6.5 Internal Tool calling
+Ollamaに公開する現行Internal Tool catalogは次のとおりとする。個々のOS Executable (`grep.exe`等) をInternal Toolとして増殖させない。catalogの構成は設計事項であり、仕様上の固定数として扱わない。
+| Internal Tool | 責務 | 主なoperation |
| --- | --- | --- |
| file_search | 検索 | glob / name / text / regex / tail相当 |
| file_read | 読取 | whole / range / metadata |
| file_edit | 編集 | overwrite / patch / atomic_replace / delete |
-| process_run | 承認済Process実行 | exe + args + cwd + timeout |
+| process_run | Entry Process実行 | Executable + args + cwd + timeout |
| task_update | TaskGraph更新 | add / dependency / estimate |
| dir | ディレクトリ操作 | mkdir / rmdir / list |
---
@@ -278,7 +292,7 @@ Ollamaに公開する現行Internal Tool catalogは次のとおりとする。å€
```
- wholeはsize上限を超えた場合range利用を要求する。
- whole readの既定上限は1 MiBとする。
-- read前にcanonical pathとreparse pointを検査する。
+- read前にLogical Pathをreparse resolutionし、確認済みResolved PathとEffective Filesystem Policyを検査する。
### 7.3 file_edit
```json
{
@@ -290,10 +304,10 @@ Ollamaに公開する現行Internal Tool catalogは次のとおりとする。å€
"new_path":"string|null"
}
```
-- overwrite/patch/atomic_replaceはwrite permissionを要求する。
-- `patch` operationの`patch` fieldは、JSON文字列化した`{"old_text":"...","new_text":"..."}`を受け取る。unified diffは受け付けない。`old_text`は対象file内で正確に1回だけ一致しなければならない。不正形式または一致数不正は再試行可能なTool failureとする。
-- deleteはdelete permissionを要求する。
-- renameはsource/destination双方にwrite permissionを要求する。destinationが既に存在する場合は拒否する(overwrite不可)。cross-volume renameは不支持でTool failureとして返す。
+- overwrite/patch/atomic_replaceはwrite Filesystem Capabilityを要求する。
+- `patch` operationの`patch` fieldは、JSON文字列化した`{"old_text":"...","new_text":"..."}`を受け取る。unified diffは受け付けない。`old_text`は対象file内で正確に1回だけ一致しなければならない。不正形式または一致数不正は再試行可能なInternal Tool failureとする。
+- deleteはdelete Filesystem Capabilityを要求する。
+- renameはsource/destination双方にwrite Filesystem Capabilityを要求する。destinationが既に存在する場合は拒否する(overwrite不可)。cross-volume renameは不支持でInternal Tool failureとして返す。
- rename transaction: PREPARE(source/destination検査+journal) → APPLY(File.Move) → VERIFY(source不存在/destination存在/hash一致) → COMMIT。crash recoveryはdestination hashがjournal記録と一致する場合のみdestinationからsourceへ戻す。
- `expected_sha256` 指定時はoptimistic concurrency guardとして利用する。
### 7.4 process_run
@@ -306,10 +320,11 @@ Ollamaに公開する現行Internal Tool catalogは次のとおりとする。å€
"stdin":"string|null"
}
```
-- shell command line 1本ではなくexeとargsを分離する。
-- executableはcanonical path + SHA-256 approval必須。
-- shell category disabled時はcmd/powershell/pwshを拒否する。
-- cwdはfilesystem policy内でなければならない。
+- Shell Command 1本ではなくExecutableとargsを分離する。
+- `executable`はApproved Entry Executable/ShellのAlias、Runtime ComponentのLogical/Resolved Path、またはdevelopment Workspace ArtifactのLogical/Resolved Pathとする。Alias以外のrelative Logical Pathは`cwd`を基準に解決する。Approved Entry ExecutableのResolved PathをAliasの代用として直接指定することはできない。
+- Approved Entry Executable/ShellはResolved Path + SHA-256のExecutable Approval必須。Runtime ComponentとWorkspace Artifactは分類rootによって判定する。
+- External Unknown ExecutableをEntry Processとして直接起動しない。Descendant Processは個別分類せずLPAC/ACL/WFP/Job confinementを継承する。
+- cwdはEffective Filesystem Policy内でなければならない。
### 7.5 task_update
```json
{
@@ -334,13 +349,13 @@ PlannerがDAG、不変条件、completed immutable、max_tasksを再検査して
- mkdir は Write capabilityを要求し、`Directory.CreateDirectory` で親ディレクトリも含めて作成する。
- rmdir は Delete capabilityを要求し、**空ディレクトリのみ**削除する。非空の場合は `ToolValidationException` で拒否する。内容復元の複雑さとMemoryBackupLimit超過リスクを排除するため、再帰削除は行わない。
- list は Read capabilityを要求し、直下のエントリ一覧を返す。外部効果ではない。
-- 既存の PathPolicy / CanonicalPath / TaskAclManager (root継承ACE) / TaskResourceJournal / RecoveryManager 枠組みを利用する。新規作成ディレクトリは親の継承ACEを自動取得するため追加ACL操作は不要。
+- 既存の `ResolvedPath` / `PathPolicy` / `TaskAclManager`(root継承ACE)/ `TaskResourceJournal` / `RecoveryManager` 枠組みを利用する。新規作成directoryは親の継承ACEを自動取得するため追加ACL操作は不要。
- mkdir後crash → ディレクトリ削除、rmdir後crash → 空ディレクトリ再作成、でRecoveryする。
-### 7.7 Tool error表現
-既存のTool resultへ、`error_code`、`error_category`、`target_path`、`required_permission`、`retry_allowed`、`resolution`をoptional fieldとして保持する。これは独立したerror体系ではなく、既存の`SandboxPolicyException`と`ToolExecutionResult`の分類情報を失わずRuntime、Audit、localized messageへ渡す表現である。内部messageと利用者向けlocalized messageは分離し、内部Tool名は利用者向け結果へ含めない。
+### 7.7 Internal Tool error表現
+既存のInternal Tool resultへ、`error_code`、`error_category`、`target_path`、`required_permission`、`retry_allowed`、`resolution`をoptional fieldとして保持する。これは独立したerror体系ではなく、既存の`SandboxPolicyException`と`ToolExecutionResult`の分類情報を失わずRuntime、Audit、localized messageへ渡す表現である。内部messageと利用者向けlocalized messageは分離し、Internal Tool名は利用者向け結果へ含めない。
### 7.8 ToolUseGuard
-Taskには `requires_external_effect` をPlanner内部属性として持たせる。**Planning フェーズで LLM structured output により Task ごとに判定・設定される**(keyword heuristic ではない)。trueのTaskで、当該iteration中のtool callが0かつ完了証拠がない場合、LLMへ「未実行」を示すsystem-generated observationを追加して再試行する。同一失敗fingerprintが5回に達したらTaskをfailedにする。ただし、Policy EngineのdenyおよびSandbox backendの`UnauthorizedAccessException`は`RetryAllowed=false`のterminal Tool resultへ変換する。Runtimeはobserverへ結果を1回記録した後、同一Tool呼出しへ再投入せず、§8.4の条件付きreplanへ遷移する。
+Taskには `requires_external_effect` をPlanner内部属性として持たせる。**Planning フェーズで LLM structured output により Task ごとに判定・設定される**(keyword heuristic ではない)。trueのTaskで、当該iteration中のInternal Tool Callが0かつ完了証拠がない場合、LLMへ「未実行」を示すsystem-generated observationを追加して再試行する。同一失敗fingerprintが5回に達したらTaskをfailedにする。ただし、Policy EngineのdenyおよびSandbox backendの`UnauthorizedAccessException`は`RetryAllowed=false`のterminal Internal Tool resultへ変換する。Runtimeはobserverへ結果を1回記録した後、同一Internal Tool Callへ再投入せず、§8.4の条件付きreplanへ遷移する。
---
## 8. Token Planner設計
### 8.1 Task model
@@ -360,6 +375,8 @@ Task
RequiredTools: set<Internal Tool name>
```
Taskは、完了に成功証跡が必要なTool名を`RequiredTools`として持つ。Planner structured outputの`required_tools`から構築し、Runtimeは集合内の全Toolが成功した場合だけTaskの操作を検証済みとする。read/search/list Taskは副作用不要でも対応Toolを持ち、純粋な説明Taskだけが空集合となる。`RequiresExternalEffect=true`では集合内の副作用Toolから外部効果も確認する。例えば`process_run`が必要なTask中の`file_edit`成功は完了根拠にしない。current Taskの集合に含まれない副作用ToolはBackendへdispatchせず、Task scope外のTool resultとしてLLMへ返してqueued Taskの先取りを決定的に防ぐ。scope外結果はAudit/LLM相関には残すが、次Taskのactive Execution Result contextへは引き継がない。
+
+codingの編集・検証・失敗修正は同一Taskへまとめ、`file_edit`と`process_run`をRequiredToolsにする。両Toolを要求する検証Taskが、別の`file_edit` Taskへ直接または間接に依存するsplit planはこの規則に反するためPlanner validationで拒否し、具体的な理由を付けて1回だけ訂正させる。
### 8.2 PERT計算
```text
ExpectedTokens = (O + 4*M + P) / 6.0
@@ -376,7 +393,7 @@ topological sort後にforward passで各Taskの累積expected token costを計ç®
- 実績がPを超過しても即失敗にはせず、remaining Taskのestimate見直しをLLMへ要求できる。
- `max_tasks=32`超過提案は拒否し、既存Taskの統合をLLMへ求める。
- Budget Classは意味的Taskを分割する唯一の理由にしない。Taskが複数独立成果物を含む場合に分割する。
-- Policy denyは同一Tool呼出しへ再投入しない。許可済み能力だけで代替できる可能性がある場合、`running -> planning -> running` と遷移してreplanを最大3回まで試行できる。
+- Effective Sandbox Policy denyは同一Internal Tool Callへ再投入しない。許可済みcapabilityだけで代替できる可能性がある場合、`running -> planning -> running` と遷移してreplanを最大3回まで試行できる。
- completed TaskのID、状態、usageを保持する。failed Taskをdependency解決済みとして扱わない。
- 同一deny fingerprintの再発、計画未変更、実行可能Taskなし、structured replan失敗は即Failedとする。全Taskがcompletedの場合だけAgent Taskをcompletedにする。
- structured planが空または不正な場合は、設定された`think`を維持したまま訂正instruction付きで1回だけ再試行し、それでも失敗した場合はplanning failureとする。外部効果要否が不明なfallback graphは作成しない。
@@ -397,7 +414,7 @@ AgentSession
CreatedAt / LastAccessAt
```
### 9.2 Context assembly
-順序を次に固定する。
+実行phaseの順序を次に固定する。
1. Fixed System Prompt
2. User System Prompt
3. locale instruction
@@ -410,6 +427,8 @@ AgentSession
10. current Taskの`requires_external_effect`に基づく実行要件
11. recent Conversation
+Planning/replanning phaseはread-onlyのTaskGraph生成契約を最初のSystem messageとして使用し、locale、Skill catalog、Sandbox capability summary、original MCP Task requestだけを組み立てる。file調査やInternal Tool実行を指示するFixed/User System PromptはPlanning契約と矛盾するため、このphaseへ混在させない。生成後のTask実行では上記1〜11の優先順位を適用する。
+
実行要件は、現在runningのTaskだけを実行してqueued Taskを先取りしないことをSystemおよび末尾のcurrent Task user messageで指示する。末尾messageにはoriginal MCP Task requestもexact値の参照元として併記する。外部効果が必要なTaskでは文章上の宣言、計画、JSON例を実行済みと扱わず、対応するInternal Toolを直ちに呼び出すよう指示する。成功済みのread結果はExecution Resultから再利用し、不要な再読込を避けてwrite/processへ進む。外部効果が不要なTaskにはToolを強制しない。未解決のTool失敗後にToolを呼ばない応答も同一失敗として数え、既定の同一失敗上限でfailedにする。
### 9.3 Summary policy
`num_ctx`に対する利用率が設定閾値を超えたとき、古いConversation/Execution Resultを要約する。利用率 = latest `prompt_eval_count` / `num_ctx`(累積 InputTokens ではない)。既定閾値は80%とする。次は削除・曖昧化してはならない。
@@ -422,7 +441,7 @@ AgentSession
- 承認/Policyそのものではなく現在利用可能なcapability summary
raw chain-of-thoughtは保存・要約対象としない。
---
-## 10. SandboxPolicy YAML設計
+## 10. User Sandbox Configuration・Resolution・Effective Policy設計
### 10.1 schema概要
```yaml
schema_version: 1
@@ -453,21 +472,21 @@ ollama:
planner:
max_tasks: 32
sandbox:
- filesystem:
- read: []
- write: []
- execute: []
- delete: []
- process:
- allow_shell: true
- approvals: []
- max_processes: 16
+ profile: development
+ workspaces: []
+ mounts: []
+ execution:
+ executables: []
+ shells: []
network:
- mode: deny
- allow: []
+ access: none
+ local_endpoints: []
+ restricted_endpoints: []
resources:
- memory_mb: 4096
- process_timeout_seconds: 1800
+ profile: normal
+ memory_mb:
+ process_limit:
+ execution_timeout_seconds:
file_edit:
memory_backup_limit_mb: 16
limits:
@@ -484,59 +503,90 @@ logging:
directory: logs
```
base YAMLと同じdirectoryに`agent.local.yaml`が存在する場合、YAML nodeを読み取ってmapを再帰mergeし、scalarとsequenceはlocal nodeで置換してからtyped configurationへdeserializeする。unknown propertyを含むvalidationはmerge後に行い、全相対pathのbaseはbase YAMLのdirectoryとする。local file、そこから参照するUser System PromptおよびSkill directoryはRelease Bundleのpublish入力に含めない。
-### 10.2 Filesystem entry
-各list itemはpath文字列とする。起動時に絶対canonical pathへ正規化して保持する。file/directoryは実在時に種別を記録し、未作成write targetは最も近い既存親directoryまで検査する。
-### 10.3 Process approval entry
+### 10.2 Workspace・Mount・Profile
+- `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
```yaml
-approvals:
- - path: C:/Program Files/Git/cmd/git.exe
- sha256: "<64 hex chars>"
- - path: C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe
- sha256: "<64 hex chars>"
+execution:
+ executables: [python, git]
+ shells: [powershell]
```
-署名はaudit metadataとして取得可能だが、v1のallow判定はpath + SHA-256を必須条件とする。
+- 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`だけを許可する。
### 10.4 Network entry
```yaml
network:
- mode: allow
- allow:
+ access: restricted
+ local_endpoints: []
+ restricted_endpoints:
- host: github.com
ports: [443]
- - host: api.github.com
- ports: [443]
```
-`allow` modeでもlist外はdeny。`deny`ではlistを無視して全denyする。localhostも特別扱いしない。
+`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.7 Effective Sandbox Policy
+
+確認済みResolution Resultと現在のApproval Storeを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。
+- Artifact RootとRuntime Root: Entry分類およびACL生成に使用するResolved Root。
+- Network: `none|local|restricted`とTask開始時にsnapshot化するendpoint。
+- 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や設定を変更しない。
---
## 11. Windows強制設計
-### 11.1 AppContainer
+### 11.1 Less Privileged AppContainer
- Application Profileを固定名で作成/取得する。
- 起動時にprofile SIDを解決する。
- Taskごとにprofileを作り直さない。
-- AppContainer外の通常user tokenに戻るbreakawayを許可しない。
+- standard `CreateProcess`の`PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES`に加え、`PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY=PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT`を指定してLPAC tokenを生成する。
+- Experimental CreateProcessInSandbox、AppLocker、WDACへ依存しない。LPAC生成または互換性検査失敗時に通常AppContainerへfallbackしない。
+- baseline capabilityは`registryRead`と、PowerShell Runtimeが起動時のETW provider登録に必要とする`lpacInstrumentation`とする。Registry writeと`lpacCom`は付与しない。Network capabilityはEffective Network Policyが必要な場合だけ付与する。`lpacInstrumentation`追加後もRegistry writeとCOM activationの実拒否をWindows Security suiteで検証する。
+- AppContainer外の通常user tokenに戻るbreakawayを許可しない。DescendantはLPAC tokenを継承する。
+- `CreateProcess`へBrokerのenvironment blockをそのまま継承しない。Windows/runtime起動に必要なsystem/profile path、`ComSpec`、System32だけのPATH、Task `cwd`を指すTEMP/TMPと`DOTNET_CLI_HOME`、非対話かつnetwork非依存の.NET初回起動・telemetry・workload resolver値、Process Tree外の共有MSBuild server/node reuseを無効化する値から成る明示的なUnicode environment blockを渡し、任意のtoken、password、SSH/API credential環境変数を除外する。profile path自体はFilesystem Capabilityを付与しない。
### 11.2 NTFS ACL
-- Policy Engineが許可pathから必要ACEを導出する。
-- 許可pathがdirectoryの場合、そのrootへ`ContainerInherit | ObjectInherit`付きのApplication SID ACEを1件だけtransactionalに追加し、既存およびTask中に作成されたdescendantへ継承させる。descendantごとのDACL列挙・snapshot・明示ACE追加は行わない。
+- Effective Sandbox PolicyのFilesystem RootからApplication SID ACEを導出する。
+- read-only directoryはdirectory traverse/list/readとfile readを別ACEとして付与し、fileのExecuteFileを含めない。execute rootだけがfile read/execute ACEを取得する。
+- development Workspace/Artifact Rootはread/write/delete/execute、Runtime RootとResolved `%SystemRoot%/System32`はread/execute、Mountは指定accessのみを取得する。
+- 許可pathがdirectoryの場合、rootへのinheritance ACEで既存およびTask中に作成されたdescendantを覆い、descendantごとのDACL列挙は行わない。
- 許可pathがfileの場合は、そのfileだけへ継承なしACEを追加する。異なる権限を持つnested rootは別scopeとして逆順cleanupする。
- protected DACLを持つdescendantの継承保護は解除しない。そのpathはOS拒否のままとし、Sandbox権限を暗黙に拡張しない。
- rootの元security descriptorをRecovery Journalへ1件記録する。
- Task終了で追加分を除去し、元状態との整合を検査する。
### 11.3 Job Object
- TaskごとにJob Objectを1つ作成する。
-- root sandbox processをJobへ割り当てる。descendantを同一Jobへ継承させる。
+- Entry ProcessをJobへ割り当て、全Descendantを同一Job chainへ継承させる。
- breakawayを許可しない。
- process count / memory limitをJobへ設定する。
- `KILL_ON_JOB_CLOSE`相当を利用し、application終了時にprocess treeを残さない。
-### 11.4 Process approval
-Sandbox Brokerは実行直前にcanonical pathを取得し、ファイルを開いた状態でSHA-256を計算し、approvalと一致確認してから起動する。TOCTOU低減のため、検証対象と実行対象のpath解決がずれないよう実装時にWin32のfile identity確認を追加する。具体API選択は実装時に確定する。
+### 11.4 Entry Process分類・Executable Approval
+- AliasはApproval Storeの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方針
WFP実装は複雑な汎用Firewallにしない。設計上の変換規則だけを固定する。
-1. `deny`ならApplication Profile/Task processに対するoutbound blockを作成する。
-2. `allow`ならdefault blockを置き、列挙hostをTask開始時にDNS解決する。
+1. `none`ならnetwork capabilityを付与せず、Application Profile/Sandbox Process Treeへoutbound blockを作成する。
+2. `local`は列挙portをIPv4/IPv6 Loopbackへ、`restricted`は列挙hostをTask開始時にIP解決する。
3. 解決したIP集合とportの組にだけallow filterを作成する。
4. Task実行中はそのIP snapshotを使用し、複雑な動的DNS追随は行わない。
5. DNS解決失敗はdeny。
6. Task終了でTask固有WFP filterを削除する。
-7. localhostはallow-listに明示されない限り許可しない。
+7. LoopbackもEffective Network Policyに明示されない限り許可しない。LANをlocalへ含めない。
具体的なWFP layer、sublayer、filter condition、transaction APIは実装時にCodexがWindows SDKを確認して決定し、Security Integration Testで仕様を検証する。
### 11.6 Named Pipe
Sandbox workerとのIPCが必要な場合はtask固有Named Pipeを使用し、DACLをApplication SIDとBrokerだけに限定する。TCP localhostを内部IPCに使わない。
@@ -546,7 +596,7 @@ Sandbox workerとのIPCが必要な場合はtask固有Named Pipeを使用し、D
| 性質 | 本システムでの意味 |
| --- | --- |
| Atomicity | Task Resource適用が途中失敗した場合、適用済み変更をrollbackする。 |
-| Consistency | SandboxPolicyとSecurity Invariantを満たす状態だけを実行可能状態とする。 |
+| Consistency | Effective Sandbox PolicyとSecurity Invariantを満たす状態だけを実行可能状態とする。 |
| Isolation | Task固有ResourceをTask IDで識別し、他Task cleanupで誤削除しない。 |
| Durability | Task Resource自体の永続化ではなく、cleanupに必要なjournalをcrashに耐えて保持する。 |
### 12.2 状態機械
@@ -625,7 +675,7 @@ call ID、session ID、task ID、Planner Task ID、phase、iteration、replan番
### 15.3 Audit JSONL
Auditはappend-only JSONLを採用する。1 event = 1 line。
```json
-{"ts":"...","event":"process.start","session_id":"...","task_id":"...","exe":"...","sha256":"...","args":["..."],"decision":"allow"}
+{"ts":"...","event":"process.start","session_id":"...","task_id":"...","exe":"...","sha256":"...","arguments":"redacted","argument_count":2,"decision":"allow"}
{"ts":"...","event":"file.write","task_id":"...","path":"...","before_sha256":"...","after_sha256":"..."}
{"ts":"...","event":"policy.deny","task_id":"...","kind":"network","target":"example.com:443","reason":"not_allowlisted"}
```
@@ -672,7 +722,7 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio
- locale baseline/placeholder
- Ollama endpoint URI
- Sandbox path canonicalization
-- process approval path/hash format
+- Executable Approval StoreのAlias/Logical Path/Resolved Path/hash format
- network host/port syntax
- resource limit range
- Fixed/User prompt file state
@@ -684,8 +734,8 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio
| TokenPERT | O<=M<=P<=budget、式のunit test |
| TokenCPM | 既知DAGに対するcritical path unit test |
| Filesystem | allow/deny/reparse point integration test |
-| Process | hash mismatch、path mismatch、shell toggle |
-| Network | deny loopback、allow-list host/port |
+| Process | Alias不明、Approval不足、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 |
| Localization | fallback、unknown key fatal、placeholder validation |
@@ -693,26 +743,30 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio
## 19. Security Test Matrix
| ID | ケース | 期待 |
| --- | --- | --- |
-| ST-01 | read許可file | 成功 |
-| ST-02 | read未許可file | ACCESS_DENIED |
-| ST-03 | write許可file | 成功 |
-| ST-04 | delete権限なしdelete | 拒否 |
+| ST-01 | readonly Workspaceのread | 成功 |
+| ST-02 | readonly Workspaceのwrite/delete/execute | すべて拒否 |
+| ST-03 | development Workspaceのread/write/delete | 成功 |
+| ST-04 | development Workspace ArtifactのEntry起動 | Approvalなしで成功 |
| ST-05 | junctionで許可外へ脱出 | 拒否 |
-| ST-06 | symlink chainで許可外へ脱出 | 拒否 |
+| ST-06 | symlink chain/cycle | 許可外は拒否、cycleはfail-closed |
| ST-07 | 承認exe/hash一致 | 成功 |
| ST-08 | exe更新/hash不一致 | 拒否 |
-| ST-09 | shell disabledでPowerShell | 拒否 |
-| ST-10 | 子が未承認exe起動 | 拒否 |
-| ST-11 | Job close | 全descendant終了 |
-| ST-12 | network denyで127.0.0.1 | 拒否 |
-| ST-13 | network allowで許可host:port | 成功 |
-| ST-14 | network allowで未許可port | 拒否 |
+| ST-09 | Approved Shell分類 | cmd.exe/powershell.exe/pwsh.exeだけ直接起動可 |
+| ST-10 | Descendantが個別未承認Executableを起動 | 同じLPAC identity/Job内で成功 |
+| ST-11 | Job close/breakaway/process/memory/timeout | breakaway不可、制限時は全Process Tree停止 |
+| ST-12 | Network noneで127.0.0.1 | 拒否 |
+| ST-13 | Network local/restrictedで許可address:port | 成功 |
+| ST-14 | Network local/restrictedでPolicy外port | 拒否 |
| ST-15 | MCPからpolicy変更試行 | schema/機能上不可能 |
-| ST-16 | Ollama tool arg path脱出 | Policyで拒否 |
+| ST-16 | Ollama Internal Tool CallのResolved Path脱出 | Policyで拒否 |
| ST-17 | crash after ACL apply | 再起動cleanup |
| ST-18 | crash after WFP apply | 再起動cleanup |
-| ST-19 | malformed locale | 起動失敗 |
-| ST-20 | non-loopback HTTP bind | 起動失敗 |
+| ST-19 | LPAC baselineでRegistry read/writeとCOM | readのみ成功、write/COM拒否 |
+| 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 |
---
## 20. エラー分類
| Category | 例 | Task結果 | Retry |
@@ -724,7 +778,7 @@ closed Sessionは再利用しない。プロセス再起動後に以前のSessio
| tool_failure | process nonzero/patch mismatch | running→replan | 最大同一5 |
| resource_limit | timeout/memory/process limit | failedまたはreplan | 原則No |
| recovery_failure | ACL/WFP cleanup不能 | application fatal | No |
-errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MCPにはlocalized textを返す。
+errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MCPにはlocalized textを返す。Sandbox Configuration/Resolution/Approvalの失敗は少なくとも`configuration.invalid`、`sandbox.resolution_unconfirmed`、`sandbox.resolution_changed`、`executable.approval_missing`、`executable.resolution_changed`、`executable.hash_changed`、`executable.shell_unsupported`、`process.lpac_unsupported`を既存分類へ統合し、category、対象、解決方法を保持する。
---
## 21. 実装順序
| Phase | 実装 | 完了条件 |
@@ -735,7 +789,7 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC
| 4 | Internal Tool dispatcher | tool schemaとToolUseGuard |
| 5 | Filesystem/Process Sandbox | ACL/AppContainer/Job integration test |
| 6 | Resource Transaction/Recovery | 強制crash recovery test |
-| 7 | WFP | deny/allow-list integration test |
+| 7 | WFP | none/local/restricted integration test |
| 8 | Vision/Streamable HTTP | image optional、loopback HTTP |
| 9 | Audit/Hardening | security matrix全PASS |
| 10 | Release/Operational E2E | self-contained Bundle単独起動、stdio/HTTP実利用 |
@@ -744,7 +798,7 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC
- `dotnet publish -c Release -r win-x64 --self-contained true` のfolder publishを配布元とする。
- Bundleはversion付きZIP、展開済みdirectory、file manifest、SHA-256 checksumから成る。
- 起動時の相対pathは実行ファイルdirectoryを基準に解決し、ソースツリーを参照しない。
-- 必須Gateはlocal Ollamaだけで実行し、cloud modelは任意互換試験に限定する。
+- 現行Operational E2Eは配布既定の`deepseek-v4-flash:cloud`を使用する。rate limit、接続失敗、model unavailableをapplication PASSとして扱わない。
---
## 22. Codex実装制約
- 新規NuGet dependencyを勝手に追加しない。追加時は標準ライブラリで不能な理由を示す。
@@ -820,17 +874,18 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC
| ollama.options.* | typed runtime options |
| ollama.extra_options.* | future passthrough options |
| planner.max_tasks | 32 default |
-| sandbox.filesystem.read | read allow-list |
-| sandbox.filesystem.write | write allow-list |
-| sandbox.filesystem.execute | execute path allow-list |
-| sandbox.filesystem.delete | delete allow-list |
-| sandbox.process.allow_shell | shell toggle |
-| sandbox.process.approvals | path+sha256 approvals |
-| sandbox.process.max_processes | Job process limit |
-| sandbox.network.mode | deny/allow |
-| sandbox.network.allow | host/port list |
-| sandbox.resources.memory_mb | Job memory limit |
-| sandbox.resources.process_timeout_seconds | process timeout |
+| 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.network.access | none/local/restricted |
+| sandbox.network.local_endpoints | Loopback port list |
+| sandbox.network.restricted_endpoints | host/port list |
+| sandbox.resources.profile | constrained/normal/extended |
+| sandbox.resources.memory_mb | optional memory override |
+| sandbox.resources.process_limit | optional Job process override |
+| sandbox.resources.execution_timeout_seconds | optional execution timeout override |
| file_edit.memory_backup_limit_mb | memory/temp threshold |
| localization.directory | locale directory |
| localization.fallback | en |
@@ -875,7 +930,7 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC
- [ ] process hashは実行直前に再計算しているか。
- [ ] shell有効でもFilesystem/Network policyを迂回できないか。
- [ ] Job breakawayがないか。
-- [ ] Network allow-list外がdefault denyか。
+- [ ] Effective Network Policy外がdefault denyか。
- [ ] WFP/ACL変更前にjournalを書いているか。
- [ ] 起動時Recoveryがserveより前か。
- [ ] file rollback後にhash検証しているか。
diff --git a/doc/Specifications.md b/doc/Specifications.md
index 9f6e9b8..79cb653 100644
--- a/doc/Specifications.md
+++ b/doc/Specifications.md
@@ -77,12 +77,22 @@ IPA「厳密な仕様記述入門」の考え方に従い、課題・仕様・è¨
| Token CPM | TaskGraph上でexpected token costを重みとしてcritical pathを求めるモデル。 |
| Token Budget Class | 1K~128KのTask単位token予算階級。Ollama `num_ctx`とは別概念。 |
| Session | 会話、TaskGraph、Execution Resultをメモリ上に保持する論理単位。 |
-| SandboxPolicy | Filesystem、Process、Network、Resourceの許可範囲を表すホワイトリスト型ポリシー。 |
-| Sandbox | SandboxPolicyをWindows機構で強制したプロセス実行環境。 |
+| User Sandbox Configuration | Workspace、Mount、Execution、Network、Resourceを利用者の意図で表すsemantic設定。 |
+| Sandbox Environment Resolution | Logical Path、Executable Alias、Runtime等をHost上のResolved Pathへ解決する処理。 |
+| Effective Sandbox Policy | ResolutionとExecutable Approvalの結果から生成され、Policy Engineが使用する解決済みホワイトリスト。従来のSandboxPolicyはこの意味でのみ使用する。 |
+| Sandbox | Effective Sandbox PolicyをWindows機構で強制したExecution Environment。 |
| Sandbox Broker | プロセス起動・許可判定・Windows強制機構の調停を担当する信頼境界。 |
-| Application Profile | アプリケーション固定のAppContainer profile。Taskごとには作成・削除しない。 |
+| Application Profile | アプリケーション固定のLess Privileged AppContainer (LPAC) profile。Taskごとには作成・削除しない。 |
| Task Resource | Task中だけ存在するACL、WFP filter、Job Object、一時ファイル等。 |
-| Approval | 実行ファイルのcanonical pathとSHA-256を信頼対象として登録する操作。 |
+| Executable | Windows上でProcessとして起動可能な実行対象。Internal Toolとは区別する。 |
+| Executable Alias | 利用者がHost Executableを指定する人間向け名称。Alias解決だけではApprovalにならない。 |
+| Approved Entry Executable | BrokerがEntry Processとして直接起動できる、Executable Approval済みHost Executable。 |
+| Executable Approval | Entry ExecutableのResolved PathとSHA-256を人間が信頼対象として登録する操作。 |
+| Runtime / Runtime Root / Runtime Component | Entry Executableの動作に必要な実行環境、そのFilesystem root、その配下のExecutable/resource。 |
+| Workspace Artifact | development Workspace内で生成または配置され、Host Executable ApprovalなしでSandbox Entry Processとして起動できる実行物。 |
+| Shell | command interpreterとして動作するExecutable category。 |
+| Sandbox Process Tree | Brokerが起動したEntry Processと、同じconfinementを継承する全Descendant Process。 |
+| Logical Path / Resolved Path | 利用者が指定したpathと、reparse resolution後にPolicy評価へ使用する実体path。 |
| Fixed System Prompt | 実行ファイルに組み込まれ、利用者が変更できない最上位指示。 |
| User System Prompt | 設定で指定可能だがFixed System Promptを上書きできない追加指示。 |
| Skill | LLMへの補助指示を記述したMarkdown。実行権限そのものを与えない。 |
@@ -100,8 +110,8 @@ IPA「厳密な仕様記述入門」の考え方に従い、課題・仕様・è¨
| UC-02 | MCP Client | Task状態を照会する | 状態と、完了時には最終回答をテキストで得る |
| UC-03 | 利用者 | 画像付き依頼を送信する | Vision対応modelなら処理される |
| UC-04 | 利用者 | 言語を指定して起動する | CLI/診断/MCP応答/LLM最終回答が指定言語になる |
-| UC-05 | 管理者 | SandboxPolicyを設定する | 未許可能力はdenyされる |
-| UC-06 | 管理者 | 実行ファイルを承認する | pathとSHA-256一致時だけ起動できる |
+| UC-05 | 利用者 | User Sandbox Configurationを設定・確認する | semantic設定からEffective Sandbox Policyが可視化され、未許可能力はdenyされる |
+| UC-06 | 利用者 | Entry Executableを承認する | Alias、Resolved Path、SHA-256一致時だけBrokerから直接起動できる |
| UC-07 | Agent | 計画を作成・更新する | TaskGraphがDAGを維持しToken CPMを再計算する |
| UC-08 | Agent | Internal Toolを呼ぶ | Policy検査後にのみ副作用が発生する |
| UC-09 | システム | 異常終了から復旧する | 残留Task Resourceを除去してから受付開始する |
@@ -171,9 +181,16 @@ v1の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>
```
-command省略時、および先頭引数がoptionの場合は `serve` として扱う。CLIは起動方式と表示言語を変更するためのI/Fであり、SandboxPolicyやOllama sampling値を個別overrideするI/Fにしない。
+command省略時、および先頭引数がoptionの場合は `serve` として扱う。`resolve`、`list`、`show`、`check`はread-onlyとする。`approve`、`revoke`、`confirm`は差分表示後の対話確認を既定とし、明示的な`--yes`を許可してAuditへ記録する。MCP ClientおよびLLMからこれらの変更操作を呼び出せない。
### 5.7 MCP 2026-07-28とTasks Extension
@@ -319,22 +336,25 @@ Context要約を開始する利用率閾値は設定可能でなければならã
- frontmatterなしのSkillは推測せずrejectする。malformed YAML、duplicate skill nameは起動/check時fatalとする。
- Progressive Disclosure: Stage 1 Discovery (name + descriptionのみ) → Stage 2 Selection (LLM) → Stage 3 Instructions Load (選択SKILL.md本文) → Stage 4 Resources (scripts/references/assets)。
- Skill選択はLLMにより行う。選択可能集合は設定されたSkill directoryに限定する。Runtimeは選択結果を検証し、unknown Skill名を読み込まない。
-- Skillロードは権限付与を意味しない。`allowed-tools` metadataはSandbox permissionとして解釈しない。
-- Skillに `scripts/` が含まれていても自動実行許可しない。実行には通常どおりFilesystem execute capability、Process Approval、path + SHA-256、Sandbox Brokerを必須とする。
+- Skillロードはcapability付与を意味しない。`allowed-tools` metadataはSandbox Capabilityとして解釈しない。
+- Skillに `scripts/` が含まれていても自動実行許可しない。Entry起動には通常どおりEffective Sandbox Policy上のFilesystem execute capability、Entry Process分類、必要なExecutable Approval、Sandbox Brokerを必須とする。
- Skill directory内resourceについてcanonicalization、reparse point検査、Skill root外へのescape禁止を行う。
- Skill選択・本文ロードはInternal Toolとして公開せず、Agent Runtime / Prompt Context Managementの責務として実装する。
- Internal Toolの具体的なcatalogとschemaは設計事項とする。MCP Clientへ公開せず、Internal Toolによる副作用は必ずPolicy検査を通過しなければならない。
---
-## 11. SandboxPolicy仕様
+## 11. User Sandbox Configuration・Effective Sandbox Policy仕様
### 11.1 基本原則
-- **ホワイトリスト方式**を採用する。未指定能力はdenyする。
-- 子・孫プロセスは親より強い権限を取得できない。
-- LLMはSandboxPolicyを変更できない。
-- Policy変更は設定変更とアプリ再起動を必要とする。
+- User Sandbox Configurationは`readonly`または`development` Profile、Workspace、Mount、Execution、Network、Resourceを表す。
+- Sandbox Environment ResolutionとExecutable Approval Resolutionの成功後だけEffective Sandbox Policyを生成する。
+- Effective Sandbox PolicyはResolved Filesystem Rootのcapabilityと由来、Approved Entry Executable、Runtime Root、Artifact Root、Network endpoint、Resource limit、固定baseline capabilityを利用者が確認可能な形で保持する。
+- **ホワイトリスト方式**を採用し、未指定能力はdenyする。
+- Descendant ProcessはEntry Processと同じLPAC、Filesystem、Network、Job confinementを継承し、親より強いcapabilityを取得できない。
+- LLMとMCP ClientはUser Sandbox Configuration、Resolution Result、Executable Approval、Effective Sandbox Policyを変更できない。
+- User Sandbox Configuration変更はconfig別Resolution Resultの再確認とアプリ再起動を必要とする。
- v1で開放可能なのはFilesystem、Process、Network、Resourceとする。その他能力はdenyとする。
### 11.2 Filesystem Policy
@@ -343,37 +363,43 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| --- | --- |
| read | ファイル内容読取、directory列挙。 |
| write | create、overwrite、renameを含む。deleteは含まない。 |
-| execute | 対象ファイルを実行対象として利用できる。Process approvalも別途必要。 |
+| execute | 対象fileをExecutable imageとして利用できる。Entry Processの分類に応じてExecutable Approvalも必要。 |
| delete | ファイルまたはdirectoryを削除できる。 |
| ID | 要件 |
| --- | --- |
-| FS-01 | 指定粒度はdirectoryまたはfile。workspaceという特別な権限単位は持たない。 |
-| FS-02 | pathはcanonicalize後にPolicy評価する。 |
-| FS-03 | symlink、junction、その他reparse pointを経由した許可範囲外への脱出を禁止する。 |
+| FS-01 | Workspace Root、Mount Root、Runtime Root、Artifact Root、個別Executable fileをFilesystem Rootへ変換する。 |
+| FS-02 | Logical Pathはreparse chainとcycleを検査してResolved Pathへ変換した後にPolicy評価する。 |
+| FS-03 | 未確認または確認後に解決先が変化したLogical Pathを使用せず、許可範囲外への脱出を禁止する。 |
| FS-04 | write権限だけではdeleteできない。 |
| FS-05 | ACLとAppContainerの両方で強制可能な形にする。 |
| FS-06 | ディレクトリ作成 (mkdir) はwrite権限で許可する。ディレクトリ削除 (rmdir) はdelete権限で許可するが、空ディレクトリのみとする。ディレクトリ列挙 (list) はread権限で許可する。 |
| FS-07 | renameはsource/destination双方にwrite権限を要求する。destination既存時は拒否する。cross-volume renameは不支持とする。 |
+| FS-08 | readonly ProfileのWorkspaceはreadのみ、development ProfileのWorkspaceはread/write/deleteおよびWorkspace Artifact実行を許可する。 |
+| FS-09 | Mount accessはread_only、read_write、read_write_deleteのいずれかとし、MountからのExecutable起動を許可しない。 |
+| FS-10 | read Filesystem CapabilityをWindows ACL execute permissionへ暗黙変換しない。 |
### 11.3 Process Policy
| ID | 要件 |
| --- | --- |
-| PRC-01 | 実行対象はcanonical pathとSHA-256が承認記録と一致しなければならない。 |
-| PRC-02 | 実行ファイル更新でhashが変化した場合は自動許可せず再承認を要求する。 |
-| PRC-03 | path変更も再承認を要求する。 |
-| PRC-04 | cmd.exe、powershell.exe、pwsh.exeは機能上デフォルト許可とするが、承認照合を免除しない。 |
-| PRC-05 | shell categoryは設定で禁止可能。 |
-| PRC-06 | すべてのdescendant processに同等以下のSandboxPolicyを適用する。 |
-| PRC-07 | 実行前判定はSandbox Brokerが行う。 |
+| PRC-01 | Approved Entry ExecutableとApproved Shell ExecutableはAlias、Resolved Path、SHA-256がExecutable Approvalと一致しなければならない。 |
+| PRC-02 | Entry Executable更新でhashが変化した場合は自動許可せず再承認を要求する。pathまたはreparse解決先変更も同様とする。 |
+| PRC-03 | 確認済みRuntime Root内のRuntime Componentは個別hashなしでEntry Processとして起動可能とする。 |
+| PRC-04 | development Workspace内のWorkspace ArtifactはHost Executable ApprovalなしでEntry Processとして起動可能とする。 |
+| PRC-05 | External Unknown ExecutableをBrokerからEntry Processとして直接起動してはならない。 |
+| PRC-06 | cmd.exe、powershell.exe、pwsh.exeをShellとして分類し、直接起動にはExecutable Approvalを要求する。Shellの許可は追加capabilityを付与しない。 |
+| PRC-07 | Descendant Processは個別Executable Approvalを要求せず許可するが、同じLPAC token、ACL、WFP、Job Objectを継承しなければならない。 |
+| PRC-08 | 全DescendantをJob chain内へ閉じ込め、breakawayを許可しない。 |
+| PRC-09 | Entry Processの分類とApproval判定はSandbox Brokerが実行前に行う。 |
### 11.4 Network Policy
-- Network modeは `deny` と `allow` の2値とする。
-- `deny` はlocalhostを含む全通信を拒否する。
-- `allow` はhost/port allow-listに一致するoutboundだけを許可する。
-- allow-listが空の `allow` は実質denyとして扱い、全開放を意味しない。
+- Network Access Modeは `none`、`local`、`restricted` の3値とする。
+- `none` はLoopbackを含む全通信を拒否する。
+- `local` は列挙portに対するIPv4/IPv6 Loopbackだけを許可し、LANを含めない。
+- `restricted` は列挙host/portに一致するoutboundだけを許可する。
+- endpointが空またはmodeと矛盾する設定は拒否し、全開放を意味するfallbackを持たない。
- host名はhostsファイルに近い単純な列挙思想で扱い、汎用Firewall言語は実装しない。
### 11.5 Resource Policy
@@ -385,6 +411,8 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| wall-clock timeout | TaskまたはProcessの停止条件として設定可能。 |
| child process | 親と同等以下の制約下でのみ許可。 |
+Resource Profileは`constrained = 1024 MiB/4 Process/300秒`、`normal = 4096 MiB/16 Process/1800秒`、`extended = 8192 MiB/32 Process/3600秒`とし、各正数値を個別override可能とする。
+
### 11.6 v1で明示denyする能力
- Registry write
@@ -411,7 +439,9 @@ Context要約を開始する利用率閾値は設定可能でなければならã
## 13. Application Profile・Task Resource仕様
-- AppContainer profileはapplication固定とする。
+- Application Profileはapplication固定のLPACとし、`ALL APPLICATION PACKAGES`由来の既定accessを除外する。
+- 全Profileの固定baselineとしてRegistry readとSandbox Process Tree自身のinstrumentation registrationを許可し、Registry writeとCOM capabilityはdenyする。
+- Resolved `%SystemRoot%/System32`をOS Runtime RootとしてSandbox Process Treeへread/execute公開する。Brokerからの直接Entry起動は別途Executable Approvalを要求する。
- Task終了時にApplication Profileを削除しない。
- Task固有ACL、WFP filter、Job Object、一時ファイル等はTask Resourceとして管理する。
- Task Resourceは正常終了・失敗・cancelのいずれでも残留させない。
@@ -441,13 +471,15 @@ Context要約を開始する利用率閾値は設定可能でなければならã
## 15. Configuration仕様
- 主設定形式はYAML。
-- `schema_version` を必須とする。
+- `schema_version: 1` を必須とする。本改訂前の`filesystem`および`process.approvals`形式は破壊的に廃止し、移行方法付きで拒否する。
- 設定変更の反映には再起動を必須とする。
- Hot Reloadは明示的に非対応。
- CLIの `--config` で設定ファイルpathを選択可能。
- base設定と同じdirectoryに `agent.local.yaml` が存在する場合は任意overrideとして読み込む。mapは再帰mergeし、scalarと配列はlocal側で置換する。merge後の設定全体をvalidationし、相対pathはbase設定directoryを基準に解決する。
- Release Bundleは既定設定だけを配布し、利用者のlocal設定、User System Prompt、Skillをpublishで上書きしてはならない。
-- 起動時にschema、path、hash approval、locale、policyの静的validationを行う。
+- 起動時に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にする。
---
@@ -457,7 +489,7 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| --- | --- |
| Application Log | stderrに出力可能。stdio時stdoutへログを出してはならない。ファイルにも保存する。 |
| LLM Log | promptとresponseを記録する。call、Session、Task、Planner Task、phase、iteration、replan、model、token usageを相関できなければならない。secretを継承しない前提でも、ログ出力先は保護する。 |
-| Audit Log | 通常ログと分離する。Task lifecycle、Tool、process、args、exit code、file write、delete、denied access、network rule、recoveryをSession/Taskと相関して記録する。 |
+| Audit Log | 通常ログと分離する。Task lifecycle、Internal Tool Call、Sandbox Process、秘密情報を除いたargument metadata、exit code、file write、delete、denied access、network rule、recoveryをSession/Taskと相関して記録する。argument本文、画像、Base64、credentialは記録しない。 |
| Token Metrics | Task/Call単位のinput/output/total tokenを記録する。 |
---
@@ -481,17 +513,18 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| ID | 不変条件 |
| --- | --- |
| SEC-INV-01 | LLMの出力だけでは権限を増加できない。 |
-| SEC-INV-02 | MCP ClientはSandboxPolicyを変更できない。 |
+| SEC-INV-02 | MCP ClientはUser Sandbox Configuration、Resolution Result、Executable Approval、Effective Sandbox Policyを変更できない。 |
| SEC-INV-03 | descendant processは親より強い権限を持てない。 |
| SEC-INV-04 | 未許可Filesystem pathへR/W/E/Deleteできない。 |
-| SEC-INV-05 | 承認hashと一致しない実行ファイルを起動できない。 |
-| SEC-INV-06 | Network denyではlocalhostを含め通信できない。 |
-| SEC-INV-07 | Network allowでもallow-list外host/portへ通信できない。 |
+| SEC-INV-05 | 承認hashと一致しないHost ExecutableをBrokerからEntry Processとして直接起動できない。 |
+| SEC-INV-06 | Network noneではLoopbackを含め通信できない。 |
+| SEC-INV-07 | Network local/restrictedでもEffective Network Policy外のaddress/portへ通信できない。 |
| SEC-INV-08 | CredentialをSandboxへ暗黙継承しない。 |
| SEC-INV-09 | Fixed System Promptは利用者入力で置換できない。 |
| SEC-INV-10 | Task終了後にTask Resourceを残さない。 |
| SEC-INV-11 | Recovery未完了状態でMCP受付を開始しない。 |
| SEC-INV-12 | stdio stdoutにはMCP protocol以外を書かない。 |
+| SEC-INV-13 | Sandbox Process TreeのどのProcessもEffective Sandbox Policy外のresourceまたはcapabilityを取得できない。 |
---
@@ -505,7 +538,7 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| NFR-04 | Maintainability | 外部MCP Toolを2個に固定し、内部責務を分離する。 |
| NFR-05 | Dependency Control | 標準ライブラリで実現可能な機能に新規依存を追加しない。 |
| NFR-06 | Portability | v1はWindows 11 x64専用。Windows機構を抽象化し過ぎない。 |
-| NFR-07 | Observability | Task、Tool、Process、Token、Policy denialを追跡可能にする。 |
+| NFR-07 | Observability | Task、Internal Tool Call、Sandbox Process、Token、Effective Sandbox Policy denialを追跡可能にする。 |
| NFR-08 | Localization | 英語・日本語を標準提供し、追加localeをコード変更なしで導入可能。 |
| NFR-09 | Readability | 設定、ログ、仕様語彙を一貫させる。 |
@@ -522,11 +555,13 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| AT-05 | MCP temperature overrideがTask scopeのみで有効。 |
| AT-06 | 非Vision modelへ画像指定時、選択localeのerrorが返る。 |
| AT-07 | 未許可file read/write/deleteがOSまたはBrokerで拒否されAuditに残る。 |
-| AT-08 | reparse point経由で許可path外へ到達できない。 |
-| AT-09 | 承認hash変更後のexe実行が拒否される。 |
-| AT-10 | Network denyでloopbackを含む通信が拒否される。 |
-| AT-11 | allow-list外host/portへの通信が拒否される。 |
-| AT-12 | 子プロセス・孫プロセスがJobとSandbox制約から逸脱できない。 |
+| AT-08 | reparse解決結果が未確認または変更されたLogical Pathは拒否される。 |
+| AT-09 | Approved Entry Executableのhash変更後の直接起動が拒否される。 |
+| AT-10 | Network noneでLoopbackを含む通信が拒否される。 |
+| AT-11 | local/restrictedのEffective Network Policy外address/portへの通信が拒否される。 |
+| AT-12 | Entry/Descendant ProcessがLPACとJob confinementを継承し、breakawayまたはPolicy外resource accessができない。 |
+| AT-21 | Approved Python、Approved PowerShell、Approved .NETから通常のDescendant Processを生成できる。 |
+| AT-22 | development Workspace ArtifactはApprovalなしでEntry起動でき、External Unknown Executableの直接Entry起動は拒否される。 |
| AT-13 | Task crash後、次回起動RecoveryでTask Resourceが除去される。 |
| AT-14 | TaskGraph cycle追加が拒否される。 |
| AT-15 | completed Taskが変更されない。 |
@@ -540,10 +575,10 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| ID | 検証条件 |
| --- | --- |
-| E2E-01 | ソースツリー外へ展開したRelease Bundleだけを使い、local Ollamaとstdio MCP Clientから実際のfixture修正、build、test、日本語応答、Audit追跡、Task Resource cleanupまで完走する。 |
-| E2E-02 | E2E-01と同等の作業をloopback限定Streamable HTTPで完走する。 |
+| E2E-01 | ソースツリー外へ展開したRelease Bundle、Python 3.14 Approval、配布既定model、stdio MCP Clientを使い、複雑なPython programとsubprocessを含むtestの作成・実行、日本語応答、Audit追跡、Task Resource cleanupまで完走する。 |
+| E2E-02 | E2E-01と同等の作業をloopback限定Streamable HTTPのIPv4/IPv6で完走する。 |
-v1の完了には、win-x64向けself-contained Release Bundleが単独起動でき、E2E-01とE2E-02がInternetまたはcloud modelに依存せずPASSすることを必須とする。
+v1の完了には、win-x64向けself-contained Release Bundleが単独でEnvironment Resolutionを実行でき、確認済みOperational配置でE2E-01とE2E-02が`deepseek-v4-flash:cloud`を使ってPASSすることを必須とする。rate limit、接続失敗、model unavailableをPASSへ読み替えない。
---
@@ -573,7 +608,7 @@ v1の完了には、win-x64向けself-contained Release Bundleが単独起動で
## 23. 変更管理
-- 本仕様の変更は `schema_version`、MCP Tool schema、SandboxPolicy、安全不変条件への影響を必ず確認する。
+- 本仕様の変更は `schema_version`、MCP Tool schema、Effective Sandbox Policy、安全不変条件への影響を必ず確認する。
- Design.mdが本仕様と矛盾した場合は本仕様を優先し、Design.mdを修正する。
- 実装都合で仕様を暗黙変更してはならない。必要な場合は先に仕様を変更する。
@@ -603,7 +638,7 @@ v1の完了には、win-x64向けself-contained Release Bundleが単独起動で
- `temperature` overrideを永続設定へ書き戻すこと。
- Legacy HTTP+SSEを互換目的で自動有効化すること。
- Streamable HTTPをloopback外にbindすること。
-- write permissionだけでdeleteを許可すること。
+- write Filesystem Capabilityだけでdeleteを許可すること。
- reparse point解決前の文字列pathだけでallow判定すること。
- hash不一致の実行ファイルを自動再承認すること。
- Network `allow`をunrestricted outboundとして解釈すること。
diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md
index b9cda6d..c2c348e 100644
--- a/plans/ImplementationPlan.md
+++ b/plans/ImplementationPlan.md
@@ -115,10 +115,10 @@
| 2 | MCP Public Interface | stdio、非同期Task受付、`agent_run/status` | G2 |
| 3 | Session / Ollama / Prompt | text会話、Session、token usage、Prompt優先順位 | G3 |
| 4 | Token Planner | TaskGraph、Token PERT/CPM、再計画 | G4 |
-| 5 | Internal Tool / Policy Boundary | Design.mdのTool catalog、ToolUseGuard、Policy Engine | G5 |
+| 5 | Internal Tool / Policy Boundary | Design.mdのInternal Tool catalog、ToolUseGuard、Policy Engine | G5 |
| 6 | Filesystem / Process Sandbox | AppContainer、ACL、Job、hash approval | G6 |
| 7 | Transaction / Recovery | ACL Resource Transaction、File rollback、Recovery Journal | G7 |
-| 8 | Network Sandbox | WFP deny / host-port allow-list | G8 |
+| 8 | Network Sandbox | WFP none / local / restricted | G8 |
| 9 | Vision / Streamable HTTP | optional画像、loopback HTTP | G9 |
| 10 | Audit / Security Hardening | Audit JSONL、全Security Matrix | G10 |
| 11 | Acceptance / Release Audit | AT全件、文書整合、未完了ゼロ確認 | G11 |
@@ -327,25 +327,25 @@ Planner deterministic test全PASS。
- OLL-10〜11
- SEC-INV-01〜02
## 10.2 Internal Tool catalog
-| Tool | Operation |
+| Internal Tool | Operation |
| --- | --- |
| `file_search` | glob / name / text / regex / tail |
| `file_read` | whole / range / metadata |
| `file_edit` | overwrite / patch / atomic_replace / delete |
-| `process_run` | exe + args + cwd + timeout |
+| `process_run` | Executable + args + cwd + timeout |
| `task_update` | add / dependency / estimate |
-個々の `grep`、`find`、`tail` 等を独立Toolにしない。
+個々の `grep.exe`、`find.exe`、`tail.exe` 等を独立Internal Toolにしない。
## 10.3 実装対象
- JSON Schema
- Ollama native tool calling
- Internal Tool Dispatcher
- Policy Engine I/F
-- Tool argument validation
+- Internal Tool argument validation
- ToolUseGuard
- `requires_external_effect` — **Planning フェーズで LLM structured output により Task ごとに判定・設定**(keyword heuristic 廃止)
- same failure fingerprint limit=5
- Plan/Run フェーズ分離: PlanAsync (structured output) → RunAsync (ready Task 順次実行)
-- replan: Policy deny 時に最大3回まで再計画可能
+- replan: Effective Sandbox Policy deny時に最大3回まで再計画可能
- Context Summary: `prompt_eval_count / num_ctx` ≥ 80% で古い Conversation/ExecutionResult を compact
## 10.4 重要な段階制約
このPhaseの副作用backendはfake/in-memory実装を使用可能とし、Windows sandboxを迂回した本物の無制限file/process操作を実装してはならない。
@@ -353,13 +353,13 @@ Planner deterministic test全PASS。
- [ ] MCP ClientからInternal Toolが見えない
- [ ] OllamaにはDesign.md 7章のcatalogだけを公開
- [ ] malformed tool args拒否
-- [ ] Policy deny時に副作用なし
-- [ ] Policy denyは同一Tool呼出しへ再投入せず、許可済み能力内の条件付き再計画を最大3回行い、同一deny・計画未変更・実行可能Taskなし・再計画失敗でfailedとなる
+- [ ] Effective Sandbox Policy deny時に副作用なし
+- [ ] Effective Sandbox Policy denyは同一Internal Tool Callへ再投入せず、許可済みcapability内の条件付き再計画を最大3回行い、同一deny・計画未変更・実行可能Taskなし・再計画失敗でfailedとなる
- [ ] external effect必須Taskでtool未使用ならcompletedにならない
- [ ] 同一失敗5回でfailed
- [ ] pure analysis TaskにはToolを強制しない
### Gate G5
-LLM→Tool→Policyの境界が固定され、Policyを通らない副作用経路が存在しないこと。
+LLM→Internal Tool Call→Policy Engineの境界が固定され、Effective Sandbox Policyを通らない副作用経路が存在しないこと。
---
# 11. Phase 6 — Filesystem / Process Sandbox
## 11.1 対応仕様
@@ -367,39 +367,36 @@ LLM→Tool→Policyの境界が固定され、Policyを通らない副作用経è
- SEC-INV-03〜05、08
- ST-01〜11、ST-16
## 11.2 実装対象
-- Application固定AppContainer Profile
-- Sandbox Broker
-- FilesystemPolicy R/W/E/Delete
-- canonical path
-- reparse point防御
-- NTFS ACL(承認directory rootへの継承ACE、root単位snapshot、protected descendantのfail-safe維持)
-- Process approval canonical path + SHA-256
-- shell toggle
-- Job Object
-- descendant制御
-- process count / memory limit
-- KILL_ON_JOB_CLOSE
+- Application固定LPAC Profileと`ALL APPLICATION PACKAGES` opt-out
+- User Sandbox ConfigurationからEffective Sandbox Policyへの変換
+- Logical Pathのreparse chain解決、config別Resolution 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
+- 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 Process approval
-承認条件は以下を両方満たすこと。
+## 11.3 Executable Approval
+BrokerがApproved Entry ExecutableまたはApproved Shell Executableを直接起動する条件は以下をすべて満たすこと。
```text
-canonical path == approval.path
-SHA-256 == approval.sha256
+Alias == approval.alias
+Resolved Path == approval.resolved_path
+SHA-256 == approval.sha256
```
-hash変更、path変更は再承認を必要とする。
+hash、Logical Path、Resolved Pathの変更は再承認を必要とする。Descendant Processには個別Approvalを要求せず、Sandbox Process Treeのconfinementで制御する。
## 11.4 必須Security Test
-- [ ] ST-01 read許可成功
-- [ ] ST-02 read未許可拒否
-- [ ] ST-03 write許可成功
-- [ ] ST-04 delete権限なし拒否
-- [ ] ST-05 junction脱出拒否
-- [ ] ST-06 symlink chain脱出拒否
-- [ ] ST-07 approved exe成功
-- [ ] ST-08 hash mismatch拒否
-- [ ] ST-09 shell disabled拒否
-- [ ] ST-10 descendant未承認exe拒否
-- [ ] ST-11 Job closeで全descendant終了
-- [ ] ST-16 Ollama tool path脱出拒否
+- [ ] readonly Workspaceでread成功、write/delete/Artifact Entry拒否
+- [ ] development Workspaceでread/write/delete/Artifact Entry成功
+- [ ] Mount accessとEntry起動禁止
+- [ ] reparse chain/cycle、Resolution変更、fingerprint変更をfail-closed
+- [ ] Approved EntryのAlias/Resolved Path/hash一致、stale hash拒否
+- [ ] Runtime Component、Workspace Artifact、OS Runtime、External Unknown Entryの分類
+- [ ] Entry/Descendant tokenが同じLPAC identityでJob chain内に残る
+- [ ] Approved Python→Python subprocess、Approved PowerShell→cmd、Approved .NET→build/testが成功
+- [ ] breakaway、未許可Filesystem、Registry write、COM、Networkを拒否
+- [ ] Job close/cancel/timeoutで全Descendant終了
+- [ ] Ollama Internal Tool CallのResolved Path脱出拒否
## 11.5 監査証跡
Security testでは「例外になった」だけでなく、OS/Brokerで実際に対象資源へ副作用が発生していない証跡を残す。
### Gate G6
@@ -460,7 +457,7 @@ mode=deny
mode=allow
-> default deny
- -> allow-list hostをTask開始時にDNS resolve
+ -> restricted endpoint hostをTask開始時にDNS resolveしてIP snapshot化
-> IP snapshot + portだけallow
```
## 13.3 実装時にCodexが選択してよい範囲
@@ -565,12 +562,11 @@ 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 check`、stdio initialize、loopback HTTP起動を実行する。
+- ソースツリー外の空directoryへ展開し、Bundle内の資源だけで`agent-mcp sandbox resolve`まで実行する。`check`、stdio、HTTPは、利用者がResolutionを確認し必要なExecutableを承認したOperational配置で実行する。
## 16.2.2 Operational E2E Test
-- E2E-01: Release Bundle、local Ollama、実測approval、stdio MCP Clientでfixtureのread/edit/process/build/test、日本語応答、Audit追跡、cleanupまで完走する。
-- E2E-02: 同等の作業をloopback Streamable HTTPで完走し、Legacy SSE、GET SSE、non-loopback bindが存在しないことを確認する。
-- Cloud model試験は任意互換試験とし、Release Gateへ含めない。
+- E2E-01: Release Bundle、`deepseek-v4-flash:cloud`、Python 3.14の実測Approval、stdio MCP Clientで一時fixtureへ複雑なPython programとtestを作成し、Python subprocessを含むtest、日本語応答、Audit追跡、cleanupまで完走する。
+- E2E-02: 同等の作業をloopback Streamable HTTPのIPv4/IPv6で完走し、Legacy SSE、GET SSE、non-loopback bindが存在しないことを確認する。
## 16.3 Requirement Traceability監査
最低限次のマトリクスを完成させる。
| Requirement | Implementation | Test | Result | Evidence |
@@ -622,7 +618,7 @@ AT-01〜AT-20をすべて再実行する。
9. 仕様・設計逸脱0、または承認済み文書更新済み
10. Recovery後のclean state確認
11. publish済みwin-x64 self-contained Release Bundleが単独で起動可能
-12. E2E-01 / E2E-02がlocal OllamaでPASS
+12. E2E-01 / E2E-02が配布既定の`deepseek-v4-flash:cloud`とPython 3.14でPASS
13. READMEだけでfresh directoryから初回セットアップ可能
---
# 17. フェーズ横断テスト分類
diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md
index 6025755..4f901e6 100644
--- a/plans/RequirementTraceability.md
+++ b/plans/RequirementTraceability.md
@@ -1,8 +1,8 @@
# Requirement Traceability
-現行実装の検証対象はcommit `37386b8`(2026-08-11 JST)である。2026-08-09を含む過去の結果は`plans/progress/`に履歴として保持するが、現行PASS判定には使用しない。
+Sandbox Usability Revision後の現行実装を対象とする。commit `37386b8`(2026-08-11 JST)以前の結果は`plans/progress/`と本書末尾に履歴として保持するが、現行PASS判定には使用しない。
-状態は`PASS / PARTIAL / FAIL / NOT REVERIFIED`を使う。現在、Requirement mapping、Acceptance Tests、Security Testsに未説明の`PARTIAL / FAIL / NOT REVERIFIED`はない。
+状態は`PASS / PARTIAL / FAIL / NOT REVERIFIED`を使う。2026-08-12の現行working-tree管理者証跡は`artifacts/evidence/sandbox-usability-final-working-tree-2/`に保存する。
## Requirement mapping
@@ -15,11 +15,11 @@
| 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〜07 | `PathPolicy`, `TaskAclManager`, `WindowsToolBackend` | Elevated Windows Security/Recovery 42件、Operational E2E | PASS |
-| PRC-01〜07 | `ExecutablePolicy`, `RestrictedProcessLauncher`, Job/child restriction | Elevated Windows suite、in-process build/self-test E2E | 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分類、Entry/Descendant LPAC SID・Job、PowerShell/Python/.NET build/test、breakaway、process/memory/timeout | PASS |
| REC-01〜07 | retry、timeout、cancel cleanup、`RecoveryManager` | Core lifecycle、elevated crash/recovery matrix、残留0 | PASS |
-| SEC-INV-01〜05 | deny-by-default、Policy非変更、path/hash/descendant制約 | Core境界、elevated Windows suite、E2E | PASS |
-| SEC-INV-06〜08 | WFP allow/deny、credential非継承 | elevated WFP/security tests、Bundle check、E2E | 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 |
| SEC-INV-09 | Fixed Prompt precedence | prompt tests | PASS |
| SEC-INV-10〜11 | cleanup、Recovery gate | Core cleanup、elevated recovery tests、全E2E後残留0 | PASS |
| SEC-INV-12 | stdio stdout purity | MCP stdio integration、Operational stdio E2E | PASS |
@@ -33,7 +33,7 @@
| ID | Current implementation / test | Status |
|---|---|---|
| AT-01〜06 | MCP stdio/HTTP、公開schema、Task lifecycle、Ollama、Vision拒否 | PASS |
-| AT-07〜13 | Windows backend、reparse、process、WFP、crash recovery | PASS |
+| AT-07〜13、21〜22 | Resolution、LPAC/ACL/Process Tree、WFP、crash recovery | PASS |
| AT-14〜17 | cycle、immutable、PERT/CPM、全phase token集計 | PASS |
| AT-18〜20 | en/ja、追加locale、malformed/unknown locale | PASS |
@@ -41,8 +41,8 @@
| ID | Current implementation / test | Status |
|---|---|---|
-| ST-01〜09 | capability/path/reparse/hash/shell、ACL/AppContainer実効果 | PASS |
-| ST-10〜14 | child restriction、Job timeout、WFP actual traffic | PASS |
+| ST-01〜09 | Profile/Mount/Resolution/Approval、ACL/LPAC実効果 | PASS |
+| ST-10〜14 | Descendant inheritance、Job timeout、WFP actual traffic | PASS |
| ST-15〜16 | MCP非公開境界、Design Tool catalogの名前/schema/Policy境界 | PASS |
| ST-17〜18 | ACL/WFP/loopback crash recovery | PASS |
| ST-19〜20 | malformed locale fatal、non-loopback bind拒否 | PASS |
@@ -53,14 +53,14 @@ Internal Tool testは件数を仕様としてassertしない。`Design.md`のToo
| Verification | Date / privilege | Result |
|---|---|---|
-| Release build | 2026-08-11 / non-elevated / Release | warning 0、error 0 |
-| Agent.Core.Tests | 2026-08-11 / administrator suite | 82 PASS、0 FAIL、0 skip |
-| Agent.Planner.Tests | 2026-08-11 / administrator suite | 10 PASS、0 FAIL、0 skip |
-| Agent.Windows.Tests | 2026-08-11 / administrator | 42 PASS、0 FAIL、0 skip。Security/Recovery/crash matrixを含む |
-| win-x64 self-contained Bundle | 2026-08-11 / publish後administrator check | directory/ZIP/checksum生成、local override非同梱、runtime check PASS。ZIP SHA-256 `8ccc538557dcb96cc95ecae7b1858de3544997b16a5675aacb05a03f539829b1` |
-| Operational E2E | 2026-08-11 / administrator / `deepseek-v4-flash:cloud` | stdio、HTTP IPv4、HTTP IPv6すべてPASS。read/edit/build/self-test、ACL/WFP/temp cleanup確認 |
-| Task Resource repository state | 2026-08-11 | `state/journals`, `state/backups`, `state/mcp-tasks`はいずれも不存在 |
+| 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 |
+| Agent.Windows.Tests | 2026-08-12 / administrator | 53 PASS、0 FAIL、0 skip。LPAC/ACL/Job/WFP/Recovery、PowerShell/Python/.NET Process Treeを含む |
+| win-x64 self-contained Bundle | 2026-08-12 / administrator / fresh展開 | check PASS。既定ZIPにlocal override・local prompt/Skill・固有pathなし。deployment local資産保持。ZIP SHA-256 `f220cbf66d6d2bfd110542cdeec48cd64c97aec549b4ffd999c4746f3324ecb0` |
+| Operational E2E | 2026-08-12 / administrator / `deepseek-v4-flash:cloud`, `think:true`, Python 3.14.3 | stdio、HTTP IPv4、HTTP IPv6すべてPASS。Python実装、子Python unittest、独立検証、ACL/WFP/temp cleanup確認 |
+| Task Resource residual state | 2026-08-12 / administrator | Journal 0、backup 0、published `agent-mcp` process 0 |
| MCP 2026-07-28 conformance runner 0.2.0-alpha.11 | 2026-08-11 / loopback HTTP | application failure 0。適用可能checkはPASS。production fixture非公開とWindows runner終了不具合はrunner limitation 9件として非PASSのまま分離 |
| MCP Tasks conformance runner | 2026-08-11 / loopback HTTP | application failure 0。runner専用fixture不足による未実施27件をrunner limitationとして非PASSのまま分離 |
-証跡は`artifacts/evidence/final-37386b8/`に生成した。conformanceのrunner limitationはRequirement PASSの根拠に使用しておらず、対応する製品機能はCore/MCP integration、Windows suite、Operational E2Eで検証した。
+conformance runnerの行はcommit `37386b8`の履歴であり、旧証跡は`artifacts/evidence/final-37386b8/`に保持する。現行ReleaseのSecurity/Operational証跡は上記2026-08-12記録を正本とする。
diff --git a/plans/progress/Phase6.md b/plans/progress/Phase6.md
index fdfd175..03a6120 100644
--- a/plans/progress/Phase6.md
+++ b/plans/progress/Phase6.md
@@ -1,6 +1,7 @@
# Phase 6 Progress
> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。
+> Sandbox Usability Revisionでchild-process restriction、path/hash設定、shell toggleはLPAC Process Tree、Executable Alias/Approval Store、semantic Execution設定へ置換された。本記録は新設計の受入証跡には使用しない。
- 実装: fixed AppContainer、canonical/reparse policy、能力別ACL、path+SHA-256、Job Object、`PROCESS_CREATION_CHILD_PROCESS_RESTRICTED`、Broker launch。
- 実測: approved PowerShellからの `Start-Process`、Process API、shell、rename/copyの4経路を試し、全unbrokered childをOS restrictionで拒否した。markerなし、Job process不変、非zero親結果、Audit `policy.deny`を確認した。
diff --git a/prompts/user-system.md b/prompts/user-system.md
index 9766b67..482d757 100644
--- a/prompts/user-system.md
+++ b/prompts/user-system.md
@@ -36,9 +36,10 @@
# Running Programs
-- 承認済みの実行fileだけを直接起動する。
+- `process_run.executable`には設定されたExecutable Alias、Runtime Component、またはdevelopment Workspace Artifactを指定する。
- executable、cwd、argsを分け、引数の順序と値を維持する。
-- 作成するprogramやtestからdescendant processを生成しない。
+- build、test、subprocessなど必要なDescendant ProcessはEntry Processと同じSandbox Process Tree内で実行できる。個別承認を要求しない。
+- Descendant ProcessをSandbox外へ逃がす方法、Job Objectからbreakawayする方法、権限を拡張する方法は試さない。
- 依頼なしにpackageをinstallしたりnetworkへ接続したりしない。
- stdout、stderr、exit codeを確認し、期待結果と照合する。
diff --git a/scripts/Launch-ElevatedVerification.ps1 b/scripts/Launch-ElevatedVerification.ps1
new file mode 100644
index 0000000..b4327bd
--- /dev/null
+++ b/scripts/Launch-ElevatedVerification.ps1
@@ -0,0 +1,10 @@
+param([string]$EvidenceRoot = 'artifacts\evidence\sandbox-usability-precommit')
+
+$ErrorActionPreference = 'Stop'
+$verification = (Resolve-Path (Join-Path $PSScriptRoot 'Run-Final-ElevatedVerification.ps1')).Path
+$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$arguments = @(
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-File', ('"' + $verification + '"'),
+ '-EvidenceRoot', $EvidenceRoot
+)
+Start-Process -FilePath 'C:\Program Files\PowerShell\7\pwsh.exe' -Verb RunAs -ArgumentList $arguments -WorkingDirectory $repository -WindowStyle Normal
diff --git a/scripts/Operational-E2E.ps1 b/scripts/Operational-E2E.ps1
index 4b964f1..b253198 100644
--- a/scripts/Operational-E2E.ps1
+++ b/scripts/Operational-E2E.ps1
@@ -3,122 +3,52 @@ param(
[string]$Transport = 'stdio',
[ValidateSet('127.0.0.1', '::1')]
[string]$HttpHost = '127.0.0.1',
- [string]$ReleaseZip = 'build\Release\publish\agent-sandbox-mcp-1.0.0-win-x64.zip'
+ [string]$ReleaseZip = 'build\Release\publish\agent-sandbox-mcp-1.0.0-win-x64.zip',
+ [string]$PythonPath
)
$ErrorActionPreference = 'Stop'
$OutputEncoding = [Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = $OutputEncoding
-$originalTemp = $env:TEMP
-$originalTmp = $env:TMP
-$originalDotnetCliHome = $env:DOTNET_CLI_HOME
-$originalFirstTimeExperience = $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE
-$originalTelemetry = $env:DOTNET_CLI_TELEMETRY_OPTOUT
-$originalAddGlobalToolsToPath = $env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH
-$originalUserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$zip = (Resolve-Path (Join-Path $repository $ReleaseZip)).Path
$root = Join-Path ([IO.Path]::GetTempPath()) ("agent-mcp-operational-e2e-" + [Guid]::NewGuid().ToString('N'))
$bundleRoot = Join-Path $root 'bundle'
$fixture = Join-Path $root 'fixture'
-New-Item -ItemType Directory -Path $bundleRoot,$fixture | Out-Null
+$alias = 'python-e2e-' + [Guid]::NewGuid().ToString('N').Substring(0, 12)
$passed = $false
+$approved = $false
+$resolutionStorePath = $null
+$server = $null
+New-Item -ItemType Directory -Path $bundleRoot,$fixture | Out-Null
$fixtureAclBefore = (Get-Acl -LiteralPath $fixture).Sddl
try {
Expand-Archive -LiteralPath $zip -DestinationPath $bundleRoot
$bundle = (Get-ChildItem -LiteralPath $bundleRoot -Directory | Select-Object -First 1).FullName
- $e2eSkills = Join-Path $bundle 'e2e-skills'
- New-Item -ItemType Directory -Path $e2eSkills | Out-Null
- $program = @'
-namespace OperationalFixture;
-
-public static class Program
-{
- private const string Value = "BUG";
- public static int Main(string[] args)
- {
- if (args.Length == 1 && args[0] == "--self-test") return Value == "FIXED" ? 0 : 1;
- System.Console.WriteLine(Value);
- return 0;
+ $agent = Join-Path $bundle 'agent-mcp.exe'
+ $configPath = Join-Path $bundle 'agent.yaml'
+ if (-not $PythonPath) {
+ $PythonPath = (& uv python find 3.14).Trim()
+ if ($LASTEXITCODE -ne 0 -or -not $PythonPath) { throw 'Python 3.14 could not be resolved with uv.' }
}
-}
-'@
- Set-Content -LiteralPath (Join-Path $fixture 'Program.cs') -Value $program -Encoding utf8NoBOM
- Set-Content -LiteralPath (Join-Path $fixture 'OperationalFixture.csproj') -Encoding utf8NoBOM -Value @'
-<Project Sdk="Microsoft.NET.Sdk">
- <PropertyGroup>
- <OutputType>Exe</OutputType>
- <TargetFramework>net10.0</TargetFramework>
- <UseAppHost>false</UseAppHost>
- <UseSharedCompilation>false</UseSharedCompilation>
- </PropertyGroup>
-</Project>
-'@
- $sandboxTemp = Join-Path $fixture '.sandbox-temp'
- $sandboxCliHome = Join-Path $fixture '.dotnet-home'
- New-Item -ItemType Directory -Path $sandboxTemp,$sandboxCliHome | Out-Null
- $env:TEMP = $sandboxTemp
- $env:TMP = $sandboxTemp
- $env:DOTNET_CLI_HOME = $sandboxCliHome
- $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'
- $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
- $env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = 'false'
+ $pythonLogical = [IO.Path]::GetFullPath($PythonPath)
+ $python = (Get-Item -LiteralPath $pythonLogical).Target
+ if (-not $python) { $python = $pythonLogical }
+ elseif (-not [IO.Path]::IsPathFullyQualified($python)) { $python = [IO.Path]::GetFullPath((Join-Path (Split-Path $pythonLogical) $python)) }
+ $python = (Resolve-Path -LiteralPath $python).Path
+ $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
- $dotnet = (Get-Command dotnet).Source
- $dotnetRoot = Split-Path $dotnet
- $sdkVersion = (& $dotnet --version).Trim()
- $csc = Join-Path $dotnetRoot "sdk\$sdkVersion\Roslyn\bincore\csc.dll"
- $referenceDirectory = (Get-ChildItem (Join-Path $dotnetRoot 'packs\Microsoft.NETCore.App.Ref') -Directory |
- Sort-Object { [Version]$_.Name } -Descending | Select-Object -First 1).FullName + '\ref\net10.0'
- $outputDirectory = Join-Path $fixture 'build'
- New-Item -ItemType Directory -Path $outputDirectory | Out-Null
- $dll = Join-Path $outputDirectory 'OperationalFixture.dll'
- Set-Content -LiteralPath (Join-Path $outputDirectory 'OperationalFixture.runtimeconfig.json') -Encoding utf8NoBOM -Value @'
-{"runtimeOptions":{"tfm":"net10.0","framework":{"name":"Microsoft.NETCore.App","version":"10.0.0"},"rollForward":"LatestPatch"}}
-'@
- $rsp = Join-Path $fixture 'build.rsp'
- Set-Content -LiteralPath $rsp -Encoding utf8NoBOM -Value @"
--nostdlib+
--target:exe
--deterministic+
--debug-
--out:"$dll"
--r:"$(Join-Path $referenceDirectory 'System.Runtime.dll')"
--r:"$(Join-Path $referenceDirectory 'System.Console.dll')"
-"$(Join-Path $fixture 'Program.cs')"
-"@
- & $dotnet exec $csc "@$rsp"
- if ($LASTEXITCODE -ne 0) { throw 'Initial in-process compiler fixture build failed.' }
+ Set-Content -LiteralPath (Join-Path $fixture 'README.md') -Encoding utf8NoBOM -Value @'
+# Graph metrics fixture
- $precompute = Join-Path $root 'precompute'
- New-Item -ItemType Directory -Path $precompute | Out-Null
- $expectedSource = Join-Path $precompute 'Program.cs'
- $expectedDll = Join-Path $precompute 'OperationalFixture.dll'
- $expectedRsp = Join-Path $precompute 'expected.rsp'
- Set-Content -LiteralPath $expectedSource -Value $program.Replace('"BUG"','"FIXED"') -Encoding utf8NoBOM
- Set-Content -LiteralPath $expectedRsp -Encoding utf8NoBOM -Value @"
--nostdlib+
--target:exe
--deterministic+
--debug-
--out:"$expectedDll"
--r:"$(Join-Path $referenceDirectory 'System.Runtime.dll')"
--r:"$(Join-Path $referenceDirectory 'System.Console.dll')"
-"$expectedSource"
-"@
- & $dotnet exec $csc "@$expectedRsp"
- if ($LASTEXITCODE -ne 0) { throw 'Expected fixture hash build failed.' }
- $expectedDllHash = (Get-FileHash -LiteralPath $expectedDll -Algorithm SHA256).Hash
- $fixtureAclBaseline = @{}
- @($fixture) + @(Get-ChildItem -LiteralPath $fixture -Recurse -Force | Select-Object -ExpandProperty FullName) | ForEach-Object {
- $fixtureAclBaseline[$_] = (Get-Acl -LiteralPath $_).Sddl
- }
+Implement `graph_metrics.py` and `test_graph_metrics.py`. The implementation must validate dependencies,
+produce a deterministic topological order, detect cycles, and calculate the weighted critical path.
+'@
- $dotnetHash = (Get-FileHash -LiteralPath $dotnet -Algorithm SHA256).Hash
- $cscHash = (Get-FileHash -LiteralPath $csc -Algorithm SHA256).Hash
$fixtureYaml = $fixture.Replace('\','/')
- $dotnetYaml = $dotnet.Replace('\','/')
$config = @"
schema_version: 1
application:
@@ -139,7 +69,7 @@ agent:
ollama:
endpoint: http://127.0.0.1:11434
model: deepseek-v4-flash:cloud
- think: false
+ think: true
keep_alive: 5m
options:
num_ctx: 32768
@@ -148,27 +78,21 @@ ollama:
planner:
max_tasks: 32
sandbox:
- filesystem:
- read: [$fixtureYaml]
- write: [$fixtureYaml]
- execute: [$dotnetYaml, $($csc.Replace('\','/')), $($dll.Replace('\','/'))]
- delete: [$fixtureYaml]
- process:
- allow_shell: false
- approvals:
- - path: $dotnetYaml
- sha256: $dotnetHash
- - path: $($csc.Replace('\','/'))
- sha256: $cscHash
- - path: $($dll.Replace('\','/'))
- sha256: $expectedDllHash
- max_processes: 8
+ profile: development
+ workspaces:
+ - $fixtureYaml
+ mounts: []
+ execution:
+ executables:
+ - $alias
+ shells: []
network:
- mode: deny
- allow: []
+ access: none
+ local_endpoints: []
+ restricted_endpoints: []
resources:
- memory_mb: 2048
- process_timeout_seconds: 300
+ profile: constrained
+ execution_timeout_seconds: 300
file_edit:
memory_backup_limit_mb: 16
limits:
@@ -180,95 +104,90 @@ localization:
directory: config/locales
fallback: en
skills:
- directory: e2e-skills
+ directory: skills
logging:
directory: logs
max_file_bytes: 10485760
retained_files: 5
"@
- Set-Content -LiteralPath (Join-Path $bundle 'agent.yaml') -Value $config -Encoding utf8NoBOM
- $programPath = (Join-Path $fixture 'Program.cs').Replace('\','/')
- $cscPath = $csc.Replace('\','/')
- $rspPath = $rsp.Replace('\','/')
- $dllPath = $dll.Replace('\','/')
- $buildRequest = @"
-実作業を行ってください。最終応答は日本語にしてください。
-1. file_readで $programPath を読む。
-2. file_editのoverwriteで、他の内容を維持したまま文字列 \"BUG\" を \"FIXED\" に変更する。
-3. process_runで executable=$dotnetYaml, cwd=$fixtureYaml, args=[\"exec\",\"$cscPath\",\"@$rspPath\"] を実行し、descendantを生成しないin-process C# buildのexit code 0を確認する。
-すべてのtool結果を確認してから完了すること。
-"@
- $testRequest = @"
-直前のbuild成果物を独立Taskで実テストしてください。最終応答は日本語にしてください。
-process_runで executable=$dotnetYaml, cwd=$fixtureYaml, args=[\"$dllPath\",\"--self-test\"] を1回実行し、exit code 0を確認してから完了すること。
-"@
+ 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.' }
+ $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
+ if ($LASTEXITCODE -ne 0) { throw 'Effective Sandbox Policy show failed.' }
+ $request = @"
+実作業を行ってください。作業ディレクトリは $fixtureYaml です。
+README.md の仕様に従い、外部パッケージを使わず graph_metrics.py と test_graph_metrics.py を作成してください。
+テストには、正常なDAG、同順位時の決定的な順序、未知dependency、cycle、weighted critical pathを含めてください。
+さらに test_graph_metrics.py 自身が subprocess.run([sys.executable, ...]) で小さな子Pythonを起動して成功を検証するテストを含めてください。
+README.md の読取後、2ファイルの作成、最初のテスト実行、失敗時の修正と再実行は、分割せず単一の実装・検証Taskとして計画してください。
+最後に process_run で executable=$alias, cwd=$fixtureYaml, args=["-m","unittest","discover","-v"] を実行し、全テスト成功を確認してください。
+最終応答は日本語にし、作成ファイルとテスト結果を簡潔に報告してください。
+"@
$clientProject = Join-Path $repository 'scripts\E2EClient\E2EClient.csproj'
if ($Transport -eq 'stdio') {
- dotnet run --project $clientProject -c Release --no-build -- stdio (Join-Path $bundle 'agent-mcp.exe') (Join-Path $bundle 'agent.yaml') $buildRequest $testRequest
- if ($LASTEXITCODE -ne 0) { throw 'E2E-01 stdio client failed.' }
+ dotnet run --project $clientProject -c Release --no-build -- stdio $agent $configPath $request
+ if ($LASTEXITCODE -ne 0) { throw 'E2E stdio client failed.' }
}
else {
- $server = Start-Process -FilePath (Join-Path $bundle 'agent-mcp.exe') -ArgumentList @('serve','--config',(Join-Path $bundle 'agent.yaml'),'--lang','ja','--transport','http') -WorkingDirectory $bundle -WindowStyle Hidden -PassThru
- try {
- $httpAuthority = if ($HttpHost -eq '::1') { '[::1]' } else { $HttpHost }
- $endpoint = "http://${httpAuthority}:18080/mcp"
- $ready = $false
- for ($attempt = 0; $attempt -lt 100; $attempt++) {
- try { $response = Invoke-WebRequest -Uri $endpoint -Method Get -SkipHttpErrorCheck; if ($response.StatusCode -eq 405) { $ready=$true; break } } catch { }
- Start-Sleep -Milliseconds 100
- }
- if (-not $ready) { throw 'HTTP server did not become ready.' }
- dotnet run --project $clientProject -c Release --no-build -- http $endpoint unused $buildRequest $testRequest
- if ($LASTEXITCODE -ne 0) { throw 'E2E-02 HTTP client failed.' }
+ $server = Start-Process -FilePath $agent -ArgumentList @('serve','--config',$configPath,'--lang','ja','--transport','http') -WorkingDirectory $bundle -WindowStyle Hidden -PassThru
+ $httpAuthority = if ($HttpHost -eq '::1') { '[::1]' } else { $HttpHost }
+ $endpoint = "http://${httpAuthority}:18080/mcp"
+ $ready = $false
+ for ($attempt = 0; $attempt -lt 100; $attempt++) {
+ try { $response = Invoke-WebRequest -Uri $endpoint -Method Get -SkipHttpErrorCheck; if ($response.StatusCode -eq 405) { $ready=$true; break } } catch { }
+ Start-Sleep -Milliseconds 100
}
- finally { if (-not $server.HasExited) { $server.Kill($true); $server.WaitForExit() } }
+ if (-not $ready) { throw 'HTTP server did not become ready.' }
+ dotnet run --project $clientProject -c Release --no-build -- http $endpoint unused $request
+ if ($LASTEXITCODE -ne 0) { throw 'E2E HTTP client failed.' }
+ if (-not $server.HasExited) { $server.Kill($true); $server.WaitForExit() }
+ $server = $null
}
- & $dotnet exec $csc "@$rsp"
- if ($LASTEXITCODE -ne 0) { throw 'Post-task fixture build failed.' }
- & $dotnet $dll --self-test
- if ($LASTEXITCODE -ne 0) { throw 'Post-task fixture self-test failed.' }
+ & $python -m unittest discover -v -s $fixture
+ if ($LASTEXITCODE -ne 0) { throw 'Independent Python verification failed.' }
+ foreach ($required in 'graph_metrics.py','test_graph_metrics.py') {
+ if (-not (Test-Path -LiteralPath (Join-Path $fixture $required))) { throw "Expected artifact is missing: $required" }
+ }
if ((Get-ChildItem -LiteralPath (Join-Path $bundle 'state\journals') -File -ErrorAction SilentlyContinue | Measure-Object).Count -ne 0) { throw 'Journal residue detected.' }
if ((Get-ChildItem -LiteralPath (Join-Path $bundle 'state\backups') -Recurse -File -ErrorAction SilentlyContinue | Measure-Object).Count -ne 0) { throw 'Temporary backup residue detected.' }
if ((Get-Acl -LiteralPath $fixture).Sddl -ne $fixtureAclBefore) { throw 'Fixture ACL was not restored byte-for-byte.' }
- $bundleExe = (Join-Path $bundle 'agent-mcp.exe')
- $orphan = Get-Process -Name 'agent-mcp' -ErrorAction SilentlyContinue | Where-Object { try { $_.Path -eq $bundleExe } catch { $false } }
- if ($orphan) { throw 'Orphan agent-mcp process detected.' }
- foreach ($entry in $fixtureAclBaseline.GetEnumerator()) {
- if ((Get-Acl -LiteralPath $entry.Key).Sddl -ne $entry.Value) { throw "Existing resource ACL was not restored byte-for-byte: $($entry.Key)" }
- }
- $audit = Get-Content -LiteralPath (Join-Path $bundle 'logs\audit.jsonl')
- $auditEvents = $audit | ForEach-Object { $_ | ConvertFrom-Json }
+ $auditEvents = Get-Content -LiteralPath (Join-Path $bundle 'logs\audit.jsonl') | ForEach-Object { $_ | ConvertFrom-Json }
$appContainerSid = ($auditEvents | Where-Object Event -eq 'acl.apply' | Select-Object -First 1).Data.sid
- if (-not $appContainerSid) { throw 'Audit did not contain the AppContainer SID.' }
- $processStarts = @($auditEvents | Where-Object Event -eq 'process.start')
- if (-not ($processStarts | Where-Object { $_.Data.managed_entry_path -eq $csc })) { throw 'Audit is missing the approved in-process compiler execution.' }
- if (-not ($processStarts | Where-Object { $_.Data.managed_entry_path -eq $dll })) { throw 'Audit is missing the approved fixture self-test execution.' }
+ if (-not $appContainerSid) { throw 'Audit is missing the AppContainer SID.' }
foreach ($path in @($fixture) + @(Get-ChildItem -LiteralPath $fixture -Recurse -Force | Select-Object -ExpandProperty FullName)) {
if ((Get-Acl -LiteralPath $path).Sddl.Contains($appContainerSid, [StringComparison]::OrdinalIgnoreCase)) {
throw "AppContainer ACL residue detected: $path"
}
}
- $auditText = $audit -join "`n"
- if ($auditText -notmatch 'file.write' -or $auditText -notmatch 'process.start' -or $auditText -notmatch 'network.rule.remove' -or $auditText -notmatch 'acl.restore') { throw 'Audit trace is incomplete.' }
+ if (-not ($auditEvents | Where-Object Event -eq 'process.start')) { throw 'Audit is missing process.start.' }
+ if (-not ($auditEvents | Where-Object Event -eq 'process.exit')) { throw 'Audit is missing process.exit.' }
+ if (-not ($auditEvents | Where-Object { $_.Event -eq 'network.rule.remove' -and $_.Data.residual_filters -eq '0' })) { throw 'Audit is missing zero-residual network.rule.remove.' }
+ $orphan = Get-Process -Name agent-mcp -ErrorAction SilentlyContinue | Where-Object { try { $_.Path -eq $agent } catch { $false } }
+ if ($orphan) { throw 'Operational Bundle left an orphan agent-mcp process.' }
$passed = $true
- Write-Output "Operational E2E $Transport/$HttpHost PASS: $root"
+ Write-Output "Operational E2E $Transport/$HttpHost PASS with $pythonVersion"
}
catch {
Write-Error "Operational E2E $Transport FAILED. Evidence retained at $root. $($_.Exception.Message)"
throw
}
finally {
- $env:TEMP = $originalTemp
- $env:TMP = $originalTmp
- $env:DOTNET_CLI_HOME = $originalDotnetCliHome
- $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = $originalFirstTimeExperience
- $env:DOTNET_CLI_TELEMETRY_OPTOUT = $originalTelemetry
- $env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = $originalAddGlobalToolsToPath
- $currentUserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
- if ($currentUserPath -cne $originalUserPath) {
- Write-Error 'Operational E2E changed the persistent user PATH.'
+ 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 ($passed) { Remove-Item -LiteralPath $root -Recurse -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 a36c2b4..d794bb5 100644
--- a/scripts/Publish-Release.ps1
+++ b/scripts/Publish-Release.ps1
@@ -15,22 +15,18 @@ $bundleName = "agent-sandbox-mcp-$Version-win-x64"
$bundle = Join-Path $outputRootPath $bundleName
$zip = Join-Path $outputRootPath "$bundleName.zip"
$checksum = "$zip.sha256"
+$stagingRoot = Join-Path $outputRootPath (".staging-" + [Guid]::NewGuid().ToString('N'))
+$stagingBundle = Join-Path $stagingRoot $bundleName
New-Item -ItemType Directory -Force -Path $outputRootPath | Out-Null
-if (Test-Path -LiteralPath $bundle) {
- $resolvedBundle = (Resolve-Path -LiteralPath $bundle).Path
- if (-not $resolvedBundle.StartsWith($outputRootPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
- throw 'Existing Release Bundle escaped the validated output root.'
- }
- Get-ChildItem -LiteralPath $resolvedBundle -Force | Remove-Item -Recurse -Force
-}
if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
if (Test-Path -LiteralPath $checksum) { Remove-Item -LiteralPath $checksum -Force }
+New-Item -ItemType Directory -Path $stagingBundle | Out-Null
$appProject = Join-Path $repository 'src\Agent.App\Agent.App.csproj'
dotnet restore $appProject -r win-x64 --locked-mode
if ($LASTEXITCODE -ne 0) { throw "win-x64 locked restore failed with exit code $LASTEXITCODE" }
-dotnet publish $appProject -c Release -r win-x64 --self-contained true --no-restore --output $bundle -p:Version=$Version
+dotnet publish $appProject -c Release -r win-x64 --self-contained true --no-restore --output $stagingBundle -p:Version=$Version
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" }
$required = @(
@@ -38,23 +34,23 @@ $required = @(
'prompts\user-system.md', 'skills', 'README.md'
)
foreach ($relative in $required) {
- if (-not (Test-Path -LiteralPath (Join-Path $bundle $relative))) { throw "Release Bundle is missing $relative" }
+ if (-not (Test-Path -LiteralPath (Join-Path $stagingBundle $relative))) { throw "Release Bundle is missing $relative" }
}
foreach ($relative in @('agent.local.yaml', 'config\agent.local.example.yaml')) {
- if (Test-Path -LiteralPath (Join-Path $bundle $relative)) { throw "Release Bundle must not contain local override input: $relative" }
+ if (Test-Path -LiteralPath (Join-Path $stagingBundle $relative)) { throw "Release Bundle must not contain local override input: $relative" }
}
-$manifestPath = Join-Path $bundle 'publish-manifest.sha256'
-Get-ChildItem -LiteralPath $bundle -File -Recurse |
+$manifestPath = Join-Path $stagingBundle 'publish-manifest.sha256'
+Get-ChildItem -LiteralPath $stagingBundle -File -Recurse |
Where-Object FullName -ne $manifestPath |
Sort-Object FullName |
ForEach-Object {
- $relative = [IO.Path]::GetRelativePath($bundle, $_.FullName).Replace('\', '/')
+ $relative = [IO.Path]::GetRelativePath($stagingBundle, $_.FullName).Replace('\', '/')
$hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $relative"
} | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM
-Compress-Archive -LiteralPath $bundle -DestinationPath $zip -CompressionLevel Optimal
+Compress-Archive -LiteralPath $stagingBundle -DestinationPath $zip -CompressionLevel Optimal
$zipHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
"$zipHash $([IO.Path]::GetFileName($zip))" | Set-Content -LiteralPath $checksum -Encoding ascii
@@ -70,8 +66,8 @@ try {
if (-not $SkipRuntimeCheck) {
Push-Location $freshBundle
try {
- & .\agent-mcp.exe check --config .\agent.yaml --lang en
- if ($LASTEXITCODE -ne 0) { throw "Bundle check failed with exit code $LASTEXITCODE. Run this script in an elevated terminal." }
+ & .\agent-mcp.exe sandbox resolve --config .\agent.yaml --lang en
+ if ($LASTEXITCODE -ne 0) { throw "Bundle resolution check failed with exit code $LASTEXITCODE." }
}
finally { Pop-Location }
}
@@ -80,6 +76,25 @@ finally {
if (Test-Path -LiteralPath $freshRoot) { Remove-Item -LiteralPath $freshRoot -Recurse -Force }
}
+# The ZIP is reproducible defaults only. The deployment directory keeps explicitly local,
+# publish-excluded inputs while all versioned defaults and binaries are replaced.
+try {
+ if (Test-Path -LiteralPath $bundle) {
+ $resolvedBundle = (Resolve-Path -LiteralPath $bundle).Path
+ if (-not $resolvedBundle.StartsWith($outputRootPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Existing deployment Bundle escaped the validated output root.'
+ }
+ Get-ChildItem -LiteralPath $resolvedBundle -Force |
+ Where-Object Name -notin @('agent.local.yaml', 'local', 'logs', 'state') |
+ Remove-Item -Recurse -Force
+ }
+ else { New-Item -ItemType Directory -Path $bundle | Out-Null }
+ Copy-Item -Path (Join-Path $stagingBundle '*') -Destination $bundle -Recurse -Force
+}
+finally {
+ if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot -Recurse -Force }
+}
+
Write-Output "Bundle: $bundle"
Write-Output "ZIP: $zip"
Write-Output "SHA-256: $checksum"
diff --git a/scripts/Restart-PublishedServer.ps1 b/scripts/Restart-PublishedServer.ps1
new file mode 100644
index 0000000..58dd928
--- /dev/null
+++ b/scripts/Restart-PublishedServer.ps1
@@ -0,0 +1,30 @@
+param(
+ [string]$BundlePath = 'build\Release\publish\agent-sandbox-mcp-1.0.0-win-x64',
+ [switch]$StopOnly
+)
+
+$ErrorActionPreference = 'Stop'
+$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$bundle = [IO.Path]::GetFullPath((Join-Path $repository $BundlePath))
+$publishRoot = [IO.Path]::GetFullPath((Join-Path $repository 'build\Release\publish'))
+if (-not $bundle.StartsWith($publishRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'BundlePath must remain inside build\Release\publish.'
+}
+$agent = Join-Path $bundle 'agent-mcp.exe'
+$configuration = Join-Path $bundle 'agent.yaml'
+
+$matching = Get-Process -Name 'agent-mcp' -ErrorAction SilentlyContinue | Where-Object {
+ try { [IO.Path]::GetFullPath($_.Path).Equals($agent, [StringComparison]::OrdinalIgnoreCase) }
+ catch { $false }
+}
+foreach ($process in $matching) {
+ Stop-Process -Id $process.Id -Force
+ $process.WaitForExit()
+ Write-Output "Stopped published agent-mcp PID $($process.Id)."
+}
+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
+Write-Output "Started published agent-mcp PID $($started.Id)."
diff --git a/scripts/Run-Final-ElevatedVerification.ps1 b/scripts/Run-Final-ElevatedVerification.ps1
index 9039c77..627bd65 100644
--- a/scripts/Run-Final-ElevatedVerification.ps1
+++ b/scripts/Run-Final-ElevatedVerification.ps1
@@ -12,6 +12,18 @@ if (-not $evidence.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, [
}
New-Item -ItemType Directory -Force -Path $evidence | Out-Null
$failed = $false
+$metadata = Join-Path $evidence 'metadata.txt'
+$administrator = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator)
+@(
+ "timestamp=$(Get-Date -Format o)"
+ "commit=$(git -C $repository rev-parse HEAD)"
+ "administrator=$administrator"
+ "working_tree_clean=$(-not [bool](git -C $repository status --porcelain))"
+ "os=$([Environment]::OSVersion.VersionString)"
+ "dotnet=$(& dotnet --version)"
+) | Set-Content -LiteralPath $metadata -Encoding utf8NoBOM
+if (-not $administrator) { throw 'Final elevated verification must run as Windows Administrator.' }
function Invoke-VerificationStep {
param([string]$Name, [scriptblock]$Action)
@@ -50,6 +62,14 @@ Invoke-VerificationStep 'e2e-02-http-ipv4' {
Invoke-VerificationStep 'e2e-02-http-ipv6' {
& (Join-Path $PSScriptRoot 'Operational-E2E.ps1') -Transport http -HttpHost ::1
}
+Invoke-VerificationStep 'residual-state' {
+ $journalCount = @(Get-ChildItem -LiteralPath (Join-Path $bundle 'state\journals') -File -ErrorAction SilentlyContinue).Count
+ $backupCount = @(Get-ChildItem -LiteralPath (Join-Path $bundle 'state\backups') -File -Recurse -ErrorAction SilentlyContinue).Count
+ $bundleAgent = Join-Path $bundle 'agent-mcp.exe'
+ $processCount = @(Get-Process -Name agent-mcp -ErrorAction SilentlyContinue | Where-Object { try { $_.Path -eq $bundleAgent } catch { $false } }).Count
+ "journal_count=$journalCount backup_count=$backupCount published_process_count=$processCount"
+ if ($journalCount -ne 0 -or $backupCount -ne 0 -or $processCount -ne 0) { throw 'Task Resource residue was detected.' }
+}
if ($failed) {
Write-Error "Final elevated verification FAILED. Evidence: $evidence"
diff --git a/skills/python/SKILL.md b/skills/python/SKILL.md
index 966a4fd..867d74c 100644
--- a/skills/python/SKILL.md
+++ b/skills/python/SKILL.md
@@ -13,7 +13,7 @@ description: Pythonコードを作成・実行・検証する。計算、デーã
- 小さな修正で`file_edit`の`patch` operationを使う場合、`patch` fieldにはJSON文字列`{"old_text":"置換前","new_text":"置換後"}`を渡す。unified diffや`@@`形式を渡さない。置換前文字列はfile内で正確に1回だけ一致させる。
4. 承認済みのPython executableを直接`process_run`し、cwdとargsを分ける。shell、launcher、環境探索Toolを経由しない。
5. 通常のscriptは`-I <script>`で実行する。単一test scriptは`unittest.main()`を持たせて`-I <test-script>`で実行するか、`-I -m unittest discover -s <test-directory> -p <pattern> -v`で明示的に探索する。isolated modeでimport不能になるfile pathを`-m unittest`へ直接渡さない。
-6. test codeでも`subprocess`、`multiprocessing`、`os.system`を使用しない。CLIはentry functionへ引数とI/Oを渡し、同一process内で検証する。
+6. CLIや並列処理の仕様を検証する必要があれば、Python標準ライブラリの`subprocess`や`multiprocessing`を使用できる。生成したDescendant Processは同じSandbox Process Treeに留め、breakawayや権限拡張を試さない。
7. 正常系に加え、重要な境界条件と失敗条件を決定的なtestで確認する。
8. stdout、stderr、exit codeを確認し、exit code 0と期待結果の両方を満たした場合だけ成功と報告する。
@@ -26,5 +26,5 @@ description: Pythonコードを作成・実行・検証する。計算、デーã
## Safety and Reporting
- Python executableまたは作業directoryの権限が不足している場合は迂回しない。
-- descendant processを生成するコードや、依頼されていないfilesystem/network操作を追加しない。
+- 必要のないDescendant Processや、依頼されていないfilesystem/network操作を追加しない。
- 最終応答では作成・変更したfile、実行したtest、exit code、主要結果、未検証事項を簡潔に示す。
diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj
index 17b7bc9..e3447c5 100644
--- a/src/Agent.App/Agent.App.csproj
+++ b/src/Agent.App/Agent.App.csproj
@@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Agent.Core.Tests" />
+ <InternalsVisibleTo Include="Agent.Windows.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs
index 3fdac9f..3b3fde1 100644
--- a/src/Agent.App/Cli/CommandLine.cs
+++ b/src/Agent.App/Cli/CommandLine.cs
@@ -1,38 +1,104 @@
namespace Agent.App.Cli;
-public enum AppCommand { Serve, Check }
+public enum AppCommand
+{
+ Serve,
+ Check,
+ ExecutableResolve,
+ ExecutableApprove,
+ ExecutableList,
+ ExecutableRevoke,
+ SandboxResolve,
+ SandboxConfirm,
+ SandboxShow
+}
-public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? Language, string? Transport)
+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")
{
public static CommandLine Parse(string[] args)
{
bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal);
- AppCommand command = implicitServe ? AppCommand.Serve : args[0] switch
+ 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")
{
- "serve" => AppCommand.Serve,
- "check" => AppCommand.Check,
- _ => throw new CommandLineException("Expected 'serve' or 'check'.")
- };
+ 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'.");
string? config = null;
string? language = null;
string? transport = null;
- for (int i = implicitServe ? 0 : 1; i < args.Length; i++)
+ string? executablePath = null;
+ string? runtimeRoot = null;
+ bool noRuntime = false;
+ bool yes = false;
+ string format = "text";
+ while (index < args.Length)
{
- string option = args[i];
- if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'.");
- string value = args[++i];
+ string option = args[index++];
switch (option)
{
- case "--config" when config is null: config = value; break;
- case "--lang" when language is null: language = value; break;
- case "--transport" when command == AppCommand.Serve && transport is null && value is "stdio" or "http": transport = value; break;
- case "--transport" when command == AppCommand.Check: throw new CommandLineException("--transport is only valid for serve.");
- case "--transport": throw new CommandLineException("Transport must be stdio or http.");
- default: throw new CommandLineException($"Unknown or duplicate option '{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.");
}
}
- return new CommandLine(command, config, language, transport);
+ 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++];
+ }
}
}
diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs
index c5f720b..b2f94a7 100644
--- a/src/Agent.App/Configuration/AgentConfiguration.cs
+++ b/src/Agent.App/Configuration/AgentConfiguration.cs
@@ -64,13 +64,32 @@ public sealed class OllamaGenerationOptions
}
public sealed class PlannerOptions { public int MaxTasks { get; set; } = 32; }
-public sealed class SandboxOptions { public FilesystemOptions Filesystem { get; set; } = new(); public ProcessOptions Process { get; set; } = new(); public NetworkOptions Network { get; set; } = new(); public ResourceOptions Resources { get; set; } = new(); }
-public sealed class FilesystemOptions { public List<string> Read { get; set; } = []; public List<string> Write { get; set; } = []; public List<string> Execute { get; set; } = []; public List<string> Delete { get; set; } = []; }
-public sealed class ProcessOptions { public bool AllowShell { get; set; } = true; public List<ExecutableApproval> Approvals { get; set; } = []; public int MaxProcesses { get; set; } = 16; }
-public sealed class ExecutableApproval { public string Path { get; set; } = string.Empty; public string Sha256 { get; set; } = string.Empty; }
-public sealed class NetworkOptions { public string Mode { get; set; } = "deny"; public List<NetworkAllowance> Allow { get; set; } = []; }
+public sealed class SandboxOptions
+{
+ public string Profile { get; set; } = "readonly";
+ public List<string> Workspaces { get; set; } = [];
+ public List<MountOptions> Mounts { get; set; } = [];
+ public ExecutionOptions Execution { get; set; } = new();
+ public NetworkOptions Network { get; set; } = new();
+ 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 NetworkOptions
+{
+ public string Access { get; set; } = "none";
+ public List<LocalEndpointOptions> LocalEndpoints { get; set; } = [];
+ public List<NetworkAllowance> RestrictedEndpoints { get; set; } = [];
+}
+public sealed class LocalEndpointOptions { public int Port { get; set; } }
public sealed class NetworkAllowance { public string Host { get; set; } = string.Empty; public List<int> Ports { get; set; } = []; }
-public sealed class ResourceOptions { public int MemoryMb { get; set; } = 4096; public int ProcessTimeoutSeconds { get; set; } = 1800; }
+public sealed class ResourceOptions
+{
+ public string Profile { get; set; } = "normal";
+ public int? MemoryMb { get; set; }
+ public int? ProcessLimit { get; set; }
+ public int? ExecutionTimeoutSeconds { get; set; }
+}
public sealed class FileEditOptions { public int MemoryBackupLimitMb { get; set; } = 16; }
public sealed class LimitOptions { public long WholeReadBytes { get; set; } = 1024 * 1024; public int SearchMaxResults { get; set; } = 1000; public int TailMaxLines { get; set; } = 10000; public double ContextSummaryRatio { get; set; } = 0.8; }
public sealed class LocalizationOptions { public string Directory { get; set; } = "config/locales"; public string Fallback { get; set; } = "en"; }
diff --git a/src/Agent.App/Configuration/ConfigurationException.cs b/src/Agent.App/Configuration/ConfigurationException.cs
index 67f38a5..dd14ea9 100644
--- a/src/Agent.App/Configuration/ConfigurationException.cs
+++ b/src/Agent.App/Configuration/ConfigurationException.cs
@@ -1,4 +1,11 @@
namespace Agent.App.Configuration;
-public sealed class ConfigurationException(string message, Exception? innerException = null)
- : Exception(message, innerException);
+public sealed class ConfigurationException(string message, Exception? innerException = null,
+ string code = "configuration.invalid", string category = "configuration", string? target = null,
+ string? resolution = null) : Exception(message, innerException)
+{
+ public string Code { get; } = code;
+ public string Category { get; } = category;
+ public string? Target { get; } = target;
+ public string? Resolution { get; } = resolution;
+}
diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs
index 26c56e1..6e15904 100644
--- a/src/Agent.App/Configuration/ConfigurationLoader.cs
+++ b/src/Agent.App/Configuration/ConfigurationLoader.cs
@@ -22,6 +22,7 @@ public static partial class ConfigurationLoader
string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml");
if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath))
yaml = MergeYaml(yaml, File.ReadAllText(localPath));
+ RejectLegacySandbox(yaml);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
@@ -61,26 +62,7 @@ public static partial class ConfigurationLoader
if (value.Ollama.Options.NumCtx is <= 0) Throw("ollama.options.num_ctx must be positive.");
if (value.Ollama.Options.Temperature is < 0 or > 2) Throw("ollama.options.temperature must be in 0..2.");
if (value.Planner.MaxTasks is < 1 or > 32) Throw("planner.max_tasks must be in 1..32.");
- if (value.Sandbox.Process.MaxProcesses <= 0 || value.Sandbox.Resources.MemoryMb <= 0 || value.Sandbox.Resources.ProcessTimeoutSeconds <= 0) Throw("sandbox resource limits must be positive.");
- if (value.Sandbox.Network.Mode is not ("deny" or "allow")) Throw("sandbox.network.mode must be deny or allow.");
- foreach (NetworkAllowance allowance in value.Sandbox.Network.Allow)
- {
- if (string.IsNullOrWhiteSpace(allowance.Host) || allowance.Ports.Count == 0 || allowance.Ports.Any(static p => p is < 1 or > 65535)) Throw("sandbox.network.allow contains an invalid host or port.");
- }
- foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals)
- {
- if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters.");
- string approvalPath = Path.GetFullPath(approval.Path);
- bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath));
- if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute.");
- }
- foreach (string executeEntry in value.Sandbox.Filesystem.Execute)
- {
- string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry);
- if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval =>
- string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase)))
- Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals.");
- }
+ ValidateSandbox(value.Sandbox);
if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid.");
if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid.");
if (value.Logging.Level is not ("information" or "warning" or "error" or "fatal")) Throw("logging.level must be information, warning, error, or fatal.");
@@ -102,12 +84,62 @@ public static partial class ConfigurationLoader
public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory);
- private static bool ContainsPath(string baseDirectory, string configuredPath, string target)
+ private static void ValidateSandbox(SandboxOptions sandbox)
+ {
+ if (sandbox.Profile is not ("readonly" or "development")) Throw("sandbox.profile must be readonly or development.");
+ if (sandbox.Workspaces.Any(string.IsNullOrWhiteSpace)) Throw("sandbox.workspaces contains an empty Logical Path.");
+ foreach (MountOptions mount in sandbox.Mounts)
+ {
+ if (string.IsNullOrWhiteSpace(mount.Path) || mount.Access is not ("read_only" or "read_write" or "read_write_delete"))
+ Throw("sandbox.mounts requires a Logical Path and access read_only, read_write, or read_write_delete.");
+ 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();
+ 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.");
+
+ if (sandbox.Network.Access is not ("none" or "local" or "restricted"))
+ Throw("sandbox.network.access must be none, local, or restricted.");
+ if (sandbox.Network.LocalEndpoints.Any(static endpoint => endpoint.Port is < 1 or > 65535))
+ Throw("sandbox.network.local_endpoints contains an invalid port.");
+ foreach (NetworkAllowance endpoint in sandbox.Network.RestrictedEndpoints)
+ if (string.IsNullOrWhiteSpace(endpoint.Host) || endpoint.Ports.Count == 0 || endpoint.Ports.Any(static port => port is < 1 or > 65535))
+ Throw("sandbox.network.restricted_endpoints contains an invalid host or port.");
+ if (sandbox.Network.Access == "none" && (sandbox.Network.LocalEndpoints.Count != 0 || sandbox.Network.RestrictedEndpoints.Count != 0))
+ Throw("sandbox.network.access none cannot contain endpoints.");
+ if (sandbox.Network.Access == "local" && (sandbox.Network.LocalEndpoints.Count == 0 || sandbox.Network.RestrictedEndpoints.Count != 0))
+ Throw("sandbox.network.access local requires local_endpoints and forbids restricted_endpoints.");
+ if (sandbox.Network.Access == "restricted" && (sandbox.Network.RestrictedEndpoints.Count == 0 || sandbox.Network.LocalEndpoints.Count != 0))
+ Throw("sandbox.network.access restricted requires restricted_endpoints and forbids local_endpoints.");
+
+ if (sandbox.Resources.Profile is not ("constrained" or "normal" or "extended"))
+ Throw("sandbox.resources.profile must be constrained, normal, or extended.");
+ if (sandbox.Resources.MemoryMb is <= 0 || sandbox.Resources.ProcessLimit is <= 0 || sandbox.Resources.ExecutionTimeoutSeconds is <= 0)
+ Throw("sandbox resource overrides must be positive.");
+ }
+
+ private static void RejectLegacySandbox(string yaml)
{
- string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath);
- if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true;
- string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
- return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
+ 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'.");
+ }
+
+ private static bool TryGet(YamlMappingNode map, string name, out YamlNode? value)
+ {
+ foreach (KeyValuePair<YamlNode, YamlNode> pair in map.Children)
+ {
+ if (pair.Key is YamlScalarNode key && key.Value == name)
+ {
+ value = pair.Value;
+ return true;
+ }
+ }
+ value = null;
+ return false;
}
private static string MergeYaml(string baseYaml, string localYaml)
@@ -154,6 +186,6 @@ public static partial class ConfigurationLoader
}
private static void Throw(string message) => throw new ConfigurationException(message);
- [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)]
- private static partial Regex Sha256Regex();
+ [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant)]
+ private static partial Regex AliasRegex();
}
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 3d2d2db..b58c914 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -1,6 +1,7 @@
using Agent.App.Cli;
using Agent.App.Configuration;
using Agent.App.Logging;
+using Agent.App.Sandbox;
using Agent.Core.Localization;
using Agent.Core.Prompts;
using Agent.Core.Sessions;
@@ -11,6 +12,7 @@ using Agent.Core.Tools;
using Agent.Windows.Recovery;
using Agent.Windows.Security;
using Agent.Windows.Tools;
+using System.Text.Json;
namespace Agent.App;
@@ -35,8 +37,13 @@ internal static class Program
_ = FixedPrompt.Load();
string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory);
- UserOnlyDirectory.Ensure(logDirectory);
- using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level);
+ if (commandLine.Command is not (AppCommand.Serve or AppCommand.Check))
+ return RunManagementCommand(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)
+ .Compile(journalDirectory, backupDirectory);
if (commandLine.Command == AppCommand.Check)
{
WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run();
@@ -48,15 +55,15 @@ internal static class Program
{
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 });
- log.Error(failure);
Console.Error.WriteLine(failure);
return 2;
}
- log.Information(locale.Get("check.success"));
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);
log.Information(locale.Get("app.starting"));
WindowsPlatformCheckResult servePlatform = WindowsPlatformCheck.Run();
if (!servePlatform.Success)
@@ -75,7 +82,7 @@ internal static class Program
}
var sessionStore = new SessionStore();
- InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore);
+ InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, effectiveSandboxPolicy, logDirectory, sessionStore);
using var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles);
string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks");
UserOnlyDirectory.Ensure(taskStateDirectory);
@@ -136,7 +143,10 @@ internal static class Program
LocaleException => "locale.invalid",
_ => "app.io_error"
};
- return locale.Format(key, new Dictionary<string, object?> { ["message"] = error.Message });
+ string message = error is ConfigurationException configuration
+ ? FormatConfigurationError(locale, configuration)
+ : error.Message;
+ return locale.Format(key, new Dictionary<string, object?> { ["message"] = message });
}
catch
{
@@ -144,39 +154,33 @@ internal static class Program
}
}
- private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory, SessionStore? sessionStore = null)
+ private static string FormatConfigurationError(LocaleCatalog locale, ConfigurationException error)
{
- string stateDirectory = Path.Combine(baseDirectory, "state");
- string journalDirectory = Path.Combine(stateDirectory, "journals");
- string backupDirectory = Path.Combine(stateDirectory, "backups");
- Directory.CreateDirectory(journalDirectory);
- Directory.CreateDirectory(backupDirectory);
+ 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",
+ _ => null
+ };
+ string localized = key is null ? error.Message : locale.Format(key, new Dictionary<string, object?> { ["target"] = error.Target ?? "-" });
+ string resolution = key is null || error.Resolution is null ? error.Resolution ?? "-" : locale.Get(key + ".resolution");
+ return $"code={error.Code}; category={error.Category}; target={error.Target ?? "-"}; message={localized}; resolution={resolution}";
+ }
+
+ private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore = null)
+ {
+ Directory.CreateDirectory(effectiveSandboxPolicy.JournalDirectory);
+ Directory.CreateDirectory(effectiveSandboxPolicy.BackupDirectory);
var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl"));
- RecoveryResult recovery = new RecoveryManager(journalDirectory, audit).RecoverAll();
+ RecoveryResult recovery = new RecoveryManager(effectiveSandboxPolicy.JournalDirectory, audit).RecoverAll();
if (!recovery.IsClean) throw new InvalidOperationException("Recovery did not reach a clean state: " + string.Join(" | ", recovery.Errors));
- var fileEntries = new List<FilePolicyEntry>();
- Add(configuration.Sandbox.Filesystem.Read, FileCapability.Read);
- Add(configuration.Sandbox.Filesystem.Write, FileCapability.Write);
- Add(configuration.Sandbox.Filesystem.Execute, FileCapability.Execute);
- Add(configuration.Sandbox.Filesystem.Delete, FileCapability.Delete);
- var settings = new WindowsSandboxSettings(
- fileEntries,
- configuration.Sandbox.Process.Approvals.Select(approval => new Agent.Windows.Security.ExecutableApproval(approval.Path, approval.Sha256)).ToArray(),
- configuration.Sandbox.Process.AllowShell,
- new SandboxJobLimits((uint)configuration.Sandbox.Process.MaxProcesses, checked((ulong)configuration.Sandbox.Resources.MemoryMb * 1024 * 1024)),
- configuration.Sandbox.Network.Mode,
- configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
- journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
- configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines,
- configuration.Sandbox.Resources.ProcessTimeoutSeconds);
- var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore));
+ var backend = new WindowsToolBackend(effectiveSandboxPolicy, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore));
var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal);
return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend);
-
- void Add(IEnumerable<string> paths, FileCapability capability)
- {
- foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability));
- }
}
internal static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language,
@@ -184,7 +188,7 @@ internal static class Program
userPrompt,
language,
skills,
- "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.",
+ "Windows LPAC, ACL, Job Object process-tree, and WFP sandbox capabilities.",
configuration.Agent.MaxIterations,
configuration.Agent.SameFailureLimit,
locale.Get("ollama.vision_unsupported"),
@@ -192,4 +196,155 @@ internal static class Program
configuration.Limits.ContextSummaryRatio,
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;
+ if (Console.IsInputRedirected) throw new CommandLineException("Interactive confirmation is unavailable; review the displayed diff and re-run with --yes.");
+ Console.Out.Write($"{prompt} [y/N] ");
+ 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/SandboxServices.cs b/src/Agent.App/Sandbox/SandboxServices.cs
new file mode 100644
index 0000000..93211b8
--- /dev/null
+++ b/src/Agent.App/Sandbox/SandboxServices.cs
@@ -0,0 +1,361 @@
+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;
+ }
+}
+
+internal static class AtomicJson
+{
+ public static void Write<T>(string path, T value, JsonSerializerOptions options)
+ {
+ string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, value, options);
+ stream.Flush(flushToDisk: true);
+ }
+ File.Move(temporary, path, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ }
+}
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 5ca9d1c..676fe0d 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -303,7 +303,8 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output,
toolResult.ErrorCode ?? "policy_denied", toolResult.ErrorCategory, toolResult.TargetPath,
toolResult.RequiredPermission, toolResult.Resolution);
- failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
+ if (!toolResult.Success && !isMetadataTool)
+ failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
}
bool verified = plannerTask.RequiredTools.All(successfulRequiredTools.Contains) && (!requiresExternalEffect || externalEffectEvidence);
@@ -527,12 +528,19 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
private string LocalizePolicyDenial(PolicyDeniedException error)
{
if (locale is null) return error.Message;
+ string resolutionKey = error.ErrorCategory switch
+ {
+ "filesystem" => "runtime.policy_resolution.filesystem",
+ "process" => "runtime.policy_resolution.process",
+ "network" => "runtime.policy_resolution.network",
+ _ => "runtime.policy_resolution.default"
+ };
return locale.Format("runtime.policy_denied", new Dictionary<string, object?>
{
["code"] = error.ErrorCode,
["path"] = error.TargetPath ?? "-",
["permission"] = error.RequiredPermission ?? "-",
- ["resolution"] = error.Resolution ?? "-"
+ ["resolution"] = locale.Get(resolutionKey)
});
}
diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs
index 3591259..756cc41 100644
--- a/src/Agent.Core/Runtime/PlannerService.cs
+++ b/src/Agent.Core/Runtime/PlannerService.cs
@@ -31,12 +31,13 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
var messages = new List<OllamaMessage>
{
- new("system", FixedPrompt.Load()),
- new("system", _settings.UserSystemPrompt),
+ // Planning has a dedicated read-only contract. Execution prompts contain instructions
+ // to inspect files and call tools, so mixing them here creates contradictory system
+ // instructions and causes models to execute narratively instead of returning a graph.
+ new("system", BuildPlanningInstruction()),
new("system", $"Respond in locale: {_settings.Locale}."),
new("system", ComposeSkillCatalog(_settings.Skills)),
new("system", _settings.SandboxCapabilitySummary),
- new("system", BuildPlanningInstruction()),
new("user", request, images)
};
@@ -58,11 +59,9 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
var messages = new List<OllamaMessage>
{
- new("system", FixedPrompt.Load()),
- new("system", _settings.UserSystemPrompt),
+ new("system", BuildReplanInstruction(existing, replanReason)),
new("system", $"Respond in locale: {_settings.Locale}."),
new("system", _settings.SandboxCapabilitySummary),
- new("system", BuildReplanInstruction(existing, replanReason)),
new("user", request)
};
@@ -74,6 +73,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest)
{
+ ValidatePlanStructure(plan);
var graph = new TaskGraph(_settings.MaxTasks);
var idMap = new List<Guid>();
@@ -105,6 +105,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest)
{
+ ValidatePlanStructure(plan);
PlannerTask[] completed = existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).ToArray();
var completedByDescription = completed.GroupBy(static t => t.Description, StringComparer.Ordinal)
.ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal);
@@ -182,6 +183,29 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
return tools;
}
+ private static void ValidatePlanStructure(PlanResult plan)
+ {
+ for (int index = 0; index < plan.Tasks.Count; index++)
+ {
+ PlannedTask task = plan.Tasks[index];
+ if (!task.RequiredTools.Contains("file_edit", StringComparer.Ordinal) ||
+ !task.RequiredTools.Contains("process_run", StringComparer.Ordinal)) continue;
+
+ var pending = new Stack<int>(task.Dependencies);
+ var visited = new HashSet<int>();
+ while (pending.TryPop(out int dependency))
+ {
+ if (dependency < 0 || dependency >= index || !visited.Add(dependency)) continue;
+ PlannedTask ancestor = plan.Tasks[dependency];
+ if (ancestor.RequiredTools.Contains("file_edit", StringComparer.Ordinal))
+ throw new PlannerValidationException(
+ "A coding implementation-and-verification Task cannot depend on a separate file_edit Task. " +
+ "Combine the edits, optional corrections, and process verification in one Task requiring file_edit and process_run.");
+ foreach (int parent in ancestor.Dependencies) pending.Push(parent);
+ }
+ }
+ }
+
private static PlanResult? ParsePlan(string content)
{
try
@@ -266,7 +290,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
{
attemptMessages = messages.Concat([
new OllamaMessage("system",
- "The previous response was not a valid TaskGraph JSON object. Do not call or execute any Tool while planning. " +
+ $"The previous response was not a valid TaskGraph: {ex.Message} Do not call or execute any Tool while planning. " +
"Return only one JSON object that matches the supplied schema, beginning with {\"tasks\":[ and containing no Markdown or commentary.")
]).ToArray();
}
diff --git a/src/Agent.Windows/NativeMethods.txt b/src/Agent.Windows/NativeMethods.txt
index 471b47d..61efc53 100644
--- a/src/Agent.Windows/NativeMethods.txt
+++ b/src/Agent.Windows/NativeMethods.txt
@@ -4,18 +4,25 @@ CreateJobObject
CreateProcess
DeleteProcThreadAttributeList
GetExitCodeProcess
+GetTokenInformation
InitializeProcThreadAttributeList
+IsProcessInJob
JOBOBJECT_EXTENDED_LIMIT_INFORMATION
JOB_OBJECT_LIMIT
-PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES
+PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY
PROC_THREAD_ATTRIBUTE_HANDLE_LIST
+DeriveCapabilitySidsFromName
+LocalFree
+OpenProcessToken
ResumeThread
SetHandleInformation
SetInformationJobObject
TerminateJobObject
UpdateProcThreadAttribute
STARTUPINFOEXW
+TOKEN_INFORMATION_CLASS
+TOKEN_APPCONTAINER_INFORMATION
WaitForSingleObject
Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0
Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0
diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
index bc4d291..9f78b3d 100644
--- a/src/Agent.Windows/Network/TaskNetworkPolicy.cs
+++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
@@ -11,24 +11,25 @@ public sealed class TaskNetworkPolicy : IDisposable
private readonly ISecurityAuditSink _audit;
private readonly Guid _entryId;
private readonly SecurityIdentifier _appContainerSid;
- private readonly Guid _loopbackEntryId;
+ private readonly Guid? _loopbackEntryId;
private readonly bool _loopbackAdded;
private readonly Guid? _sessionId;
private bool _disposed;
private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId,
- SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded, Guid? sessionId)
+ SecurityIdentifier appContainerSid, Guid? loopbackEntryId, bool loopbackAdded, Guid? sessionId)
{
_session = session; _journal = journal; _audit = audit; _entryId = entryId;
_appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded;
_sessionId = sessionId;
}
- public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit, Guid? sessionId = null)
+ public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances,
+ ISecurityAuditSink audit, Guid? sessionId = null, bool enableLoopback = true)
{
- bool originallyExempt = LoopbackExemption.Contains(appContainerSid);
- Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value,
- new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() });
+ bool originallyExempt = enableLoopback && LoopbackExemption.Contains(appContainerSid);
+ Guid? loopbackEntryId = enableLoopback ? journal.Prepare("network.loopback_exemption", appContainerSid.Value,
+ new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() }) : null;
bool loopbackAdded = false;
Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string>
{
@@ -37,15 +38,18 @@ public sealed class TaskNetworkPolicy : IDisposable
WfpDynamicSession session = WfpDynamicSession.Open();
try
{
- loopbackAdded = LoopbackExemption.Ensure(appContainerSid);
- journal.MarkApplied(loopbackEntryId, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() });
+ if (enableLoopback)
+ {
+ loopbackAdded = LoopbackExemption.Ensure(appContainerSid);
+ journal.MarkApplied(loopbackEntryId!.Value, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() });
+ }
IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances);
journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) });
audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary<string, string>
{
["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic"
}, SessionId: sessionId));
- audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
+ if (enableLoopback) audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId));
return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded, sessionId);
}
@@ -54,7 +58,7 @@ public sealed class TaskNetworkPolicy : IDisposable
session.Dispose();
journal.MarkCleaned(entryId);
if (loopbackAdded) LoopbackExemption.Remove(appContainerSid);
- journal.MarkCleaned(loopbackEntryId);
+ if (loopbackEntryId is not null) journal.MarkCleaned(loopbackEntryId.Value);
throw;
}
}
@@ -66,9 +70,9 @@ public sealed class TaskNetworkPolicy : IDisposable
_session.Dispose();
_journal.MarkCleaned(_entryId);
if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid);
- _journal.MarkCleaned(_loopbackEntryId);
+ if (_loopbackEntryId is not null) _journal.MarkCleaned(_loopbackEntryId.Value);
_audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId));
- _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
+ if (_loopbackEntryId is not null) _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }, SessionId: _sessionId));
_disposed = true;
}
diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
index 25c949d..ff4ac5a 100644
--- a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
+++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
@@ -11,7 +11,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public FileEditTransaction Replace(string path, ReadOnlySpan<byte> content, string? expectedSha256 = null)
{
- string canonical = CanonicalPath.Resolve(path, requireExisting: false);
+ string canonical = ResolvedPath.Resolve(path, requireExisting: false);
bool existed = File.Exists(canonical);
byte[]? original = existed ? File.ReadAllBytes(canonical) : null;
string originalHash = original is null ? string.Empty : Convert.ToHexString(SHA256.HashData(original));
@@ -45,7 +45,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public FileDeleteTransaction Delete(string path, string? expectedSha256 = null)
{
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
byte[] original = File.ReadAllBytes(canonical);
string originalHash = Convert.ToHexString(SHA256.HashData(original));
if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal))
@@ -71,7 +71,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public DirectoryMkdirTransaction Mkdir(string path)
{
- string canonical = CanonicalPath.Resolve(path, requireExisting: false);
+ string canonical = ResolvedPath.Resolve(path, requireExisting: false);
bool existed = Directory.Exists(canonical);
if (existed) throw new InvalidOperationException($"Directory already exists: '{canonical}'.");
if (File.Exists(canonical)) throw new InvalidOperationException($"A file exists at the target path: '{canonical}'.");
@@ -83,7 +83,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public DirectoryRmdirTransaction Rmdir(string path)
{
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
if (!Directory.Exists(canonical)) throw new DirectoryNotFoundException($"Directory does not exist: '{canonical}'.");
if (Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly).Any())
throw new ToolValidationException("Directory is not empty. Remove contents first before removing the directory.");
@@ -95,8 +95,8 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public FileRenameTransaction Rename(string sourcePath, string destinationPath)
{
- string source = CanonicalPath.Resolve(sourcePath);
- string destination = CanonicalPath.Resolve(destinationPath, requireExisting: false);
+ string source = ResolvedPath.Resolve(sourcePath);
+ string destination = ResolvedPath.Resolve(destinationPath, requireExisting: false);
if (!File.Exists(source)) throw new FileNotFoundException($"Source file does not exist: '{source}'.");
if (File.Exists(destination)) throw new ToolValidationException($"Destination already exists: '{destination}'. Rename overwrite is not allowed.");
if (!source.StartsWith(System.IO.Path.GetPathRoot(source)!, StringComparison.OrdinalIgnoreCase) ||
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
index 6c72724..02306fd 100644
--- a/src/Agent.Windows/Security/ExecutablePolicy.cs
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -2,46 +2,91 @@ using System.Security.Cryptography;
namespace Agent.Windows.Security;
-public sealed record ExecutableApproval(string Path, string Sha256);
-public sealed record VerifiedExecutable(string CanonicalPath, string Sha256);
+public enum EntryExecutableClassification { ApprovedEntryExecutable, ApprovedShellExecutable, RuntimeComponent, WorkspaceArtifact }
-public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell)
+public sealed record ApprovedEntryExecutable(string Alias, string ResolvedPath, string Sha256, bool IsShell, string? RuntimeRoot);
+public sealed record VerifiedEntryExecutable(string ResolvedPath, string Sha256, EntryExecutableClassification Classification, string? Alias = null);
+
+public sealed class EntryExecutablePolicy(
+ IEnumerable<ApprovedEntryExecutable> approvals,
+ IEnumerable<string> runtimeRoots,
+ IEnumerable<string> artifactRoots)
{
- private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary(
- approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase);
+ private readonly IReadOnlyDictionary<string, ApprovedEntryExecutable> _aliases = approvals.ToDictionary(
+ approval => approval.Alias, Normalize, StringComparer.OrdinalIgnoreCase);
+ private readonly string[] _runtimeRoots = runtimeRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ private readonly string[] _artifactRoots = artifactRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
- public VerifiedExecutable Verify(string executable)
+ public VerifiedEntryExecutable VerifyEntry(string executable, string? baseDirectory = null)
{
- string canonical = CanonicalPath.Resolve(executable);
- if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.", "process", canonical, "execute", "sandbox.process.allow_shell");
- if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals");
- using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read);
- string actualHash = Convert.ToHexString(SHA256.HashData(stream));
- if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash)))
- throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals");
- return new(canonical, actualHash);
+ if (_aliases.TryGetValue(executable, out ApprovedEntryExecutable? byAlias)) return VerifyApproved(byAlias);
+ string candidate = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable);
+ string resolved;
+ try { resolved = ResolvedPath.Resolve(candidate); }
+ catch (FileNotFoundException)
+ {
+ throw new SandboxPolicyException("process.alias_or_path_unknown", $"Executable Alias or Logical Path does not resolve to an existing file: '{executable}'.",
+ "process", Path.GetFullPath(candidate), "execute", "Use a configured Executable Alias or an existing Runtime Component/development Workspace Artifact path.");
+ }
+ if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)))
+ return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.RuntimeComponent);
+ 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.");
}
- public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments)
+ public string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments, string? baseDirectory = null)
{
- if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null;
+ string executablePath = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable);
+ string resolved = _aliases.TryGetValue(executable, out ApprovedEntryExecutable? approval) ? approval.ResolvedPath : ResolvedPath.Resolve(executablePath);
+ if (!Path.GetFileName(resolved).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null;
if (arguments[0].Equals("exec", StringComparison.OrdinalIgnoreCase))
{
if (arguments.Count < 2 || arguments[1].StartsWith("-", StringComparison.Ordinal))
throw new SandboxPolicyException("process.managed_entry_invalid", "dotnet exec requires an explicit managed entry assembly as its next argument.");
- return arguments[1];
+ return ResolveArgument(arguments[1], baseDirectory);
}
- return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null;
+ return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? ResolveArgument(arguments[0], baseDirectory) : null;
+ }
+
+ public void VerifyManagedEntryPoint(string path)
+ {
+ string resolved = ResolvedPath.Resolve(path);
+ if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)) || _artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return;
+ throw new SandboxPolicyException("process.managed_entry_unknown", $"Managed Entry Artifact is outside every confirmed Runtime Root and development Workspace: '{resolved}'.",
+ "process", resolved, "execute", "Place the artifact in a development Workspace or confirm its Runtime Root.");
}
public static string ComputeSha256(string executable)
{
- using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read);
+ using FileStream stream = new(ResolvedPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read);
return Convert.ToHexString(SHA256.HashData(stream));
}
- private static bool IsShell(string path) => Path.GetFileName(path) is string name &&
- (name.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase));
+ private static ApprovedEntryExecutable Normalize(ApprovedEntryExecutable approval) => approval with
+ {
+ ResolvedPath = ResolvedPath.Resolve(approval.ResolvedPath),
+ Sha256 = NormalizeHash(approval.Sha256),
+ RuntimeRoot = approval.RuntimeRoot is null ? null : ResolvedPath.Resolve(approval.RuntimeRoot)
+ };
+
+ private static VerifiedEntryExecutable VerifyApproved(ApprovedEntryExecutable approval)
+ {
+ string resolved = ResolvedPath.Resolve(approval.ResolvedPath);
+ 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}'.");
+ return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias);
+ }
+
+ private static bool IsAtOrBelow(string candidate, string root) =>
+ candidate.Equals(root, StringComparison.OrdinalIgnoreCase) ||
+ candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ private static string ResolveArgument(string path, string? baseDirectory) => Path.IsPathFullyQualified(path)
+ ? path : Path.GetFullPath(path, baseDirectory ?? Environment.CurrentDirectory);
+
private static string NormalizeHash(string hash)
{
string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs
index 3de4804..67c7340 100644
--- a/src/Agent.Windows/Security/PathPolicy.cs
+++ b/src/Agent.Windows/Security/PathPolicy.cs
@@ -3,23 +3,23 @@ namespace Agent.Windows.Security;
[Flags]
public enum FileCapability { None = 0, Read = 1, Write = 2, Execute = 4, Delete = 8 }
-public sealed record FilePolicyEntry(string Path, FileCapability Capabilities);
+public sealed record FilePolicyEntry(string Path, FileCapability Capabilities, string Origin = "unspecified");
public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries)
{
- private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = CanonicalPath.Resolve(entry.Path, requireExisting: false) }).ToArray();
+ private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = ResolvedPath.Resolve(entry.Path, requireExisting: false) }).ToArray();
public bool IsAllowed(string path, FileCapability required)
{
- string canonical = CanonicalPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete);
+ string canonical = ResolvedPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete);
return _entries.Any(entry => IsAtOrBelow(canonical, entry.Path) && (entry.Capabilities & required) == required);
}
public void Demand(string path, FileCapability required)
{
if (!IsAllowed(path, required))
- throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server.",
- "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), $"sandbox.filesystem.{required.ToString().ToLowerInvariant()}");
+ throw new SandboxPolicyException("policy.deny", $"Resolved Path '{path}' is outside the Effective Sandbox Policy for {required}.",
+ "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), "Confirm a Workspace or Mount that grants the required Filesystem Capability, then restart the server.");
}
private static bool IsAtOrBelow(string candidate, string root) =>
@@ -27,7 +27,7 @@ public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries)
candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
-public static class CanonicalPath
+public static class ResolvedPath
{
public static string Resolve(string path, bool requireExisting = true)
{
@@ -35,21 +35,34 @@ public static class CanonicalPath
string fullPath = Path.GetFullPath(path);
string? root = Path.GetPathRoot(fullPath);
if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root.");
+ if (root.StartsWith("\\\\", StringComparison.Ordinal))
+ throw new SandboxPolicyException("path.non_local", "Only local filesystem paths are supported.", "filesystem", fullPath, null, "Select a local Workspace, Mount, Runtime Root, or Executable.");
string current = root;
+ var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string segment in fullPath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, segment);
if (!File.Exists(current) && !Directory.Exists(current)) continue;
- if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
+ while ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
+ string reparsePath = Path.GetFullPath(current);
+ if (!visited.Add(reparsePath))
+ throw new SandboxPolicyException("path.reparse_cycle", $"Reparse resolution cycle detected at '{reparsePath}'.", "filesystem", reparsePath, null, "Remove the reparse cycle.");
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
- string? target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.LinkTarget;
- throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead.",
- "filesystem", current, null, string.IsNullOrWhiteSpace(target) ? "sandbox.filesystem" : Path.GetFullPath(target));
+ string? target;
+ try { target = info.ResolveLinkTarget(returnFinalTarget: false)?.FullName; }
+ catch (IOException ex)
+ {
+ throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}': {ex.Message}",
+ "filesystem", current, null, "Repair or remove the reparse point.");
+ }
+ if (string.IsNullOrWhiteSpace(target))
+ throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}'.", "filesystem", current, null, "Repair or remove the reparse point.");
+ current = Path.GetFullPath(target);
}
}
- if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath);
- return Path.TrimEndingDirectorySeparator(fullPath);
+ if (requireExisting && !File.Exists(current) && !Directory.Exists(current)) throw new FileNotFoundException("Resolved Path does not exist.", current);
+ return Path.TrimEndingDirectorySeparator(current);
}
}
diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
index 687af94..c3a19da 100644
--- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
+++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
@@ -14,21 +14,27 @@ namespace Agent.Windows.Security;
public sealed record RestrictedProcessResult(uint ProcessId, uint ExitCode, string StandardOutput, string StandardError);
public sealed record SandboxJobLimits(uint MaxProcesses = 16, ulong MaxJobMemoryBytes = 4UL * 1024 * 1024 * 1024);
-public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, SandboxJobLimits? jobLimits = null)
+public sealed unsafe class RestrictedProcessLauncher(EntryExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null,
+ SandboxJobLimits? jobLimits = null, bool allowNetwork = false)
{
- private const uint ProcessCreationChildProcessRestricted = 0x1;
+ private const uint ProcessCreationAllApplicationPackagesOptOut = 0x1;
private readonly SandboxJobLimits _jobLimits = jobLimits ?? new();
public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout,
- string? standardInput = null, Guid? sessionId = null)
+ string? standardInput = null, Guid? sessionId = null) =>
+ Run(taskId, executable, arguments, workingDirectory, timeout, standardInput, sessionId, CancellationToken.None);
+
+ public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout,
+ string? standardInput, Guid? sessionId, CancellationToken cancellationToken)
{
- VerifiedExecutable verified;
- VerifiedExecutable? managedEntry = null;
+ string cwd = ResolvedPath.Resolve(workingDirectory);
+ VerifiedEntryExecutable verified;
+ string? managedEntry = null;
try
{
- verified = executablePolicy.Verify(executable);
- string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments);
- if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath);
+ verified = executablePolicy.VerifyEntry(executable, cwd);
+ managedEntry = executablePolicy.ManagedEntryPoint(verified.ResolvedPath, arguments, cwd);
+ if (managedEntry is not null) executablePolicy.VerifyManagedEntryPoint(managedEntry);
}
catch (SandboxPolicyException ex)
{
@@ -36,7 +42,6 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
throw;
}
- string cwd = CanonicalPath.Resolve(workingDirectory);
using FileStream stdin = CaptureFile();
using FileStream stdout = CaptureFile();
using FileStream stderr = CaptureFile();
@@ -63,18 +68,14 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
throw LastError("SetInformationJobObject");
nuint attributeSize = 0;
- uint attributeCount = appContainerSid is null ? 2u : 3u;
+ uint attributeCount = appContainerSid is null ? 1u : 3u;
_ = PInvoke.InitializeProcThreadAttributeList(default, attributeCount, 0, &attributeSize);
LPPROC_THREAD_ATTRIBUTE_LIST attributes = (LPPROC_THREAD_ATTRIBUTE_LIST)NativeMemory.Alloc(attributeSize);
void* sidBuffer = null;
- void* internetClientSidBuffer = null;
- void* privateNetworkSidBuffer = null;
+ var capabilityBuffers = new List<nint>();
try
{
if (!PInvoke.InitializeProcThreadAttributeList(attributes, attributeCount, 0, &attributeSize)) throw LastError("InitializeProcThreadAttributeList");
- uint childPolicy = ProcessCreationChildProcessRestricted;
- if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &childPolicy, sizeof(uint), null, null))
- throw LastError("UpdateProcThreadAttribute");
HANDLE[] inheritedHandles =
[
new(stdin.SafeFileHandle.DangerousGetHandle()),
@@ -95,17 +96,24 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
sidBuffer = NativeMemory.Alloc((nuint)sidBytes.Length);
Marshal.Copy(sidBytes, 0, (nint)sidBuffer, sidBytes.Length);
securityCapabilities.AppContainerSid = (nint)sidBuffer;
- internetClientSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-1"));
- privateNetworkSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-3"));
- SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[2];
- capabilities[0].Sid = new PSID(internetClientSidBuffer);
- capabilities[0].Attributes = 0x00000004;
- capabilities[1].Sid = new PSID(privateNetworkSidBuffer);
- capabilities[1].Attributes = 0x00000004;
+ SecurityIdentifier[] capabilitySids = allowNetwork
+ ? [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation"), new("S-1-15-3-1"), new("S-1-15-3-3")]
+ : [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation")];
+ SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[capabilitySids.Length];
+ for (int i = 0; i < capabilitySids.Length; i++)
+ {
+ void* buffer = AllocateSid(capabilitySids[i]);
+ capabilityBuffers.Add((nint)buffer);
+ capabilities[i].Sid = new PSID(buffer);
+ capabilities[i].Attributes = 0x00000004;
+ }
securityCapabilities.Capabilities = (nint)capabilities;
- securityCapabilities.CapabilityCount = 2;
+ securityCapabilities.CapabilityCount = (uint)capabilitySids.Length;
if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
- &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)");
+ &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LpacFailure("UpdateProcThreadAttribute(SecurityCapabilities)", verified.ResolvedPath);
+ uint allApplicationPackagesPolicy = ProcessCreationAllApplicationPackagesOptOut;
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY,
+ &allApplicationPackagesPolicy, sizeof(uint), null, null)) throw LpacFailure("UpdateProcThreadAttribute(AllApplicationPackagesPolicy)", verified.ResolvedPath);
}
STARTUPINFOEXW startup = default;
startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW);
@@ -115,28 +123,29 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
startup.StartupInfo.hStdError = inheritedHandles[2];
startup.lpAttributeList = attributes;
PROCESS_INFORMATION process = default;
- string commandLine = BuildCommandLine(verified.CanonicalPath, arguments);
+ string commandLine = BuildCommandLine(verified.ResolvedPath, arguments);
Span<char> commandBuffer = new char[commandLine.Length + 1];
commandLine.AsSpan().CopyTo(commandBuffer);
- if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
- PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED,
- null, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess");
+ char[] environmentBlock = BuildEnvironmentBlock(cwd);
+ fixed (char* environment = environmentBlock)
+ {
+ if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
+ PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED |
+ PROCESS_CREATION_FLAGS.CREATE_UNICODE_ENVIRONMENT,
+ environment, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess");
+ }
try
{
using var processView = new SafeFileHandle(process.hProcess, ownsHandle: false);
if (!PInvoke.AssignProcessToJobObject(job, processView)) throw LastError("AssignProcessToJobObject");
try
{
- VerifiedExecutable postCreate = executablePolicy.Verify(executable);
- if (!string.Equals(postCreate.CanonicalPath, verified.CanonicalPath, StringComparison.OrdinalIgnoreCase) ||
+ VerifiedEntryExecutable postCreate = executablePolicy.VerifyEntry(executable, cwd);
+ if (!string.Equals(postCreate.ResolvedPath, verified.ResolvedPath, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(postCreate.Sha256, verified.Sha256, StringComparison.Ordinal))
throw new SandboxPolicyException("process.identity_changed", "Executable identity changed during process creation.");
if (managedEntry is not null)
- {
- VerifiedExecutable postCreateManaged = executablePolicy.Verify(managedEntry.CanonicalPath);
- if (!string.Equals(postCreateManaged.Sha256, managedEntry.Sha256, StringComparison.Ordinal))
- throw new SandboxPolicyException("process.managed_identity_changed", "Managed entry assembly identity changed during process creation.");
- }
+ executablePolicy.VerifyManagedEntryPoint(managedEntry);
}
catch (SandboxPolicyException ex)
{
@@ -146,20 +155,41 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
}, SessionId: sessionId, ErrorCode: ex.Code));
throw;
}
+ (bool tokenIsAppContainer, string? tokenAppContainerSid, bool processInJob) = InspectConfinement(processView, job);
+ if (appContainerSid is not null && (!tokenIsAppContainer ||
+ !string.Equals(tokenAppContainerSid, appContainerSid.Value, StringComparison.Ordinal) || !processInJob))
+ throw new SandboxPolicyException("process.lpac_mismatch",
+ "Entry Process did not receive the required LPAC identity and Job Object confinement.",
+ "process", verified.ResolvedPath, "execute", "Verify LPAC support on this Windows host; no fallback is permitted.");
if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread");
var processAudit = new Dictionary<string, string>
{
- ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted",
- ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer",
+ ["path"] = verified.ResolvedPath, ["sha256"] = verified.Sha256, ["entry_classification"] = verified.Classification.ToString(),
+ ["alias"] = verified.Alias ?? string.Empty, ["descendant_policy"] = "lpac_inherited",
+ ["app_container_sid"] = appContainerSid?.Value ?? "none", ["lpac"] = (appContainerSid is not null).ToString(),
+ ["token_is_app_container"] = tokenIsAppContainer.ToString(), ["token_app_container_sid"] = tokenAppContainerSid ?? "none",
+ ["process_in_job"] = processInJob.ToString(), ["all_application_packages_opt_out"] = (appContainerSid is not null).ToString(),
+ ["network_capabilities"] = appContainerSid is null || !allowNetwork ? "none" : "internetClient,privateNetworkClientServer",
+ ["baseline_capabilities"] = appContainerSid is null ? "none" : "registryRead,lpacInstrumentation",
+ ["environment_policy"] = "minimal_no_broker_credentials",
+ ["arguments"] = "redacted", ["argument_count"] = arguments.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
["pid"] = process.dwProcessId.ToString()
};
if (managedEntry is not null)
{
- processAudit["managed_entry_path"] = managedEntry.CanonicalPath;
- processAudit["managed_entry_sha256"] = managedEntry.Sha256;
+ processAudit["managed_entry_path"] = ResolvedPath.Resolve(managedEntry);
}
audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId));
- WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1)));
+ long deadline = Environment.TickCount64 + checked((long)Math.Clamp(timeout.TotalMilliseconds, 1, int.MaxValue));
+ WAIT_EVENT wait;
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ long remaining = deadline - Environment.TickCount64;
+ if (remaining <= 0) { wait = WAIT_EVENT.WAIT_TIMEOUT; break; }
+ wait = PInvoke.WaitForSingleObject(process.hProcess, (uint)Math.Min(remaining, 100));
+ if (wait != WAIT_EVENT.WAIT_TIMEOUT) break;
+ }
if (wait == WAIT_EVENT.WAIT_TIMEOUT)
{
_ = PInvoke.TerminateJobObject(job, 1460);
@@ -172,12 +202,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
{
audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string>
{
- ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(),
- ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString()
- }, SessionId: sessionId, ErrorCode: "process.child_denied"));
+ ["code"] = "sandbox_process_failure", ["pid"] = process.dwProcessId.ToString(),
+ ["descendant_policy"] = "lpac_inherited", ["exit_code"] = exitCode.ToString()
+ }, SessionId: sessionId, ErrorCode: "process.nonzero_exit"));
}
return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr));
}
+ catch (OperationCanceledException)
+ {
+ _ = PInvoke.TerminateJobObject(job, 1223);
+ _ = PInvoke.WaitForSingleObject(process.hProcess, 5000);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.cancel", "terminated",
+ new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId));
+ throw;
+ }
finally
{
PInvoke.CloseHandle(process.hThread);
@@ -188,8 +226,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
{
if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes);
NativeMemory.Free(sidBuffer);
- NativeMemory.Free(internetClientSidBuffer);
- NativeMemory.Free(privateNetworkSidBuffer);
+ foreach (nint buffer in capabilityBuffers) NativeMemory.Free((void*)buffer);
NativeMemory.Free(attributes.Value);
}
}
@@ -201,6 +238,34 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
int code = Marshal.GetLastWin32Error();
return new(code, $"{operation} failed with Win32 error {code}.");
}
+
+ private static SandboxPolicyException LpacFailure(string operation, string executable)
+ {
+ int code = Marshal.GetLastWin32Error();
+ return new("process.lpac_unsupported", $"{operation} failed with Win32 error {code}; no fallback is permitted.",
+ "process", executable, "execute", "Use a supported Windows 11 host and verify LPAC capability derivation and process-attribute support.");
+ }
+
+ private static (bool IsAppContainer, string? AppContainerSid, bool InJob) InspectConfinement(SafeFileHandle process, SafeFileHandle job)
+ {
+ if (!PInvoke.IsProcessInJob(process, job, out BOOL inJob)) throw LastError("IsProcessInJob");
+ if (!PInvoke.OpenProcessToken(process, TOKEN_ACCESS_MASK.TOKEN_QUERY, out SafeFileHandle token)) throw LastError("OpenProcessToken");
+ using (token)
+ {
+ Span<byte> flag = stackalloc byte[sizeof(uint)];
+ if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenIsAppContainer, flag, out _))
+ throw LastError("GetTokenInformation(TokenIsAppContainer)");
+ bool isAppContainer = MemoryMarshal.Read<uint>(flag) != 0;
+ _ = PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, Span<byte>.Empty, out uint sidInformationLength);
+ if (sidInformationLength < IntPtr.Size) throw LastError("GetTokenInformation(TokenAppContainerSid size)");
+ Span<byte> sidInformation = sidInformationLength <= 256 ? stackalloc byte[(int)sidInformationLength] : new byte[sidInformationLength];
+ if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, sidInformation, out _))
+ throw LastError("GetTokenInformation(TokenAppContainerSid)");
+ nint sidPointer = MemoryMarshal.Read<nint>(sidInformation);
+ string? sid = sidPointer == 0 ? null : new SecurityIdentifier(sidPointer).Value;
+ return (isAppContainer, sid, inJob);
+ }
+ }
private static FileStream CaptureFile()
{
string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp");
@@ -215,6 +280,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length);
return buffer;
}
+
+ private static SecurityIdentifier DeriveCapabilitySid(string name)
+ {
+ if (!PInvoke.DeriveCapabilitySidsFromName(name, out PSID* groupSids, out uint groupCount, out PSID* capabilitySids, out uint capabilityCount) || capabilityCount != 1)
+ throw LastError($"DeriveCapabilitySidsFromName({name})");
+ try { return new SecurityIdentifier((nint)capabilitySids[0].Value); }
+ finally
+ {
+ for (uint i = 0; i < groupCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids[i].Value));
+ for (uint i = 0; i < capabilityCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids[i].Value));
+ _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids));
+ _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids));
+ }
+ }
private static void MakeInheritable(SafeFileHandle handle)
{
if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT))
@@ -230,6 +309,45 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
return count > maxCharacters ? new string(buffer, 0, maxCharacters) + "\n[output truncated]" : new string(buffer, 0, count);
}
private static string BuildCommandLine(string executable, IReadOnlyList<string> arguments) => string.Join(' ', [Quote(executable), .. arguments.Select(Quote)]);
+ private static char[] BuildEnvironmentBlock(string cwd)
+ {
+ string systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
+ var values = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ComSpec"] = Path.Combine(systemRoot, "System32", "cmd.exe"),
+ ["DOTNET_CLI_HOME"] = cwd,
+ ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
+ ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0",
+ ["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "true",
+ ["DOTNET_NOLOGO"] = "1",
+ ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1",
+ ["DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK"] = "true",
+ ["MSBuildEnableWorkloadResolver"] = "false",
+ ["MSBUILDDISABLENODEREUSE"] = "1",
+ ["NUMBER_OF_PROCESSORS"] = Environment.ProcessorCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["OS"] = "Windows_NT",
+ ["PATH"] = Path.Combine(systemRoot, "System32"),
+ ["PATHEXT"] = ".COM;.EXE;.BAT;.CMD",
+ ["PROCESSOR_ARCHITECTURE"] = RuntimeInformation.ProcessArchitecture.ToString().ToUpperInvariant(),
+ ["SystemDrive"] = Path.GetPathRoot(systemRoot)!.TrimEnd(Path.DirectorySeparatorChar),
+ ["SystemRoot"] = systemRoot,
+ ["TEMP"] = cwd,
+ ["TMP"] = cwd,
+ ["windir"] = systemRoot
+ };
+ foreach (string name in new[]
+ {
+ "ALLUSERSPROFILE", "APPDATA", "CommonProgramFiles", "CommonProgramFiles(x86)", "CommonProgramW6432",
+ "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)",
+ "ProgramW6432", "PUBLIC", "USERDOMAIN", "USERNAME", "USERPROFILE"
+ })
+ {
+ string? value = Environment.GetEnvironmentVariable(name);
+ if (!string.IsNullOrEmpty(value)) values[name] = value;
+ }
+ string block = string.Concat(values.Select(pair => $"{pair.Key}={pair.Value}\0")) + "\0";
+ return block.ToCharArray();
+ }
private static string Quote(string value)
{
if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value;
diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs
index 2b091a6..be0080d 100644
--- a/src/Agent.Windows/Security/TaskAclManager.cs
+++ b/src/Agent.Windows/Security/TaskAclManager.cs
@@ -10,27 +10,64 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity
{
public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities)
{
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
FileSystemSecurity security = ReadSecurity(canonical);
byte[] original = security.GetSecurityDescriptorBinaryForm();
Guid? journalEntry = journal?.Prepare("acl", canonical, new Dictionary<string, string>
{
["security_descriptor"] = Convert.ToBase64String(original)
});
- FileSystemRights rights = ToRights(capabilities);
- if (rights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
bool directory = Directory.Exists(canonical);
- var rule = new FileSystemAccessRule(appContainerSid, rights,
- directory ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None,
- PropagationFlags.None, AccessControlType.Allow);
- security.AddAccessRule(rule);
- WriteSecurity(canonical, security);
- if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value);
+ FileSystemRights auditRights;
+ if (directory)
+ {
+ FileSystemRights directoryRights = ToDirectoryRights(capabilities);
+ FileSystemRights fileRights = ToFileRights(capabilities);
+ if (directoryRights == 0 || fileRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
+ security.AddAccessRule(new FileSystemAccessRule(appContainerSid, directoryRights,
+ InheritanceFlags.ContainerInherit, PropagationFlags.None, AccessControlType.Allow));
+ security.AddAccessRule(new FileSystemAccessRule(appContainerSid, fileRights,
+ InheritanceFlags.ObjectInherit, PropagationFlags.InheritOnly, AccessControlType.Allow));
+ auditRights = directoryRights | fileRights;
+ }
+ else
+ {
+ auditRights = ToFileRights(capabilities);
+ if (auditRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
+ security.AddAccessRule(new FileSystemAccessRule(appContainerSid, auditRights,
+ InheritanceFlags.None, PropagationFlags.None, AccessControlType.Allow));
+ }
+ try
+ {
+ WriteSecurity(canonical, security);
+ if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value);
+ }
+ catch (Exception applyFailure)
+ {
+ try
+ {
+ byte[] current = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm();
+ if (!current.AsSpan().SequenceEqual(original))
+ {
+ FileSystemSecurity rollback = Directory.Exists(canonical) ? new DirectorySecurity() : new FileSecurity();
+ rollback.SetSecurityDescriptorBinaryForm(original, AccessControlSections.Access);
+ WriteSecurity(canonical, rollback);
+ byte[] restored = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm();
+ if (!restored.AsSpan().SequenceEqual(original)) throw new InvalidOperationException("ACL rollback verification failed.");
+ }
+ if (journalEntry is not null) journal!.MarkCleaned(journalEntry.Value);
+ }
+ catch (Exception rollbackFailure)
+ {
+ throw new AggregateException("ACL apply failed and immediate rollback did not reach a verified clean state.", applyFailure, rollbackFailure);
+ }
+ throw;
+ }
audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string>
{
["path"] = canonical,
["sid"] = appContainerSid.Value,
- ["rights"] = rights.ToString(),
+ ["rights"] = auditRights.ToString(),
["scope"] = directory ? "root_inheritance" : "file"
}));
return new(canonical, original, journalEntry);
@@ -55,12 +92,23 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity
else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security);
}
- private static FileSystemRights ToRights(FileCapability capabilities)
+ private static FileSystemRights ToFileRights(FileCapability capabilities)
{
FileSystemRights rights = 0;
- if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory;
+ if ((capabilities & FileCapability.Read) != 0)
+ rights |= FileSystemRights.ReadData | FileSystemRights.ReadAttributes | FileSystemRights.ReadExtendedAttributes | FileSystemRights.ReadPermissions;
if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write;
- if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute;
+ if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ExecuteFile | FileSystemRights.ReadData | FileSystemRights.ReadAttributes;
+ if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete;
+ return rights;
+ }
+
+ private static FileSystemRights ToDirectoryRights(FileCapability capabilities)
+ {
+ FileSystemRights rights = FileSystemRights.Traverse | FileSystemRights.ReadAttributes | FileSystemRights.ReadPermissions;
+ if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ListDirectory | FileSystemRights.ReadExtendedAttributes;
+ if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.CreateFiles | FileSystemRights.CreateDirectories |
+ FileSystemRights.WriteAttributes | FileSystemRights.WriteExtendedAttributes;
if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles;
return rights;
}
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index 5cbf853..af4c906 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -10,12 +10,13 @@ using Agent.Windows.Security;
namespace Agent.Windows.Tools;
-public sealed record WindowsSandboxSettings(
+public sealed record EffectiveSandboxPolicy(
IReadOnlyList<FilePolicyEntry> Files,
- IReadOnlyList<ExecutableApproval> Executables,
- bool AllowShell,
+ IReadOnlyList<ApprovedEntryExecutable> EntryExecutables,
+ IReadOnlyList<string> RuntimeRoots,
+ IReadOnlyList<string> ArtifactRoots,
SandboxJobLimits JobLimits,
- string NetworkMode,
+ string NetworkAccess,
IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances,
string JournalDirectory,
string BackupDirectory,
@@ -23,17 +24,22 @@ public sealed record WindowsSandboxSettings(
long WholeReadLimitBytes,
int SearchResultLimit,
int TailLineLimit,
- int ProcessTimeoutSeconds = 300);
+ int ProcessTimeoutSeconds = 300,
+ string ResourceProfile = "normal",
+ IReadOnlyList<string>? BaselineCapabilities = null);
-public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend
+public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend
{
private readonly ConcurrentDictionary<Guid, TaskContext> _contexts = new();
private readonly PathPolicy _paths = new(settings.Files);
- private readonly ExecutablePolicy _executables = new(settings.Executables, settings.AllowShell);
+ private readonly EntryExecutablePolicy _entryExecutables = new(settings.EntryExecutables, settings.RuntimeRoots, settings.ArtifactRoots);
private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler();
public async Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken)
{
+ if (settings.BaselineCapabilities is not null &&
+ !settings.BaselineCapabilities.SequenceEqual(["registryRead", "lpacInstrumentation"], StringComparer.Ordinal))
+ throw new InvalidOperationException("Effective Sandbox Policy baseline capabilities do not match the fixed LPAC baseline.");
if (_contexts.Count != 0) throw new InvalidOperationException("Only one Task Resource scope may be active.");
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
TaskResourceJournal journal = TaskResourceJournal.Create(settings.JournalDirectory, taskId);
@@ -42,23 +48,29 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
_taskUpdates.BeginTask(taskId, sessionId);
try
{
- foreach ((string path, FileCapability capabilities) in settings.Files
- .GroupBy(static entry => CanonicalPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase)
- .Select(static group => (group.Key, group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities))))
+ foreach ((string path, FileCapability capabilities, bool osRuntimeRoot) in settings.Files
+ .GroupBy(static entry => ResolvedPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase)
+ .Select(static group => (group.Key,
+ group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities),
+ group.All(static item => item.Origin == "os_runtime_root"))))
{
+ // System32 has a protected OS-owned DACL. Its existing LPAC access is verified by
+ // real process-tree tests; changing or unprotecting that DACL is forbidden.
+ if (osRuntimeRoot) continue;
// OS/runtime executables may already grant AppContainer execute through their protected system ACL.
// Do not rewrite a file ACL when execute is the only requested capability; CreateProcess remains
- // constrained by AppContainer access plus canonical path/SHA-256 approval.
+ // constrained by LPAC access plus Resolved Path/SHA-256 Entry approval.
if (File.Exists(path) && capabilities == FileCapability.Execute) continue;
// A single inheritable ACE on an approved directory covers existing and newly-created
// descendants. Snapshotting and rewriting every child ACL is both redundant and
// prohibitively expensive for runtime trees such as Python installations.
context.Acls.Add(acl.Grant(taskId, path, capabilities));
}
- IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkMode == "allow"
+ IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkAccess != "none"
? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false)
: [];
- context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId);
+ context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId,
+ enableLoopback: settings.NetworkAccess != "none");
if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists.");
audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.start", "running", new Dictionary<string, string>(), SessionId: sessionId));
}
@@ -82,7 +94,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
"file_search" => Task.FromResult(Search(arguments)),
"file_read" => Task.FromResult(Read(arguments)),
"file_edit" => Task.FromResult(Edit(context, arguments)),
- "process_run" => Task.FromResult(RunProcess(context, arguments)),
+ "process_run" => Task.FromResult(RunProcess(context, arguments, cancellationToken)),
"task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)),
"dir" => Task.FromResult(DirectoryOp(context, arguments)),
_ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.")
@@ -128,7 +140,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
{
string root = arguments.GetProperty("root").GetString()!;
_paths.Demand(root, FileCapability.Read);
- string canonical = CanonicalPath.Resolve(root);
+ string canonical = ResolvedPath.Resolve(root);
string mode = arguments.GetProperty("mode").GetString()!;
string query = arguments.GetProperty("query").GetString()!;
int max = arguments.TryGetProperty("max_results", out JsonElement maxElement) ? maxElement.GetInt32() : 100;
@@ -164,10 +176,10 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
{
string path = arguments.GetProperty("path").GetString()!;
_paths.Demand(path, FileCapability.Read);
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
string mode = arguments.GetProperty("mode").GetString()!;
FileInfo info = new(canonical);
- if (mode == "metadata") return Success(JsonSerializer.Serialize(new { path = canonical, info.Length, info.LastWriteTimeUtc, sha256 = ExecutablePolicy.ComputeSha256(canonical) }));
+ if (mode == "metadata") return Success(JsonSerializer.Serialize(new { path = canonical, info.Length, info.LastWriteTimeUtc, sha256 = EntryExecutablePolicy.ComputeSha256(canonical) }));
if (mode == "whole")
{
if (info.Length > settings.WholeReadLimitBytes) throw new IOException("Whole read exceeds configured limit; use range mode.");
@@ -245,7 +257,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
if (operation == "list")
{
_paths.Demand(path, FileCapability.Read);
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
var entries = Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly)
.Select(Path.GetFullPath)
.ToArray();
@@ -254,20 +266,20 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
throw new ToolValidationException($"Unsupported dir operation '{operation}'.");
}
- private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments)
+ private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments, CancellationToken cancellationToken)
{
Guid taskId = context.Journal.TaskId;
string executable = arguments.GetProperty("executable").GetString()!;
string cwd = arguments.GetProperty("cwd").GetString()!;
- _paths.Demand(executable, FileCapability.Execute);
_paths.Demand(cwd, FileCapability.Read);
string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray();
- string? managedEntryPoint = ExecutablePolicy.ManagedEntryPoint(executable, args);
+ string? managedEntryPoint = _entryExecutables.ManagedEntryPoint(executable, args, cwd);
if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute);
int seconds = ResolveProcessTimeout(arguments, settings.ProcessTimeoutSeconds);
string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null;
- RestrictedProcessResult result = new RestrictedProcessLauncher(_executables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits)
- .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId);
+ RestrictedProcessResult result = new RestrictedProcessLauncher(_entryExecutables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits,
+ allowNetwork: settings.NetworkAccess != "none")
+ .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId, cancellationToken);
string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}";
return new(result.ExitCode == 0, output, true,
result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}");
@@ -278,7 +290,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : configuredTimeoutSeconds;
if (seconds <= 0) throw new ToolValidationException("timeout_seconds must be positive.");
if (seconds > configuredTimeoutSeconds)
- throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({configuredTimeoutSeconds}).");
+ throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.execution_timeout_seconds ({configuredTimeoutSeconds}).");
return seconds;
}
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs
index e588825..954792b 100644
--- a/tests/Agent.Core.Tests/ConfigurationTests.cs
+++ b/tests/Agent.Core.Tests/ConfigurationTests.cs
@@ -3,6 +3,8 @@ using Agent.App.Configuration;
using Agent.App;
using Agent.Core.Localization;
using Agent.Core.Skills;
+using Agent.App.Sandbox;
+using Agent.Windows.Security;
namespace Agent.Core.Tests;
@@ -16,7 +18,7 @@ public sealed class ConfigurationTests
AgentConfiguration result = ConfigurationLoader.Load(config);
Assert.Equal(1, result.SchemaVersion);
Assert.Equal("model", result.Ollama.Model);
- Assert.Equal("deny", result.Sandbox.Network.Mode);
+ Assert.Equal("none", result.Sandbox.Network.Access);
}
[Fact]
@@ -93,14 +95,13 @@ public sealed class ConfigurationTests
application:
language: ja
sandbox:
- filesystem:
- read: [C:/local-only]
+ workspaces: [C:/local-only]
""");
AgentConfiguration result = ConfigurationLoader.Load(config);
Assert.Equal("ja", result.Application.Language);
- Assert.Equal(["C:/local-only"], result.Sandbox.Filesystem.Read);
+ Assert.Equal(["C:/local-only"], result.Sandbox.Workspaces);
Assert.Equal("model", result.Ollama.Model);
}
@@ -115,21 +116,243 @@ public sealed class ConfigurationTests
}
[Fact]
- public void Execute_file_without_process_approval_reports_canonical_keys()
+ public void Legacy_filesystem_and_process_schema_is_rejected_with_migration_diagnostic()
{
using var directory = new TestDirectory();
string config = CreateValidConfiguration(directory);
- string executable = directory.File("tools/tool.exe", "fixture").Replace('\\', '/');
directory.File("agent.local.yaml", $$"""
sandbox:
filesystem:
- execute: [{{executable}}]
+ execute: [C:/legacy/tool.exe]
+ process:
+ approvals: []
+ """);
+
+ 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);
+ }
+
+ [Fact]
+ public void Semantic_sandbox_rejects_readonly_write_mount_and_conflicting_network_endpoints()
+ {
+ using var directory = new TestDirectory();
+ string config = CreateValidConfiguration(directory);
+ directory.File("agent.local.yaml", """
+ sandbox:
+ profile: readonly
+ mounts:
+ - path: C:/data
+ access: read_write
+ network:
+ access: none
+ local_endpoints:
+ - port: 11434
""");
ConfigurationException error = Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config));
- Assert.Contains("sandbox.filesystem.execute", error.Message, StringComparison.Ordinal);
- Assert.Contains("sandbox.process.approvals", error.Message, StringComparison.Ordinal);
- Assert.Contains(Path.GetFullPath(executable), error.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("readonly", error.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Management_commands_require_config_and_capture_options()
+ {
+ 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); }
+ }
+
+ [Fact]
+ public void Shell_classification_accepts_only_the_documented_command_interpreters()
+ {
+ Assert.True(ExecutableResolver.IsSupportedShell("C:/Windows/System32/cmd.exe"));
+ Assert.True(ExecutableResolver.IsSupportedShell("C:/Program Files/PowerShell/7/pwsh.exe"));
+ Assert.False(ExecutableResolver.IsSupportedShell("C:/tools/python.exe"));
+ }
+
+ [Fact]
+ public void Resolution_confirmation_is_invalidated_by_config_fingerprint_change()
+ {
+ using var directory = new TestDirectory();
+ string configPath = directory.File("agent.yaml", "schema_version: 1");
+ 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 = "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();
+ _ = 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);
+ }
+
+ [Fact]
+ public void Approval_store_never_refreshes_a_changed_executable_identity()
+ {
+ using var directory = new TestDirectory();
+ string configPath = directory.File("agent.yaml", "schema_version: 1");
+ 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();
+ 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.Empty(Directory.EnumerateFiles(stores, "*.tmp"));
+ }
+
+ [Fact]
+ public void Approval_store_rejects_malformed_identity_records()
+ {
+ using var directory = new TestDirectory();
+ string storePath = directory.File("stores/approvals.json", """
+ {
+ "schemaVersion": 1,
+ "approvals": [{
+ "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"
+ }]
+ }
+ """);
+ var store = new ExecutableApprovalStore(storePath, protectDirectory: false);
+
+ Assert.Throws<ConfigurationException>(() => store.List());
+ }
+
+ [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 workspace = Path.Combine(directory.Path, "workspace");
+ string nested = Path.Combine(workspace, "read-only-data");
+ Directory.CreateDirectory(nested);
+ string stores = Path.Combine(directory.Path, "stores");
+ var configuration = new AgentConfiguration
+ {
+ Sandbox = new SandboxOptions
+ {
+ Profile = "development",
+ Workspaces = [workspace],
+ 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();
+
+ ConfigurationException error = Assert.Throws<ConfigurationException>(() =>
+ resolver.Compile(Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups")));
+ Assert.Contains("cannot be enforced", error.Message, StringComparison.Ordinal);
+ }
+
+ [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 workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string stores = Path.Combine(directory.Path, "stores");
+ var configuration = new AgentConfiguration
+ {
+ Sandbox = new SandboxOptions
+ {
+ Profile = "development",
+ Workspaces = [workspace],
+ Network = new NetworkOptions { Access = "local", LocalEndpoints = [new LocalEndpointOptions { Port = 11434 }] },
+ 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();
+
+ Agent.Windows.Tools.EffectiveSandboxPolicy policy = resolver.Compile(
+ Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"));
+
+ Assert.Contains(workspace, policy.ArtifactRoots, StringComparer.OrdinalIgnoreCase);
+ Assert.Contains(policy.Files, item => item.Path.Equals(workspace, StringComparison.OrdinalIgnoreCase) &&
+ item.Capabilities == (FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute));
+ Assert.Equal(6u, policy.JobLimits.MaxProcesses);
+ Assert.Equal(1024UL * 1024 * 1024, policy.JobLimits.MaxJobMemoryBytes);
+ Assert.Equal(450, policy.ProcessTimeoutSeconds);
+ Assert.Contains(policy.NetworkAllowances, item => item.Host == "127.0.0.1" && item.Ports.SequenceEqual([11434]));
+ Assert.Contains(policy.NetworkAllowances, item => item.Host == "::1" && item.Ports.SequenceEqual([11434]));
+ }
+
+ [Fact]
+ 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 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();
+
+ Agent.Windows.Tools.EffectiveSandboxPolicy policy = resolver.Compile(
+ Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"));
+
+ Assert.Empty(policy.ArtifactRoots);
+ Assert.Contains(policy.Files, item => item.Path.Equals(workspace, StringComparison.OrdinalIgnoreCase) && item.Capabilities == FileCapability.Read);
}
[Fact]
@@ -160,6 +383,13 @@ public sealed class ConfigurationTests
string message = Program.FormatStartupError(["--lang", "ja"], new CommandLineException("bad option"));
Assert.Contains("コマンドラインが不正です", 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",
+ resolution: "internal resolution"));
+ Assert.Contains("SHA-256が変更されました", sandbox, StringComparison.Ordinal);
+ Assert.Contains("明示的に再承認", sandbox, StringComparison.Ordinal);
+ Assert.DoesNotContain("internal resolution", sandbox, StringComparison.Ordinal);
}
[Fact]
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index 082ea9b..b7f27e7 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -275,7 +275,7 @@ public sealed class PromptAndRuntimeTests
{
TaskRegistry registry = new();
var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true);
- var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"));
+ var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("execution-only-marker", "en", [], "sandbox"));
TaskSnapshot task = registry.Enqueue("analyze", null, 0.7);
registry.Transition(task.TaskId, AgentTaskState.Planning);
@@ -291,6 +291,8 @@ public sealed class PromptAndRuntimeTests
message.Role == "system" && message.Content.Contains("one implementation-and-verification Task", StringComparison.Ordinal));
Assert.Contains(ollama.PlanningMessages[1], message =>
message.Role == "system" && message.Content.Contains("Do not call or execute any Tool", StringComparison.Ordinal));
+ Assert.DoesNotContain(ollama.PlanningMessages.SelectMany(static messages => messages), message =>
+ message.Content.Contains("execution-only-marker", StringComparison.Ordinal));
}
[Fact]
@@ -311,6 +313,23 @@ public sealed class PromptAndRuntimeTests
}
[Fact]
+ public async Task Split_coding_plan_is_rejected_and_retried_with_specific_correction()
+ {
+ TaskRegistry registry = new();
+ var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true, semanticSplitFirst: true);
+ var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"));
+ TaskSnapshot task = registry.Enqueue("implement and test", null, null);
+ registry.Transition(task.TaskId, AgentTaskState.Planning);
+
+ await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed));
+ Assert.Equal(AgentTaskState.Completed, completed!.State);
+ Assert.Contains(ollama.PlanningMessages[1], message => message.Role == "system" &&
+ message.Content.Contains("cannot depend on a separate file_edit Task", StringComparison.Ordinal));
+ }
+
+ [Fact]
public async Task Failed_context_summary_does_not_delete_original_context()
{
var sessions = new SessionStore();
@@ -610,7 +629,7 @@ public sealed class PromptAndRuntimeTests
}
}
- private sealed class ThinkRetryOllamaClient(bool succeedOnRetry) : IOllamaClient
+ private sealed class ThinkRetryOllamaClient(bool succeedOnRetry, bool semanticSplitFirst = false) : IOllamaClient
{
public List<bool?> ThinkOverrides { get; } = [];
public List<double?> Temperatures { get; } = [];
@@ -623,7 +642,9 @@ public sealed class PromptAndRuntimeTests
ThinkOverrides.Add(thinkOverride);
Temperatures.Add(temperatureOverride);
PlanningMessages.Add(messages);
- string content = ThinkOverrides.Count == 2 && succeedOnRetry
+ string content = ThinkOverrides.Count == 1 && semanticSplitFirst
+ ? """{"tasks":[{"description":"edit","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["file_edit"]},{"description":"verify and correct","dependencies":[0],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["file_edit","process_run"]}]}"""
+ : ThinkOverrides.Count == 2 && succeedOnRetry
? """
Here is the requested plan.
diff --git a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
index 0b45401..7888f53 100644
--- a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
+++ b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Agent.Windows\Agent.Windows.csproj" />
+ <ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
index d025eac..6686833 100644
--- a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
+++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
@@ -59,7 +59,7 @@ internal static class CrashScenarioBootstrap
string auditPath = Path.Combine(root, "crash-audit.jsonl");
var processAudit = new JsonLinesSecurityAuditSink(auditPath);
var launcher = new RestrictedProcessLauncher(
- new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), processAudit);
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), processAudit);
string escapedAudit = auditPath.Replace("'", "''", StringComparison.Ordinal);
string crashError = Path.Combine(root, "crash-error.txt").Replace("'", "''", StringComparison.Ordinal);
string watchdogScript = $"$d=[DateTime]::UtcNow.AddSeconds(10); while([DateTime]::UtcNow -lt $d){{ if((Test-Path -LiteralPath '{escapedAudit}') -and (Select-String -LiteralPath '{escapedAudit}' -SimpleMatch '\"event\":\"process.start\"' -Quiet)){{ Stop-Process -Id {Environment.ProcessId} -Force; exit 0 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{crashError}' -Value 'Sandbox process did not start before crash'; Stop-Process -Id {Environment.ProcessId} -Force";
@@ -68,7 +68,7 @@ internal static class CrashScenarioBootstrap
FileName = powershell, UseShellExecute = false, CreateNoWindow = true,
ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", watchdogScript }
});
- _ = launcher.Run(taskId, powershell,
+ _ = launcher.Run(taskId, "powershell",
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"],
Environment.SystemDirectory, TimeSpan.FromMinutes(1));
break;
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index da6b325..09ed61d 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -9,12 +9,36 @@ using Agent.Windows.Network;
using Agent.Windows.Tools;
using Agent.Core.Tools;
using System.Text.Json;
+using Agent.App.Sandbox;
+using Agent.App.Configuration;
namespace Agent.Windows.Tests;
public sealed class SecurityPolicyTests
{
[Fact]
+ public void Approval_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"));
+ DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(
+ new DirectoryInfo(Path.GetDirectoryName(storePath)!), 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))
+ .OfType<FileSystemAccessRule>().ToArray();
+ Assert.NotEmpty(rules);
+ Assert.All(rules, rule => Assert.Equal(identity.User, rule.IdentityReference));
+ }
+
+ [Fact]
public void Process_timeout_uses_configured_default_and_rejects_higher_values()
{
using JsonDocument omitted = JsonDocument.Parse("{}");
@@ -52,11 +76,12 @@ public sealed class SecurityPolicyTests
}
[Fact]
- public void Reparse_escape_is_rejected()
+ public void Reparse_escape_is_resolved_and_denied_by_effective_root()
{
using TestDirectory approved = new();
using TestDirectory outside = new();
string link = Path.Combine(approved.Path, "escape");
+ File.WriteAllText(Path.Combine(outside.Path, "secret.txt"), "secret");
using Process junction = Process.Start(new ProcessStartInfo
{
FileName = Environment.GetEnvironmentVariable("ComSpec")!,
@@ -70,8 +95,7 @@ public sealed class SecurityPolicyTests
try
{
- SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read));
- Assert.Equal("path.reparse", error.Code);
+ Assert.False(policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read));
}
finally
{
@@ -80,68 +104,131 @@ public sealed class SecurityPolicyTests
}
[Fact]
- public void Symlink_chain_escape_is_rejected()
+ public void Symlink_chain_is_resolved_and_denied_by_effective_root()
{
using TestDirectory approved = new();
using TestDirectory outside = new();
string second = Path.Combine(approved.Path, "second-link");
string first = Path.Combine(approved.Path, "first-link");
+ File.WriteAllText(Path.Combine(outside.Path, "secret.txt"), "secret");
Directory.CreateSymbolicLink(second, outside.Path);
Directory.CreateSymbolicLink(first, second);
PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]);
- SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() =>
- policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read));
+ Assert.False(policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read));
+ Directory.Delete(first);
+ Directory.Delete(second);
+ }
+
+ [Fact]
+ public void Reparse_resolution_cycle_is_rejected_fail_closed()
+ {
+ using TestDirectory directory = new();
+ string first = Path.Combine(directory.Path, "cycle-a");
+ string second = Path.Combine(directory.Path, "cycle-b");
+ Directory.CreateSymbolicLink(first, second);
+ Directory.CreateSymbolicLink(second, first);
+
+ SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => ResolvedPath.Resolve(first));
- Assert.Equal("path.reparse", error.Code);
+ Assert.Equal("path.reparse_cycle", error.Code);
Directory.Delete(first);
Directory.Delete(second);
}
[Fact]
- public void Executable_requires_approved_path_and_hash_and_shell_toggle()
+ public void Confirmed_resolution_is_invalidated_when_a_junction_target_changes()
{
+ using TestDirectory directory = new();
+ string firstTarget = Path.Combine(directory.Path, "target-one");
+ string secondTarget = Path.Combine(directory.Path, "target-two");
+ string junctionPath = Path.Combine(directory.Path, "workspace-link");
+ Directory.CreateDirectory(firstTarget);
+ Directory.CreateDirectory(secondTarget);
+ CreateJunction(junctionPath, firstTarget);
+ string configPath = Path.Combine(directory.Path, "agent.yaml");
+ File.WriteAllText(configPath, "schema_version: 1");
+ 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();
+ 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);
+ Directory.Delete(junctionPath);
+ }
+
+ [Fact]
+ public void Entry_executable_requires_alias_path_and_hash_identity()
+ {
+ using TestDirectory roots = new();
string executable = Environment.ProcessPath!;
- string hash = ExecutablePolicy.ComputeSha256(executable);
- Assert.Equal(hash, new ExecutablePolicy([new ExecutableApproval(executable, hash)], true).Verify(executable).Sha256);
- Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([], true).Verify(executable));
+ string hash = EntryExecutablePolicy.ComputeSha256(executable);
+ var approval = new ApprovedEntryExecutable("test", executable, hash, false, null);
+ Assert.Equal(hash, new EntryExecutablePolicy([approval], [], []).VerifyEntry("test").Sha256);
+ Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([approval], [], []).VerifyEntry(executable));
+ Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([], [], []).VerifyEntry(executable));
+ Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([], [], [])
+ .VerifyEntry(Path.Combine(Environment.SystemDirectory, "cmd.exe")));
string badHash = new string(hash[0] == '0' ? '1' : '0', 1) + hash[1..];
- Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(executable, badHash)], true).Verify(executable));
-
- string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
- Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], false).Verify(powershell));
+ Assert.Throws<SandboxPolicyException>(() => new EntryExecutablePolicy([approval with { Sha256 = badHash }], [], []).VerifyEntry("test"));
+
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe");
+ var shell = new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null);
+ Assert.Equal(EntryExecutableClassification.ApprovedShellExecutable,
+ new EntryExecutablePolicy([shell], [], []).VerifyEntry("powershell").Classification);
+
+ string runtimeRoot = Path.Combine(roots.Path, "runtime");
+ string artifactRoot = Path.Combine(roots.Path, "workspace");
+ Directory.CreateDirectory(runtimeRoot);
+ Directory.CreateDirectory(artifactRoot);
+ string runtimeComponent = Path.Combine(runtimeRoot, "component.exe");
+ string workspaceArtifact = Path.Combine(artifactRoot, "artifact.exe");
+ File.WriteAllText(runtimeComponent, "runtime");
+ File.WriteAllText(workspaceArtifact, "artifact");
+ var rootsPolicy = new EntryExecutablePolicy([], [runtimeRoot], [artifactRoot]);
+ Assert.Equal(EntryExecutableClassification.RuntimeComponent, rootsPolicy.VerifyEntry(runtimeComponent).Classification);
+ Assert.Equal(EntryExecutableClassification.WorkspaceArtifact, rootsPolicy.VerifyEntry(workspaceArtifact).Classification);
+ Assert.Equal(EntryExecutableClassification.WorkspaceArtifact, rootsPolicy.VerifyEntry("artifact.exe", artifactRoot).Classification);
}
[Fact]
- public void Restricted_parent_cannot_start_unbrokered_descendant()
+ public void Approved_entry_can_start_descendants_in_the_same_job_tree()
{
using TestDirectory directory = new();
string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
var audit = new MemorySecurityAuditSink();
var launcher = new RestrictedProcessLauncher(
- new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit);
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit);
string copied = Path.Combine(directory.Path, "renamed-child.exe");
File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, copied);
- string[] markers = Enumerable.Range(1, 4).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray();
+ string[] markers = Enumerable.Range(1, 2).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray();
string[] commands =
[
$"$ErrorActionPreference='Stop'; Start-Process -FilePath $env:ComSpec -ArgumentList '/d','/c','echo compromised > \"{markers[0]}\"' -Wait",
- $"$ErrorActionPreference='Stop'; [Diagnostics.Process]::Start($env:ComSpec, '/d /c echo compromised ^> \"{markers[1]}\"').WaitForExit()",
- $"$ErrorActionPreference='Stop'; & $env:ComSpec /d /c 'echo compromised > \"{markers[2]}\"'",
- $"$ErrorActionPreference='Stop'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[3]}\"' -Wait"
+ $"$ErrorActionPreference='Stop'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[1]}\"' -Wait"
];
foreach (string command in commands)
{
- RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), powershell,
- ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30));
- Assert.NotEqual(0u, result.ExitCode);
+ RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), "powershell",
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30),
+ null, null, TestContext.Current.CancellationToken);
+ Assert.Equal(0u, result.ExitCode);
}
- Assert.All(markers, marker => Assert.False(File.Exists(marker)));
- Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["child_process_policy"] == "restricted");
- Assert.Contains(audit.Events, item => item.Event == "process.exit" && item.Data["exit_code"] != "0");
- Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "policy.deny" && item.Data["child_process_policy"] == "restricted"));
+ Assert.All(markers, marker => Assert.True(File.Exists(marker)));
+ Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["descendant_policy"] == "lpac_inherited");
+ Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "process.exit" && item.Data["exit_code"] == "0"));
}
[Fact]
@@ -150,11 +237,11 @@ public sealed class SecurityPolicyTests
string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
var audit = new MemorySecurityAuditSink();
var launcher = new RestrictedProcessLauncher(
- new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit);
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit);
- Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), powershell,
+ Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), "powershell",
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"],
- Environment.SystemDirectory, TimeSpan.FromMilliseconds(250)));
+ Environment.SystemDirectory, TimeSpan.FromMilliseconds(250), null, null, TestContext.Current.CancellationToken));
SecurityAuditEvent timeout = Assert.Single(audit.Events, item => item.Event == "process.timeout");
int pid = int.Parse(timeout.Data["pid"], System.Globalization.CultureInfo.InvariantCulture);
@@ -162,19 +249,58 @@ public sealed class SecurityPolicyTests
}
[Fact]
- public void Dotnet_managed_entry_requires_its_own_path_and_hash_approval()
+ public void Job_process_limit_applies_to_the_entire_descendant_tree()
+ {
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []),
+ new MemorySecurityAuditSink(), jobLimits: new SandboxJobLimits(1, 512UL * 1024 * 1024));
+ const string script = "$denied=$false; try { $p=Start-Process -PassThru -ErrorAction Stop -FilePath $env:ComSpec -ArgumentList '/d','/c','exit 0'; $p.WaitForExit() } catch { $denied=$true }; if(-not $denied){ exit 46 }";
+
+ RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), "powershell",
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], Environment.SystemDirectory,
+ TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(0u, result.ExitCode);
+ }
+
+ [Fact]
+ public void Cancellation_terminates_the_entire_sandbox_job_without_orphan()
+ {
+ using TestDirectory directory = new();
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
+ string childPidFile = Path.Combine(directory.Path, "child.pid");
+ var audit = new MemorySecurityAuditSink();
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), audit);
+ using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+
+ Assert.Throws<OperationCanceledException>(() => launcher.Run(Guid.NewGuid(), "powershell",
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
+ $"$p=Start-Process -PassThru -FilePath $env:ComSpec -ArgumentList '/d','/c','ping -n 30 127.0.0.1 > nul'; Set-Content -LiteralPath '{childPidFile}' -Value $p.Id; Start-Sleep -Seconds 30"],
+ directory.Path, TimeSpan.FromSeconds(60), null, null, cancellation.Token));
+
+ Assert.True(File.Exists(childPidFile));
+ int childPid = int.Parse(File.ReadAllText(childPidFile), System.Globalization.CultureInfo.InvariantCulture);
+ Assert.Throws<ArgumentException>(() => Process.GetProcessById(childPid));
+ Assert.Contains(audit.Events, item => item.Event == "process.cancel" && item.Outcome == "terminated");
+ }
+
+ [Fact]
+ public void Dotnet_managed_entry_requires_runtime_or_artifact_containment()
{
string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe");
string managedAssembly = typeof(SecurityPolicyTests).Assembly.Location;
var audit = new MemorySecurityAuditSink();
var launcher = new RestrictedProcessLauncher(
- new ExecutablePolicy([new ExecutableApproval(dotnet, ExecutablePolicy.ComputeSha256(dotnet))], true), audit);
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, null)], [], []), audit);
SandboxPolicyException denied = Assert.Throws<SandboxPolicyException>(() => launcher.Run(
- Guid.NewGuid(), dotnet, [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5)));
+ Guid.NewGuid(), "dotnet", [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5),
+ null, null, TestContext.Current.CancellationToken));
- Assert.Equal("process.path_denied", denied.Code);
- Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.path_denied");
+ Assert.Equal("process.managed_entry_unknown", denied.Code);
+ Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.managed_entry_unknown");
}
[Fact]
@@ -285,15 +411,94 @@ public sealed class SecurityPolicyTests
try
{
var launcher = new RestrictedProcessLauncher(
- new ExecutablePolicy([new ExecutableApproval(commandProcessor, ExecutablePolicy.ComputeSha256(commandProcessor))], true),
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("cmd", commandProcessor, EntryExecutablePolicy.ComputeSha256(commandProcessor), true, null)], [], []),
audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024));
- RestrictedProcessResult result = launcher.Run(taskId, commandProcessor, ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], directory.Path, TimeSpan.FromSeconds(30));
+ RestrictedProcessResult result = launcher.Run(taskId, "cmd", ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"],
+ directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken);
Assert.Equal(0u, result.ExitCode);
Assert.Equal("APP_OK", File.ReadAllText(marker).Trim());
Assert.Contains("CAPTURE_OK", result.StandardOutput, StringComparison.Ordinal);
- Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["app_container_sid"] == profile.Sid.Value);
+ Assert.Contains(audit.Events, item => item.Event == "process.start" &&
+ item.Data["app_container_sid"] == profile.Sid.Value && item.Data["token_app_container_sid"] == profile.Sid.Value &&
+ item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString &&
+ item.Data["all_application_packages_opt_out"] == bool.TrueString);
+ }
+ finally
+ {
+ acl.Restore(taskId, snapshot);
+ }
+ }
+
+ [Fact]
+ public void Lpac_baseline_allows_registry_read_but_denies_registry_write_and_com_activation()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe");
+ string registryName = "AgentSandboxMcpDenied" + Guid.NewGuid().ToString("N");
+ string secretName = "AGENT_SANDBOX_SECRET_" + Guid.NewGuid().ToString("N");
+ Environment.SetEnvironmentVariable(secretName, "must-not-cross-broker-boundary");
+ var audit = new MemorySecurityAuditSink();
+ TaskAclManager acl = new(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write);
+ try
+ {
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []),
+ audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024));
+ string script = $"$ErrorActionPreference='Stop'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ProductName; " +
+ $"$writeDenied=$false; try {{ New-Item -Path 'HKCU:\\Software\\{registryName}' -Force | Out-Null }} catch {{ $writeDenied=$true }}; " +
+ "$comDenied=$false; try { $null=New-Object -ComObject Scripting.FileSystemObject } catch { $comDenied=$true }; " +
+ $"if(-not $writeDenied -or -not $comDenied -or $env:{secretName}){{ exit 43 }}; Write-Output \"REGISTRY_READ=$p;REGISTRY_WRITE_DENIED=$writeDenied;COM_DENIED=$comDenied;BROKER_SECRET_INHERITED=$([bool]$env:{secretName})\"";
+
+ RestrictedProcessResult result = launcher.Run(taskId, "powershell",
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], directory.Path, TimeSpan.FromSeconds(30),
+ null, null, TestContext.Current.CancellationToken);
+
+ Assert.True(result.ExitCode == 0u,
+ $"LPAC baseline probe exited {result.ExitCode}. stdout: {result.StandardOutput}; stderr: {result.StandardError}");
+ Assert.Contains("REGISTRY_READ=", result.StandardOutput, StringComparison.Ordinal);
+ Assert.Contains("REGISTRY_WRITE_DENIED=True", result.StandardOutput, StringComparison.Ordinal);
+ Assert.Contains("COM_DENIED=True", result.StandardOutput, StringComparison.Ordinal);
+ Assert.Contains("BROKER_SECRET_INHERITED=False", result.StandardOutput, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree($"Software\\{registryName}", throwOnMissingSubKey: false);
+ Environment.SetEnvironmentVariable(secretName, null);
+ acl.Restore(taskId, snapshot);
+ }
+ }
+
+ [Fact]
+ public void Approved_powershell_starts_unapproved_cmd_descendant_inside_lpac_job()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe");
+ string marker = Path.Combine(directory.Path, "cmd-descendant.txt");
+ var audit = new MemorySecurityAuditSink();
+ TaskAclManager acl = new(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write);
+ try
+ {
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []),
+ audit, profile.Sid, new SandboxJobLimits(4, 512UL * 1024 * 1024));
+ string command = $"& $env:ComSpec /d /c exit 0; if($LASTEXITCODE -ne 0){{ exit 44 }}; Set-Content -LiteralPath '{marker}' -Value 'LPAC_CHILD'";
+
+ RestrictedProcessResult result = launcher.Run(taskId, "powershell",
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30),
+ null, null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(0u, result.ExitCode);
+ Assert.Equal("LPAC_CHILD", File.ReadAllText(marker).Trim());
+ Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["token_is_app_container"] == bool.TrueString &&
+ item.Data["process_in_job"] == bool.TrueString && item.Data["descendant_policy"] == "lpac_inherited");
}
finally
{
@@ -302,6 +507,178 @@ public sealed class SecurityPolicyTests
}
[Fact]
+ public void Approved_python314_starts_python_subprocess_inside_lpac_job()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string uvPythonRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "uv", "python");
+ string python = Directory.EnumerateFiles(uvPythonRoot, "python.exe", SearchOption.AllDirectories)
+ .Where(path => path.Contains("cpython-3.14", StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase).First();
+ string resolvedPython = ResolvedPath.Resolve(python);
+ string runtimeRoot = Path.GetDirectoryName(resolvedPython)!;
+ string marker = Path.Combine(directory.Path, "python-child.txt");
+ string breakawayMarker = Path.Combine(directory.Path, "python-breakaway-child.txt");
+ var audit = new MemorySecurityAuditSink();
+ TaskAclManager acl = new(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ var snapshots = new List<AclSnapshot>();
+ try
+ {
+ snapshots.Add(acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write));
+ snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute));
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("python", resolvedPython, EntryExecutablePolicy.ComputeSha256(resolvedPython), false, runtimeRoot)],
+ [runtimeRoot], []), audit, profile.Sid, new SandboxJobLimits(4, 256UL * 1024 * 1024));
+ static string ChildProbe(string path) => "import ctypes,ctypes.wintypes as w; from pathlib import Path; " +
+ "k=ctypes.windll.kernel32; a=ctypes.windll.advapi32; k.GetCurrentProcess.restype=w.HANDLE; " +
+ "a.OpenProcessToken.argtypes=[w.HANDLE,w.DWORD,ctypes.POINTER(w.HANDLE)]; a.GetTokenInformation.argtypes=[w.HANDLE,w.DWORD,ctypes.c_void_p,w.DWORD,ctypes.POINTER(w.DWORD)]; " +
+ "a.ConvertSidToStringSidW.argtypes=[ctypes.c_void_p,ctypes.POINTER(w.LPWSTR)]; k.IsProcessInJob.argtypes=[w.HANDLE,w.HANDLE,ctypes.POINTER(w.BOOL)]; k.LocalFree.argtypes=[ctypes.c_void_p]; token=w.HANDLE(); " +
+ "ok=a.OpenProcessToken(k.GetCurrentProcess(),8,ctypes.byref(token)); " +
+ "size=w.DWORD(); a.GetTokenInformation(token,31,None,0,ctypes.byref(size)); buf=ctypes.create_string_buffer(size.value); " +
+ "ok=ok and a.GetTokenInformation(token,31,buf,size,ctypes.byref(size)); sid=ctypes.c_void_p.from_buffer(buf).value; " +
+ "text=w.LPWSTR(); ok=ok and a.ConvertSidToStringSidW(sid,ctypes.byref(text)); in_job=w.BOOL(); ok=ok and k.IsProcessInJob(k.GetCurrentProcess(),None,ctypes.byref(in_job)); " +
+ $"Path({JsonSerializer.Serialize(path)}).write_text(text.value+'|'+str(bool(in_job.value)),encoding='utf-8'); k.LocalFree(text); raise SystemExit(0 if ok else 48)";
+ string childCode = ChildProbe(marker);
+ string breakawayCode = ChildProbe(breakawayMarker);
+ string parentCode = $"import subprocess,sys; " +
+ $"result=subprocess.run([sys.executable,'-c',{JsonSerializer.Serialize(childCode)}]); " +
+ "denied=False; " +
+ $"\ntry: breakaway=subprocess.run([sys.executable,'-c',{JsonSerializer.Serialize(breakawayCode)}],creationflags=0x01000000); denied=(breakaway.returncode != 0)" +
+ "\nexcept OSError: denied=True" +
+ $"\nif not denied and __import__('pathlib').Path({JsonSerializer.Serialize(breakawayMarker)}).exists(): denied=__import__('pathlib').Path({JsonSerializer.Serialize(breakawayMarker)}).read_text(encoding='utf-8').endswith('|True')" +
+ "\nmemory_denied=False" +
+ "\ntry: payload=bytearray(512*1024*1024)" +
+ "\nexcept MemoryError: memory_denied=True" +
+ "\nraise SystemExit(0 if result.returncode == 0 and denied and memory_denied else 45)";
+
+ RestrictedProcessResult result = launcher.Run(taskId, "python", ["-c", parentCode], directory.Path, TimeSpan.FromSeconds(30),
+ null, null, TestContext.Current.CancellationToken);
+
+ Assert.True(result.ExitCode == 0u, $"Python LPAC subprocess exited {result.ExitCode}: {result.StandardError}");
+ Assert.Equal(profile.Sid.Value + "|True", File.ReadAllText(marker));
+ Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["token_is_app_container"] == bool.TrueString &&
+ item.Data["process_in_job"] == bool.TrueString);
+ }
+ finally
+ {
+ foreach (AclSnapshot snapshot in snapshots.AsEnumerable().Reverse()) acl.Restore(taskId, snapshot);
+ }
+ }
+
+ [Fact]
+ public void Development_workspace_artifact_runs_as_an_entry_without_an_approval()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string artifact = Path.Combine(directory.Path, "workspace-artifact.exe");
+ string marker = Path.Combine(directory.Path, "artifact-entry.txt");
+ File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, artifact);
+ var audit = new MemorySecurityAuditSink();
+ var acl = new TaskAclManager(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot snapshot = acl.Grant(taskId, directory.Path,
+ FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute);
+ try
+ {
+ var launcher = new RestrictedProcessLauncher(new EntryExecutablePolicy([], [], [directory.Path]), audit, profile.Sid);
+
+ RestrictedProcessResult result = launcher.Run(taskId, artifact, ["/d", "/c", $"echo ARTIFACT_OK>{marker}"],
+ directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(0u, result.ExitCode);
+ Assert.Equal("ARTIFACT_OK", File.ReadAllText(marker).Trim());
+ Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["entry_classification"] == "WorkspaceArtifact");
+ }
+ finally { acl.Restore(taskId, snapshot); }
+ }
+
+ [Fact]
+ public void Readonly_workspace_denies_write_delete_and_execute_inside_the_lpac_tree()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string data = Path.Combine(directory.Path, "data.txt");
+ string artifact = Path.Combine(directory.Path, "artifact.exe");
+ File.WriteAllText(data, "keep");
+ File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, artifact);
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe");
+ var audit = new MemorySecurityAuditSink();
+ var acl = new TaskAclManager(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read);
+ try
+ {
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []),
+ audit, profile.Sid);
+ string script = $"$writeDenied=$false; try {{ Set-Content -LiteralPath '{data}' -Value changed -ErrorAction Stop }} catch {{ $writeDenied=$true }}; " +
+ $"$deleteDenied=$false; try {{ Remove-Item -LiteralPath '{data}' -Force -ErrorAction Stop }} catch {{ $deleteDenied=$true }}; " +
+ $"$executeDenied=$false; try {{ & '{artifact}' /d /c exit 0; if($LASTEXITCODE -ne 0){{ $executeDenied=$true }} }} catch {{ $executeDenied=$true }}; " +
+ "if(-not $writeDenied -or -not $deleteDenied -or -not $executeDenied){ exit 47 }";
+
+ RestrictedProcessResult result = launcher.Run(taskId, "powershell",
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], directory.Path,
+ TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(0u, result.ExitCode);
+ Assert.Equal("keep", File.ReadAllText(data));
+ }
+ finally { acl.Restore(taskId, snapshot); }
+ }
+
+ [Fact]
+ public void Approved_dotnet_build_and_test_commands_create_confined_process_trees()
+ {
+ AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ using TestDirectory directory = new();
+ string project = Path.Combine(directory.Path, "ProcessTree.proj");
+ string buildMarker = Path.Combine(directory.Path, "build-child.txt");
+ string testMarker = Path.Combine(directory.Path, "test-child.txt");
+ File.WriteAllText(project, $$"""
+ <Project>
+ <Target Name="Build">
+ <WriteLinesToFile File="{{buildMarker}}" Lines="BUILD_CHILD" Overwrite="true" />
+ </Target>
+ <Target Name="VSTest">
+ <WriteLinesToFile File="{{testMarker}}" Lines="TEST_CHILD" Overwrite="true" />
+ </Target>
+ </Project>
+ """);
+ string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe");
+ string runtimeRoot = Path.GetDirectoryName(dotnet)!;
+ var audit = new MemorySecurityAuditSink();
+ var acl = new TaskAclManager(profile.Sid, audit);
+ Guid taskId = Guid.NewGuid();
+ var snapshots = new List<AclSnapshot>();
+ try
+ {
+ snapshots.Add(acl.Grant(taskId, directory.Path,
+ FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute));
+ snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute));
+ // System32 owns a protected DACL. Do not rewrite it; successful cmd descendants below
+ // are the enforcement check for its existing LPAC read/execute access.
+ var launcher = new RestrictedProcessLauncher(
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, runtimeRoot)],
+ [runtimeRoot], [directory.Path]), audit, profile.Sid, new SandboxJobLimits(16, 1024UL * 1024 * 1024));
+
+ RestrictedProcessResult build = launcher.Run(taskId, "dotnet", ["build", project, "--no-restore", "-nologo"],
+ directory.Path, TimeSpan.FromSeconds(60), null, null, TestContext.Current.CancellationToken);
+ RestrictedProcessResult test = launcher.Run(taskId, "dotnet", ["test", project, "--no-restore", "-nologo"],
+ directory.Path, TimeSpan.FromSeconds(60), null, null, TestContext.Current.CancellationToken);
+
+ Assert.True(build.ExitCode == 0u, $"dotnet build failed. stdout: {build.StandardOutput} stderr: {build.StandardError}");
+ Assert.True(test.ExitCode == 0u, $"dotnet test failed. stdout: {test.StandardOutput} stderr: {test.StandardError}");
+ Assert.Equal("BUILD_CHILD", File.ReadAllText(buildMarker).Trim());
+ Assert.Equal("TEST_CHILD", File.ReadAllText(testMarker).Trim());
+ }
+ finally
+ {
+ foreach (AclSnapshot snapshot in snapshots.AsEnumerable().Reverse()) acl.Restore(taskId, snapshot);
+ }
+ }
+
+ [Fact]
public void File_transaction_commit_rollback_and_hash_guard_leave_no_journal()
{
using TestDirectory directory = new();
@@ -567,9 +944,9 @@ public sealed class SecurityPolicyTests
File.WriteAllText(target, "before");
byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -606,9 +983,9 @@ public sealed class SecurityPolicyTests
string target = Path.Combine(workspace, "subdir");
byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm();
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -637,9 +1014,9 @@ public sealed class SecurityPolicyTests
string target = Path.Combine(workspace, "subdir");
Directory.CreateDirectory(target);
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -665,17 +1042,20 @@ public sealed class SecurityPolicyTests
Directory.CreateDirectory(target);
File.WriteAllText(Path.Combine(target, "child.txt"), "content");
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}""");
- await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken));
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken);
+ Assert.False(result.Success);
+ Assert.True(result.RetryAllowed);
+ Assert.Equal("tool.input_invalid", result.ErrorCode);
Assert.True(Directory.Exists(target)); // directory still exists
await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
}
@@ -689,9 +1069,9 @@ public sealed class SecurityPolicyTests
Directory.CreateDirectory(Path.Combine(workspace, "child-dir"));
File.WriteAllText(Path.Combine(workspace, "child.txt"), "content");
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -715,9 +1095,9 @@ public sealed class SecurityPolicyTests
string workspace = Path.Combine(directory.Path, "workspace");
Directory.CreateDirectory(workspace);
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -741,17 +1121,20 @@ public sealed class SecurityPolicyTests
string binaryFile = Path.Combine(workspace, "binary.dat");
File.WriteAllBytes(binaryFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..new byte[100]]);
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(binaryFile)}},"mode":"tail","query":"","tail_lines":10}""");
- await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken));
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken);
+ Assert.False(result.Success);
+ Assert.True(result.RetryAllowed);
+ Assert.Equal("tool.input_invalid", result.ErrorCode);
await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
}
@@ -777,9 +1160,9 @@ public sealed class SecurityPolicyTests
};
File.WriteAllBytes(textFile, bytes);
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -802,9 +1185,9 @@ public sealed class SecurityPolicyTests
string dest = Path.Combine(workspace, "dest.txt");
File.WriteAllText(source, "rename-content");
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -833,17 +1216,20 @@ public sealed class SecurityPolicyTests
File.WriteAllText(source, "source-content");
File.WriteAllText(dest, "dest-content");
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
- await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken));
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken);
+ Assert.False(result.Success);
+ Assert.True(result.RetryAllowed);
+ Assert.Equal("tool.input_invalid", result.ErrorCode);
Assert.True(File.Exists(source)); // source still exists
Assert.True(File.Exists(dest)); // dest untouched
await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
@@ -860,9 +1246,9 @@ public sealed class SecurityPolicyTests
string dest = Path.Combine(outside.Path, "escaped.txt");
File.WriteAllText(source, "content");
var audit = new MemorySecurityAuditSink();
- var settings = new WindowsSandboxSettings(
- [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false,
- new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ var settings = new EffectiveSandboxPolicy(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], [], [],
+ new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
@@ -883,8 +1269,8 @@ public sealed class SecurityPolicyTests
string curl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "curl.exe");
var audit = new MemorySecurityAuditSink();
var launcher = new RestrictedProcessLauncher(
- new ExecutablePolicy([new ExecutableApproval(curl, ExecutablePolicy.ComputeSha256(curl))], true),
- audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024));
+ new EntryExecutablePolicy([new ApprovedEntryExecutable("curl", curl, EntryExecutablePolicy.ComputeSha256(curl), false, null)], [], []),
+ audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024), allowNetwork: true);
using TestDirectory directory = new();
bool diagnosticExemptionAdded = LoopbackExemption.Ensure(profile.Sid);
@@ -945,7 +1331,20 @@ public sealed class SecurityPolicyTests
}
private static RestrictedProcessResult Curl(RestrictedProcessLauncher launcher, Guid taskId, string curl, string url, string cwd) =>
- launcher.Run(taskId, curl, ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10));
+ launcher.Run(taskId, "curl", ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10));
+
+ private static void CreateJunction(string path, string target)
+ {
+ using Process process = Process.Start(new ProcessStartInfo
+ {
+ FileName = Environment.GetEnvironmentVariable("ComSpec")!,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ ArgumentList = { "/d", "/c", "mklink", "/J", path, target }
+ })!;
+ process.WaitForExit();
+ Assert.Equal(0, process.ExitCode);
+ }
private static byte[] AccessDescriptor(string path)
{