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) => `