The audited agent compartment
cruft agent run executes a script, canonically code an LLM wrote, inside a fail-closed, budgeted compartment where every authority is explicit and every action is a JSONL audit record. Covers the programming model, the full run flag reference, budgets, approvals, secrets, audit records, and doctor.
cruft agent runs a piece of code, canonically code an LLM wrote, inside a fail-closed, budgeted, fully audited compartment. It is the isolation story (compartments) productized for the agent use case: every authority is explicit, every action is a JSONL audit record, every resource has a budget, and the whole run is replayable from that log.
cruft agent run agent.js --timeout-ms=500 --audit-log=audit.jsonl \
--tool=echo --context-json='{"goal":"summarize"}'
cruft agent doctor # machine-readable control inventory
cruft agent replay audit.jsonl # summarize a recorded run
Beyond a single run, cruft agent schedule turns the one-shot sandbox into a durable, resumable, multi-turn job: an agent can suspend at a tool call, a timer, or a human approval, persist its whole state to disk, and resume, even after a full process restart, still inside the same fail-closed compartment. That durability makes cruft agent a harness for long-running agents whose jobs outlive the process that started them.
This page is the overview: the programming model, the full cruft agent run flag reference, budgets, async behavior, the approval and secret mechanics, the audit-log record types, and doctor. Three companion pages go deeper:
- The durable multi-turn scheduler.
- Every tool, what it can touch, and its schema.
- Projects, policy files, and the commands that build and pin them.
Subcommand map
cruft agent is one verb with many subcommands. Running an agent and inspecting a run live here; authoring and pinning a project live on the companion pages.
| Subcommand | What it does | Detailed on |
|---|---|---|
run | Execute an agent under explicit authority and budgets, writing an audit log | this page |
schedule start/tick/input/cancel/replay/status/list | Drive a durable, resumable, multi-turn job | Scheduler |
doctor [--json/--human] [--project <dir>] | Emit the control inventory (optionally scoped to a project) | this page |
replay [--json/--human] <audit.jsonl> | Summarize a recorded run; replay --diff [--events] <before> <after> compares two logs | this page |
history [--json/--human] <project/policy/audit.jsonl> | List recorded run ids for a project or log | this page |
tool list / tool describe <name> | Inspect the tool manifest and a tool's schema | Agent tools |
template list / template describe <name> | Inspect the workflow scaffolds | Projects & policy |
init <dir> [--blank/--template=<name>] [--force] | Scaffold a project | Projects & policy |
policy validate / policy explain / policy diff | Validate, explain, or diff a policy file | Projects & policy |
add-tool / remove-tool, add-module / remove-module, add-package / remove-package, add-import-hook / remove-import-hook | Edit a project's authority | Projects & policy |
set-context / unset-context, set-budget / unset-budget, set-session / unset-session, set-worker / unset-worker | Edit a project's inputs, budgets, and options | Projects & policy |
bundle <project/policy> --out <dir> [--run-id=<id>/--failed] | Export a run's evidence | Projects & policy |
reset [--dry-run] [--rollback] <project/policy> | Reset the session store, with one backup to roll back to | Projects & policy |
approval allow/deny <approval.jsonl> <approval-id> [--reason=<text>] | Record an out-of-band approval decision | this page |
hash <path> [--kind=source/module/import-hook/package] [--specifier=<spec>] | Compute the fnv1a64: integrity hash the caps expect | Projects & policy |
The agent programming model
An agent file is a script evaluated in strict mode inside the compartment, with a small endowed surface:
| Endowment | Contract |
|---|---|
context | Your --context-json, deeply frozen (hardened recursively), the agent cannot mutate its own instructions |
emit(event) | Append a JSON event to the outbox; the event is JSON-cloned (no live references escape) and budget-checked |
callTool(name, args) | Invoke an explicitly registered tool; args JSON-cloned and byte-budgeted both directions |
importValue(spec, export) | Promise of a value from an explicitly admitted module (see Projects & policy for module policy) |
state | A single-turn scratch object, snapshotted into the audit log at turn end |
Round trip:
// agent.js
emit({ kind: "start", goal: context.goal });
const r = callTool("echo", { value: context.goal });
emit({ kind: "tool-result", r });
{"type":"run_start","tools":["echo"],"event_budget":{...},...}
{"type":"event","event":{"kind":"start","goal":"docs-probe"},...}
{"type":"tool_call","tool":"echo","policy":"allowed","args":{...},"arg_bytes":22,...}
{"type":"tool_result","tool":"echo","result":{...},"result_bytes":22,...}
{"type":"availability_check",...}
{"type":"authority_check","object_prototype_polluted":false,"global_polluted":false,...}
{"type":"state_snapshot","state":{},...}
Note the authority_check record: the runtime verifies after the turn that the agent did not pollute Object.prototype or the global. This is tamper evidence recorded after the turn, on top of the prevention the boundary already enforces.
Fail-closed authority
Nothing is ambient.
- Tools are an explicit registry. With no
--tool=echoflag,callTool("echo", …)throwsagent tool denied: echo, the run fails, and the audit log records atool_denial. The built-in tool set exists for harness use;processrequires a supervisor that is not yet available and says so. The full manifest is on Agent tools. - Modules are an explicit policy. Imports resolve only through
--module specifier=path(inline admission),--package specifier=pathwith--package-integrity specifier=fnv1a64:hash(hash-capped package graphs), or--import-hookwith its own source-hash cap. Ambientnode_modulesfallback is denied.--entry-moduleselects a static module entrypoint instead of script mode. See Projects & policy. - The tool membrane clones and classifies. Arguments and results are JSON-cloned across the boundary (no shared mutable objects), byte-budgeted, and failures are classified (invalid shape vs. denial vs. tool error) in the audit record.
cruft agent run flag reference
Every flag below appears in the shipping run usage. The agent file is the one positional argument (or --entry-module in module mode). Alternatively, pass --project=<dir> or --policy=<agent-policy.json> to take identity, authority, budgets, and inputs from a project (see Projects & policy); flags on the command line still apply.
Identity and output
| Flag | Meaning |
|---|---|
--project=<dir> | Run from a project directory, sourcing policy, inputs, and budgets |
--policy=<agent-policy.json> | Run from a standalone policy file |
--run-id=<id> | Label the run; the id appears in the audit log and in history/bundle |
--audit-log=<path> | Where to write the JSONL audit log |
--worker | Run under the worker host (today forwards emits, sync/promise tools, state, close, module entry, session, source hooks, package caps, and audit notes; the full membrane is not yet available) |
Tools and approval
| Flag | Meaning |
|---|---|
--tool=<name> | Register a tool. Valid names: echo, fail, slow, readFile, listFiles, writeArtifact, osv.query, npm.metadata, github.issue.read, github.pr.read, github.pr.files.list, github.release.latest.read, github.file.read, github.compare.read, github.commit.read, github.repo.read, github.workflow.run.read, github.workflow.jobs.list, github.check.runs.list, model.call, process |
--require-approval=<tool> | Gate a tool: each call is held pending an approval decision before any effect |
--approve-tool=<tool> | Pre-approve a gated tool for this run |
--approval-log=<approval.jsonl> | Durable approval-decision log consulted at the gate |
--approval-max-age-ms=<n> | Treat approval-log decisions older than this as stale |
Tool inputs
| Flag | Meaning |
|---|---|
--fs-read=<path> | Grant read-only access to a precollected path cap (enables readFile/listFiles) |
--fs-read-include=<glob> | Restrict the read cap to matching paths |
--fs-read-exclude=<glob> | Exclude matching paths from the read cap |
--fs-write=<dir> | Grant durable writes under one output root (enables writeArtifact) |
--osv-fixture=<fixture.json> | Serve osv.query from a deterministic fixture instead of the live API |
--model-fixture=<fixture.json> | Serve model.call from a deterministic fixture |
--model-provider=openai.responses | Route model.call to the OpenAI Responses provider |
--model-api-key-env=<ENV> | Name the env var holding the model API key (value never audited) |
--github-token-env=<ENV> | Name the env var holding an optional GitHub bearer token (value never audited) |
--secret=<tool=ENV> | Bind a named env var as a secret scope for one tool (see Secrets) |
--process-command=<name=path> | Admit one named executable for the process tool |
--process-cwd=<dir> | Working directory the process tool runs in |
--process-env=<KEY=value> | Admit one environment entry for the process tool |
--named-network-cache-dir=<dir> | Persistent cache directory for named-network tools |
--named-network-cache-mode=read-through/offline | Fetch-and-cache, or serve only from cache |
--named-network-cache-max-age-ms=<n> | Maximum age of a cache entry served |
--named-network-cache-max-entries=<n> | Maximum entries kept in the cache |
--named-network-retry-attempts=<0..3> | Bounded live retries; backoff and outcome are audited |
--import-hook <specifier=path> | Admit an import hook by specifier and source path |
--import-hook-integrity <specifier=fnv1a64:hash> | Cap an import hook to a source hash |
Modules
| Flag | Meaning |
|---|---|
--module <specifier=path> | Admit a single module by specifier and path |
--package <specifier=path> | Admit a package graph by specifier and path |
--package-integrity <specifier=fnv1a64:hash> | Cap a package graph to a source hash |
--entry-module=<specifier> | Run an admitted module as the entrypoint instead of script mode |
Context, state, and session
| Flag | Meaning |
|---|---|
--context-json=<json> | The context endowment, deeply frozen for the agent |
--state-json=<json> | Seed the state object at turn start |
--session-file=<path> | File-backed session for turn lineage (advancing turn_id) |
--redact-field=<field> | Redact a named field from audited events and tool payloads |
--expect-event=<kind=schema.json> | Validate emitted events of a kind against a JSON schema |
Budgets
| Flag | Meaning |
|---|---|
--timeout-ms=<ms> | Wall-clock gate for the turn (uncatchable) |
--tool-timeout-ms=<n> | Async tool promise settlement |
--max-events=<n> | Event outbox count |
--max-event-bytes=<n> | Cumulative event outbox size |
--max-tool-arg-bytes=<n> | Tool argument bytes per call |
--max-tool-result-bytes=<n> | Tool result bytes per call |
--max-state-bytes=<n> | State snapshot size |
--process-output-stream-chunk-bytes=<n> | Chunk size bound for process stream output |
--max-microtasks=<n> | Microtask turn budget |
--max-steps=<n> | Step budget |
--max-rss-mb=<n> | Memory ceiling, enforced by a child-process supervisor |
Budgets: everything is bounded
| Budget | Default | Notes |
|---|---|---|
--timeout-ms | 250 | Wall-clock for the turn, the compartment's uncatchable gate: a try/catch around while(true){} never sees the termination |
--tool-timeout-ms | 250 | Async tool promise settlement |
--max-events / --max-event-bytes | 128 / 64 KiB | Outbox count and cumulative size; the 4th emit under --max-events=3 throws agent event budget exceeded: events |
--max-tool-arg-bytes / --max-tool-result-bytes | 64 KiB each | Tool payloads, each direction |
--max-state-bytes | 64 KiB | The state snapshot |
--max-microtasks | 10,000 | Microtask turn budget |
--max-steps | off | Step budget |
--max-rss-mb | off | Memory, enforced by running the agent under a child-process supervisor |
Pending promises at turn end are detached, not awaited, an agent cannot extend its life by leaving work dangling. Project policy files carry their own budget block (for example the package-review template pins timeout_ms: 500, tool_timeout_ms: 250, max_events: 16); those values override the defaults above when you run from a project.
Async, timers, and the availability check
Asynchrony is bounded and audited the same way authority is. At turn end the runtime writes an availability_check record, a self-attestation of exactly how each async effect was handled, and the behavior behind each field is enforced, not advisory:
- No ambient timers. An agent compartment has no
setTimeoutorclearTimeout(typeof setTimeout === "undefined"), attested astimer_handles: "not_endowed_use_scheduler_sleep_protocol". Microtasks still run (Promise.resolve().then(…)fires within the microtask budget), but any real wall-clock delay must go through the durable scheduler'sscheduler.sleep. There is no way for an agent to arm a background timer. - Pending promises are detached. A promise that never settles does not hang the turn; at turn end it is detached (
pending_promise_disposition: "detached_at_turn_end") and the run still endsok. An agent cannot extend its life by leaving async work dangling. - Async tool calls are independently timed.
callTool(...).then(...)is gated by--tool-timeout-ms, separate from the wall timeout. An overrun writes atool_timeoutrecord (with the budget), the success continuation never fires, and the tenant's rejection handler runs, the run can still endokbecause the rejection was handled. - Revocation crosses the async boundary. A synchronous
close()writes arevocationrecord; a queued microtask continuation that touches a surface (emit,callTool,state.set) after the close is refused and logged as arevocation_denial. A continuation cannot escape a revocation that already happened this turn. - Unavailable capabilities fail closed, visibly. An async effect that needs an ambient capability the build does not endow (for example the
processtool's supervisor, or the full worker membrane) does not run best-effort: it aborts before any tenant event and writes a singleunsupported_controlrecord naming thecontroland a…required_not_availablereason. The effect is refused and named, never partially performed.
Approval workflow
Any tool can be put behind a pre-effect gate. Under --require-approval=<tool>, each call to that tool is held before it can touch anything; the gate consults --approve-tool=<tool> (a pre-approval for this run) and the durable --approval-log=<approval.jsonl>. Decisions in the log older than --approval-max-age-ms=<n> are treated as stale and do not satisfy the gate.
Decisions are recorded out of band with:
cruft agent approval allow audit/approval.jsonl <approval-id> --reason="reviewed diff"
cruft agent approval deny audit/approval.jsonl <approval-id> --reason="unexpected host"
The gate runs the same way on the same thread and under --worker (the worker path routes the decision through its host-call RPC). Approvals and denials are replay-visible: they appear in the audit log so a reviewer sees exactly which calls were held and how they were resolved.
Secrets and named-network cache
Secrets are env-only. --secret=<tool=ENV> binds a named environment variable as a secret scope for one tool; --model-api-key-env and --github-token-env name the env vars for the model and GitHub credentials. In every case the audit record stores the env-var name and the credential mode, never the value. There is no way to hand a tool a literal secret on the command line, so the value cannot leak into a log or a shell history.
The named-network tools (osv.query, npm.metadata, the github.* readers, and model.call) can be backed by a persistent cache. --named-network-cache-dir sets the directory; --named-network-cache-mode=read-through fetches and caches, while offline serves only from the cache and never touches the network. --named-network-cache-max-age-ms and --named-network-cache-max-entries bound freshness and size, and cache hits and misses are replay-visible so there is no hidden freshness claim. --named-network-retry-attempts=<0..3> adds bounded live retries whose backoff and outcome are also audited.
Audit log record types
The JSONL audit log is the run's complete record. The type field takes these values:
type | When it appears |
|---|---|
run_start / run_end | Bookends: tools and budgets at the top, status (ok or a reason) at the bottom |
event | Each emit(event) from the agent |
tool_call / tool_result / tool_error | A callTool, its result bytes, or a throw/transport failure |
tool_denial | A call to an unregistered or ungranted tool |
tool_timeout | An async tool exceeded --tool-timeout-ms |
tool_approval_pending / _granted / _denied / _stale | The approval gate's decisions |
availability_check | Post-turn attestation of how each async effect was handled |
authority_check | Post-turn check that Object.prototype and the global were not polluted |
unsupported_control | An effect that needs an unavailable capability, refused and named |
revocation / revocation_denial | close() fired / a post-close surface use was refused |
state_snapshot / state_reset | The state object at turn end / its inter-turn reset |
session_load / session_save | Turn-lineage session read and write |
scheduler_await | A turn suspended on the durable scheduler (timer / tool / input) |
module_import / module_result / module_denial | Admitted-module resolution (async via a promise record) |
named_network_cache_stale / _eviction / named_network_retry | Named-network cache and retry events |
process_output_stream / process_output_budget | Streamed process output and its byte-budget disposition |
budget_exceeded / timeout / post_turn_failure / schema_validation | Budget breaches, timeouts, post-turn failures, and --expect-event results |
audit_note / audit_controls | A redacted tenant note / the effective-control echo |
worker_host_call | A worker-routed host-call RPC |
Replay also surfaces module imports and denials, session loads and saves, budget breaches, timeouts, and RSS breaches. Replay is a pure function of the log: cruft agent replay audit.jsonl reduces it to a one-glance summary of runs, events, tool calls, results, denials, and errors, and cruft agent replay --diff [--events] before.jsonl after.jsonl compares two recorded runs (add --events to diff the event streams too).
State, sessions, and turns
State is deliberately a single-turn store: the state object is snapshotted to the audit log at turn end and reset between turns (a counter agent run twice through the same --session-file reports count: 1 both times). --session-file provides turn lineage, the session records the advancing turn_id, not ambient memory. An agent that should remember something must emit it, and its operator must feed it back through --context-json or --state-json: memory crosses the boundary explicitly, where it is visible and auditable, or not at all.
cruft agent doctor
Doctor emits one JSON object inventorying every control and its exact status. Two controls are reported as currently not available: the process-tool supervisor (so the shell, exec, and spawn tools are listed unavailable with reason=process_tool_supervisor_required_not_available), and the full worker membrane (--worker today forwards emits, tools, state, and the rest, but the complete membrane is required_not_available). Its closing field is the discipline in one line:
"public_claim_predicate": "sandbox claims must be scoped to listed controls"
A sandbox claim about cruft agent may only cite what doctor lists as enforced, the anti-overclaim rule, machine-checkable. Pass cruft agent doctor --project <dir> to scope the inventory to a project's resolved authority.
Reading the design
Three ideas organize the design:
- Audit is the product. The JSONL log is complete enough that replay is a pure function of it; the run's story is data.
- Budgets compose with authority. Capability control says what an agent may touch; budgets say how much, count, bytes, steps, microtasks, memory, time. The boundary enforces both; the agent is never asked to limit itself.
doctorscopes the claims. Unfinished controls are listed as not available rather than omitted, and the public claim predicate scopes what may be said to what is listed.