emektor/toolwrit

A written authority for AI agents. Allowlist the tools, cap the budget, prove what happened.

TypeScript

0

54 commits

updated Oct 13, 2026

See the code

See what people are saying

SourceMessageScoreDate

I made a sidecar that enforces an allowlist + budget on any MCP server's tool calls (open source) (r/coolgithubprojects)

Library (TypeScript + Python) or a sidecar in front of any MCP server. YAML policy, call/USD budget per run, hash-chained audit log, RFC 3161 file stamps. Apache-2.0.

0

Oct 13, 2026

README

Toolwrit

English · Deutsch · 简体中文 · 日本語 · Español

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

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:

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:

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:

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:

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:

{ "command": "npx", "args": ["-y", "@acme/filesystem-mcp", "/srv/workspace"] }

After:

{
  "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]
CommandPurposeExit codes
runWrap an MCP server process; enforce every tools/call.passthrough
verifyVerify 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
receiptSummarise a run as one object — plan, usage, outcome, warnings. --json for one line of JSON.0 chain verifies, 1 it does not
anchorAppend 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
checkEvaluate a single hypothetical call against a policy.0 allow, 1 deny, 2 ask
explainHuman-readable policy summary for a reviewer.0
driftCompare one run against the runs before it. See Drift.0 normal or too little history, 1 drift or a broken chain
stampTimestamp any file, or with --check verify it still matches its stamp. See 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:

{
  "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:

{
  "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.

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:

{
  "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.

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:

{
  "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.

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.

Policy

FieldTypeRequiredMeaning
version"1"yesSchema version. Only "1" exists; anything else is rejected at load.
namestringnoFree-text label carried into audit exports.
default"allow" | "deny" | "ask"noEffect when no rule matches. Defaults to deny.
budgetBudgetLimitsnoRun-scoped ceilings. Absent means unlimited.
planRunPlannoThe declared, pre-approved envelope. Requires budget.
rulesPolicyRule[]yesThe 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

FieldTypeRequiredMeaning
idstringyesStable identifier. Must be unique within the policy. Surfaced in decisions and audit entries, and used to attribute rate-limit counts.
descriptionstringnoHuman-readable justification. Becomes the reason in a denial and the text a reviewer reads.
toolsstring[]yesNon-empty list of tool-name globs.
effect"allow" | "deny" | "ask"yesWhat this rule does when it matches.
whenRecord<string, ArgConstraint>noArgument constraints keyed by dotted path. All must hold for the rule to match.
limitRateLimitnoThrottle 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.

Patternfs.readfs.read.rawgithub/create_issue
fs.readmatch——
fs.*match——
fs.**matchmatch—
github/*——match
*———
**matchmatchmatch

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.

FieldTypeApplies toMeaning
optionalbooleananyAllow the argument to be missing entirely. Default false.
type"string" | "number" | "boolean" | "object" | "array"anyRuntime type check. A mismatch short-circuits: no other constraint on that path is reported.
oneOfunknown[]anyValue must deep-equal one of these.
noneOfunknown[]anyValue must deep-equal none of these.
matchesstringstringsMust match this regular expression. Not anchored for you — write ^…$ if you mean the whole string.
startsWithstring[]stringsMust start with one of these prefixes. The filesystem-scoping primitive.
excludesstring[]stringsMust not contain any of these substrings. Cheap traversal guard ("..").
urlHostsstring[]stringsMust parse as a URL whose hostname is in this list. An unparseable string fails.
minnumbernumbersInclusive lower bound.
maxnumbernumbersInclusive upper bound.
minLengthnumberstrings, arraysInclusive lower bound on .length.
maxLengthnumberstrings, arraysInclusive 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

FieldTypeRequiredMeaning
maxnumber (non-negative integer)yesMaximum calls attributed to this rule inside the window.
perSecondsnumber (positive)noSliding 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.

FieldTypeMeaning
callsnumberHard ceiling on total permitted tool calls in the run.
tokensnumberHard ceiling on input+output tokens attributed to the run, via meter().
usdnumberHard ceiling on estimated spend, from meter() pricing and spend().
bytesnumberHard 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.
secondsnumberWall-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.

FieldTypeRequiredMeaning
purposestringyesWhat the run is for, in the operator's words. Must be non-empty. Carried into the audit log and every warning message.
approvedBystringnoWho approved the envelope. Recorded, never verified by Toolwrit. Logged as null when unset.
warnAtnumber[]noFractions 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.

FieldTypeMeaning
dimension"calls" | "tokens" | "usd" | "seconds" | "bytes"Which budget dimension crossed.
thresholdnumberThe warnAt fraction that fired.
used / limitnumberConsumption and ceiling for that dimension.
messagestringReady-to-send summary.

Toolwrit options

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.

MethodReturnsNotes
check(tool, args?)DecisionEvaluates 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?)voidRecord a model turn. price is USD per 1,000 tokens.
spend(usd)voidRecord non-token spend.
usage()BudgetUsage{ calls, tokens, usd, bytes, startedAt }.
entries()readonly AuditEntry[]The chain recorded so far.
head()stringHash 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:

{
  "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:

{
  "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:

new Toolwrit({ policy, auditFile: './audit.jsonl', redact: ['headers.authorization'] });
"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:

{
  "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.

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

DeterministicSelf-hostedAdds latencyJailbreakableAudit artefact
Prompt-level guardrails (system-prompt rules, tool descriptions)No — the model decidesYesNoYes, directly — it is text in the same channelNo
LLM-judge guardrails (a second model approves each call)No — sampled outputUsually a vendor APIYes — an extra model call per tool callYes — the judge takes a prompt tooOnly if the vendor keeps one; not verifiable by you
API gateways / egress proxiesYes, for what they can seeSometimes; often a hosted hopYes — a network round tripNoRequest logs, not decision logs; usually not hash-chained
ToolwritYes — a pure function of policy + call + ledgerYes — in-process or a local sidecarNegligible — no network, no modelNo prompt to jailbreakHash-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 — exhaustive constraint reference, the plan block, and common policies.
  • js/docs/threat-model.md — what Toolwrit defends against, what it does not, and how to anchor the audit chain.
  • js/docs/python.md — the Python package (toolwrit, imports as toolwrit), its API, and the chain-compatibility guarantee.
  • 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.

Integration help, custom policy work or a port to another language: [email protected].

Licence

Apache License 2.0. See LICENSE.

Contributing

See CONTRIBUTING.md. The short version: no new runtime dependencies, and any change to enforcement semantics needs a test that fails without it.

agent-security
ai-agents
audit-log
guardrails
llm
mcp
policy-engine
python
typescript

emektor/toolwrit

A written authority for AI agents. Allowlist the tools, cap the budget, prove what happened.

TypeScript

0

54 commits

updated Oct 13, 2026

See the code

See what people are saying

SourceMessageScoreDate

I made a sidecar that enforces an allowlist + budget on any MCP server's tool calls (open source) (r/coolgithubprojects)

Library (TypeScript + Python) or a sidecar in front of any MCP server. YAML policy, call/USD budget per run, hash-chained audit log, RFC 3161 file stamps. Apache-2.0.

0

Oct 13, 2026

README

Toolwrit

English · Deutsch · 简体中文 · 日本語 · Español

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

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:

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:

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:

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:

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:

{ "command": "npx", "args": ["-y", "@acme/filesystem-mcp", "/srv/workspace"] }

After:

{
  "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]
CommandPurposeExit codes
runWrap an MCP server process; enforce every tools/call.passthrough
verifyVerify 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
receiptSummarise a run as one object — plan, usage, outcome, warnings. --json for one line of JSON.0 chain verifies, 1 it does not
anchorAppend 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
checkEvaluate a single hypothetical call against a policy.0 allow, 1 deny, 2 ask
explainHuman-readable policy summary for a reviewer.0
driftCompare one run against the runs before it. See Drift.0 normal or too little history, 1 drift or a broken chain
stampTimestamp any file, or with --check verify it still matches its stamp. See 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:

{
  "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:

{
  "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.

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:

{
  "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.

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:

{
  "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.

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.

Policy

FieldTypeRequiredMeaning
version"1"yesSchema version. Only "1" exists; anything else is rejected at load.
namestringnoFree-text label carried into audit exports.
default"allow" | "deny" | "ask"noEffect when no rule matches. Defaults to deny.
budgetBudgetLimitsnoRun-scoped ceilings. Absent means unlimited.
planRunPlannoThe declared, pre-approved envelope. Requires budget.
rulesPolicyRule[]yesThe 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

FieldTypeRequiredMeaning
idstringyesStable identifier. Must be unique within the policy. Surfaced in decisions and audit entries, and used to attribute rate-limit counts.
descriptionstringnoHuman-readable justification. Becomes the reason in a denial and the text a reviewer reads.
toolsstring[]yesNon-empty list of tool-name globs.
effect"allow" | "deny" | "ask"yesWhat this rule does when it matches.
whenRecord<string, ArgConstraint>noArgument constraints keyed by dotted path. All must hold for the rule to match.
limitRateLimitnoThrottle 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.

Patternfs.readfs.read.rawgithub/create_issue
fs.readmatch——
fs.*match——
fs.**matchmatch—
github/*——match
*———
**matchmatchmatch

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.

FieldTypeApplies toMeaning
optionalbooleananyAllow the argument to be missing entirely. Default false.
type"string" | "number" | "boolean" | "object" | "array"anyRuntime type check. A mismatch short-circuits: no other constraint on that path is reported.
oneOfunknown[]anyValue must deep-equal one of these.
noneOfunknown[]anyValue must deep-equal none of these.
matchesstringstringsMust match this regular expression. Not anchored for you — write ^…$ if you mean the whole string.
startsWithstring[]stringsMust start with one of these prefixes. The filesystem-scoping primitive.
excludesstring[]stringsMust not contain any of these substrings. Cheap traversal guard ("..").
urlHostsstring[]stringsMust parse as a URL whose hostname is in this list. An unparseable string fails.
minnumbernumbersInclusive lower bound.
maxnumbernumbersInclusive upper bound.
minLengthnumberstrings, arraysInclusive lower bound on .length.
maxLengthnumberstrings, arraysInclusive 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

FieldTypeRequiredMeaning
maxnumber (non-negative integer)yesMaximum calls attributed to this rule inside the window.
perSecondsnumber (positive)noSliding 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.

FieldTypeMeaning
callsnumberHard ceiling on total permitted tool calls in the run.
tokensnumberHard ceiling on input+output tokens attributed to the run, via meter().
usdnumberHard ceiling on estimated spend, from meter() pricing and spend().
bytesnumberHard 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.
secondsnumberWall-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.

FieldTypeRequiredMeaning
purposestringyesWhat the run is for, in the operator's words. Must be non-empty. Carried into the audit log and every warning message.
approvedBystringnoWho approved the envelope. Recorded, never verified by Toolwrit. Logged as null when unset.
warnAtnumber[]noFractions 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.

FieldTypeMeaning
dimension"calls" | "tokens" | "usd" | "seconds" | "bytes"Which budget dimension crossed.
thresholdnumberThe warnAt fraction that fired.
used / limitnumberConsumption and ceiling for that dimension.
messagestringReady-to-send summary.

Toolwrit options

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.

MethodReturnsNotes
check(tool, args?)DecisionEvaluates 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?)voidRecord a model turn. price is USD per 1,000 tokens.
spend(usd)voidRecord non-token spend.
usage()BudgetUsage{ calls, tokens, usd, bytes, startedAt }.
entries()readonly AuditEntry[]The chain recorded so far.
head()stringHash 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:

{
  "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:

{
  "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:

new Toolwrit({ policy, auditFile: './audit.jsonl', redact: ['headers.authorization'] });
"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:

{
  "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.

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

DeterministicSelf-hostedAdds latencyJailbreakableAudit artefact
Prompt-level guardrails (system-prompt rules, tool descriptions)No — the model decidesYesNoYes, directly — it is text in the same channelNo
LLM-judge guardrails (a second model approves each call)No — sampled outputUsually a vendor APIYes — an extra model call per tool callYes — the judge takes a prompt tooOnly if the vendor keeps one; not verifiable by you
API gateways / egress proxiesYes, for what they can seeSometimes; often a hosted hopYes — a network round tripNoRequest logs, not decision logs; usually not hash-chained
ToolwritYes — a pure function of policy + call + ledgerYes — in-process or a local sidecarNegligible — no network, no modelNo prompt to jailbreakHash-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 — exhaustive constraint reference, the plan block, and common policies.
  • js/docs/threat-model.md — what Toolwrit defends against, what it does not, and how to anchor the audit chain.
  • js/docs/python.md — the Python package (toolwrit, imports as toolwrit), its API, and the chain-compatibility guarantee.
  • 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.

Integration help, custom policy work or a port to another language: [email protected].

Licence

Apache License 2.0. See LICENSE.

Contributing

See CONTRIBUTING.md. The short version: no new runtime dependencies, and any change to enforcement semantics needs a test that fails without it.

agent-security
ai-agents
audit-log
guardrails
llm
mcp
policy-engine
python
typescript

Project health

Latest release

0.4.0 · Oct 13, 2026

Releases this year

4