Audit log
ClaudeScope appends one JSON Lines record per successful write to
~/.claude/claude-scope/audit.jsonl. The log is the foundation for
Undo and history; the design document
lives at
#19.
Record shape
Each line is a self-contained JSON record. Phase 1 (#122) pinned the
schema; phases 3-5 (#124 / #125 / #126) added one optional field —
restore — for undo/redo/restore meta-entries, leaving every older
record valid.
{
"id": "01HF...", // ULID (Crockford base32). Lex-sort = chrono-sort.
"kind": "move", // move | add | delete | change_kind | restore
"leaf_kind": "permission_rule", // permission_rule | permission_list | top_level_key
"actor": "gui", // gui | cli | skill — who triggered it
"project_dir": "/work/proj", // optional — user-scope-only ops omit
"from": { // optional — Add omits; every restore omits
"scope": "project",
"file_path": "/work/proj/.claude/settings.json",
"top_level_key": "permissions",
"key_before": { "allow": ["Bash(ls)", "Read(**)"] },
"key_after": { "allow": ["Read(**)"] }
},
"to": { // optional — Delete omits
"scope": "user",
"file_path": "/home/me/.claude/settings.json",
"top_level_key": "permissions",
"key_before": { "allow": [] },
"key_after": { "allow": ["Bash(ls)"] }
},
"path": ["permissions", "allow", 0],
"to_kind": null, // set on change-kind ops
"claude_scope_version": "0.6.0"
}
The affected top-level key (permissions for permission ops, the
moved key for top-level moves) is snapshotted before AND after on
each side. That's the minimum needed for an undo to invert any logged
op without re-reading history, while staying narrower than the whole
file.
Restore entries
An undo, redo, or restore-to-point appends a record with
kind: "restore". Instead of from / to it carries a restore
object — a restore can touch more than two files, so two fixed sides
aren't enough:
"restore": {
"target_id": "01HF...", // the entry undone / redone / restored-to
"direction": "undo", // undo | redo | to_point
"files": [ // one Side per file the restore rewrote
{
"scope": "project",
"file_path": "/work/proj/.claude/settings.json",
"top_level_key": "permissions",
"key_before": { "allow": ["Read(**)"] },
"key_after": { "allow": ["Bash(ls)", "Read(**)"] }
}
]
}
Because each files entry snapshots before AND after, a restore is
itself invertible — undoing an undo is just another restore. The
undo/redo cursor is never stored: it's reconstructed on demand by
replaying the direction of every restore entry over the ordered
list of ops (audit::undo_redo_state). A write appended after an undo
latches a sequence break, which withholds redo rather than
discarding the forward stack.
Snapshot-restore — writing a file back to a key_before the log
already captured — is how undo stays faithful. Synthesizing a reverse
move/add/delete instead would mishandle a move into a scope that
already shared the rule (the reverse move would wrongly strip the
destination's own copy); writing the captured snapshot back cannot.
Why ULID, not timestamps
ULIDs embed a 48-bit millisecond timestamp in their first half and a random suffix in the second. Three benefits:
- Lex-sort = chrono-sort. A reader can
sortthe file byidand get correct ordering without trusting wall-clock timestamps, which can skew across machines or under NTP jumps. - One field, not two. A separate
tsfield could drift fromidunder clock corrections. Decoding the timestamp from the ULID at read time keeps the two in lockstep by construction. - Compact. 26 chars of base32, fits a line cleanly.
The frontend never parses Crockford base32 directly — the
list_audit_records IPC returns an AuditRecordView with id and a
derived ts_ms: u64 flattened together. The CLI mirrors the same
shape with --json.
Append is one syscall
audit::append opens with O_APPEND and writes the serialized
record + \n in a single write_all call. For any sane record size
that lands as a single kernel write() syscall, which the OS
executes atomically against concurrent appenders. That's the
property the truncated-tail-tolerance in audit::read_all relies on:
a partial line is treated as one skipped entry, not as a parse
failure that aborts the read.
Fail-open
A failure to append never fails the primary write. The user asked
ClaudeScope to move a rule; an audit.jsonl permission error
doesn't negate that. The append happens after the settings write
succeeds, so a failure here means the file is updated but not
logged — which is the safe direction for ambiguity.
Tauri emits an audit-error event when an append fails, surfaced as
a non-fatal warning in the GUI; the CLI prints the same information
to stderr.
Rotation and concurrency (#166)
Two ClaudeScope sessions can target the same audit log — a GUI plus a CLI invocation, two CLI invocations in different shells, or a Claude Code skill driving the CLI alongside an open GUI. The rotation + append sequence has to behave correctly under that interleaving.
Cross-process lock. Every rotate-then-append pair runs inside
audit::persist, which holds an advisory exclusive lock on
<audit_dir>/audit.lock for the duration. fs2 provides the
cross-platform primitive (flock on POSIX, LockFileEx on
Windows). Two processes both arriving with the active log near the
cap serialize on the lock: the first rotates and appends to a fresh
active file; the second waits, sees the active file is fresh
(under cap, no rotation needed), and appends to the same fresh
file.
Archive-aware reads. Even with the lock, audit.jsonl may have
been rotated into audit-YYYY-MM.jsonl between two appends — that's
the normal-case behavior under rotation, not a bug. audit::read_all
therefore enumerates every audit-*.jsonl archive in the directory,
reads them in filename order (which is chronological — the naming
encodes YYYY-MM plus collision suffix), then reads the active log,
and returns the concatenated record stream. Undo / redo / history
all see every persisted record regardless of which file it lives in.
File naming. The active log is audit.jsonl. The first
rotation lands at audit-YYYY-MM.jsonl using the file's mtime so
the stamp reflects the data inside. Same-month collisions become
audit-YYYY-MM-2.jsonl, audit-YYYY-MM-3.jsonl, … — the lex order
of those filenames also matches chronological order, so the
archive enumeration above stays sorted without timestamp parsing.
Cap. Default 10 MB, configurable under Settings → Audit log rotation. The cap is on the active file's size; archives can grow unbounded across history.
Restore across archives. Phase 4 (plan_restore_to) builds its
window from audit::read_all, which now spans archives. Restoring
to a point that lives in an archive works the same way as restoring
to a point in the active log — same record_sides walk, same per-
file snapshot revert.
Security invariants
The audit log is read by every undo / redo / restore call, and its
contents drive direct writes to the user's settings files. A hostile
line appended to audit.jsonl (cloud-sync collision, malicious
postinstall, compromised tool with user-scope write) must not be able
to escalate into arbitrary file-write. Three gates enforce that.
Path allowlist (#183). Every audit-log boundary —
undo_redo_target, restore_to_point_preview,
apply_restore_to_point, and the CLI's cmd_undo / cmd_redo /
cmd_restore — calls validate_audit_records immediately after
reading records and before building any RestorePlan. Each
Side.file_path must equal one of the four resolved ScopePaths
slots (local, project, user_local, user) for that record's
own project_dir, against the active home override. Canonicalization
absorbs platform-specific path forms (/var → /private/var on macOS,
\\?\C:\… extended-length paths on Windows). The basename must be
settings.json or settings.local.json. Any path outside the
allowlist is refused with a clear path injection refused message;
no writes happen.
Degraded-log refusal (#170). audit::read_all reports a count
of unreadable lines (corrupt JSON, schema drift the reader can't
parse, truncated tails). Every undo / redo / restore path gates on
that count: the GUI's require_clean_audit_log and the CLI's
read_audit_log both refuse with a non-zero exit / blocked topbar
button when skipped > 0. A partial log could leave the undo/redo
state machine pointing at an op that was already undone, and
confirming would emit a duplicate restore entry.
Tail-ID stalecheck (#165, #171). Every restore re-reads the log
immediately before applying and compares the trailing record's ULID
against the value captured at preview time. A mismatch means the log
changed under the user — refuse rather than apply a plan against a
log the user didn't see. This catches concurrent GUI/CLI appends in
the prompt window AND the (smaller) window between the initial read
and --yes apply.
Sandbox
When --home is set, emit_audit skips the rotation + append
sequence entirely. Scratch runs cannot pollute the real
~/.claude/claude-scope/. The preferences-write path has the same
guard.