Skip to content

Commit 16c0e08

Browse files
authored
feat(scripts): YOLO transcript analyzer for permissions.allow seeding (#281)
* feat(scripts): YOLO transcript analyzer for permissions.allow seeding Walks Claude Code JSONL session transcripts under ~/.claude-work/projects/ and ~/.claude/projects/, extracts paired-success Bash invocations, classifies them by safety category (read-only, search/inspect, build/test, file/cache, local-git mutation, MUTATING), and emits a proposed permissions.allow array as both stdout summary and machine-readable JSON at ~/.local/spellbook/state/proposed_allow_list.json. The script never writes settings.json -- the orchestrator (or user) is expected to review the proposal before piping it through install_permissions. Mutating commands (git push, gh pr merge, acli jira workitem transition/edit) are recorded under rejected_mutating and never promoted to the allowlist regardless of frequency. Bash invocations are yielded only when paired with an explicit non-error tool_result; unpaired tool_uses (interrupted sessions where the user-side confirmation never landed) are dropped, since we cannot confirm the command actually succeeded. * fix(installer): handle null values in settings.json reads A settings.json file containing the literal JSON value ``null`` (or any non-object top-level value) used to crash the installer with ``TypeError: dict(None)`` when ``read_settings`` returned the parsed ``None`` and downstream callers tried to wrap it in a dict. Fixes three null-propagation paths surfaced by Gemini code review: - ``_settings_io.read_settings`` now returns ``{}`` for any non-dict top-level JSON value, treating it the same as a missing or empty file. - ``install_permissions`` now uses ``... or {}`` / ``... or []`` guards on ``existing_settings.get("permissions")`` and ``perms_section.get(bucket)`` so an explicit ``"permissions": null`` or ``"allow": null`` in the user's settings.json no longer crashes the install path. - ``uninstall_permissions`` carries the same guards on its own read of the permissions section. * test(installer): migrate managed_permissions_state test to tripwire The wiring tests in test_claude_code_wires_default_mode_and_permissions used ``monkeypatch.setattr`` to redirect the ``managed_permissions_state._STATE_FILE_PATH`` constant to a tmp_path, which violates the repo style rule that ``monkeypatch`` is reserved for env vars, cwd, and sys.path. To support tripwire-based mocking of the state-file path, introduce a ``_state_file_path()`` accessor on ``managed_permissions_state`` and route every internal callsite through it. The accessor reads the existing ``_STATE_FILE_PATH`` constant dynamically so the legacy monkeypatch-based tests in test_install_default_mode.py continue to work unchanged; new tests should mock the accessor instead. The five wiring tests now register a tripwire mock for ``_state_file_path`` with an empirically probed call budget (9 per install, 6 per uninstall) and assert the calls in the same ``in_any_order`` block as the existing mocks. If the SUT changes the number of state-file accesses, the assertion will fail loudly and the constants ``_STATE_PATH_CALLS_PER_INSTALL`` and ``_STATE_PATH_CALLS_PER_UNINSTALL`` must be updated together with the new probed counts. * test(installer): replace _FlakyReplace stub with tripwire sequential mock The hooks atomic-write tests used a hand-rolled ``_FlakyReplace`` class to stub ``os.replace``: first call raises ``PermissionError``, second call performs the real rename. Hand-rolled stubs are forbidden by the repo style guide -- mocks must go through tripwire so the verifier can assert every interaction. Replace the stub with tripwire's sequential FIFO of side effects: ``mock_replace.__call__.raises(PermissionError(...)).calls(real_os_replace)``. The retry contract is now expressed in the assertion order itself -- ``assert_call(..., raised=AnyThing)`` followed by ``assert_call(..., returned=AnyThing)`` -- which is strictly more verifiable than the previous ``flaky.count == 2`` check, since the order of the raise-then-succeed sequence is now part of the assertion. Also drops the import-time ``_REAL_OS_REPLACE`` capture; the real ``os.replace`` is captured inside the helper instead so the test file has no module-global state. * test(installer): cover read_settings non-dict JSON guard Adds 12 tests for read_settings in installer/components/_settings_io.py: absent file, empty file, whitespace-only file, valid object, malformed JSON (raises), and the non-object top-level cases (null, empty array, populated array, number, string, true, false) that the isinstance guard collapses to {}. Closes Momus BOT-A2 (review on axiomantic/spellbook PR review pass 2). --------- Co-authored-by: elijahr <153711+elijahr@users.noreply.github.com>
1 parent 854b7ca commit 16c0e08

13 files changed

Lines changed: 1318 additions & 65 deletions

File tree

.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.60.0
1+
0.61.0

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.61.0] - 2026-05-06
9+
10+
### Added
11+
12+
- **YOLO transcript analyzer** (`scripts/analyze_yolo_transcripts.py`).
13+
Scans Claude Code session JSONLs under `~/.claude-work/projects/` and
14+
`~/.claude/projects/` (both by default; `--config-dir PATH` repeatable
15+
to override) for successful Bash invocations, buckets them by
16+
`(first-token, sorted -flag tokens)`, and proposes a
17+
`permissions.allow` array of gitignore-style patterns
18+
(`Bash(git status:*)`, etc.) grouped into five safety categories:
19+
read-only, search/inspect, build/test idempotent, local file/cache,
20+
and local git mutation. Mutating commands (`git push`, `gh pr merge`,
21+
`gh pr close`, `acli jira workitem transition`, `acli jira workitem
22+
edit`) are explicitly rejected and surfaced in a separate
23+
`rejected_mutating` section so reviewers can see what was filtered.
24+
Output is written to `~/.local/spellbook/state/proposed_allow_list.json`
25+
with deterministic key ordering, plus a human-readable summary on
26+
stdout. The script never writes `settings.json` directly — the
27+
proposal is intended for review before being fed into
28+
`install_permissions(allow=...)` from Phase 0. Subagent transcripts
29+
under `<session>/subagents/agent-*.jsonl` are walked alongside main
30+
sessions; failed Bash invocations (paired `tool_result.is_error`) are
31+
filtered out before bucketing.
32+
833
## [0.60.0] - 2026-05-05
934

