Guides / The audit log
The audit log
kosa8 writes down every action that changes something: who asked, what they asked for, and whether it was allowed. Each entry is sealed to the one before it, so an entry that is edited or removed later shows. This page explains what is in the log, what is deliberately left out, and how to answer questions with it.
What gets an entry
The log is one file, ~/.kosa8/audit.jsonl, with one JSON object per line. Only the
background service writes to it.
Written down
- Every request to the service that changes state: creating, running, pulling, deleting, snapshotting, restoring. Refused requests too, with their status.
- Every request a sandbox makes through its egress broker, allowed or refused.
- Every connection or DNS lookup the network guard refused.
- Every tool-call decision the MCP gateway made, and what each allowed call returned, as a digest.
- Signed approvals: requested, granted, refused and used.
- An agent token that was refused.
Left out, on purpose
- Reads. A log that is 99% list calls is a log nobody reads.
- Tool arguments and results. Their SHA-256 digests are recorded instead.
- Prompts and model output, unless the organisation policy turns recording on.
- Environment variables. That is where credentials are passed on purpose.
- Query strings and request bodies of brokered requests.
- API keys, placeholders and tokens.
The log is forwarded to other systems and kept for years, unencrypted. A digest lets someone who holds the original arguments prove they are the ones that were sent, without putting them where everyone can read them.
One action, several entries
- The tool call is decided and recorded. This entry is written when the MCP policy governs the tool; a third-party tool gets one on every call.
- The request reaches the service, which records the action with the image and command.
- Inside the sandbox, a request to an allowed host is recorded with where it went.
- A request to anywhere else is refused on your side of the boundary, and the attempt is recorded.
The shape of an entry
{
"seq": 189, // its place in the log; rises by one
"time": "2026-10-14T02:26:44.569612Z", // UTC
"actor": "guest", // who did it
"action": "sandbox.egress.allow", // what was done
"target": "broker-76849", // to what: here, the sandbox
"detail": "{\"kind\":\"route\",\"host\":\"httpbin.org\",\"port\":443,\"method\":\"GET\",\"path\":\"/headers\",\"route\":\"echo\",\"status\":200,\"bytes_in\":239}",
"status": 200, // the outcome
"format": 2, // the entry version
"refs": { // the same facts, to search by value
"sandbox": "broker-76849",
"egress": {"kind": "route", "allowed": true, "host": "httpbin.org", "port": 443,
"method": "GET", "path": "/headers", "route": "echo", "status": 200}
},
"prev_hash": "b9b0f86a08f0acc8ecaa0630852f0db6def2ce2a207c33709162520f60db572e",
"hash": "9cf2cd1bfa024769533d35abae8b01ea5d9420f2abf4a7e4169d1d9b064a87af"
}
The comments are for this page; the file holds plain JSON, one entry per line.
| Field | Meaning |
|---|---|
seq | The entry's number. A gap means an entry was removed. |
time | When it was written, in UTC. |
actor | Who did it. See the next table. |
action | What was done, as a stable name built from the request: sandboxes.create, sandboxes.run, snapshots.restore, agents.create, pull. Requests through the Docker-compatible API read containers.create, containers.start and so on. |
target | What it was done to: a sandbox, a snapshot, a container, or SERVER/TOOL for a tool call. Left out when the name is in the request body, as for sandboxes.create. |
detail | A line for a person to read. For network entries it is itself JSON. |
status | The outcome as an HTTP status: 200 or 201 for done, 401 for a refused token, 403 for refused by a rule. |
format | The entry version. 2 for every entry written now; absent on entries from before versions existed. |
refs | What the entry points at, by value, so a program can find it without parsing detail. Left out when there is nothing to name. |
prev_hash | The hash of the entry before this one. |
hash | This entry's seal. It covers every field above, refs included. |
Actors
| Actor | Who that is |
|---|---|
human | The kosa8 command line. |
agent | An AI client, through kosa8 mcp serve. |
tool | Another program on the service's socket, such as the Docker CLI. Also any request that does not say who it is. |
guest | Code inside a sandbox, as observed from your side: a network request it made or was refused. |
policy | A step the service took because the signed organisation policy required it, such as encrypting a snapshot the moment it was taken. |
human, agent and tool are what the caller said about
itself. The service has verified an agent only when the entry also carries
refs.agent, which means the request presented that agent's token. An entry marked
agent with no refs.agent is an unverified claim. See
Agent identity.
What refs can carry
| Key | What it names |
|---|---|
sandbox | The sandbox the entry is about. |
snapshot | A snapshot, by id. |
agent | The agent identity the service verified: id, name, scope. |
egress | One request through a sandbox's broker: kind, allowed, host, port, method, path, route, status, suppressed. |
mcp | One tool-call decision: phase, server, tool, scope, allowed, rule, the policy that decided, and digests of the arguments and result. |
session | One run of the MCP gateway or the model gateway, tying its entries together. |
parent_seq | An earlier entry this one continues: the call a tool result answers, the approval a request used. |
exchange | One request the model gateway answered, by digest. |
model | The digest of the model that ran. |
labels | The classification a sandbox or snapshot carries. |
approval_request, approvals | A signed approval request and the approvals presented with it. |
files | Files by path, size and digest: an encrypted snapshot's parts, a wiped disk, a capture. |
seal_manifest, signer | The exact sealed snapshot an entry is about, and whose key sealed it. |
policy_digest | The organisation policy in force. |
Three you will meet often:
// refs.agent — on every request a verified agent made "agent": {"id": "agt_8c7f67b7892f", "name": "reviewer", "scope": "eng"} // refs.egress — a refused request through the broker "egress": {"kind": "connect", "allowed": false, "host": "evil.example", "port": 443} // refs.mcp — a tool call a rule denied "mcp": {"phase": "call", "server": "echo", "tool": "echo", "scope": "eng", "allowed": false, "rule": "eng/echo rules \"no-secrets\"", "policy": "corp", "policy_digest": "sha256:56f7e2bd15b2dfb4d8a4f7a4d73487311ea58a45e6c8019ec61beb1f3ca29e59", "arguments": "31ba7019834990d9adcf1296cfb715a6088eff3e55e8eb920aff687716c2893b", "server_sandbox": "mcp-echo"}
refs.egress.kind is route for a request the broker added a credential
to, http for a plain request, and connect for an encrypted tunnel, where
the broker sees the host and port and nothing inside. refs.mcp.phase is
expose (the tool was offered to, or withheld from, the agent when the gateway
started), call, result, or check for
kosa8 mcp-policy check.
Commands in the log
"Something ran in sandbox dev" is not much of a record. Every run and exec entry says what ran: the image, the command line, and the SHA-256 of the exact arguments.
{"seq":272,"time":"2026-10-14T02:32:55.374227Z","actor":"human","action":"sandboxes.run",
"target":"docdemo",
"detail":"image=alpine cmd=\"uname -r\" cmd_sha256=63288e3ac7d977d241c72d374e6c340f684732e525ba7a8ee5f5dfacc3f17dd1",
"status":200,"format":2,"prev_hash":"09494244…","hash":"c6576089…"}
A command line is the commonest place a secret is typed by mistake, so parts shaped like a credential are replaced before the line is written:
What was run
curl -H "Authorization: Bearer abc.def.ghi" \ https://api.example.com/v1/deploy psql --password=hunter2 -c "select 1" env API_KEY=sk-live-0123456789abcdefghij \ node app.js
What the entry holds
curl -H Authorization: Bearer [redacted] https://api.example.com/v1/deploy psql --password=[redacted] -c select 1 env API_KEY=[redacted] node app.js
(Each command is one line in the entry; they are wrapped here to fit.) Lines longer than 512
bytes are cut and end in …. cmd_sha256 is computed from the real
arguments, before redaction, so two commands that differ only in a redacted value still have
different digests, and someone who holds the command can prove it is the one that ran.
kosa8 recognises the usual shapes: a value after a name such as password,
token, secret or api_key, a Bearer or
Basic value, and well-known key prefixes. A secret that looks like an ordinary word
is written as typed. Pass secrets through the environment, which is never recorded, or keep them
out of the sandbox entirely with a brokered credential.
How the log shows tampering
- Every entry has a
hashthat covers all of its fields. - The next entry carries that hash as its
prev_hash, so the two are linked. - And so on: every entry depends on every entry before it.
- The hash is a keyed one (HMAC-SHA256). The key is made once, on first use, in
~/.kosa8/audit.key, readable only by you. Without it the hashes cannot be recomputed. - After each entry the service records where the chain ends, in
~/.kosa8/audit.head, sealed with the same key. - Change an entry and its hash stops matching its contents.
- Remove one and the next entry's
prev_hashpoints at nothing, and the numbering has a gap. - Cut entries off the end and what is left is a perfect, shorter chain. The head record is what shows it is shorter than it was.
$ kosa8 audit verify audit chain intact: 17 entries verified
When something is wrong, verify names the first entry that fails and exits with a
non-zero status:
| What was done to the log | What kosa8 audit verify prints after audit chain BROKEN at entry N: |
|---|---|
| An entry was edited | entry hash mismatch: this entry's contents were altered |
| An entry was changed or removed, seen from the next one | previous-hash mismatch: an earlier entry was changed or removed |
| An entry was removed and the links patched | sequence gap: an entry was removed |
| Entries were cut from the end | entries are missing from the end: the log reaches 250, but 273 were recorded |
The end was cut and audit.head deleted | the recorded end of the chain is missing: audit.head was removed, so entries may have been cut from the end |
| The last entry was replaced | the last entry is not the one that was recorded: the end of the log was rewritten |
If audit.key is removed from a log that has entries, kosa8 does not make a new one
and carry on. It stops writing entries, because an entry written without the key is one anybody
could have written.
Signatures anyone can check
The keyed hash proves an entry to whoever holds audit.key, and that same key is
what would let them forge one. So every entry is also signed with a private key that stays on this
machine (~/.kosa8/audit.sign.key), and the public half is enough to check it. An
auditor, a customer or a SIEM can verify a record, or an
evidence package, without being trusted with anything.
$ kosa8 audit key SHA256:1e8488f24da196ef… ed25519 (current) public key (DER, base64): MCowBQYDK2VwAyEA… $ kosa8 audit verify audit chain intact: 17 entries verified 17 signed, by SHA256:1e8488f24da196ef… (`kosa8 audit key` to pin)
Send the key id to whoever will check your records through a different channel from the records, such as a ticket or a signed email, so they pin the key they expect rather than the one a package names. A hardware key (Secure Enclave, TPM) signs with ECDSA P-256 and is checked the same way. Entries written before signing existed keep their keyed hash and verify as they always did. What a signature cannot show by itself is that nothing was cut from the end; the head record still covers that.
Reading and exporting
$ kosa8 audit ls
SEQ TIME ACTOR ACTION TARGET STATUS
21 02:16:29 guest sandbox.egress.allow brk2 200
| Command | What it does |
|---|---|
kosa8 audit ls | Shows recent entries as a table. -n, --limit sets how many (default 25; 0 for all). --actor keeps one actor from those. Long targets are shortened in the table. |
kosa8 audit ls --json | The same entries as a JSON array, complete: detail, refs and the hashes. Use this with jq. |
kosa8 audit verify | Walks the chain and confirms every entry is authentic and none is missing. --archive also verifies the sealed segments retention moved old entries into. |
kosa8 audit export | Writes every entry. --format jsonl (the default) or cef, the ArcSight format many SIEMs read. --label exports only entries about one classification. |
kosa8 audit forward | Sends entries each configured sink has not yet received, in order, with their sequence numbers and hashes. --sink NAME for one sink; -f, --follow to keep going. Sinks (syslog, Splunk HEC, HTTPS) are configured in ~/.kosa8/audit-config.json, and the service forwards on its own while it runs. |
kosa8 audit status | Shows where entries are forwarded, how far each sink has got, and retention. |
kosa8 audit prune | Applies the retention policy now: old entries are sealed and removed from the live log, and the chain still verifies afterwards. --dry-run reports without changing anything. |
kosa8 evidence export | Writes a signed package of the entries about a --sandbox, --snapshot, --session or time range (--since, --until), with the stretch of chain around them. -o names the file. |
kosa8 evidence verify PACKAGE | Checks such a package using nothing but the package: no service, no kosa8 state, no network. --pubkey pins who exported it; --audit-key also checks that the machine's key wrote every entry. |
Whoever you send a package to does not need kosa8 to check it.
kosa8 evidence verify --web verifier.html writes the same verification as a single HTML
file. They open it in any current browser and drop the package on it: the .tar, the
package folder, or a zip of either. The page reads the package inside the browser and gives the
verdict kosa8 evidence verify gives without --audit-key, check by check,
naming each file that fails and why. It makes no network requests, and its Content-Security-Policy
forbids them, so it works from a saved copy on a machine with no network. Send the page alongside
the package, and send your public key through a different channel so they can pin it in the page.
Checking the keyed hashes still needs the audit key and the CLI.
Network entries are rate-limited
A sandbox can open connections far faster than a sealed log should grow, so network entries have a budget. Nothing is lost silently: what was not written individually is counted.
| Source | Budget | Where the count goes |
|---|---|---|
| Requests through the broker, in three budgets of their own: refused, credentialed (route), and other allowed | 120 entries a minute for each kind, for each sandbox | refs.egress.suppressed on the next entry of the same kind: how many requests since the last recorded one had no entry of their own. |
| Connections and DNS lookups the network guard refused | 20 entries a minute for each sandbox | suppressed inside the next denial's detail, or in a final summary entry when the sandbox's network closes. |
Ordinary link chatter a Linux machine sends at every boot (IPv6 router messages, multicast) is
dropped and counted, never audited. kosa8 sandbox egress NAME shows the counts.
Answering questions with the log
These use kosa8 audit ls --json -n 0, which prints every entry as one JSON array,
and the jq tool to pick from it.
Which agent did this?
$ kosa8 audit ls --json -n 0 | jq -r '.[]
| select(.refs.agent != null)
| [.seq, .refs.agent.name, .refs.agent.id, .action, .target] | @tsv'
264 reviewer agt_8c7f67b7892f mcp.decision echo/upload
266 reviewer agt_8c7f67b7892f sandboxes.containers mcp-echo
To find requests that only claimed to be an agent, ask for the opposite:
select(.actor == "agent" and .refs.agent == null).
What did a sandbox try to reach?
$ kosa8 audit ls --json -n 0 | jq -r '.[] | select(.target == "broker-76849" and (.action | startswith("sandbox.egress."))) | (.detail | fromjson) as $d | [.seq, .action, ($d.host // $d.name // $d.dst), ($d.port // "-"), ($d.path // "-"), ($d.reason // "-")] | @tsv' 189 sandbox.egress.allow httpbin.org 443 /headers - 191 sandbox.egress.deny httpbin.org 443 /anything path not allowed on route 195 sandbox.egress.allow example.com 80 / - 196 sandbox.egress.deny neverssl.com 80 / host not in allow-list 197 sandbox.egress.deny evil.example 443 - host not in allow-list 199 sandbox.egress.deny example.com 53 - name not in egress policy deny 201 sandbox.egress.deny 192.168.127.254 22 - not in egress policy deny
For these entries detail is JSON, which is why the filter parses it. Requests
through the broker carry host; refusals by the network guard carry dst,
and name for a DNS lookup.
Which tool calls were refused, and by which rule?
$ kosa8 audit ls --json -n 0 | jq -r '.[]
| select(.action == "mcp.decision" and .refs.mcp.phase == "call" and (.refs.mcp.allowed | not))
| [.seq, (.refs.agent.name // "-"), .target, (.refs.mcp.rule // "(no rule: default deny)")] | @tsv'
250 reviewer echo/echo eng/echo rules "no-secrets"
251 reviewer echo/echo schema
252 reviewer kosa8/sandbox_delete eng/kosa8 deny_tools "sandbox_delete"
253 reviewer echo/echo eng/echo rules "deploys-need-a-person"
A rule reads SCOPE/SERVER, then which list fired, then the pattern or rule id.
schema means the arguments did not fit the tool's pinned schema. A call held for a
person's approval appears here too; add .detail to the list to read the reason.
What commands ran in a sandbox?
$ kosa8 audit ls --json -n 0 | jq -r '.[]
| select(.target == "docdemo" and (.action | test("^sandboxes\\.(run|exec|start|start-interactive)$")))
| [.seq, .time, .actor, .detail] | @tsv'
272 2026-10-14T02:32:55.374227Z human image=alpine cmd="uname -r" cmd_sha256=63288e3ac7d977d241c72d374e6c340f684732e525ba7a8ee5f5dfacc3f17dd1
Was anything auto-allowed by the scorer?
$ kosa8 audit ls --json -n 0 | jq -r '.[]
| select(.action == "mcp.decision" and ((.detail // "") | contains("auto-allowed by")))
| [.seq, (.refs.agent.name // "-"), .target, .detail] | @tsv'
When a policy configures a local scorer and it lets a call through in place of asking a person,
the decision's detail says so, with the scorer, its confidence and the threshold. No
output means it never happened. A call a person approved reads approved by a person (held
call ID) in the same place.
What this does not do
- Tamper-evident, not tamper-proof. The log and the key that seals it are files on the
same computer. Someone with full control of that computer can rewrite the log and reseal it, and
nothing on the machine can prevent that. Forward the log off the machine
(
kosa8 audit forward), and keep a copy ofaudit.keysomewhere else so an export can be checked against it. - Attribution is verified only for agents with a token. The service's socket belongs to
your user, and any program running as you can call it and say it is a person. What the log
guarantees is narrower: an entry with
refs.agentcame from a holder of that agent's token. - Arguments, results and prompts are not kept. Only their digests are. The log can prove what was sent if you have it from somewhere else; it cannot show you what was sent.
- Inside an encrypted tunnel the broker records the host and port only. The method and
path of a request appear for brokered credentials and plain HTTP, not for
connectentries. - Not every network event has its own entry. Past the budgets above, events are counted
in
suppressed. - Redaction of command lines is a best effort, as described above.
- Reads are not logged. The log does not show who listed sandboxes or read a file.