Skip to content

v0.3.0 — field-test hardening + Docker-host detection security fix

Latest

Choose a tag to compare

@techeretic techeretic released this 26 Sep 05:02
· 2 commits to master since this release
3e09253

A minor release on v0.2.1. We ran Sandy end to end against a public third-party MCP server (@cyanheads/libofcongress-mcp-server): first under macOS Seatbelt, then again on Linux in a read-only Docker container. This release fixes everything that test found (write-up). It includes one security fix, and it adds capability (MCP Registry import, Seatbelt detection).

Security

  • A Linux host running Docker was detected as a docker sandbox (fail-open). The container detector matched docker|containerd anywhere in /proc/self/mountinfo. A Docker host lists every running container's overlay rootfs there, so on a bare Linux machine with any container running, a config declaring sandbox.runtime: "docker" started unsandboxed and reported RESULT: OK. Now only the process's own root mount is inspected. Affects v0.2.1 and earlier on Linux hosts running containers; upgrade. Advisory: GHSA-vrfr-983g-7848 (high). (#60)

New

  • sandy import reads MCP Registry server.json. Conversion is deterministic, and the result still has to pass the existing manifest schema. The registry format lists no tools, so --tools <server=a,b> is required. When a server offers both a hosted endpoint and a package, --registry-source remote|package is required too. Headers, URL templates, unsupported registries or transports, and required arguments with no fixed value are refused (exit 3). (#56)
  • macos-sandbox-exec is detected. Detection runs a nested no-op sandbox-exec, which the kernel refuses under a restrictive Seatbelt profile. Runtimes that still can't be detected (systemd-nspawn, chroot, windows-appcontainer) now get an error that points at custom. (#54)
  • import --apply can add the first server to a missing or empty mcp-servers.json. A refused apply now restores both config files byte for byte. (#55)

Fixed

  • Reports are never silently dropped.

    • run: a report.file that the configured format can't be written under (e.g. .md with pdf) is refused with exit 2 before any MCP call.
    • run/ask: a report that couldn't be written prints report: NOT WRITTEN — <reason> and exits 1. This includes a failed multi-round consolidation in ask.
    • A failed narrate rewrite no longer crashes ask.
    • A filename the model proposes that the configured format can't use is replaced by the default name.

    (#52, #60)

  • stdio MCP servers' stderr is drained. Previously it was piped but never read: a non-Node server writing more than ~64 KiB to stderr could block forever, and a server that died at startup reported only Connection closed. A startup failure now includes the server's own error lines, e.g. (server stderr: npm error enoent … mkdir '/home/node/.npm'). (#60)

  • A failed import fetch is audited (import_fetch with outcome: "error"). (#51)

  • Markdown footnotes pair up: one reference and one definition per claim. (#53)

  • The custom boundary message is accurate. It says Sandy continues under the operator's boundary and can't verify it, instead of claiming to refuse. (#57)

  • Every audit event carries a per-invocation session id, so (session, seq) is unique in a shared JSONL log. (#58)

Notes

  • Contract changes: new exit-2/exit-1 cases for reports, a session field on audit events, declaredRuntime in the capability manifest, and a format field on the import result.
  • Tests: 387/387 (was 349 at v0.2.1). Typecheck and build are green, and CI (core, the Docker/Firejail boundary × mode matrix, and the identity check) passes.
  • Version bumped 0.2.1 → 0.3.0 in package.json, package-lock.json, plugin/.claude-plugin/plugin.json, and the two runtime identity strings.