Skip to content
41 changes: 40 additions & 1 deletion docs/src/architecture/audit-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ record valid.
},
"path": ["permissions", "allow", 0],
"to_kind": null, // set on change-kind ops
"claude_scope_version": "0.3.0"
"claude_scope_version": "0.6.0"
}
```

Expand Down Expand Up @@ -166,6 +166,45 @@ 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
Expand Down
23 changes: 23 additions & 0 deletions docs/src/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,26 @@ For `undo` / `redo` / `restore`, `--json` emits the `RestorePreview`
for a `--dry-run` and the resulting `restore` entry (as an
`AuditRecordView`, the same shape `history --json` produces) once
applied.

### Refusals

`undo` / `redo` / `restore` exit non-zero with a clear message and
write nothing in these cases:

- **`N audit-log entries are unreadable — refusing to compute
undo/redo against a partial log.`** The audit log has malformed
lines that the reader skipped. Acting against a partial log could
emit a duplicate restore entry; repair the log (copy aside, drop
the broken lines) and retry.
- **`the audit log changed since the plan was built` /
`the audit log changed while waiting for confirmation`.** A
concurrent CLI or GUI session appended to the log between this
command's plan-build and apply (or during the confirm prompt). The
stale plan is rejected; re-run the command against the fresh log.
- **`path injection refused`.** An entry's `file_path` points outside
the legitimate scope set for its `project_dir`. Indicates the log
has been hand-edited or corrupted by a third party; do not apply.
See the [security threat model](../security.md#threat-model).

These refusals apply equally under `--yes` — there is no path that
silently writes to the wrong file.
41 changes: 41 additions & 0 deletions docs/src/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,44 @@ tomorrow with no code change on our end.
documented at [Architecture → Audit log](./architecture/audit-log.md).
- The docs site (this one) has **no analytics**. No tracking
pixels, no third-party scripts.

## Threat model

ClaudeScope is a single-user desktop tool. The user running the app
is the only legitimate operator. Within that frame, two threat
surfaces are explicitly defended:

**Hostile audit-log entries.** An attacker who can append a line to
`~/.claude/claude-scope/audit.jsonl` (cloud-sync collision from a
compromised peer, a previously-compromised tool with user-scope
write, a malicious package postinstall, a shared workstation) should
not be able to escalate that capability. The audit log drives
`apply_restore_plan` to write to files at paths recorded in the log
itself — without defenses, one well-formed line could direct
ClaudeScope to write attacker-controlled JSON to any path the user
can write to. The
[Architecture → Audit log § Security invariants](./architecture/audit-log.md#security-invariants)
section documents the three gates (path allowlist, degraded-log
refusal, tail-ID stalecheck) that enforce containment. The path
allowlist is the load-bearing control: every `Side.file_path` is
validated against the four legitimate `ScopePaths` for its
`project_dir` before any write happens.

**Hand-edited settings files.** Users (or other tools) may edit
`settings.json` outside ClaudeScope at any time. The atomic-write
path captures a `FileStamp` at load time and refuses the save when
the stamp differs at persist time — the user's external edit
survives. See
[Architecture → Atomic writes](./architecture/atomic-writes.md) for
the contract.

Out of scope:

- Multi-user systems with adversarial co-users. The threat model
assumes a single trusted user; on shared workstations, OS-level
ACLs are the right layer.
- Resource-exhaustion attacks. A user who can write large amounts
of data into `audit.jsonl` can fill the disk; that's a property
of any append-only log, not a ClaudeScope-specific issue.
- Tampering with the bundled binary. Binary integrity is the
installer / OS package manager's responsibility.
22 changes: 22 additions & 0 deletions docs/src/user-guide/undo-redo.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ captured. If the file was hand-edited since (so its current contents
differ from what the log expected), the confirm modal shows a warning
band — you can still proceed, but you'll be overwriting that edit.

**Disabled with a "log degraded" tooltip.** If
`~/.claude/claude-scope/audit.jsonl` has unreadable lines (corrupt
JSON, a crash mid-write, schema drift the build doesn't recognize),
both Undo and Redo are withheld until the log is repaired. The
tooltip names the count of unreadable entries. Acting against a
partial log could emit a duplicate restore entry, so ClaudeScope
refuses rather than guess. The CLI exits non-zero with the same
message. Repair option: copy `audit.jsonl` aside, drop the broken
lines, restart.

**"Log changed since the preview."** If a concurrent CLI or GUI
session writes to the audit log while the confirm modal is open,
the apply is refused with that message — re-open the History view
and retry against the fresh state. Same posture under
`claude-scope-cli undo` and `--yes`.

**"Path injection refused."** If an audit-log entry's `file_path`
points outside the legitimate scope set for its project (e.g. an
attacker hand-wrote a hostile line into `audit.jsonl`), the restore
is refused before any I/O. See the
[Security threat model](../security.md#threat-model) for context.

## Restore to before an entry

Single-step undo walks back one operation at a time. To jump back
Expand Down
66 changes: 42 additions & 24 deletions src-tauri/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,39 +546,41 @@ fn audit_archives(home: Option<&Path>) -> io::Result<Vec<PathBuf>> {
Ok(archives)
}

/// Sort key for an `audit-YYYY-MM[-N].jsonl` archive filename:
/// `(YYYY-MM string, N as integer)`. The base archive (no suffix)
/// sorts as `N=1` so it comes before any collision suffix
/// (`audit-YYYY-MM-2.jsonl` and beyond). Names that don't match the
/// pattern fall to the end with `N=u32::MAX`, which keeps them
/// deterministic without claiming they were chronologically first.
fn archive_sort_key(name: &str) -> (String, u32) {
/// Sort key for an `audit-YYYY-MM[-N].jsonl` archive filename. Tuple
/// shape is `(is_unrecognized, YYYY-MM, suffix)`:
///
/// - `is_unrecognized: bool` is `false` for parseable shapes and
/// `true` for everything else. Putting it first means parseable
/// archives sort before unknown ones regardless of name — fixing
/// codex 3rd-pass [P2] where a stray `audit-0000.jsonl` would
/// otherwise lex-sort ahead of valid `audit-2026-05.jsonl`.
/// - `YYYY-MM` string for chronological grouping (lex == chrono on
/// ISO-like dates).
/// - `suffix: u32` — 1 for the base file, 2/3/… for the collision
/// siblings.
fn archive_sort_key(name: &str) -> (bool, String, u32) {
// Strip the `audit-` prefix and `.jsonl` suffix; what's left is
// either `YYYY-MM` or `YYYY-MM-N`.
let stem = name
.strip_prefix("audit-")
.and_then(|s| s.strip_suffix(".jsonl"))
.unwrap_or("");
let is_year_month = |s: &str| s.len() == 7 && s.as_bytes().get(4) == Some(&b'-');
// Try `YYYY-MM-N` first: split on the LAST `-` and parse the tail.
if let Some((head, tail)) = stem.rsplit_once('-') {
if let Ok(n) = tail.parse::<u32>() {
// Tail parsed as a number — but only treat it as a
// collision suffix when the head still looks like
// `YYYY-MM` (7 chars, digits + one dash). Otherwise the
// whole stem is a bare year-month with a hyphen inside
// (e.g. `2026-05`).
if head.len() == 7 && head.as_bytes().get(4) == Some(&b'-') {
return (head.to_string(), n);
if is_year_month(head) {
return (false, head.to_string(), n);
}
}
}
// Bare `YYYY-MM` form — treat as collision index 1 so it sorts
// before the `-2`, `-3`, … siblings.
if stem.len() == 7 && stem.as_bytes().get(4) == Some(&b'-') {
return (stem.to_string(), 1);
if is_year_month(stem) {
return (false, stem.to_string(), 1);
}
// Unrecognized shape; park at the end deterministically.
(stem.to_string(), u32::MAX)
// Unrecognized shape; park after every parseable archive.
(true, stem.to_string(), 0)
}

/// Read records + skipped count from a single audit-log file. Factored
Expand Down Expand Up @@ -1511,12 +1513,28 @@ mod tests {
}

#[test]
fn archive_sort_key_parks_unknown_shapes_deterministically() {
// Unrecognized shapes still need a stable position. Park them
// at the end via u32::MAX rather than mixing with parseable
// archives.
let key = archive_sort_key("audit-garbage.jsonl");
assert_eq!(key.1, u32::MAX);
fn archive_sort_key_parks_unknown_shapes_after_valid() {
// Codex 3rd-pass [P2] fix: unknown shapes must sort AFTER
// every parseable archive, regardless of name. A stray
// `audit-0000.jsonl` would otherwise lex-sort ahead of valid
// dates. Test both directly via key + via the actual sort.
let valid = archive_sort_key("audit-2026-05.jsonl");
let unknown = archive_sort_key("audit-0000.jsonl");
assert!(
valid < unknown,
"valid archive {valid:?} should sort before unknown {unknown:?}"
);

let mut names = [
"audit-0000.jsonl",
"audit-2026-05.jsonl",
"audit-garbage.jsonl",
];
names.sort_by_cached_key(|n| archive_sort_key(n));
assert_eq!(
names[0], "audit-2026-05.jsonl",
"parseable archive must come first"
);
}

#[test]
Expand Down
Loading
Loading