I keep running into the same wall at work: I want an AI assistant to pull together the CRM, Jira, the wiki, the dashboards — the stuff I’d otherwise read by hand — and turn it into something I can act on. And I can’t, because that data lives behind the VPN, and every hosted assistant I’ve tried wants to ship it out the door. Egress rules and sandbox policies exist for good reasons, but they also block the one tool that would save hours. So I built Sandy — an assistant that’s designed to never leave the sandbox it’s running in, by construction, not by policy.

The name is the pitch

S — ANDBOXable. Sandy talks to internal systems only through MCP servers you explicitly declare, and it never touches the network or the filesystem except through a boundary it can prove it can’t leave. It gathers information — CRM, Jira, databases, wikis, observability tools, whatever you wire up — and turns it into a report with full provenance: every claim footnoted back to the exact source call that produced it.

Why “policy” wasn’t good enough

The obvious move is to point a frontier model at your internal systems and let it go. That fails in one of three ways: it phones home with your data (egress), it summarizes “from the CRM” without telling you which ticket or field it’s actually quoting (fabrication), or it does something you didn’t ask it to do (scope creep). Any one of those gets an assistant killed at the pilot stage in an enterprise. So the design goal for Sandy wasn’t “add guardrails” — it was to make all three impossible structurally.

An untrusted reasoner, a fixed executor

The whole architecture rests on one split. A reasoner — your host LLM in Claude Code or Codex, or a small local model — proposes a plan of MCP tool calls. Sandy, the executor, validates that plan against a policy, runs only what’s legal inside a sandbox it can prove it’s confined to, and turns the results into a report. The reasoner is swappable and never trusted; the executor is fixed, deterministic, and audited.

flowchart TD
    user["User (CLI / Claude Code / Codex)"]

    subgraph sandy["Sandy Service"]
        parser["Request Parser"] --> orch["Orchestrator"]
        orch --> router["Task Router"]
        router --> mcp["MCP Client Manager"]
        router --> files["File Manager"]
        enforcer["Sandbox Enforcer"]
        mcp -.-> enforcer
        files -.-> enforcer
    end

    subgraph boundary["Sandbox Boundary"]
        servers["MCP Servers"] --> internal["Internal Services"]
    end

    user --> sandy
    mcp -- "MCP protocol" --> servers

That split is what makes the rest of the guarantees fall out cleanly:

  • MCP-only communication. There’s no general HTTP client anywhere in Sandy. Every network dial goes through a single choke point — the NetworkGuard — which only allows http(s) to a host:port declared in config. No raw HTTP, no gRPC, no SSH, no “the model said to call that endpoint.” Not on the allowlist means it doesn’t happen.
  • Zero egress, and that’s a proof, not a promise. It’s demonstrated in-process and at the network level in Docker, and the enforcer is proven runtime-agnostic — the same config and request under Docker and under Firejail produce byte-identical behavior.
  • Least privilege. Per-server tool allowlists are applied before a tool is even wired in, and every request gets re-validated against the legal catalog. A model can only plan what the policy already allows, and it can’t retry its way into something illegal.
  • Fail closed. No sandbox boundary detected? Sandy refuses to start. Invalid config? It exits with a message instead of guessing. Writes are off by default — an admin has to allowlist them, and a human has to approve each one.
  • Auditable end to end. An append-only, structured log records every MCP call, every file mutation, every write attempt, every model invocation, every blocked egress.

Reports that can’t fabricate

The output is Markdown, HTML, DOCX, XLSX, or PDF — all five rendering the same content, because the report is a deterministic function of the claims and gaps. No model touches the scaffolding, so it’s stable and testable. Every claim carries a footnote back to its source server, tool, args hash, and timestamp. And when a source doesn’t contribute — a 500, an empty result, an unreachable server — Sandy records an explicit gap instead of papering over the hole with a plausible-sounding number. A report with visible gaps is more trustworthy than a clean one you can’t verify.

Two ways to run it

Plugin mode installs into Claude Code or Codex. The host LLM does the reasoning and calls a small set of sandy.* tools over MCP; Sandy executes deterministically inside the sandbox. This is the fast path if you’re already living in a coding assistant.

Standalone mode bundles a small local model (Qwen3-4B-Instruct by default) as a subprocess on loopback, inside the same sandbox. The model plans, Sandy validates and executes, the model narrates (clearly labeled), and the whole system has zero external egress by construction — no frontier model in the loop at all. This is the one I actually care about for fully air-gapped, VPN-restricted setups.

Both modes share the same core: the enforcer, the MCP manager, the file manager, the orchestrator, the audit log. The only thing that changes is who sits in the reasoner seat.

Where it’s at

v0.1.3 — both modes are built, and 323 tests pass with typecheck and build green. I ran a full-repo security review that closed 7 findings (shipped as private advisories), followed by a dozen more fix PRs. It’s Apache-2.0, with a deliberately minimal dependency set — the binary report writers are hand-rolled rather than pulled in as libraries, to keep the install surface small.

If you want to try it yourself:

git clone git@github.com:techeretic/sandy.git && cd sandy
npm ci && npm run build
node bin/sandy.js check --config config/sandy.json
node bin/sandy.js run <request.json> --config config/sandy.json

Point it at one internal MCP server you already have, ask it for a small report, and follow one footnote back to the exact call that produced it. Then try to make it reach an endpoint you didn’t declare, and watch it refuse — and log the refusal.

Full docs are in the User Guide, and the longer pitch is in the repo’s product post.