Permission audit log
Every gate decision lands in audit.log on the receiver machine. This guide is how to read it.
The permission audit log is per-machine. The cabinet’s audit log is per-request server-side. They cover different layers and both are useful.
Where it lives
File: <LogDir>/audit.log. Resolved per platform (utils.LogDir()):
- macOS:
~/Library/Logs/cmdop/audit.log - Linux:
~/.local/state/cmdop/audit.log(respectsXDG_STATE_HOME) - Windows:
%LOCALAPPDATA%\cmdop\Logs\audit.log
JSON-line format, lumberjack-rotated (10 MB × 5 files = 50 MB max).
Reading it
The CLI tail is the easiest:
cmdop permissions audit --tail 50
cmdop permissions audit --since 1h
cmdop permissions audit --tool execute_command
cmdop permissions audit --decision denyFields
Every entry includes:
| Field | Meaning |
|---|---|
ts | ISO timestamp. |
decision | allow, deny, ask_allowed, ask_denied, ask_timeout. |
tool | The tool name, e.g. execute_command. |
operation | Dispatcher operation, e.g. exec for connect(exec:...). |
target | Argument the matcher ran against (command / path / hostname). |
mode | Active mode at decision time. |
source | floor, rule, or mode. |
rule_id | Sha256 of the matched rule (empty if source != rule). |
reason | Reason from rule, floor message, or “no UI wired”. |
input_digest | Sha256 of the full tool input — useful for correlating with logs. |
ask_ms | Milliseconds spent waiting on a UI decision (only for ask_*). |
hostname | Caller hostname, populated from gRPC metadata. |
user | Caller OAuth user, if available. |
Sample entries
{"ts":"2026-04-25T12:34:56Z","decision":"allow","tool":"execute_command","target":"git status","mode":"default","source":"rule","rule_id":"abc123","reason":"developer convenience"}
{"ts":"2026-04-25T12:35:11Z","decision":"deny","tool":"write_file","target":"/Users/me/.env","mode":"default","source":"floor","reason":"protected file suffix"}
{"ts":"2026-04-25T12:35:42Z","decision":"ask_allowed","tool":"write_file","target":"/Users/me/projects/notes.md","mode":"default","source":"rule","rule_id":"def456","ask_ms":1834}
{"ts":"2026-04-25T12:36:01Z","decision":"ask_timeout","tool":"execute_command","target":"npm test","mode":"default","source":"rule","rule_id":"def456","reason":"no UI wired","ask_ms":60000}Common patterns
“What did the agent want last hour?”
cmdop permissions audit --since 1h | jq -s 'group_by(.tool) | map({tool: .[0].tool, count: length}) | sort_by(-.count)'“Show me only blocked calls.”
cmdop permissions audit --decision deny --tail 100“Find what triggered a specific rule.”
cmdop permissions audit --tail 1000 | jq 'select(.rule_id == "abc123")'Retention
10 MB × 5 files locally. For longer retention, ship the file to a SIEM via your log shipper of choice (Vector, Fluent Bit, Datadog Agent). The path is stable across daemon restarts.
The local audit log is local. If the machine is wiped or the disk fails, the audit history is gone. Ship to a centralized store for compliance.
Where the writer lives
internal/agent/permissions/audit.go writes JSON lines via lumberjack. The build-up function Build(BuildConfig) in internal/agent/permissions/build.go wires the audit logger to the gate at daemon start.