diff options
40 files changed, 2538 insertions, 802 deletions
@@ -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) { |
