ClaudeScope

Desktop GUI for promoting Claude Code permission rules between scopes — without hand-editing JSON.

Claude Code reads settings from JSON files at four scopes (User, User-Local, Project, Local). Moving a rule from one to another by hand means editing two files, preserving key order, and not breaking the JSON. ClaudeScope shows every rule across the four scopes side-by-side and moves them with a click.

What's here

  • User guide — the scope model, how moves work, and how history / undo (#19) lets you walk operations back.
  • Reference — permission-rule grammar ClaudeScope understands, the claude-scope-cli subcommand surface, and the per-key help shown by tooltips.
  • Architecture — how the Rust backend and TypeScript front end fit together; the atomic-write invariants; the on-disk audit log.
  • Contributing — local dev setup, the test suites, and the release workflow.
  • Security — how to report a vulnerability and what CI catches.

Install

Pre-built installers for Windows, macOS, and Linux are attached to each GitHub Release. First launch on Windows or macOS shows an unsigned-binary warning; signed builds are planned but not yet available — see Contributing → Known limitation: unsigned builds.

To run from source, see Contributing → Develop.

Scopes

Claude Code reads settings from JSON files at four scopes. The scopes are laid out in precedence order, highest first — the highest-precedence file that defines a key wins.

#ScopePathTracked?
1Managedenterprise-deployedOut of scope for ClaudeScope.
2Local./.claude/settings.local.jsonProject-local, gitignored.
3Project./.claude/settings.jsonCommitted with the project.
4User-Local~/.claude/settings.local.jsonMachine-local override.
5User~/.claude/settings.jsonMachine-global.

ClaudeScope omits Managed (#1) entirely — it's an enterprise concern that lives outside the personal-tool workflow this project targets.

~/.claude/settings.local.json was originally excluded too, but Claude Code is observed to create and use it when the user's home directory is itself inside a git repo. ClaudeScope treats it as a first-class scope on par with the other three.

Column layout

The four scopes render as columns, broadest-on-the-left to narrowest-on-the-right:

┌─────────┬─────────────┬──────────┬───────┐
│  User   │ User-Local  │ Project  │ Local │
└─────────┴─────────────┴──────────┴───────┘
   ←──── broader / shared      narrower ───→

That's the opposite of precedence order. ClaudeScope's column order is about audience — User-scope rules affect every project, Local-scope rules affect one. The Effective settings panel is the trailing column in the same row, summarizing what applies in the active directory. Default-collapsed with inline counts; expand to see the full union across scopes. It's not a full precedence-aware evaluation of what Claude Code resolves at runtime — the subtitle in the app says so.

Per-scope file presence

A scope's column shows as absent when its file doesn't exist on disk. ClaudeScope never auto-creates a scope file just to fill the column; it creates the file on first write to that scope, and only then. A move into an empty scope produces a fresh settings.json (or settings.local.json) with just the moved rule.

The User and User-Local scopes resolve relative to the OS home directory (%USERPROFILE% on Windows, $HOME elsewhere); Project and Local resolve from the currently-loaded project root. See Architecture → Overview for how scope discovery walks up from the project picker's selection.

Switching projects

Two ways to switch the loaded project:

  • Open project… — file picker; default to ~ on first open.
  • Recent projects dropdown — click the project label in the topbar for a menu of recently-opened roots (capped at 10, persists across sessions). See Moving rules for the move flows that this enables across projects.

Moving rules

Every per-rule action goes through the same primitive: pick a rule, pick a destination scope (or allow / deny / ask if you're reclassifying), confirm the diff, write. The destination file is written first, then the source file is rewritten without the rule. If the source rewrite fails, the destination is rolled back — the rule never ends up in two places, never goes missing. See Architecture → Atomic writes for the invariants.

From the GUI

Each rule row carries move buttons for every other scope (→ User, → Project, etc.). Right-click the row for the full menu:

  • Move to → submenu with scopes + a "Project…" submenu listing every discovered Claude project (so a rule can land directly in another repo's .claude/settings.json without switching the loaded project).
  • Change kind → switch allowdenyask within the same scope.
  • Copy / Paste — clipboard plumbing for rules and rule lists.
  • Delete — drops the rule from its scope.

Every action that writes shows a diff preview modal first. Apply the move with Enter, cancel with Escape. The trigger button gets focus back when the modal closes.

Press / anywhere to focus the search input. Filter is case-insensitive substring across rules in every scope. Match counts appear per group (allow (1/3) = one match out of three). Escape clears the filter.

Tool grouping

When two or more rules share a tool prefix (Bash(...), handoff(...)), they fold under a synthetic Tool ▸ (N) node. The threshold defaults to 2; future work tracked in #115 will surface it as a Settings option.

Sandbox mode

Two ways to dogfood a build against a throwaway home directory rather than your real ~/.claude/:

# Env vars (works with `npm run tauri dev`):
CLAUDE_SCOPE_HOME=/tmp/scratch CLAUDE_SCOPE_PROJECT=/tmp/scratch/project npm run tauri dev

# CLI flags (works against a built binary):
claude-scope --home /tmp/scratch --project /tmp/scratch/project

When either override is active, a yellow Sandbox mode banner sits under the toolbar so you can't forget you're in scratch mode. The audit log, recent-projects LRU, and preferences write to the scratch home too — running ClaudeScope under --home cannot pollute the real config dir.

scripts/scratch-home.py seeds a fresh scratch dir with realistic example settings:

python scripts/scratch-home.py            # auto temp dir
python scripts/scratch-home.py /tmp/sb    # explicit, idempotent
python scripts/scratch-home.py /tmp/sb --force   # wipe + reseed

Undo and history

ClaudeScope keeps an append-only audit log of every successful write — moves, adds, deletes, kind changes. The log answers questions the single-slot .bak model can't:

  • "What did I change in the last hour, in order?"
  • "Undo the last change." (Not the whole session — just the last one.)
  • "Take me back to before I imported that preset."

What ships today

PhaseFeatureStatus
1JSONL audit log at ~/.claude/claude-scope/audit.jsonlShipped (#19 phase 1)
2Read-only History dialog (topbar button) listing entries newest-firstShipped (#19 phase 2)
3Undo / redo via the diff-confirm modal, Ctrl/Cmd+Z shortcutsShipped (#124)
4"Restore to before this" — multi-step rollback from a History rowShipped (#125)
5CLI parity — claude-scope-cli history / undo / redo / restoreShipped (#126)

The on-disk schema is stable as of phase 1; phases 3-5 added an optional restore field for undo/redo/restore meta-entries without breaking older log entries. See Architecture → Audit log for the record format.

Using the History dialog

Click History in the topbar. Entries appear newest-first, with:

  • A color-tinted verb (Move permission rule / Add permission rule / Delete permission rule / Change kind → deny).
  • An ISO timestamp (locale-formatted text, ISO datetime=… for screen readers).
  • The scope arrow (Project → User for cross-scope moves; single scope for adds/deletes).
  • The affected rule string, recovered by diffing the before/after snapshots in the record.

A restore entry (the result of an undo / redo / restore-to-point) shows as Undo / Redo / Restore to point, names the entry it acted on, and lists the scopes it touched.

The bottom of the list surfaces a count of any malformed entries the reader skipped — useful if audit.jsonl was hand-edited or partially written during a crash.

Undo and redo

The topbar's Undo and Redo buttons — and Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z — step backward and forward through the log one operation at a time. Each button's tooltip names exactly what the next step would do (e.g. "Undo: Move permission rule Bash(ls) (3 min ago)").

Every undo and redo previews before it writes: it routes through the same diff-confirm modal a move does, so you see the per-file before/after and confirm. Confirming appends a new restore entry — the log is append-only, so an undo is itself recorded and can be redone.

Sequence break. If you make a fresh change after an undo, the redo stack is ambiguous — Redo greys out and its tooltip explains why, rather than silently discarding the forward history.

External edits. Undo writes a file back to the snapshot the log 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 for context.

Restore to before an entry

Single-step undo walks back one operation at a time. To jump back multiple operations at once — "take me back to before I imported that preset" — open History and click the Restore button on the target row.

That reverts the targeted entry and every entry logged after it: ClaudeScope walks the log forward, computes the net per-file delta, and writes each affected file back to its state before the target. The confirm modal lists every file that will change and how many operations are being reverted ("Restoring to 4 ops back"). A restore-to-point is itself a single log entry, so it can be undone like any other operation.

Rotation

Once the log exceeds the size cap (default 10 MB) it's renamed to audit-YYYY-MM.jsonl and the next entry lands in a fresh file. Old archives stay on disk indefinitely — delete them by hand if you want. Both behaviors are configurable under Settings → Audit log rotation.

CLI

claude-scope-cli mirrors the History dialog and the undo / redo / restore actions from a shell:

# Last 20 entries, newest first.
claude-scope-cli history

# Undo the most recent change — previews, then prompts.
claude-scope-cli undo

# Redo it, no prompt.
claude-scope-cli redo --yes

# Preview a restore-to-point without writing.
claude-scope-cli restore 01JABC --dry-run

The entry id passed to restore is a full ULID or any unique prefix. --dry-run prints the planned restore and writes nothing; --yes skips the confirm prompt; --json emits machine-readable output. CLI restores log a restore entry with actor: cli, so a GUI session sees them in its History. See CLI reference for the full subcommand surface.

Rule syntax

Claude Code's permission grammar isn't publicly documented in full, but a few common shapes are well-established. ClaudeScope recognizes these and surfaces a subtle ⚠ lint badge on rules that don't match any of them. The badge is best-effort, not authoritative — a flagged rule may still be valid; the popover names the heuristic that tripped so you can judge.

Shapes ClaudeScope knows

ShapeExampleNotes
Bash(<pattern>)Bash(git status)Shell command. Patterns may include * wildcards (Bash(git *)).
Read(<glob>)Read(**)File read. Standard glob syntax.
WebFetch(domain:<host>)WebFetch(domain:docs.claude.com)URL fetch restricted to a domain.
mcp__<server>__<tool>mcp__filesystem__read_fileMCP server tool invocation.
<Tool>(<args>)Edit(*.ts) / Write(README.md)Generic tool-with-arguments shape.

Rules outside these shapes (no parentheses, unrecognized tool names, malformed args) are still allowed on disk — Claude Code may know shapes ClaudeScope doesn't. The lint just flags them so you can double-check.

Kinds

Every rule lives under one of three lists at permissions.<kind>:

  • allow — permit the action without prompting.
  • deny — refuse the action.
  • ask — prompt before allowing.

ClaudeScope shows each kind in its own column within a scope and lets you reclassify a rule in-place (right-click → Change kind → pick the destination kind). The same primitive powers cross-scope kind changes — moving Bash(rm *) from project-allow to user-deny is one operation.

Examples by tool

Most realistic settings files have rules clustered around a few tools. Two or more rules sharing a Tool(...) prefix fold under a synthetic Tool ▸ (N) group in the per-scope tree view:

{
  "permissions": {
    "allow": [
      "Bash(git status)",
      "Bash(git diff)",
      "Bash(npm test)",
      "Read(**)",
      "WebFetch(domain:docs.claude.com)"
    ]
  }
}

In the UI:

permissions
├── allow
│   ├── Bash ▸ (3)
│   │   ├── Bash(git status)
│   │   ├── Bash(git diff)
│   │   └── Bash(npm test)
│   ├── Read(**)
│   └── WebFetch(domain:docs.claude.com)

The grouping threshold defaults to 2; tracked in #115 for a Settings-dialog control.

Useful docs

CLI

claude-scope-cli is a terminal front end that re-uses the same Rust backend the GUI does — scope discovery, atomic writes, the move-leaf primitive, the audit log. It's installed alongside the GUI by the release installers and is also available via cargo build --bin claude-scope-cli for source builds.

The Claude Code skill side (#13) wraps this binary; the JSON output of every subcommand is stable enough that a script or skill can rely on it.

Global flags

Every subcommand accepts:

  • --project-dir <PATH> — pin the project root. Without this, ClaudeScope walks up from cwd for the nearest .git or .claude/.
  • --home-dir <PATH> — sandbox mode. Redirects user / user-local scope lookups (and the audit log) under this directory.

Subcommands

scopes

List recognized scopes and their on-disk state.

claude-scope-cli scopes [--json]

show

Print one scope's raw contents, or the effective merged view.

claude-scope-cli show --scope user [--json]
claude-scope-cli show --effective [--json]

--scope and --effective are mutually exclusive.

list-rules

List permission rules across scopes.

claude-scope-cli list-rules [--scope <SCOPE>] [--kind <KIND>] [--json]

--kind accepts allow / deny / ask.

list-projects

Enumerate Claude projects discovered on this machine, via the ~/.claude/projects/ transcript registry.

claude-scope-cli list-projects [--json]

version

Print the same diagnostic block the GUI's About dialog shows. Useful for bug reports — paste the output into the issue.

claude-scope-cli version [--json]

The clap-builtin --version still prints just the version line.

move

Move a permission rule between scopes. Writes are atomic and create a .bak of the original on the first write of the session.

claude-scope-cli move "Bash(git status)" --kind allow --from project --to user [--dry-run] [--json]

history

Read the audit log. See Undo and history for the user-facing description; the CLI surface mirrors the History dialog one-for-one.

claude-scope-cli history [--since <DURATION>] [--limit <N>] [--kind <KIND>]... [--json]
  • --since accepts s / m / h / d / ms suffixes. Bare numbers are rejected — the parser refuses to guess units.
  • --limit defaults to 20; 0 is unlimited.
  • --kind is repeatable: move / add / delete / change-kind / restore. OR semantics when multiple flags are passed.
  • --json emits the same AuditLogPage wire shape the GUI's list_audit_records IPC returns.

Human output: tab-separated ULID ts verb scope-arrow rule, newest-first.

undo / redo

Step backward / forward through the audit log one operation at a time, mirroring the GUI's Undo / Redo buttons.

claude-scope-cli undo [--dry-run] [--yes] [--json]
claude-scope-cli redo [--dry-run] [--yes] [--json]

undo reverts the most recent operation by writing the affected files back to the snapshots in the log; redo re-applies the most recently undone one. redo exits non-zero after a sequence break (a change made since the last undo) — the forward stack is then ambiguous, same rule as the greyed-out GUI button.

Both print the planned restore and prompt Apply? [y/N] unless --yes is passed. --dry-run prints the plan and writes nothing. Each successful run appends a restore entry with actor: cli.

restore

Restore every file affected by a logged entry — and the entries after it — back to its state before that entry.

claude-scope-cli restore <ENTRY-ID> [--dry-run] [--yes] [--json]

<ENTRY-ID> is a full ULID or any unique prefix; an ambiguous prefix prints the candidates and exits non-zero. Same --dry-run / --yes flags as undo.

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.

These refusals apply equally under --yes — there is no path that silently writes to the wrong file.

Settings keys

A reference for every recognized top-level key in .claude/settings.json (and the .local.json overrides), with the same one-sentence explanations the in-app hover tooltips surface.

The full per-key help map lives in src/ui.ts today; tracked in #9 for canonical content with deep links to Claude Code's documentation. Until that's exported as structured data, this page is a stub — see the in-app tooltips for the current help text.

Common keys

  • permissionsallow / deny / ask rule lists. The primary surface ClaudeScope manages. See Rule syntax.
  • env — environment variables Claude Code injects into each agent invocation.
  • hooks — pre / post tool-call hooks. Powerful and easy to misuse; double-check before moving these between scopes.
  • theme — UI theme override (auto / light / dark).
  • model — default model selector.
  • apiKeyHelper — script invoked to source the API key on demand.
  • statusLine — custom status-line configuration.
  • enableAllProjectMcpServers — escape hatch for per-project MCP plumbing.

For the authoritative list, see Claude Code's settings documentation.

Architecture overview

ClaudeScope is a Tauri 2 app — Rust backend, web front end, single binary output. The split:

┌─────────────────────────────────────────────────────┐
│              Vanilla TS + Vite (front end)          │
│  src/main.ts       state + IPC + keybindings        │
│  src/ui.ts         DOM rendering, diff modal, …     │
│  src/lint.ts       shape-level rule-string lint     │
│  src/types.ts      shared types mirrored from Rust  │
└────────────────────┬────────────────────────────────┘
                     │  Tauri IPC (#[tauri::command])
┌────────────────────┴────────────────────────────────┐
│                     Rust backend                     │
│  src-tauri/src/                                      │
│    main.rs       Tauri entry                         │
│    lib.rs        Builder + managed state + IPC reg.  │
│    scope.rs      Scope discovery (walk-up, paths)    │
│    projects.rs   ~/.claude/projects/ enumeration     │
│    io_atomic.rs  Atomic read/write + .bak tracker    │
│    model.rs      SettingsDoc, perm add/remove        │
│    commands.rs   #[tauri::command] handlers          │
│    watcher.rs    notify-based file watcher           │
│    audit.rs      JSONL audit log + rotation          │
│    preferences.rs Per-user config (theme, LRU, …)    │
│    app_info.rs   Build/runtime diagnostics           │
│    runtime.rs    --home / --project override layer   │
└──────────────────────────────────────────────────────┘

A second binary claude-scope-cli (src-tauri/src/bin/cli.rs) re-uses the same library — same scope discovery, same atomic-write machinery, same audit log. Anything the GUI can do via IPC, the CLI eventually exposes as a subcommand. See CLI for the current surface.

Boundaries

  • Front end never writes files directly. Every file-touching operation goes through a Tauri command. That keeps the atomic-write invariants concentrated on the Rust side where they can't be reordered by a re-render.
  • Wire types live in one place per direction. Rust structs in commands.rs serialize to JSON; src/types.ts mirrors the same shapes by hand. A schema generator would be over-engineering for the surface area today.
  • The audit log is the source of truth for history; the filesystem is the source of truth for state. They can diverge — if a user hand-edits a settings file between sessions, the log is stale, and restore (when it lands) re-reads the current file before computing any delta.

Scope discovery

scope::resolve_with_home() walks up from a starting directory looking for a .git entry (preferred), or a .claude/ directory, to identify the project root. From there it derives:

  • project_dir/.claude/settings.local.jsonLocal
  • project_dir/.claude/settings.jsonProject
  • <home>/.claude/settings.local.jsonUser-Local
  • <home>/.claude/settings.jsonUser

The <home> argument defaults to dirs::home_dir() but is overridable for sandbox runs (--home, CLAUDE_SCOPE_HOME). See Moving rules → Sandbox mode.

Stack

  • Tauri 2 — Rust backend + webview.
  • Vanilla TypeScript + Vite — front end (intentionally tiny, no framework).
  • serde_json with preserve_order — key order survives round-trips.
  • notify-debouncer-mini — cross-platform file watching with built-in debouncing.
  • ulid — chronologically-sortable IDs for audit-log records.
  • Biome 2 — formatter + linter + import sorter for the front end.

Atomic writes

Every settings-file write in ClaudeScope funnels through a single atomic_write_json chokepoint in src-tauri/src/io_atomic.rs. The sequence:

  1. Serialize the in-memory SettingsDoc to bytes.
  2. Re-parse the produced bytes via serde_json::from_slice — if they don't round-trip, the write is refused before anything hits disk. Bad bytes never escape.
  3. Tempfile in the same directory as the target.
  4. Stamp recheck — a FileStamp (mtime + length) captured at load time is compared to the target's current state. If the file changed on disk between load and save, the write is refused with a "reload and retry" error rather than overwriting the external edit. The recheck has a microsecond-scale TOCTOU window between the comparison and the persist — a writer that lands inside that window will still be overwritten.
  5. Rename the tempfile over the target. The rename is filesystem-atomic.
  6. On Unix, the parent directory is also fsync'd so the rename is crash-durable. On Windows, the parent-dir flush is skipped — std::fs cannot open a directory handle for flushing without a small raw-winapi wrapper, and NTFS journals rename metadata in MoveFileEx already.

Backups

On the first write of a given file per session, the original is copied to <file>.bak. Existing .bak files from previous runs are preserved, not clobbered. This is a one-shot backup attempt, not a best-effort write-through: if backup creation is required and the copy fails, the error is surfaced and the write is not attempted.

The behavior is opt-out under Settings → Backups (#88). Users whose .claude/ is version-controlled and don't want extra files can turn it off — at which point the audit log (Undo and history) becomes the only safety net.

Moves are two-file transactions

A scope-to-scope move writes the destination first, then removes the rule from the source. If the source write fails, the destination write is rolled back so the rule ends up in exactly one scope rather than being lost or duplicated. If the rollback itself fails the user sees an explicit error rather than a silent partial state.

The rollback path can't reuse apply_move_leaf_impl — it has to write the pre-move destination directly, with no .bak (we already created one), and with no stamp check (we are the canonical writer of the destination at that point). This is why the apply impl threads backups and stamp parameters down explicitly rather than reading them from a static.

What's atomic vs. what's not

Atomic:

  • The bytes hitting any single file. Either the new contents land intact or the old contents are preserved — never a partial write.
  • The rename of tempfile-over-target on a single filesystem.
  • The bytes-validate-as-JSON gate. The re-parse step runs before any filesystem operation; broken JSON never reaches disk.

Not atomic:

  • The pair of writes that constitute a cross-scope move. They are two filesystem operations; if the process is killed between them, the rollback can't run. The destination's .bak is the recovery path in that case.
  • The audit log append relative to the settings write. The audit entry lands after the settings write succeeds, so a process kill in that narrow window means a write happened but isn't logged. The log being authoritative-for-history-but-not-state (Architecture → Overview) absorbs that gap.

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 sort the file by id and get correct ordering without trusting wall-clock timestamps, which can skew across machines or under NTP jumps.
  • One field, not two. A separate ts field could drift from id under 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.

Contributing

This document covers local dev setup, the test suites, and the release flow. For the original project brief — problem statement, must-have behavior, non-goals — see CLAUDE.md at the repo root.

Develop

Prerequisites

  • Rust — stable, 1.88+ (imposed by Tauri 2's transitive deps). Install via rustup:

    • Windows: winget install Rustlang.Rustup (then restart your terminal).
    • macOS / Linux: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh.

    The Tauri CLI is bundled as an npm dev dependency — cargo install tauri-cli is not needed. Use npm run tauri dev (not cargo tauri dev).

  • Node.js 20+ and npm.

  • Linux build deps (only on Linux):

    sudo apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
    
  • pre-commit (optional, strongly recommended — CI runs the same hooks):

    pip install --user pre-commit
    pre-commit install
    

Run

npm install
npm run tauri dev

For a non-destructive dogfood loop against a throwaway home, see Moving rules → Sandbox mode.

Tests

# Rust unit tests (scope discovery, atomic writes, audit log, …).
(cd src-tauri && cargo test)

# Front-end type check + Vitest.
npx tsc --noEmit
npx vitest run

A manual smoke pass for UI changes lives in docs/SMOKE_TEST.md at the repo root — a ~5-minute checklist covering the move flow, watcher reload, scope visibility, and side-effect verification.

Lint / format

# Run everything the `lint` CI job runs (Biome, rustfmt, clippy,
# hygiene hooks).
pre-commit run --all-files

# Front-end only:
npm run lint       # biome check (lint + format check)
npm run format     # biome format --write

# Rust side:
(cd src-tauri && cargo fmt --all --check && cargo clippy --all-targets -- -D warnings)

Build

npm run tauri build

Docs

This site is built with mdBook. Edit the Markdown under docs/src/ and either of:

# Live-reload preview at http://localhost:3000.
mdbook serve docs/

# One-shot build to docs/book/ (the CI artifact).
mdbook build docs/

mdbook-linkcheck runs as a hard gate during CI — broken internal links fail the build. External link rot is monitored separately by the lychee workflow on the same Markdown surface.

CI

Every push to dev and every PR against dev runs four jobs in parallel:

  • build — matrix across ubuntu-24.04, windows-latest, macos-latest. Each runs npm ci, tsc --noEmit, vite build, cargo test --lib --locked.
  • msrv (1.88) — validates the declared MSRV via cargo check --lib --tests --locked on Rust 1.88.0.
  • lintpre-commit/action@v3 (Biome, rustfmt, clippy, hygiene hooks).
  • securityrustsec/audit-check against the RustSec advisory DB + npm audit --package-lock-only --audit-level=high (reads the lockfile directly; no node_modules install, no lifecycle scripts).

Releasing

Version bumps are automated by release-please but manually triggered. Run Actions → Release Please → Run workflow and release-please walks dev since the last tag, opens a "release PR" that bumps every version-carrying file in lockstep (package.json, package-lock.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json, plus src-tauri/Cargo.lock via a follow-up sync step). Merging that PR pushes a vX.Y.Z tag, which triggers release.yml to build and upload installers + SHA256SUMS.txt. Release notes live on the GitHub Release itself — skip-changelog is set in release-please-config.json, so there is no tracked CHANGELOG.md.

If you ever need to bump versions by hand (e.g. release-please is broken), do not regenerate package-lock.json with npm install --package-lock-only on Windows — npm drops Linux-only optional deps (e.g. @emnapi/*, @napi-rs/*) and npm ci then fails on the Linux CI runner. Edit only the two "version" fields in the lockfile and leave the dependency tree alone. scripts/sync-cargo-lock.py updates the claude-scope entry in Cargo.lock without touching anything else.

Known limitation: unsigned builds

Releases are currently unsigned:

  • Windows: first launch shows a SmartScreen warning (click "More info" → "Run anyway").
  • macOS: first launch shows a Gatekeeper warning (right-click the app → Open → Open).
  • Linux: no warning.

Signing would require a Windows code-signing certificate and/or an Apple Developer ID + notarization. Planned, but out of scope until there's demand.

Security

Reporting a vulnerability

Please do not open a public GitHub issue for security problems.

Report them via GitHub Private Vulnerability Reporting. It's the fastest channel and keeps the details private until there's a fix ready to announce. You don't need any special access — anyone with a GitHub account can file one.

What to include:

  • A clear description of the issue and its impact.
  • Steps to reproduce (the smallest version that still demonstrates the problem).
  • The affected version or commit.
  • Any suggested fix or mitigation, if you have one.

Acknowledgement: within a few business days, with a sense of the expected timeline from there.

Supported versions

ClaudeScope is pre-1.0. Only the most recent tagged release gets security fixes — and until v0.1.0 actually ships, that means the latest commit on the dev branch. Older tags (once they exist) are archived as-is.

What CI catches

These checks run on every push to dev and every PR against it (see .github/workflows/ci.yml):

  • cargo-audit — gates on advisories in the RustSec advisory DB for any Rust dep in src-tauri/Cargo.lock.
  • npm audit --package-lock-only --audit-level=high — gates on high+ severity vulnerabilities in the npm tree, reading package-lock.json directly (no node_modules install, no lifecycle scripts).
  • detect-private-key pre-commit hook — rejects commits containing common private-key headers.
  • Dependabot — opens weekly grouped PRs for Cargo, npm, and GitHub Actions minor/patch bumps so known-patched vulns don't linger.

Anything cargo-audit or npm audit surfaces after a clean merge is a signal to ship a patch PR. The advisory DBs update continuously, so a dep that was clean yesterday can light up the security job tomorrow with no code change on our end.

Privacy

  • ClaudeScope is a local-only tool. It reads and writes files on the user's machine; no network requests in normal operation.
  • The audit log (~/.claude/claude-scope/audit.jsonl) is as sensitive as the settings files themselves — it contains full rule text and project paths. Don't ship it anywhere. The on-disk format is documented at Architecture → Audit log.
  • 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 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 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.