cruft inside an OS-level sandbox (container, VM, seccomp) as well, and treat the in-process boundary as defense in depth.Run the code your model wrote.
Grant it one tool at a time.
cruft agent runs a script, code an LLM wrote, inside a fail-closed, budgeted compartment. Every tool it can call is one you named on the command line, every call crosses an audited membrane, and the whole run replays from its log.
// cruft agent run agent.js --tool=echo --audit-log=audit.jsonl \ // --context-json='{"goal":"triage"}' emit({ kind: "start", goal: context.goal }); const r = callTool("echo", { value: context.goal }); // a granted tool callTool("readFile", { path: "/etc/passwd" }); // never granted // the run's audit log, one JSON record per action: {"type":"tool_call", "tool":"echo", "policy":"allowed"} {"type":"tool_denial", "tool":"readFile", "policy":"denied"} // threw inside the agent
The registry is an allowlist
A tool it was not granted is denied
An agent reaches the outside only through callTool(name, args), and only the tools you passed as --tool=<name> are in its registry. A call to anything else is refused before it runs: the call throws agent tool denied inside the agent, the run records a tool_denial, and nothing external happens. There is no ambient fallback, and the agent cannot widen its own set.
# the full manifest, without running anything $ cruft agent tool list echo available=true worker=true readFile available=with_--fs-read osv.query available=true pinned=api.osv.dev model.call available=with_--model-provider ... # doctor states the posture in one line $ cruft agent doctor "tools": "explicit_registry" "ambient_authority": "denied_by_default"
// arguments are cloned in, results cloned out; // no live reference crosses the boundary const r = callTool("writeArtifact", { path: "report.md", content: draft, }); // each call resolves to exactly one audited outcome {"type":"tool_call", "arg_bytes":214} {"type":"tool_result", "result_bytes":88} // ran {"type":"tool_denial"} // never allowed {"type":"tool_error"} // ran and threw
One membrane, one contract
Every call is cloned, budgeted, and classified
A tool call crosses a fixed boundary. Arguments are JSON-cloned into the tool and the result cloned back out, so neither side hands the other a mutable object. Both directions are capped by --max-tool-arg-bytes and --max-tool-result-bytes. And every call resolves into exactly one audited outcome: an invalid argument shape, a denial, or a tool error. The log names which, so a replay tells a call that was never allowed apart from one that ran and failed.
No general network
A network tool reaches one address
There is no ambient fetch inside an agent. A network tool reaches a single pinned endpoint and nothing else, and its credential comes from a host environment variable you name. The agent never sees the token, and the audit records the variable name and the credential mode, never the value.
| Tool | Reaches only | Credential |
|---|---|---|
osv.query | api.osv.dev/v1/query | none |
npm.metadata | registry.npmjs.org | none |
github.pr.read | api.github.com/repos/… | host env, agent never sees it |
model.call | api.openai.com/v1/responses | host env, agent never sees it |
The endpoint is fixed in the tool, not chosen by the agent, so a compromised agent cannot redirect a lookup or open a socket of its own. A credential is supplied by name (--model-api-key-env=OPENAI_KEY); the token value is never written to the audit log.
The grant is data
The whole authority is one file you can review
A run's entire authority pins into agent-policy.json: the tools it grants, the resource budgets, the modules and packages it admits, each with an fnv1a64 hash so a later change to a source is a validation failure, not a silent substitution. Check it into the repository, review it in a pull request, validate it in CI, and replay the run from its log. cruft agent policy explain also prints the run's non-claims, so its boundary is stated up front.
// agent-policy.json { "schema_version": 1, "agent": "agent.js", "tools": ["readFile", "model.call"], "budgets": { "timeout_ms": 500, "max_steps": 100000 }, "packages": { "left-pad": "packages/left-pad.js" }, "package_integrity": { "left-pad": "fnv1a64:be63..." } } # the check to run in CI $ cruft agent policy validate --strict --project-confined .
# a durable, resumable job: # suspend at a tool call or an approval, # persist to disk, resume after a restart $ cruft agent schedule start --project ./review $ cruft agent schedule tick <job-id> --store ./jobs $ cruft agent schedule input <job-id> --token ... # gate a specific tool on a human decision $ cruft agent run agent.js \ --tool=process --require-approval=process
Beyond one turn
Long-running agents, still fail-closed
An agent is a compartment, so the same boundary holds whether it runs once or for a week. cruft agent schedule turns the one-shot run into a durable job: it can suspend at a tool call, a timer, or a human approval, persist its whole state, and resume after a full process restart, still inside the same registry and budgets. A tool can be gated on an out-of-band approval before it is allowed to run at all.
The model underneath
The agent compartment productizes two things Cruft already does: the isolation of a Compartment and the economics of a worker. Read the primitives it stands on.