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-clisubcommand 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.
| # | Scope | Path | Tracked? |
|---|---|---|---|
| 1 | Managed | enterprise-deployed | Out of scope for ClaudeScope. |
| 2 | Local | ./.claude/settings.local.json | Project-local, gitignored. |
| 3 | Project | ./.claude/settings.json | Committed with the project. |
| 4 | User-Local | ~/.claude/settings.local.json | Machine-local override. |
| 5 | User | ~/.claude/settings.json | Machine-global. |
ClaudeScope omits Managed (#1) entirely — it's an enterprise concern that lives outside the personal-tool workflow this project targets.
~/.claude/settings.local.jsonwas 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.jsonwithout switching the loaded project). - Change kind → switch
allow↔deny↔askwithin 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.
Search
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
| Phase | Feature | Status |
|---|---|---|
| 1 | JSONL audit log at ~/.claude/claude-scope/audit.jsonl | Shipped (#19 phase 1) |
| 2 | Read-only History dialog (topbar button) listing entries newest-first | Shipped (#19 phase 2) |
| 3 | Undo / redo via the diff-confirm modal, Ctrl/Cmd+Z shortcuts | Shipped (#124) |
| 4 | "Restore to before this" — multi-step rollback from a History row | Shipped (#125) |
| 5 | CLI parity — claude-scope-cli history / undo / redo / restore | Shipped (#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 → Userfor 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
| Shape | Example | Notes |
|---|---|---|
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_file | MCP 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
- Claude Code permissions — upstream reference for the permission system.
- Settings keys — per-key help map (planned, #9).
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 fromcwdfor the nearest.gitor.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]
--sinceacceptss/m/h/d/mssuffixes. Bare numbers are rejected — the parser refuses to guess units.--limitdefaults to 20;0is unlimited.--kindis repeatable:move/add/delete/change-kind/restore. OR semantics when multiple flags are passed.--jsonemits the sameAuditLogPagewire shape the GUI'slist_audit_recordsIPC 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'sfile_pathpoints outside the legitimate scope set for itsproject_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
permissions—allow/deny/askrule 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.rsserialize to JSON;src/types.tsmirrors 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.json→ Localproject_dir/.claude/settings.json→ Project<home>/.claude/settings.local.json→ User-Local<home>/.claude/settings.json→ User
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_jsonwithpreserve_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:
- Serialize the in-memory
SettingsDocto bytes. - 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. - Tempfile in the same directory as the target.
- 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 thepersist— a writer that lands inside that window will still be overwritten. - Rename the tempfile over the target. The rename is filesystem-atomic.
- On Unix, the parent directory is also
fsync'd so the rename is crash-durable. On Windows, the parent-dir flush is skipped —std::fscannot open a directory handle for flushing without a small raw-winapi wrapper, and NTFS journals rename metadata inMoveFileExalready.
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
.bakis 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
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.
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-cliis not needed. Usenpm run tauri dev(notcargo tauri dev). - Windows:
-
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 acrossubuntu-24.04,windows-latest,macos-latest. Each runsnpm ci,tsc --noEmit,vite build,cargo test --lib --locked.msrv (1.88)— validates the declared MSRV viacargo check --lib --tests --lockedon Rust 1.88.0.lint—pre-commit/action@v3(Biome, rustfmt, clippy, hygiene hooks).security—rustsec/audit-checkagainst the RustSec advisory DB +npm audit --package-lock-only --audit-level=high(reads the lockfile directly; nonode_modulesinstall, 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 insrc-tauri/Cargo.lock.npm audit --package-lock-only --audit-level=high— gates on high+ severity vulnerabilities in the npm tree, readingpackage-lock.jsondirectly (nonode_modulesinstall, no lifecycle scripts).detect-private-keypre-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.jsonlcan 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.