diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
| commit | b07b41cd435991cd254957af79055080c85b346e (patch) | |
| tree | 27720d3ce14e4d0b84670a55ac26a2f877ce3a20 | |
| parent | 2fefd29e86c3b926d0cf4a362251d3f119688612 (diff) | |
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。
.NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。
Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。
Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。
Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
127 files changed, 6503 insertions, 6 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11f5673 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +**/bin/ +**/obj/ +.vs/ +.vscode/ +logs/ +state/ +artifacts/ +build/ +*.user +*.suo diff --git a/AgentSandboxMcp.slnx b/AgentSandboxMcp.slnx new file mode 100644 index 0000000..6a82cfe --- /dev/null +++ b/AgentSandboxMcp.slnx @@ -0,0 +1,17 @@ +<Solution> + <Folder Name="/src/"> + <Project Path="src/Agent.App/Agent.App.csproj" /> + <Project Path="src/Agent.Core/Agent.Core.csproj" /> + <Project Path="src/Agent.Mcp/Agent.Mcp.csproj" /> + <Project Path="src/Agent.Planner/Agent.Planner.csproj" /> + <Project Path="src/Agent.Windows/Agent.Windows.csproj" /> + </Folder> + <Folder Name="/tests/"> + <Project Path="tests/Agent.Core.Tests/Agent.Core.Tests.csproj" /> + <Project Path="tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj" /> + <Project Path="tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj" /> + </Folder> + <Folder Name="/scripts/"> + <Project Path="scripts/E2EClient/E2EClient.csproj" /> + </Folder> +</Solution> diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f48abd2 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,18 @@ +<Project> + <PropertyGroup> + <RepositoryBuildRoot>$(MSBuildThisFileDirectory)build\</RepositoryBuildRoot> + <BaseOutputPath>$(RepositoryBuildRoot)</BaseOutputPath> + <OutputPath>$(RepositoryBuildRoot)$(Configuration)\$(MSBuildProjectName)\</OutputPath> + <BaseIntermediateOutputPath>$(RepositoryBuildRoot)obj\$(MSBuildProjectName)\</BaseIntermediateOutputPath> + <IntermediateOutputPath>$(BaseIntermediateOutputPath)$(Configuration)\</IntermediateOutputPath> + <MSBuildProjectExtensionsPath>$(BaseIntermediateOutputPath)</MSBuildProjectExtensionsPath> + <DefaultItemExcludes>$(DefaultItemExcludes);**\bin\**;**\obj\**</DefaultItemExcludes> + <TargetFramework>net10.0</TargetFramework> + <LangVersion>14.0</LangVersion> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild> + <Deterministic>true</Deterministic> + </PropertyGroup> +</Project> diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..f8a11fd --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ +<Project> + <PropertyGroup> + <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> + </PropertyGroup> + <ItemGroup> + <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> + <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.298" /> + <PackageVersion Include="ModelContextProtocol" Version="1.4.1" /> + <PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.4.1" /> + <PackageVersion Include="YamlDotNet" Version="18.1.0" /> + <PackageVersion Include="xunit.v3" Version="3.2.2" /> + </ItemGroup> +</Project> diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2a350c --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# AI Agent Sandbox MCP Server v1 + +Windows 11上でlocal Ollamaを利用し、明示的に承認したFilesystem、Process、Network操作だけをAppContainer内で実行するMCP Serverです。MCP公開Toolは `agent_run` と `agent_status` の2つです。 + +## 動作要件 + +- Windows 11 x64 +- 管理者権限(TaskごとのWFP filter追加・削除に必要) +- Base Filtering Engine (BFE)が実行中 +- Ollama。tested versionは実測した `0.32.6`。minimum supported versionは未確定です。 +- 必須local model: `hf.co/unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL` + +サービス、常駐helper、恒久Firewall ruleは使用しません。WFP objectはdynamic sessionだけに作成されます。AppContainerからlocalhostへ接続するTaskでは、固定AppContainer SIDのloopback exemptionをTask中だけ追加し、終了時または次回起動Recoveryで削除します。 + +## 初回セットアップ + +管理者としてPowerShellを起動し、Release Bundleへ移動します。 + +```powershell +ollama --version +ollama pull hf.co/unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL +ollama serve +``` + +別の管理者PowerShellで診断します。 + +```powershell +.\agent-mcp.exe check --config .\agent.yaml --lang ja +``` + +`administrator=true`、`bfe_engine_open=true`、`wfp_write_transaction=true`、`wfp_filter_add_delete=true` が必要です。probeはdynamic WFP filterを追加後すぐ削除し、残留させません。 + +## agent.yaml + +Filesystem pathは能力ごとに指定します。未指定能力はdenyです。 + +```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許可とは独立です。 + +## stdio + +WFP操作のため、stdioではMCP Client自体を管理者PowerShellから起動します。Client設定例: + +```json +{ + "mcpServers": { + "agent-sandbox": { + "command": "C:\\tools\\agent-sandbox-mcp\\agent-mcp.exe", + "args": ["serve", "--config", "C:\\tools\\agent-sandbox-mcp\\agent.yaml", "--lang", "ja", "--transport", "stdio"] + } + } +} +``` + +`agent_run`へ自然言語の依頼を送り、返却されたUUIDを `agent_status` でterminal stateまでpollします。複数Taskはqueuedで受理しますが、planning/runningおよびTask Resource適用は全体で1件です。 + +## Streamable HTTP + +管理者PowerShellでserverを起動します。 + +```powershell +.\agent-mcp.exe serve --config .\agent.yaml --lang ja --transport http +``` + +Client endpointは `http://127.0.0.1:8080/mcp` です。設定できるbindは `127.0.0.1` または `::1` だけです。Streamable HTTP POSTだけを使用し、GET SSE、`/sse`、`/message` Legacy SSEは利用できません。 + +## LogとRecovery + +- `logs/application.log`: 起動・診断・管理event +- `logs/llm.jsonl`: LLM request/response、token usage、tool result +- `logs/audit.jsonl`: Task ID付きfile/process/policy/ACL/WFP/recovery event +- `state/journals/`: crash recovery journal。正常終了後は空 +- `state/backups/`: 大容量file transaction backup。正常終了後は空 + +log directoryは起動ユーザーだけがアクセスできるprotected DACLへ設定されます。起動時はMCP受付前にRecoveryを実行し、復旧不能なhash競合があれば起動しません。 + +正常停止後、`agent-mcp.exe` descendant、追加ACL、WFP filter、loopback exemption、`state/backups`、`state/journals` が残っていないことを確認してください。loopback exemptionは `CheckNetIsolation.exe LoopbackExempt -s` で確認できます。 + +## よくあるエラー + +- `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能力へ追加し、再起動してください。 +- rollback拒否: Task中に外部processが同じfileを変更しています。Audit Logとjournalを確認し、手動reviewしてください。 + +## ソースからのbuild/release + +```powershell +dotnet restore AgentSandboxMcp.slnx +dotnet build AgentSandboxMcp.slnx -c Release --no-restore +dotnet test AgentSandboxMcp.slnx -c Release --no-build --no-restore +.\scripts\Publish-Release.ps1 +``` + +生成物はすべてrepository rootの `build/` に集約されます。 + +- `build/Debug/<ProjectName>/`: Debug build +- `build/Release/<ProjectName>/`: Release build +- `build/obj/<ProjectName>/`: restoreおよびcompiler中間生成物 +- `build/Release/publish/`: win-x64 self-contained Bundle、version付きZIP、checksum + +`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で実行してください。 diff --git a/config/agent.yaml b/config/agent.yaml new file mode 100644 index 0000000..05a5237 --- /dev/null +++ b/config/agent.yaml @@ -0,0 +1,59 @@ +schema_version: 1 +application: + language: en +mcp: + transport: stdio + http: + host: 127.0.0.1 + port: 8080 + images: + max_count: 4 + max_bytes_each: 10485760 + max_total_bytes: 20971520 +agent: + user_system_prompt: prompts/user-system.md + max_iterations: 128 + same_failure_limit: 5 +ollama: + endpoint: http://127.0.0.1:11434 + model: hf.co/unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL + think: true + keep_alive: 5m + options: + num_ctx: 32768 + temperature: 0.4 + extra_options: {} +planner: + max_tasks: 32 +sandbox: + filesystem: + read: [] + write: [] + execute: [] + delete: [] + process: + allow_shell: true + approvals: [] + max_processes: 16 + network: + mode: deny + allow: [] + resources: + memory_mb: 4096 + process_timeout_seconds: 1800 +file_edit: + memory_backup_limit_mb: 16 +limits: + whole_read_bytes: 1048576 + search_max_results: 1000 + tail_max_lines: 10000 + context_summary_ratio: 0.8 +localization: + directory: config/locales + fallback: en +skills: + directory: skills +logging: + directory: logs + max_file_bytes: 10485760 + retained_files: 5 diff --git a/config/locales/en.json b/config/locales/en.json new file mode 100644 index 0000000..4fcdb95 --- /dev/null +++ b/config/locales/en.json @@ -0,0 +1,18 @@ +{ + "app.starting": "Starting agent.", + "check.success": "Configuration and runtime checks passed.", + "check.failure": "Check failed: {message}", + "check.wfp_admin_required": "Windows administrator elevation is required for WFP task isolation. Start agent-mcp.exe from an elevated terminal.", + "config.invalid": "Invalid configuration: {message}", + "locale.invalid": "Invalid locale: {message}", + "mcp.task_queued": "Task {task} was queued in session {session}.", + "mcp.task_not_found": "Task {task} was not found.", + "mcp.status.queued": "The task is queued.", + "mcp.status.planning": "The task is being planned.", + "mcp.status.running": "The task is running.", + "mcp.status.completed": "The task completed.", + "mcp.status.failed": "The task failed.", + "mcp.status.cancelled": "The task was cancelled.", + "sandbox.access_denied": "Access denied: {path}", + "ollama.vision_unsupported": "The selected model does not support image input." +} diff --git a/config/locales/ja.json b/config/locales/ja.json new file mode 100644 index 0000000..0bdbca8 --- /dev/null +++ b/config/locales/ja.json @@ -0,0 +1,18 @@ +{ + "app.starting": "エージェントを起動しています。", + "check.success": "設定と実行環境の確認に成功しました。", + "check.failure": "確認に失敗しました: {message}", + "check.wfp_admin_required": "WFPによるTask分離にはWindows管理者権限が必要です。管理者として起動したターミナルからagent-mcp.exeを起動してください。", + "config.invalid": "設定が不正です: {message}", + "locale.invalid": "ロケールが不正です: {message}", + "mcp.task_queued": "タスク {task} をセッション {session} に登録しました。", + "mcp.task_not_found": "タスク {task} が見つかりません。", + "mcp.status.queued": "タスクは待機中です。", + "mcp.status.planning": "タスクを計画しています。", + "mcp.status.running": "タスクを実行しています。", + "mcp.status.completed": "タスクが完了しました。", + "mcp.status.failed": "タスクが失敗しました。", + "mcp.status.cancelled": "タスクが取り消されました。", + "sandbox.access_denied": "アクセスが拒否されました: {path}", + "ollama.vision_unsupported": "選択されたモデルは画像入力に対応していません。" +} diff --git a/doc/Design.md b/doc/Design.md index f877e3e..d3ace2a 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -206,7 +206,7 @@ result: <final natural-language answer; completed only> - `stream=false`をv1既定とし、Agent内部でも1call単位の完了responseを扱う。 - `prompt_eval_count` と `eval_count` を毎callで取得する。 ### 6.2 Option mapping -`OllamaOptions` は既知optionをnullable typed propertyとして持つ。設定されていないpropertyはpayloadへ出さない。`extra_options` は `Dictionary<string, JsonElement>` とし、typed propertyと同名keyがある場合はconfiguration errorとする。 +`OllamaOptions` は既知optionをnullable typed propertyとして持つ。設定されていないpropertyはpayloadへ出さない。`extra_options` は `Dictionary<string, JsonElement>` とし、typed propertyと同名keyがある場合はtyped propertyを優先し、同名のextra optionをpayloadへ出さない。 代表的typed property: ```text num_ctx, num_keep, num_predict, temperature, seed, @@ -223,6 +223,7 @@ overrideはTask objectにcopyし、global configurationを変更しない。Sess ### 6.4 Vision - `images[]`をBase64としてOllama messageへ転送する。 - 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は次の5群に固定する。個々のOS command (`grep.exe`等) をtoolとして増殖させない。 @@ -242,11 +243,13 @@ Ollamaに公開するInternal Toolは次の5群に固定する。個々のOS com - `root`はread許可directory/file配下でなければdeny。 - `tail`はtext file向け。binaryは拒否する。 - `max_results`にhard upper boundを設け、context爆発を防ぐ。 +- `max_results`のhard upper boundは1,000件、`tail_lines`は10,000行とする。 ### 7.2 file_read ```json {"path":"string","mode":"whole|range|metadata","start":0,"length":0} ``` - wholeはsize上限を超えた場合range利用を要求する。 +- whole readの既定上限は1 MiBとする。 - read前にcanonical pathとreparse pointを検査する。 ### 7.3 file_edit ```json @@ -286,7 +289,7 @@ Ollamaに公開するInternal Toolは次の5群に固定する。個々のOS com "optimistic_tokens":0, "most_likely_tokens":0, "pessimistic_tokens":0, - "state":"queued|running|completed|failed|null" + "state":"queued|planning|running|completed|failed|cancelled|null" } ``` PlannerがDAG、不変条件、completed immutable、max_tasksを再検査してから適用する。 @@ -300,7 +303,7 @@ Task Id: UUID Description: string Dependencies: Set<TaskId> - State: queued|running|completed|failed + State: queued|planning|running|completed|failed|cancelled BudgetClass: TokenBudgetClass O, M, P: long ExpectedTokens: double @@ -349,7 +352,7 @@ AgentSession 7. previous Summary 8. recent Conversation / Execution Result ### 9.3 Summary policy -`num_ctx`に対する利用率が設定閾値を超えたとき、古いConversation/Execution Resultを要約する。次は削除・曖昧化してはならない。 +`num_ctx`に対する利用率が設定閾値を超えたとき、古いConversation/Execution Resultを要約する。既定閾値は80%とする。次は削除・曖昧化してはならない。 - 利用者が明示した制約 - 未完了Taskとdependency - completed Taskの重要成果 @@ -370,6 +373,10 @@ mcp: http: host: 127.0.0.1 port: 8080 + images: + max_count: 4 + max_bytes_each: 10485760 + max_total_bytes: 20971520 agent: user_system_prompt: prompts/user-system.md max_iterations: 128 @@ -403,6 +410,11 @@ sandbox: process_timeout_seconds: 1800 file_edit: memory_backup_limit_mb: 16 +limits: + whole_read_bytes: 1048576 + search_max_results: 1000 + tail_max_lines: 10000 + context_summary_ratio: 0.8 localization: directory: config/locales fallback: en @@ -515,6 +527,8 @@ failure -> rollback snapshot -> verify original hash ``` ### 13.2 backup threshold `memory_backup_limit_mb`以下はメモリsnapshot、それより大きい場合はTask専用temp directoryへbackupする。16MBをdefault候補とするが、実装時benchmarkで変更可能。これは仕様上の固定値ではない。 + +v1の既定値は16 MiBとし、benchmark結果を変更根拠として記録できるconfiguration defaultとして扱う。 ### 13.3 Patch patchは適用前にtarget hashまたはcontext一致を検査する。曖昧適用を禁止し、複数候補に一致する場合は失敗としてLLMへ返す。 --- @@ -567,9 +581,12 @@ Auditはappend-only JSONLを採用する。1 event = 1 line。 - stdio: EOFまたはparent process消失をClient切断とみなし、所有Taskをsafe stop。 - Streamable HTTP: 1回のHTTP response完了は切断ではない。MCP sessionの明示終了、session失効、server shutdownをsafe stop triggerとする。 ### 16.3 Ollama retry -network/temporary errorだけを最大3回retryする。validation error、Vision unsupported、invalid model等の恒久errorは即failed。backoffの具体値は実装時に小さく固定し、汎用retry frameworkを導入しない。 +network/temporary errorだけを最大3回retryする。validation error、Vision unsupported、invalid model等の恒久errorは即failed。backoffは250 ms、500 ms、1,000 msに固定し、汎用retry frameworkを導入しない。 --- ## 17. 状態遷移 +### 17.0 実行キュー +複数Taskの受付は許可するが、`planning` / `running` およびTask Resource適用はアプリケーション全体で最大1件とする。受付順の単一キューで直列実行する。 + ### 17.1 Task ```text queued -> planning -> running -> completed @@ -658,6 +675,13 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC | 7 | WFP | deny/allow-list 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実利用 | + +### 21.1 Release設計 +- `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は任意互換試験に限定する。 --- ## 22. Codex実装制約 - 新規NuGet dependencyを勝手に追加しない。追加時は標準ライブラリで不能な理由を示す。 @@ -744,6 +768,13 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC | localization.fallback | en | | skills.directory | Skill directory | | logging.directory | log directory | +| limits.whole_read_bytes | whole read上限 | +| limits.search_max_results | search結果上限 | +| limits.tail_max_lines | tail行数上限 | +| limits.context_summary_ratio | context要約開始比率 | +| mcp.images.max_count | 画像数上限 | +| mcp.images.max_bytes_each | 1画像byte上限 | +| mcp.images.max_total_bytes | request画像合計byte上限 | ## 28. 主要データ型索引 - `AgentSession` - `AgentTask` diff --git a/doc/Specifications.md b/doc/Specifications.md index 9d13343..2aa19c5 100644 --- a/doc/Specifications.md +++ b/doc/Specifications.md @@ -155,6 +155,13 @@ IPA「厳密な仕様記述入門」の考え方に従い、課題・仕様・è¨ | failed | 回復不能な失敗 | 終端 | | cancelled | 安全停止完了 | 終端 | +### 5.5.1 v1実行並列性 + +- 複数Taskを `queued` として同時に受け付けることができる。 +- アプリケーション全体で `planning` または `running` になれるTaskは最大1件とする。 +- Task Resourceを適用できるTaskも同時に最大1件とする。 +- `agent_run` の非同期受付は維持し、実行待ちTaskは受付順の単一キューで処理する。 + ### 5.6 CLI v1のCLI surfaceは次に限定する。 @@ -186,6 +193,8 @@ CLIは起動方式と表示言語を変更するためのI/Fであり、SandboxP | OLL-12 | Retry | Ollama通信失敗は既定3回まで再試行する。 | | OLL-13 | Usage | `prompt_eval_count` と `eval_count` をTask実測tokenとして記録する。 | +Vision入力は設定可能な画像数、1画像あたりのsize、request合計sizeの上限を持ち、上限超過をOllama呼出し前に拒否しなければならない。具体的な既定値は設計および設定schemaで定義する。 + --- ## 7. Agent Runtime仕様 @@ -205,6 +214,8 @@ CLIは起動方式と表示言語を変更するためのI/Fであり、SandboxP AgentはTaskの期待副作用と観測結果を比較し、実行が必要なのにtool callが0件の場合はTaskをcompletedにしない。再試行はAgent Loopの失敗回数へ算入する。純粋な説明・分析Taskにはtool callを強制しない。 +Internal Toolが返す読取・検索・tail結果は設定可能な上限を持ち、contextを無制限に消費してはならない。上限値は設計および設定schemaで定義する。 + --- ## 8. Planner仕様 @@ -261,6 +272,8 @@ Task実績はOllama応答の `prompt_eval_count` をinput token、`eval_count` ã | SES-05 | context上限接近時は古い情報を単純削除せず要約する。 | | SES-06 | 要約後も未完了Task、security decision、重要なexecution result、利用者制約を保持する。 | +Context要約を開始する利用率閾値は設定可能でなければならない。具体値は設計および設定schemaで定義する。 + --- ## 10. Skill・Prompt仕様 @@ -359,6 +372,7 @@ Task実績はOllama応答の `prompt_eval_count` をinput token、`eval_count` ã - overwrite、patch、atomic replaceを併用できる。 - 通常は変更前内容をメモリに保持し、編集後検証に失敗した場合にrollbackする。 - 設定されたmemory backup閾値を超える大容量ファイルでは、Sandbox管理下の一時backupを使用できる。 +- memory backup閾値は設定可能でなければならない。 - 利用者向け恒久 `.bak` ファイルは既定で作成しない。 - atomic replaceが適用可能な操作では、部分書込み状態を利用者から観測させないことを優先する。 @@ -489,6 +503,15 @@ Task実績はOllama応答の `prompt_eval_count` をinput token、`eval_count` ã | AT-19 | 追加localeがコード変更なしでロードされる。 | | AT-20 | 未知locale keyまたは壊れたJSONで起動/checkが失敗する。 | +### 20.1 Operational E2E + +| ID | 検証条件 | +| --- | --- | +| E2E-01 | ソースツリー外へ展開したRelease Bundleだけを使い、local Ollamaとstdio MCP Clientから実際のfixture修正、build、test、日本語応答、Audit追跡、Task Resource cleanupまで完走する。 | +| E2E-02 | E2E-01と同等の作業をloopback限定Streamable HTTPで完走する。 | + +v1の完了には、win-x64向けself-contained Release Bundleが単独起動でき、E2E-01とE2E-02がInternetまたはcloud modelに依存せずPASSすることを必須とする。 + --- ## 21. 要求トレーサビリティ diff --git a/global.json b/global.json new file mode 100644 index 0000000..a0e9d0e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.301", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md index c31f178..2507f5a 100644 --- a/plans/ImplementationPlan.md +++ b/plans/ImplementationPlan.md @@ -137,6 +137,8 @@ Security境界を後回しにした状態で、実際の任意Process実行をå… - warning方針 - Git ignore - 最小README +- `ollama --version` の実測baseline証跡 +- v1単一実行キュー(複数queued、planning/running/Task Resource適用は全体で最大1件) ## 5.3 非対象 - MCP実処理 - Ollama通信 @@ -265,7 +267,7 @@ MCP surfaceが仕様どおり2 Toolのみで固定されていること。 - [ ] Session継続でconversationが保持される - [ ] process再起動後Sessionが消失する - [ ] temperature overrideが当該Taskだけに作用 -- [ ] typed optionと`extra_options`競合を拒否 +- [ ] typed optionと`extra_options`競合時にtyped optionが優先される - [ ] prompt/eval tokenを取得 - [ ] transient errorだけ最大3回retry - [ ] permanent errorを無駄にretryしない @@ -549,6 +551,16 @@ ST-01〜20全PASS。skip 0。Security関連TODO 0。 Codexの自己申告ではなく、仕様書のAcceptance IDと実成果を1対1で監査する。 ## 16.2 Acceptance Test 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起動を実行する。 + +## 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へ含めない。 ## 16.3 Requirement Traceability監査 最低限次のマトリクスを完成させる。 | Requirement | Implementation | Test | Result | Evidence | @@ -599,6 +611,9 @@ AT-01〜AT-20をすべて再実行する。 8. Security関連未完了0 9. 仕様・設計逸脱0、または承認済み文書更新済み 10. Recovery後のclean state確認 +11. publish済みwin-x64 self-contained Release Bundleが単独で起動可能 +12. E2E-01 / E2E-02がlocal OllamaでPASS +13. READMEだけでfresh directoryから初回セットアップ可能 --- # 17. フェーズ横断テスト分類 | 種別 | 目的 | 実行タイミング | @@ -755,3 +770,7 @@ Codex作業後、人間はこの表を更新する。 - 全Security TestがPASSする。 - skipped test、Security TODO、未承認dependencyが0である。 - Codexの主張ではなく、再実行可能な証跡によって上記を確認できる。 +- Release build、Unit、Integration、ST-01〜20、AT-01〜20、Operational E2EがすべてPASSする。 +- Release Bundle単独でstdio/Streamable HTTPを実利用できる。 +- orphan process、ACL、WFP filter、temporary resource、journalの残留が0である。 +- READMEだけで初回セットアップからTask実行、Audit確認、安全な終了まで再現できる。 diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md new file mode 100644 index 0000000..aec82a2 --- /dev/null +++ b/plans/RequirementTraceability.md @@ -0,0 +1,66 @@ +# Requirement Traceability + +`PASS` は2026-08-09のRelease buildおよび管理者権限の最終実行で、実行可能なtest/evidenceが確認できたことを示す。最終証跡は `artifacts/evidence/final-elevated/` に保存する。 + +## Acceptance Tests + +| ID | Implementation / evidence | Status | +|---|---|---| +| AT-01 | `McpStdioTests.InitializeListAndCallExposeOnlyTwoStrictTools` | PASS | +| AT-02 | `McpHttpTests`、E2E-02 `127.0.0.1` / `::1` | PASS | +| AT-03 | HTTP GET `/mcp`, `/sse`, `/message` assertions | PASS | +| AT-04 | `TaskRegistryTests`, MCP stdio integration | PASS | +| AT-05 | `OllamaClientTests.Chat_sends_expected_request_and_returns_usage` | PASS | +| AT-06 | `Non_vision_model_rejects_images_with_selected_locale_message_before_chat` | PASS | +| AT-07 | Windows backend実効果試験、E2E Audit | PASS | +| AT-08 | junction / symlink chain escape試験 | PASS | +| AT-09 | path/hash一致・不一致試験 | PASS | +| AT-10 | AppContainer loopback actual traffic default deny | PASS | +| AT-11 | exact host:port allowおよびwrong-port actual deny | PASS | +| AT-12 | child-process restriction 4経路、Job timeout/orphan 0 | PASS | +| AT-13 | ACL/WFP/loopback journal crash recovery試験 | PASS | +| AT-14 | `TaskGraphTests.Cycle_is_rejected_without_mutating_graph` | PASS | +| AT-15 | `TaskGraphTests.Completed_task_is_immutable` | PASS | +| AT-16 | deterministic PERT/CPM tests | PASS | +| AT-17 | runtime and graph usage accumulation tests | PASS | +| AT-18 | en/ja locale、3経路の日本語Operational E2E | PASS | +| AT-19 | `LocaleCatalogTests.AdditionalLocaleLoadsWithoutCodeChange` | PASS | +| AT-20 | malformed/unknown locale tests | PASS | + +## Security Tests + +| ID | Implementation / evidence | Status | +|---|---|---| +| ST-01 | E2E `file_read`実行、capability試験 | PASS | +| ST-02 | outside-root deny、副作用なし | PASS | +| ST-03 | backend `file_edit`実効果、ACL復旧 | PASS | +| ST-04 | delete capability独立試験 | PASS | +| ST-05 | junction escape拒否 | PASS | +| ST-06 | two-link symlink chain escape拒否 | PASS | +| ST-07 | approved canonical path/hash process成功 | PASS | +| ST-08 | hash mismatch拒否 | PASS | +| ST-09 | shell disabled拒否 | PASS | +| ST-10 | `Start-Process`、Process API、shell、rename/copyの4経路をOS child restrictionで拒否、marker/PIDなし、Audit deny | PASS | +| ST-11 | Job timeout後PID不存在、Operational E2E orphan 0 | PASS | +| ST-12 | AppContainer actual loopback default deny | PASS | +| ST-13 | IPv4/IPv6 filter生成、IPv4 exact allow actual traffic | PASS | +| ST-14 | actual wrong-port deny | PASS | +| ST-15 | public MCP exposes only two strict tools | PASS | +| ST-16 | strict tool schema、path policy deny時backend call 0 | PASS | +| ST-17 | applied ACL journalのcrash recovery、descriptor一致 | PASS | +| ST-18 | dynamic WFP engine close+loopback exemption journal recovery、残留0 | PASS | +| ST-19 | malformed locale fatal tests | PASS | +| ST-20 | non-loopback bind rejection test | PASS | + +## Operational / Release + +| Requirement | Evidence | Status | +|---|---|---| +| Release build | Release build warning 0/error 0 | PASS | +| Unit/integration | Planner 10/10, Core 40/40, skip 0 | PASS | +| win-x64 self-contained Bundle | publish directory, ZIP, manifest, checksum | PASS | +| Bundle-only elevated startup | `bundle-check.log`: administrator/BFE/WFP probe PASS | PASS | +| E2E-01 stdio | `e2e-01-stdio.log`: local Ollama、read/edit/build/test、日本語、Audit、cleanup | PASS | +| E2E-02 HTTP (`127.0.0.1`, `::1`) | `e2e-02-http-ipv4.log` / `e2e-02-http-ipv6.log` | PASS | +| clean resource state | process/ACL/WFP/loopback/temp/journal/backup残留0 | PASS | +| README-only fresh setup | ZIP fresh extraction、source参照0、README手順による最終runner | PASS | diff --git a/plans/progress/Phase0.md b/plans/progress/Phase0.md new file mode 100644 index 0000000..d648dd1 --- /dev/null +++ b/plans/progress/Phase0.md @@ -0,0 +1,41 @@ +# Phase 0 Completion Report + +## 実施内容 + +- Specifications、Design、ImplementationPlanの優先順位に従い、単一Task実行、option競合、実装上限、Release/E2E要件を整合した。 +- .NET 10/C# 14 solution、5 production project、3 test project、Central Package Managementを構築した。 +- Ollama実測versionをtested baselineとして記録した。 + +## 対応Requirement / Design + +- Requirements: NFR-04、NFR-05、NFR-09、E2E-01、E2E-02 +- Design: 2章、4章、17章、21章 + +## 検証 + +- `dotnet restore AgentSandboxMcp.slnx`: PASS +- `dotnet build AgentSandboxMcp.slnx -c Release --no-restore`: PASS、warning 0、error 0 +- `dotnet test AgentSandboxMcp.slnx -c Release --no-restore`: PASS、3/3、failed 0、skipped 0 +- `dotnet list AgentSandboxMcp.slnx package`: 承認済みdirect packageだけであることを確認 +- `dotnet list <project> reference`: Design 4.1のdependency方向と一致 +- `git diff --check`: PASS + +## Dependency + +- ModelContextProtocol / AspNetCore 1.4.1 +- Microsoft.Windows.CsWin32 0.3.298 +- YamlDotNet 18.1.0 +- Microsoft.Extensions.Hosting 10.0.10 +- xunit.v3 3.2.2 / Microsoft.NET.Test.Sdk 18.8.1 + +## 未完了・既知問題 + +- Phase 1以降の機能は計画どおり未実装。 +- Security testはPhase 6以降で実施する。 + +## Gate + +- G0: PASS +- TODO/FIXME: 0 +- Test skip: 0 +- 仕様・設計逸脱: 0 diff --git a/plans/progress/Phase1.md b/plans/progress/Phase1.md new file mode 100644 index 0000000..da58a47 --- /dev/null +++ b/plans/progress/Phase1.md @@ -0,0 +1,7 @@ +# Phase 1 Progress + +- 実装: strict typed YAML、起動時validation、en/ja localeとfallback、embedded Fixed Prompt、限定CLI、rolling/Application/LLM/Audit log基盤。 +- 自動検証: configuration、locale、fixed promptのCore testsがPASS。 +- Dependency差分: 承認済みYamlDotNet、Hostingのみ。追加なし。 +- Gate G1: PASS(後続統合で継続監査)。 +- Skip/TODO: 0。 diff --git a/plans/progress/Phase10.md b/plans/progress/Phase10.md new file mode 100644 index 0000000..de3b81b --- /dev/null +++ b/plans/progress/Phase10.md @@ -0,0 +1,6 @@ +# Phase 10 Progress + +- 実装: Application/LLM/Audit log、user-only log DACL、Task ID audit、locale message基盤。 +- 現在の走査: Security TODO/FIXME 0、test skip 0。 +- 最終管理者run: Core 42/42、Planner 10/10、Windows Security/Recovery 25/25、skip 0。Requirement TraceabilityのST-01~20 / AT-01~20を全PASSへ更新した。 +- Gate G10: **PASS**。 diff --git a/plans/progress/Phase11.md b/plans/progress/Phase11.md new file mode 100644 index 0000000..c03a473 --- /dev/null +++ b/plans/progress/Phase11.md @@ -0,0 +1,8 @@ +# Phase 11 Progress + +- 生成済み: win-x64 self-contained directory/ZIP、required files、356-entry SHA-256 manifest、ZIP checksum。 +- fresh temporary extraction、source-tree reference scan、管理者Bundle-only `check`(BFE/WFP add/delete probe)PASS。 +- local OllamaによるE2E-01 stdio、E2E-02 HTTP `127.0.0.1` / `::1` が全PASS。実file read/edit、broker process、in-process build、独立self-test、日本語応答、Task ID Auditを確認した。 +- 各run後にorphan process、ACL、WFP、loopback exemption、temp、backup、journal残留0。README手順だけのfresh-directory実行を確認した。 +- 最終Bundle ZIP SHA-256: `7d5815e1d1c480c58666816e200fa8dbf3ec65d48787192abfa0a2de87dc6e51`、manifest 356 entries。 +- Gate G11 / final DoD: **PASS — v1実装完了・実利用可能**。 diff --git a/plans/progress/Phase2.md b/plans/progress/Phase2.md new file mode 100644 index 0000000..b97c15b --- /dev/null +++ b/plans/progress/Phase2.md @@ -0,0 +1,5 @@ +# Phase 2 Progress + +- 実装: stdio、公開Toolを `agent_run` / `agent_status` の2つに限定、strict schema、UUID、非同期queue、global single-reader worker、stdout purity。 +- 自動検証: stdio SDK integrationを含む最終Core 42/42 PASS、skip 0。 +- Gate G2: PASS(Release Bundle E2EはPhase 11で判定)。 diff --git a/plans/progress/Phase3.md b/plans/progress/Phase3.md new file mode 100644 index 0000000..1a0cf26 --- /dev/null +++ b/plans/progress/Phase3.md @@ -0,0 +1,5 @@ +# Phase 3 Progress + +- 実装: Ollama Chat API、native tool call、session、prompt order、token usage、Task temperature、250/500/1000 ms retry、typed option優先。 +- 実測: Ollama 0.32.6、local Gemma modelでtext `LOCAL_OK`、native tool call、Vision `VISION_OK` を確認。cloudはRelease Gate外。 +- Gate G3: PASS。offline Operational E2EはG11で再検証する。 diff --git a/plans/progress/Phase4.md b/plans/progress/Phase4.md new file mode 100644 index 0000000..cb45fc0 --- /dev/null +++ b/plans/progress/Phase4.md @@ -0,0 +1,6 @@ +# Phase 4 Progress + +- 実装: TaskGraph、DAG rollback、32件default、completed immutable、Token Budget、PERT/CPM、usage累積。 +- 統合: `task_update` を実TaskGraphへ接続し、計画更新を外部効果証拠から除外。 +- 自動検証: Planner 10/10 PASS、Core TaskGraph tool test PASS、skip 0。 +- Gate G4: PASS。 diff --git a/plans/progress/Phase5.md b/plans/progress/Phase5.md new file mode 100644 index 0000000..2915584 --- /dev/null +++ b/plans/progress/Phase5.md @@ -0,0 +1,6 @@ +# Phase 5 Progress + +- 実装: Internal Tool 5群、strict schema/validator、deny-by-default dispatcher、ToolUseGuard、同一失敗5回、外部効果証拠。 +- Phase 5 backendはfake、Phase 6以降はWindows backendへ差し替え。 +- 自動検証: malformed/unknown field、policy deny、effect evidence、failure limitを確認。 +- Gate G5: PASS。 diff --git a/plans/progress/Phase6.md b/plans/progress/Phase6.md new file mode 100644 index 0000000..ff9a6d0 --- /dev/null +++ b/plans/progress/Phase6.md @@ -0,0 +1,6 @@ +# Phase 6 Progress + +- 実装: 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`を確認した。 +- symlink chain/junction、path/hash、shell toggle、Job timeoutおよびbroker crash後orphan 0を管理者環境で確認した。 +- Gate G6: **PASS**。Windows Security/Recovery suite 25/25、skip 0。 diff --git a/plans/progress/Phase7.md b/plans/progress/Phase7.md new file mode 100644 index 0000000..45d2518 --- /dev/null +++ b/plans/progress/Phase7.md @@ -0,0 +1,7 @@ +# Phase 7 Progress + +- 実装: durable Task Resource Journal、atomic file replace/delete、inline/large backup、hash guard、reverse recovery、ACL/WFP recovery。 +- 自動検証: commit/rollback、外部変更時rollback拒否、起動Recovery、journal/backup cleanupを確認。 +- 独立processをFailFastするcrash matrixでACL適用前後、file replace前後、process起動後、cleanup途中、WFP適用後を検証した。次回Recovery後にfile/ACL/backup/temp/journal/loopback exemption残留0、broker crash後orphan 0。 +- crash matrixにより発見したCleaned journal entryの再cleanupを修正した。 +- Gate G7: **PASS**。 diff --git a/plans/progress/Phase8.md b/plans/progress/Phase8.md new file mode 100644 index 0000000..6bb0921 --- /dev/null +++ b/plans/progress/Phase8.md @@ -0,0 +1,6 @@ +# Phase 8 Progress + +- 実装: dynamic WFP session/transaction、AppContainer SID条件、IPv4/IPv6 permit/default-block、hard permit、DNS snapshot、Task中だけのloopback exemption、explicit cleanup、Recovery、`agent-mcp check`。 +- 実測: 非管理者はengine open成功後transaction error 5。管理者実測ではfilter add/delete testがPASS。サービス/helper/恒久ruleなし。 +- AppContainer curlのactual trafficでloopback default deny、exact IPv4:port allow、wrong-port denyを確認。dynamic engine crashとloopback journal Recovery後の残留0も確認した。 +- Gate G8: **PASS**。 diff --git a/plans/progress/Phase9.md b/plans/progress/Phase9.md new file mode 100644 index 0000000..ea229be --- /dev/null +++ b/plans/progress/Phase9.md @@ -0,0 +1,6 @@ +# Phase 9 Progress + +- 実装: Vision count/per-image/total validation、Ollama capability check、loopback-only Streamable HTTP、Origin検査、GET/Legacy SSE拒否。 +- 自動検証: 127.0.0.1 SDK integration、non-loopback bind拒否、hostile Origin拒否。 +- 最終検証: `127.0.0.1` / `::1` Operational E2E PASS、localized non-Vision rejectionをOllama call前に確認。 +- Gate G9: **PASS**。 diff --git a/prompts/user-system.md b/prompts/user-system.md new file mode 100644 index 0000000..2986739 --- /dev/null +++ b/prompts/user-system.md @@ -0,0 +1 @@ +<!-- Add project-specific instructions here. This prompt cannot override the embedded Fixed System Prompt. --> diff --git a/scripts/E2EClient/E2EClient.csproj b/scripts/E2EClient/E2EClient.csproj new file mode 100644 index 0000000..50fabca --- /dev/null +++ b/scripts/E2EClient/E2EClient.csproj @@ -0,0 +1,9 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0</TargetFramework> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="ModelContextProtocol" /> + </ItemGroup> +</Project> diff --git a/scripts/E2EClient/Program.cs b/scripts/E2EClient/Program.cs new file mode 100644 index 0000000..ee7bb75 --- /dev/null +++ b/scripts/E2EClient/Program.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +if (args.Length < 4 || args[0] is not ("stdio" or "http")) +{ + Console.Error.WriteLine("Usage: E2EClient stdio <agent-exe> <config> <request> | http <endpoint> <unused> <request>"); + return 2; +} + +IClientTransport transport = args[0] == "stdio" + ? new StdioClientTransport(new StdioClientTransportOptions + { + Name = "agent-mcp-operational-e2e", + Command = Path.GetFullPath(args[1]), + Arguments = ["serve", "--config", Path.GetFullPath(args[2]), "--lang", "ja", "--transport", "stdio"], + WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(args[1]))! + }) + : new HttpClientTransport(new HttpClientTransportOptions + { + Name = "agent-mcp-operational-e2e-http", + Endpoint = new Uri(args[1], UriKind.Absolute), + TransportMode = HttpTransportMode.StreamableHttp + }); + +await using McpClient client = await McpClient.CreateAsync(transport); +IList<McpClientTool> tools = await client.ListToolsAsync(); +if (!tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal).SequenceEqual(["agent_run", "agent_status"])) + throw new InvalidOperationException("Unexpected MCP public tool surface."); + +_ = await ExecuteTask(client, args[3], null); +if (args.Length >= 5) _ = await ExecuteTask(client, args[4], null); +return 0; + +static async Task<(Guid TaskId, Guid SessionId)> ExecuteTask(McpClient client, string request, Guid? sessionId) +{ + var runArguments = new Dictionary<string, object?> { ["request"] = request, ["temperature"] = 0.0 }; + if (sessionId is not null) runArguments["session_id"] = sessionId.Value; + CallToolResult run = await client.CallToolAsync("agent_run", runArguments); + if (run.IsError is true) throw new InvalidOperationException(Text(run)); + string accepted = Text(run); + Guid taskId = ReadGuid(accepted, "task_id"); + Guid acceptedSessionId = ReadGuid(accepted, "session_id"); + Console.WriteLine(accepted); + for (int poll = 0; poll < 600; poll++) + { + await Task.Delay(500); + CallToolResult status = await client.CallToolAsync("agent_status", new Dictionary<string, object?> { ["task_id"] = taskId, ["session_id"] = acceptedSessionId }); + string text = Text(status); + string state = ReadValue(text, "status"); + if (state is "completed" or "failed" or "cancelled") + { + Console.WriteLine(text); + if (state != "completed") throw new InvalidOperationException($"Task {taskId} ended in state {state}."); + return (taskId, acceptedSessionId); + } + } + throw new TimeoutException($"Task {taskId} did not reach a terminal state."); +} + +static string Text(CallToolResult result) => string.Join('\n', result.Content.OfType<TextContentBlock>().Select(static block => block.Text)); +static Guid ReadGuid(string text, string key) => Guid.Parse(ReadValue(text, key)); +static string ReadValue(string text, string key) +{ + string prefix = key + ":"; + return text.Split('\n').First(line => line.StartsWith(prefix, StringComparison.Ordinal))[prefix.Length..].Trim(); +} diff --git a/scripts/Operational-E2E.ps1 b/scripts/Operational-E2E.ps1 new file mode 100644 index 0000000..221e62d --- /dev/null +++ b/scripts/Operational-E2E.ps1 @@ -0,0 +1,264 @@ +param( + [ValidateSet('stdio', 'http')] + [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' +) + +$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 +$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 +$passed = $false +$fixtureAclBefore = (Get-Acl -LiteralPath $fixture).Sddl + +try { + Expand-Archive -LiteralPath $zip -DestinationPath $bundleRoot + $bundle = (Get-ChildItem -LiteralPath $bundleRoot -Directory | Select-Object -First 1).FullName + $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; + } +} +'@ + 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' + + $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.' } + + $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 + } + + $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: + language: ja +mcp: + transport: $Transport + http: + host: $HttpHost + port: 18080 + images: + max_count: 4 + max_bytes_each: 10485760 + max_total_bytes: 20971520 +agent: + user_system_prompt: prompts/user-system.md + max_iterations: 32 + same_failure_limit: 5 +ollama: + endpoint: http://127.0.0.1:11434 + model: hf.co/unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL + think: false + keep_alive: 5m + options: + num_ctx: 32768 + temperature: 0 + extra_options: {} +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 + network: + mode: deny + allow: [] + resources: + memory_mb: 2048 + process_timeout_seconds: 300 +file_edit: + memory_backup_limit_mb: 16 +limits: + whole_read_bytes: 1048576 + search_max_results: 1000 + tail_max_lines: 10000 + context_summary_ratio: 0.8 +localization: + directory: config/locales + fallback: en +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を確認してから完了すること。 +"@ + + $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.' } + } + 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.' } + } + finally { if (-not $server.HasExited) { $server.Kill($true); $server.WaitForExit() } } + } + + & $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.' } + 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 } + $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.' } + 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.overwrite' -or $auditText -notmatch 'process.start' -or $auditText -notmatch 'wfp.remove' -or $auditText -notmatch 'acl.restore') { throw 'Audit trace is incomplete.' } + $passed = $true + Write-Output "Operational E2E $Transport/$HttpHost PASS: $root" +} +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 + if ($passed) { Remove-Item -LiteralPath $root -Recurse -Force } +} diff --git a/scripts/Publish-Release.ps1 b/scripts/Publish-Release.ps1 new file mode 100644 index 0000000..8ada466 --- /dev/null +++ b/scripts/Publish-Release.ps1 @@ -0,0 +1,76 @@ +param( + [string]$Version = '1.0.0', + [string]$OutputRoot = 'build\Release\publish', + [switch]$SkipRuntimeCheck +) + +$ErrorActionPreference = 'Stop' +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$outputRootPath = [IO.Path]::GetFullPath((Join-Path $repository $OutputRoot)) +$buildRootPath = [IO.Path]::GetFullPath((Join-Path $repository 'build')) +if (-not $outputRootPath.StartsWith($buildRootPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Release output must remain inside the repository build directory.' +} +$bundleName = "agent-sandbox-mcp-$Version-win-x64" +$bundle = Join-Path $outputRootPath $bundleName +$zip = Join-Path $outputRootPath "$bundleName.zip" +$checksum = "$zip.sha256" + +New-Item -ItemType Directory -Force -Path $outputRootPath | Out-Null +if (Test-Path -LiteralPath $bundle) { Remove-Item -LiteralPath $bundle -Recurse -Force } +if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force } +if (Test-Path -LiteralPath $checksum) { Remove-Item -LiteralPath $checksum -Force } + +$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 +if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" } + +$required = @( + 'agent-mcp.exe', 'agent.yaml', 'config\locales\en.json', 'config\locales\ja.json', + '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" } +} + +$manifestPath = Join-Path $bundle 'publish-manifest.sha256' +Get-ChildItem -LiteralPath $bundle -File -Recurse | + Where-Object FullName -ne $manifestPath | + Sort-Object FullName | + ForEach-Object { + $relative = [IO.Path]::GetRelativePath($bundle, $_.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 +$zipHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant() +"$zipHash $([IO.Path]::GetFileName($zip))" | Set-Content -LiteralPath $checksum -Encoding ascii + +$freshRoot = Join-Path ([IO.Path]::GetTempPath()) ("agent-mcp-release-check-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $freshRoot | Out-Null +try { + Expand-Archive -LiteralPath $zip -DestinationPath $freshRoot + $freshBundle = Join-Path $freshRoot $bundleName + $sourceReferences = Get-ChildItem -LiteralPath $freshBundle -File -Recurse | + Where-Object Extension -in @('.yaml', '.json', '.md', '.config') | + Select-String -SimpleMatch -Pattern $repository + if ($sourceReferences) { throw 'Release Bundle contains a source-tree reference.' } + 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." } + } + finally { Pop-Location } + } +} +finally { + if (Test-Path -LiteralPath $freshRoot) { Remove-Item -LiteralPath $freshRoot -Recurse -Force } +} + +Write-Output "Bundle: $bundle" +Write-Output "ZIP: $zip" +Write-Output "SHA-256: $checksum" diff --git a/scripts/Run-ElevatedE2E.ps1 b/scripts/Run-ElevatedE2E.ps1 new file mode 100644 index 0000000..7a87a51 --- /dev/null +++ b/scripts/Run-ElevatedE2E.ps1 @@ -0,0 +1,18 @@ +param( + [ValidateSet('stdio', 'http')] + [string]$Transport, + [ValidateSet('127.0.0.1', '::1')] + [string]$HttpHost = '127.0.0.1', + [string]$EvidencePath +) + +$ErrorActionPreference = 'Continue' +try { + & (Join-Path $PSScriptRoot 'Operational-E2E.ps1') -Transport $Transport -HttpHost $HttpHost *>&1 | Tee-Object -FilePath $EvidencePath +} +catch { + $_ | Out-String | Tee-Object -FilePath $EvidencePath -Append + exit 1 +} +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +exit 0 diff --git a/scripts/Run-Final-ElevatedVerification.ps1 b/scripts/Run-Final-ElevatedVerification.ps1 new file mode 100644 index 0000000..9039c77 --- /dev/null +++ b/scripts/Run-Final-ElevatedVerification.ps1 @@ -0,0 +1,59 @@ +param( + [string]$EvidenceRoot = 'artifacts\evidence\final-elevated' +) + +$ErrorActionPreference = 'Continue' +$OutputEncoding = [Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = $OutputEncoding +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$evidence = [IO.Path]::GetFullPath((Join-Path $repository $EvidenceRoot)) +if (-not $evidence.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Evidence output must remain inside the repository.' +} +New-Item -ItemType Directory -Force -Path $evidence | Out-Null +$failed = $false + +function Invoke-VerificationStep { + param([string]$Name, [scriptblock]$Action) + $path = Join-Path $evidence "$Name.log" + "START $Name $(Get-Date -Format o)" | Tee-Object -FilePath $path + $exitCode = 0 + try { + & $Action *>&1 | Tee-Object -FilePath $path -Append + if ($LASTEXITCODE) { $exitCode = $LASTEXITCODE } + } + catch { + $_ | Out-String | Tee-Object -FilePath $path -Append + $exitCode = 1 + } + "END $Name exit=$exitCode $(Get-Date -Format o)" | Tee-Object -FilePath $path -Append + if ($exitCode -ne 0) { $script:failed = $true } +} + +$bundle = Join-Path $repository 'build\Release\publish\agent-sandbox-mcp-1.0.0-win-x64' +$windowsTests = Join-Path $repository 'build\Release\Agent.Windows.Tests\net10.0-windows10.0.26100.0\Agent.Windows.Tests.exe' +$coreTests = Join-Path $repository 'build\Release\Agent.Core.Tests\net10.0-windows10.0.26100.0\Agent.Core.Tests.exe' +$plannerTests = Join-Path $repository 'build\Release\Agent.Planner.Tests\net10.0\Agent.Planner.Tests.exe' + +Invoke-VerificationStep 'bundle-check' { + & (Join-Path $bundle 'agent-mcp.exe') check --config (Join-Path $bundle 'agent.yaml') --lang en +} +Invoke-VerificationStep 'windows-security-tests' { & $windowsTests } +Invoke-VerificationStep 'core-unit-integration-tests' { & $coreTests } +Invoke-VerificationStep 'planner-unit-tests' { & $plannerTests } +Invoke-VerificationStep 'e2e-01-stdio' { + & (Join-Path $PSScriptRoot 'Operational-E2E.ps1') -Transport stdio +} +Invoke-VerificationStep 'e2e-02-http-ipv4' { + & (Join-Path $PSScriptRoot 'Operational-E2E.ps1') -Transport http -HttpHost 127.0.0.1 +} +Invoke-VerificationStep 'e2e-02-http-ipv6' { + & (Join-Path $PSScriptRoot 'Operational-E2E.ps1') -Transport http -HttpHost ::1 +} + +if ($failed) { + Write-Error "Final elevated verification FAILED. Evidence: $evidence" + exit 1 +} +Write-Output "Final elevated verification PASS. Evidence: $evidence" +exit 0 diff --git a/skills/.keep b/skills/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/.keep @@ -0,0 +1 @@ + diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj new file mode 100644 index 0000000..a42ebad --- /dev/null +++ b/src/Agent.App/Agent.App.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> + <AssemblyName>agent-mcp</AssemblyName> + <RootNamespace>Agent.App</RootNamespace> + <Version>1.0.0</Version> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\Agent.Mcp\Agent.Mcp.csproj" /> + <ProjectReference Include="..\Agent.Windows\Agent.Windows.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Hosting" /> + <PackageReference Include="YamlDotNet" /> + </ItemGroup> + <ItemGroup> + <Content Include="..\..\config\agent.yaml" Link="agent.yaml" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <Content Include="..\..\config\locales\**\*.json" Link="config\locales\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <Content Include="..\..\prompts\user-system.md" Link="prompts\user-system.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <Content Include="..\..\skills\.keep" Link="skills\.keep" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <Content Include="..\..\README.md" Link="README.md" CopyToPublishDirectory="PreserveNewest" /> + </ItemGroup> +</Project> diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs new file mode 100644 index 0000000..df65080 --- /dev/null +++ b/src/Agent.App/Cli/CommandLine.cs @@ -0,0 +1,39 @@ +namespace Agent.App.Cli; + +public enum AppCommand { Serve, Check } + +public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? Language, string? Transport) +{ + public static CommandLine Parse(string[] args) + { + if (args.Length == 0) throw new CommandLineException("Expected 'serve' or 'check'."); + AppCommand command = args[0] switch + { + "serve" => AppCommand.Serve, + "check" => AppCommand.Check, + _ => throw new CommandLineException("Expected 'serve' or 'check'.") + }; + + string? config = null; + string? language = null; + string? transport = null; + for (int i = 1; i < args.Length; i++) + { + string option = args[i]; + if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'."); + string value = args[++i]; + 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}'."); + } + } + return new CommandLine(command, config, language, transport); + } +} + +public sealed class CommandLineException(string message) : Exception(message); diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs new file mode 100644 index 0000000..800286b --- /dev/null +++ b/src/Agent.App/Configuration/AgentConfiguration.cs @@ -0,0 +1,76 @@ +namespace Agent.App.Configuration; + +public sealed class AgentConfiguration +{ + public int SchemaVersion { get; set; } + public ApplicationOptions Application { get; set; } = new(); + public McpOptions Mcp { get; set; } = new(); + public RuntimeOptions Agent { get; set; } = new(); + public OllamaConfiguration Ollama { get; set; } = new(); + public PlannerOptions Planner { get; set; } = new(); + public SandboxOptions Sandbox { get; set; } = new(); + public FileEditOptions FileEdit { get; set; } = new(); + public LimitOptions Limits { get; set; } = new(); + public LocalizationOptions Localization { get; set; } = new(); + public SkillOptions Skills { get; set; } = new(); + public LoggingOptions Logging { get; set; } = new(); +} + +public sealed class ApplicationOptions { public string Language { get; set; } = "en"; } + +public sealed class McpOptions +{ + public string Transport { get; set; } = "stdio"; + public HttpOptions Http { get; set; } = new(); + public ImageOptions Images { get; set; } = new(); +} + +public sealed class HttpOptions { public string Host { get; set; } = "127.0.0.1"; public int Port { get; set; } = 8080; } +public sealed class ImageOptions { public int MaxCount { get; set; } = 4; public long MaxBytesEach { get; set; } = 10 * 1024 * 1024; public long MaxTotalBytes { get; set; } = 20 * 1024 * 1024; } +public sealed class RuntimeOptions { public string UserSystemPrompt { get; set; } = "prompts/user-system.md"; public int MaxIterations { get; set; } = 128; public int SameFailureLimit { get; set; } = 5; } + +public sealed class OllamaConfiguration +{ + public string Endpoint { get; set; } = "http://127.0.0.1:11434"; + public string Model { get; set; } = string.Empty; + public bool Think { get; set; } = true; + public string KeepAlive { get; set; } = "5m"; + public OllamaGenerationOptions Options { get; set; } = new(); + public Dictionary<string, object?> ExtraOptions { get; set; } = new(StringComparer.Ordinal); +} + +public sealed class OllamaGenerationOptions +{ + public int? NumCtx { get; set; } + public int? NumKeep { get; set; } + public int? NumPredict { get; set; } + public double? Temperature { get; set; } + public int? Seed { get; set; } + public int? TopK { get; set; } + public double? TopP { get; set; } + public double? MinP { get; set; } + public double? TypicalP { get; set; } + public double? TfsZ { get; set; } + public int? RepeatLastN { get; set; } + public double? RepeatPenalty { get; set; } + public double? PresencePenalty { get; set; } + public double? FrequencyPenalty { get; set; } + public int? Mirostat { get; set; } + public double? MirostatTau { get; set; } + public double? MirostatEta { get; set; } + public List<string> Stop { get; set; } = []; +} + +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 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 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"; } +public sealed class SkillOptions { public string Directory { get; set; } = "skills"; } +public sealed class LoggingOptions { public string Directory { get; set; } = "logs"; public long MaxFileBytes { get; set; } = 10 * 1024 * 1024; public int RetainedFiles { get; set; } = 5; } diff --git a/src/Agent.App/Configuration/ConfigurationException.cs b/src/Agent.App/Configuration/ConfigurationException.cs new file mode 100644 index 0000000..67f38a5 --- /dev/null +++ b/src/Agent.App/Configuration/ConfigurationException.cs @@ -0,0 +1,4 @@ +namespace Agent.App.Configuration; + +public sealed class ConfigurationException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs new file mode 100644 index 0000000..b6c4c2d --- /dev/null +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text.RegularExpressions; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Agent.App.Configuration; + +public static partial class ConfigurationLoader +{ + public static AgentConfiguration Load(string path) + { + string absolutePath = Path.GetFullPath(path); + if (!File.Exists(absolutePath)) + { + throw new ConfigurationException($"Configuration file does not exist: {absolutePath}"); + } + + try + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(File.ReadAllText(absolutePath)) + ?? throw new ConfigurationException("Configuration document is empty."); + Validate(configuration, Path.GetDirectoryName(absolutePath)!); + return configuration; + } + catch (ConfigurationException) + { + throw; + } + catch (Exception ex) + { + throw new ConfigurationException($"Could not parse YAML: {ex.Message}", ex); + } + } + + public static void Validate(AgentConfiguration value, string baseDirectory) + { + if (value.SchemaVersion != 1) Throw("schema_version must be 1."); + if (string.IsNullOrWhiteSpace(value.Application.Language)) Throw("application.language is required."); + if (value.Mcp.Transport is not ("stdio" or "http")) Throw("mcp.transport must be stdio or http."); + if (!IPAddress.TryParse(value.Mcp.Http.Host, out IPAddress? host) || !IPAddress.IsLoopback(host)) Throw("mcp.http.host must be 127.0.0.1 or ::1."); + if (value.Mcp.Http.Port is < 1 or > 65535) Throw("mcp.http.port is outside 1..65535."); + if (value.Mcp.Images.MaxCount < 0 || value.Mcp.Images.MaxBytesEach <= 0 || value.Mcp.Images.MaxTotalBytes <= 0) Throw("mcp.images limits must be positive."); + if (value.Mcp.Images.MaxTotalBytes < value.Mcp.Images.MaxBytesEach) Throw("mcp.images.max_total_bytes must be >= max_bytes_each."); + if (value.Agent.MaxIterations <= 0 || value.Agent.SameFailureLimit <= 0) Throw("agent iteration limits must be positive."); + if (!Uri.TryCreate(value.Ollama.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme is not ("http" or "https")) Throw("ollama.endpoint must be an absolute HTTP(S) URI."); + if (string.IsNullOrWhiteSpace(value.Ollama.Model)) Throw("ollama.model is required."); + 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."); + } + 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."); + + RequireFile(baseDirectory, value.Agent.UserSystemPrompt, "agent.user_system_prompt"); + RequireDirectory(baseDirectory, value.Localization.Directory, "localization.directory"); + RequireDirectory(baseDirectory, value.Skills.Directory, "skills.directory"); + } + + private static void RequireFile(string baseDirectory, string path, string key) + { + if (!File.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); + } + + private static void RequireDirectory(string baseDirectory, string path, string key) + { + if (!Directory.Exists(Resolve(baseDirectory, path))) Throw($"{key} does not exist: {path}"); + } + + public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); + private static void Throw(string message) => throw new ConfigurationException(message); + [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] + private static partial Regex Sha256Regex(); +} diff --git a/src/Agent.App/Configuration/OllamaSettingsFactory.cs b/src/Agent.App/Configuration/OllamaSettingsFactory.cs new file mode 100644 index 0000000..cd71139 --- /dev/null +++ b/src/Agent.App/Configuration/OllamaSettingsFactory.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Agent.Core.Ollama; + +namespace Agent.App.Configuration; + +public static class OllamaSettingsFactory +{ + public static OllamaSettings Create(OllamaConfiguration configuration) + { + var options = configuration.ExtraOptions.ToDictionary( + static pair => pair.Key, + static pair => JsonSerializer.SerializeToElement(pair.Value, pair.Value?.GetType() ?? typeof(object)), + StringComparer.Ordinal); + Set(options, "num_ctx", configuration.Options.NumCtx); + Set(options, "num_keep", configuration.Options.NumKeep); + Set(options, "num_predict", configuration.Options.NumPredict); + Set(options, "temperature", configuration.Options.Temperature); + Set(options, "seed", configuration.Options.Seed); + Set(options, "top_k", configuration.Options.TopK); + Set(options, "top_p", configuration.Options.TopP); + Set(options, "min_p", configuration.Options.MinP); + Set(options, "typical_p", configuration.Options.TypicalP); + Set(options, "tfs_z", configuration.Options.TfsZ); + Set(options, "repeat_last_n", configuration.Options.RepeatLastN); + Set(options, "repeat_penalty", configuration.Options.RepeatPenalty); + Set(options, "presence_penalty", configuration.Options.PresencePenalty); + Set(options, "frequency_penalty", configuration.Options.FrequencyPenalty); + Set(options, "mirostat", configuration.Options.Mirostat); + Set(options, "mirostat_tau", configuration.Options.MirostatTau); + Set(options, "mirostat_eta", configuration.Options.MirostatEta); + if (configuration.Options.Stop.Count > 0) options["stop"] = JsonSerializer.SerializeToElement(configuration.Options.Stop); + return new OllamaSettings(new Uri(configuration.Endpoint, UriKind.Absolute), configuration.Model, configuration.Think, configuration.KeepAlive, options); + } + + private static void Set<T>(Dictionary<string, JsonElement> options, string key, T? value) where T : struct + { + if (value is not null) options[key] = JsonSerializer.SerializeToElement(value.Value); + } +} diff --git a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs new file mode 100644 index 0000000..b7cbd26 --- /dev/null +++ b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Agent.Core.Ollama; +using Agent.Core.Runtime; +using Agent.Core.Tools; + +namespace Agent.App.Logging; + +public sealed class JsonLinesRuntimeObserver(string logDirectory) : IAgentRuntimeObserver +{ + private readonly object _sync = new(); + private readonly string _path = Path.Combine(logDirectory, "llm.jsonl"); + + public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) => Write(new + { + timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.request", iteration, + messages = messages.Select(static message => new { message.Role, message.Content, image_count = message.Images?.Count ?? 0, message.ToolName }) + }); + + public void LlmResponse(Guid taskId, int iteration, OllamaResult result) => Write(new + { + timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.response", iteration, + result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens, + tool_calls = result.ToolCalls?.Select(static call => call.Function.Name).ToArray() ?? [] + }); + + public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) => Write(new + { + timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.tool_result", tool_name = toolName, + result.Success, result.ExternalEffect, result.Output, result.FailureFingerprint + }); + + private void Write(object value) + { + string line = JsonSerializer.Serialize(value); + lock (_sync) File.AppendAllText(_path, line + Environment.NewLine); + } +} diff --git a/src/Agent.App/Logging/RollingFileLog.cs b/src/Agent.App/Logging/RollingFileLog.cs new file mode 100644 index 0000000..801768d --- /dev/null +++ b/src/Agent.App/Logging/RollingFileLog.cs @@ -0,0 +1,55 @@ +using System.Globalization; + +namespace Agent.App.Logging; + +public sealed class RollingFileLog : IDisposable +{ + private readonly object _sync = new(); + private readonly string _path; + private readonly long _maxBytes; + private readonly int _retainedFiles; + private StreamWriter _writer; + + public RollingFileLog(string directory, long maxBytes, int retainedFiles) + { + Directory.CreateDirectory(directory); + _path = Path.Combine(directory, "application.log"); + _maxBytes = maxBytes; + _retainedFiles = retainedFiles; + _writer = Open(); + } + + public void Information(string message) => Write("INF", message); + public void Error(string message) => Write("ERR", message); + + private void Write(string level, string message) + { + lock (_sync) + { + if (_writer.BaseStream.Length >= _maxBytes) Rotate(); + string line = string.Create(CultureInfo.InvariantCulture, $"{DateTimeOffset.UtcNow:O} [{level}] {message}"); + Console.Error.WriteLine(line); + _writer.WriteLine(line); + _writer.Flush(); + } + } + + private StreamWriter Open() => new(new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read), new System.Text.UTF8Encoding(false)); + + private void Rotate() + { + _writer.Dispose(); + for (int index = _retainedFiles - 1; index >= 1; index--) + { + string source = $"{_path}.{index}"; + if (File.Exists(source)) File.Move(source, $"{_path}.{index + 1}", true); + } + if (File.Exists(_path)) File.Move(_path, $"{_path}.1", true); + _writer = Open(); + } + + public void Dispose() + { + lock (_sync) _writer.Dispose(); + } +} diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs new file mode 100644 index 0000000..3fae93f --- /dev/null +++ b/src/Agent.App/Program.cs @@ -0,0 +1,130 @@ +using Agent.App.Cli; +using Agent.App.Configuration; +using Agent.App.Logging; +using Agent.Core.Localization; +using Agent.Core.Prompts; +using Agent.Mcp; +using Agent.Core.Runtime; +using Agent.Windows.Diagnostics; +using Agent.Core.Tools; +using Agent.Windows.Recovery; +using Agent.Windows.Security; +using Agent.Windows.Tools; + +namespace Agent.App; + +internal static class Program +{ + public static async Task<int> Main(string[] args) + { + try + { + CommandLine commandLine = CommandLine.Parse(args); + string configPath = commandLine.ConfigPath is null + ? Path.Combine(AppContext.BaseDirectory, "agent.yaml") + : Path.GetFullPath(commandLine.ConfigPath); + AgentConfiguration configuration = ConfigurationLoader.Load(configPath); + if (commandLine.Transport is not null) configuration.Mcp.Transport = commandLine.Transport; + ConfigurationLoader.Validate(configuration, Path.GetDirectoryName(configPath)!); + + string language = commandLine.Language ?? configuration.Application.Language ?? "en"; + string baseDirectory = Path.GetDirectoryName(configPath)!; + string localeDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Localization.Directory); + LocaleCatalog locale = LocaleCatalog.Load(localeDirectory, language, configuration.Localization.Fallback); + _ = FixedPrompt.Load(); + + string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory); + UserOnlyDirectory.Ensure(logDirectory); + using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles); + if (commandLine.Command == AppCommand.Check) + { + WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run(); + Console.Out.WriteLine($"administrator={platform.IsAdministrator.ToString().ToLowerInvariant()}"); + Console.Out.WriteLine($"bfe_engine_open={platform.BfeEngineOpen.ToString().ToLowerInvariant()}"); + Console.Out.WriteLine($"wfp_write_transaction={platform.WfpWriteTransaction.ToString().ToLowerInvariant()}"); + Console.Out.WriteLine($"wfp_filter_add_delete={platform.WfpFilterAddDelete.ToString().ToLowerInvariant()}"); + if (!platform.Success) + { + string detail = $"{locale.Get("check.wfp_admin_required")} error_code={platform.ErrorCode}; operation={platform.ErrorOperation}"; + string failure = locale.Format("check.failure", new Dictionary<string, object?> { ["message"] = detail }); + log.Error(failure); + Console.Error.WriteLine(failure); + return 2; + } + log.Information(locale.Get("check.success")); + Console.Out.WriteLine(locale.Get("check.success")); + return 0; + } + + log.Information(locale.Get("app.starting")); + InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory); + var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory); + if (configuration.Mcp.Transport == "stdio") + { + string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); + string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); + string[] skills = Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.TopDirectoryOnly).Select(File.ReadAllText).ToArray(); + var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported")); + var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); + await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false); + return 0; + } + if (configuration.Mcp.Transport == "http") + { + string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); + string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); + string[] skills = Directory.EnumerateFiles(skillDirectory, "*.md", SearchOption.TopDirectoryOnly).Select(File.ReadAllText).ToArray(); + var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported")); + var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); + await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, + configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver).ConfigureAwait(false); + return 0; + } + throw new ConfigurationException("Unsupported MCP transport."); + } + catch (Exception ex) when (ex is CommandLineException or ConfigurationException or LocaleException or IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"agent-mcp: {ex.Message}"); + return 2; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + Console.Error.WriteLine($"agent-mcp: [APP-UNHANDLED] {ex.Message}"); + return 2; + } + } + + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory) + { + string stateDirectory = Path.Combine(baseDirectory, "state"); + string journalDirectory = Path.Combine(stateDirectory, "journals"); + string backupDirectory = Path.Combine(stateDirectory, "backups"); + Directory.CreateDirectory(journalDirectory); + Directory.CreateDirectory(backupDirectory); + var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); + RecoveryResult recovery = new RecoveryManager(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); + var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks)); + 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)); + } + } +} diff --git a/src/Agent.Core/Agent.Core.csproj b/src/Agent.Core/Agent.Core.csproj new file mode 100644 index 0000000..9816d8a --- /dev/null +++ b/src/Agent.Core/Agent.Core.csproj @@ -0,0 +1,8 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <ItemGroup> + <ProjectReference Include="..\Agent.Planner\Agent.Planner.csproj" /> + </ItemGroup> + <ItemGroup> + <EmbeddedResource Include="Prompts\FixedSystemPrompt.md" /> + </ItemGroup> +</Project> diff --git a/src/Agent.Core/AssemblyMarker.cs b/src/Agent.Core/AssemblyMarker.cs new file mode 100644 index 0000000..2efa480 --- /dev/null +++ b/src/Agent.Core/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Core; + +public static class AssemblyMarker; diff --git a/src/Agent.Core/Localization/LanguageContext.cs b/src/Agent.Core/Localization/LanguageContext.cs new file mode 100644 index 0000000..f8878b5 --- /dev/null +++ b/src/Agent.Core/Localization/LanguageContext.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Localization; + +public sealed record LanguageContext(string Locale); diff --git a/src/Agent.Core/Localization/LocaleCatalog.cs b/src/Agent.Core/Localization/LocaleCatalog.cs new file mode 100644 index 0000000..3c49283 --- /dev/null +++ b/src/Agent.Core/Localization/LocaleCatalog.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Agent.Core.Localization; + +public sealed partial class LocaleCatalog +{ + private readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> _locales; + private readonly IReadOnlyDictionary<string, string> _fallback; + + private LocaleCatalog(string selectedLocale, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> locales, IReadOnlyDictionary<string, string> fallback) + { + SelectedLocale = selectedLocale; + _locales = locales; + _fallback = fallback; + } + + public string SelectedLocale { get; } + public IReadOnlyCollection<string> Keys => _fallback.Keys.ToArray(); + + public static LocaleCatalog Load(string directory, string selectedLocale, string fallbackLocale = "en") + { + if (!Directory.Exists(directory)) throw new LocaleException($"Locale directory does not exist: {directory}"); + + var locales = new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.OrdinalIgnoreCase); + foreach (string file in Directory.EnumerateFiles(directory, "*.json", SearchOption.TopDirectoryOnly)) + { + string locale = Path.GetFileNameWithoutExtension(file); + locales.Add(locale, ReadFlatObject(file)); + } + + if (!locales.TryGetValue(fallbackLocale, out IReadOnlyDictionary<string, string>? fallback)) + throw new LocaleException($"Fallback locale '{fallbackLocale}' is missing."); + + HashSet<string> baselineKeys = fallback.Keys.ToHashSet(StringComparer.Ordinal); + Dictionary<string, HashSet<string>> baselinePlaceholders = fallback.ToDictionary(static p => p.Key, static p => Placeholders(p.Value), StringComparer.Ordinal); + foreach ((string locale, IReadOnlyDictionary<string, string> values) in locales) + { + string? unknown = values.Keys.FirstOrDefault(key => !baselineKeys.Contains(key)); + if (unknown is not null) throw new LocaleException($"Locale '{locale}' contains unknown key '{unknown}'."); + foreach ((string key, string message) in values) + { + if (!baselinePlaceholders[key].SetEquals(Placeholders(message))) + throw new LocaleException($"Locale '{locale}' has mismatched placeholders for key '{key}'."); + } + } + + if (!locales.ContainsKey(selectedLocale)) throw new LocaleException($"Selected locale '{selectedLocale}' is missing."); + return new LocaleCatalog(selectedLocale, locales, fallback); + } + + public string Get(string key) + { + if (_locales[SelectedLocale].TryGetValue(key, out string? selected)) return selected; + return _fallback.TryGetValue(key, out string? fallback) ? fallback : key; + } + + public string Format(string key, IReadOnlyDictionary<string, object?> values) + { + string template = Get(key); + return PlaceholderRegex().Replace(template, match => + { + string name = match.Groups[1].Value; + if (!values.TryGetValue(name, out object? value)) throw new LocaleException($"Missing placeholder value '{name}' for key '{key}'."); + return Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + }); + } + + private static Dictionary<string, string> ReadFlatObject(string file) + { + try + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(file)); + if (document.RootElement.ValueKind != JsonValueKind.Object) throw new LocaleException($"Locale file must contain a JSON object: {file}"); + var result = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) throw new LocaleException($"Locale value '{property.Name}' must be a string."); + if (!result.TryAdd(property.Name, property.Value.GetString()!)) throw new LocaleException($"Duplicate locale key '{property.Name}'."); + } + return result; + } + catch (LocaleException) + { + throw; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + throw new LocaleException($"Could not load locale file '{file}': {ex.Message}", ex); + } + } + + private static HashSet<string> Placeholders(string text) => PlaceholderRegex().Matches(text).Select(static m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); + + [GeneratedRegex("\\{([A-Za-z][A-Za-z0-9_]*)\\}", RegexOptions.CultureInvariant)] + private static partial Regex PlaceholderRegex(); +} diff --git a/src/Agent.Core/Localization/LocaleException.cs b/src/Agent.Core/Localization/LocaleException.cs new file mode 100644 index 0000000..788a45c --- /dev/null +++ b/src/Agent.Core/Localization/LocaleException.cs @@ -0,0 +1,4 @@ +namespace Agent.Core.Localization; + +public sealed class LocaleException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs new file mode 100644 index 0000000..a03a353 --- /dev/null +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Ollama; + +using Agent.Core.Tools; + +public interface IOllamaClient +{ + Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken); + Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) => + ChatAsync(messages, temperatureOverride, cancellationToken); + Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(null); +} diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs new file mode 100644 index 0000000..bfd2210 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed class OllamaClient : IOllamaClient +{ + private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1)]; + private readonly HttpClient _httpClient; + private readonly OllamaSettings _settings; + private readonly Func<TimeSpan, CancellationToken, Task> _delay; + private bool? _visionCapability; + + public OllamaClient(HttpClient httpClient, OllamaSettings settings, Func<TimeSpan, CancellationToken, Task>? delay = null) + { + _httpClient = httpClient; + _httpClient.BaseAddress = settings.Endpoint; + _settings = settings; + _delay = delay ?? Task.Delay; + } + + public async Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, cancellationToken).ConfigureAwait(false); + + public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, tools, cancellationToken).ConfigureAwait(false); + + public async Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) + { + if (_visionCapability is not null) return _visionCapability; + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/show", new { model = _settings.Model }, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return null; + using JsonDocument document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), cancellationToken: cancellationToken).ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("capabilities", out JsonElement capabilities) || capabilities.ValueKind != JsonValueKind.Array) return null; + _visionCapability = capabilities.EnumerateArray().Any(static item => item.GetString()?.Equals("vision", StringComparison.OrdinalIgnoreCase) == true); + return _visionCapability; + } + catch (HttpRequestException) { return null; } + } + + private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, CancellationToken cancellationToken) + { + Dictionary<string, JsonElement> options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value); + var request = new OllamaChatRequest + { + Model = _settings.Model, + Messages = messages, + Stream = false, + Think = _settings.Think, + KeepAlive = _settings.KeepAlive, + Options = options, + Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray() + }; + + for (int attempt = 0; ; attempt++) + { + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/chat", request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + bool transient = IsTransient(response.StatusCode); + if (transient && attempt < RetryDelays.Length) + { + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + continue; + } + throw new OllamaException($"Ollama returned HTTP {(int)response.StatusCode}: {body}", transient); + } + + OllamaChatResponse result = await response.Content.ReadFromJsonAsync<OllamaChatResponse>(cancellationToken: cancellationToken).ConfigureAwait(false) + ?? throw new OllamaException("Ollama returned an empty response.", false); + if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false); + if (result.Message is null) throw new OllamaException("Ollama response has no message.", false); + return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + if (attempt >= RetryDelays.Length) throw new OllamaException("Ollama is unavailable after three retries.", true, ex); + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + } + } + } + + private static bool IsTransient(HttpStatusCode statusCode) => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; +} diff --git a/src/Agent.Core/Ollama/OllamaException.cs b/src/Agent.Core/Ollama/OllamaException.cs new file mode 100644 index 0000000..6b0c158 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaException.cs @@ -0,0 +1,7 @@ +namespace Agent.Core.Ollama; + +public sealed class OllamaException(string message, bool transient, Exception? innerException = null) + : Exception(message, innerException) +{ + public bool IsTransient { get; } = transient; +} diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs new file mode 100644 index 0000000..823a178 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaModels.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed record OllamaMessage( + [property: JsonPropertyName("role")] string Role, + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<string>? Images = null, + [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null, + [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<OllamaToolCall>? ToolCalls = null); + +public sealed record OllamaToolCall([property: JsonPropertyName("function")] OllamaFunctionCall Function); +public sealed record OllamaFunctionCall( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("arguments")] JsonElement Arguments); + +public sealed record OllamaToolDefinition( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("function")] OllamaFunctionDefinition Function); +public sealed record OllamaFunctionDefinition( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("parameters")] JsonElement Parameters); + +public sealed class OllamaChatRequest +{ + [JsonPropertyName("model")] public required string Model { get; init; } + [JsonPropertyName("messages")] public required IReadOnlyList<OllamaMessage> Messages { get; init; } + [JsonPropertyName("stream")] public bool Stream { get; init; } + [JsonPropertyName("think")] public bool Think { get; init; } + [JsonPropertyName("keep_alive")] public required string KeepAlive { get; init; } + [JsonPropertyName("options")] public required IReadOnlyDictionary<string, System.Text.Json.JsonElement> Options { get; init; } + [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList<OllamaToolDefinition>? Tools { get; init; } +} + +public sealed class OllamaChatResponse +{ + [JsonPropertyName("message")] public OllamaMessage? Message { get; init; } + [JsonPropertyName("prompt_eval_count")] public long PromptEvalCount { get; init; } + [JsonPropertyName("eval_count")] public long EvalCount { get; init; } + [JsonPropertyName("error")] public string? Error { get; init; } +} + +public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null); diff --git a/src/Agent.Core/Ollama/OllamaSettings.cs b/src/Agent.Core/Ollama/OllamaSettings.cs new file mode 100644 index 0000000..94ab3ef --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaSettings.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace Agent.Core.Ollama; + +public sealed record OllamaSettings( + Uri Endpoint, + string Model, + bool Think, + string KeepAlive, + IReadOnlyDictionary<string, JsonElement> Options); diff --git a/src/Agent.Core/Prompts/FixedPrompt.cs b/src/Agent.Core/Prompts/FixedPrompt.cs new file mode 100644 index 0000000..6995bd4 --- /dev/null +++ b/src/Agent.Core/Prompts/FixedPrompt.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace Agent.Core.Prompts; + +public static class FixedPrompt +{ + private const string ResourceName = "Agent.Core.Prompts.FixedSystemPrompt.md"; + + public static string Load() + { + using Stream stream = typeof(FixedPrompt).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' was not found."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/Agent.Core/Prompts/FixedSystemPrompt.md b/src/Agent.Core/Prompts/FixedSystemPrompt.md new file mode 100644 index 0000000..3cbc6e7 --- /dev/null +++ b/src/Agent.Core/Prompts/FixedSystemPrompt.md @@ -0,0 +1,4 @@ +You are the execution agent inside AI Agent Sandbox MCP Server. +Follow the active SandboxPolicy and never claim an external effect unless an Internal Tool result proves it occurred. +Never reveal hidden reasoning, policy internals, credentials, or capabilities not present in the supplied capability summary. +Complete a request only when every TaskGraph node is completed and all required effects are verified. diff --git a/src/Agent.Core/Prompts/PromptComposer.cs b/src/Agent.Core/Prompts/PromptComposer.cs new file mode 100644 index 0000000..e07c7e8 --- /dev/null +++ b/src/Agent.Core/Prompts/PromptComposer.cs @@ -0,0 +1,36 @@ +using Agent.Core.Ollama; +using Agent.Core.Sessions; + +namespace Agent.Core.Prompts; + +public sealed record PromptContext( + string FixedSystemPrompt, + string UserSystemPrompt, + string Locale, + IReadOnlyList<string> Skills, + string SandboxCapabilitySummary, + string TaskGraphSummary, + string PreviousSummary, + IReadOnlyList<ConversationMessage> Conversation, + string Request, + IReadOnlyList<string>? Images = null); + +public static class PromptComposer +{ + public static IReadOnlyList<OllamaMessage> Compose(PromptContext context) + { + var messages = new List<OllamaMessage> + { + new("system", context.FixedSystemPrompt), + new("system", context.UserSystemPrompt), + new("system", $"Respond in locale: {context.Locale}."), + new("system", string.Join("\n\n", context.Skills)), + new("system", context.SandboxCapabilitySummary), + new("system", context.TaskGraphSummary), + new("system", context.PreviousSummary) + }; + messages.AddRange(context.Conversation.Select(static item => new OllamaMessage(item.Role, item.Content))); + messages.Add(new OllamaMessage("user", context.Request, context.Images)); + return messages; + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs new file mode 100644 index 0000000..c2fa568 --- /dev/null +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -0,0 +1,165 @@ +using Agent.Core.Ollama; +using Agent.Core.Prompts; +using Agent.Core.Sessions; +using Agent.Core.Tasks; +using Agent.Core.Tools; + +namespace Agent.Core.Runtime; + +public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) +{ + private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( + new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), + new FakeInternalToolBackend()); + private readonly IAgentRuntimeObserver _observer = observer ?? NullAgentRuntimeObserver.Instance; + + public async Task ExecuteAsync(Guid taskId, CancellationToken cancellationToken) + { + if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return; + if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null) + { + registry.Transition(taskId, AgentTaskState.Failed, result: "Session not found."); + return; + } + + try + { + await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) + throw new InvalidOperationException(settings.VisionUnsupportedMessage); + var messages = PromptComposer.Compose(new PromptContext( + FixedPrompt.Load(), + settings.UserSystemPrompt, + settings.Locale, + settings.Skills, + settings.SandboxCapabilitySummary, + "Current task: " + task.Request, + string.Empty, + session.Conversation, + task.Request, + task.Images)).ToList(); + var guard = new ToolUseGuard(settings.SameFailureLimit); + bool requiresExternalEffect = RequiresExternalEffect(task.Request); + bool externalEffectEvidence = false; + var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal); + IReadOnlyDictionary<string, int> requiredToolCalls = RequiredToolCalls(task.Request); + var successfulToolCalls = new Dictionary<string, int>(StringComparer.Ordinal); + string? finalContent = null; + for (int iteration = 0; iteration < settings.MaxIterations; iteration++) + { + _observer.LlmRequest(taskId, iteration, messages); + OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(taskId, iteration, result); + registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); + IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? []; + if (calls.Count > 0) + { + messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls)); + string? failureFingerprint = null; + foreach (OllamaToolCall call in calls) + { + ToolExecutionResult toolResult; + try + { + toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is ToolValidationException or ArgumentException or UnauthorizedAccessException) + { + toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}"); + } + externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect; + if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name); + else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output; + if (toolResult.Success) successfulToolCalls[call.Function.Name] = successfulToolCalls.GetValueOrDefault(call.Function.Name) + 1; + _observer.ToolResult(taskId, call.Function.Name, toolResult); + failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; + messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); + } + ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint); + if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation); + continue; + } + + if (unresolvedToolFailures.Count > 0) + { + messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " + + string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}")))); + continue; + } + string[] missingCalls = requiredToolCalls + .Where(requirement => successfulToolCalls.GetValueOrDefault(requirement.Key) < requirement.Value) + .Select(requirement => $"{requirement.Key}: {successfulToolCalls.GetValueOrDefault(requirement.Key)}/{requirement.Value}") + .ToArray(); + if (missingCalls.Length > 0) + { + messages.Add(new OllamaMessage("system", "Completion is not allowed because the request explicitly requires successful tool calls that are still missing: " + string.Join("; ", missingCalls))); + continue; + } + ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence); + if (completion.Decision == ToolUseGuardDecision.Retry) + { + messages.Add(new OllamaMessage("system", completion.Observation)); + continue; + } + if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation); + finalContent = result.Content; + break; + } + if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false); + if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure); + session.Append(new ConversationMessage("user", task.Request)); + session.Append(new ConversationMessage("assistant", finalContent)); + registry.Transition(taskId, AgentTaskState.Completed, result: finalContent); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); + string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}"; + registry.Transition(taskId, AgentTaskState.Cancelled, result: result); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); + string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}"; + registry.Transition(taskId, AgentTaskState.Failed, result: result); + } + } + + private async Task<string?> EndTaskSafelyAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + try + { + await _tools.EndTaskAsync(taskId, success, cancellationToken).ConfigureAwait(false); + return null; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + return $"Task resource cleanup failed: {ex.Message}"; + } + } + + private static bool RequiresExternalEffect(string request) + { + string normalized = request.ToLowerInvariant(); + string[] indicators = ["edit", "modify", "change", "fix", "write", "delete", "create", "build", "test", "run", "修正", "変更", "編集", "作成", "削除", "ビルド", "テスト", "実行"]; + return indicators.Any(normalized.Contains); + } + + private static IReadOnlyDictionary<string, int> RequiredToolCalls(string request) + { + var result = new Dictionary<string, int>(StringComparer.Ordinal); + foreach (InternalToolDefinition tool in InternalToolCatalog.All) + { + int count = 0; + int offset = 0; + while ((offset = request.IndexOf(tool.Name, offset, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + offset += tool.Name.Length; + } + if (count > 0) result[tool.Name] = count; + } + return result; + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs new file mode 100644 index 0000000..9bc04b9 --- /dev/null +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -0,0 +1,10 @@ +namespace Agent.Core.Runtime; + +public sealed record AgentRuntimeSettings( + string UserSystemPrompt, + string Locale, + IReadOnlyList<string> Skills, + string SandboxCapabilitySummary, + int MaxIterations = 128, + int SameFailureLimit = 5, + string VisionUnsupportedMessage = "The selected model does not support image input."); diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs new file mode 100644 index 0000000..e4e26e3 --- /dev/null +++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs @@ -0,0 +1,19 @@ +using Agent.Core.Ollama; +using Agent.Core.Tools; + +namespace Agent.Core.Runtime; + +public interface IAgentRuntimeObserver +{ + void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages); + void LlmResponse(Guid taskId, int iteration, OllamaResult result); + void ToolResult(Guid taskId, string toolName, ToolExecutionResult result); +} + +public sealed class NullAgentRuntimeObserver : IAgentRuntimeObserver +{ + public static NullAgentRuntimeObserver Instance { get; } = new(); + public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) { } + public void LlmResponse(Guid taskId, int iteration, OllamaResult result) { } + public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) { } +} diff --git a/src/Agent.Core/Sessions/AgentSession.cs b/src/Agent.Core/Sessions/AgentSession.cs new file mode 100644 index 0000000..d561c8d --- /dev/null +++ b/src/Agent.Core/Sessions/AgentSession.cs @@ -0,0 +1,26 @@ +namespace Agent.Core.Sessions; + +public sealed class AgentSession(Guid sessionId) +{ + private readonly object _sync = new(); + private readonly List<ConversationMessage> _conversation = []; + + public Guid SessionId { get; } = sessionId; + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow; + + public IReadOnlyList<ConversationMessage> Conversation + { + get { lock (_sync) return _conversation.ToArray(); } + } + + public void Append(ConversationMessage message) + { + ArgumentNullException.ThrowIfNull(message); + lock (_sync) + { + _conversation.Add(message); + LastAccessAt = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/Agent.Core/Sessions/ConversationMessage.cs b/src/Agent.Core/Sessions/ConversationMessage.cs new file mode 100644 index 0000000..9d48747 --- /dev/null +++ b/src/Agent.Core/Sessions/ConversationMessage.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Sessions; + +public sealed record ConversationMessage(string Role, string Content); diff --git a/src/Agent.Core/Sessions/SessionStore.cs b/src/Agent.Core/Sessions/SessionStore.cs new file mode 100644 index 0000000..59ea02c --- /dev/null +++ b/src/Agent.Core/Sessions/SessionStore.cs @@ -0,0 +1,19 @@ +using System.Collections.Concurrent; + +namespace Agent.Core.Sessions; + +public sealed class SessionStore +{ + private readonly ConcurrentDictionary<Guid, AgentSession> _sessions = new(); + + public AgentSession Create() + { + while (true) + { + var session = new AgentSession(Guid.NewGuid()); + if (_sessions.TryAdd(session.SessionId, session)) return session; + } + } + + public bool TryGet(Guid sessionId, out AgentSession? session) => _sessions.TryGetValue(sessionId, out session); +} diff --git a/src/Agent.Core/Tasks/AgentTaskState.cs b/src/Agent.Core/Tasks/AgentTaskState.cs new file mode 100644 index 0000000..927e8d7 --- /dev/null +++ b/src/Agent.Core/Tasks/AgentTaskState.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Tasks; + +public enum AgentTaskState +{ + Queued, + Planning, + Running, + Completed, + Failed, + Cancelled +} diff --git a/src/Agent.Core/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs new file mode 100644 index 0000000..b61e525 --- /dev/null +++ b/src/Agent.Core/Tasks/TaskRegistry.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Agent.Core.Sessions; + +namespace Agent.Core.Tasks; + +public sealed class TaskRegistry +{ + private readonly ConcurrentDictionary<Guid, MutableTask> _tasks = new(); + private readonly SessionStore _sessions; + private readonly Channel<Guid> _queue = Channel.CreateUnbounded<Guid>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + public TaskRegistry(SessionStore? sessions = null) => _sessions = sessions ?? new SessionStore(); + + public SessionStore Sessions => _sessions; + + public TaskSnapshot Enqueue(string request, Guid? requestedSessionId, double? temperature, IReadOnlyList<string>? images = null) + { + if (string.IsNullOrWhiteSpace(request)) throw new ArgumentException("Request cannot be empty.", nameof(request)); + if (temperature is < 0 or > 2) throw new ArgumentOutOfRangeException(nameof(temperature)); + Guid sessionId; + if (requestedSessionId is { } existing) + { + if (!_sessions.TryGet(existing, out _)) throw new KeyNotFoundException($"Session '{existing}' was not found."); + sessionId = existing; + } + else + { + sessionId = _sessions.Create().SessionId; + } + + var task = new MutableTask(Guid.NewGuid(), sessionId, request, temperature, images ?? []); + if (!_tasks.TryAdd(task.TaskId, task)) throw new InvalidOperationException("Could not register task."); + if (!_queue.Writer.TryWrite(task.TaskId)) + { + _tasks.TryRemove(task.TaskId, out _); + throw new InvalidOperationException("Task queue is not accepting work."); + } + return task.Snapshot(); + } + + public bool TryGet(Guid taskId, Guid? sessionId, out TaskSnapshot? snapshot) + { + if (_tasks.TryGetValue(taskId, out MutableTask? task) && (sessionId is null || task.SessionId == sessionId)) + { + snapshot = task.Snapshot(); + return true; + } + snapshot = null; + return false; + } + + public IAsyncEnumerable<Guid> ReadQueuedAsync(CancellationToken cancellationToken) => _queue.Reader.ReadAllAsync(cancellationToken); + + public bool Transition(Guid taskId, AgentTaskState state, string? currentTask = null, string? result = null) + { + return _tasks.TryGetValue(taskId, out MutableTask? task) && task.Transition(state, currentTask, result); + } + + public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens); + + private sealed class MutableTask(Guid taskId, Guid sessionId, string request, double? temperature, IReadOnlyList<string> images) + { + private readonly object _sync = new(); + private AgentTaskState _state = AgentTaskState.Queued; + private string? _currentTask; + private string? _result; + private long _inputTokens; + private long _outputTokens; + + public Guid TaskId { get; } = taskId; + public Guid SessionId { get; } = sessionId; + + public TaskSnapshot Snapshot() + { + lock (_sync) + { + int completed = _state == AgentTaskState.Completed ? 1 : 0; + return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, completed, 1, _inputTokens, _outputTokens, 0, images); + } + } + + public bool AddUsage(long inputTokens, long outputTokens) + { + if (inputTokens < 0 || outputTokens < 0) return false; + lock (_sync) + { + _inputTokens += inputTokens; + _outputTokens += outputTokens; + return true; + } + } + + public bool Transition(AgentTaskState next, string? currentTask, string? result) + { + lock (_sync) + { + if (!Allowed(_state, next)) return false; + _state = next; + _currentTask = currentTask; + _result = result; + return true; + } + } + + private static bool Allowed(AgentTaskState current, AgentTaskState next) => current switch + { + AgentTaskState.Queued => next is AgentTaskState.Planning or AgentTaskState.Failed or AgentTaskState.Cancelled, + AgentTaskState.Planning => next is AgentTaskState.Running or AgentTaskState.Failed or AgentTaskState.Cancelled, + AgentTaskState.Running => next is AgentTaskState.Planning or AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled, + _ => false + }; + } +} diff --git a/src/Agent.Core/Tasks/TaskSnapshot.cs b/src/Agent.Core/Tasks/TaskSnapshot.cs new file mode 100644 index 0000000..0a15de3 --- /dev/null +++ b/src/Agent.Core/Tasks/TaskSnapshot.cs @@ -0,0 +1,16 @@ +namespace Agent.Core.Tasks; + +public sealed record TaskSnapshot( + Guid TaskId, + Guid SessionId, + AgentTaskState State, + string Request, + double? Temperature, + string? CurrentTask, + string? Result, + int CompletedTasks, + int TotalTasks, + long InputTokens, + long OutputTokens, + double CriticalPathTokens, + IReadOnlyList<string> Images); diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs new file mode 100644 index 0000000..1563c16 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolCatalog.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolCatalog +{ + private static readonly IReadOnlyDictionary<string, InternalToolDefinition> Definitions = CreateDefinitions(); + + public static IReadOnlyList<InternalToolDefinition> All { get; } = Definitions.Values.ToArray(); + public static InternalToolDefinition Get(string name) => Definitions.TryGetValue(name, out InternalToolDefinition? definition) + ? definition + : throw new ToolValidationException($"Unknown internal tool '{name}'."); + + private static IReadOnlyDictionary<string, InternalToolDefinition> CreateDefinitions() + { + InternalToolDefinition[] definitions = + [ + Define("file_search", "Search files under an approved root.", """{"type":"object","additionalProperties":false,"required":["root","mode","query"],"properties":{"root":{"type":"string"},"mode":{"type":"string","enum":["glob","name","text","regex","tail"]},"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"maximum":1000},"tail_lines":{"type":"integer","minimum":1,"maximum":10000}}}"""), + Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""), + Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]}}}"""), + Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""), + Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}""") + ]; + return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal); + } + + private static InternalToolDefinition Define(string name, string description, string schema) + { + using JsonDocument document = JsonDocument.Parse(schema); + return new(name, description, document.RootElement.Clone()); + } +} diff --git a/src/Agent.Core/Tools/InternalToolDefinition.cs b/src/Agent.Core/Tools/InternalToolDefinition.cs new file mode 100644 index 0000000..8f06764 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDefinition.cs @@ -0,0 +1,5 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record InternalToolDefinition(string Name, string Description, JsonElement Parameters); diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs new file mode 100644 index 0000000..7302c1f --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalToolBackend backend) +{ + public Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, cancellationToken); + public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => backend.EndTaskAsync(taskId, success, cancellationToken); + + public async Task<ToolExecutionResult> DispatchAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + InternalToolValidator.Validate(toolName, arguments); + ToolPolicyDecision decision = policy.Evaluate(toolName, arguments); + if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}"); + return await backend.ExecuteAsync(taskId, toolName, arguments, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class FakeInternalToolBackend : IInternalToolBackend +{ + private readonly List<(string Name, JsonElement Arguments)> _calls = []; + public IReadOnlyList<(string Name, JsonElement Arguments)> Calls => _calls; + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + _calls.Add((toolName, arguments.Clone())); + bool effect = toolName is "file_edit" or "process_run"; + return Task.FromResult(new ToolExecutionResult(true, "fake-ok", effect)); + } +} diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs new file mode 100644 index 0000000..c48719e --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -0,0 +1,86 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolValidator +{ + public static void Validate(string name, JsonElement arguments) + { + InternalToolDefinition definition = InternalToolCatalog.Get(name); + if (arguments.ValueKind != JsonValueKind.Object) throw new ToolValidationException("Tool arguments must be an object."); + JsonElement properties = definition.Parameters.GetProperty("properties"); + var known = properties.EnumerateObject().Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); + foreach (JsonProperty argument in arguments.EnumerateObject()) + { + if (!known.Contains(argument.Name)) throw new ToolValidationException($"Unknown argument '{argument.Name}'."); + ValidateValue(argument.Name, argument.Value, properties.GetProperty(argument.Name)); + } + foreach (JsonElement required in definition.Parameters.GetProperty("required").EnumerateArray()) + { + string requiredName = required.GetString()!; + if (!arguments.TryGetProperty(requiredName, out _)) throw new ToolValidationException($"Missing required argument '{requiredName}'."); + } + ValidateOperationSpecific(name, arguments); + } + + private static void ValidateValue(string name, JsonElement value, JsonElement schema) + { + if (value.ValueKind == JsonValueKind.Null && AllowsNull(schema)) return; + bool validType = GetTypes(schema).Any(type => type switch + { + "string" => value.ValueKind == JsonValueKind.String, + "integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _), + "array" => value.ValueKind == JsonValueKind.Array, + _ => false + }); + if (!validType) throw new ToolValidationException($"Argument '{name}' has an invalid type."); + if (schema.TryGetProperty("enum", out JsonElement enumValues) && !enumValues.EnumerateArray().Any(item => JsonElement.DeepEquals(item, value))) + throw new ToolValidationException($"Argument '{name}' has an unsupported value."); + if (value.ValueKind == JsonValueKind.Number) + { + long number = value.GetInt64(); + if (schema.TryGetProperty("minimum", out JsonElement minimum) && number < minimum.GetInt64()) throw new ToolValidationException($"Argument '{name}' is below its minimum."); + if (schema.TryGetProperty("maximum", out JsonElement maximum) && number > maximum.GetInt64()) throw new ToolValidationException($"Argument '{name}' exceeds its maximum."); + } + if (value.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out JsonElement items)) + foreach (JsonElement item in value.EnumerateArray()) ValidateValue(name, item, items); + } + + private static IEnumerable<string> GetTypes(JsonElement schema) + { + JsonElement type = schema.GetProperty("type"); + return type.ValueKind == JsonValueKind.Array + ? type.EnumerateArray().Select(static item => item.GetString()!).ToArray() + : [type.GetString()!]; + } + private static bool AllowsNull(JsonElement schema) => GetTypes(schema).Contains("null", StringComparer.Ordinal); + + private static void ValidateOperationSpecific(string name, JsonElement arguments) + { + if (name == "file_read" && arguments.GetProperty("mode").GetString() == "range" && !arguments.TryGetProperty("length", out _)) + throw new ToolValidationException("Range reads require length."); + if (name == "file_edit") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation is "overwrite" or "atomic_replace" && (!arguments.TryGetProperty("content", out JsonElement content) || content.ValueKind != JsonValueKind.String)) + throw new ToolValidationException($"{operation} requires content."); + if (operation == "patch" && (!arguments.TryGetProperty("patch", out JsonElement patch) || patch.ValueKind != JsonValueKind.String)) + throw new ToolValidationException("patch requires patch text."); + } + if (name == "task_update") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation == "add") Require(arguments, "description", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + if (operation == "add_dependency") Require(arguments, "task_id", "depends_on"); + if (operation == "set_state") Require(arguments, "task_id", "state"); + if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + } + + static void Require(JsonElement value, params string[] properties) + { + foreach (string property in properties) + if (!value.TryGetProperty(property, out JsonElement item) || item.ValueKind == JsonValueKind.Null) + throw new ToolValidationException($"{property} is required for this operation."); + } + } +} diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs new file mode 100644 index 0000000..03daa56 --- /dev/null +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Agent.Planner; + +namespace Agent.Core.Tools; + +public interface ITaskUpdateHandler +{ + void BeginTask(Guid taskId); + ToolExecutionResult Execute(Guid taskId, JsonElement arguments); + void EndTask(Guid taskId); +} + +public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler +{ + private readonly ConcurrentDictionary<Guid, TaskGraph> _graphs = new(); + + public void BeginTask(Guid taskId) + { + if (!_graphs.TryAdd(taskId, new TaskGraph(maxTasks))) + throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists."); + } + + public ToolExecutionResult Execute(Guid taskId, JsonElement arguments) + { + if (!_graphs.TryGetValue(taskId, out TaskGraph? graph)) + throw new InvalidOperationException("TaskGraph scope is not active."); + + string operation = arguments.GetProperty("operation").GetString()!; + switch (operation) + { + case "add": + graph.AddTask( + RequiredString(arguments, "description"), + Budget(arguments), + Estimate(arguments), + Ids(arguments, "depends_on"), + id: OptionalId(arguments, "task_id")); + break; + case "add_dependency": + Guid taskIdValue = RequiredId(arguments, "task_id"); + Guid[] dependencies = Ids(arguments, "depends_on"); + if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id."); + foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency); + break; + case "set_state": + graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state"))); + break; + case "set_estimate": + graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments)); + break; + default: + throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); + } + + string output = JsonSerializer.Serialize(new + { + tasks = graph.Tasks.Select(static task => new + { + task_id = task.Id, + description = task.Description, + depends_on = task.Dependencies, + state = task.State.ToString().ToLowerInvariant(), + expected_tokens = task.ExpectedTokens + }), + critical_path = graph.CriticalPath.TaskIds, + critical_path_tokens = graph.CriticalPath.CumulativeTokenCost, + total_token_budget = graph.TotalTokenBudget + }); + return new ToolExecutionResult(true, output, false); + } + + public void EndTask(Guid taskId) => _graphs.TryRemove(taskId, out _); + + private static TokenBudgetClass Budget(JsonElement arguments) => + TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); + + private static TokenEstimate Estimate(JsonElement arguments) => new( + RequiredInt64(arguments, "optimistic_tokens"), + RequiredInt64(arguments, "most_likely_tokens"), + RequiredInt64(arguments, "pessimistic_tokens")); + + private static string RequiredString(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString()) + ? value.GetString()! + : throw new ToolValidationException($"{property} is required for this operation."); + + private static long RequiredInt64(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt64() + : throw new ToolValidationException($"{property} is required for this operation."); + + private static Guid RequiredId(JsonElement arguments, string property) => + OptionalId(arguments, property) ?? throw new ToolValidationException($"{property} must be a UUID for this operation."); + + private static Guid? OptionalId(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value) || value.ValueKind == JsonValueKind.Null) return null; + return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out Guid id) + ? id + : throw new ToolValidationException($"{property} must be a UUID."); + } + + private static Guid[] Ids(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value)) return []; + var ids = new List<Guid>(); + foreach (JsonElement item in value.EnumerateArray()) + ids.Add(Guid.TryParse(item.GetString(), out Guid id) ? id : throw new ToolValidationException($"{property} must contain only UUIDs.")); + return [.. ids]; + } + + private static PlannerTaskState ParseState(string state) => state switch + { + "queued" => PlannerTaskState.Queued, + "planning" => PlannerTaskState.Planning, + "running" => PlannerTaskState.Running, + "completed" => PlannerTaskState.Completed, + "failed" => PlannerTaskState.Failed, + "cancelled" => PlannerTaskState.Cancelled, + _ => throw new ToolValidationException($"Unknown task state '{state}'.") + }; +} diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs new file mode 100644 index 0000000..c9f216a --- /dev/null +++ b/src/Agent.Core/Tools/ToolPolicy.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record ToolPolicyDecision(bool Allowed, string Reason) +{ + public static ToolPolicyDecision Allow() => new(true, "allowed"); + public static ToolPolicyDecision Deny(string reason) => new(false, reason); +} + +public interface IToolPolicyEngine +{ + ToolPolicyDecision Evaluate(string toolName, JsonElement arguments); +} + +public interface IInternalToolBackend +{ + Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => Task.CompletedTask; + Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken); + Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed record ToolExecutionResult(bool Success, string Output, bool ExternalEffect, string? FailureFingerprint = null); + +public sealed class DenyByDefaultPolicy(IReadOnlySet<string>? allowedTools = null) : IToolPolicyEngine +{ + private readonly IReadOnlySet<string> _allowedTools = allowedTools ?? new HashSet<string>(StringComparer.Ordinal); + public ToolPolicyDecision Evaluate(string toolName, JsonElement arguments) => _allowedTools.Contains(toolName) + ? ToolPolicyDecision.Allow() + : ToolPolicyDecision.Deny("Capability is not allowed by policy."); +} diff --git a/src/Agent.Core/Tools/ToolUseGuard.cs b/src/Agent.Core/Tools/ToolUseGuard.cs new file mode 100644 index 0000000..633d34c --- /dev/null +++ b/src/Agent.Core/Tools/ToolUseGuard.cs @@ -0,0 +1,22 @@ +namespace Agent.Core.Tools; + +public enum ToolUseGuardDecision { Complete, Retry, Fail } + +public sealed record ToolUseGuardResult(ToolUseGuardDecision Decision, string Observation, int IdenticalFailureCount); + +public sealed class ToolUseGuard(int sameFailureLimit = 5) +{ + private readonly Dictionary<string, int> _failures = new(StringComparer.Ordinal); + public int SameFailureLimit { get; } = sameFailureLimit > 0 ? sameFailureLimit : throw new ArgumentOutOfRangeException(nameof(sameFailureLimit)); + + public ToolUseGuardResult Evaluate(bool requiresExternalEffect, bool hadToolCall, bool hasExternalEffectEvidence, string? failureFingerprint = null) + { + if (!requiresExternalEffect) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + if (hasExternalEffectEvidence) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + string fingerprint = string.IsNullOrWhiteSpace(failureFingerprint) ? "missing-external-effect" : failureFingerprint; + int count = _failures.TryGetValue(fingerprint, out int prior) ? prior + 1 : 1; + _failures[fingerprint] = count; + if (count >= SameFailureLimit) return new(ToolUseGuardDecision.Fail, "Required external effect was not completed after repeated identical failures.", count); + return new(ToolUseGuardDecision.Retry, "No verified external effect occurred; perform the required operation using an internal tool.", count); + } +} diff --git a/src/Agent.Core/Tools/ToolValidationException.cs b/src/Agent.Core/Tools/ToolValidationException.cs new file mode 100644 index 0000000..a99472c --- /dev/null +++ b/src/Agent.Core/Tools/ToolValidationException.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Tools; + +public sealed class ToolValidationException(string message) : InvalidOperationException(message); diff --git a/src/Agent.Mcp/Agent.Mcp.csproj b/src/Agent.Mcp/Agent.Mcp.csproj new file mode 100644 index 0000000..5b09556 --- /dev/null +++ b/src/Agent.Mcp/Agent.Mcp.csproj @@ -0,0 +1,12 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <ItemGroup> + <ProjectReference Include="..\Agent.Core\Agent.Core.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="ModelContextProtocol" /> + <PackageReference Include="ModelContextProtocol.AspNetCore" /> + </ItemGroup> + <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> + </ItemGroup> +</Project> diff --git a/src/Agent.Mcp/AgentTools.cs b/src/Agent.Mcp/AgentTools.cs new file mode 100644 index 0000000..c44d6ec --- /dev/null +++ b/src/Agent.Mcp/AgentTools.cs @@ -0,0 +1,90 @@ +using System.ComponentModel; +using Agent.Core.Localization; +using Agent.Core.Tasks; +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; + +namespace Agent.Mcp; + +[McpServerToolType] +public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits) +{ + [McpServerTool(Name = "agent_run", ReadOnly = false, Destructive = true, OpenWorld = false, UseStructuredContent = false)] + [Description("Queue a natural-language request for asynchronous execution.")] + public string AgentRun( + RequestContext<CallToolRequestParams> context, + [Description("Non-empty natural-language work request.")] string request, + [Description("Existing session UUID, or omit to create a session.")] Guid? session_id = null, + [Description("Task-scoped temperature from 0.0 through 2.0.")] double? temperature = null, + [Description("Optional PNG, JPEG, or WebP images.")] IReadOnlyList<McpImageInput>? images = null) + { + RequireOnly(context, "request", "session_id", "temperature", "images"); + ValidateImages(images); + TaskSnapshot task = registry.Enqueue(request, session_id, temperature, images?.Select(static image => image.DataBase64).ToArray()); + string message = locale.Format("mcp.task_queued", new Dictionary<string, object?> + { + ["task"] = task.TaskId, + ["session"] = task.SessionId + }); + return string.Join('\n', + $"task_id: {task.TaskId}", + $"session_id: {task.SessionId}", + "status: queued", + $"message: {message}"); + } + + private void ValidateImages(IReadOnlyList<McpImageInput>? images) + { + if (images is null) return; + if (images.Count > visionLimits.MaxCount) throw new ArgumentException($"Image count exceeds configured limit {visionLimits.MaxCount}."); + long total = 0; + foreach (McpImageInput image in images) + { + if (image.MimeType is not ("image/png" or "image/jpeg" or "image/webp")) throw new ArgumentException($"Unsupported image MIME type '{image.MimeType}'."); + byte[] bytes; + try { bytes = Convert.FromBase64String(image.DataBase64); } + catch (FormatException ex) { throw new ArgumentException("Image data_base64 is invalid.", ex); } + if (bytes.LongLength > visionLimits.MaxBytesEach) throw new ArgumentException($"Image exceeds configured per-image limit {visionLimits.MaxBytesEach} bytes."); + total = checked(total + bytes.LongLength); + if (total > visionLimits.MaxTotalBytes) throw new ArgumentException($"Images exceed configured total limit {visionLimits.MaxTotalBytes} bytes."); + } + } + + [McpServerTool(Name = "agent_status", ReadOnly = true, Destructive = false, OpenWorld = false, UseStructuredContent = false)] + [Description("Get the current state and result of a queued agent task.")] + public string AgentStatus( + RequestContext<CallToolRequestParams> context, + [Description("Task UUID returned by agent_run.")] Guid task_id, + [Description("Optional session UUID used to verify task ownership.")] Guid? session_id = null) + { + RequireOnly(context, "task_id", "session_id"); + if (!registry.TryGet(task_id, session_id, out TaskSnapshot? task) || task is null) + { + return string.Join('\n', + $"task_id: {task_id}", + "status: failed", + $"message: {locale.Format("mcp.task_not_found", new Dictionary<string, object?> { ["task"] = task_id })}"); + } + + string state = task.State.ToString().ToLowerInvariant(); + bool terminal = task.State is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled; + return string.Join('\n', + $"task_id: {task.TaskId}", + $"session_id: {task.SessionId}", + $"status: {state}", + $"completed_tasks: {task.CompletedTasks}/{task.TotalTasks}", + $"current_task: {task.CurrentTask ?? string.Empty}", + $"token_usage: input={task.InputTokens} output={task.OutputTokens} total={task.InputTokens + task.OutputTokens}", + $"critical_path_tokens: {task.CriticalPathTokens.ToString(System.Globalization.CultureInfo.InvariantCulture)}", + $"message: {locale.Get("mcp.status." + state)}", + terminal ? $"result: {task.Result}" : "result:"); + } + + private static void RequireOnly(RequestContext<CallToolRequestParams> context, params string[] allowed) + { + if (context.Params?.Arguments is null) return; + HashSet<string> allowedSet = allowed.ToHashSet(StringComparer.Ordinal); + string? unknown = context.Params.Arguments.Keys.FirstOrDefault(key => !allowedSet.Contains(key)); + if (unknown is not null) throw new ArgumentException($"Unknown tool property '{unknown}'."); + } +} diff --git a/src/Agent.Mcp/AssemblyMarker.cs b/src/Agent.Mcp/AssemblyMarker.cs new file mode 100644 index 0000000..ef6d87f --- /dev/null +++ b/src/Agent.Mcp/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Mcp; + +public static class AssemblyMarker; diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs new file mode 100644 index 0000000..ff546e0 --- /dev/null +++ b/src/Agent.Mcp/McpHost.cs @@ -0,0 +1,136 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.Json.Nodes; +using System.Reflection; +using Agent.Core.Localization; +using Agent.Core.Tasks; +using Agent.Core.Ollama; +using Agent.Core.Runtime; +using Agent.Core.Tools; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Server; + +namespace Agent.Mcp; + +public static class McpHost +{ + public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = [], + ContentRootPath = AppContext.BaseDirectory + }); + builder.Logging.ClearProviders(); + var registry = new TaskRegistry(); + using var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var ollama = new OllamaClient(httpClient, ollamaSettings); + var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer); + builder.Services.AddSingleton(locale); + builder.Services.AddSingleton(registry); + builder.Services.AddSingleton(runtime); + builder.Services.AddHostedService<QueuedTaskWorker>(); + + McpServerTool[] tools = CreateTools(registry, locale, visionLimits); + + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(tools); + + using IHost host = builder.Build(); + await host.RunAsync(cancellationToken).ConfigureAwait(false); + } + + public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) + { + if (!System.Net.IPAddress.TryParse(hostName, out System.Net.IPAddress? address) || !System.Net.IPAddress.IsLoopback(address)) + throw new ArgumentException("HTTP transport host must be a loopback IP literal.", nameof(hostName)); + WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], ContentRootPath = AppContext.BaseDirectory }); + builder.Logging.ClearProviders(); + string listenHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); + builder.WebHost.UseUrls($"http://{listenHost}:{port}"); + var registry = new TaskRegistry(); + var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer); + builder.Services.AddSingleton(locale); + builder.Services.AddSingleton(registry); + builder.Services.AddSingleton(runtime); + builder.Services.AddHostedService<QueuedTaskWorker>(); + McpServerTool[] tools = CreateTools(registry, locale, visionLimits); + builder.Services.AddMcpServer().WithHttpTransport().WithTools(tools); + await using WebApplication app = builder.Build(); + app.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues origins) && + origins.Any(origin => !IsLoopbackOrigin(origin))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + if (context.Request.Path == "/mcp" && HttpMethods.IsGet(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + await next(context).ConfigureAwait(false); + }); + app.MapMcp("/mcp"); + try { await app.RunAsync(cancellationToken).ConfigureAwait(false); } + finally { httpClient.Dispose(); } + } + + private static bool IsLoopbackOrigin(string? origin) + { + return Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && + (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) || + (System.Net.IPAddress.TryParse(uri.Host, out System.Net.IPAddress? address) && System.Net.IPAddress.IsLoopback(address))); + } + + private static McpServerTool[] CreateTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits) + { + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + var target = new AgentTools(registry, locale, visionLimits); + McpServerTool[] tools = typeof(AgentTools) + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Where(static method => method.GetCustomAttribute<McpServerToolAttribute>() is not null) + .Select(method => McpServerTool.Create(method, target, new McpServerToolCreateOptions { SerializerOptions = serializerOptions })) + .ToArray(); + foreach (McpServerTool tool in tools) MakeSchemaStrict(tool); + return tools; + } + + private static void MakeSchemaStrict(McpServerTool tool) + { + JsonNode root = JsonNode.Parse(tool.ProtocolTool.InputSchema.GetRawText()) + ?? throw new InvalidOperationException($"Tool '{tool.ProtocolTool.Name}' has no input schema."); + Visit(root); + tool.ProtocolTool.InputSchema = JsonSerializer.SerializeToElement(root); + + static void Visit(JsonNode? node) + { + if (node is JsonObject obj) + { + if (obj["type"] is JsonValue type && type.TryGetValue(out string? typeName) && typeName == "object") + obj["additionalProperties"] = false; + foreach (KeyValuePair<string, JsonNode?> property in obj.ToArray()) Visit(property.Value); + } + else if (node is JsonArray array) + { + foreach (JsonNode? item in array) Visit(item); + } + } + } +} diff --git a/src/Agent.Mcp/McpImageInput.cs b/src/Agent.Mcp/McpImageInput.cs new file mode 100644 index 0000000..9c0bd81 --- /dev/null +++ b/src/Agent.Mcp/McpImageInput.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Agent.Mcp; + +public sealed class McpImageInput +{ + [JsonPropertyName("mime_type")] + public required string MimeType { get; init; } + + [JsonPropertyName("data_base64")] + public required string DataBase64 { get; init; } +} diff --git a/src/Agent.Mcp/QueuedTaskWorker.cs b/src/Agent.Mcp/QueuedTaskWorker.cs new file mode 100644 index 0000000..23404e3 --- /dev/null +++ b/src/Agent.Mcp/QueuedTaskWorker.cs @@ -0,0 +1,18 @@ +using Agent.Core.Tasks; +using Microsoft.Extensions.Hosting; +using Agent.Core.Runtime; + +namespace Agent.Mcp; + +public sealed class QueuedTaskWorker(TaskRegistry registry, AgentRuntime runtime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (Guid taskId in registry.ReadQueuedAsync(stoppingToken).ConfigureAwait(false)) + { + if (!registry.Transition(taskId, AgentTaskState.Planning, "planning")) continue; + registry.Transition(taskId, AgentTaskState.Running, "ollama chat"); + await runtime.ExecuteAsync(taskId, stoppingToken).ConfigureAwait(false); + } + } +} diff --git a/src/Agent.Mcp/VisionLimits.cs b/src/Agent.Mcp/VisionLimits.cs new file mode 100644 index 0000000..ad6dfb3 --- /dev/null +++ b/src/Agent.Mcp/VisionLimits.cs @@ -0,0 +1,3 @@ +namespace Agent.Mcp; + +public sealed record VisionLimits(int MaxCount = 4, long MaxBytesEach = 10 * 1024 * 1024, long MaxTotalBytes = 20 * 1024 * 1024); diff --git a/src/Agent.Planner/Agent.Planner.csproj b/src/Agent.Planner/Agent.Planner.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/src/Agent.Planner/Agent.Planner.csproj @@ -0,0 +1 @@ +<Project Sdk="Microsoft.NET.Sdk" /> diff --git a/src/Agent.Planner/AssemblyMarker.cs b/src/Agent.Planner/AssemblyMarker.cs new file mode 100644 index 0000000..e90784d --- /dev/null +++ b/src/Agent.Planner/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Planner; + +public static class AssemblyMarker; diff --git a/src/Agent.Planner/PlannerTask.cs b/src/Agent.Planner/PlannerTask.cs new file mode 100644 index 0000000..edc85c2 --- /dev/null +++ b/src/Agent.Planner/PlannerTask.cs @@ -0,0 +1,18 @@ +namespace Agent.Planner; + +public sealed record PlannerTask( + Guid Id, + string Description, + IReadOnlySet<Guid> Dependencies, + PlannerTaskState State, + TokenBudgetClass BudgetClass, + TokenEstimate Estimate, + long ActualInputTokens, + long ActualOutputTokens, + bool RequiresExternalEffect) +{ + public double ExpectedTokens => Estimate.ExpectedTokens; + public long ActualTotalTokens => checked(ActualInputTokens + ActualOutputTokens); +} + +public sealed record CriticalPathResult(IReadOnlyList<Guid> TaskIds, double CumulativeTokenCost); diff --git a/src/Agent.Planner/PlannerTaskState.cs b/src/Agent.Planner/PlannerTaskState.cs new file mode 100644 index 0000000..d0b79bf --- /dev/null +++ b/src/Agent.Planner/PlannerTaskState.cs @@ -0,0 +1,11 @@ +namespace Agent.Planner; + +public enum PlannerTaskState +{ + Queued, + Planning, + Running, + Completed, + Failed, + Cancelled +} diff --git a/src/Agent.Planner/PlannerValidationException.cs b/src/Agent.Planner/PlannerValidationException.cs new file mode 100644 index 0000000..934d549 --- /dev/null +++ b/src/Agent.Planner/PlannerValidationException.cs @@ -0,0 +1,3 @@ +namespace Agent.Planner; + +public sealed class PlannerValidationException(string message) : InvalidOperationException(message); diff --git a/src/Agent.Planner/TaskGraph.cs b/src/Agent.Planner/TaskGraph.cs new file mode 100644 index 0000000..7a4d45b --- /dev/null +++ b/src/Agent.Planner/TaskGraph.cs @@ -0,0 +1,179 @@ +namespace Agent.Planner; + +public sealed class TaskGraph(int maxTasks = 32) +{ + private readonly Dictionary<Guid, MutableTask> _tasks = []; + private readonly List<Guid> _insertionOrder = []; + private CriticalPathResult _criticalPath = new([], 0); + + public int MaxTasks { get; } = maxTasks > 0 ? maxTasks : throw new ArgumentOutOfRangeException(nameof(maxTasks)); + public int Count => _tasks.Count; + public CriticalPathResult CriticalPath => _criticalPath; + public long TotalTokenBudget => _tasks.Values.Aggregate(0L, static (total, task) => checked(total + task.BudgetClass.MaxTokens())); + public IReadOnlyList<PlannerTask> Tasks => _insertionOrder.Select(id => _tasks[id].Snapshot()).ToArray(); + + public PlannerTask AddTask( + string description, + TokenBudgetClass budgetClass, + TokenEstimate estimate, + IEnumerable<Guid>? dependencies = null, + bool requiresExternalEffect = false, + Guid? id = null) + { + if (string.IsNullOrWhiteSpace(description)) throw new PlannerValidationException("Task description is required."); + if (_tasks.Count >= MaxTasks) throw new PlannerValidationException($"Task limit of {MaxTasks} exceeded."); + estimate.Validate(budgetClass); + Guid taskId = id ?? Guid.NewGuid(); + if (_tasks.ContainsKey(taskId)) throw new PlannerValidationException($"Task '{taskId}' already exists."); + HashSet<Guid> dependencySet = dependencies?.ToHashSet() ?? []; + EnsureDependenciesExist(dependencySet); + if (dependencySet.Contains(taskId)) throw new PlannerValidationException("A task cannot depend on itself."); + + _tasks.Add(taskId, new MutableTask(taskId, description, dependencySet, budgetClass, estimate, requiresExternalEffect)); + _insertionOrder.Add(taskId); + try + { + Recalculate(); + } + catch + { + _tasks.Remove(taskId); + _insertionOrder.Remove(taskId); + throw; + } + return _tasks[taskId].Snapshot(); + } + + public void AddDependency(Guid taskId, Guid dependencyId) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + _ = GetMutable(dependencyId); + if (!task.Dependencies.Add(dependencyId)) return; + try + { + Recalculate(); + } + catch + { + task.Dependencies.Remove(dependencyId); + throw; + } + } + + public void SetEstimate(Guid taskId, TokenBudgetClass budgetClass, TokenEstimate estimate) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + estimate.Validate(budgetClass); + (TokenBudgetClass previousBudget, TokenEstimate previousEstimate) = (task.BudgetClass, task.Estimate); + task.BudgetClass = budgetClass; + task.Estimate = estimate; + try { Recalculate(); } + catch + { + task.BudgetClass = previousBudget; + task.Estimate = previousEstimate; + throw; + } + } + + public void SetState(Guid taskId, PlannerTaskState state) + { + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + task.State = state; + } + + public void AddUsage(Guid taskId, long inputTokens, long outputTokens) + { + if (inputTokens < 0 || outputTokens < 0) throw new ArgumentOutOfRangeException(nameof(inputTokens), "Token usage cannot be negative."); + MutableTask task = GetMutable(taskId); + EnsureMutable(task); + task.ActualInputTokens = checked(task.ActualInputTokens + inputTokens); + task.ActualOutputTokens = checked(task.ActualOutputTokens + outputTokens); + } + + public PlannerTask Get(Guid taskId) => GetMutable(taskId).Snapshot(); + + private void Recalculate() + { + IReadOnlyList<Guid> order = TopologicalOrder(); + var costs = new Dictionary<Guid, double>(); + var predecessors = new Dictionary<Guid, Guid?>(); + foreach (Guid id in order) + { + MutableTask task = _tasks[id]; + Guid? best = null; + double bestCost = 0; + foreach (Guid dependency in task.Dependencies.OrderBy(InsertionIndex)) + { + double candidate = costs[dependency]; + if (best is null || candidate > bestCost) + { + best = dependency; + bestCost = candidate; + } + } + costs[id] = bestCost + task.Estimate.ExpectedTokens; + predecessors[id] = best; + } + if (order.Count == 0) + { + _criticalPath = new([], 0); + return; + } + Guid end = order[0]; + foreach (Guid id in order.Skip(1)) if (costs[id] > costs[end]) end = id; + var path = new List<Guid>(); + for (Guid? current = end; current is not null; current = predecessors[current.Value]) path.Add(current.Value); + path.Reverse(); + _criticalPath = new(path, costs[end]); + } + + private IReadOnlyList<Guid> TopologicalOrder() + { + var indegree = _tasks.ToDictionary(static pair => pair.Key, static pair => pair.Value.Dependencies.Count); + var ready = new SortedSet<int>(_insertionOrder.Select((id, index) => (id, index)).Where(item => indegree[item.id] == 0).Select(static item => item.index)); + var result = new List<Guid>(_tasks.Count); + while (ready.Count > 0) + { + int index = ready.Min; + ready.Remove(index); + Guid id = _insertionOrder[index]; + result.Add(id); + foreach (Guid dependent in _insertionOrder.Where(candidate => _tasks[candidate].Dependencies.Contains(id))) + { + indegree[dependent]--; + if (indegree[dependent] == 0) ready.Add(InsertionIndex(dependent)); + } + } + if (result.Count != _tasks.Count) throw new PlannerValidationException("TaskGraph must be a DAG; the update creates a cycle."); + return result; + } + + private int InsertionIndex(Guid id) => _insertionOrder.IndexOf(id); + private MutableTask GetMutable(Guid id) => _tasks.TryGetValue(id, out MutableTask? task) ? task : throw new KeyNotFoundException($"Task '{id}' was not found."); + private void EnsureDependenciesExist(IEnumerable<Guid> dependencies) + { + foreach (Guid dependency in dependencies) _ = GetMutable(dependency); + } + private static void EnsureMutable(MutableTask task) + { + if (task.State == PlannerTaskState.Completed) throw new PlannerValidationException("Completed tasks are immutable."); + } + + private sealed class MutableTask(Guid id, string description, HashSet<Guid> dependencies, TokenBudgetClass budgetClass, TokenEstimate estimate, bool requiresExternalEffect) + { + public Guid Id { get; } = id; + public string Description { get; } = description; + public HashSet<Guid> Dependencies { get; } = dependencies; + public PlannerTaskState State { get; set; } = PlannerTaskState.Queued; + public TokenBudgetClass BudgetClass { get; set; } = budgetClass; + public TokenEstimate Estimate { get; set; } = estimate; + public long ActualInputTokens { get; set; } + public long ActualOutputTokens { get; set; } + public bool RequiresExternalEffect { get; } = requiresExternalEffect; + public PlannerTask Snapshot() => new(Id, Description, Dependencies.ToHashSet(), State, BudgetClass, Estimate, ActualInputTokens, ActualOutputTokens, RequiresExternalEffect); + } +} diff --git a/src/Agent.Planner/TokenBudgetClass.cs b/src/Agent.Planner/TokenBudgetClass.cs new file mode 100644 index 0000000..35894c7 --- /dev/null +++ b/src/Agent.Planner/TokenBudgetClass.cs @@ -0,0 +1,31 @@ +namespace Agent.Planner; + +public enum TokenBudgetClass +{ + K1 = 1_024, + K2 = 2_048, + K4 = 4_096, + K8 = 8_192, + K16 = 16_384, + K32 = 32_768, + K64 = 65_536, + K128 = 131_072 +} + +public static class TokenBudgetClassExtensions +{ + public static long MaxTokens(this TokenBudgetClass value) => (long)value; + + public static TokenBudgetClass Parse(string value) => value switch + { + "1K" => TokenBudgetClass.K1, + "2K" => TokenBudgetClass.K2, + "4K" => TokenBudgetClass.K4, + "8K" => TokenBudgetClass.K8, + "16K" => TokenBudgetClass.K16, + "32K" => TokenBudgetClass.K32, + "64K" => TokenBudgetClass.K64, + "128K" => TokenBudgetClass.K128, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown token budget class.") + }; +} diff --git a/src/Agent.Planner/TokenEstimate.cs b/src/Agent.Planner/TokenEstimate.cs new file mode 100644 index 0000000..4b8920a --- /dev/null +++ b/src/Agent.Planner/TokenEstimate.cs @@ -0,0 +1,15 @@ +namespace Agent.Planner; + +public readonly record struct TokenEstimate(long Optimistic, long MostLikely, long Pessimistic) +{ + public double ExpectedTokens => (Optimistic + (4d * MostLikely) + Pessimistic) / 6d; + public double VarianceTokens => Math.Pow((Pessimistic - Optimistic) / 6d, 2); + + public void Validate(TokenBudgetClass budgetClass) + { + if (Optimistic <= 0 || Optimistic > MostLikely || MostLikely > Pessimistic || Pessimistic > budgetClass.MaxTokens()) + { + throw new PlannerValidationException("Estimate must satisfy 0 < O <= M <= P <= token budget."); + } + } +} diff --git a/src/Agent.Windows/Agent.Windows.csproj b/src/Agent.Windows/Agent.Windows.csproj new file mode 100644 index 0000000..ff2f299 --- /dev/null +++ b/src/Agent.Windows/Agent.Windows.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> + <CompilerGeneratedFilesOutputPath>$(IntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\Agent.Core\Agent.Core.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" /> + </ItemGroup> + <ItemGroup> + <InternalsVisibleTo Include="Agent.Windows.Tests" /> + </ItemGroup> +</Project> diff --git a/src/Agent.Windows/AssemblyMarker.cs b/src/Agent.Windows/AssemblyMarker.cs new file mode 100644 index 0000000..7426ca0 --- /dev/null +++ b/src/Agent.Windows/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Windows; + +public static class AssemblyMarker; diff --git a/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs new file mode 100644 index 0000000..b8ca0ae --- /dev/null +++ b/src/Agent.Windows/Diagnostics/WindowsPlatformCheck.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using System.Security.Principal; +using Agent.Windows.Network; +using Agent.Windows.Security; + +namespace Agent.Windows.Diagnostics; + +public sealed record WindowsPlatformCheckResult( + bool IsAdministrator, + bool BfeEngineOpen, + bool WfpWriteTransaction, + bool WfpFilterAddDelete, + int? ErrorCode, + string? ErrorOperation) +{ + public bool Success => IsAdministrator && BfeEngineOpen && WfpWriteTransaction && WfpFilterAddDelete; +} + +public static class WindowsPlatformCheck +{ + public static WindowsPlatformCheckResult Run() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + bool administrator = new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + bool open = false; + bool transaction = false; + bool filters = false; + try + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using WfpDynamicSession session = WfpDynamicSession.Open(); + open = true; + session.ProbeWriteTransaction(); + transaction = true; + IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(profile.Sid); + if (ids.Count != 2 || ids.Any(static id => id == 0)) throw new InvalidOperationException("WFP probe did not create both IPv4 and IPv6 filters."); + session.RemovePolicy(); + filters = true; + return new(administrator, open, transaction, filters, null, null); + } + catch (Win32Exception ex) + { + return new(administrator, open, transaction, filters, ex.NativeErrorCode, ex.Message); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or InvalidOperationException or IOException) + { + return new(administrator, open, transaction, filters, ex.HResult, ex.Message); + } + } +} diff --git a/src/Agent.Windows/NativeMethods.txt b/src/Agent.Windows/NativeMethods.txt new file mode 100644 index 0000000..471b47d --- /dev/null +++ b/src/Agent.Windows/NativeMethods.txt @@ -0,0 +1,46 @@ +AssignProcessToJobObject +CloseHandle +CreateJobObject +CreateProcess +DeleteProcThreadAttributeList +GetExitCodeProcess +InitializeProcThreadAttributeList +JOBOBJECT_EXTENDED_LIMIT_INFORMATION +JOB_OBJECT_LIMIT +PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY +PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES +PROC_THREAD_ATTRIBUTE_HANDLE_LIST +ResumeThread +SetHandleInformation +SetInformationJobObject +TerminateJobObject +UpdateProcThreadAttribute +STARTUPINFOEXW +WaitForSingleObject +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionBegin0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionCommit0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionAbort0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerAdd0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmSubLayerDeleteByKey0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterAdd0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmFilterDeleteById0 +FWPM_SESSION0 +FWPM_SESSION_FLAG_DYNAMIC +FWPM_SUBLAYER0 +FWPM_FILTER0 +FWPM_FILTER_CONDITION0 +FWPM_LAYER_ALE_AUTH_CONNECT_V4 +FWPM_LAYER_ALE_AUTH_CONNECT_V6 +FWPM_CONDITION_ALE_PACKAGE_ID +FWPM_CONDITION_IP_REMOTE_ADDRESS +FWPM_CONDITION_IP_REMOTE_PORT +FWP_BYTE_ARRAY16 +FWP_ACTION_TYPE +FWP_MATCH_TYPE +FWP_DATA_TYPE +NetworkIsolationGetAppContainerConfig +NetworkIsolationSetAppContainerConfig +GetProcessHeap +HeapFree diff --git a/src/Agent.Windows/Network/LoopbackExemption.cs b/src/Agent.Windows/Network/LoopbackExemption.cs new file mode 100644 index 0000000..a3c68ba --- /dev/null +++ b/src/Agent.Windows/Network/LoopbackExemption.cs @@ -0,0 +1,123 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.Memory; + +namespace Agent.Windows.Network; + +internal static unsafe class LoopbackExemption +{ + private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption"; + + public static bool Contains(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + return Read().Any(item => item.Sid.Equals(sid)); + } + + public static bool Ensure(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List<Entry> entries = Read(); + if (entries.Any(item => item.Sid.Equals(sid))) return false; + entries.Add(new(sid, 0)); + Write(entries); + return true; + } + + public static void Remove(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List<Entry> entries = Read(); + if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return; + Write(entries); + } + + private static MutexLease AcquireMutex() + { + var mutex = new Mutex(false, MutexName); + try + { + try + { + if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock."); + } + catch (AbandonedMutexException) + { + // Ownership is granted to this thread when the prior owner died. + } + return new(mutex); + } + catch + { + mutex.Dispose(); + throw; + } + } + + private static List<Entry> Read() + { + uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native); + ThrowIfError(result, "NetworkIsolationGetAppContainerConfig"); + try + { + var entries = new List<Entry>(checked((int)count)); + for (uint index = 0; index < count; index++) + { + SID_AND_ATTRIBUTES item = native[index]; + entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes)); + } + return entries; + } + finally + { + HANDLE heap = PInvoke.GetProcessHeap(); + for (uint index = 0; index < count; index++) + if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value); + if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native); + } + } + + private static void Write(IReadOnlyList<Entry> entries) + { + var native = new SID_AND_ATTRIBUTES[entries.Count]; + var allocations = new List<nint>(entries.Count); + try + { + for (int index = 0; index < entries.Count; index++) + { + byte[] bytes = new byte[entries[index].Sid.BinaryLength]; + entries[index].Sid.GetBinaryForm(bytes, 0); + void* allocation = NativeMemory.Alloc((nuint)bytes.Length); + Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length); + allocations.Add((nint)allocation); + native[index].Sid = new PSID(allocation); + native[index].Attributes = entries[index].Attributes; + } + ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig"); + } + finally + { + foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation); + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}."); + } + + private sealed record Entry(SecurityIdentifier Sid, uint Attributes); + + private sealed class MutexLease(Mutex mutex) : IDisposable + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } +} diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs new file mode 100644 index 0000000..59b2602 --- /dev/null +++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs @@ -0,0 +1,73 @@ +using System.Security.Principal; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Network; + +public sealed class TaskNetworkPolicy : IDisposable +{ + private readonly WfpDynamicSession _session; + private readonly TaskResourceJournal _journal; + private readonly ISecurityAuditSink _audit; + private readonly Guid _entryId; + private readonly SecurityIdentifier _appContainerSid; + private readonly Guid _loopbackEntryId; + private readonly bool _loopbackAdded; + private bool _disposed; + + private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId, + SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded) + { + _session = session; _journal = journal; _audit = audit; _entryId = entryId; + _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded; + } + + public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit) + { + bool originallyExempt = LoopbackExemption.Contains(appContainerSid); + Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value, + new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() }); + bool loopbackAdded = false; + Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string> + { + ["session"] = "dynamic", ["allowances"] = string.Join(',', allowances.Select(static item => $"{item.Address}:{item.Port}")) + }); + WfpDynamicSession session = WfpDynamicSession.Open(); + try + { + loopbackAdded = LoopbackExemption.Ensure(appContainerSid); + journal.MarkApplied(loopbackEntryId, 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, "wfp.apply", "applied", new Dictionary<string, string> + { + ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic" + })); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved", + new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value })); + return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded); + } + catch + { + session.Dispose(); + journal.MarkCleaned(entryId); + if (loopbackAdded) LoopbackExemption.Remove(appContainerSid); + journal.MarkCleaned(loopbackEntryId); + throw; + } + } + + public void Dispose() + { + if (_disposed) return; + _session.RemovePolicy(); + _session.Dispose(); + _journal.MarkCleaned(_entryId); + if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid); + _journal.MarkCleaned(_loopbackEntryId); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" })); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", + new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" })); + _disposed = true; + } +} diff --git a/src/Agent.Windows/Network/WfpDynamicSession.cs b/src/Agent.Windows/Network/WfpDynamicSession.cs new file mode 100644 index 0000000..ef8ba8e --- /dev/null +++ b/src/Agent.Windows/Network/WfpDynamicSession.cs @@ -0,0 +1,208 @@ +using System.ComponentModel; +using System.Buffers.Binary; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.NetworkManagement.WindowsFilteringPlatform; +using Windows.Win32.Security; + +namespace Agent.Windows.Network; + +public sealed record NetworkEndpoint(IPAddress Address, ushort Port); + +public static class NetworkAllowanceSnapshot +{ + public static async Task<IReadOnlyList<NetworkEndpoint>> ResolveAsync(IEnumerable<(string Host, IReadOnlyList<int> Ports)> allowances, CancellationToken cancellationToken) + { + var endpoints = new HashSet<NetworkEndpoint>(); + foreach ((string host, IReadOnlyList<int> ports) in allowances) + { + IPAddress[] addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false); + foreach (int port in ports) + { + if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(allowances), $"Invalid network port {port}."); + foreach (IPAddress address in addresses.Where(static item => item.AddressFamily is System.Net.Sockets.AddressFamily.InterNetwork or System.Net.Sockets.AddressFamily.InterNetworkV6)) + endpoints.Add(new(address, (ushort)port)); + } + } + return endpoints.OrderBy(static item => item.Address.ToString(), StringComparer.Ordinal).ThenBy(static item => item.Port).ToArray(); + } +} + +public sealed unsafe class WfpDynamicSession : IDisposable +{ + private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008; + private readonly FwpmEngineClose0SafeHandle _engine; + private Guid? _subLayerKey; + private readonly List<ulong> _filterIds = []; + + private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine; + public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session") + { + FWPM_SESSION0 session = default; + session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC; + uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine); + ThrowIfError(result, "FwpmEngineOpen0"); + return new(engine); + } + + public void ProbeWriteTransaction() + { + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + result = PInvoke.FwpmTransactionAbort0(_engine); + ThrowIfError(result, "FwpmTransactionAbort0"); + } + + public IReadOnlyList<ulong> ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint>? allowedEndpoints = null) + { + if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session."); + byte[] sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + bool committed = false; + try + { + Guid subLayerKey = Guid.NewGuid(); + fixed (char* subLayerName = "Agent Sandbox MCP task sublayer") + { + FWPM_SUBLAYER0 subLayer = default; + subLayer.subLayerKey = subLayerKey; + subLayer.displayData.name = new PWSTR(subLayerName); + subLayer.weight = 0x7FFF; + ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0"); + } + + fixed (byte* sidPointer = sidBytes) + { + foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer)); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block")); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block")); + } + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0"); + committed = true; + _subLayerKey = subLayerKey; + return _filterIds.ToArray(); + } + finally + { + if (!committed) + { + _filterIds.Clear(); + _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + } + + public void RemovePolicy() + { + if (_subLayerKey is null) return; + ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)"); + bool committed = false; + try + { + foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0"); + Guid subLayerKey = _subLayerKey.Value; + ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0"); + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)"); + committed = true; + _filterIds.Clear(); + _subLayerKey = null; + } + finally + { + if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + + public void Dispose() + { + _engine.Dispose(); + } + + private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName) + { + FWPM_FILTER_CONDITION0 condition = default; + condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID; + condition.conditionValue.sid = sid; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layerKey; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 8; + filter.numFilterConditions = 1; + filter.filterCondition = &condition; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0"); + return id; + } + } + + private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid) + { + FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3]; + conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID; + conditions[0].conditionValue.sid = sid; + conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS; + conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + Guid layer; + FWP_BYTE_ARRAY16 ipv6 = default; + if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32; + // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the + // numeric value is 0x7F000001; parse the network-order octets into + // that value rather than reinterpreting their in-memory byte order. + conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes()); + } + else + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; + ipv6.byteArray16 = endpoint.Address.GetAddressBytes(); + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE; + conditions[1].conditionValue.byteArray16 = &ipv6; + } + conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT; + conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16; + conditions[2].conditionValue.uint16 = endpoint.Port; + string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}"; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layer; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 15; + filter.numFilterConditions = 3; + filter.filterCondition = conditions; + // A plain permit is a soft permit and can be overridden by a block + // from another sub-layer (for example Windows Firewall). The exact + // task allowance must be a hard permit; our higher-weight match in + // this sub-layer still prevents the task default-block from running. + filter.flags = FwpmFilterFlagClearActionRight; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)"); + return id; + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}."); + } +} diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs new file mode 100644 index 0000000..df69a5e --- /dev/null +++ b/src/Agent.Windows/Recovery/RecoveryManager.cs @@ -0,0 +1,114 @@ +using System.Security.AccessControl; +using System.Security.Cryptography; +using Agent.Windows.Security; +using Agent.Windows.Network; + +namespace Agent.Windows.Recovery; + +public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovered, IReadOnlyList<string> Errors) +{ + public bool IsClean => Errors.Count == 0; +} + +public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit) +{ + public RecoveryResult RecoverAll() + { + if (!Directory.Exists(journalDirectory)) return new(0, 0, []); + int journals = 0; + int resources = 0; + var errors = new List<string>(); + foreach (string path in Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal)) + { + journals++; + try + { + TaskResourceJournal journal = TaskResourceJournal.Open(path); + foreach (JournalEntry entry in journal.Entries.Reverse()) + { + if (entry.State == JournalEntryState.Cleaned) continue; + if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry); + CleanupPrepared(entry); + journal.MarkCleaned(entry.Id); + resources++; + } + journal.CompleteAndDelete(); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary<string, string>())); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + errors.Add($"{path}: {ex.Message}"); + } + } + return new(journals, resources, errors); + } + + private static void RecoverEntry(Guid taskId, JournalEntry entry) + { + switch (entry.Kind) + { + case "file.replace": RecoverFile(entry); break; + case "file.delete": RecoverDeletedFile(entry); break; + case "acl": RecoverAcl(entry); break; + case "wfp.dynamic": break; // BFE removes every object when the originating dynamic session ends. + case "network.loopback_exemption": RecoverLoopback(entry); break; + default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'."); + } + } + + private static void RecoverLoopback(JournalEntry entry) + { + if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target)); + } + + private static void RecoverFile(JournalEntry entry) + { + string replacement = entry.Data["replacement_sha256"]; + if (!File.Exists(entry.Target) || !Hash(entry.Target).Equals(replacement, StringComparison.Ordinal)) + throw new InvalidOperationException($"File '{entry.Target}' changed after crash; automatic rollback refused."); + if (bool.Parse(entry.Data["existed"])) + { + byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Recovery backup hash mismatch."); + string temporary = entry.Target + ".recovery.tmp"; + File.WriteAllBytes(temporary, original); + File.Move(temporary, entry.Target, overwrite: true); + } + else File.Delete(entry.Target); + } + + private static void RecoverAcl(JournalEntry entry) + { + byte[] descriptor = Convert.FromBase64String(entry.Data["security_descriptor"]); + FileSystemSecurity security = Directory.Exists(entry.Target) ? new DirectorySecurity() : new FileSecurity(); + security.SetSecurityDescriptorBinaryForm(descriptor, AccessControlSections.Access); + if (security is DirectorySecurity directory) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(entry.Target), directory); + else FileSystemAclExtensions.SetAccessControl(new FileInfo(entry.Target), (FileSecurity)security); + } + + private static void RecoverDeletedFile(JournalEntry entry) + { + if (File.Exists(entry.Target)) throw new InvalidOperationException($"Deleted path '{entry.Target}' was recreated after crash; automatic recovery refused."); + byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Delete recovery backup hash mismatch."); + File.WriteAllBytes(entry.Target, original); + } + + private static void CleanupPrepared(JournalEntry entry) + { + if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"])) + LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target)); + if (entry.Data.TryGetValue("temporary_path", out string? temporary) && temporary.Length > 0) File.Delete(temporary); + if (entry.Data.TryGetValue("backup_path", out string? backup) && backup.Length > 0) + { + File.Delete(backup); + string? parent = System.IO.Path.GetDirectoryName(backup); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); + } + } + private static string Hash(string target) + { + using FileStream stream = File.OpenRead(target); + return Convert.ToHexString(SHA256.HashData(stream)); + } +} diff --git a/src/Agent.Windows/Recovery/TaskResourceJournal.cs b/src/Agent.Windows/Recovery/TaskResourceJournal.cs new file mode 100644 index 0000000..b638041 --- /dev/null +++ b/src/Agent.Windows/Recovery/TaskResourceJournal.cs @@ -0,0 +1,108 @@ +using System.Text.Json; + +namespace Agent.Windows.Recovery; + +public enum JournalEntryState { Prepared, Applied, Cleaned } + +public sealed class JournalEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Kind { get; set; } = string.Empty; + public string Target { get; set; } = string.Empty; + public JournalEntryState State { get; set; } + public Dictionary<string, string> Data { get; set; } = new(StringComparer.Ordinal); +} + +public sealed class JournalDocument +{ + public int SchemaVersion { get; set; } = 1; + public Guid TaskId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public List<JournalEntry> Entries { get; set; } = []; +} + +public sealed class TaskResourceJournal +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private readonly object _sync = new(); + private readonly JournalDocument _document; + + private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; } + public string Path { get; } + public Guid TaskId => _document.TaskId; + public IReadOnlyList<JournalEntry> Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } } + + public static TaskResourceJournal Create(string journalDirectory, Guid taskId) + { + Directory.CreateDirectory(journalDirectory); + string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json"); + if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'."); + var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId }); + journal.Persist(); + return journal; + } + + public static TaskResourceJournal Open(string path) + { + JournalDocument document = JsonSerializer.Deserialize<JournalDocument>(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException($"Journal '{path}' is empty."); + if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid."); + return new(path, document); + } + + public Guid Prepare(string kind, string target, IReadOnlyDictionary<string, string> data) + { + lock (_sync) + { + var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) }; + _document.Entries.Add(entry); + Persist(); + return entry.Id; + } + } + + public void MarkApplied(Guid entryId, IReadOnlyDictionary<string, string>? additionalData = null) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied."); + if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value; + entry.State = JournalEntryState.Applied; + Persist(); + } + } + + public void MarkCleaned(Guid entryId) + { + lock (_sync) + { + JournalEntry entry = Get(entryId); + if (entry.State == JournalEntryState.Cleaned) return; + entry.State = JournalEntryState.Cleaned; + Persist(); + } + } + + public void CompleteAndDelete() + { + lock (_sync) + { + if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources."); + File.Delete(Path); + } + } + + private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found."); + private void Persist() + { + string temporary = Path + ".tmp"; + using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, _document, JsonOptions); + stream.Flush(flushToDisk: true); + } + File.Move(temporary, Path, overwrite: true); + } + private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) }; +} diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs new file mode 100644 index 0000000..52963a0 --- /dev/null +++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; +using System.Text; +using Agent.Windows.Security; + +namespace Agent.Windows.Recovery; + +public sealed class TransactionalFileEditor(TaskResourceJournal journal, string backupRoot, long memoryBackupLimitBytes = 16 * 1024 * 1024) +{ + public FileEditTransaction Replace(string path, string content, string? expectedSha256 = null) => Replace(path, Encoding.UTF8.GetBytes(content), expectedSha256); + + public FileEditTransaction Replace(string path, ReadOnlySpan<byte> content, string? expectedSha256 = null) + { + string canonical = CanonicalPath.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)); + if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal)) + throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256."); + + string backupPath = string.Empty; + string inline = string.Empty; + if (original is not null && original.LongLength > memoryBackupLimitBytes) + { + string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N")); + Directory.CreateDirectory(directory); + backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup"); + File.WriteAllBytes(backupPath, original); + } + else if (original is not null) inline = Convert.ToBase64String(original); + + string temporary = canonical + ".agent-mcp-" + Guid.NewGuid().ToString("N") + ".tmp"; + var data = new Dictionary<string, string> + { + ["existed"] = existed.ToString(), ["original_sha256"] = originalHash, ["original_base64"] = inline, + ["backup_path"] = backupPath, ["temporary_path"] = temporary + }; + Guid entryId = journal.Prepare("file.replace", canonical, data); + File.WriteAllBytes(temporary, content); + string replacementHash = Convert.ToHexString(SHA256.HashData(content)); + File.Move(temporary, canonical, overwrite: true); + journal.MarkApplied(entryId, new Dictionary<string, string> { ["replacement_sha256"] = replacementHash }); + return new(journal, entryId, canonical, originalHash, replacementHash, backupPath, inline, existed); + } + + public FileDeleteTransaction Delete(string path, string? expectedSha256 = null) + { + string canonical = CanonicalPath.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)) + throw new SandboxPolicyException("file.concurrent_change", "File hash does not match expected_sha256."); + string backupPath = string.Empty; + string inline = string.Empty; + if (original.LongLength > memoryBackupLimitBytes) + { + string directory = System.IO.Path.Combine(backupRoot, journal.TaskId.ToString("N")); + Directory.CreateDirectory(directory); + backupPath = System.IO.Path.Combine(directory, Guid.NewGuid().ToString("N") + ".backup"); + File.WriteAllBytes(backupPath, original); + } + else inline = Convert.ToBase64String(original); + Guid entryId = journal.Prepare("file.delete", canonical, new Dictionary<string, string> + { + ["original_sha256"] = originalHash, ["original_base64"] = inline, ["backup_path"] = backupPath + }); + File.Delete(canonical); + journal.MarkApplied(entryId); + return new(journal, entryId, canonical, originalHash, backupPath, inline); + } + + private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); +} + +public sealed class FileDeleteTransaction(TaskResourceJournal journal, Guid entryId, string path, string originalHash, string backupPath, string originalBase64) +{ + private bool _finished; + public void Commit() { if (_finished) return; Cleanup(); journal.MarkCleaned(entryId); _finished = true; } + public void Rollback() + { + if (_finished) return; + if (File.Exists(path)) throw new InvalidOperationException("Rollback refused because the deleted path was recreated."); + byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Delete backup hash verification failed."); + File.WriteAllBytes(path, original); + Cleanup(); journal.MarkCleaned(entryId); _finished = true; + } + private void Cleanup() + { + if (backupPath.Length == 0) return; + File.Delete(backupPath); + string? parent = System.IO.Path.GetDirectoryName(backupPath); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); + } +} + +public sealed class FileEditTransaction( + TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash, + string backupPath, string originalBase64, bool originallyExisted) +{ + private bool _finished; + + public void Commit() + { + if (_finished) return; + DeleteBackup(); + journal.MarkCleaned(entryId); + _finished = true; + } + + public void Rollback() + { + if (_finished) return; + if (!File.Exists(path) || !Hash(path).Equals(replacementHash, StringComparison.Ordinal)) + throw new InvalidOperationException("Rollback refused because the replacement file changed after the transaction."); + if (originallyExisted) + { + byte[] original = backupPath.Length > 0 ? File.ReadAllBytes(backupPath) : Convert.FromBase64String(originalBase64); + if (!Convert.ToHexString(SHA256.HashData(original)).Equals(originalHash, StringComparison.Ordinal)) throw new InvalidDataException("Backup hash verification failed."); + string temporary = path + ".rollback-" + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllBytes(temporary, original); + File.Move(temporary, path, overwrite: true); + } + else File.Delete(path); + DeleteBackup(); + journal.MarkCleaned(entryId); + _finished = true; + } + + private void DeleteBackup() + { + if (backupPath.Length == 0) return; + File.Delete(backupPath); + string? parent = System.IO.Path.GetDirectoryName(backupPath); + if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); + } + private static string Hash(string target) + { + using FileStream stream = File.OpenRead(target); + return Convert.ToHexString(SHA256.HashData(stream)); + } +} diff --git a/src/Agent.Windows/Security/AppContainerProfile.cs b/src/Agent.Windows/Security/AppContainerProfile.cs new file mode 100644 index 0000000..47aa64e --- /dev/null +++ b/src/Agent.Windows/Security/AppContainerProfile.cs @@ -0,0 +1,41 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace Agent.Windows.Security; + +public sealed partial class AppContainerProfile +{ + public const string FixedName = "AgentSandboxMcp.V1"; + private const int AlreadyExists = unchecked((int)0x800700B7); + + private AppContainerProfile(string name, SecurityIdentifier sid) { Name = name; Sid = sid; } + public string Name { get; } + public SecurityIdentifier Sid { get; } + + public static AppContainerProfile EnsureFixed() + { + int result = Native.CreateAppContainerProfile(FixedName, "Agent Sandbox MCP v1", "Fixed sandbox profile for Agent Sandbox MCP", 0, 0, out nint sidPointer); + if (result == AlreadyExists) result = Native.DeriveAppContainerSidFromAppContainerName(FixedName, out sidPointer); + if (result < 0) Marshal.ThrowExceptionForHR(result); + try + { + return new(FixedName, new SecurityIdentifier(sidPointer)); + } + finally + { + if (sidPointer != 0) _ = Native.FreeSid(sidPointer); + } + } + + private static partial class Native + { + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int CreateAppContainerProfile(string name, string displayName, string description, nint capabilities, uint capabilityCount, out nint sid); + + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int DeriveAppContainerSidFromAppContainerName(string name, out nint sid); + + [LibraryImport("advapi32.dll")] + internal static partial nint FreeSid(nint sid); + } +} diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs new file mode 100644 index 0000000..3d20bfa --- /dev/null +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -0,0 +1,51 @@ +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 sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell) +{ + private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary( + approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase); + + public VerifiedExecutable Verify(string executable) + { + string canonical = CanonicalPath.Resolve(executable); + if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled."); + if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", "Executable path is not approved."); + 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."); + return new(canonical, actualHash); + } + + public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments) + { + if (!Path.GetFileName(executable).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 arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null; + } + + public static string ComputeSha256(string executable) + { + using FileStream stream = new(CanonicalPath.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 string NormalizeHash(string hash) + { + string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); + if (normalized.Length != 64 || !normalized.All(Uri.IsHexDigit)) throw new ArgumentException("SHA-256 approval must contain 64 hexadecimal characters.", nameof(hash)); + return normalized; + } +} diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs new file mode 100644 index 0000000..32a949c --- /dev/null +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -0,0 +1,52 @@ +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 class PathPolicy(IEnumerable<FilePolicyEntry> entries) +{ + private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = CanonicalPath.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); + 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}."); + } + + private static bool IsAtOrBelow(string candidate, string root) => + candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || + candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); +} + +public static class CanonicalPath +{ + public static string Resolve(string path, bool requireExisting = true) + { + if (string.IsNullOrWhiteSpace(path)) throw new SandboxPolicyException("path.invalid", "Path is required."); + string fullPath = Path.GetFullPath(path); + string? root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root."); + string current = root; + 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) + throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'."); + } + if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); + return Path.TrimEndingDirectorySeparator(fullPath); + } +} + +public sealed class SandboxPolicyException(string code, string message) : UnauthorizedAccessException(message) +{ + public string Code { get; } = code; +} diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs new file mode 100644 index 0000000..0786150 --- /dev/null +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -0,0 +1,255 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using Microsoft.Win32.SafeHandles; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.JobObjects; +using Windows.Win32.System.Threading; + +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) +{ + private const uint ProcessCreationChildProcessRestricted = 0x1; + private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); + + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null) + { + VerifiedExecutable verified; + VerifiedExecutable? managedEntry = null; + try + { + verified = executablePolicy.Verify(executable); + string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments); + if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath); + } + catch (SandboxPolicyException ex) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable })); + throw; + } + + string cwd = CanonicalPath.Resolve(workingDirectory); + using FileStream stdin = CaptureFile(); + using FileStream stdout = CaptureFile(); + using FileStream stderr = CaptureFile(); + if (!string.IsNullOrEmpty(standardInput)) + { + byte[] input = Encoding.UTF8.GetBytes(standardInput); + stdin.Write(input); + stdin.Flush(true); + } + stdin.Position = 0; + MakeInheritable(stdin.SafeFileHandle); + MakeInheritable(stdout.SafeFileHandle); + MakeInheritable(stderr.SafeFileHandle); + using SafeFileHandle job = PInvoke.CreateJobObject(null, null); + if (job.IsInvalid) throw LastError("CreateJobObject"); + try + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = default; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | + JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_JOB_MEMORY; + limits.BasicLimitInformation.ActiveProcessLimit = _jobLimits.MaxProcesses; + limits.JobMemoryLimit = (nuint)_jobLimits.MaxJobMemoryBytes; + if (!PInvoke.SetInformationJobObject(new HANDLE(job.DangerousGetHandle()), JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION))) + throw LastError("SetInformationJobObject"); + + nuint attributeSize = 0; + uint attributeCount = appContainerSid is null ? 2u : 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; + 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()), + new(stdout.SafeFileHandle.DangerousGetHandle()), + new(stderr.SafeFileHandle.DangerousGetHandle()) + ]; + fixed (HANDLE* inheritedHandlePointer = inheritedHandles) + { + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inheritedHandlePointer, (nuint)(sizeof(HANDLE) * inheritedHandles.Length), null, null)) + throw LastError("UpdateProcThreadAttribute(HandleList)"); + } + SecurityCapabilities securityCapabilities = default; + if (appContainerSid is not null) + { + byte[] sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + 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; + securityCapabilities.Capabilities = (nint)capabilities; + securityCapabilities.CapabilityCount = 2; + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)"); + } + STARTUPINFOEXW startup = default; + startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW); + startup.StartupInfo.dwFlags = STARTUPINFOW_FLAGS.STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inheritedHandles[0]; + startup.StartupInfo.hStdOutput = inheritedHandles[1]; + startup.StartupInfo.hStdError = inheritedHandles[2]; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process = default; + string commandLine = BuildCommandLine(verified.CanonicalPath, 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"); + 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) || + !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."); + } + } + catch (SandboxPolicyException ex) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> + { + ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume" + })); + throw; + } + 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", + ["pid"] = process.dwProcessId.ToString() + }; + if (managedEntry is not null) + { + processAudit["managed_entry_path"] = managedEntry.CanonicalPath; + processAudit["managed_entry_sha256"] = managedEntry.Sha256; + } + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit)); + WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1))); + if (wait == WAIT_EVENT.WAIT_TIMEOUT) + { + _ = PInvoke.TerminateJobObject(job, 1460); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() })); + throw new TimeoutException("Sandbox process exceeded its timeout."); + } + if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess"); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() })); + if (exitCode != 0) + { + 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() + })); + } + return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); + } + finally + { + PInvoke.CloseHandle(process.hThread); + PInvoke.CloseHandle(process.hProcess); + } + } + finally + { + if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes); + NativeMemory.Free(sidBuffer); + NativeMemory.Free(internetClientSidBuffer); + NativeMemory.Free(privateNetworkSidBuffer); + NativeMemory.Free(attributes.Value); + } + } + finally { } + } + + private static Win32Exception LastError(string operation) + { + int code = Marshal.GetLastWin32Error(); + return new(code, $"{operation} failed with Win32 error {code}."); + } + private static FileStream CaptureFile() + { + string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp"); + return new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, 4096, + FileOptions.DeleteOnClose | FileOptions.SequentialScan); + } + private static void* AllocateSid(SecurityIdentifier sid) + { + byte[] bytes = new byte[sid.BinaryLength]; + sid.GetBinaryForm(bytes, 0); + void* buffer = NativeMemory.Alloc((nuint)bytes.Length); + Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length); + return buffer; + } + private static void MakeInheritable(SafeFileHandle handle) + { + if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT)) + throw LastError("SetHandleInformation"); + } + private static string ReadCapture(FileStream stream) + { + const int maxCharacters = 1024 * 1024; + stream.Position = 0; + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + char[] buffer = new char[maxCharacters + 1]; + int count = reader.ReadBlock(buffer, 0, buffer.Length); + 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 string Quote(string value) + { + if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value; + var result = new System.Text.StringBuilder().Append('"'); + int slashes = 0; + foreach (char character in value) + { + if (character == '\\') { slashes++; continue; } + if (character == '"') result.Append('\\', (slashes * 2) + 1).Append('"'); + else result.Append('\\', slashes).Append(character); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityCapabilities + { + public nint AppContainerSid; + public nint Capabilities; + public uint CapabilityCount; + public uint Reserved; + } +} diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs new file mode 100644 index 0000000..8858c2b --- /dev/null +++ b/src/Agent.Windows/Security/SecurityAudit.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Agent.Windows.Security; + +public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary<string, string> Data); + +public interface ISecurityAuditSink +{ + void Write(SecurityAuditEvent auditEvent); +} + +public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink +{ + private readonly object _sync = new(); + public void Write(SecurityAuditEvent auditEvent) + { + string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (directory is not null) Directory.CreateDirectory(directory); + string line = JsonSerializer.Serialize(auditEvent); + lock (_sync) File.AppendAllText(path, line + Environment.NewLine); + } +} + +public sealed class MemorySecurityAuditSink : ISecurityAuditSink +{ + private readonly List<SecurityAuditEvent> _events = []; + public IReadOnlyList<SecurityAuditEvent> Events => _events; + public void Write(SecurityAuditEvent auditEvent) => _events.Add(auditEvent); +} diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs new file mode 100644 index 0000000..f01e859 --- /dev/null +++ b/src/Agent.Windows/Security/TaskAclManager.cs @@ -0,0 +1,76 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using Agent.Windows.Recovery; + +namespace Agent.Windows.Security; + +public sealed record AclSnapshot(string Path, byte[] SecurityDescriptor, Guid? JournalEntryId = null); + +public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurityAuditSink audit, TaskResourceJournal? journal = null) +{ + public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities) + { + string canonical = CanonicalPath.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); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string> { ["path"] = canonical, ["sid"] = appContainerSid.Value, ["rights"] = rights.ToString() })); + return new(canonical, original, journalEntry); + } + + public void Restore(Guid taskId, AclSnapshot snapshot) + { + FileSystemSecurity security = Directory.Exists(snapshot.Path) ? new DirectorySecurity() : new FileSecurity(); + security.SetSecurityDescriptorBinaryForm(snapshot.SecurityDescriptor, AccessControlSections.Access); + WriteSecurity(snapshot.Path, security); + if (snapshot.JournalEntryId is not null) journal!.MarkCleaned(snapshot.JournalEntryId.Value); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.restore", "restored", new Dictionary<string, string> { ["path"] = snapshot.Path })); + } + + public void NormalizeNewEntry(Guid taskId, string path) + { + string canonical = CanonicalPath.Resolve(path); + FileSystemSecurity security = ReadSecurity(canonical); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)) + .OfType<FileSystemAccessRule>() + .Where(rule => rule.IdentityReference == appContainerSid) + .ToArray()) + security.RemoveAccessRuleSpecific(rule); + security.SetAccessRuleProtection(isProtected: false, preserveInheritance: false); + WriteSecurity(canonical, security); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.normalize", "normalized", new Dictionary<string, string> { ["path"] = canonical })); + } + + private static FileSystemSecurity ReadSecurity(string path) => Directory.Exists(path) + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); + + private static void WriteSecurity(string path, FileSystemSecurity security) + { + if (security is DirectorySecurity directorySecurity) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), directorySecurity); + else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); + } + + private static FileSystemRights ToRights(FileCapability capabilities) + { + FileSystemRights rights = 0; + if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory; + if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write; + if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute; + if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles; + return rights; + } +} diff --git a/src/Agent.Windows/Security/UserOnlyDirectory.cs b/src/Agent.Windows/Security/UserOnlyDirectory.cs new file mode 100644 index 0000000..a9ae5ae --- /dev/null +++ b/src/Agent.Windows/Security/UserOnlyDirectory.cs @@ -0,0 +1,20 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +namespace Agent.Windows.Security; + +public static class UserOnlyDirectory +{ + public static void Ensure(string path) + { + Directory.CreateDirectory(path); + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity.User ?? throw new InvalidOperationException("Current Windows identity has no user SID."); + var security = new DirectorySecurity(); + security.SetOwner(user); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), security); + } +} diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs new file mode 100644 index 0000000..a21544b --- /dev/null +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -0,0 +1,257 @@ +using System.Collections.Concurrent; +using System.IO.Enumeration; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Agent.Core.Tools; +using Agent.Windows.Network; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Tools; + +public sealed record WindowsSandboxSettings( + IReadOnlyList<FilePolicyEntry> Files, + IReadOnlyList<ExecutableApproval> Executables, + bool AllowShell, + SandboxJobLimits JobLimits, + string NetworkMode, + IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances, + string JournalDirectory, + string BackupDirectory, + long MemoryBackupLimitBytes, + long WholeReadLimitBytes, + int SearchResultLimit, + int TailLineLimit); + +public sealed class WindowsToolBackend(WindowsSandboxSettings 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 ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler(); + + public async Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) + { + 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); + var acl = new TaskAclManager(profile.Sid, audit, journal); + var context = new TaskContext(journal, acl); + _taskUpdates.BeginTask(taskId); + 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)))) + { + // 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. + if (File.Exists(path) && capabilities == FileCapability.Execute) continue; + context.Roots.Add(path); + foreach (string target in ExistingAclTargets(path)) context.Acls.Add(acl.Grant(taskId, target, capabilities)); + } + IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkMode == "allow" + ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false) + : []; + context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit); + if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists."); + } + catch + { + try { await CleanupAsync(taskId, context).ConfigureAwait(false); } + finally { _taskUpdates.EndTask(taskId); } + throw; + } + } + + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + TaskContext context = Get(taskId); + return toolName switch + { + "file_search" => Task.FromResult(Search(arguments)), + "file_read" => Task.FromResult(Read(arguments)), + "file_edit" => Task.FromResult(Edit(context, arguments)), + "process_run" => Task.FromResult(RunProcess(taskId, arguments)), + "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), + _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") + }; + } + + public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + if (!_contexts.TryRemove(taskId, out TaskContext? context)) return; + try { await CleanupAsync(taskId, context).ConfigureAwait(false); } + finally { _taskUpdates.EndTask(taskId); } + } + + private ToolExecutionResult Search(JsonElement arguments) + { + string root = arguments.GetProperty("root").GetString()!; + _paths.Demand(root, FileCapability.Read); + string canonical = CanonicalPath.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; + max = Math.Min(max, settings.SearchResultLimit); + if (mode == "tail") + { + int lines = arguments.TryGetProperty("tail_lines", out JsonElement lineElement) ? lineElement.GetInt32() : 100; + lines = Math.Min(lines, settings.TailLineLimit); + if (new FileInfo(canonical).Length > settings.WholeReadLimitBytes * 16) throw new IOException("Tail input is too large."); + string[] content = File.ReadLines(canonical).TakeLast(lines).ToArray(); + return Success(JsonSerializer.Serialize(content)); + } + IEnumerable<string> files = File.Exists(canonical) ? [canonical] : Directory.EnumerateFiles(canonical, "*", new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }); + IEnumerable<string> results = mode switch + { + "glob" => files.Where(path => FileSystemName.MatchesSimpleExpression(query, Path.GetRelativePath(canonical, path), ignoreCase: true)), + "name" => files.Where(path => Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase)), + "text" => files.Where(path => IsTextMatch(path, text => text.Contains(query, StringComparison.Ordinal))), + "regex" => SearchRegex(files, query), + _ => throw new ToolValidationException($"Unsupported search mode '{mode}'.") + }; + return Success(JsonSerializer.Serialize(results.Take(max).Select(Path.GetFullPath).ToArray())); + } + + private ToolExecutionResult Read(JsonElement arguments) + { + string path = arguments.GetProperty("path").GetString()!; + _paths.Demand(path, FileCapability.Read); + string canonical = CanonicalPath.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 == "whole") + { + if (info.Length > settings.WholeReadLimitBytes) throw new IOException("Whole read exceeds configured limit; use range mode."); + return Success(File.ReadAllText(canonical)); + } + long start = arguments.TryGetProperty("start", out JsonElement startElement) ? startElement.GetInt64() : 0; + int length = arguments.GetProperty("length").GetInt32(); + if (length > settings.WholeReadLimitBytes) throw new IOException("Range length exceeds configured limit."); + using FileStream stream = File.OpenRead(canonical); + stream.Position = start; + byte[] buffer = new byte[Math.Min(length, checked((int)Math.Max(0, stream.Length - start)))]; + _ = stream.Read(buffer); + return Success(Encoding.UTF8.GetString(buffer)); + } + + private ToolExecutionResult Edit(TaskContext context, JsonElement arguments) + { + string path = arguments.GetProperty("path").GetString()!; + string operation = arguments.GetProperty("operation").GetString()!; + string? expected = arguments.TryGetProperty("expected_sha256", out JsonElement expectedElement) && expectedElement.ValueKind == JsonValueKind.String ? expectedElement.GetString() : null; + var editor = new TransactionalFileEditor(context.Journal, settings.BackupDirectory, settings.MemoryBackupLimitBytes); + if (operation == "delete") + { + _paths.Demand(path, FileCapability.Delete); + FileDeleteTransaction transaction = editor.Delete(path, expected); + transaction.Commit(); + } + else + { + _paths.Demand(path, FileCapability.Write); + string replacement = operation == "patch" + ? ApplyPatch(path, arguments.GetProperty("patch").GetString()!) + : arguments.GetProperty("content").GetString()!; + FileEditTransaction transaction = editor.Replace(path, replacement, expected); + transaction.Commit(); + } + audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary<string, string> { ["path"] = Path.GetFullPath(path) })); + return Success($"{operation} completed for {Path.GetFullPath(path)}", externalEffect: true); + } + + private ToolExecutionResult RunProcess(Guid taskId, JsonElement arguments) + { + 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); + if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute); + int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : 300; + 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); + 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}"); + } + + private async Task CleanupAsync(Guid taskId, TaskContext context) + { + List<Exception> failures = []; + try { context.Network?.Dispose(); } catch (Exception ex) { failures.Add(ex); } + foreach (AclSnapshot snapshot in context.Acls.AsEnumerable().Reverse()) try { context.Acl.Restore(taskId, snapshot); } catch (Exception ex) { failures.Add(ex); } + var existingSnapshots = context.Acls.Select(static snapshot => snapshot.Path).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (string entry in context.Roots.SelectMany(ExistingAclTargets).Distinct(StringComparer.OrdinalIgnoreCase) + .Where(entry => !existingSnapshots.Contains(entry)) + .OrderBy(static entry => entry.Count(character => character == Path.DirectorySeparatorChar))) + try { context.Acl.NormalizeNewEntry(taskId, entry); } catch (Exception ex) { failures.Add(ex); } + if (failures.Count == 0) context.Journal.CompleteAndDelete(); + await Task.CompletedTask; + if (failures.Count > 0) throw new AggregateException("Task resource cleanup failed.", failures); + } + + private TaskContext Get(Guid taskId) => _contexts.TryGetValue(taskId, out TaskContext? context) ? context : throw new InvalidOperationException("Task Resource scope is not active."); + private static ToolExecutionResult Success(string output, bool externalEffect = false) => new(true, output, externalEffect); + private static bool IsTextMatch(string path, Func<string, bool> predicate) + { + try { return new FileInfo(path).Length <= 1024 * 1024 && predicate(File.ReadAllText(path)); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException) { return false; } + } + private static IEnumerable<string> SearchRegex(IEnumerable<string> files, string query) + { + var regex = new Regex(query, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + return files.Where(path => IsTextMatch(path, regex.IsMatch)); + } + private static IEnumerable<string> ExistingAclTargets(string path) + { + if (File.Exists(path)) + { + yield return path; + yield break; + } + if (!Directory.Exists(path)) yield break; + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint + }; + foreach (string entry in Directory.EnumerateFileSystemEntries(path, "*", options) + .OrderByDescending(static entry => entry.Count(character => character == Path.DirectorySeparatorChar))) + yield return entry; + yield return path; + } + private static string ApplyPatch(string path, string patch) + { + string current = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + using JsonDocument document = JsonDocument.Parse(patch); + string oldText = document.RootElement.GetProperty("old_text").GetString()!; + string newText = document.RootElement.GetProperty("new_text").GetString()!; + int first = current.IndexOf(oldText, StringComparison.Ordinal); + if (first < 0 || current.IndexOf(oldText, first + oldText.Length, StringComparison.Ordinal) >= 0) throw new InvalidOperationException("Patch old_text must match exactly once."); + return current[..first] + newText + current[(first + oldText.Length)..]; + } + + private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl) + { + public TaskResourceJournal Journal { get; } = journal; + public TaskAclManager Acl { get; } = acl; + public List<AclSnapshot> Acls { get; } = []; + public HashSet<string> Roots { get; } = new(StringComparer.OrdinalIgnoreCase); + public TaskNetworkPolicy? Network { get; set; } + } +} diff --git a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj new file mode 100644 index 0000000..1326733 --- /dev/null +++ b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\Agent.Core\Agent.Core.csproj" /> + <ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit.v3" /> + </ItemGroup> +</Project> diff --git a/tests/Agent.Core.Tests/BaselineTests.cs b/tests/Agent.Core.Tests/BaselineTests.cs new file mode 100644 index 0000000..a9b8fb7 --- /dev/null +++ b/tests/Agent.Core.Tests/BaselineTests.cs @@ -0,0 +1,10 @@ +namespace Agent.Core.Tests; + +public sealed class BaselineTests +{ + [Fact] + public void CoreAssemblyLoads() + { + Assert.Equal("Agent.Core", typeof(Core.AssemblyMarker).Assembly.GetName().Name); + } +} diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs new file mode 100644 index 0000000..7ef4afb --- /dev/null +++ b/tests/Agent.Core.Tests/ConfigurationTests.cs @@ -0,0 +1,66 @@ +using Agent.App.Cli; +using Agent.App.Configuration; + +namespace Agent.Core.Tests; + +public sealed class ConfigurationTests +{ + [Fact] + public void ValidYamlLoadsTypedConfiguration() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + AgentConfiguration result = ConfigurationLoader.Load(config); + Assert.Equal(1, result.SchemaVersion); + Assert.Equal("model", result.Ollama.Model); + Assert.Equal("deny", result.Sandbox.Network.Mode); + } + + [Fact] + public void UnknownYamlPropertyIsFatal() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + System.IO.File.AppendAllText(config, "unknown_root: true\n"); + Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); + } + + [Fact] + public void InvalidSchemaIsFatal() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + System.IO.File.WriteAllText(config, System.IO.File.ReadAllText(config).Replace("schema_version: 1", "schema_version: 2", StringComparison.Ordinal)); + Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); + } + + [Fact] + public void CommandLineLanguageOverrideIsCaptured() + { + CommandLine result = CommandLine.Parse(["serve", "--lang", "ja", "--transport", "http"]); + Assert.Equal("ja", result.Language); + Assert.Equal("http", result.Transport); + } + + private static string CreateValidConfiguration(TestDirectory directory) + { + directory.File("prompts/user-system.md", string.Empty); + Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "config/locales")); + Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills")); + return directory.File("agent.yaml", """ + schema_version: 1 + application: + language: en + agent: + user_system_prompt: prompts/user-system.md + ollama: + endpoint: http://127.0.0.1:11434 + model: model + localization: + directory: config/locales + fallback: en + skills: + directory: skills + """); + } +} diff --git a/tests/Agent.Core.Tests/FixedPromptTests.cs b/tests/Agent.Core.Tests/FixedPromptTests.cs new file mode 100644 index 0000000..93ec7be --- /dev/null +++ b/tests/Agent.Core.Tests/FixedPromptTests.cs @@ -0,0 +1,14 @@ +using Agent.Core.Prompts; + +namespace Agent.Core.Tests; + +public sealed class FixedPromptTests +{ + [Fact] + public void FixedPromptIsEmbeddedAndNonEmpty() + { + string prompt = FixedPrompt.Load(); + Assert.Contains("SandboxPolicy", prompt, StringComparison.Ordinal); + Assert.DoesNotContain("user-system.md", prompt, StringComparison.Ordinal); + } +} diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs new file mode 100644 index 0000000..1e241ce --- /dev/null +++ b/tests/Agent.Core.Tests/InternalToolTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Tests; + +public sealed class InternalToolTests +{ + [Fact] + public void Catalog_exposes_exactly_five_strict_tool_groups() + { + Assert.Equal(["file_search", "file_read", "file_edit", "process_run", "task_update"], InternalToolCatalog.All.Select(x => x.Name)); + foreach (InternalToolDefinition tool in InternalToolCatalog.All) Assert.False(tool.Parameters.GetProperty("additionalProperties").GetBoolean()); + } + + [Theory] + [InlineData("file_read", "{}")] + [InlineData("file_read", "{\"path\":\"a\",\"mode\":\"whole\",\"unknown\":1}")] + [InlineData("file_read", "{\"path\":\"a\",\"mode\":\"invalid\"}")] + [InlineData("file_search", "{\"root\":\".\",\"mode\":\"text\",\"query\":\"x\",\"max_results\":1001}")] + [InlineData("process_run", "{\"executable\":\"x\",\"args\":\"bad\",\"cwd\":\".\"}")] + [InlineData("task_update", "{\"operation\":\"add\",\"description\":\"missing estimate\"}")] + public void Malformed_arguments_are_rejected(string tool, string json) + { + using JsonDocument document = JsonDocument.Parse(json); + Assert.Throws<ToolValidationException>(() => InternalToolValidator.Validate(tool, document.RootElement)); + } + + [Fact] + public async Task Policy_deny_has_no_backend_side_effect() + { + FakeInternalToolBackend backend = new(); + InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(), backend); + using JsonDocument document = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"changed"}"""); + + ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "file_edit", document.RootElement, TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.False(result.ExternalEffect); + Assert.Empty(backend.Calls); + } + + [Fact] + public async Task Allowed_call_reaches_fake_backend_only_after_validation_and_policy() + { + FakeInternalToolBackend backend = new(); + InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(new HashSet<string>(["file_read"], StringComparer.Ordinal)), backend); + using JsonDocument document = JsonDocument.Parse("""{"path":"x","mode":"whole"}"""); + + ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "file_read", document.RootElement, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Single(backend.Calls); + } + + [Fact] + public void Guard_requires_effect_and_fails_on_fifth_identical_failure() + { + ToolUseGuard guard = new(); + for (int attempt = 1; attempt < 5; attempt++) Assert.Equal(ToolUseGuardDecision.Retry, guard.Evaluate(true, false, false, "same").Decision); + ToolUseGuardResult fifth = guard.Evaluate(true, false, false, "same"); + Assert.Equal(ToolUseGuardDecision.Fail, fifth.Decision); + Assert.Equal(5, fifth.IdenticalFailureCount); + } + + [Fact] + public void Guard_does_not_force_tools_for_pure_analysis() + { + Assert.Equal(ToolUseGuardDecision.Complete, new ToolUseGuard().Evaluate(false, false, false).Decision); + } + + [Fact] + public void Task_update_mutates_the_real_deterministic_graph_without_external_effect_evidence() + { + Guid scope = Guid.NewGuid(); + Guid node = Guid.NewGuid(); + var handler = new TaskGraphToolHandler(); + handler.BeginTask(scope); + using JsonDocument add = JsonDocument.Parse($$"""{"operation":"add","task_id":"{{node}}","description":"Build fixture","depends_on":[],"budget_class":"1K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30}"""); + + ToolExecutionResult result = handler.Execute(scope, add.RootElement); + + Assert.True(result.Success); + Assert.False(result.ExternalEffect); + Assert.Contains(node.ToString(), result.Output, StringComparison.OrdinalIgnoreCase); + handler.EndTask(scope); + Assert.Throws<InvalidOperationException>(() => handler.Execute(scope, add.RootElement)); + } +} diff --git a/tests/Agent.Core.Tests/LocaleCatalogTests.cs b/tests/Agent.Core.Tests/LocaleCatalogTests.cs new file mode 100644 index 0000000..e5b6085 --- /dev/null +++ b/tests/Agent.Core.Tests/LocaleCatalogTests.cs @@ -0,0 +1,54 @@ +using Agent.Core.Localization; + +namespace Agent.Core.Tests; + +public sealed class LocaleCatalogTests +{ + [Fact] + public void MissingSelectedKeyFallsBackToEnglish() + { + using var directory = new TestDirectory(); + directory.File("en.json", """{"hello":"Hello {name}","only_en":"fallback"}"""); + directory.File("ja.json", """{"hello":"こんにちは {name}"}"""); + LocaleCatalog catalog = LocaleCatalog.Load(directory.Path, "ja"); + Assert.Equal("fallback", catalog.Get("only_en")); + Assert.Equal("こんにちは Codex", catalog.Format("hello", new Dictionary<string, object?> { ["name"] = "Codex" })); + } + + [Fact] + public void AdditionalLocaleLoadsWithoutCodeChange() + { + using var directory = new TestDirectory(); + directory.File("en.json", """{"hello":"Hello"}"""); + directory.File("zh-CN.json", """{"hello":"你好"}"""); + Assert.Equal("你好", LocaleCatalog.Load(directory.Path, "zh-CN").Get("hello")); + } + + [Theory] + [InlineData("{broken")] + [InlineData("[]")] + public void MalformedLocaleIsFatal(string json) + { + using var directory = new TestDirectory(); + directory.File("en.json", json); + Assert.Throws<LocaleException>(() => LocaleCatalog.Load(directory.Path, "en")); + } + + [Fact] + public void UnknownKeyIsFatal() + { + using var directory = new TestDirectory(); + directory.File("en.json", """{"known":"value"}"""); + directory.File("ja.json", """{"known":"値","unknown":"x"}"""); + Assert.Throws<LocaleException>(() => LocaleCatalog.Load(directory.Path, "ja")); + } + + [Fact] + public void PlaceholderMismatchIsFatal() + { + using var directory = new TestDirectory(); + directory.File("en.json", """{"hello":"Hello {name}"}"""); + directory.File("ja.json", """{"hello":"こんにちは {user}"}"""); + Assert.Throws<LocaleException>(() => LocaleCatalog.Load(directory.Path, "ja")); + } +} diff --git a/tests/Agent.Core.Tests/McpHttpTests.cs b/tests/Agent.Core.Tests/McpHttpTests.cs new file mode 100644 index 0000000..7e3f1cf --- /dev/null +++ b/tests/Agent.Core.Tests/McpHttpTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Net.Sockets; +using Agent.Core.Localization; +using Agent.Core.Ollama; +using Agent.Core.Runtime; +using Agent.Mcp; +using ModelContextProtocol; +using ModelContextProtocol.Client; + +namespace Agent.Core.Tests; + +public sealed class McpHttpTests +{ + [Fact(Timeout = 30_000)] + public async Task Loopback_streamable_http_works_and_get_sse_legacy_sse_are_rejected() + { + int port = FreePort(); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); + var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>()); + Task server = McpHost.RunHttpAsync(locale, settings, new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "127.0.0.1", port, stop.Token); + using var http = new HttpClient(); + Uri endpoint = new($"http://127.0.0.1:{port}/mcp"); + await WaitUntilListening(http, endpoint, TestContext.Current.CancellationToken); + + var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Name = "http-test", + Endpoint = endpoint, + TransportMode = HttpTransportMode.StreamableHttp + }); + await using (McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken)) + { + IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal)); + } + + using HttpResponseMessage get = await http.GetAsync(endpoint, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.MethodNotAllowed, get.StatusCode); + using var hostileOrigin = new HttpRequestMessage(HttpMethod.Post, endpoint); + hostileOrigin.Headers.Add("Origin", "https://example.invalid"); + using HttpResponseMessage forbidden = await http.SendAsync(hostileOrigin, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); + using var loopbackOrigin = new HttpRequestMessage(HttpMethod.Get, endpoint); + loopbackOrigin.Headers.Add("Origin", $"http://127.0.0.1:{port}"); + using HttpResponseMessage loopbackAllowed = await http.SendAsync(loopbackOrigin, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.MethodNotAllowed, loopbackAllowed.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync(new Uri($"http://127.0.0.1:{port}/sse"), TestContext.Current.CancellationToken)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await http.PostAsync(new Uri($"http://127.0.0.1:{port}/message"), null, TestContext.Current.CancellationToken)).StatusCode); + + await stop.CancelAsync(); + await server; + } + + [Fact] + public async Task Non_loopback_bind_is_rejected_before_server_start() + { + LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); + var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>()); + await Assert.ThrowsAsync<ArgumentException>(() => McpHost.RunHttpAsync(locale, settings, + new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "0.0.0.0", 8080, TestContext.Current.CancellationToken)); + } + + private static int FreePort() + { + TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static async Task WaitUntilListening(HttpClient client, Uri endpoint, CancellationToken cancellationToken) + { + for (int attempt = 0; attempt < 100; attempt++) + { + try + { + using HttpResponseMessage response = await client.GetAsync(endpoint, cancellationToken); + return; + } + catch (HttpRequestException) when (attempt < 99) + { + await Task.Delay(25, cancellationToken); + } + } + } +} diff --git a/tests/Agent.Core.Tests/McpStdioTests.cs b/tests/Agent.Core.Tests/McpStdioTests.cs new file mode 100644 index 0000000..66f4851 --- /dev/null +++ b/tests/Agent.Core.Tests/McpStdioTests.cs @@ -0,0 +1,157 @@ +using Agent.App.Configuration; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Agent.Core.Tests; + +public sealed class McpStdioTests +{ + [Fact(Timeout = 30_000)] + public async Task InitializeListAndCallExposeOnlyTwoStrictTools() + { + using var directory = new TestDirectory(); + await using var ollama = new FakeOllamaServer(); + string configPath = CreateBundleConfiguration(directory, ollama.Endpoint); + string application = typeof(ConfigurationLoader).Assembly.Location; + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "agent-mcp-test", + Command = "dotnet", + Arguments = [application, "serve", "--config", configPath, "--lang", "en"], + WorkingDirectory = directory.Path + }); + + await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal)); + foreach (McpClientTool tool in tools) + { + Assert.False(tool.JsonSchema.GetProperty("additionalProperties").GetBoolean()); + } + + CallToolResult run = await client.CallToolAsync( + "agent_run", + new Dictionary<string, object?> { ["request"] = "deterministic test" }, + cancellationToken: TestContext.Current.CancellationToken); + string text = Assert.IsType<TextContentBlock>(Assert.Single(run.Content)).Text; + Assert.Contains("status: queued", text, StringComparison.Ordinal); + Guid taskId = ReadGuid(text, "task_id"); + Guid sessionId = ReadGuid(text, "session_id"); + + CallToolResult status = await client.CallToolAsync( + "agent_status", + new Dictionary<string, object?> { ["task_id"] = taskId, ["session_id"] = sessionId }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains("task_id:", Assert.IsType<TextContentBlock>(Assert.Single(status.Content)).Text, StringComparison.Ordinal); + + CallToolResult invalid = await client.CallToolAsync( + "agent_run", + new Dictionary<string, object?> { ["request"] = "x", ["model"] = "forbidden" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(invalid.IsError); + + CallToolResult invalidImage = await client.CallToolAsync( + "agent_run", + new Dictionary<string, object?> + { + ["request"] = "x", + ["images"] = new[] { new Dictionary<string, object?> { ["mime_type"] = "image/gif", ["data_base64"] = "AA==" } } + }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(invalidImage.IsError); + } + + private static Guid ReadGuid(string text, string key) + { + string line = text.Split('\n', StringSplitOptions.TrimEntries).Single(value => value.StartsWith(key + ":", StringComparison.Ordinal)); + return Guid.Parse(line[(key.Length + 1)..].Trim()); + } + + private static string CreateBundleConfiguration(TestDirectory directory, Uri endpoint) + { + directory.File("prompts/user-system.md", string.Empty); + directory.File("config/locales/en.json", """{"app.starting":"Starting","check.success":"OK","check.failure":"Failed {message}","check.wfp_admin_required":"Administrator required","config.invalid":"Invalid {message}","locale.invalid":"Locale {message}","mcp.task_queued":"Task {task} queued in {session}","mcp.task_not_found":"Task {task} not found","sandbox.access_denied":"Denied {path}","ollama.vision_unsupported":"No vision"}"""); + Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills")); + return directory.File("agent.yaml", $$""" + schema_version: 1 + application: + language: en + mcp: + transport: stdio + agent: + user_system_prompt: prompts/user-system.md + ollama: + endpoint: {{endpoint}} + model: model + localization: + directory: config/locales + fallback: en + skills: + directory: skills + logging: + directory: logs + """); + } + + private sealed class FakeOllamaServer : IAsyncDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _loop; + + public FakeOllamaServer() + { + _listener.Start(); + Endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/"); + _loop = RunAsync(); + } + + public Uri Endpoint { get; } + + private async Task RunAsync() + { + try + { + while (!_stop.IsCancellationRequested) + { + using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token); + await HandleAsync(client, _stop.Token); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } + } + + private static async Task HandleAsync(TcpClient client, CancellationToken cancellationToken) + { + NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, true); + int contentLength = 0; + while (await reader.ReadLineAsync(cancellationToken) is { } line && line.Length > 0) + { + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + contentLength = int.Parse(line[15..].Trim(), System.Globalization.CultureInfo.InvariantCulture); + } + if (contentLength > 0) + { + char[] body = new char[contentLength]; + await reader.ReadBlockAsync(body.AsMemory(), cancellationToken); + } + byte[] payload = Encoding.UTF8.GetBytes("""{"message":{"role":"assistant","content":"done"},"prompt_eval_count":3,"eval_count":2}"""); + byte[] header = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {payload.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(payload, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + _listener.Stop(); + await _loop; + _stop.Dispose(); + } + } +} diff --git a/tests/Agent.Core.Tests/OllamaClientTests.cs b/tests/Agent.Core.Tests/OllamaClientTests.cs new file mode 100644 index 0000000..aa98764 --- /dev/null +++ b/tests/Agent.Core.Tests/OllamaClientTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Agent.App.Configuration; +using Agent.Core.Ollama; +using Agent.Core.Tools; + +namespace Agent.Core.Tests; + +public sealed class OllamaClientTests +{ + [Fact] + public async Task Chat_sends_expected_request_and_returns_usage() + { + string? body = null; + var handler = new StubHandler(async (request, _) => + { + body = await request.Content!.ReadAsStringAsync(); + return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"done"},"prompt_eval_count":12,"eval_count":7}"""); + }); + OllamaClient client = Create(handler, options: new Dictionary<string, JsonElement> + { + ["temperature"] = JsonSerializer.SerializeToElement(0.2), + ["seed"] = JsonSerializer.SerializeToElement(42) + }); + + OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], 0.7, TestContext.Current.CancellationToken); + + Assert.Equal("done", result.Content); + Assert.Equal(12, result.InputTokens); + Assert.Equal(7, result.OutputTokens); + using JsonDocument document = JsonDocument.Parse(body!); + JsonElement root = document.RootElement; + Assert.Equal("test-model", root.GetProperty("model").GetString()); + Assert.False(root.GetProperty("stream").GetBoolean()); + Assert.Equal(0.7, root.GetProperty("options").GetProperty("temperature").GetDouble()); + Assert.Equal(42, root.GetProperty("options").GetProperty("seed").GetInt32()); + } + + [Fact] + public async Task Transient_failure_retries_three_times_with_configured_backoff() + { + int attempts = 0; + var delays = new List<TimeSpan>(); + var handler = new StubHandler((_, _) => + { + attempts++; + return Task.FromResult(attempts < 4 + ? Json(HttpStatusCode.ServiceUnavailable, "busy") + : Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"ok"}}""")); + }); + var client = new OllamaClient(new HttpClient(handler), Settings(), (delay, _) => + { + delays.Add(delay); + return Task.CompletedTask; + }); + + OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken); + + Assert.Equal("ok", result.Content); + Assert.Equal(4, attempts); + Assert.Equal([TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(1000)], delays); + } + + [Fact] + public async Task Permanent_failure_is_not_retried() + { + int attempts = 0; + var handler = new StubHandler((_, _) => + { + attempts++; + return Task.FromResult(Json(HttpStatusCode.BadRequest, "invalid")); + }); + OllamaClient client = Create(handler); + + OllamaException error = await Assert.ThrowsAsync<OllamaException>(() => + client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken)); + + Assert.False(error.IsTransient); + Assert.Equal(1, attempts); + } + + [Fact] + public void Typed_options_override_extra_options() + { + var configuration = new OllamaConfiguration + { + Endpoint = "http://127.0.0.1:11434", + Model = "model", + Options = new OllamaGenerationOptions { Temperature = 0.4, NumCtx = 8192 }, + ExtraOptions = new Dictionary<string, object?> + { + ["temperature"] = 1.9, + ["num_ctx"] = 256, + ["custom"] = "preserved" + } + }; + + OllamaSettings settings = OllamaSettingsFactory.Create(configuration); + + Assert.Equal(0.4, settings.Options["temperature"].GetDouble()); + Assert.Equal(8192, settings.Options["num_ctx"].GetInt32()); + Assert.Equal("preserved", settings.Options["custom"].GetString()); + } + + [Fact] + public async Task Native_tool_catalog_is_serialized_and_tool_call_is_parsed() + { + string? body = null; + var handler = new StubHandler(async (request, _) => + { + body = await request.Content!.ReadAsStringAsync(); + return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"file_read","arguments":{"path":"x","mode":"whole"}}}]},"prompt_eval_count":2,"eval_count":1}"""); + }); + OllamaClient client = Create(handler); + + OllamaResult result = await client.ChatWithToolsAsync([new OllamaMessage("user", "read")], null, InternalToolCatalog.All, TestContext.Current.CancellationToken); + + using JsonDocument document = JsonDocument.Parse(body!); + Assert.Equal(5, document.RootElement.GetProperty("tools").GetArrayLength()); + Assert.All(document.RootElement.GetProperty("tools").EnumerateArray(), tool => + Assert.False(tool.GetProperty("function").GetProperty("parameters").GetProperty("additionalProperties").GetBoolean())); + OllamaToolCall call = Assert.Single(result.ToolCalls!); + Assert.Equal("file_read", call.Function.Name); + Assert.Equal("x", call.Function.Arguments.GetProperty("path").GetString()); + } + + private static OllamaClient Create(HttpMessageHandler handler, IReadOnlyDictionary<string, JsonElement>? options = null) => + new(new HttpClient(handler), Settings(options), static (_, _) => Task.CompletedTask); + + private static OllamaSettings Settings(IReadOnlyDictionary<string, JsonElement>? options = null) => + new(new Uri("http://127.0.0.1:11434"), "test-model", false, "0", options ?? new Dictionary<string, JsonElement>()); + + private static HttpResponseMessage Json(HttpStatusCode statusCode, string content) => new(statusCode) + { + Content = new StringContent(content, Encoding.UTF8, "application/json") + }; + + private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> callback) : HttpMessageHandler + { + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => callback(request, cancellationToken); + } +} diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs new file mode 100644 index 0000000..23ad2d2 --- /dev/null +++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs @@ -0,0 +1,220 @@ +using Agent.Core.Ollama; +using Agent.Core.Prompts; +using Agent.Core.Runtime; +using Agent.Core.Sessions; +using Agent.Core.Tasks; +using Agent.Core.Tools; +using System.Text.Json; + +namespace Agent.Core.Tests; + +public sealed class PromptAndRuntimeTests +{ + [Fact] + public void Prompt_order_is_fixed_and_request_is_last() + { + IReadOnlyList<OllamaMessage> messages = PromptComposer.Compose(new PromptContext( + "fixed", "user system", "ja", ["skill"], "capability", "graph", "summary", + [new ConversationMessage("user", "old"), new ConversationMessage("assistant", "reply")], + "new request", ["image"])); + + Assert.Equal(["fixed", "user system", "Respond in locale: ja.", "skill", "capability", "graph", "summary", "old", "reply", "new request"], messages.Select(x => x.Content)); + Assert.Equal("system", messages[0].Role); + Assert.Equal("user", messages[^1].Role); + Assert.Equal(["image"], messages[^1].Images); + } + + [Fact] + public async Task Runtime_continues_session_and_accumulates_usage() + { + var sessions = new SessionStore(); + var registry = new TaskRegistry(sessions); + var ollama = new RecordingOllamaClient(); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("custom", "ja", [], "sandbox")); + TaskSnapshot first = registry.Enqueue("first", null, 0.5); + Assert.True(registry.Transition(first.TaskId, AgentTaskState.Planning)); + Assert.True(registry.Transition(first.TaskId, AgentTaskState.Running)); + + await runtime.ExecuteAsync(first.TaskId, TestContext.Current.CancellationToken); + Assert.True(registry.TryGet(first.TaskId, null, out TaskSnapshot? completed)); + Assert.True(completed!.State == AgentTaskState.Completed, completed.Result); + Assert.Equal(3, completed.InputTokens); + Assert.Equal(2, completed.OutputTokens); + + TaskSnapshot second = registry.Enqueue("second", first.SessionId, null); + Assert.True(registry.Transition(second.TaskId, AgentTaskState.Planning)); + Assert.True(registry.Transition(second.TaskId, AgentTaskState.Running)); + await runtime.ExecuteAsync(second.TaskId, TestContext.Current.CancellationToken); + + Assert.Equal(2, ollama.Calls.Count); + Assert.Contains(ollama.Calls[1], message => message.Role == "user" && message.Content == "first"); + Assert.Contains(ollama.Calls[1], message => message.Role == "assistant" && message.Content == "answer-1"); + Assert.Equal(0.5, ollama.Temperatures[0]); + Assert.Null(ollama.Temperatures[1]); + } + + [Fact] + public void Session_store_is_process_local() + { + SessionStore first = new(); + Guid id = first.Create().SessionId; + SessionStore restarted = new(); + + Assert.False(restarted.TryGet(id, out _)); + } + + [Fact] + public async Task Non_vision_model_rejects_images_with_selected_locale_message_before_chat() + { + TaskRegistry registry = new(); + var ollama = new NonVisionOllamaClient(); + const string localized = "選択したモデルは画像入力に対応していません。"; + var runtime = new AgentRuntime(registry, ollama, + new AgentRuntimeSettings("", "ja", [], "sandbox", VisionUnsupportedMessage: localized)); + TaskSnapshot task = registry.Enqueue("画像を確認", null, null, ["base64-image"]); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); + Assert.Equal(AgentTaskState.Failed, failed!.State); + Assert.Equal(localized, failed.Result); + Assert.Equal(0, ollama.ChatCalls); + } + + [Fact] + public async Task Runtime_dispatches_native_tool_call_then_completes_only_after_effect_evidence() + { + TaskRegistry registry = new(); + FakeInternalToolBackend backend = new(); + var dispatcher = new InternalToolDispatcher( + new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); + var ollama = new ToolCallingOllamaClient(); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "ja", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("fileを修正", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); + Assert.True(completed!.State == AgentTaskState.Completed, $"{completed.Result}; backend={backend.Calls.Count}:{string.Join(',', backend.Calls.Select(x => x.Name))}; ollama={ollama.CallCount}"); + Assert.Equal("修正しました。", completed.Result); + Assert.Single(backend.Calls); + Assert.Equal("file_edit", backend.Calls[0].Name); + Assert.Equal(2, ollama.CallCount); + Assert.Equal(5, ollama.ToolCount); + } + + [Fact] + public async Task Runtime_reaches_terminal_failed_state_when_resource_cleanup_fails() + { + TaskRegistry registry = new(); + var backend = new CleanupFailingBackend(); + var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(), backend); + var runtime = new AgentRuntime(registry, new RecordingOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("analyze", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); + Assert.Equal(AgentTaskState.Failed, failed!.State); + Assert.Contains("cleanup failed", failed.Result, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, backend.EndCalls); + } + + [Fact] + public async Task Runtime_cannot_complete_with_an_unresolved_tool_failure() + { + TaskRegistry registry = new(); + var dispatcher = new InternalToolDispatcher( + new DenyByDefaultPolicy(new HashSet<string>(["process_run"], StringComparer.Ordinal)), new FailingProcessBackend()); + var runtime = new AgentRuntime(registry, new FailingThenFinalOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox", MaxIterations: 2), dispatcher); + TaskSnapshot task = registry.Enqueue("run build", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); + Assert.Equal(AgentTaskState.Failed, failed!.State); + Assert.Contains("iteration limit", failed.Result, StringComparison.OrdinalIgnoreCase); + } + + private sealed class RecordingOllamaClient : IOllamaClient + { + public List<IReadOnlyList<OllamaMessage>> Calls { get; } = []; + public List<double?> Temperatures { get; } = []; + + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) + { + Calls.Add(messages); + Temperatures.Add(temperatureOverride); + return Task.FromResult(new OllamaResult($"answer-{Calls.Count}", 3, 2)); + } + } + + private sealed class ToolCallingOllamaClient : IOllamaClient + { + public int CallCount { get; private set; } + public int ToolCount { get; private set; } + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("修正しました。", 1, 1)); + public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + CallCount++; + ToolCount = tools.Count; + if (CallCount == 1) + { + using JsonDocument document = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"new"}"""); + return Task.FromResult(new OllamaResult("", 2, 1, [new OllamaToolCall(new OllamaFunctionCall("file_edit", document.RootElement.Clone()))])); + } + return Task.FromResult(new OllamaResult("修正しました。", 2, 2)); + } + } + + private sealed class NonVisionOllamaClient : IOllamaClient + { + public int ChatCalls { get; private set; } + public Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(false); + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) + { + ChatCalls++; + return Task.FromResult(new OllamaResult("unexpected", 0, 0)); + } + } + + private sealed class CleanupFailingBackend : IInternalToolBackend + { + public int EndCalls { get; private set; } + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => + Task.FromResult(new ToolExecutionResult(true, "unused", false)); + public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + EndCalls++; + throw new IOException("cleanup failed"); + } + } + + private sealed class FailingProcessBackend : IInternalToolBackend + { + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => + Task.FromResult(new ToolExecutionResult(false, "exit 1", true, "process:1")); + } + + private sealed class FailingThenFinalOllamaClient : IOllamaClient + { + private int _calls; + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("done", 1, 1)); + public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + if (_calls++ > 0) return Task.FromResult(new OllamaResult("done", 1, 1)); + using JsonDocument arguments = JsonDocument.Parse("""{"executable":"x","args":[],"cwd":"."}"""); + return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("process_run", arguments.RootElement.Clone()))])); + } + } +} diff --git a/tests/Agent.Core.Tests/TaskRegistryTests.cs b/tests/Agent.Core.Tests/TaskRegistryTests.cs new file mode 100644 index 0000000..33e16ab --- /dev/null +++ b/tests/Agent.Core.Tests/TaskRegistryTests.cs @@ -0,0 +1,35 @@ +using Agent.Core.Tasks; + +namespace Agent.Core.Tests; + +public sealed class TaskRegistryTests +{ + [Fact] + public void NewTaskGetsUniqueTaskAndSessionIds() + { + var registry = new TaskRegistry(); + TaskSnapshot first = registry.Enqueue("one", null, null); + TaskSnapshot second = registry.Enqueue("two", null, 0.2); + Assert.NotEqual(first.TaskId, second.TaskId); + Assert.NotEqual(first.SessionId, second.SessionId); + Assert.Equal(AgentTaskState.Queued, first.State); + } + + [Fact] + public void UnknownSessionIsRejected() + { + var registry = new TaskRegistry(); + Assert.Throws<KeyNotFoundException>(() => registry.Enqueue("request", Guid.NewGuid(), null)); + } + + [Fact] + public void TerminalTaskCannotTransition() + { + var registry = new TaskRegistry(); + TaskSnapshot task = registry.Enqueue("request", null, null); + Assert.True(registry.Transition(task.TaskId, AgentTaskState.Planning)); + Assert.True(registry.Transition(task.TaskId, AgentTaskState.Running)); + Assert.True(registry.Transition(task.TaskId, AgentTaskState.Completed)); + Assert.False(registry.Transition(task.TaskId, AgentTaskState.Running)); + } +} diff --git a/tests/Agent.Core.Tests/TestDirectory.cs b/tests/Agent.Core.Tests/TestDirectory.cs new file mode 100644 index 0000000..075d050 --- /dev/null +++ b/tests/Agent.Core.Tests/TestDirectory.cs @@ -0,0 +1,21 @@ +namespace Agent.Core.Tests; + +internal sealed class TestDirectory : IDisposable +{ + public TestDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "agent-mcp-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + public string File(string relativePath, string content) + { + string path = System.IO.Path.Combine(Path, relativePath); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + System.IO.File.WriteAllText(path, content); + return path; + } + + public void Dispose() => Directory.Delete(Path, true); +} diff --git a/tests/Agent.Core.Tests/Usings.cs b/tests/Agent.Core.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/Agent.Core.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj b/tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj new file mode 100644 index 0000000..16264cb --- /dev/null +++ b/tests/Agent.Planner.Tests/Agent.Planner.Tests.csproj @@ -0,0 +1,14 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\Agent.Planner\Agent.Planner.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit.v3" /> + </ItemGroup> +</Project> diff --git a/tests/Agent.Planner.Tests/BaselineTests.cs b/tests/Agent.Planner.Tests/BaselineTests.cs new file mode 100644 index 0000000..a459b18 --- /dev/null +++ b/tests/Agent.Planner.Tests/BaselineTests.cs @@ -0,0 +1,10 @@ +namespace Agent.Planner.Tests; + +public sealed class BaselineTests +{ + [Fact] + public void PlannerAssemblyLoads() + { + Assert.Equal("Agent.Planner", typeof(Planner.AssemblyMarker).Assembly.GetName().Name); + } +} diff --git a/tests/Agent.Planner.Tests/TaskGraphTests.cs b/tests/Agent.Planner.Tests/TaskGraphTests.cs new file mode 100644 index 0000000..b09cd56 --- /dev/null +++ b/tests/Agent.Planner.Tests/TaskGraphTests.cs @@ -0,0 +1,80 @@ +using Agent.Planner; + +namespace Agent.Planner.Tests; + +public sealed class TaskGraphTests +{ + private static readonly TokenEstimate Small = new(60, 60, 60); + + [Fact] + public void Known_dag_has_deterministic_critical_path() + { + TaskGraph graph = new(); + PlannerTask a = graph.AddTask("A", TokenBudgetClass.K1, new TokenEstimate(60, 60, 60)); + PlannerTask b = graph.AddTask("B", TokenBudgetClass.K1, new TokenEstimate(120, 120, 120), [a.Id]); + _ = graph.AddTask("C", TokenBudgetClass.K1, new TokenEstimate(30, 30, 30), [a.Id]); + PlannerTask d = graph.AddTask("D", TokenBudgetClass.K1, new TokenEstimate(60, 60, 60), [b.Id]); + + Assert.Equal([a.Id, b.Id, d.Id], graph.CriticalPath.TaskIds); + Assert.Equal(240, graph.CriticalPath.CumulativeTokenCost); + } + + [Fact] + public void Cycle_is_rejected_without_mutating_graph() + { + TaskGraph graph = new(); + PlannerTask a = graph.AddTask("A", TokenBudgetClass.K1, Small); + PlannerTask b = graph.AddTask("B", TokenBudgetClass.K1, Small, [a.Id]); + + Assert.Throws<PlannerValidationException>(() => graph.AddDependency(a.Id, b.Id)); + Assert.Empty(graph.Get(a.Id).Dependencies); + } + + [Fact] + public void Max_tasks_is_enforced() + { + TaskGraph graph = new(1); + _ = graph.AddTask("A", TokenBudgetClass.K1, Small); + Assert.Throws<PlannerValidationException>(() => graph.AddTask("B", TokenBudgetClass.K1, Small)); + } + + [Theory] + [InlineData(0, 1, 1)] + [InlineData(2, 1, 2)] + [InlineData(1, 3, 2)] + [InlineData(1, 2, 2048)] + public void Invalid_estimate_is_rejected(long optimistic, long likely, long pessimistic) + { + TaskGraph graph = new(); + Assert.Throws<PlannerValidationException>(() => graph.AddTask("bad", TokenBudgetClass.K1, new TokenEstimate(optimistic, likely, pessimistic))); + } + + [Fact] + public void Completed_task_is_immutable() + { + TaskGraph graph = new(); + PlannerTask task = graph.AddTask("A", TokenBudgetClass.K1, Small); + graph.SetState(task.Id, PlannerTaskState.Completed); + + Assert.Throws<PlannerValidationException>(() => graph.SetEstimate(task.Id, TokenBudgetClass.K2, new TokenEstimate(1, 2, 3))); + Assert.Throws<PlannerValidationException>(() => graph.SetState(task.Id, PlannerTaskState.Failed)); + Assert.Throws<PlannerValidationException>(() => graph.AddUsage(task.Id, 1, 1)); + } + + [Fact] + public void Graph_change_recalculates_cpm_and_usage_accumulates() + { + TaskGraph graph = new(); + PlannerTask a = graph.AddTask("A", TokenBudgetClass.K1, Small); + PlannerTask b = graph.AddTask("B", TokenBudgetClass.K1, new TokenEstimate(30, 30, 30)); + Assert.Equal([a.Id], graph.CriticalPath.TaskIds); + + graph.AddDependency(b.Id, a.Id); + graph.AddUsage(b.Id, 10, 5); + graph.AddUsage(b.Id, 2, 3); + + Assert.Equal([a.Id, b.Id], graph.CriticalPath.TaskIds); + Assert.Equal(20, graph.Get(b.Id).ActualTotalTokens); + Assert.Equal(2_048, graph.TotalTokenBudget); + } +} diff --git a/tests/Agent.Planner.Tests/Usings.cs b/tests/Agent.Planner.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/Agent.Planner.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj new file mode 100644 index 0000000..0b45401 --- /dev/null +++ b/tests/Agent.Windows.Tests/Agent.Windows.Tests.csproj @@ -0,0 +1,15 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\src\Agent.Windows\Agent.Windows.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit.v3" /> + </ItemGroup> +</Project> diff --git a/tests/Agent.Windows.Tests/BaselineTests.cs b/tests/Agent.Windows.Tests/BaselineTests.cs new file mode 100644 index 0000000..a4c1cfd --- /dev/null +++ b/tests/Agent.Windows.Tests/BaselineTests.cs @@ -0,0 +1,10 @@ +namespace Agent.Windows.Tests; + +public sealed class BaselineTests +{ + [Fact] + public void WindowsAssemblyLoads() + { + Assert.Equal("Agent.Windows", typeof(Windows.AssemblyMarker).Assembly.GetName().Name); + } +} diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs new file mode 100644 index 0000000..7dd3629 --- /dev/null +++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs @@ -0,0 +1,89 @@ +using System.Runtime.CompilerServices; +using System.Security.AccessControl; +using System.Diagnostics; +using Agent.Windows.Network; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Tests; + +internal static class CrashScenarioBootstrap +{ + [ModuleInitializer] + internal static void RunIfRequested() + { + string? scenario = Environment.GetEnvironmentVariable("AGENT_MCP_CRASH_SCENARIO"); + if (string.IsNullOrEmpty(scenario)) return; + string root = Environment.GetEnvironmentVariable("AGENT_MCP_CRASH_ROOT") + ?? throw new InvalidOperationException("Crash root is required."); + Guid taskId = Guid.Parse(Environment.GetEnvironmentVariable("AGENT_MCP_CRASH_TASK")!); + try + { + TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(root, "journals"), taskId); + switch (scenario) + { + case "acl": + var acl = new TaskAclManager(AppContainerProfile.EnsureFixed().Sid, new MemorySecurityAuditSink(), journal); + _ = acl.Grant(taskId, Path.Combine(root, "resource"), FileCapability.Read | FileCapability.Write); + break; + case "acl-before": + byte[] descriptor = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(Path.Combine(root, "resource")), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + _ = journal.Prepare("acl", Path.Combine(root, "resource"), + new Dictionary<string, string> { ["security_descriptor"] = Convert.ToBase64String(descriptor) }); + break; + case "file-before": + string temporary = Path.Combine(root, "resource.txt.agent-mcp-prepared.tmp"); + string backup = Path.Combine(root, "backups", taskId.ToString("N"), "prepared.backup"); + Directory.CreateDirectory(Path.GetDirectoryName(backup)!); + File.WriteAllText(temporary, "temporary"); + File.WriteAllText(backup, "before-crash"); + _ = journal.Prepare("file.replace", Path.Combine(root, "resource.txt"), new Dictionary<string, string> + { + ["existed"] = bool.TrueString, ["original_sha256"] = string.Empty, ["original_base64"] = string.Empty, + ["backup_path"] = backup, ["temporary_path"] = temporary + }); + break; + case "file-replace": + _ = new TransactionalFileEditor(journal, Path.Combine(root, "backups"), 1) + .Replace(Path.Combine(root, "resource.txt"), "after-crash"); + break; + case "process-after": + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + 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); + 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"; + _ = Process.Start(new ProcessStartInfo + { + FileName = powershell, UseShellExecute = false, CreateNoWindow = true, + ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", watchdogScript } + }); + _ = launcher.Run(taskId, powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], + Environment.SystemDirectory, TimeSpan.FromMinutes(1)); + break; + case "cleanup-mid": + var cleanupAcl = new TaskAclManager(AppContainerProfile.EnsureFixed().Sid, new MemorySecurityAuditSink(), journal); + _ = cleanupAcl.Grant(taskId, Path.Combine(root, "resource"), FileCapability.Read | FileCapability.Write); + FileEditTransaction edit = new TransactionalFileEditor(journal, Path.Combine(root, "backups"), 1) + .Replace(Path.Combine(root, "resource.txt"), "during-cleanup"); + edit.Rollback(); + break; + case "wfp": + _ = TaskNetworkPolicy.Apply(journal, AppContainerProfile.EnsureFixed().Sid, [], new MemorySecurityAuditSink()); + break; + default: + throw new InvalidOperationException($"Unknown crash scenario '{scenario}'."); + } + } + catch (Exception ex) + { + File.WriteAllText(Path.Combine(root, "crash-error.txt"), ex.ToString()); + } + Environment.FailFast($"Intentional security recovery crash point: {scenario}"); + } +} diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs new file mode 100644 index 0000000..d54ab13 --- /dev/null +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -0,0 +1,579 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Security.AccessControl; +using System.Text; +using Agent.Windows.Recovery; +using Agent.Windows.Security; +using Agent.Windows.Network; +using Agent.Windows.Tools; +using Agent.Core.Tools; +using System.Text.Json; + +namespace Agent.Windows.Tests; + +public sealed class SecurityPolicyTests +{ + [Fact] + public void Read_write_and_delete_capabilities_are_independent() + { + using TestDirectory directory = new(); + string file = Path.Combine(directory.Path, "file.txt"); + File.WriteAllText(file, "data"); + PathPolicy policy = new([new FilePolicyEntry(directory.Path, FileCapability.Read | FileCapability.Write)]); + + Assert.True(policy.IsAllowed(file, FileCapability.Read)); + Assert.True(policy.IsAllowed(Path.Combine(directory.Path, "new.txt"), FileCapability.Write)); + Assert.False(policy.IsAllowed(file, FileCapability.Delete)); + Assert.Throws<SandboxPolicyException>(() => policy.Demand(file, FileCapability.Delete)); + } + + [Fact] + public void Path_outside_approved_root_is_denied() + { + using TestDirectory approved = new(); + using TestDirectory outside = new(); + string secret = Path.Combine(outside.Path, "secret.txt"); + File.WriteAllText(secret, "secret"); + PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); + + Assert.False(policy.IsAllowed(secret, FileCapability.Read)); + } + + [Fact] + public void Reparse_escape_is_rejected() + { + using TestDirectory approved = new(); + using TestDirectory outside = new(); + string link = Path.Combine(approved.Path, "escape"); + using Process junction = Process.Start(new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec")!, + UseShellExecute = false, + CreateNoWindow = true, + ArgumentList = { "/d", "/c", "mklink", "/J", link, outside.Path } + })!; + junction.WaitForExit(); + Assert.Equal(0, junction.ExitCode); + PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); + + try + { + SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); + Assert.Equal("path.reparse", error.Code); + } + finally + { + Directory.Delete(link); + } + } + + [Fact] + public void Symlink_chain_escape_is_rejected() + { + using TestDirectory approved = new(); + using TestDirectory outside = new(); + string second = Path.Combine(approved.Path, "second-link"); + string first = Path.Combine(approved.Path, "first-link"); + 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.Equal("path.reparse", error.Code); + Directory.Delete(first); + Directory.Delete(second); + } + + [Fact] + public void Executable_requires_approved_path_and_hash_and_shell_toggle() + { + 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 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)); + } + + [Fact] + public void Restricted_parent_cannot_start_unbrokered_descendant() + { + 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); + 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[] 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" + ]; + + 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); + } + + 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")); + } + + [Fact] + public void Job_timeout_terminates_the_entire_sandbox_job_without_orphan() + { + 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); + + Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], + Environment.SystemDirectory, TimeSpan.FromMilliseconds(250))); + + SecurityAuditEvent timeout = Assert.Single(audit.Events, item => item.Event == "process.timeout"); + int pid = int.Parse(timeout.Data["pid"], System.Globalization.CultureInfo.InvariantCulture); + Assert.Throws<ArgumentException>(() => Process.GetProcessById(pid)); + } + + [Fact] + public void Dotnet_managed_entry_requires_its_own_path_and_hash_approval() + { + 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); + + SandboxPolicyException denied = Assert.Throws<SandboxPolicyException>(() => launcher.Run( + Guid.NewGuid(), dotnet, [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5))); + + Assert.Equal("process.path_denied", denied.Code); + Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.path_denied"); + } + + [Fact] + public void Fixed_app_container_is_stable_and_acl_is_restored_exactly() + { + AppContainerProfile first = AppContainerProfile.EnsureFixed(); + AppContainerProfile second = AppContainerProfile.EnsureFixed(); + Assert.Equal(AppContainerProfile.FixedName, first.Name); + Assert.Equal(first.Sid.Value, second.Sid.Value); + + using TestDirectory directory = new(); + byte[] before = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + var audit = new MemorySecurityAuditSink(); + TaskAclManager manager = new(first.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = manager.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); + byte[] during = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + Assert.NotEqual(Convert.ToHexString(before), Convert.ToHexString(during)); + + manager.Restore(taskId, snapshot); + + byte[] after = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(before), Convert.ToHexString(after)); + Assert.Contains(audit.Events, item => item.Event == "acl.apply"); + Assert.Contains(audit.Events, item => item.Event == "acl.restore"); + } + + [Fact] + public void Brokered_process_runs_in_fixed_app_container_with_task_acl() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string marker = Path.Combine(directory.Path, "appcontainer-write.txt"); + string commandProcessor = Environment.GetEnvironmentVariable("ComSpec")!; + 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 ExecutablePolicy([new ExecutableApproval(commandProcessor, ExecutablePolicy.ComputeSha256(commandProcessor))], true), + 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)); + + 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); + } + finally + { + acl.Restore(taskId, snapshot); + } + } + + [Fact] + public void File_transaction_commit_rollback_and_hash_guard_leave_no_journal() + { + using TestDirectory directory = new(); + string journals = Path.Combine(directory.Path, "journals"); + string backups = Path.Combine(directory.Path, "backups"); + string target = Path.Combine(directory.Path, "target.txt"); + File.WriteAllText(target, "original"); + TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid()); + TransactionalFileEditor editor = new(journal, backups, memoryBackupLimitBytes: 1); + + FileEditTransaction rollback = editor.Replace(target, "replacement"); + Assert.Equal("replacement", File.ReadAllText(target)); + rollback.Rollback(); + Assert.Equal("original", File.ReadAllText(target)); + + FileEditTransaction commit = editor.Replace(target, "committed"); + commit.Commit(); + journal.CompleteAndDelete(); + + Assert.Equal("committed", File.ReadAllText(target)); + Assert.Empty(Directory.EnumerateFiles(journals)); + Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N")))); + } + + [Fact] + public void Recovery_rolls_back_applied_file_in_reverse_cleanup_path() + { + using TestDirectory directory = new(); + string journals = Path.Combine(directory.Path, "journals"); + string backups = Path.Combine(directory.Path, "backups"); + string target = Path.Combine(directory.Path, "target.txt"); + File.WriteAllText(target, "before-crash"); + TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid()); + _ = new TransactionalFileEditor(journal, backups, memoryBackupLimitBytes: 1).Replace(target, "after-crash"); + + RecoveryResult result = new RecoveryManager(journals, new MemorySecurityAuditSink()).RecoverAll(); + + Assert.True(result.IsClean); + Assert.Equal("before-crash", File.ReadAllText(target)); + Assert.Empty(Directory.EnumerateFiles(journals)); + Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N")))); + } + + [Fact] + public void Recovery_after_crash_restores_acl_loopback_and_dynamic_wfp_state() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string journals = Path.Combine(directory.Path, "journals"); + string resource = Path.Combine(directory.Path, "resource"); + Directory.CreateDirectory(resource); + byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + var audit = new MemorySecurityAuditSink(); + Guid taskId = Guid.NewGuid(); + TaskResourceJournal journal = TaskResourceJournal.Create(journals, taskId); + + var acl = new TaskAclManager(profile.Sid, audit, journal); + _ = acl.Grant(taskId, resource, FileCapability.Read | FileCapability.Write); + + Assert.False(LoopbackExemption.Contains(profile.Sid)); + Guid loopbackEntry = journal.Prepare("network.loopback_exemption", profile.Sid.Value, + new Dictionary<string, string> { ["originally_present"] = bool.FalseString }); + Assert.True(LoopbackExemption.Ensure(profile.Sid)); + journal.MarkApplied(loopbackEntry, new Dictionary<string, string> { ["added"] = bool.TrueString }); + + Guid wfpEntry = journal.Prepare("wfp.dynamic", profile.Sid.Value, + new Dictionary<string, string> { ["session"] = "dynamic", ["allowances"] = string.Empty }); + using (WfpDynamicSession crashedSession = WfpDynamicSession.Open()) + { + IReadOnlyList<ulong> filterIds = crashedSession.ApplyDefaultBlock(profile.Sid); + journal.MarkApplied(wfpEntry, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', filterIds) }); + // Closing the engine without RemovePolicy models abrupt process exit: + // BFE owns and deletes every object in this dynamic session. + } + + RecoveryResult recovered = new RecoveryManager(journals, audit).RecoverAll(); + + Assert.True(recovered.IsClean); + Assert.Equal(1, recovered.JournalsRecovered); + Assert.Equal(3, recovered.ResourcesRecovered); + Assert.False(LoopbackExemption.Contains(profile.Sid)); + byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); + Assert.Empty(Directory.EnumerateFiles(journals)); + } + + [Theory] + [InlineData("acl")] + [InlineData("acl-before")] + [InlineData("file-before")] + [InlineData("file-replace")] + [InlineData("process-after")] + [InlineData("cleanup-mid")] + [InlineData("wfp")] + public void Independent_process_crash_is_fully_recovered_on_next_start(string scenario) + { + using TestDirectory directory = new(); + string resourceDirectory = Path.Combine(directory.Path, "resource"); + string resourceFile = Path.Combine(directory.Path, "resource.txt"); + Directory.CreateDirectory(resourceDirectory); + File.WriteAllText(resourceFile, "before-crash"); + byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + Guid taskId = Guid.NewGuid(); + using var child = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Environment.ProcessPath!, + UseShellExecute = false, + CreateNoWindow = true, + Environment = + { + ["AGENT_MCP_CRASH_SCENARIO"] = scenario, + ["AGENT_MCP_CRASH_ROOT"] = directory.Path, + ["AGENT_MCP_CRASH_TASK"] = taskId.ToString("D") + } + } + }; + Assert.True(child.Start()); + Assert.True(child.WaitForExit(60_000)); + Assert.NotEqual(0, child.ExitCode); + Assert.False(File.Exists(Path.Combine(directory.Path, "crash-error.txt")), + File.Exists(Path.Combine(directory.Path, "crash-error.txt")) ? File.ReadAllText(Path.Combine(directory.Path, "crash-error.txt")) : string.Empty); + Assert.Single(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); + + RecoveryResult recovered = new RecoveryManager(Path.Combine(directory.Path, "journals"), new MemorySecurityAuditSink()).RecoverAll(); + + Assert.True(recovered.IsClean, string.Join(Environment.NewLine, recovered.Errors)); + Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); + Assert.Equal("before-crash", File.ReadAllText(resourceFile)); + byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); + Assert.False(LoopbackExemption.Contains(AppContainerProfile.EnsureFixed().Sid)); + string backups = Path.Combine(directory.Path, "backups"); + Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any()); + if (scenario == "process-after") + { + string auditPath = Path.Combine(directory.Path, "crash-audit.jsonl"); + using JsonDocument audit = JsonDocument.Parse(File.ReadLines(auditPath).Single(line => line.Contains("process.start", StringComparison.Ordinal))); + int sandboxPid = int.Parse(audit.RootElement.GetProperty("Data").GetProperty("pid").GetString()!, + System.Globalization.CultureInfo.InvariantCulture); + Assert.True(SpinWait.SpinUntil(() => !ProcessExists(sandboxPid), TimeSpan.FromSeconds(5)), $"Orphan sandbox PID {sandboxPid} survived broker crash."); + } + } + + private static bool ProcessExists(int pid) + { + try { using Process process = Process.GetProcessById(pid); return !process.HasExited; } + catch (ArgumentException) { return false; } + } + + [Fact] + public void Rollback_refuses_to_overwrite_post_transaction_external_change() + { + using TestDirectory directory = new(); + string target = Path.Combine(directory.Path, "target.txt"); + File.WriteAllText(target, "before"); + TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "journals"), Guid.NewGuid()); + FileEditTransaction transaction = new TransactionalFileEditor(journal, Path.Combine(directory.Path, "backups")).Replace(target, "replacement"); + File.WriteAllText(target, "external-change"); + + Assert.Throws<InvalidOperationException>(() => transaction.Rollback()); + + File.WriteAllText(target, "replacement"); + transaction.Rollback(); + journal.CompleteAndDelete(); + } + + [Fact] + public void Wfp_dynamic_session_adds_and_explicitly_removes_ipv4_ipv6_package_filters() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using WfpDynamicSession session = WfpDynamicSession.Open(); + session.ProbeWriteTransaction(); + + IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(profile.Sid, + [new NetworkEndpoint(IPAddress.Loopback, 11434), new NetworkEndpoint(IPAddress.IPv6Loopback, 11434)]); + + Assert.Equal(4, ids.Count); + Assert.All(ids, id => Assert.NotEqual(0UL, id)); + session.RemovePolicy(); + } + + [Fact] + public async Task Windows_backend_applies_file_effect_and_cleans_acl_wfp_temp_and_journal() + { + using TestDirectory directory = new(); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string target = Path.Combine(workspace, "target.txt"); + 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"), + 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); + var backend = new WindowsToolBackend(settings, audit); + Guid taskId = Guid.NewGuid(); + await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken); + using JsonDocument edit = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"overwrite","content":"after"}"""); + + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", edit.RootElement, TestContext.Current.CancellationToken); + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.True(result.ExternalEffect); + Assert.Equal("after", File.ReadAllText(target)); + byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); + Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); + string backups = Path.Combine(directory.Path, "backups"); + Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any()); + Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0"); + Assert.Contains(audit.Events, item => item.Event == "acl.restore"); + } + + [Fact(Timeout = 60_000)] + public async Task Wfp_enforces_actual_appcontainer_loopback_deny_allow_and_port_scope() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + 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)); + using TestDirectory directory = new(); + + bool diagnosticExemptionAdded = LoopbackExemption.Ensure(profile.Sid); + try + { + using var isolationControl = new OneShotHttpServer(IPAddress.Loopback); + RestrictedProcessResult isolationResult = Curl(launcher, Guid.NewGuid(), curl, isolationControl.Url, Environment.SystemDirectory); + Assert.True(isolationResult.ExitCode == 0u, + $"Loopback exemption did not enable AppContainer traffic before WFP was applied. exit {isolationResult.ExitCode}, stderr: {isolationResult.StandardError}"); + Assert.True(isolationControl.Accepted); + } + finally + { + if (diagnosticExemptionAdded) LoopbackExemption.Remove(profile.Sid); + } + + using (var deniedServer = new OneShotHttpServer(IPAddress.Loopback)) + { + Guid taskId = Guid.NewGuid(); + TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "deny-journal"), taskId); + using (TaskNetworkPolicy.Apply(journal, profile.Sid, [], audit)) + { + RestrictedProcessResult denied = Curl(launcher, taskId, curl, deniedServer.Url, Environment.SystemDirectory); + Assert.NotEqual(0u, denied.ExitCode); + Assert.False(deniedServer.Accepted); + } + journal.CompleteAndDelete(); + } + + IPAddress allowedAddress = IPAddress.Loopback; + using (var controlServer = new OneShotHttpServer(allowedAddress)) + using (var controlClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }) + { + string controlBody = await controlClient.GetStringAsync(controlServer.Url, TestContext.Current.CancellationToken); + Assert.Equal("WFP_OK", controlBody); + Assert.True(controlServer.Accepted); + } + using var allowedV4 = new OneShotHttpServer(allowedAddress); + using var deniedPort = new OneShotHttpServer(allowedAddress); + Guid allowTaskId = Guid.NewGuid(); + TaskResourceJournal allowJournal = TaskResourceJournal.Create(Path.Combine(directory.Path, "allow-journal"), allowTaskId); + IReadOnlyList<NetworkEndpoint> allowances = + [ + new(allowedAddress, checked((ushort)allowedV4.Port)) + ]; + using (TaskNetworkPolicy.Apply(allowJournal, profile.Sid, allowances, audit)) + { + RestrictedProcessResult v4 = Curl(launcher, allowTaskId, curl, allowedV4.Url, Environment.SystemDirectory); + RestrictedProcessResult wrongPort = Curl(launcher, allowTaskId, curl, deniedPort.Url, Environment.SystemDirectory); + Assert.True(v4.ExitCode == 0u, $"Allowed AppContainer curl failed with exit {v4.ExitCode}. stderr: {v4.StandardError}"); + Assert.Contains("WFP_OK", v4.StandardOutput, StringComparison.Ordinal); + Assert.True(allowedV4.Accepted); + Assert.NotEqual(0u, wrongPort.ExitCode); + Assert.False(deniedPort.Accepted); + } + allowJournal.CompleteAndDelete(); + Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0"); + } + + 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)); + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "agent-mcp-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + public string Path { get; } + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + private sealed class OneShotHttpServer : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _stop = new(); + private readonly Task _serve; + private int _accepted; + + public OneShotHttpServer(IPAddress address) + { + _listener = new TcpListener(address, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + string host = address.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); + Url = $"http://{host}:{Port}/"; + _serve = ServeAsync(); + } + + public int Port { get; } + public string Url { get; } + public bool Accepted => Volatile.Read(ref _accepted) != 0; + + private async Task ServeAsync() + { + try + { + using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token); + Interlocked.Exchange(ref _accepted, 1); + using NetworkStream stream = client.GetStream(); + byte[] request = new byte[4096]; + int received = 0; + while (received < request.Length) + { + int count = await stream.ReadAsync(request.AsMemory(received), _stop.Token); + if (count == 0) break; + received += count; + if (received >= 4 && request.AsSpan(0, received).IndexOf("\r\n\r\n"u8) >= 0) break; + } + byte[] response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nWFP_OK"); + await stream.WriteAsync(response, _stop.Token); + } + catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException) { } + } + + public void Dispose() + { + _stop.Cancel(); + _listener.Stop(); + try { _serve.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } + _stop.Dispose(); + } + } +} diff --git a/tests/Agent.Windows.Tests/Usings.cs b/tests/Agent.Windows.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/Agent.Windows.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; |
