Skip to content

Releases: techeretic/sandy

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

Choose a tag to compare

@techeretic techeretic released this 26 Sep 05:02
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.

v0.2.1 — three correctness fixes

Choose a tag to compare

@techeretic techeretic released this 24 Sep 05:58

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 a report spec now writes its report. Previously a model-less sandy run whose request omitted report gathered the data, printed the report to stdout, and wrote no file to report_output_dir. Root cause: the report file write is gated on request.report being present (it is not tied to the model narrate step), which a bare request does not set. sandy run is a report-producing verb, so it now always writes the default report (title = goal, timestamped filename) into report_output_dir — it never silently drops the artifact. The default is applied in the CLI run path only; the plugin's sandy.gather deliberately stays file-less.

  • sandy import — default staging anchors to the config dir, not the cwd. stage resolved .sandy-import/ against the process cwd, so running sandy import from another directory left a stray .sandy-import/<hash>.json behind. The default now anchors to the config directory — the same anchor as report_output_dir — so a run from any directory stages next to sandy.json. An explicit --stage-dir override 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.mjs printed http://0.0.0.0:<port>/mcp on ready; the in-process harness dials exactly that URL, and 0.0.0.0 is a valid bind wildcard but not a dialable destination on Linux, so the three egress tests hung there. It now advertises 127.0.0.1 while keeping the 0.0.0.0 bind — 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 package files and 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 run with no report spec asserts exactly one file lands in report_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.1 in package.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

Choose a tag to compare

@techeretic techeretic released this 22 Sep 00:41

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).
  • --apply merges into mcp-servers.json + appends the computed sandbox.allowed_network lines, 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 an import_fetch event (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 exact allowed_network lines to add, the env-var names (never values), and the per-server allowlist — which is never auto-expanded (confirm it or pass --tools, validated against capabilities).
    • Explicit apply only. --apply is 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 full loadSandyConfig as 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 --auto path is a documented follow-up (the core has no LLM client in v1 — see docs/IMPORT_DESIGN.md).
  • Audit events — import_fetch + import_staged join the forensic record (2 new types in src/audit/logger.ts).
  • CLI — the import verb 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.json description 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 × mode matrix + byte-identical-signature identity check): the import dial is a separate, confirmed operator action, not a runtime egress path — the matrix runs sandy run / sandy ask (never sandy 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)

Choose a tag to compare

@techeretic techeretic released this 29 Aug 08:08

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-only renderReport; OrchestratorResult / LoopResult / ReportToolResult carry the artifact in-band as reportArtifactB64 (base64).
    • The File Manager gains a byte-exact writeBinary (magic-prefix validated: ZIP PK for docx/xlsx, %PDF- for pdf; journaled as base64 so undo is byte-exact; same confinement/ignore/confirmation gates + audit). A text write() 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's sandy.report.
  • Fail-closed, unchanged law. REPORT_FORMATS now lists all five, so loadSandyConfig admits them; the format check stays fail-closed as defense in depth (a schema/renderer divergence is a ConfigError, 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 obj header, single- and multi-page.
  • Conformance (in-process + the Docker/Firejail boundary × mode matrix) 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

Choose a tag to compare

@techeretic techeretic released this 28 Aug 17:17

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 surfaces reportError without discarding claims/gaps · #7 SessionCache.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 × mode matrix (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

Choose a tag to compare

@techeretic techeretic released this 23 Aug 05:43

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 — green
  • npm test — 178/178 (was 169 at review time; +9 security tests)
  • CI conformance matrix (Docker × Firejail × plugin × standalone) — byte-identical signatures, egress harnesses pass