Skip to the content.

Audit Log

This doc explains the audit log format, location, and retention for permission decisions and tool executions.

Location

Every hook invocation writes structured entries to .claude/permissions-log/ inside the project root. The plugin uses CLAUDE_PROJECT_DIR to locate that directory, which Claude Code always sets when invoking hooks.

Files are partitioned by hour in local time. Two files are written per hour to the same directory:

.claude/permissions-log/
└── YYYY-MM/
    └── DD/
        ├── HH.json   # JSON Lines: one entry per line, machine-readable
        └── HH.log    # plain text: human-readable summary

Retention

On every hook invocation the plugin automatically removes month directories older than two calendar months. The three most recent months (including the current one) are always kept.

Format

.json: JSON Lines (NDJSON): one JSON object per line, newline-terminated, UTF-8 encoded. Intended for programmatic querying with tools like jq.

.log: plain text, one line per entry, intended for direct human reading. All timestamps use ISO 8601 format in local time with timezone offset.

Human-readable example (.log)

10:23:01  TOOL     Bash      "ls && rm -rf /"
10:23:01  RULE               "ls" → .claude/permissions.yaml:4 → allow
10:23:01  RULE               "rm -rf /" → .claude/permissions.yaml:8 → deny "rm is not allowed"
10:23:01  NODE               "ls && rm -rf /" → deny "rm is not allowed"
10:23:01  RESULT   Bash      "ls && rm -rf /" → DENY "rm is not allowed"
10:23:02  EXECUTE  Bash      "ls -la"

Columns: HH:MM:SS, label (9 chars), tool name (10 chars), then the entry detail. The tool name column is blank on RULE and NODE lines.

For an allowed tool the full sequence is: TOOL (request received) → RULE/NODE lines → RESULT (decision) → EXECUTE (tool ran, written by the PostToolUse hook). When a tool is denied there is no EXECUTE line.

RULE lines show the matched sub-command, the source file and line of the rule that fired, and the decision. NODE lines show the aggregated decision at each intermediate AST node (e.g. a && expression).

Entry types

Type Written by When
config_load pre-hook.js Once per config file loaded
tool_request pre-hook.js Once per invocation, before decisions
rule_match pre-hook.js Once per non-abstaining rule at a node
no_rule_match pre-hook.js Once per node where every rule abstained (.log label NOMATCH)
aggregation pre-hook.js Once per intermediate node after combining children and own rules
final_decision pre-hook.js Once per invocation before returning
tool_execution post-hook.js Once per allowed tool execution

JSON Lines entry types (.json)

config_load: logged once per config file loaded. In the .log file this appears as a CONFIG line at the top of the hour.

{"type":"config_load","timestamp":"2025-06-15T10:00:00.000+10:00","filePath":".claude/permissions.yaml","ruleCount":12}

tool_request: logged once per hook invocation before any rule evaluation.

{"type":"tool_request","timestamp":"2025-06-15T10:23:01.000+10:00","tool":"Bash","input":{"command":"ls -la"},"cwd":"/home/user/project"}

rule_match: logged for each rule that returns a non-abstain decision. ruleFile and ruleLine identify the source of the rule; both are omitted for built-in rules that have no source file.

{"type":"rule_match","timestamp":"2025-06-15T10:23:01.001+10:00","ruleFile":".claude/permissions.yaml","ruleLine":4,"cmd":"ls","decision":"allow"}

no_rule_match: logged for each AST node (command, read, write, edit, multiedit, other) where every rule abstained. Surfaces gaps in the user’s permissions.yaml.

{"type":"no_rule_match","timestamp":"2025-06-15T10:23:01.001+10:00","nodeType":"command","cmd":"pwd"}

In the .log file this appears as a NOMATCH line; the second column is the AST node type and the third is the cmd value the engine tried to match (command text or file path).

aggregation: logged once per intermediate AST node (bash root, binop) after combining children and own-rule results.

{"type":"aggregation","timestamp":"2025-06-15T10:23:01.002+10:00","cmd":"ls && rm -rf /","decision":"deny","reason":"rm is not allowed"}

final_decision: logged once per hook invocation just before returning the result.

{"type":"final_decision","timestamp":"2025-06-15T10:23:01.003+10:00","tool":"Bash","cmd":"ls && rm -rf /","decision":"deny","reason":"rm is not allowed"}

tool_execution: logged once per PostToolUse invocation, after the tool has run. Only appears for tools that were allowed (denied tools never execute).

{"type":"tool_execution","timestamp":"2025-06-15T10:23:02.000+10:00","tool":"Bash","input":{"command":"ls -la"},"cwd":"/home/user/project","response":{"output":"total 8\n...","isError":false},"isError":false}

isError is extracted as a first-class field from the tool response for easy filtering. The full raw response is stored in response.

Useful one-liners

Tail the current hour’s human-readable log:

tail -f .claude/permissions-log/$(date +%Y-%m/%d/%H).log

View all blocked commands (JSON):

grep '"decision":"deny"' .claude/permissions-log/**/*.json

View all approved commands (JSON):

grep '"decision":"allow"' .claude/permissions-log/**/*.json

View all tool executions that errored:

grep '"type":"tool_execution"' .claude/permissions-log/**/*.json | grep '"isError":true'

Pending approval files

When the pre-hook returns ask, a separate Markdown file is written under .claude/permissions-log/pending/ for use while the approval prompt is on screen. These files are not part of the hourly audit log. See PENDING-APPROVALS.md for the format, lifecycle, and how to list outstanding prompts.