kosa8 docs

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 audit entries one agent action produces Four things happen and each writes an entry. The agent calls the sandbox_run tool, which writes an mcp.decision entry. The service runs the command, which writes a sandboxes.run entry with the image, the command and its digest. The command fetches a package through the broker, which writes sandbox.egress.allow. The command tries another host, which writes sandbox.egress.deny. What happened The entry written agent calls sandbox_run the policy decides the call first mcp.decision actor agent · scope, rule, digest of the arguments the service runs the command in the sandbox the agent named sandboxes.run actor agent · image, cmd, cmd_sha256, status the command fetches a package through the sandbox's broker sandbox.egress.allow actor guest · host, port, method, path, status the command tries another host one the sandbox was not given sandbox.egress.deny actor guest · where it tried to go, and why not
Each layer records what it decided. Together they answer "what did the agent do?"
  1. 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.
  2. The request reaches the service, which records the action with the image and command.
  3. Inside the sandbox, a request to an allowed host is recorded with where it went.
  4. 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.

FieldMeaning
seqThe entry's number. A gap means an entry was removed.
timeWhen it was written, in UTC.
actorWho did it. See the next table.
actionWhat 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.
targetWhat 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.
detailA line for a person to read. For network entries it is itself JSON.
statusThe outcome as an HTTP status: 200 or 201 for done, 401 for a refused token, 403 for refused by a rule.
formatThe entry version. 2 for every entry written now; absent on entries from before versions existed.
refsWhat the entry points at, by value, so a program can find it without parsing detail. Left out when there is nothing to name.
prev_hashThe hash of the entry before this one.
hashThis entry's seal. It covers every field above, refs included.

Actors

ActorWho that is
humanThe kosa8 command line.
agentAn AI client, through kosa8 mcp serve.
toolAnother program on the service's socket, such as the Docker CLI. Also any request that does not say who it is.
guestCode inside a sandbox, as observed from your side: a network request it made or was refused.
policyA step the service took because the signed organisation policy required it, such as encrypting a snapshot the moment it was taken.
"agent" is a claim unless the entry names one

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

KeyWhat it names
sandboxThe sandbox the entry is about.
snapshotA snapshot, by id.
agentThe agent identity the service verified: id, name, scope.
egressOne request through a sandbox's broker: kind, allowed, host, port, method, path, route, status, suppressed.
mcpOne tool-call decision: phase, server, tool, scope, allowed, rule, the policy that decided, and digests of the arguments and result.
sessionOne run of the MCP gateway or the model gateway, tying its entries together.
parent_seqAn earlier entry this one continues: the call a tool result answers, the approval a request used.
exchangeOne request the model gateway answered, by digest.
modelThe digest of the model that ran.
labelsThe classification a sandbox or snapshot carries.
approval_request, approvalsA signed approval request and the approvals presented with it.
filesFiles by path, size and digest: an encrypted snapshot's parts, a wiped disk, a capture.
seal_manifest, signerThe exact sealed snapshot an entry is about, and whose key sealed it.
policy_digestThe 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.

Redaction is a courtesy, not a guarantee

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

The audit hash chain Three entries in a row. Each carries the hash of the one before it as prev_hash, and its own hash. Every hash is an HMAC under a key in audit.key. A separate record, audit.head, holds the number and hash of the last entry. Editing entry 272 makes its hash wrong; entry 273 then no longer links to it; cutting entries from the end disagrees with the head record. entry 271 prev_hash 0c1324e7… hash 09494244… entry 272 prev_hash 09494244… hash c6576089… entry 273 prev_hash c6576089… hash 3094b138… audit.key each hash is an HMAC-SHA256 under this key, kept beside the log audit.head seq 273 hash 3094b138… edit entry 272 its hash no longer fits remove 272 273 links to nothing cut the end head still says 273
Three checks: each entry's own seal, its link to the one before, and the recorded end.
  1. Every entry has a hash that covers all of its fields.
  2. The next entry carries that hash as its prev_hash, so the two are linked.
  3. And so on: every entry depends on every entry before it.
  4. 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.
  5. After each entry the service records where the chain ends, in ~/.kosa8/audit.head, sealed with the same key.
  6. Change an entry and its hash stops matching its contents.
  7. Remove one and the next entry's prev_hash points at nothing, and the numbering has a gap.
  8. 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 logWhat kosa8 audit verify prints after audit chain BROKEN at entry N:
An entry was editedentry hash mismatch: this entry's contents were altered
An entry was changed or removed, seen from the next oneprevious-hash mismatch: an earlier entry was changed or removed
An entry was removed and the links patchedsequence gap: an entry was removed
Entries were cut from the endentries are missing from the end: the log reaches 250, but 273 were recorded
The end was cut and audit.head deletedthe recorded end of the chain is missing: audit.head was removed, so entries may have been cut from the end
The last entry was replacedthe 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
CommandWhat it does
kosa8 audit lsShows 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 --jsonThe same entries as a JSON array, complete: detail, refs and the hashes. Use this with jq.
kosa8 audit verifyWalks 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 exportWrites every entry. --format jsonl (the default) or cef, the ArcSight format many SIEMs read. --label exports only entries about one classification.
kosa8 audit forwardSends 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 statusShows where entries are forwarded, how far each sink has got, and retention.
kosa8 audit pruneApplies 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 exportWrites 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 PACKAGEChecks 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.

SourceBudgetWhere the count goes
Requests through the broker, in three budgets of their own: refused, credentialed (route), and other allowed120 entries a minute for each kind, for each sandboxrefs.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 refused20 entries a minute for each sandboxsuppressed 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 of audit.key somewhere 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.agent came 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 connect entries.
  • 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.

Where to go next