1035
### Added

installer/components/_settings_io.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@ def read_settings(settings_path: Path) -> dict:
1515
1616
Raises ``json.JSONDecodeError`` on malformed JSON and ``OSError``
1717
on read failures; callers handle those to emit failed HookResults.
18+
19+
A file containing the literal JSON value ``null`` (or any non-object
20+
top-level JSON like an array or scalar) is treated as "no settings"
21+
and returned as an empty dict, since downstream callers assume a
22+
mapping.
1823
"""
1924
if not settings_path.exists():
2025
return {}
2126
text = settings_path.read_text(encoding="utf-8")
2227
if not text.strip():
2328
return {}
24-
return json.loads(text)
29+
obj = json.loads(text)
30+
return obj if isinstance(obj, dict) else {}

installer/components/managed_permissions_state.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,23 @@
3737

3838
logger = logging.getLogger(__name__)
3939

40-
# Resolved at import time. Tests monkeypatch this attribute to redirect to a
41-
# pytest tmp_path. See ``tests/installer/test_managed_permissions_state.py``.
40+
# Resolved at import time. Legacy tests monkeypatch this attribute to redirect
41+
# to a pytest tmp_path; new tests should mock ``_state_file_path`` via tripwire
42+
# instead. See ``tests/installer/test_managed_permissions_state.py``.
4243
_STATE_FILE_PATH: Path = Path.home() / ".local" / "spellbook" / "state" / "managed_permissions.json"
4344

45+
46+
def _state_file_path() -> Path:
47+
"""Return the on-disk path of the managed-permissions state file.
48+
49+
Internal callers use this indirection so tests can mock the path via
50+
``tripwire.mock("installer.components.managed_permissions_state:_state_file_path")``
51+
instead of monkey-patching the module-level constant. Reads
52+
``_STATE_FILE_PATH`` dynamically so existing monkeypatch-based tests
53+
continue to work without modification.
54+
"""
55+
return _STATE_FILE_PATH
56+
4457
_SCHEMA_VERSION = 1
4558

4659

@@ -59,7 +72,7 @@ def read_state() -> Dict:
5972
recovery path. Callers do not need to handle ``FileNotFoundError`` or
6073
``json.JSONDecodeError`` themselves.
6174
"""
62-
path = _STATE_FILE_PATH
75+
path = _state_file_path()
6376
if not path.exists():
6477
return _empty_schema()
6578
try:
@@ -149,7 +162,7 @@ def update_managed_set(
149162
new_entry["ask"] = desired["ask"]
150163
config_dirs[key] = new_entry
151164
state["version"] = _SCHEMA_VERSION
152-
atomic_write_json(str(_STATE_FILE_PATH), state)
165+
atomic_write_json(str(_state_file_path()), state)
153166

154167
return diff
155168

@@ -184,7 +197,7 @@ def set_managed_default_mode(config_dir: Path, mode: Optional[str]) -> None:
184197
else:
185198
per_dir["default_mode"] = mode
186199
state["version"] = _SCHEMA_VERSION
187-
atomic_write_json(str(_STATE_FILE_PATH), state)
200+
atomic_write_json(str(_state_file_path()), state)
188201

189202

190203
def reconcile(config_dir: Path) -> Dict[str, List[str]]:
@@ -211,4 +224,5 @@ def _state_lock_path() -> Path:
211224
Distinct suffix from ``atomic_write_json``'s internal ``.lock`` sibling so
212225
the two locks do not collide on the same name during a write.
213226
"""
214-
return _STATE_FILE_PATH.with_suffix(_STATE_FILE_PATH.suffix + ".coordlock")
227+
state_path = _state_file_path()
228+
return state_path.with_suffix(state_path.suffix + ".coordlock")

installer/components/permissions.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,23 @@ def install_permissions(
102102
message=f"permissions: failed to read state file: {e}",
103103
)
104104

105-
perms_section: Dict[str, List[str]] = dict(existing_settings.get("permissions", {}))
105+
# ``or {}`` guards against settings files that contain ``"permissions": null``
106+
# (or any non-dict value) -- ``dict(None)`` raises TypeError. Treat those as
107+
# "no permissions section" so a malformed-but-recoverable settings file does
108+
# not crash the installer.
109+
perms_section: Dict[str, List[str]] = dict(
110+
existing_settings.get("permissions") or {}
111+
)
106112

107113
# Snapshot the pre-modification bucket contents BEFORE we mutate anything.
108114
# Used after the write to distinguish entries spellbook actually added
109115
# (absent before, present after) from entries the user had already added
110116
# by hand and that happened to overlap our desired set. Recording the
111117
# latter as "managed" would silently transfer ownership and let a future
112118
# uninstall delete the user's entry. See GEM-M3 / design §14.
119+
# ``or []`` guards against bucket values that are explicitly ``null``.
113120
pre_existing: Dict[str, set] = {
114-
bucket: set(perms_section.get(bucket, []))
121+
bucket: set(perms_section.get(bucket) or [])
115122
for bucket in ("allow", "deny", "ask")
116123
}
117124

@@ -125,7 +132,8 @@ def install_permissions(
125132
("deny", desired_deny),
126133
("ask", desired_ask),
127134
):
128-
current_list: List[str] = list(perms_section.get(bucket, []))
135+
# ``or []`` guards against bucket values that are explicitly ``null``.
136+
current_list: List[str] = list(perms_section.get(bucket) or [])
129137
prior_set = set(prior_managed.get(bucket, []))
130138
desired_set = set(desired)
131139
to_remove = prior_set - desired_set
@@ -335,11 +343,14 @@ def uninstall_permissions(
335343
message=f"permissions: failed to read {settings_path.name}: {e}",
336344
)
337345

338-
perms_section: Dict[str, List[str]] = dict(existing_settings.get("permissions", {}))
346+
# ``or {}`` / ``or []`` guard against ``null`` values in settings.json.
347+
perms_section: Dict[str, List[str]] = dict(
348+
existing_settings.get("permissions") or {}
349+
)
339350
removed_total = 0
340351
new_perms: Dict[str, List[str]] = {}
341352
for bucket in ("allow", "deny", "ask"):
342-
current_list = list(perms_section.get(bucket, []))
353+
current_list = list(perms_section.get(bucket) or [])
343354
managed_set = set(prior_managed.get(bucket, []))
344355
kept = [e for e in current_list if e not in managed_set]
345356
removed_total += len(current_list) - len(kept)

0 commit comments

Comments
 (0)