Keep an audit log¶
When something goes wrong, someone will ask what the agent was allowed to do and what it was stopped from doing. Debug logs are a poor answer: anyone with write access can edit them, and they often contain the very customer data you were protecting. interlock's audit log writes one receipt per decision. Receipts are chained and signed, so editing or deleting one is detectable. Arguments are stored as a hash, never as values.
Receipts are off by default. Turn them on when you need a record you can hand to someone else: a security review, an incident, a customer asking what happened.
Turn it on¶
Pass a sink to install. Every enforced decision from then on appends a receipt
to audit.jsonl:
import interlock
from interlock import Blocked, deny_tool, enforce_tool_call, span
from interlock.sink import FileSink
KEY = load_your_secret_key() # 32 random bytes, kept outside the log's reach
sink = FileSink("audit.jsonl", key=KEY)
interlock.install(rules=[deny_tool("drop_table", policy_id="db.v1")], sink=sink)
with span(principal="acme/agent42"):
enforce_tool_call("read_rows", {"table": "orders"})
try:
enforce_tool_call("drop_table", {"table": "orders"})
except Blocked:
pass
The blocked call's receipt, one JSON object per line:
{"action":"drop_table","args_digest":"98795f398f6036966300c90bc9db03591f1902e5aaafde2fef2c101488bff642","attributed_to":null,"mac":"9acd7b608f4de6fa3ac932035a042b0fb89fe191cea728cb44f24512d9e14fc9","phase":"call","policy_id":"db.v1","prev":"9ce4f86348191b29bb9c6ffbd6098ce38f7f484720ba37d70f6535909d044a3e","principal":"acme/agent42","reason":"tool not permitted","span_id":"1ee1d2cbfd2342cdaf00f0ecaf7bbe49","ts":1790876799.192898,"verdict":"BLOCK"}
interlock.receipt.set_sink(sink) does the same as install(sink=...).
install() never removes a sink. Only set_sink(None) turns receipts off again.
Receipts come from the places that enforce decisions: @guard,
enforce_tool_call and enforce_tool_result (and so the MCP and LangChain
adapters), and sandbox launches. Rules never write receipts, so a rule can't
forge one or quietly skip one. Allowed calls get receipts too.
What a receipt records¶
| Field | What it holds |
|---|---|
ts |
when the call was captured (Unix time) |
action |
the tool or function name |
verdict |
ALLOW, BLOCK, or MODIFY |
reason |
the rule's reason, copied as written |
policy_id |
the rule's policy id, if it set one |
attributed_to |
the name of the argument that triggered the decision |
principal |
who was acting |
span_id |
the agent run this call belonged to |
phase |
call or result |
args_digest |
SHA-256 over the arguments the caller passed |
prev |
the previous receipt's mac (64 zeros for the first) |
mac |
the signature over every other field |
observed |
true when monitor mode recorded a decision without enforcing it; absent otherwise |
The digest proves which payload was seen without storing it. If you have the arguments in hand, you can check them against a receipt:
from interlock.receipt import digest_args
digest_args({"table": "orders"}) == receipt.args_digest # True for the matching call
The digest covers the arguments as passed. If a rule rewrote them, the receipt still digests the originals.
Warning The
reasonfield is copied verbatim. interlock's own rules use fixed reasons, but if your rule writes an argument value into its reason (an email address, a file path), that value ends up in the log. Keep values out of the reasons you write.
Monitor mode¶
Under enforcement="monitor" (or @monitor()), a call that a rule would have
blocked goes through anyway. Its receipt still says BLOCK, because that was the
decision, and adds "observed": true so nobody reads it as enforcement:
from interlock import monitor
@monitor()
def drop_table(table: str) -> str:
return "dropped " + table
with span(principal="acme/agent42"):
drop_table(table="orders") # runs: monitor mode never blocks
{"action":"drop_table","args_digest":"98795f398f6036966300c90bc9db03591f1902e5aaafde2fef2c101488bff642","attributed_to":null,"mac":"ee3a23178f3f5fab8bbe0e42a78b81c7eaef94b6722aa83e40165bc51793504b","observed":true,"phase":"call","policy_id":"db.v1","prev":"0000000000000000000000000000000000000000000000000000000000000000","principal":"acme/agent42","reason":"tool not permitted","span_id":"310e33610784493d9893a4403ed795d1","ts":1790944627.908606,"verdict":"BLOCK"}
observed is covered by the signature like every other field. Receipts for
enforced decisions leave it out, so logs written before it existed verify
unchanged.
Verify the log¶
verify() walks the chain. It returns None when the log is intact, or a
message naming the first bad receipt:
FileSink("audit.jsonl", key=KEY).verify()
# -> None
FileSink("audit.jsonl", key=b"some other key").verify()
# -> 'receipt 0: MAC mismatch (edited, or verified with a different key)'
Here is what tampering looks like. Someone flips the blocked call to ALLOW:
Someone deletes that line instead:
Each receipt carries the previous receipt's signature, and its own signature covers that link. An edit breaks the edited receipt. A deletion or reordering breaks the link to the next one.
Catch receipts cut from the end¶
Deleting the last receipts leaves a shorter chain that still links up, so the
chain alone can't show it. Record the latest signature, sink.head, somewhere
the agent's host can't rewrite (another system, a ticket, a log shipper), and
pass it back when you verify:
head = sink.head # after the last write you care about
# ... later, someone deletes the final line of audit.jsonl ...
sink.verify()
# -> None the chain itself still links up
sink.verify(head=head)
# -> 'chain ends at receipt 1 but the expected head is 173e14001ec82ded...: receipts were cut from the end, or this is not the log that head came from'
An auditor without a sink object uses verify_file, which reads the file
without opening it for writing:
Choose and keep a key¶
The default key is random and generated fresh for each process. Without your own key, only the process that wrote a log can verify it, and once it exits nobody can.
For a log you need to check later, pass a long-lived key:
import os
KEY = bytes.fromhex(os.environ["INTERLOCK_RECEIPT_KEY"]) # 64 hex characters
sink = FileSink(path, key=KEY)
Keep the key where the agent's tools can't read it: a secret manager, or an environment variable the agent's tools never see. Anyone who holds the key can write receipts that verify, so a key the agent can read protects nothing.
Restarts¶
A FileSink opened on an existing log continues its chain from the last receipt
in the file, so one audit.jsonl can span restarts:
interlock.install(rules=rules, sink=FileSink("audit.jsonl", key=KEY))
enforce_tool_call("read_rows", {"table": "orders"})
# the service restarts and opens the same file
sink = FileSink("audit.jsonl", key=KEY)
interlock.install(rules=rules, sink=sink)
enforce_tool_call("read_rows", {"table": "customers"})
sink.verify()
# -> None
Keep the same key across restarts, or the receipts written under the old key won't verify under the new one.
If the file ends in something that isn't a receipt, for example a line cut short by a crash mid-write, the sink refuses to open it rather than chaining new receipts onto damage nobody looked at:
ValueError: broken.jsonl: the last line is not a readable receipt (Unterminated string starting at: line 1 column 21 (char 20)); move the file aside or repair it before appending
Warning One file, one writer at a time. Two sinks open on the same file at once (two processes, or two instances in one process) both continue from the same last receipt, so their writes fork the chain and
verify()reports a break. Give concurrent processes their own files, for example with the process id in the name.
Test with an in-memory sink¶
InMemorySink keeps receipts in a list, which makes assertions easy:
from interlock.sink import InMemorySink
from interlock.receipt import set_sink
mem = InMemorySink()
interlock.install(rules=[deny_tool("drop_table")], sink=mem)
try:
enforce_tool_call("drop_table", {"table": "t"})
except Blocked:
pass
receipt = mem.receipts[0]
receipt.action, receipt.verdict, receipt.reason, mem.verify()
# -> ('drop_table', 'BLOCK', 'tool not permitted', None)
set_sink(None) # don't leak the sink into other tests
Anything with a write(receipt) method can be installed as a sink. Subclass
ChainedSink (implement _append and _read_back) to keep the chaining and
signing while storing receipts somewhere else.
Let others verify without your key¶
The default signer is HMAC-SHA256: a shared-secret signature. It proves the log hasn't changed since it was written, by someone who held the key. It doesn't prove which key holder wrote it, and anyone who verifies it needs the key, which also lets them write receipts that verify.
For a log an outside auditor can check without being able to forge it, sign
with a private key and give the auditor a verifier that holds only the public
key. A signer is any function (key, message) -> bytes; a verifier is any
function (message, signature) -> bool. Here is Ed25519 with the
cryptography package (interlock itself stays dependency-free):
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key() # give the auditor only this
def ed25519_signer(_key, message):
return private_key.sign(message) # the key argument is unused; the closure holds it
def ed25519_verifier(message, signature):
try:
public_key.verify(signature, message)
return True
except InvalidSignature:
return False
sink = FileSink("signed.jsonl", signer=ed25519_signer, key=b"unused")
interlock.install(rules=rules, sink=sink)
# ... the agent runs ...
# the auditor's side: no private key anywhere
verify_file("signed.jsonl", verifier=ed25519_verifier)
# -> None
verify_file("signed.jsonl", verifier=some_other_public_keys_verifier)
# -> 'receipt 0: signature rejected by the verifier (edited, or signed with a different key)'
sink.verify(verifier=...) and verify_file(..., verifier=..., head=...) take
the same verifier. The exact bytes each receipt is signed over come from
interlock.receipt.signing_payload(receipt), if you need to check one by hand.
Receipts from the sidecar¶
When a Node.js agent uses the sidecar, the daemon writes the receipts, because it is the process making the decisions:
interlock-sidecar --rules my_service.policies:RULES \
--socket "$HOME/.interlock/guard.sock" \
--receipts audit.jsonl --key-file receipt.key
--key-file holds 64 hex characters or raw bytes (a trailing newline is
ignored). Without it the daemon signs with a per-process key and prints a
warning, because the log is then verifiable only by that process. The JS client
doesn't write receipts of its own, so each decision is recorded once.
Limits¶
- A cut tail needs a recorded head. Deleting the last receipts leaves a
valid, shorter chain.
verify(head=...)catches it only if you kept the head somewhere the agent's host can't change. Shipping receipts off the host as they're written does the same job. - One writer per file. Restarts continue the chain; two writers open at the
same time fork it, and
verify()reports the fork. - HMAC proves integrity, not authorship. Anyone with the key can write
receipts that verify. Use a public-key signer and
verifier=when that matters. - Rewrites aren't recorded in detail. A
MODIFYreceipt shows that the call was rewritten and by which policy, not what the new arguments were.
Next¶
- Production checklist: where receipts fit in a rollout
- Python API reference:
FileSink,InMemorySink,Receipt - Sidecar reference:
--receiptsand--key-file - Security model