toolwrit
# Toolwrit
**English** · [Deutsch](README.de.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [Español](README.es.md)
**A written authority for AI agents.** Allowlist the tools, cap the budget, prove what happened.
Apache-2.0 · Node >= 20 · Python >= 3.10
A writ is a written order from an authority. The policy file is that order; the *return of the writ* — the historical record of what was done under it — is the audit log. Both halves of the product are in the name.
Toolwrit is a **library and a sidecar, not a gateway**. Your traffic never leaves your infrastructure. You do not point your agent at someone else's cloud — you wrap your tool handlers in three lines, or you put `toolwrit run --policy toolwrit.yaml --` in front of an MCP server you already run.
---
## The problem in 5 lines
An agent with tool access has whatever authority its tools have.
A `fs.write` tool is a write primitive; a `billing.refund` tool is a refund primitive. The model decides when to pull them.
Prompt-level guardrails — "never touch files outside the workspace" — are advisory. They live inside the same text channel the attacker (or the confused model) controls.
You need enforcement that sits **outside the model**: a check that runs on the call itself, cannot be argued with, and leaves a record.
That is all Toolwrit is.
---
## 60-second start
```
npm install toolwrit
```
The package installs a binary named `toolwrit`. The Python port speaks the same
policy language and writes a compatible audit chain — see
[`js/docs/python.md`](js/docs/python.md). Until it is on PyPI, install it from the
repository; it installs the same `toolwrit` binary:
```
pip install "git+https://github.com/emektor/toolwrit.git#subdirectory=python"
```
**`toolwrit.yaml`**
```yaml
version: "1"
name: support-agent
default: deny
budget:
calls: 50
usd: 2.00
rules:
- id: read-workspace
tools: ["fs.read", "fs.list"]
effect: allow
when:
path:
startsWith: ["/srv/workspace/"]
excludes: [".."]
- id: no-secrets
tools: ["fs.*"]
effect: deny
when:
path:
matches: "\\.env|id_rsa"
```
**The three-line code change.** Wherever you currently execute a tool call, wrap it:
```ts
import { Toolwrit, ToolwritDenied, loadPolicyFile } from 'toolwrit';
const toolwrit = new Toolwrit({
policy: loadPolicyFile('./toolwrit.yaml'),
auditFile: './audit.jsonl',
});
// before: const result = await tools[name](args);
// after:
const result = await toolwrit.guard(name, args, () => tools[name](args));
```
That is the whole integration. `guard` evaluates the call, writes the decision to the audit chain, and only then invokes your handler. A refusal throws `ToolwritDenied` — never a silently skipped side effect:
```ts
try {
return await toolwrit.guard(name, args, () => tools[name](args));
} catch (err) {
if (err instanceof ToolwritDenied) {
// Hand the boundary back to the model so it can adapt.
return { isError: true, content: [{ type: 'text', text: err.message }] };
}
throw err;
}
```
Metering is explicit, because the pricing assumption should be visible in your code rather than guessed by ours:
```ts
toolwrit.meter(inputTokens, outputTokens, { input: 0.003, output: 0.015 }); // USD per 1k tokens
toolwrit.spend(0.02); // non-token spend
```
And at the end of the run:
```ts
console.log(toolwrit.usage()); // { calls: 1, tokens: 1500, usd: 0.0281, bytes: 512, startedAt: 1789035198799 }
console.log(toolwrit.head()); // a 64-char sha256 — the head of this run's audit chain
```
---
## The MCP one-liner
If your agent already talks to an MCP server, you do not need to touch application code at all. Put Toolwrit in front of the server process. Every `tools/call` that crosses the boundary is enforced against your policy.
Before, in your MCP client config:
```json
{ "command": "npx", "args": ["-y", "@acme/filesystem-mcp", "/srv/workspace"] }
```
After:
```json
{
"command": "toolwrit",
"args": [
"run", "--policy", "./toolwrit.yaml", "--audit", "./audit.jsonl",
"--", "npx", "-y", "@acme/filesystem-mcp", "/srv/workspace"
]
}
```
Or from a shell:
```
toolwrit run --policy ./toolwrit.yaml --audit ./audit.jsonl -- npx -y @acme/filesystem-mcp /srv/workspace
```
A refusal is returned to the agent as an **MCP tool error, not a protocol error**. That distinction matters: a protocol error looks like a broken server and the model retries blindly, while a tool error is content the model reads — it sees the boundary, sees which argument was wrong, and adapts. The stated reason is the same text a human operator gets.
### The rest of the CLI
```
toolwrit run --policy <file> [--audit <file>] [--run <id>] -- <command> [args...]
toolwrit verify <audit.jsonl> [--against <anchor.jsonl> [--require-timestamp] [--max-lag <seconds>]]
toolwrit receipt <audit.jsonl> [--json]
toolwrit anchor <audit.jsonl> --to <anchor.jsonl> [--tsa [url]] [--tsa-ca <roots.pem>]
toolwrit check --policy <file> --tool <name> [--args <json>]
toolwrit explain --policy <file>
toolwrit drift <audit.jsonl>... [--run <id>] [--factor <n>] [--json]
toolwrit stamp <file>... [--out <dir>] [--tsa <url>] [--check]
```
| Command | Purpose | Exit codes |
| --- | --- | --- |
| `run` | Wrap an MCP server process; enforce every `tools/call`. | passthrough |
| `verify` | Verify the hash chain of an audit file. With `--against`, also check its head still matches the receipt anchored for that run, and the receipt's timestamp when it has one. | `0` every requested check passed, `1` any failed |
| `receipt` | Summarise a run as one object — plan, usage, outcome, warnings. `--json` for one line of JSON. | `0` chain verifies, `1` it does not |
| `anchor` | Append that receipt to an append-only anchor file. `--tsa` has a Time-Stamp Authority sign it first. | `0` appended, `1` the chain does not verify or the timestamp failed |
| `check` | Evaluate a single hypothetical call against a policy. | `0` allow, `1` deny, `2` ask |
| `explain` | Human-readable policy summary for a reviewer. | `0` |
| `drift` | Compare one run against the runs before it. See [Drift](#drift). | `0` normal or too little history, `1` drift or a broken chain |
| `stamp` | Timestamp any file, or with `--check` verify it still matches its stamp. See [Stamping files](#stamping-files). | `0` every file stamped or verified, `1` any failed |
`check` exists for CI. A policy is a security control, so regressions in it should break the build:
```
toolwrit check --policy toolwrit.yaml --tool fs.read --args '{"path":"/srv/workspace/notes.md"}' # expect 0
toolwrit check --policy toolwrit.yaml --tool fs.write --args '{"path":"/etc/passwd"}' # expect 1
toolwrit check --policy toolwrit.yaml --tool shell.exec # expect 1
```
Assert what your policy must *refuse*, not only what it permits. Allowlists rot in the permissive direction.
---
## How a decision is made
`evaluate(call, ctx)` is a pure function of **policy + call + ledger state**. No clock reads, no I/O, no model in the loop. The same three inputs always produce the same `Decision`, which is what makes the audit log replayable months later.
The ladder, in order:
1. **Budget.** An exhausted budget denies everything, whatever the rules say. `calls`, `tokens`, `usd`, `bytes` and `seconds` are checked in that order; the first one at or over its limit wins.
2. **Rate limits.** Among rules whose tool glob and argument constraints matched, any rule with a spent `limit` denies immediately.
3. **Effect precedence among matching rules: `deny` beats `ask` beats `allow`.** Rule order in the file does not matter. You cannot accidentally shadow a deny by putting an allow above it.
4. **No match at all** falls through to `policy.default`, which is **`deny`** unless you set it otherwise.
Deny-by-default is the whole posture. An unknown tool is denied. A known tool with arguments no rule accepts is denied. An `ask` rule with no `onAsk` handler configured is denied — an unattended run must never silently upgrade itself to allowed.
Two details worth internalising:
- A rule with `effect: deny` whose `when` constraints do *not* hold simply does not apply. It is not a trapdoor to allow; the call still has to find an allow rule of its own.
- Rate limits count entries attributed to that rule id in the audit history. Denied calls are not counted — being refused does not burn your quota.
### A worked near-miss
Using the policy above, the agent asks for `fs.read` with `{"path": "/etc/passwd"}`. The `read-workspace` rule targets `fs.read`, so it is considered — but its `startsWith` constraint rejects the argument. No other rule allows it, so the default applies:
```json
{
"effect": "deny",
"rule": null,
"reason": "no rule allows \"fs.read\" with these arguments",
"violations": [
{
"rule": "read-workspace",
"path": "path",
"constraint": "startsWith",
"message": "argument \"path\" must start with one of [\"/srv/workspace/\"]"
}
]
}
```
Note the shape. When a rule *wanted* this tool but rejected the arguments, Toolwrit reports that near-miss instead of the useless "no rule matched" — so the model is told which argument was wrong and can retry inside the boundary. When nothing targeted the tool at all, you get the blunter version:
```json
{
"effect": "deny",
"rule": null,
"reason": "no rule allows tool \"shell.exec\"",
"violations": [
{
"rule": "policy",
"constraint": "default",
"message": "tool \"shell.exec\" is not in the policy; the default effect is deny"
}
]
}
```
`ToolwritDenied.message` is that `reason` prefixed with the tool name:
```
toolwrit: fs.read denied — no rule allows "fs.read" with these arguments
```
---
## Run plans
A `plan:` block is the envelope a run declares before it starts and a human approves once.
```yaml
budget:
calls: 200
usd: 5.00
bytes: 20000000
seconds: 3600
plan:
purpose: nightly CRM export for the EU region
approvedBy: ergin
# warnAt: [0.8, 0.95] # the default
```
The argument for it is worth stating plainly, because it is the whole reason the feature exists. **A global threshold has to guess at every job at once.** "More than 50MB is suspicious" is either too loose to catch anything — the number has to clear your biggest legitimate job — or too tight to leave switched on, in which case somebody mutes it, and an alert people mute is the same as no alert.
A declared envelope does not guess. A render job that asked for an hour and two gigabytes gets exactly that, uninterrupted. The signal stops being "this looks like a lot" and becomes "this run left the envelope its operator approved" — which is a fact, not a hunch. Approve once at the start, hear nothing until a threshold.
**The plan is written into the audit chain as entry 1, before the run can consume anything.** The `Toolwrit` constructor records it; no guarded call can precede it. What was *authorised* is therefore part of the tamper-evident record, not just what happened, and nobody can later claim a different budget was approved than the one the run started under:
```json
{
"seq": 1,
"at": 1789178401000,
"run": "nightly-2026-09-12",
"tool": "toolwrit:plan",
"args": {
"purpose": "nightly CRM export for the EU region",
"approvedBy": "ergin",
"budget": { "calls": 200, "usd": 5, "bytes": 20000000, "seconds": 3600 },
"warnAt": [0.8, 0.95]
},
"decision": { "effect": "allow", "rule": "plan", "reason": "run plan recorded", "violations": [] },
"usage": { "calls": 0, "tokens": 0, "usd": 0, "bytes": 0 },
"prev": "0000000000000000000000000000000000000000000000000000000000000000",
"hash": "e375bde8b22e4e1d2726a1a9d4110a5ae1fd922329689f2aa2b1f64d1ed43382"
}
```
Note the zero usage: the envelope is committed before anything can be spent against it. The defaults Toolwrit will actually use are recorded too (`warnAt: [0.8, 0.95]`, `approvedBy: null` when unset), so the entry is self-describing rather than a document you have to re-derive from the policy file.
### Warnings
`warnAt` is a list of fractions of the budget. Each threshold fires **at most once per dimension**, across `calls`, `tokens`, `usd`, `bytes` and `seconds` — repetition is what gets an alert muted. A warning is delivered to `onWarn` *and* recorded in the chain, so "nobody told me it was at 95%" is answerable from the log rather than from whether a Slack message happened to get delivered.
```ts
const toolwrit = new Toolwrit({
policy: loadPolicyFile('./export.yaml'),
auditFile: './audit.jsonl',
// Keep this non-blocking: it runs inline with metering. Queue the post,
// do not await it.
onWarn: (w) => console.log(w.message),
});
```
```
run "nightly-2026-09-12" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelope
```
The `BudgetWarning` handed to `onWarn` carries `dimension`, `threshold`, `used`, `limit` and that ready-to-send `message`. The chain entry carries the same fields:
```json
{
"seq": 6,
"at": 1789178410000,
"run": "nightly-2026-09-12",
"tool": "toolwrit:warning",
"args": {
"dimension": "bytes",
"threshold": 0.8,
"used": 16000000,
"limit": 20000000,
"message": "run \"nightly-2026-09-12\" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelope"
},
"decision": { "effect": "allow", "rule": "plan", "reason": "run \"nightly-2026-09-12\" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelope", "violations": [] },
"usage": { "calls": 4, "tokens": 0, "usd": 0, "bytes": 16000000 },
"prev": "da02708df6bc2338bf03089dd7356bf1e81a0b1c3e6895216a655f877bc4e905",
"hash": "4cd48e58f9b83f20a143534960983d5d72062507d8c356b35b2292845ce479cd"
}
```
A warning is a report, not an enforcement action: crossing 95% does not refuse anything. The budget does the refusing, at 100%.
Three load-time rules, all of which fail loudly:
- `purpose` is required and must be non-empty.
- A `plan` requires a `budget` to measure against. A plan with nothing to measure would report nothing, and silence reads as "all clear" rather than "not configured".
- `warnAt` entries must be greater than `0` and at most `1`. They are sorted ascending at load, so the order you write them in does not matter.
---
## The bytes ceiling
`budget.bytes` caps the total size of what tools **return** over a run. It is the volume half of containment, and it catches a different attack from the rules.
Bulk exfiltration is rarely a forbidden action. It is a permitted action repeated until something is drained — page 1, page 2, page 3, up to page nine thousand. Every one of those calls passes the allowlist, because reading customer records is exactly what the agent is for. Only the running total gives it away.
```yaml
budget:
bytes: 20000000 # 20 MB of tool output for the whole run
```
```
call 1: ok, bytes now 4000000
call 2: ok, bytes now 8000000
call 3: ok, bytes now 12000000
call 4: ok, bytes now 16000000
call 5: ok, bytes now 20000000
call 6: toolwrit: crm.search denied — data budget exhausted: 20000000/20000000 bytes returned by tools
```
### The limitation, stated up front
**A result's size cannot be known before the tool runs.** So the measurement happens after the fact, and the ordering it imposes is unavoidable:
> The call that blows the ceiling **completes**, and the **next** one is refused. A bytes ceiling bounds a run at the limit *plus one call*, never exactly the limit.
If a single tool call can return your entire database, a bytes ceiling will not save you — cap the page size on the tool itself, with `maxLength` or a server-side limit. What the ceiling bounds is *iteration*: the second, tenth and hundredth call in a drain.
A second honest limit: **an unserialisable result is under-counted.** Sizes are measured as the UTF-8 byte length of a string, the byte length of a `Uint8Array`/`ArrayBuffer`, or the UTF-8 byte length of `JSON.stringify(result)` for anything else. A value that cannot be stringified (a cycle, a throwing `toJSON`) falls back to its `String()` form, which is usually far smaller than the data it holds. `null` and `undefined` count as zero. This fails *open* — it is documented rather than hidden, because throwing an exception from inside the enforcement layer would be worse.
Two smaller notes:
- Bytes are attributed to the call's own timestamp, not to a fresh clock read, so the ledger does not depend on how long a tool happened to take.
- Under `toolwrit run`, what is measured is the **whole JSON-RPC response object** the downstream MCP server sent, not just the text inside it. In a measured run a 100-character text result cost 173 bytes. Size the budget against the protocol envelope, not the payload. The proxy also handles requests concurrently, so calls already in flight are not counted until they return.
---
## Policy reference
Full reference with a worked example per constraint: [`js/docs/policy-reference.md`](js/docs/policy-reference.md).
### Policy
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `version` | `"1"` | yes | Schema version. Only `"1"` exists; anything else is rejected at load. |
| `name` | `string` | no | Free-text label carried into audit exports. |
| `default` | `"allow" \| "deny" \| "ask"` | no | Effect when no rule matches. **Defaults to `deny`.** |
| `budget` | `BudgetLimits` | no | Run-scoped ceilings. Absent means unlimited. |
| `plan` | `RunPlan` | no | The declared, pre-approved envelope. **Requires `budget`.** |
| `rules` | `PolicyRule[]` | yes | The clauses. May be empty, which with the default means "deny everything". |
Unknown top-level keys are a hard load error. In a security policy a typo is not a harmless no-op — a silently ignored `startWith` would turn a scoped filesystem rule into an unscoped one.
### PolicyRule
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `id` | `string` | yes | Stable identifier. Must be unique within the policy. Surfaced in decisions and audit entries, and used to attribute rate-limit counts. |
| `description` | `string` | no | Human-readable justification. Becomes the `reason` in a denial and the text a reviewer reads. |
| `tools` | `string[]` | yes | Non-empty list of tool-name globs. |
| `effect` | `"allow" \| "deny" \| "ask"` | yes | What this rule does when it matches. |
| `when` | `Record<string, ArgConstraint>` | no | Argument constraints keyed by dotted path. **All** must hold for the rule to match. |
| `limit` | `RateLimit` | no | Throttle for calls attributed to this rule. Exceeding it denies. |
**Glob semantics.** `*` matches any run of characters **except the separators `.` and `/`**. `**` matches anything, separators included. Everything else is literal; matching is case-sensitive and whole-string.
| Pattern | `fs.read` | `fs.read.raw` | `github/create_issue` |
| --- | --- | --- | --- |
| `fs.read` | match | — | — |
| `fs.*` | match | — | — |
| `fs.**` | match | match | — |
| `github/*` | — | — | match |
| `*` | — | — | — |
| `**` | match | match | match |
Use `**`, not `*`, when you mean "every tool".
### ArgConstraint
Applied to one argument, addressed by a dotted path (`path`, `body.recipients.0`). Numeric segments index into arrays. A path that runs off the end of the object — or through a `null` — resolves to *missing* rather than throwing, so a malformed model argument becomes a policy violation and not a crash.
Every constraint present must hold. **An absent value fails every constraint except `optional`.**
| Field | Type | Applies to | Meaning |
| --- | --- | --- | --- |
| `optional` | `boolean` | any | Allow the argument to be missing entirely. Default `false`. |
| `type` | `"string" \| "number" \| "boolean" \| "object" \| "array"` | any | Runtime type check. A mismatch short-circuits: no other constraint on that path is reported. |
| `oneOf` | `unknown[]` | any | Value must deep-equal one of these. |
| `noneOf` | `unknown[]` | any | Value must deep-equal none of these. |
| `matches` | `string` | strings | Must match this regular expression. **Not anchored for you** — write `^…$` if you mean the whole string. |
| `startsWith` | `string[]` | strings | Must start with one of these prefixes. The filesystem-scoping primitive. |
| `excludes` | `string[]` | strings | Must not contain any of these substrings. Cheap traversal guard (`".."`). |
| `urlHosts` | `string[]` | strings | Must parse as a URL whose hostname is in this list. An unparseable string fails. |
| `min` | `number` | numbers | Inclusive lower bound. |
| `max` | `number` | numbers | Inclusive upper bound. |
| `minLength` | `number` | strings, arrays | Inclusive lower bound on `.length`. |
| `maxLength` | `number` | strings, arrays | Inclusive upper bound on `.length`. |
A constraint that does not apply to the value's runtime type produces a `type` violation rather than being skipped: `max: 100` against the string `"9999"` fails. Skipping it would fail open, since a satisfied constraint lets the rule match and the call through.
**`urlHosts` and the leading dot.** An entry beginning with `.` matches that domain *and every subdomain*: `.example.com` covers `example.com` and `api.example.com`. Anything else must match the hostname exactly, so an allowlist never widens by accident. `example.com` (no dot) does **not** admit `api.example.com`, and — importantly — `.example.com` does not admit `notexample.com`.
### RateLimit
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `max` | `number` (non-negative integer) | yes | Maximum calls attributed to this rule inside the window. |
| `perSeconds` | `number` (positive) | no | Sliding window length in seconds. **Omit for "per run".** |
`max: 0` is legal and means "never" — a way to disable a rule without deleting it.
### BudgetLimits
Run-scoped. Every field is optional; each must be a positive number. All are checked **before** any rule, and use `>=`: a limit of `calls: 50` permits calls 1 through 50 and refuses the 51st.
| Field | Type | Meaning |
| --- | --- | --- |
| `calls` | `number` | Hard ceiling on total permitted tool calls in the run. |
| `tokens` | `number` | Hard ceiling on input+output tokens attributed to the run, via `meter()`. |
| `usd` | `number` | Hard ceiling on estimated spend, from `meter()` pricing and `spend()`. |
| `bytes` | `number` | Hard ceiling on bytes **returned by tools** across the run. Measured after each call, so the breaching call completes and the next is refused — see [The bytes ceiling](#the-bytes-ceiling). |
| `seconds` | `number` | Wall-clock ceiling, measured from the **first metered event**, not from process start. |
Toolwrit does not count tokens or dollars for you — it cannot see your model calls. `meter()` and `spend()` are how consumption enters the ledger. A `tokens` or `usd` budget with no `meter()` calls is inert; a `seconds` budget only begins once something has been metered or a call has been permitted. `calls` and `bytes` are the two dimensions Toolwrit fills in by itself, from `guard()`.
### RunPlan
Requires a `budget`; a plan with nothing to measure against is a load error.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `purpose` | `string` | yes | What the run is for, in the operator's words. Must be non-empty. Carried into the audit log and every warning message. |
| `approvedBy` | `string` | no | Who approved the envelope. **Recorded, never verified by Toolwrit.** Logged as `null` when unset. |
| `warnAt` | `number[]` | no | Fractions of the budget at which the run reports. Each entry must be `> 0` and `<= 1`. Sorted ascending at load. Defaults to `[0.8, 0.95]`. |
### BudgetWarning
Passed to `onWarn` and recorded as a `toolwrit:warning` audit entry. Fires at most once per threshold per dimension.
| Field | Type | Meaning |
| --- | --- | --- |
| `dimension` | `"calls" \| "tokens" \| "usd" \| "seconds" \| "bytes"` | Which budget dimension crossed. |
| `threshold` | `number` | The `warnAt` fraction that fired. |
| `used` / `limit` | `number` | Consumption and ceiling for that dimension. |
| `message` | `string` | Ready-to-send summary. |
### Toolwrit options
```ts
new Toolwrit({
policy, // Policy — required
run: 'run-2026-09-10-a', // string — stamped on audit entries; a UUID when omitted
auditFile: './audit.jsonl', // string — append entries here; they are buffered either way
redact: ['headers.authorization'], // string[] — dotted paths blanked before hashing
onAsk: async (call, decision) => confirmWithHuman(call), // ApprovalHandler
onWarn: (warning) => postToSlack(warning.message), // (w: BudgetWarning) => void
now: () => Date.now(), // () => number — injectable clock; tests pass a fixed one
});
```
`onWarn` runs inline with metering. Keep it non-blocking — queue the notification rather than awaiting it. If the policy has a `plan`, the constructor writes the `toolwrit:plan` entry immediately, so constructing a `Toolwrit` with an `auditFile` always appends entry 1 before you call anything.
| Method | Returns | Notes |
| --- | --- | --- |
| `check(tool, args?)` | `Decision` | Evaluates without recording or executing. For dry runs and operator previews. |
| `guard(tool, args, execute)` | `Promise<T>` | Evaluate, record, then execute. Throws `ToolwritDenied` on refusal. |
| `meter(input, output, price?)` | `void` | Record a model turn. `price` is USD per **1,000** tokens. |
| `spend(usd)` | `void` | Record non-token spend. |
| `usage()` | `BudgetUsage` | `{ calls, tokens, usd, bytes, startedAt }`. |
| `entries()` | `readonly AuditEntry[]` | The chain recorded so far. |
| `head()` | `string` | Hash of the newest entry — what an anchor commits to. |
`ToolwritDenied` carries `tool`, `decision` and `auditHash` — the hash of the entry recording the refusal, worth quoting in a support ticket.
The library also exports the pieces directly for embedding: `evaluate`, `loadPolicyFile`, `parsePolicy`, `validatePolicy`, `PolicyError`, `matchesGlob`, `resolvePath`, `Ledger`, `AuditLog`, `canonicalize`, `hashEntry`, `GENESIS`, `verifyChain`, `verifyFile`, `summarize`, `verifyAgainstReceipt`.
---
## Audit and verification
Every decision — allowed, denied, or approved-after-ask — is appended to a hash chain. The format is JSONL, one entry per line, so it survives a crash mid-run and can be tailed, grepped and shipped to any log pipeline unchanged.
A real entry:
```json
{
"seq": 2,
"at": 1789035198799,
"run": "f37b4b88-19ed-42ad-914f-b45e163a3874",
"tool": "fs.read",
"args": { "path": "/srv/workspace/README.md" },
"decision": {
"effect": "allow",
"rule": "read-workspace",
"reason": "allowed by rule \"read-workspace\"",
"violations": []
},
"usage": { "calls": 1, "tokens": 0, "usd": 0, "bytes": 13 },
"prev": "4e700ae4f574319b9c49ea9da35265a498ed0e156551a5e0e3251aca76a8665e",
"hash": "bf67c74f8195def87444a9a5bc02a2535d768c8d70a4658920679eaf1e7b4e5f"
}
```
`usage` is consumption **at the moment of the decision**, before this call is counted — which is exactly the state the engine saw, so a reviewer can replay the decision from the entry alone. `bytes` lags by one call for the same reason: it is the total *before* this tool has returned anything.
Two entries describe Toolwrit itself rather than a guarded tool call: `toolwrit:plan` (the approved envelope, always entry 1 when a policy declares a plan) and `toolwrit:warning` (a threshold that fired). They share the ordinary entry shape, so one chain and one verifier cover everything.
### How the chain works
- `prev` on entry *n* is the `hash` of entry *n−1*. The first entry's `prev` is `GENESIS`, sixty-four zeroes.
- `hash` is `sha256` over a **canonical** serialisation of the entry excluding `hash` itself: object keys sorted, no incidental whitespace, `undefined` dropped. Without canonicalisation the chain would depend on V8's key ordering and a log written by one process could fail verification in another.
- Editing, deleting or reordering any past entry invalidates that entry's hash and every link after it.
```
toolwrit verify audit.jsonl
```
Exit `0` if the chain is intact, `1` if it is not. `verifyFile` returns the same result programmatically, and it stops at the **first** inconsistency with a specific cause:
```json
{
"ok": false,
"count": 1,
"head": "0000000000000000000000000000000000000000000000000000000000000000",
"failure": {
"seq": 1,
"reason": "bad-hash",
"detail": "entry hashes to 604bcf30e8eb but claims 5785cdfb11e3"
}
}
```
`reason` is one of `bad-hash` (contents were edited), `broken-link` (an entry was inserted or removed), `bad-sequence` (entries reordered), or `malformed` (a line is not valid JSON).
### Redaction
Secrets must never reach the log. Pass dotted paths to `redact` and the value is replaced with the literal string `"[redacted]"` before the entry is hashed:
```ts
new Toolwrit({ policy, auditFile: './audit.jsonl', redact: ['headers.authorization'] });
```
```json
"args": { "url": "https://api.example.com", "headers": { "authorization": "[redacted]" } }
```
Two properties follow, and both matter:
- The **redacted shape is what gets hashed.** A redaction is therefore part of the committed record, not a later edit — you cannot retroactively redact a logged secret and still pass `verify`, and you cannot forge a redaction to hide what an argument was.
- Your caller's object is untouched. Toolwrit clones before redacting, so the real argument value is still available to the tool.
Redaction is declared per-`Toolwrit`, not per-rule. If a value is sensitive anywhere, redact it everywhere.
---
## Run receipts
A hash chain proves what one run did. Nobody reads chains at scale. A fleet of 100,000 agents leaves hundreds of millions of entries behind, and "review the logs" is not an instruction anyone can act on.
A receipt is one small summary object per run. The fleet becomes **100,000 receipts instead of 100,000,000 entries**, with the runs that left their approved envelope already flagged in the summary rather than buried in it.
```
toolwrit receipt audit.jsonl
```
```
run: nightly-2026-09-12
entries: 9 2026-09-12T02:00:01.000Z → 2026-09-12T02:00:14.000Z
chain: ok
head: 6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6
purpose: nightly CRM export for the EU region (approved by ergin)
usage: 5 calls, 0 tokens, $0.0000
budget: calls 3%, usd 0%, bytes 100% of the approved envelope
outcome: 5 allowed, 1 denied, 0 asked
warnings: bytes at 80%, bytes at 95%
denied: crm.search ×1
exceeded: YES — the run hit a budget ceiling
```
`--json` prints the same thing as one line, which is the form a console ingests:
```json
{
"run": "nightly-2026-09-12",
"from": 1789178401000,
"to": 1789178414000,
"entryCount": 9,
"head": "6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6",
"chainOk": true,
"plan": {
"purpose": "nightly CRM export for the EU region",
"approvedBy": "ergin",
"budget": { "calls": 200, "usd": 5, "bytes": 20000000, "seconds": 3600 },
"warnAt": [0.8, 0.95]
},
"usage": { "calls": 5, "tokens": 0, "usd": 0, "bytes": 20000000 },
"consumed": { "calls": 0.025, "usd": 0, "bytes": 1 },
"allowed": 5,
"denied": 1,
"asked": 0,
"warnings": [
{ "dimension": "bytes", "threshold": 0.8, "used": 16000000, "limit": 20000000 },
{ "dimension": "bytes", "threshold": 0.95, "used": 20000000, "limit": 20000000 }
],
"deniedTools": { "crm.search": 1 },
"exceeded": true
}
```
The three fields to sort a fleet by: **`exceeded`** (a call was refused because a budget ceiling was reached), **`consumed`** (fraction of each budgeted dimension used — `bytes: 1` is 100% of the approved envelope), and **`deniedTools`** (which tools were refused, and how often). `chainOk: false` means the file did not verify; `receipt` still prints — a receipt over a broken chain has to say what it saw — but exits `1` so a CI step cannot accept it.
`summarize` is **pure**: a function of the chain alone, no I/O and no clock. The same entries always produce the same receipt, so one recomputed during an audit can be compared byte for byte with the one issued at the time.
```ts
import { readFileSync } from 'node:fs';
import { summarize } from 'toolwrit';
const entries = readFileSync('./audit.jsonl', 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const receipt = summarize(entries);
if (receipt.exceeded || receipt.denied > 0) escalate(receipt);
```
One accounting detail worth knowing. Each entry records usage **as of the decision**, before the call it authorises is counted, so the last entry's call count always lags by one. `usage.calls` in a receipt is therefore the count of allowed entries, not the last entry's figure — otherwise every receipt would read "9 allowed, 8 calls", which a reviewer notices immediately. Tokens, dollars and bytes still come from the last entry, since metering lands there between calls. Toolwrit's own `toolwrit:plan` and `toolwrit:warning` entries are excluded from `allowed`/`denied`/`asked`.
---
## Anchoring
Here is the hole that `verify` alone cannot close.
**Verifying a chain in isolation cannot detect that its tail was cut off.** A prefix of a valid chain is itself a valid chain: every link intact, every hash correct, `seq` running 1..n. An agent that deletes its last twenty entries hands you a log that passes.
```
toolwrit verify truncated.jsonl
ok: 6 entries verified
head: 4cd48e58f9b83f20a143534960983d5d72062507d8c356b35b2292845ce479cd
```
Exit `0`. Three calls and both budget warnings are gone, and nothing inside the file says so.
What closes it is a receipt issued at the time, kept somewhere else. `toolwrit anchor` appends one receipt line — including the head — to an append-only file:
```
toolwrit anchor audit.jsonl --to anchors.jsonl
```
```
anchored run nightly-2026-09-12 (head 6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6) to anchors.jsonl
note: an anchor only proves anything where the agent cannot rewrite it.
```
`toolwrit verify --against` then runs **two** checks and reports them separately, because only the second can catch a truncation and an operator has to see which one failed:
```
toolwrit verify audit.jsonl --against anchors.jsonl
ok: chain check — 9 entries verified
ok: anchor check — head matches the receipt anchored for run nightly-2026-09-12
head: 6a5900e535789622d82e8b78374597e412d95c016fa408401697fcff01da0ea6
```
The same command on the truncated log:
```
toolwrit verify truncated.jsonl --against anchors.jsonl
ok: chain check — 6 entries verified
FAILED: anchor check — log does not match the anchored receipt (6 entries read)
seq: 6
reason: broken-link
detail: chain head 4cd48e58f9b8 does not match the receipt's head 6a5900e53578 (6 entries present, receipt recorded 9)
```
Exit `1`. The head quoted at the time cannot be reached by any shorter or rewritten chain. A run with no anchor at all fails too, rather than passing quietly:
```
FAILED: anchor check — no receipt for run "nightly-2026-09-12" in anchors.jsonl
detail: an unanchored run cannot be shown to be complete
```
Anchoring the same run more than once is normal — mid-run and again at the end. The **last** matching line for a run wins, because the newest receipt is the strongest claim about how far the chain got. `anchor` refuses to write at all if the chain does not verify.
The programmatic form is `verifyAgainstReceipt(entries, receipt)`, which returns the same `VerifyResult` shape as `verifyChain`.
### The part Toolwrit cannot do for you
**An anchor is only worth something where the agent cannot rewrite it.** An anchor file sitting next to the audit file, writable by the same process, is not evidence — an attacker who truncates one can truncate the other. Toolwrit cannot enforce where the anchor lives. It can only make putting it there one command:
```
toolwrit anchor audit.jsonl --to /mnt/worm/anchors.jsonl # a volume the agent's user can append to and not rewrite
```
Another host, an object store with object-lock or append-only retention, a log pipeline under credentials the agent process does not hold, a ticket, a colleague's inbox. Pick by asking who has to be convinced. [`js/docs/threat-model.md`](js/docs/threat-model.md) lays out the options in ascending order of effort.
### Timestamping the receipt
`--tsa` closes most of that gap. The receipt's head is sent to an outside **RFC 3161 Time-Stamp Authority**, which signs "this head existed at time T" with a key the agent does not hold. The signed token goes into the anchor line:
```
toolwrit anchor audit.jsonl --to anchors.jsonl --tsa
```
```
anchored run tsa-fixture (head 834459b62224281a5f118865e6fd8f44d4518cf7adc8b4da04dff01d061fca16) to anchors.jsonl
timestamped by http://timestamp.digicert.com at 2026-10-07T13:33:08.000Z
```
The default TSA is DigiCert's public service: free, no account, and its certificate chains to a root that ships with Node. Any other RFC 3161 service works with `--tsa <url>`; one whose root Node does not ship (FreeTSA, an internal TSA) needs `--tsa-ca <roots.pem>`. If the TSA cannot be reached or its answer does not verify, `anchor` exits `1` and writes nothing — a receipt you asked to have timestamped never lands without one.
`verify --against` then runs a third check whenever the receipt carries a timestamp:
```
toolwrit verify audit.jsonl --against anchors.jsonl --require-timestamp --max-lag 60
ok: chain check — 4 entries verified
ok: anchor check — head matches the receipt anchored for run tsa-fixture
ok: timestamp check — DigiCert SHA256 RSA4096 Timestamp Responder 2026 1 vouches the head existed at 2026-10-07T13:33:08.000Z (0s after the last entry)
head: 834459b62224281a5f118865e6fd8f44d4518cf7adc8b4da04dff01d061fca16
```
What this buys, precisely:
- **The receipt cannot be forged or backdated.** Editing the head, the time, or the signature in the anchor line fails the check, and a token cannot be moved onto another receipt. The anchor file can now live next to the log without being decoration.
- **A rewrite after the fact shows up as lateness.** Someone who truncates or rewrites the log can only obtain a timestamp *now*. `--max-lag <seconds>` fails any receipt timestamped longer than that after the run's last entry, which is what a log rewritten later looks like. Anchor at the end of every run and a tight bound costs nothing.
- **A missing timestamp is not a pass.** Deleting the line fails the anchor check, as before; stripping only its `timestamp` field fails with `--require-timestamp`.
What it does not buy: a forger who rewrites the *entry times* too, and timestamps the result promptly, produces a log that is internally consistent and sealed on time — for the wrong time. Catching that needs one fact from outside the log: when the run actually happened (the scheduler, the billing record, the ticket). Compare it with the time the TSA vouched for.
The head is already a SHA-256 digest, so it is the timestamp's message imprint unchanged. Anyone can check a token without Toolwrit:
```
openssl ts -verify -digest <head> -token_in -in token.der -CAfile roots.pem
```
where `token.der` is the base64-decoded `timestamp.token` field. Programmatically: `requestTimestamp(head, { tsa })` and `verifyTimestamp(token, head)`.
### Stamping files
The same timestamp works for any file, not just a receipt. `toolwrit stamp` hashes the file, sends only the SHA-256 to the TSA, and writes the signed token next to it:
```
toolwrit stamp render.mp4
stamped render.mp4 (sha256 6e14cf36…) at 2026-10-12T22:53:03.000Z → render.mp4.stamp.json
toolwrit stamp render.mp4 --check
ok: render.mp4 — DigiCert SHA256 RSA4096 Timestamp Responder 2026 1 vouches it existed at 2026-10-12T22:53:03.000Z
```
The stamp proves these exact bytes existed no later than the signed time: an agent's output, a design, a contract, a video before it was re-uploaded by someone else. One changed byte and `--check` fails. The file never leaves the machine and large files are hashed as a stream. `--out <dir>` keeps the stamps in one folder instead of beside each file. Programmatically: `stampFile(path)` and `checkFileStamp(path, stamp)`.
A public TSA's timestamp is strong technical evidence, not a *qualified* electronic timestamp in the eIDAS sense. Where a court must presume it valid without further proof, point `--tsa` at a qualified, usually paid, TSA.
---
## Drift
A budget catches a run that crosses a line drawn in advance. It cannot catch a run that is strange but stays under the line: ceilings are set for the busiest legitimate day, and a hijacked agent that keeps below them passes.
`toolwrit drift` compares one run against the runs before it. Give it the audit logs oldest first, one file per run:
```
$ toolwrit drift mon.jsonl tue.jsonl wed.jsonl thu.jsonl
DRIFT: run thu is unlike the 3 runs before it
- calls 263 against a usual 20
- denied 3 against a usual 0
- fs.read called 260 times against a usual 20
- net.upload was called 2 time(s); no earlier run called it
- shell.exec was called 1 time(s); no earlier run called it
```
It flags calls, denials, bytes and spend more than `--factor` (default 3) times their median over earlier runs, any single tool called that much more often than usual, and any tool no earlier run ever called, even when that call was denied. Small absolute rises are ignored, so going from zero denials to one does not page anyone. With fewer than three earlier runs it gives no verdict. Every file's chain is verified first; a broken one fails the command instead of being compared.
It reads finished logs and decides nothing at call time, so run it from cron or CI and page someone on exit `1`. It also has limits. A run that makes the usual number of the usual calls with a different intent does not drift, and anything that never crosses the tool boundary is invisible to it. `detectDrift(entries)` is exported for use in code.
## What Toolwrit is not
Being straight about the boundary is worth more than overclaiming.
- **It does not inspect prompts or model output.** There is no classifier, no jailbreak detector, no content filter. Toolwrit sees tool names and arguments, and nothing else.
- **It cannot stop an allowed tool from doing something harmful.** If your policy permits `fs.write` under `/srv/workspace/`, Toolwrit will let the agent write nonsense there all day. The policy is the security boundary; Toolwrit only enforces it faithfully.
- **It is not a network egress filter.** `urlHosts` constrains a *URL-shaped argument you chose to constrain*. A tool that opens its own sockets is invisible to Toolwrit. If you need egress control, you need it at the network layer.
- **It governs the tool boundary only.** Anything a tool does internally, anything the model does without calling a tool, and anything another process on the host does are all outside its remit.
- **The audit log is tamper-evident, not tamper-proof.** An attacker with write access to the file can rewrite the entire chain consistently, and the result verifies perfectly. Nothing inside a file distinguishes an honest chain from a consistently forged one. Evidence requires that the head hash be anchored somewhere they do not control. See [`js/docs/threat-model.md`](js/docs/threat-model.md).
- **`verify` alone cannot detect truncation.** A prefix of a valid chain is a valid chain. Only `verify --against` an anchored receipt catches a tail that was cut off — and only if the anchor lives somewhere the agent cannot rewrite, or is timestamped (`anchor --tsa`) and checked with `--max-lag`.
- **A timestamp proves when, not what really happened.** It shows a head existed by time T. A forger who also rewrites entry times and timestamps promptly is caught only by comparing T with when the run actually took place.
- **A bytes ceiling bounds a run at the limit plus one call, not at the limit.** A result's size is not knowable before the tool runs, so the breaching call completes and the next one is refused. It bounds iteration, not a single oversized response.
- **Byte measurement under-counts an unserialisable result.** A cyclic object or a throwing `toJSON` falls back to its string form, which is usually much smaller than the data it holds. This fails open, and is documented rather than hidden.
- **`approvedBy` is recorded, not verified.** A plan's approver is a string Toolwrit writes into the chain. It is evidence of what the policy file claimed, not proof that a particular human agreed.
- **A warning is a report, not a control.** Crossing a `warnAt` threshold refuses nothing. The budget does the refusing, at 100%.
- **The budget is an estimate, not a bill.** `usd` is whatever your `meter()` price table and `spend()` calls say it is.
---
## Comparison
Describing categories, not products.
| | Deterministic | Self-hosted | Adds latency | Jailbreakable | Audit artefact |
| --- | --- | --- | --- | --- | --- |
| **Prompt-level guardrails** (system-prompt rules, tool descriptions) | No — the model decides | Yes | No | Yes, directly — it is text in the same channel | No |
| **LLM-judge guardrails** (a second model approves each call) | No — sampled output | Usually a vendor API | Yes — an extra model call per tool call | Yes — the judge takes a prompt too | Only if the vendor keeps one; not verifiable by you |
| **API gateways / egress proxies** | Yes, for what they can see | Sometimes; often a hosted hop | Yes — a network round trip | No | Request logs, not decision logs; usually not hash-chained |
| **Toolwrit** | Yes — a pure function of policy + call + ledger | Yes — in-process or a local sidecar | Negligible — no network, no model | No prompt to jailbreak | Hash-chained JSONL, verifiable offline with one command |
The wedge is narrow and deliberate: Toolwrit does one layer, deterministically, in your infrastructure, and hands you a file you can prove things with. It complements the other rows rather than replacing them — an LLM judge on top of a deny-by-default allowlist is a reasonable architecture; an LLM judge *instead of* one is not.
**One runtime dependency** (`yaml`). Apache-2.0. Node >= 20.
---
## Documentation
- [`js/docs/policy-reference.md`](js/docs/policy-reference.md) — exhaustive constraint reference, the `plan` block, and common policies.
- [`js/docs/threat-model.md`](js/docs/threat-model.md) — what Toolwrit defends against, what it does not, and how to anchor the audit chain.
- [`js/docs/python.md`](js/docs/python.md) — the Python package (`toolwrit`, imports as `toolwrit`), its API, and the chain-compatibility guarantee.
- [`SECURITY-REVIEW.md`](SECURITY-REVIEW.md) — two independent reviews, every finding they produced, the four that were published as open before being fixed, and the one that is still open.
## Commercial support
**Evidence pack.** Send your policy file and get back a PDF report and a sealed evidence folder: every rule tested with legitimate calls and with attacks on its conditions, rate limits and budgets exercised, and every decision in a hash-chained log timestamped by an independent RFC 3161 authority, verifiable with one command. Launch price €49 for the first 10 packs (then €99). A pack covers one exact policy; when it changes we tell you, and a re-test is €10 (three for €25). [Sample report](https://emektor.github.io/toolwrit/sample-evidence-pack.pdf).
Integration help, custom policy work or a port to another language: [erginsakoglu@gmail.com](mailto:erginsakoglu@gmail.com).
## Licence
Apache License 2.0. See [`LICENSE`](LICENSE).
## Contributing
See [`CONTRIBUTING.md`](js/CONTRIBUTING.md). The short version: no new runtime dependencies, and any change to enforcement semantics needs a test that fails without it.
This server cannot be deployed
TDQS
Scored across 9 tools
Most tools have clearly distinct purposes (read, list, search, metadata). However, read_file is a deprecated alias for read_text_file, creating a genuine overlap, and list_directory vs list_directory_with_sizes differ only by the sizes column. The descriptions do enough to steer selection, but these pairs are confusable.
Names mostly follow a verb_noun convention (read_file, read_text_file, list_directory, search_files, get_file_info, list_allowed_directories). The lone deviation is directory_tree, which is a noun phrase rather than verb_noun. Overall predictable and readable.
Nine tools is well-scoped for a filesystem server, covering read, list, search, and metadata operations without bloat. Every tool except the deprecated read_file earns its place.
The surface is purely read-oriented: there is no write_file, create_directory, move_file, or edit operation despite the server name suggesting writing. For a general filesystem domain this is a notable gap, though for a read-only browsing use case it would be near-complete.