diff --git a/AgenticPatterns.Tests/AgenticPatterns.Tests.csproj b/AgenticPatterns.Tests/AgenticPatterns.Tests.csproj index 360e758..5142e47 100644 --- a/AgenticPatterns.Tests/AgenticPatterns.Tests.csproj +++ b/AgenticPatterns.Tests/AgenticPatterns.Tests.csproj @@ -21,6 +21,11 @@ + + @@ -32,6 +37,7 @@ + S[npx spawns MCP server
server-everything over stdio] - S --> L[ListToolsAsync] - L --> A[Agent registered with
discovered tools] + P[Program starts] --> D{Docker
available?} + D -- no --> X[Fail closed - exit] + D -- yes --> S[docker run spawns MCP server
server-everything over stdio] + S --> L[ListToolsAsync: many tools] + L --> AL[SelectAuthorized:
allowlist add, echo only] + AL --> A[Agent registered with
allowlisted tools only] A -->|add 1234 and 5678| S A -->|echo the result| S S --> R[Final answer 6912] @@ -57,25 +68,75 @@ into the `ChatClientAgent` constructor. Semantic Kernel converts each one with `FunctionChoiceBehavior.Auto` with `RetainArgumentTypes = true` — without that, the numeric arguments reach the `add` tool as strings and the call fails schema validation. +## Security: pin, sandbox, and allowlist the server + +An MCP server is a third-party tool provider: its binary runs on your machine (or one you +control) and its tool descriptions land straight in your prompt. The pre-fix shape of this +sample — `npx -y @modelcontextprotocol/server-everything` (an **unpinned**, latest-at-run-time +package), executed directly **on the host with the application's own environment**, with +**every discovered tool bound to the agent** — is exactly what this sample now exists to *not* +do. The same rule this repo applies to every pattern that executes untrusted work: + +> **The model proposes. A constrained host validates and executes. Untrusted execution +> never inherits the application's authority.** + +Concretely: + +- **Pin the server.** `MCP.AgentFramework/Sandbox/Dockerfile` bakes in an exact version + (`@modelcontextprotocol/server-everything@2025.8.18`) at build time — no "whatever is + latest today" resolved at run time. +- **Run it in the same constrained container as CodeAct.** The pinned server is launched with + `Shared.Sandbox.SandboxRunner.BuildRunArguments`, the identical locked-down-container boundary + the **CodeAct** sample uses for model-generated code — see that pattern's security section for + the full flag-by-flag walkthrough. `McpToolBinding.Sandbox()` opts out of **no** + `SandboxOptions` default, so every row of that table applies here unchanged, `--user + 65532:65532` included: the boundary enforces non-root rather than trusting the image's own + `USER` line to do it. That matters for the "point it at another server" note below — swap in an + image whose Dockerfile never drops root and it still runs as an unprivileged uid. +- **Pass no host environment or credentials.** The container gets nothing from the host process; + the server never sees an API key, a token, or a host env var it wasn't explicitly handed. +- **Deny network unless the chosen server needs it.** This demo server only needs stdio, so + `Network: false` — `--network none`. A server that legitimately calls out (a real GitHub or + database MCP server) would need that grant made explicit and justified, not defaulted on. +- **Keep discovery and authorization separate.** `ListToolsAsync()` still returns everything the + server advertises — discovering a tool never grants it. +- **Bind an explicit allowlist.** `McpToolBinding.SelectAuthorized` filters the discovered list + down to exactly `add` and `echo` before anything reaches the agent, and **fails closed** — + throwing `InvalidOperationException` — if an allowlisted tool goes missing, on the theory that + a missing expected tool means the server isn't the one that was pinned. +- **Fail closed when the boundary is unavailable.** No Docker means no sandbox, which + means no MCP server — the sample exits with an explanatory message rather than falling back to + running the third-party server on the host. Same double opt-in as CodeAct + (`AGENTIC_PATTERNS_ALLOW_UNSAFE_HOST_EXECUTION` + `AGENTIC_PATTERNS_ACKNOWLEDGE_UNSAFE_CODE_EXECUTION`) + would be required to override that, and this sample does not wire that override up. + +The bundled Dockerfile/sandbox is a teaching boundary, not a production one — see CodeAct's +"Included sandbox ≠ production-ready sandbox" for the caveats, which apply here unchanged. + ## Key APIs | Agent Framework | Semantic Kernel | |---|---| | `McpClient.CreateAsync(new StdioClientTransport(...))` | `McpClient.CreateAsync(new StdioClientTransport(...))` | | `mcpClient.ListToolsAsync()` | `mcpClient.ListToolsAsync()` | -| `tools.Cast()` into `ChatClientAgent` | `kernel.Plugins.AddFromFunctions("McpTools", tools.Select(f => f.AsKernelFunction()))` | +| `McpToolBinding.SelectAuthorized(discovered, allowed)` | `McpToolBinding.SelectAuthorized(discovered, allowed)` | +| `tools.Where(authorized).Cast()` into `ChatClientAgent` | `kernel.Plugins.AddFromFunctions("McpTools", tools.Where(authorized).Select(f => f.AsKernelFunction()))` | | — no conversion, `McpClientTool` is an `AIFunction` | `FunctionChoiceBehaviorOptions { RetainArgumentTypes = true }` | -`StdioClientTransportOptions` is what launches the process: `Command = "npx"` with -`Arguments = ["-y", "@modelcontextprotocol/server-everything"]`. Point it at any other executable -and the rest of the code is unchanged. +`StdioClientTransportOptions` is what launches the process: `Command = "docker"` with +`Arguments = SandboxRunner.BuildRunArguments(sandbox, [])` — an empty command list, because the +image's own `ENTRYPOINT` is the pinned server binary. Point `McpToolBinding.Sandbox()`'s image at +any other sandboxed server and the rest of the code is unchanged — and because the non-root uid is +imposed by the run arguments rather than inherited from the image, an image that never drops root +does not quietly become a root container. ## What to watch in the output -The Agent Framework sample prints `MCP tools: ` followed by the full discovered list — `echo`, -`add`, `longRunningOperation`, `printEnv` and the rest — which is the whole point of the pattern -made visible; nothing in the source names them. Then comes the agent's answer containing -**6912**, a number only the remote `add` tool produced. The Semantic Kernel sample skips the -listing and prints just the streamed answer. If the run hangs or dies at startup, npx is missing -or still downloading. Compare with **ToolUse** for locally compiled tools, and **HostedTools** -for tools the model provider runs on your behalf. +Both samples print `Discovered: ` followed by the full list the container hands back — `echo`, +`add`, `longRunningOperation`, `printEnv` and the rest — then `Bound to the agent: ` showing +exactly `echo, add`, the visible proof that discovery and authorization are different steps. Then +comes the agent's answer containing **6912**, a number only the remote `add` tool produced. If the +run exits immediately with "Docker is not available", Docker isn't installed or the +daemon isn't running. Compare with **ToolUse** for locally compiled tools, **HostedTools** for +tools the model provider runs on your behalf, and **CodeAct** for the container sandbox this +sample reuses. diff --git a/PatternExplorer/patterns/ProgressiveToolDisclosure.md b/PatternExplorer/patterns/ProgressiveToolDisclosure.md index 8d860f3..f972a64 100644 --- a/PatternExplorer/patterns/ProgressiveToolDisclosure.md +++ b/PatternExplorer/patterns/ProgressiveToolDisclosure.md @@ -67,5 +67,7 @@ answered with `[tool definitions sent to the model: 1 of 16 available]`: the mod only `search_tools`, used it, and confirmed what it loaded. Turn two shows `3 of 16` — search_tools plus the two discovered tools — and the actual answers. The closing line names what was loaded on demand and notes that the other 13 definitions never entered -the context. **MCP** is the contrast case, binding every discovered tool up front; -**SkillLearning** applies the same frontmatter-first trick to learned procedures. +the context. **MCP** is a related contrast case: it still sends every *bound* tool's +definition on every call rather than searching on demand, though it narrows which +discovered tools get bound at all with an explicit allowlist. **SkillLearning** applies +the same frontmatter-first trick to learned procedures. diff --git a/PatternExplorer/patterns/StigmergicCoordination.md b/PatternExplorer/patterns/StigmergicCoordination.md index 41fb69e..ceba648 100644 --- a/PatternExplorer/patterns/StigmergicCoordination.md +++ b/PatternExplorer/patterns/StigmergicCoordination.md @@ -3,7 +3,14 @@ "title": "Stigmergic Coordination", "summary": "Workers coordinate through a shared workspace and compiler-enforced contracts instead of exchanging messages.", "category": "Orchestration", - "projects": [ { "flavor": "AgentFramework", "path": "StigmergicCoordination.AgentFramework" } ] + "risk": "Compiles model-written C# in a locked-down local container (no network, read-only source mount, resource limits); compiling runs build tasks, source generators and MSBuild targets. Fails closed without Docker. A host build requires an explicit double opt-in.", + "projects": [ + { "flavor": "AgentFramework", "path": "StigmergicCoordination.AgentFramework", "note": "Needs Docker - the build gate refuses to compile model-written source on the host.", "environmentAllowlist": [ + "AzureOpenAi__ChatModelDeployment", "AzureOpenAi__EmbeddingModelDeployment", + "AzureOpenAi__Endpoint", "AzureOpenAi__ApiKey", + "AGENTIC_PATTERNS_ALLOW_UNSAFE_HOST_EXECUTION", "AGENTIC_PATTERNS_ACKNOWLEDGE_UNSAFE_CODE_EXECUTION" + ] } + ] } --- @@ -50,7 +57,7 @@ electric vehicle — but shaped as a three-component system: `SloganModule`, `Pr `IntegrationGate.cs` — a never-executed class whose only job is to *compile*: it instantiates each worker's class where its interface is expected, so a wrong name or signature anywhere fails the build. Three workers run concurrently, each producing one C# file into a temp -workspace. The gate is `dotnet build`. +workspace. The gate is a sandboxed `dotnet build`. One trap is deliberate: the Pricing worker's brief quotes a **stale interface** (`IReadOnlyList GetPrices()`) instead of the real contract @@ -68,14 +75,26 @@ flowchart TD W1 -->|SloganModule.cs| WS W2 -->|PricingModule.cs| WS W3 -->|BriefAssembler.cs| WS - WS --> G{dotnet build
mechanical gate} + WS --> G{sandboxed dotnet build
mechanical gate} G -->|error CS0535| W2 G -->|all contracts satisfied| DONE[0 messages exchanged] ``` The model's code is compiled but **never executed** — the compiler is the integration test. -That keeps the sample inside the repository's untrusted-execution rule; a production version -would add behavioral contract tests running in a sandbox (see **CodeAct** for what that takes). +But compiling is not risk-free either: build tasks, source generators, and MSBuild targets all +run as part of a build, not just at execution time, so `dotnet build` still runs inside the +same locked-down container boundary **CodeAct** uses for model-generated code — no network, +read-only source mount, a bounded writable build directory, capped CPU/memory/pids, a +wall-clock timeout, and bounded output. The image differs, though: this sample pulls the stock +`mcr.microsoft.com/dotnet/sdk` image from the network on first use, rather than CodeAct's +repo-controlled image with an offline package cache baked in — same isolation flags, different +image provenance. The sample fails closed with no fallback to a host build unless the same +double opt-in CodeAct offers is set (and even then the timeout and source-size cap still +apply) — and it hardcodes the `docker` CLI, so unlike CodeAct there is no Podman option. A +nonzero exit with no compiler diagnostic — a permission mismatch on the mount, a resource-limit +kill, an image-pull failure — is reported as a gate error too, never as a silent pass. A +production version would go further and add behavioral contract tests running in that same +sandbox. ## Key APIs @@ -85,8 +104,8 @@ no workflow. The coordination machinery is the environment itself. - `ChatClientAgent(client, instructions, name)` — one plain agent per worker, no tools. - `Task.WhenAll(...)` — workers run concurrently precisely because they share no channel. - `File.WriteAllText` into a shared workspace — the write *is* the coordination act. -- `Process.Start("dotnet", "build ...")` — the mechanical gate; its `error CS*` lines are - parsed and routed to the worker owning the failing file. +- `BuildGate.RunAsync` / `SandboxRunner.RunAsync` — the mechanical gate, run inside a + container; its `error CS*` lines are parsed and routed to the worker owning the failing file. ## What to watch in the output diff --git a/PatternExplorer/wwwroot/app.js b/PatternExplorer/wwwroot/app.js index d112a55..1c28047 100644 --- a/PatternExplorer/wwwroot/app.js +++ b/PatternExplorer/wwwroot/app.js @@ -10,22 +10,52 @@ let flavor = null; let stream = null; let lines = []; let pendingRender = false; +let runId = null; +let runToken = null; + +const MAX_TERMINAL_LINES = 5000; // ---------- markdown ---------- +// Only these URL schemes are ever emitted as href/src - javascript:/data:/etc. in a link or +// image target render as a plain '#'/empty target instead of an executable URI. CSP's +// script-src blocks a javascript: click too, but that's a second layer, not a reason to skip +// this one - see task-2.5b-report.md "Fix round 1". +function isSafeUrl(href) { + try { + return ['http:', 'https:', 'mailto:'].includes(new URL(href, location.href).protocol); + } catch { + return false; + } +} + marked.use({ renderer: { code(token) { if (token.lang === 'mermaid') return `
${escapeHtml(token.text)}
`; return `
${escapeHtml(token.text)}
`; + }, + // Pattern docs are repo-controlled, but marked otherwise passes raw inline/block HTML + // straight through (verified: an unescaped renders live). Escaping it + // here is defence in depth, not a fix for a reachable hole. + html(token) { return escapeHtml(token.text); }, + link(token) { + const safe = isSafeUrl(token.href) ? token : { ...token, href: '#' }; + return marked.Renderer.prototype.link.call(this, safe); + }, + image(token) { + const safe = isSafeUrl(token.href) ? token : { ...token, href: '' }; + return marked.Renderer.prototype.image.call(this, safe); } } }); -mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' }); +mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'strict' }); +// Covers attribute position too (") since p.id/p.flavor/file paths get interpolated into +// href/data-* attributes below, not just text nodes. function escapeHtml(text) { - return text.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // ---------- catalog ---------- @@ -47,7 +77,7 @@ function renderList() { $('list').innerHTML = categories.map((category) => `
${escapeHtml(category)}
${matches.filter((p) => p.category === category).map((p) => ` - + ${escapeHtml(p.title)} ${[...new Set(p.projects.map((x) => x.flavor.startsWith('SemanticKernel') ? 'SK' : 'AF'))].join(' ')} `).join('')} @@ -91,7 +121,7 @@ async function select(id) { function renderFlavors() { $('flavors').innerHTML = current.projects.map((p) => ` - `).join(''); @@ -111,7 +141,7 @@ function renderSources() { const files = current.sources[flavor] ?? []; $('source-view').hidden = true; $('sources').innerHTML = files.map((f) => - ``).join(''); + ``).join(''); } async function showSource(path, button) { @@ -127,6 +157,8 @@ function run() { if (stream) stopStream(); lines = []; + runId = null; + runToken = null; $('terminal').innerHTML = ''; $('terminal-panel').hidden = false; $('terminal-title').textContent = `${current.title} · ${flavor}`; @@ -135,6 +167,11 @@ function run() { $('run').disabled = true; stream = new EventSource(`/api/run?id=${encodeURIComponent(current.id)}&flavor=${encodeURIComponent(flavor)}`); + stream.addEventListener('session', (event) => { + const session = JSON.parse(event.data); + runId = session.id; + runToken = session.token; + }); stream.onmessage = (event) => { const chunk = JSON.parse(event.data); append(chunk.s, chunk.t); @@ -148,6 +185,8 @@ function finish(state, message) { stopStream(); setStatus(state, message); $('run').disabled = false; + runId = null; + runToken = null; } function stopStream() { @@ -157,7 +196,12 @@ function stopStream() { } function cancel() { - fetch('/api/run/cancel', { method: 'POST' }); + if (runId && runToken) { + fetch(`/api/runs/${encodeURIComponent(runId)}/cancel`, { + method: 'POST', + headers: { 'X-Run-Token': runToken } + }).catch(() => {}); + } finish('done', 'stopped'); } @@ -175,6 +219,7 @@ function append(streamTag, text) { else lines.push({ stream: streamTag, text: part, open: true }); if (i < parts.length - 1) lines[lines.length - 1].open = false; }); + if (lines.length > MAX_TERMINAL_LINES) lines.splice(0, lines.length - MAX_TERMINAL_LINES); } function scheduleRender() { @@ -233,7 +278,13 @@ $('close-terminal').addEventListener('click', () => { $('stdin-form').addEventListener('submit', (e) => { e.preventDefault(); - fetch('/api/run/input', { method: 'POST', body: $('stdin').value }); + if (runId && runToken) { + fetch(`/api/runs/${encodeURIComponent(runId)}/input`, { + method: 'POST', + headers: { 'X-Run-Token': runToken }, + body: $('stdin').value + }).catch(() => {}); + } $('stdin').value = ''; }); diff --git a/README.md b/README.md index 5fa7c1b..84e695d 100644 --- a/README.md +++ b/README.md @@ -49,19 +49,27 @@ docker run --rm --init \ # then open http://localhost:5080 ``` -The image contains the .NET 10 SDK, prebuilt samples, and Node.js with npm/npx. It runs as a +The image contains the .NET 10 SDK and prebuilt samples. It runs as a non-root user; the command above also binds only to loopback and drops Linux capabilities. Do not expose Pattern Explorer directly to the internet: its run endpoints intentionally execute -samples with the supplied credentials. Enabling the two optional CodeAct variables runs generated -code directly inside this outer container instead of a nested Docker sandbox. The generated code -therefore shares the container's credentials, filesystem, and network access. +samples with the supplied credentials. Enabling the two optional unsafe-execution variables +affects **two** samples, not one: `CodeAct` then runs generated code directly inside this outer +container instead of a nested Docker sandbox, and `StigmergicCoordination` likewise runs its +`dotnet build` of model-written source there. Both then share the container's credentials, +filesystem, and network access. The `MCP` sample cannot run inside this container at all: it needs +a container runtime of its own to sandbox the MCP server, and this image ships neither a Docker +client nor a daemon socket — running it here always hits the fail-closed path and exits. Run `MCP` +from a terminal with Docker installed instead. Running a sample from the UI spawns `dotnet run` for that project and calls your Azure OpenAI deployment, exactly as running it from the terminal would. Samples that ask for approval get an input box wired to the process's stdin; the A2A sample starts its server first automatically. The write-ups live in `PatternExplorer/patterns/*.md` — one file per pattern, re-read on every -request, so edits show up on refresh. +request, so edits show up on refresh. The page serves them under a same-origin +Content-Security-Policy and renders their Markdown with raw HTML escaped rather than executed +(and Mermaid diagrams run in Mermaid's `strict` mode); pattern docs are repo-controlled, so this +is defence in depth rather than protection against untrusted authors. ## Patterns @@ -100,7 +108,7 @@ the catalog together; each result states its scope limits and cites a primary so | Handoff | Agents transferring the conversation to each other | | HostedTools | Server-side code interpreter and web search tools | | InterAgentCommunication.A2A | Agent-to-agent communication over the A2A protocol | -| MCP | Consuming Model Context Protocol tool servers | +| MCP | Consuming Model Context Protocol tool servers, sandboxed and allowlisted | | Magentic | Manager-driven open-ended multi-agent orchestration | | MultiAgentCollaboration | Group-chat orchestration | | OrchestratorWorkers | Dynamic decomposition into validated tasks for a fixed worker registry | @@ -157,9 +165,12 @@ the catalog together; each result states its scope limits and cites a primary so ## Setup -Requires the .NET 10 SDK and an Azure OpenAI deployment. The `CodeAct` sample additionally -requires Docker or Podman — it sandboxes the code the model writes and refuses to run -without isolation (see the security section below). +Requires the .NET 10 SDK and an Azure OpenAI deployment. Three samples additionally require a +container runtime and refuse to run without isolation, because each executes untrusted work: +`CodeAct` (model-generated code, Docker or Podman via `CodeExecutionOptions.ContainerRuntime`), +`MCP` (a third-party MCP server, Docker only), and `StigmergicCoordination` (`dotnet build` over +model-written source, Docker only — a build runs build tasks, source generators and MSBuild +targets). See the security section below. Configuration is read from `settings/appsettings.json` (linked into every project), environment variables, and user secrets. **Don't put your API key in `appsettings.json`** — it's tracked in git. Use user secrets instead: @@ -210,6 +221,31 @@ Concretely, for the `CodeAct` sample: no ambient credentials. Never execute model-generated code in the application process or on the application host. +The same rule applies to the `MCP` sample: a third-party MCP server is untrusted code too. +`@modelcontextprotocol/server-everything` is pinned at an exact version and baked into an +image at build time, run in the same locked-down container as `CodeAct` — every flag from the +same `Shared/Sandbox` defaults, opting out of none of them (no network, no host environment or +credentials, read-only filesystem, dropped capabilities, non-root `--user 65532:65532`, bounded +pids/memory/cpu) — and only an explicit allowlist (`add`, `echo`) of its discovered tools is ever +bound to the agent — discovery and authorization are kept separate. Unlike `CodeAct`, this sample +hardcodes the `docker` CLI, so Podman is not an option for it. Build the image once before running +either flavor: + +```bash +docker build -t agentic-patterns/mcp-server-everything:2025.8.18 MCP.AgentFramework/Sandbox +``` + +See `PatternExplorer/patterns/MCP.md` for the full walkthrough. + +And to the `StigmergicCoordination` sample, whose build gate compiles model-written C#: compiling +untrusted source *is* running untrusted code, so `dotnet build` happens inside the same boundary +(no network, read-only source mount, one bounded writable tmpfs, capped cpu/memory/pids, a +wall-clock timeout, bounded output) rather than on the host. It pulls the stock +`mcr.microsoft.com/dotnet/sdk` image rather than building a repo-controlled one, so its image +provenance differs from `CodeAct`'s — the isolation flags do not. Like `CodeAct` it exits 1 +without Docker unless the same double opt-in is set; see +`PatternExplorer/patterns/StigmergicCoordination.md`. + The A2A samples need the server running first: ```bash diff --git a/CodeAct.AgentFramework/Execution/BoundedReader.cs b/Shared/Sandbox/BoundedReader.cs similarity index 92% rename from CodeAct.AgentFramework/Execution/BoundedReader.cs rename to Shared/Sandbox/BoundedReader.cs index 3c265f0..8c212db 100644 --- a/CodeAct.AgentFramework/Execution/BoundedReader.cs +++ b/Shared/Sandbox/BoundedReader.cs @@ -1,8 +1,8 @@ using System.Text; -namespace CodeAct.AgentFramework.Execution; +namespace Shared.Sandbox; -internal static class BoundedReader +public static class BoundedReader { /// /// Reads a stream keeping at most characters, but keeps diff --git a/Shared/Sandbox/HostWorkspace.cs b/Shared/Sandbox/HostWorkspace.cs new file mode 100644 index 0000000..b844510 --- /dev/null +++ b/Shared/Sandbox/HostWorkspace.cs @@ -0,0 +1,43 @@ +namespace Shared.Sandbox; + +/// +/// Host-side staging for the directory a sandbox bind-mounts. The container runs as an +/// unrelated uid (65532 by default), so every directory it traverses and every file it reads +/// has to be world-readable — and the mode argument on Directory.CreateDirectory is a +/// mkdir(2) mode, itself masked by the process umask (verified by hand: umask 077 turns +/// a requested 0755 into 0700). chmod(2) is not subject to umask, so the explicit +/// afterwards is what actually forces +/// the bits. This lived twice — once in CodeAct's ContainerCodeRunner, once in +/// StigmergicCoordination's BuildGate, where the identical gap had to be found independently — +/// so it lives next to the boundary that needs it instead. +/// +public static class HostWorkspace +{ + private const UnixFileMode DirectoryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + + private const UnixFileMode FileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + + /// Creates and its parent world-traversable, umask or not. + public static string CreateWorldReadableDirectory(string path) + { + if (OperatingSystem.IsWindows()) return Directory.CreateDirectory(path).FullName; + + var parent = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(parent); + File.SetUnixFileMode(parent, DirectoryMode); + Directory.CreateDirectory(path); + File.SetUnixFileMode(path, DirectoryMode); + return path; + } + + /// Writes a file the sandbox uid can read — the directory mode alone is not enough. + public static async Task WriteWorldReadableAsync(string path, string content, CancellationToken cancellationToken) + { + await File.WriteAllTextAsync(path, content, cancellationToken); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, FileMode); + } +} diff --git a/Shared/Sandbox/SandboxOptions.cs b/Shared/Sandbox/SandboxOptions.cs new file mode 100644 index 0000000..a2581a2 --- /dev/null +++ b/Shared/Sandbox/SandboxOptions.cs @@ -0,0 +1,25 @@ +namespace Shared.Sandbox; + +/// +/// The constrained-execution boundary shared by every sample that runs untrusted, +/// model-generated work: deny everything by default (no network, no capabilities, no +/// host filesystem, no host environment, no root) and grant back only what the caller +/// explicitly asks for via and . +/// +public sealed record SandboxOptions( + string Image, + string ContainerRuntime = "docker", + bool Network = false, + string Memory = "512m", + string Cpus = "1", + int PidsLimit = 128, + TimeSpan Timeout = default, + int MaxOutputCharacters = 65_536, + IReadOnlyDictionary? Environment = null, + IReadOnlyList<(string Host, string Container, bool ReadOnly)>? Mounts = null, + string? ContainerName = null, + string? User = "65532:65532", + string? Tmpfs = null, + bool Interactive = false); + +public sealed record SandboxResult(int ExitCode, string StdOut, string StdErr, bool TimedOut); diff --git a/Shared/Sandbox/SandboxRunner.cs b/Shared/Sandbox/SandboxRunner.cs new file mode 100644 index 0000000..90fad7f --- /dev/null +++ b/Shared/Sandbox/SandboxRunner.cs @@ -0,0 +1,262 @@ +using System.Diagnostics; + +namespace Shared.Sandbox; + +/// +/// Runs a command inside a locked-down local container. This is the constrained-execution +/// boundary itself: least privilege throughout, nothing granted back except what an +/// individual explicitly asks for. It demonstrates the required +/// isolation boundary; it is NOT a production-grade sandbox for adversarial or multi-tenant +/// workloads (use a disposable VM/microVM isolation service for that). +/// +public static class SandboxRunner +{ + /// True when the runtime CLI exists AND its daemon answers. + public static bool IsAvailable(string containerRuntime) + { + try + { + var (exitCode, _, _) = RunRuntimeCommandAsync(containerRuntime, + ["version", "--format", "{{.Server.Version}}"], TimeSpan.FromSeconds(10)) + .GetAwaiter().GetResult(); + return exitCode == 0; + } + catch (Exception e) when (e is System.ComponentModel.Win32Exception or PlatformNotSupportedException) + { + return false; // CLI not on PATH + } + } + + /// + /// The effective pids-limit clamp, as a pure value so tests can assert it directly instead + /// of inferring it from a timing-dependent run. 0 or negative reads to docker as "unlimited" + /// — never let "unset" mean "no limit" on a security boundary. + /// + public static int EffectivePidsLimit(int configured) => configured > 0 ? configured : 128; + + /// + /// The effective timeout clamp, as a pure value so tests can assert it directly instead of + /// inferring it from a timing-dependent run. On a type whose whole purpose is bounding + /// untrusted work, "caller forgot to set Timeout" must never mean "no bound". + /// + public static TimeSpan EffectiveTimeout(TimeSpan configured) => + configured > TimeSpan.Zero ? configured : TimeSpan.FromMinutes(3); + + /// + /// The effective memory clamp. `--memory 0` (and an empty value) reads to docker as + /// UNLIMITED — the same fail-open-on-a-bound shape as a zero + /// or an unset Timeout, and just as unreachable through the type's own defaults, so it is + /// closed the same way. A non-numeric value is passed through untouched: docker rejects it + /// loudly, which is a failure, not a silently removed limit. + /// + public static string EffectiveMemory(string? configured) => IsUnbounded(configured) ? "512m" : configured!; + + /// The effective cpu clamp — `--cpus 0` is likewise "unlimited" to docker. + public static string EffectiveCpus(string? configured) => IsUnbounded(configured) ? "1" : configured!; + + // "0", "0m", "0.0", "" and null all mean "no limit" once docker parses them. + private static bool IsUnbounded(string? value) => + string.IsNullOrWhiteSpace(value) || + (double.TryParse(value.TrimEnd('b', 'B', 'k', 'K', 'm', 'M', 'g', 'G'), + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, + out var number) && number <= 0); + + /// + /// The whole security posture, as one pure function so tests can pin every flag. + /// Deny everything by default; grant back only what asks for. + /// + public static IReadOnlyList BuildRunArguments(SandboxOptions options, IReadOnlyList command) + { + List args = ["run", "--rm"]; + + if (options.ContainerName is not null) + { + args.Add("--name"); + args.Add(options.ContainerName); + } + if (options.Interactive) args.Add("-i"); + + if (!options.Network) + { + args.Add("--network"); + args.Add("none"); + } + + args.Add("--read-only"); + args.Add("--cap-drop"); + args.Add("ALL"); + args.Add("--security-opt"); + args.Add("no-new-privileges=true"); + args.Add("--pids-limit"); + args.Add(EffectivePidsLimit(options.PidsLimit).ToString()); + args.Add("--memory"); + args.Add(EffectiveMemory(options.Memory)); + args.Add("--cpus"); + args.Add(EffectiveCpus(options.Cpus)); + + if (options.User is not null) + { + args.Add("--user"); + args.Add(options.User); + } + + if (options.Tmpfs is not null) + { + args.Add("--tmpfs"); + args.Add(options.Tmpfs); + } + + if (options.Mounts is not null) + { + foreach (var (host, container, readOnly) in options.Mounts) + { + // `--mount` takes a comma-separated option list with no escaping mechanism, so a + // path containing a comma does not produce a weird mount — it INJECTS mount + // options (`src=/a,readwrite,/b`). Every caller passes a GUID temp directory + // today, but this is the one function whose entire job is the security posture: + // reject rather than build a value docker will re-parse into something else. + if (host.Contains(',') || container.Contains(',')) + throw new ArgumentException( + $"Mount paths must not contain a comma — docker would read it as another mount option: '{host}' -> '{container}'.", + nameof(options)); + + args.Add("--mount"); + args.Add($"type=bind,src={host},dst={container}" + (readOnly ? ",readonly" : "")); + } + } + + if (options.Environment is not null) + { + foreach (var (name, value) in options.Environment) + { + args.Add("--env"); + args.Add($"{name}={value}"); + } + } + + args.Add(options.Image); + args.AddRange(command); + return args; + } + + /// + /// Runs inside the sandbox and returns its output, bounded + /// per . On timeout, kills the container + /// by name (cancelling the client process does not guarantee the containerized process + /// has stopped) and reports instead of throwing. + /// Caller cancellation is never converted into a timeout result — it propagates as + /// . + /// + public static async Task RunAsync( + SandboxOptions options, IReadOnlyList command, string? stdin, CancellationToken cancellationToken) + { + // C1: kill-by-name must never be optional. SIGKILLing the docker-run CLI process + // does not stop the daemon-side container, so timeout cleanup below has to kill BY + // NAME — generate one when the caller didn't supply one rather than silently + // degrading to a leaked container. + var containerName = options.ContainerName ?? $"sandbox-{Guid.NewGuid():N}"; + options = options with + { + ContainerName = containerName, + // I3: a caller that redirects stdin but forgot -i gets a pipe docker never + // attaches to — input is silently discarded and the callee sees EOF. + Interactive = options.Interactive || stdin is not null, + }; + + using var process = StartRuntimeProcess(options.ContainerRuntime, + BuildRunArguments(options, command), redirectStandardInput: stdin is not null); + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(EffectiveTimeout(options.Timeout)); + + try + { + var stdoutTask = BoundedReader.ReadBoundedAsync( + process.StandardOutput, options.MaxOutputCharacters, timeoutCts.Token); + var stderrTask = BoundedReader.ReadBoundedAsync( + process.StandardError, options.MaxOutputCharacters, timeoutCts.Token); + + // I2: readers must already be draining, and the timeout must already be + // armed, before we write. A child that prints more than the output bound + // before reading its stdin blocks on a full stdout pipe; writing stdin + // before the drain starts (and with no cancellation token) would then hang + // forever, uncancellable by either the caller's token or the sandbox timeout. + if (stdin is not null) + { + await process.StandardInput.WriteAsync(stdin.AsMemory(), timeoutCts.Token); + process.StandardInput.Close(); + } + + await process.WaitForExitAsync(timeoutCts.Token); + + return new SandboxResult(process.ExitCode, await stdoutTask, await stderrTask, TimedOut: false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timeout, not caller cancellation. Kill by NAME: cancelling the client + // process does not guarantee the containerized process has stopped. + await KillContainerAsync(options.ContainerRuntime, containerName); + if (!process.HasExited) process.Kill(entireProcessTree: true); + return new SandboxResult( + ExitCode: -1, + StdOut: "", + StdErr: "Execution exceeded the configured time limit.", + TimedOut: true); + } + // Caller cancellation propagates as OperationCanceledException — it is never + // converted into an ordinary failure result. The finally still cleans up. + } + finally + { + await RemoveContainerAsync(options.ContainerRuntime, containerName); + } + } + + private static async Task KillContainerAsync(string containerRuntime, string containerName) => + await RunRuntimeCommandAsync(containerRuntime, ["kill", containerName], TimeSpan.FromSeconds(30)); + + private static async Task RemoveContainerAsync(string containerRuntime, string containerName) + { + // Belt and braces next to --rm; a missing container is the expected happy path. + try + { + await RunRuntimeCommandAsync(containerRuntime, ["rm", "-f", containerName], TimeSpan.FromSeconds(30)); + } + catch (System.ComponentModel.Win32Exception) { } + } + + private static Process StartRuntimeProcess( + string containerRuntime, IReadOnlyList arguments, bool redirectStandardInput = false) + { + var startInfo = new ProcessStartInfo(containerRuntime) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = redirectStandardInput + }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + return Process.Start(startInfo)!; + } + + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunRuntimeCommandAsync( + string containerRuntime, IReadOnlyList arguments, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + using var process = StartRuntimeProcess(containerRuntime, arguments); + var stdoutTask = process.StandardOutput.ReadToEndAsync(cts.Token); + var stderrTask = process.StandardError.ReadToEndAsync(cts.Token); + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + throw; + } + return (process.ExitCode, await stdoutTask, await stderrTask); + } +} diff --git a/StigmergicCoordination.AgentFramework/BuildGate.cs b/StigmergicCoordination.AgentFramework/BuildGate.cs new file mode 100644 index 0000000..4e635ba --- /dev/null +++ b/StigmergicCoordination.AgentFramework/BuildGate.cs @@ -0,0 +1,200 @@ +using Shared.Sandbox; + +namespace StigmergicCoordination.AgentFramework; + +/// +/// The mechanical gate: `dotnet build` over the shared workspace, run inside the SAME +/// constrained-execution boundary CodeAct uses for model-generated code. Compiling untrusted +/// source is still running untrusted code — build tasks, source generators, and MSBuild +/// targets all execute as part of a build, not just at run time — so the workspace is +/// compiled in a locked-down container (no network, read-only source mount, bounded writable +/// build directory, capped CPU/memory/pids, wall-clock timeout, bounded output), never +/// directly on the host. See the repository's untrusted-execution rule. +/// +public static class BuildGate +{ + public const long MaxSourceBytes = 64 * 1024; + + // ponytail: the stock SDK image, pulled from the network on first `docker run` - unlike + // CodeAct's ContainerCodeRunner, which builds a repo-controlled image with an offline + // package cache baked in (CodeAct.AgentFramework/Sandbox/Dockerfile). Same isolation + // flags, different image provenance: a first-run pull failure is a real failure mode here + // that CodeAct doesn't have. The pull also happens INSIDE `docker run`, so it is inside + // SandboxRunner's timeout: warm it is ~9s, but on a cold CI runner a slow pull surfaces as + // `error AP0002: the build gate timed out` and fails StigmergicBuildGateSandboxTests. That + // fails red rather than falsely green, so it is a flake, not a hole. Upgrade path: bake a + // similar offline image (or pre-pull in CI) if this sample needs to run with zero host + // network access, including for the initial pull. + private const string ContainerImage = "mcr.microsoft.com/dotnet/sdk:10.0"; + private const int MaxOutputCharacters = 65_536; + + // Same two variables CodeAct's CodeRunnerFactory reads (Execution/CodeRunnerFactory.cs) - + // duplicated here rather than referenced across projects, since no sample in this repo + // references another sample's project. Only THIS sample's fail-closed path offers them, + // because only this sample actually reads them - unlike MCP, which has no host fallback. + public const string UnsafeEnableVariable = "AGENTIC_PATTERNS_ALLOW_UNSAFE_HOST_EXECUTION"; + public const string UnsafeEnableValue = "true"; + public const string UnsafeAcknowledgementVariable = "AGENTIC_PATTERNS_ACKNOWLEDGE_UNSAFE_CODE_EXECUTION"; + public const string UnsafeAcknowledgementValue = "I_UNDERSTAND_THIS_RUNS_UNTRUSTED_CODE_ON_MY_HOST"; + + private const string NuGetConfig = + """ + + + + + + + """; + + public static string FailClosedMessage => + $""" + Docker is not available. This sample compiles model-generated C# files, which + is untrusted code - build tasks, source generators, and MSBuild targets all run during + a build, not just at execution. It will not be compiled on the host. + + Install Docker to run it (this sample hardcodes the docker CLI - only CodeAct takes the + runtime from an option), or explicitly opt into an unsandboxed host build + (the build still gets a timeout and a source size cap, but none of the container + isolation) with both: + 1. {UnsafeEnableVariable}={UnsafeEnableValue} + 2. {UnsafeAcknowledgementVariable}={UnsafeAcknowledgementValue} + """; + + /// Double opt-in, same shape as CodeAct - one variable alone is never enough. + public static bool IsUnsafeHostBuildRequested() => + Environment.GetEnvironmentVariable(UnsafeEnableVariable) == UnsafeEnableValue && + Environment.GetEnvironmentVariable(UnsafeAcknowledgementVariable) == UnsafeAcknowledgementValue; + + /// Oversized files never reach the compiler (sandboxed or not) - checked up front. + public static string? OversizedSourceError(string workspace) + { + foreach (var file in Directory.GetFiles(workspace, "*.cs")) + if (new FileInfo(file).Length > MaxSourceBytes) + return $"{Path.GetFileName(file)}: error AP0001: source exceeds {MaxSourceBytes} bytes"; + return null; + } + + public static List ParseErrors(string combinedOutput) => + [.. combinedOutput.Split('\n').Where(l => l.Contains(": error ")).Select(l => l.Trim()).Distinct()]; + + /// + /// C1: a nonzero exit with no parsed compiler diagnostic is NOT a pass. `cp` permission + /// failures, a `--pids-limit` kill, a daemon hiccup, or an image-pull failure all exit + /// nonzero without ever printing a line containing ": error " - discarding the exit code + /// (as the pre-sandbox `BuildAsync` did) turns every one of those into a silent PASSED. + /// Single source of truth for both the sandboxed and host-fallback paths. + /// + public static List InterpretResult(SandboxResult result) + { + if (result.TimedOut) return ["error AP0002: the build gate timed out"]; + var errors = ParseErrors(result.StdOut + result.StdErr); + if (errors.Count == 0 && result.ExitCode != 0) + { + var detail = string.IsNullOrWhiteSpace(result.StdErr) ? result.StdOut : result.StdErr; + return [$"error AP0003: the build gate could not run (exit {result.ExitCode}): {detail.Trim()}"]; + } + return errors; + } + + /// + /// `SandboxOptions` defaults to `--read-only`, so copying the workspace out of the + /// read-only `/src` mount needs a writable mount - a bounded tmpfs. The tmpfs (and HOME / + /// DOTNET_CLI_HOME) land on `/tmp`, exactly where `ContainerCodeRunner` puts them - NOT on + /// a separate `/build` tmpfs, which was tried first and measured to fail: the .NET CLI's + /// interprocess "first run" mutex is hardcoded to `/tmp/.dotnet/shm`, ignoring HOME and + /// TMPDIR, so a writable `/build` with a still-read-only `/tmp` fails on every run with + /// "mkdir(/tmp/.dotnet/shm/session1) == -1; errno == EROFS" before the compiler ever sees + /// the source. Mounting the tmpfs at `/tmp` is what `ContainerCodeRunner` already does, is + /// verified working here, and keeps the same environment variables it passes: what lets + /// the SDK run offline as a non-root user with no writable home. + /// + public static SandboxOptions SandboxedOptions(string workspace) => new( + Image: ContainerImage, + Network: false, Memory: "1g", Cpus: "2", PidsLimit: 256, + Timeout: TimeSpan.FromMinutes(3), + Tmpfs: "/tmp:rw,exec,nosuid,nodev,size=1g", + Environment: new Dictionary + { + ["HOME"] = "/tmp", + ["DOTNET_CLI_HOME"] = "/tmp/dotnet", + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + }, + Mounts: [(workspace, "/src", true)]); + + /// + /// Runs the gate. is decided ONCE by the caller (before any + /// worker runs) rather than re-checked every round, mirroring CodeAct's one-time runner + /// selection - availability cannot silently change mid-run into a different security + /// posture. + /// + public static async Task> RunAsync( + string workspace, bool useSandbox, CancellationToken cancellationToken) + { + var oversized = OversizedSourceError(workspace); + if (oversized is not null) return [oversized]; + + // Ruling 3: written every round (cheap, idempotent) so a restore that ever tries to + // reach a feed fails loudly instead of silently depending on Network: false alone. + // World-readable: this file rides the read-only /src bind mount into the sandbox too. + await WriteWorldReadableAsync(Path.Combine(workspace, "NuGet.config"), NuGetConfig, cancellationToken); + + if (!useSandbox) return await HostBuildAsync(workspace, cancellationToken); + + var result = await SandboxRunner.RunAsync(SandboxedOptions(workspace), + ["sh", "-c", "cp -r /src /tmp/build && cd /tmp/build && dotnet build -nologo --verbosity quiet"], + stdin: null, cancellationToken); + + return InterpretResult(result); + } + + /// + /// C2: the container runs as uid 65532, an unrelated uid on the host, so a workspace created + /// under a restrictive umask (0700, common with `umask 077`) is unreadable to the sandbox, + /// `cp` fails, and (pre-C1-fix) that silently read as PASSED. The umask fix itself lives in + /// - it was found and fixed independently here and in CodeAct's + /// ContainerCodeRunner, which is exactly why it is shared now rather than copied. + /// + public static string CreateWorkspaceDirectory(string path) => + HostWorkspace.CreateWorldReadableDirectory(path); + + /// Every file written into the workspace has to be readable by uid 65532, not just + /// the directory - same shared helper, same reason. + public static Task WriteWorldReadableAsync(string path, string content, CancellationToken cancellationToken) => + HostWorkspace.WriteWorldReadableAsync(path, content, cancellationToken); + + // ponytail: unsandboxed - only reachable behind the double opt-in in IsUnsafeHostBuildRequested, + // same shape as CodeAct's UnsafeHostCodeRunner. Upgrade path is the same: delete this method + // once every environment running the sample has a container runtime. + private static async Task> HostBuildAsync(string workspace, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMinutes(3)); + + using var process = System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo("dotnet", "build -nologo --verbosity quiet") + { + WorkingDirectory = workspace, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + try + { + var stdoutTask = BoundedReader.ReadBoundedAsync(process.StandardOutput, MaxOutputCharacters, timeoutCts.Token); + var stderrTask = BoundedReader.ReadBoundedAsync(process.StandardError, MaxOutputCharacters, timeoutCts.Token); + await process.WaitForExitAsync(timeoutCts.Token); + return InterpretResult(new SandboxResult(process.ExitCode, await stdoutTask, await stderrTask, TimedOut: false)); + } + catch (OperationCanceledException) + { + // I3: kill on EITHER a timeout or caller cancellation - an unsandboxed host + // `dotnet build` left running after this method returns/throws is an orphaned + // process, not just a discarded result (contrast SandboxRunner's kill-by-name). + if (!process.HasExited) process.Kill(entireProcessTree: true); + process.WaitForExit(); // release file handles so the workspace can be deleted + if (cancellationToken.IsCancellationRequested) throw; // caller cancellation stays caller cancellation + return InterpretResult(new SandboxResult(-1, "", "", TimedOut: true)); + } + } +} diff --git a/StigmergicCoordination.AgentFramework/Program.cs b/StigmergicCoordination.AgentFramework/Program.cs index 79749a7..5fab78c 100644 --- a/StigmergicCoordination.AgentFramework/Program.cs +++ b/StigmergicCoordination.AgentFramework/Program.cs @@ -1,18 +1,44 @@ -using System.Diagnostics; +using System.Runtime.InteropServices; using System.Text.RegularExpressions; using Microsoft.Agents.AI; using Shared; +using Shared.Sandbox; +using StigmergicCoordination.AgentFramework; // Stigmergic coordination: N workers build components of one system WITHOUT exchanging // a single message. All coordination flows through the shared environment — a workspace // directory, compiler-enforced C# contracts, and a build gate. The orchestrator only // launches workers and runs the gate; it never relays information between agents. // Contrast MultiAgentCollaboration, where the same domain task is coordinated by dialogue. +// +// The gate compiles model-written files, which is untrusted code — build tasks, source +// generators, and MSBuild targets all run during a build. It runs inside the same +// constrained-execution boundary CodeAct uses (see BuildGate.cs) and FAILS CLOSED when no +// container runtime is available, unless the same double opt-in CodeAct offers is set. + +// Fail closed BEFORE creating a workspace or spending a single model call: no container +// runtime and no explicit double opt-in means the sample refuses to compile anything. +var useSandbox = SandboxRunner.IsAvailable("docker"); +if (!useSandbox && !BuildGate.IsUnsafeHostBuildRequested()) +{ + Console.Error.WriteLine(BuildGate.FailClosedMessage); + return 1; +} -var workspace = Directory.CreateDirectory( - Path.Combine(Path.GetTempPath(), "stigmergy", Guid.NewGuid().ToString("N"))).FullName; +// C2: world-readable so the sandbox's non-root uid can read the bind mount, regardless of +// the operator's umask - see BuildGate.CreateWorkspaceDirectory. +var workspace = BuildGate.CreateWorkspaceDirectory( + Path.Combine(Path.GetTempPath(), "stigmergy", Guid.NewGuid().ToString("N"))); Console.WriteLine($"Workspace: {workspace}\n"); +// Minor: a plain `return` inside the round loop below cannot outrun Ctrl-C, so the SIGINT +// handler deletes the workspace directly rather than relying on the try/finally to run. +using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, _ => +{ + try { Directory.Delete(workspace, recursive: true); } + catch (IOException) { } catch (UnauthorizedAccessException) { } +}); + // ---- The environment: contracts + integration gate, written by the HOST ---- const string Contracts = @@ -54,9 +80,9 @@ public static (ISloganModule, IPricingModule, IBriefAssembler) Wire() => } """; -File.WriteAllText(Path.Combine(workspace, "Contracts.cs"), Contracts); -File.WriteAllText(Path.Combine(workspace, "IntegrationGate.cs"), Gate); -File.WriteAllText(Path.Combine(workspace, "Campaign.csproj"), +await BuildGate.WriteWorldReadableAsync(Path.Combine(workspace, "Contracts.cs"), Contracts, CancellationToken.None); +await BuildGate.WriteWorldReadableAsync(Path.Combine(workspace, "IntegrationGate.cs"), Gate, CancellationToken.None); +await BuildGate.WriteWorldReadableAsync(Path.Combine(workspace, "Campaign.csproj"), """ @@ -64,7 +90,7 @@ public static (ISloganModule, IPricingModule, IBriefAssembler) Wire() => enable - """); + """, CancellationToken.None); // ---- The workers: one file each, briefed from the environment, never from each other ---- @@ -97,53 +123,50 @@ async Task ProduceAsync((string File, string Role, string Brief) worker, string var agent = new ChatClientAgent(Settings.ChatClient, worker.Brief, worker.Role); var code = (await agent.RunAsync($"{TaskBrief}\n{extraContext}")).Text.Trim(); code = Regex.Replace(code, @"^```\w*\n|\n?```$", ""); // strip fences if the model adds them anyway - File.WriteAllText(Path.Combine(workspace, worker.File), code); + await BuildGate.WriteWorldReadableAsync(Path.Combine(workspace, worker.File), code, CancellationToken.None); Console.WriteLine($"[worker] {worker.Role} -> {worker.File} ({code.Split('\n').Length} lines, no messages to other workers)"); } -// ---- The mechanical gate: dotnet build over the shared workspace ---- +// ---- The mechanical gate: BuildGate.RunAsync, sandboxed unless the opt-in fallback fired ---- -async Task> BuildAsync() +try { - var process = Process.Start(new ProcessStartInfo("dotnet", "build -nologo --verbosity quiet") - { - WorkingDirectory = workspace, - RedirectStandardOutput = true, - RedirectStandardError = true, - })!; - var output = await process.StandardOutput.ReadToEndAsync() + await process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync(); - return [.. output.Split('\n').Where(l => l.Contains(": error ")).Select(l => l.Trim()).Distinct()]; -} - -await Task.WhenAll(workers.Select(w => ProduceAsync(w, ""))); + await Task.WhenAll(workers.Select(w => ProduceAsync(w, ""))); -for (var round = 1; round <= 3; round++) -{ - Console.WriteLine($"\n=== Build gate: round {round} ==="); - var errors = await BuildAsync(); - if (errors.Count == 0) + for (var round = 1; round <= 3; round++) { - Console.WriteLine("PASSED — every component satisfies the shared contracts."); - Console.WriteLine("\n---- Files in the shared environment ----"); - foreach (var f in Directory.GetFiles(workspace, "*.cs").Order()) - Console.WriteLine($"\n>>> {Path.GetFileName(f)}\n{File.ReadAllText(f).Trim()}"); - Console.WriteLine("\nMessages exchanged between workers: 0. The workspace did all the talking."); - return; + Console.WriteLine($"\n=== Build gate: round {round} ==="); + var errors = await BuildGate.RunAsync(workspace, useSandbox, CancellationToken.None); + if (errors.Count == 0) + { + Console.WriteLine("PASSED — every component satisfies the shared contracts."); + Console.WriteLine("\n---- Files in the shared environment ----"); + foreach (var f in Directory.GetFiles(workspace, "*.cs").Order()) + Console.WriteLine($"\n>>> {Path.GetFileName(f)}\n{File.ReadAllText(f).Trim()}"); + Console.WriteLine("\nMessages exchanged between workers: 0. The workspace did all the talking."); + return 0; + } + + foreach (var error in errors) Console.WriteLine($" {error}"); + + // Errors route by file name to the worker that owns the file — the trace in the + // environment is the only feedback channel, and it carries the REAL contract with it. + foreach (var group in errors.GroupBy(e => workers.FirstOrDefault(w => e.Contains(w.File)).File).Where(g => g.Key is not null)) + { + var worker = workers.First(w => w.File == group.Key); + Console.WriteLine($" -> gate feedback for {worker.Role}: rework {worker.File} against the real contract"); + await ProduceAsync(worker, + $"Your previous {worker.File} failed the build gate:\n{string.Join("\n", group)}\n\n" + + $"The authoritative shared contract file Contracts.cs is:\n{Contracts}\nRewrite the complete file so it compiles against it."); + } } - foreach (var error in errors) Console.WriteLine($" {error}"); - - // Errors route by file name to the worker that owns the file — the trace in the - // environment is the only feedback channel, and it carries the REAL contract with it. - foreach (var group in errors.GroupBy(e => workers.FirstOrDefault(w => e.Contains(w.File)).File).Where(g => g.Key is not null)) - { - var worker = workers.First(w => w.File == group.Key); - Console.WriteLine($" -> gate feedback for {worker.Role}: rework {worker.File} against the real contract"); - await ProduceAsync(worker, - $"Your previous {worker.File} failed the build gate:\n{string.Join("\n", group)}\n\n" + - $"The authoritative shared contract file Contracts.cs is:\n{Contracts}\nRewrite the complete file so it compiles against it."); - } + Console.WriteLine("\nFAILED — components still do not satisfy the contracts after 3 rounds."); + return 1; +} +finally +{ + // Guaranteed cleanup: the workspace must not survive a crash or the success return above. + try { Directory.Delete(workspace, recursive: true); } + catch (IOException) { } catch (UnauthorizedAccessException) { } } - -Console.WriteLine("\nFAILED — components still do not satisfy the contracts after 3 rounds.");