Releases: techeretic/sandy
Release list
v0.3.0 — field-test hardening + Docker-host detection security fix
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
dockersandbox (fail-open). The container detector matcheddocker|containerdanywhere 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 declaringsandbox.runtime: "docker"started unsandboxed and reportedRESULT: 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 importreads MCP Registryserver.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|packageis required too. Headers, URL templates, unsupported registries or transports, and required arguments with no fixed value are refused (exit 3). (#56)macos-sandbox-execis detected. Detection runs a nested no-opsandbox-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 atcustom. (#54)import --applycan add the first server to a missing or emptymcp-servers.json. A refused apply now restores both config files byte for byte. (#55)
Fixed
-
Reports are never silently dropped.
run: areport.filethat the configured format can't be written under (e.g..mdwithpdf) is refused with exit2before any MCP call.run/ask: a report that couldn't be written printsreport: NOT WRITTEN — <reason>and exits1. This includes a failed multi-round consolidation inask.- 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.
-
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_fetchwithoutcome: "error"). (#51) -
Markdown footnotes pair up: one reference and one definition per claim. (#53)
-
The
customboundary 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
sessionid, so(session, seq)is unique in a shared JSONL log. (#58)
Notes
- Contract changes: new exit-2/exit-1 cases for reports, a
sessionfield on audit events,declaredRuntimein the capability manifest, and aformatfield 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 × modematrix, and the identity check) passes. - Version bumped
0.2.1 → 0.3.0inpackage.json,package-lock.json,plugin/.claude-plugin/plugin.json, and the two runtime identity strings.
v0.2.1 — three correctness fixes
A patch release on v0.2.0 — three correctness fixes found by an external review of the repo (PR #49). No new capability, no breaking change, no change to the security invariants.
What's fixed
-
sandy run— a request without areportspec now writes its report. Previously a model-lesssandy runwhose request omittedreportgathered the data, printed the report to stdout, and wrote no file toreport_output_dir. Root cause: the report file write is gated onrequest.reportbeing present (it is not tied to the model narrate step), which a bare request does not set.sandy runis a report-producing verb, so it now always writes the default report (title = goal, timestamped filename) intoreport_output_dir— it never silently drops the artifact. The default is applied in the CLIrunpath only; the plugin'ssandy.gatherdeliberately stays file-less. -
sandy import— default staging anchors to the config dir, not the cwd.stageresolved.sandy-import/against the process cwd, so runningsandy importfrom another directory left a stray.sandy-import/<hash>.jsonbehind. The default now anchors to the config directory — the same anchor asreport_output_dir— so a run from any directory stages next tosandy.json. An explicit--stage-diroverride is honored verbatim (cwd-relative if given as a relative path). Staging stays config-free (the anchor comes from the declared config path; nothing must load). -
Egress-conformance fixture advertised a non-dialable host.
conformance/ep-server.mjsprintedhttp://0.0.0.0:<port>/mcpon ready; the in-process harness dials exactly that URL, and0.0.0.0is a valid bind wildcard but not a dialable destination on Linux, so the three egress tests hung there. It now advertises127.0.0.1while keeping the0.0.0.0bind — the Docker network-level harness is unaffected (it builds its URL from the EP container's IP, never the printed line, and still needs the all-interfaces bind to be reachable cross-container). Test-only:conformance/is not in the packagefilesand never ships.
Notes
- No security change. None of the three touches the egress path or the sandbox enforcer — the fixture fix only changes the address the in-process test dials. No new security advisories.
- Tests: +3 (one regression test per fix) — a
runwith noreportspec asserts exactly one file lands inreport_output_dir; import tests assert the default anchors to the config dir (not cwd) and that an explicit override is cwd-relative. - Verification:
npm run typecheck && npm test && npm run build→ 349/349 tests (was 346, +3), green. Real-binary smoke tests confirmed both CLI-facing fixes end-to-end. - Version bump
0.2.0 → 0.2.1inpackage.json,package-lock.json(top + root package only),plugin/.claude-plugin/plugin.json, and the two runtime identity strings. Docs updated (README Status,docs/NEXT_STEPS.md,docs/IMPORT_DESIGN.md, test counts).
v0.2.0 — sandy import: URL/file-driven MCP server configuration
The first feature release beyond the 0.1.x line: sandy import — URL- and file-driven MCP server configuration (PRs #47 / #48). Point Sandy at a URL, a file, or stdin that describes an MCP server and it produces a validated, content-hash-pinned, reviewable candidate config — no more hand-copying endpoints, tool names, and auth shapes into mcp-servers.json from remote docs.
Usage
sandy import https://registry.internal/servers/jira.json # one-shot confirmed fetch → stage
sandy import ./jira.json # air-gap: no network
curl -s <url> | sandy import - # host-fetched path
sandy import <src> --tools jira=read_sprints,read_issues --apply # explicit promote- Staged by default — nothing touches live config until
--apply(or a manual edit). --applymerges intomcp-servers.json+ appends the computedsandbox.allowed_networklines, refuses name collisions, and re-runs the full config load as the final gate (fail-closed — never ships a broken config).
What landed
- The import pipeline (
src/import.ts) — fetch → sniff → validate → stage → (apply), one flow, one trust gate:- The fetch is the one bounded, human-confirmed, audited exception to zero-egress. Sandy prints the exact
GET <url>and the operator confirms (default: no — a cancelled fetch dials nothing). The dial is bounded (30s timeout, 1MB body cap, 3-redirect cap; non-http(s) refused) and recorded as animport_fetchevent (url, finalUrl, sha256, bytes). This is the first change to the egress surface in Sandy's history. - The schema is the law. Fetched bytes run through the existing
mcpServersManifestSchema(fail-closed, exit 3) — nothing is legal because a URL said so. There is no new validation surface. - Content-hash-pinned staging under
.sandy-import/<sha256>.json— the URL is a source at time T, not a live channel: a later change to the URL is a different hash, so it produces a fresh reviewable diff, never a silent mutation (the VCS-reviewed-registry model holds). The review package prints the exactallowed_networklines to add, the env-var names (never values), and the per-server allowlist — which is never auto-expanded (confirm it or pass--tools, validated againstcapabilities). - Explicit apply only.
--applyis the sole path that touches live config: it requires a loadable config, refuses to overwrite an existing server name, preserves existing entries' order, and re-runs the fullloadSandyConfigas the final gate. - Air-gap safe. The file/stdin path needs no network at all.
- v1 is deterministic-only. Prose URLs are rejected fail-closed; the LLM-transcription
--autopath is a documented follow-up (the core has no LLM client in v1 — seedocs/IMPORT_DESIGN.md).
- The fetch is the one bounded, human-confirmed, audited exception to zero-egress. Sandy prints the exact
- Audit events —
import_fetch+import_stagedjoin the forensic record (2 new types insrc/audit/logger.ts). - CLI — the
importverb with--yes/--apply/--tools <s=a,b>; exit codes follow the stable contract (usage-class 2, fail-closed 3). - Docs —
docs/IMPORT_DESIGN.md(design) +docs/PLAN_IMPORT.md(plan); the consumer guide audited so every "zero egress" claim honestly names the exception (security threat-model note, config guide, troubleshooting, dev layout);plugin.jsondescription qualified. - Community health (PR #46) — Contributor Covenant CoC, contributing guide, security policy, issue/PR templates; ASCII diagrams → Mermaid across the docs.
Verification
npm run typecheck && npm test && npm run build→ 346/346 tests (was 323, +23), green.- Conformance green (in-process + the Docker/Firejail
boundary × modematrix + byte-identical-signature identity check): the import dial is a separate, confirmed operator action, not a runtime egress path — the matrix runssandy run/sandy ask(neversandy import), so the no-egress / cross-sandbox guarantees are unaffected. - No new dependencies — the fetch uses global
fetch+node:crypto; the install stays at 3 runtime deps.
Security note
The import dial is a documented, bounded, human-confirmed, audited exception — not a new vulnerability surface. The threat-model write-up (why the exception stays narrow, and its accepted residual risk) is in guide/security.md. No new security advisories — a feature release.
v0.1.3 — DOCX/XLSX/PDF report renderers (issue #14)
Completes issue #14 — the three binary report formats — on top of v0.1.2. Reports now render as Markdown (source of truth), HTML, DOCX, XLSX, or PDF via preferences.default_report_format. Each format is a deterministic view over the same provenance-tracked (claims, gaps): the content — every claim, gap, and provenance entry — is identical across all five (SD-06); only the presentation differs. No new security advisories: this is a feature release. 323/323 tests (was 308, +15), typecheck + build green, conformance unaffected (no new egress surface).
What landed
- DOCX / XLSX / PDF renderers (
src/orchestrator/{docx,xlsx,pdf}.ts) — deterministic views over the same(claims, gaps):- DOCX — WordprocessingML package: headings, a clearly-labeled model-narrative Summary, findings grouped by task with inline claim refs, an explicit Gaps section, and a bordered provenance table. XML-escaped; multi-line claim text keeps its line breaks.
- XLSX — SpreadsheetML: one worksheet per section (Summary when present, Findings, Gaps, Provenance), inline strings; the report title is carried.
- PDF — a minimal PDF 1.4: A4 pages, base-14 Helvetica (regular/bold/oblique) with
WinAnsiEncoding, word-wrapped paginated flow (a provenance entry never splits across pages), a page-number footer, and a correct xref table.
- Zero new dependencies. The containers are hand-rolled (the repo keeps the npm install clean — 3 runtime deps): a deterministic STORE ZIP writer (
zip.ts— fixed timestamps + part order, so identical input → byte-identical archive) underpins DOCX/XLSX, plus shared XML escaping (xml.ts). - The binary seam. These are byte artifacts, not text — none has a lossless UTF-8 string form — so the string-based pipeline gained a binary path:
renderReportArtifact(format, input): Buffer(all five) alongside the text-onlyrenderReport;OrchestratorResult/LoopResult/ReportToolResultcarry the artifact in-band asreportArtifactB64(base64).- The File Manager gains a byte-exact
writeBinary(magic-prefix validated: ZIPPKfor docx/xlsx,%PDF-for pdf; journaled as base64 so undo is byte-exact; same confinement/ignore/confirmation gates + audit). A textwrite()to a binary name is refused fail-closed. - Wired through
createOrchestrator, the loop's multi-round consolidation + narrate re-renders (both handle binary), and the plugin'ssandy.report.
- Fail-closed, unchanged law.
REPORT_FORMATSnow lists all five, soloadSandyConfigadmits them; the format check stays fail-closed as defense in depth (a schema/renderer divergence is aConfigError, never a silent Markdown fallback). Provenance/claims are identical across formats (SD-06).
Verification
npm run typecheck && npm test && npm run build→ 323/323 (was 308, +15), green.- The hand-written PDF's structure (xref offsets, startxref, object numbering) was validated directly with a no-dependency check — every xref offset points exactly at its
N 0 objheader, single- and multi-page. - Conformance (in-process + the Docker/Firejail
boundary × modematrix) is unaffected: all rendering and writing is in-process through the existing confined File Manager — no new network/egress surface.
v0.1.2 — deferred product scope + review follow-ups
Closes the entire tracked scope opened after v0.1.1. v0.1.1 (b5fdfd2, 2026-08-23) was the security-fix cut (the 7 private GHSA advisories); v0.1.2 ships everything since it — the 12 review follow-up fixes (issues #1–#12), the docs consolidation (#13), and the six deferred product/hardening items (#14–#19 + the #16 approval-UX v2 half). No new security advisories in this cut: it is a feature/hardening release. 308/308 tests, typecheck + build green, in-process conformance + the Docker/Firejail boundary×mode matrix (byte-identical signatures) green.
Deferred product scope
| Issue | What landed |
|---|---|
| #14 | HTML report format — preferences.default_report_format: "html" renders the same (claims, gaps) as a deterministic HTML view (Markdown stays the source of truth, SD-06); unimplemented formats fail closed at config load. DOCX/XLSX/PDF remain the documented template for future renderers. |
| #15 | Recurring report templates — templates.json registry (validated by the same orchestratorRequestSchema + legal-tool catalog as any ad-hoc request); sandy run <template> and POST /run {"template"}; audited template_run. |
| #16 | Write-back (Q6), complete — v1 core: admin write_allowlist (always a subset of the read allowlist, CP-02) + PolicyApprovalGate with single-use, per-write, audited approvals; default ReadOnlyGate (refuse all writes). v2 half: a first-class consent flow (sandy.write.approve / sandy.write.revoke tools; sandy.write surfaces needsApproval for legal-but-unapproved tasks), approval expiry/revocation (policy.approval_ttl_seconds, default 1800s, capped at a day; explicit expiresAt may only shorten; new audited reasons approval-expired / approval-revoked), and per-arg constraints on allowlist entries (ajv-checked, strict: true — a malformed constraint fails closed, never a no-op). |
| #17 | SANDY_REAL_MODEL conformance leg — opt-in real-GGUF leg of the sandbox matrix (Firejail, no-egress); skipped, never failed, when the model/runtime is absent, so CI stays green. |
| #18 | In-service hard memory bound — opt-in sandbox.enforce_memory_limit: true wraps the bundled model in a cgroup v2 child with memory.max = sandbox.max_memory_mb; fails closed (degraded) where there is no cgroup delegation. The default ceiling is still the service manager's cgroup. |
| #19 | Multi-turn / agentic planning — opt-in preferences.max_planning_rounds (1–5): after each gather pass the model re-plans from the rounds gathered so far; every follow-up round passes the same schema + legal-tool-catalog gate as round 1; nothing-new de-dup; bounded, audited (standalone_replan), consolidated into one re-rendered report. |
Review follow-ups (issues #1–#12) + docs (#13)
- #1 egress-allowlist default-port matching · #2 undo audited (AU-01) · #3
deleteDirectory()dry-run consistency · #4 byte-exact binary undo · #5 model child killed on a failed invocation · #6 report-write failure surfacesreportErrorwithout discarding claims/gaps · #7SessionCache.get()check-then-act race closed · #8 JSONL audit write failures surfaced · #9 narrate prompt-injection threat model documented · #10 API worker event-driven wake (no busy-poll) · #11 Apache-2.0 LICENSE · #12 hostname-allowlist DNS-rebinding limitation documented. - #13 status-doc consolidation: explicit doc roles (DIARY = history, NEXT_STEPS = forward-looking state + roadmap, README = headline + links).
Verification
npm run typecheck && npm test && npm run build→ 308/308.- Conformance: in-process (6/6) + the Docker/Firejail
boundary × modematrix (plugin + standalone) with the byte-identical-signature identity check — the no-egress and runtime-agnosticity guarantees hold for both modes, now including the write-back path. - The launch success criterion (zero network egress outside declared MCP endpoints) is unchanged: write-back routes only through the existing MCP manager +
NetworkGuard.
v0.1.1 — first cut with all 7 security fixes
First release cut after the 2026-08-22 full-repo security review. Closes 7 private security advisories (2 High, 5 Medium) — every one fail-closed, with tests (178/178) and conformance (Docker + Firejail, plugin + standalone) green.
Security fixes
| Advisory | Severity | Fix |
|---|---|---|
| GHSA-h5c5-76pv-6g85 | High | SANDY_TEST_RUNTIME sandbox-detection bypass removed from production code (detectRuntime()); CLI test determinism now goes through runCli's SandyDeps.detection overrides. SB-03 "refuse to start without a boundary" can no longer be defeated by one env var. |
| GHSA-38wj-6mjh-2jf9 | High | MCP oauth/mtls auth no longer silently connects unauthenticated — authHeaders() throws "not yet implemented"; the failure surfaces via MCP-10 (mcp.failed in sandy check, RG-05 report gaps). |
| GHSA-qx23-r762-x2j9 | Medium | Loopback local API CSRF hardening: readJsonBody() requires application/json (415) and handleRequest() rejects foreign Origin headers (403). |
| GHSA-w84c-rwhv-mrgx | Medium | Undo (reverse()) re-resolves journal paths through PathConfinement — a symlink swapped in after the original mutation is refused (SandboxViolationError), not followed (TOCTOU escape closed). |
| GHSA-r885-qm59-2mxf | Medium | list() applies ignore_patterns against the confinement root (not the queried directory), so a direct list("secrets") no longer leaks filenames a pattern like secrets/*.key was meant to hide. |
| GHSA-rm4r-g5vv-mvrm | Medium | rename() requires the (forced-minimum) overwrite confirmation when the destination exists — rename can no longer defeat the overwrite gate write() enforces. |
| GHSA-6q24-xhv7-3jg6 | Medium | scripts/provision-model.sh now SHA256-pins and fail-closed-verifies the llama-server release tarball before extracting/executing it (it is executable code); a release/variant override without an explicit SANDY_LLAMA_SHA256 fails closed. |
Other changes in this release
- Per-advisory entries in
docs/DIARY.md(one per fix). - Version identity strings bumped to
0.1.1(package, plugin, MCP server/client).
Verification
npm run typecheck/npm run build— greennpm test— 178/178 (was 169 at review time; +9 security tests)- CI conformance matrix (Docker × Firejail × plugin × standalone) — byte-identical signatures, egress harnesses pass