summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
commitb56614c814b904c224315a11f9686f0d47abd3d3 (patch)
treee6ae4feb852ea4492bf947eabba1e11ab157686a
parent98b6a2aeedd591058c1d81e401d24f3ded162926 (diff)
feat: revise sandbox usability around semantic policy and LPAC process trees
Rewrite the specification and design vocabulary around User Sandbox Configuration, Environment Resolution, Executable Approval, Effective Sandbox Policy, and Windows Enforcement. Move the Internal Tool catalog to the design without treating its count as a requirement, and update README, implementation planning, traceability, audit history, prompts, and the Python skill. Introduce schema_version 1 profiles, workspaces, mounts, executable aliases, network modes, and resource presets. Add local override merging, resolution and approval stores with atomic user-only persistence, fail-closed fingerprints, localized diagnostics, and executable/sandbox management CLI commands. Change Windows execution to LPAC entry approval with inherited descendant confinement. Separate read and execute ACLs, preserve protected OS DACLs, apply Job process/memory/timeout limits to the tree, enforce WFP network modes, prevent credential environment inheritance, disable out-of-tree MSBuild reuse, and harden transactional ACL/recovery cleanup. Isolate planning prompts from execution instructions, reject split coding plans that cannot satisfy task-scoped evidence, preserve configured thinking, and prevent optional task_update failures from corrupting completion checks. Add and update configuration, resolution, approval, LPAC, descendant, readonly/development, Registry/COM, WFP, recovery, .NET/Python/PowerShell process-tree, and Operational E2E coverage. Publish reproducible bundles while preserving deployment-local configuration, prompts, skills, logs, state, approvals, and resolutions.
-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)
{