fix: make the constrained-host rule uniform across every sample (finding 2) - #13
Merged
Conversation
…ared Move ContainerCodeRunner's argument-building, bounded output reading, timeout, and container lifecycle into Shared/Sandbox (SandboxOptions, SandboxResult, SandboxRunner) so CodeAct is no longer the only sample with a real isolation boundary; MCP and Stigmergic can reuse SandboxRunner instead of inventing a weaker one. ContainerCodeRunner.BuildRunArguments keeps its exact signature and now maps CodeExecutionOptions onto SandboxOptions, producing the same docker argument list element-for-element and in the same order. Task 2.1 of docs/superpowers/sdd/2026-08-25-second-review-remediation.
Review fix round 1 on the sandbox extraction. Every finding shared one root cause: unconditional safety behaviour in the original ContainerCodeRunner became conditional on optional SandboxOptions fields defaulting to off. - C1 (critical): RunAsync generated no container name when ContainerName was null, so timeout cleanup degraded from kill-by-name to killing only the docker-run CLI process, leaking the still-running container. RunAsync now always generates a name when the caller omits one. - I1: Timeout <= TimeSpan.Zero now clamps to a 3-minute default instead of disabling the timeout outright (also fixes a real regression: ExecutionTimeout = TimeSpan.Zero used to fail fast and now would have run unbounded). - I2: stdin is written after the output readers start and the timeout is armed, and uses a cancellable WriteAsync overload, closing a pipe-deadlock/ uncancellable-hang path. - I3: Interactive is now derived as `Interactive || stdin is not null` so a redirected pipe is never silently discarded by a missing -i. - M3: PidsLimit <= 0 clamps to 128 instead of reading to docker as unlimited. - M2: strengthened two weak test assertions. - I4: added pure BuildRunArguments tests for User/Interactive/ContainerName/ Tmpfs, plus a Docker-gated RunAsync test proving the I1 clamp. ContainerCodeRunner.cs and both pinned test files (CodeActExecutionTests.cs, CodeActSandboxSmokeTests.cs) are untouched by this round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
…waiting Fix round 2: the round-1 Docker-gated timeout test was vacuous — it ran a fast command and asserted TimedOut: false, which passes identically whether Timeout <= 0 clamps to 3 minutes or disables the timeout outright, since a fast command finishes long before either bound expires. Extract SandboxRunner.EffectiveTimeout(TimeSpan) and EffectivePidsLimit(int) as pure static methods; RunAsync and BuildRunArguments now call through them instead of inlining the ternary. New pure tests assert the clamp values directly, so reverting either clamp fails immediately with no Docker or timing involved. Renamed the surviving Docker-gated test to what it actually proves (ZeroTimeoutDoesNotCancelTheRunImmediately). ContainerCodeRunner.cs and both pinned test files are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
The sample no longer runs npx -y @modelcontextprotocol/server-everything (unpinned, latest-at-run-time, executed on the host with the app's own environment, every discovered tool bound). The server is now pinned to an exact version baked into a Docker image, launched through the same locked-down container boundary CodeAct uses (Shared.Sandbox.SandboxRunner), and only an explicit allowlist (add, echo) of its discovered tools is ever bound to the agent via the new McpToolBinding.SelectAuthorized, which fails closed if an allowlisted tool is missing. Both the Agent Framework and Semantic Kernel flavors get the same treatment and fail closed with no silent host fallback when no container runtime is available, mirroring CodeAct.AgentFramework's CodeRunnerFactory double opt-in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
…fix docs - Fail-closed message in both Program.cs no longer advertises the AGENTIC_PATTERNS_* override: neither variable is ever read by the MCP samples (only CodeRunnerFactory reads them), so the old message told a Docker-less user to do something that does nothing. - SandboxOptions now gets an explicit ContainerName on the stdio path so the container is nameable/killable even though McpClient (not SandboxRunner.RunAsync) owns the process lifecycle here. - McpToolBinding.SelectAuthorized takes HashSet<string> instead of IReadOnlySet<string> so allowed.Comparer governs both the match and the missing-check consistently, and both filter and diff run through Distinct/Except with that same comparer so duplicate discovered names don't leak into the result. Mirrored byte-identically into the SK twin. Added tests for case-insensitive allowlists and duplicate tool names. - README.md now says plainly that MCP cannot run inside the Pattern Explorer container (no docker client/socket there) instead of shipping a silently-broken run button. - MCP.md now says the sandbox image must be built manually first (unlike CodeAct, which builds its image on first run). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
…d cleanup Compiling untrusted C# is still running untrusted code (build tasks, source generators, MSBuild targets), so the stigmergic build gate now runs through Shared.Sandbox instead of a bare host `dotnet build`, fails closed with no container runtime (same double opt-in AGENTIC_PATTERNS_* fallback as CodeAct), and guarantees workspace cleanup via try/finally around the round loop (including the success-path return that used to leak it). New BuildGate.cs extracts the testable pieces; SandboxRunner reads stdout/stderr concurrently, removing the sequential-ReadToEndAsync pipe deadlock. Mounts the writable tmpfs at /tmp (not /build) - verified by hand that /build alone still fails, because the .NET CLI's first-run mutex is hardcoded under /tmp/.dotnet/shm regardless of HOME/DOTNET_CLI_HOME/TMPDIR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
…ndent perms
C1: BuildGate.InterpretResult stops treating a nonzero exit with no parsed
compiler diagnostic as PASSED (permission failures, pids-limit kills, and
similar now surface as a synthetic AP0003 error) - reproduced live with
PidsLimit: 1 ("Cannot fork") and pinned as a Docker-gated regression test.
C2: workspace directories/files are now forced world-readable via explicit
File.SetUnixFileMode after creation. Directory.CreateDirectory(path, mode)
alone - the same call ContainerCodeRunner.CreateRunDirectory makes - is NOT
enough: mkdir()'s mode argument is itself masked by the process umask,
verified by hand (0755 requested, 0700 back under umask 077). chmod() is not
subject to umask, so an explicit SetUnixFileMode call after creation is what
actually makes the permissions umask-independent.
I3: added real tests that actually enter HostBuildAsync (a genuinely broken
file, and cancellation of a live process), replacing one that threw before
reaching it. Fixed the underlying orphan: HostBuildAsync now kills the
process on caller cancellation too, not just on timeout.
Minor: SIGINT now deletes the workspace directly (the round loop's
try/finally can't be trusted to run on a signal); the finally/handler also
catch UnauthorizedAccessException; docs note the stock vs. offline-cached
image difference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
AIFunctionFactory.Create injects the invocation's CancellationToken into ExecuteCSharp's trailing parameter, so cancelling the agent run now cancels the underlying container run instead of using CancellationToken.None. Proven with a fake IGeneratedCodeRunner recording the token it received, invoked through AIFunctionFactory.Create with an already-cancelled token.
…mask Directory.CreateDirectory(path, mode)'s mode is a mkdir(2) mode, masked by the process umask like any mkdir call (verified: umask 077 turns a requested 0755 into 0700). Under a restrictive umask the container's uid 65532 couldn't traverse the per-run directory or read the bind-mounted script, so CodeAct failed loudly with "Script failed (exit N)". Mirrors StigmergicCoordination.AgentFramework/BuildGate.cs CreateWorkspaceDirectory, which hit and fixed the identical gap: create the directory, then force the bits with File.SetUnixFileMode (chmod is not subject to umask). CreateRunDirectory is now internal so a test can call it directly and assert the actual on-disk mode, independent of whatever umask the test process runs under.
RunSession.Current was process-global, so a second browser tab silently
cancelled the first tab's run. Replace it with a registry keyed by
(id, token), bound the output channel and add wall-clock/output ceilings,
and stop child processes from inheriting Explorer's whole environment -
only PATH/HOME/DOTNET_* plus a per-project allowlist (Azure OpenAI config
by default, CodeAct's host-execution opt-ins only for CodeAct) survive.
Endpoints move under /api/runs/{id}/{input,cancel}, auth'd by an
X-Run-Token header; /api/run now hands back {id, token} as the first SSE
event. This intentionally breaks app.js's calls to the old /api/run/input
and /api/run/cancel routes - task 2.5b repairs the frontend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
- Delete the CodeAct-only hardcoding of the two AGENTIC_PATTERNS_* opt-in variables in RunSession.StartProcess. StigmergicCoordination.AgentFramework (task 2.3) reads the same two variables for its own host-build fallback, so hardcoding one project silently changed its opt-out-of-fail-closed behavior when launched from Explorer. Both patterns now declare an explicit environmentAllowlist in their frontmatter instead. - Make the MaxLiveRuns cap actually atomic (Register now locks around the count-check-then-insert). - Stop projecting EnvironmentAllowlist into the /api/patterns wire shape. - Add the TryGet(a.Id, b.Token) cross-run isolation assertion. - Rename the internal CancellationToken test seam to IsCancelled (bool) to remove the Color-Color trap against the BCL type. - Name the Windows-forwarding ceiling (PATH/HOME/DOTNET_* only) as a ponytail: comment instead of building support this repo doesn't claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
…g, CSP
Repairs the app.js calls task 2.5a intentionally left broken (/api/run/cancel
and /api/run/input, both deleted): cancel and stdin now read the run id/token
from the SSE `session` event and call /api/runs/{id}/cancel and
/api/runs/{id}/input with an X-Run-Token header.
Renders pattern-doc Markdown with raw HTML escaped (marked's renderer.html
override - v15 dropped the old `sanitize` option), runs Mermaid in `strict`
mode instead of `loose`, and caps terminal history at 5000 lines dropping
from the front.
Adds a same-origin Content-Security-Policy in Program.cs; verified live via
Playwright that the vendored marked/mermaid bundles still work under it with
zero console violations and a diagram actually renders to SVG. Nothing needed
loosening from the brief's starting policy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
Review round 1: escapeHtml didn't cover " so frontmatter (flavor, pattern id, source file path) interpolated into data-*/href attributes could break out and add a real event handler - fixed the escaper and the three call sites, root-caused rather than patched per-site. Added a scheme allowlist (http/https/mailto) on marked's link/image renderers so a javascript: link or image target in a doc body neutralises to '#'/'' instead of staying clickable - CSP's script-src was the only thing stopping that before. Also: finish() now clears runId/runToken (previously only run() did), the fire-and-forget cancel/input fetches get a no-op .catch, and the CSP gained base-uri/form-action/frame-ancestors (none of the three covered by default-src). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
Final review wave: no Critical, three blocking doc/copy defects plus five of the same classes this PR exists to remove. I1 MCP passed `User: null`, the only caller-visible opt-out of a SandboxOptions default in the tree, while README and MCP.md both promised non-root. Verified by running the pinned server under `--user 65532:65532` (initialize succeeds, both flavors answer 6912 end to end), so the boundary now enforces the uid instead of trusting the image's USER line. The options moved into McpToolBinding.Sandbox() so both flavors share one definition and a test can pin it. I2 MCP and Stigmergic both hardcode the docker CLI but told users to install Docker or Podman. Stop naming Podman there - same move af79fc5 made for the override that was never read. CodeAct keeps both, it genuinely has the knob. I3 Stigmergic was missing from the README uniformity story and its Explorer card carried neither risk nor note, while its frontmatter forwards the unsafe-host pair into the child. Fixed all three, plus the README paragraph that said those variables affect only CodeAct. I4 The Explorer environment allowlist had no test: deleting Environment.Clear() left all 188 green. Extracted ApplyChildEnvironment as the seam; the mutant now fails. I5 ZeroTimeoutDoesNotCancelTheRunImmediately asserted nothing its comment claimed and ran an image absent on CI. Replaced with a run whose timeout must actually fire; deleting CancelAfter fails it (and takes 125s doing so). M1 Shared the umask fix in Shared/Sandbox/HostWorkspace instead of leaving it copied in two samples. M3 --memory 0 / --cpus 0 read to docker as unlimited; clamped like PidsLimit and Timeout. M4 --mount is comma-separated with no escaping, so a comma in a path injects options; rejected. M5 /api/run was an unauthenticated GET that starts a billed process; cross-site requests now 403. M7 noted the cold-pull-inside-the-timeout flake in BuildGate's ponytail block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
MCP stopped opting out of the non-root default in c7d0924, so nothing "depends on" User: null any more. The option still needs pinning — null must omit the flag AND its value, never emit `--user ""`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes finding 2 of the second external review (
docs/reviews/2026-08-25-second-review.md),plus the Stigmergic host-compile, CodeAct-cancellation and Pattern Explorer findings
from its medium and table sections.
The repo teaches one rule — a constrained host executes untrusted work. Before this
branch exactly one sample obeyed it. CodeAct ran model-written code in a locked-down
container; three other samples did the same class of thing on the host and said nothing
about it. This PR makes the rule uniform and, where a doc claimed a guarantee the code
did not enforce, changes the code rather than the copy.
What changed
Shared/Sandbox— the boundary, extracted.SandboxOptions,SandboxResult,SandboxRunnerandBoundedReaderlifted out ofCodeAct behaviour-preservingly: the argument list CodeAct generates is element-for-element
identical to before, flag order included, and its pinned tests pass unchanged apart from
one added
using.--read-only,--cap-drop ALLand--security-opt no-new-privilegesare unconditional — no options combination removes them — and non-positive
Timeout,PidsLimit,MemoryandCpusclamp to safe values rather than to "unlimited", becauseon a type whose only job is bounding untrusted work, "the caller forgot" must not mean
"no bound".
RunAsyncalways names its container, so kill-by-name is never optional.MCP — the sample the review named.
Was
npx -y @modelcontextprotocol/server-everything: an unpinned third-party packagedownloaded at run time, executed on the host with the application's entire environment,
with every discovered tool bound to the agent. Now a pinned repo-controlled image running
inside the boundary — no host environment, no credentials, no network, non-root — with
discovery and authorization separated behind an allowlist that fails closed if the server
does not advertise what was asked for. Verified by running the container with a canary
AZURE_OPENAI_API_KEYin the parent environment and confirmingprintenvinside showsneither it nor ~85 other host variables.
StigmergicCoordination — compiling untrusted source is running untrusted input.
dotnet buildover model-written C# moved off the host and into the boundary (build tasks,source generators and MSBuild targets all execute code). The gate no longer reports
PASSEDwhen the container failed before the compiler ran — previously a developer withumask 077saw round 1 pass instantly and would have concluded the deliberate contractdrift wasn't caught. The workspace is now cleaned on every exit path including SIGINT.
CodeAct. Cancelling an agent run now cancels the container. Separately, its run
directory is readable by the container user regardless of the operator's umask —
Directory.CreateDirectory(path, mode)is masked by umask, so the requested0755wassilently becoming
0700.Pattern Explorer.
RunSession.Currentwas process-global, so a second browser tabsilently cancelled the first tab's run. Now a per-run registry with unguessable tokens,
bounded channel, runtime and output, an environment allowlist so child samples stop
inheriting the Explorer's credentials, token-gated endpoints under a run id, HTML-escaped
rendering with a scheme allowlist on links, mermaid strict mode, and a CSP. Cross-site
GETs can no longer start Azure-billed runs.
The image. Node/npm/npx existed only for the old
npxMCP path. The base imagealready ships
curl, so the entire node build stage is gone and the healthcheck usescurl -fsS— verified in both directions, including that an unserved container correctlygoes
unhealthy.What this PR deliberately does not do
It does not give Pattern Explorer a docker client or mount the host docker socket to make
MCP runnable inside the container image. That would grant root-equivalent host access to a
web UI that runs model-driven samples — the exact escalation this PR argues against. MCP
stays unrunnable there, and the README says so plainly.
Verification
dotnet build "Agentic Patterns.slnx" -c Release— 85 projects, 0 errors, 0 warningsmain)Process.Start,npx,Assembly.Load,CSharpScript,Reflection.Emitand model-reachable file writes found no fifth sample stillexecuting untrusted work on the host — the uniformity claim is tested, not asserted
Known gaps
McpToolBindingis duplicated in the SemanticKernel twin and tested only in theAgentFramework flavor.
SecurityHeadersTestsasserts the CSP string constant rather thana live response header, so it would not catch deletion of the middleware — its own
docstring says so.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LB4jjPp7i2pxV55Vpe6tpc