Agents

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.

agent.js
// 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 isolation underneath →

# 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.

ToolReaches onlyCredential
osv.queryapi.osv.dev/v1/querynone
npm.metadataregistry.npmjs.orgnone
github.pr.readapi.github.com/repos/…host env, agent never sees it
model.callapi.openai.com/v1/responseshost 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.