Skip to content

Commit cb5a9e4

Browse files
committed
feat(audit): structured JSONL audit log for mutating verbs (Phase G)
Every mutating action now appends a JSONL record to `<content_dir>.parent/audit.log` (defaults to `~/.config/claude-config/audit.log`). Each line is a self-contained JSON object with `ts` (RFC3339 UTC), `event`, `content_dir`, `target`, and per-event fields. Events emitted: - `install`: {links_created, dir_links_created, already_correct, real_files_backed_up, global_writes} - `uninstall`: {removed, backups_restored} - `track`: {path, kind} - `decisions_apply`: {pack, force, written, overwritten, skipped} Dry-run calls do NOT write to the audit log (the log is for actual mutations only). The writer is best-effort: OSError on the log path never blocks the operation that triggered it. `ClaudeConfig.audit_log_path` is a public property so users can script over it (jq, grep, log-shipping). Four tests: - install writes one event - install --dry-run writes nothing - decisions_apply writes an event with pack name - audit_log_path lives next to content_dir Full suite: 252 passing. Closes SPEC §4 Phase G.
1 parent 97761cb commit cb5a9e4

2 files changed

Lines changed: 109 additions & 2 deletions

File tree

src/ai_config_kit/manager.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,45 @@ def __repr__(self) -> str:
11191119
f"include_history={self._include_history})"
11201120
)
11211121

1122+
# ---- audit log (Phase G) -------------------------------------------
1123+
1124+
@property
1125+
def audit_log_path(self) -> Path:
1126+
"""Append-only JSONL audit log for mutating verbs.
1127+
1128+
Lives next to ``content_dir`` so it's outside the symlinked tree
1129+
but still on the same filesystem (the default content_dir lives
1130+
at ``~/.config/claude-config/content/``, so the log goes to
1131+
``~/.config/claude-config/audit.log``).
1132+
"""
1133+
return self._content_dir.parent / "audit.log"
1134+
1135+
def _audit(self, event: str, **fields: Any) -> None:
1136+
"""Append a single JSONL event. Best-effort: never raises.
1137+
1138+
Each line is a self-contained JSON object with at minimum
1139+
``ts`` (RFC3339 UTC), ``event``, ``content_dir``, ``target``,
1140+
and the caller's keyword fields. Designed so ``jq`` /
1141+
``grep`` over the file is straightforward.
1142+
"""
1143+
from datetime import datetime, timezone
1144+
1145+
record: dict[str, Any] = {
1146+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1147+
"event": event,
1148+
"content_dir": str(self._content_dir),
1149+
"target": str(self._target_base),
1150+
**fields,
1151+
}
1152+
path = self.audit_log_path
1153+
try:
1154+
path.parent.mkdir(parents=True, exist_ok=True)
1155+
with path.open("a", encoding="utf-8") as fh:
1156+
fh.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
1157+
except OSError:
1158+
# Audit must never block the operation it's recording.
1159+
return
1160+
11221161
@classmethod
11231162
def from_config(cls, config_path: Path | str | None = None) -> ClaudeConfig:
11241163
"""Load config from JSON. Falls back to defaults when file is absent.
@@ -1543,7 +1582,7 @@ def install(self, dry_run: bool = False) -> InstallReport:
15431582
continue # missing canonical: surfaced via project_install
15441583
global_writes.append((vendor, str(dest)))
15451584

1546-
return InstallReport(
1585+
report = InstallReport(
15471586
links_created=links,
15481587
dir_links_created=dirs,
15491588
already_correct=correct,
@@ -1553,6 +1592,16 @@ def install(self, dry_run: bool = False) -> InstallReport:
15531592
secrets_skipped=secrets,
15541593
global_writes=global_writes,
15551594
)
1595+
if not dry_run:
1596+
self._audit(
1597+
"install",
1598+
links_created=report.links_created,
1599+
dir_links_created=report.dir_links_created,
1600+
already_correct=report.already_correct,
1601+
real_files_backed_up=report.real_files_backed_up,
1602+
global_writes=len(report.global_writes),
1603+
)
1604+
return report
15561605

15571606
def _adapter_for(self, vendor: str) -> VendorAdapter | None:
15581607
"""Resolve a vendor name to its adapter.
@@ -1841,6 +1890,11 @@ def uninstall(self, restore_backups: bool = True) -> UninstallReport:
18411890
if backup.exists() and not link.exists():
18421891
backup.rename(link)
18431892
restored += 1
1893+
self._audit(
1894+
"uninstall",
1895+
removed=removed,
1896+
backups_restored=restored,
1897+
)
18441898
return UninstallReport(removed=removed, backups_restored=restored)
18451899

18461900
# ---- track --------------------------------------------------------
@@ -1889,6 +1943,11 @@ def track(self, path: Path | str) -> ClaudeConfig:
18891943
if state_changed and self._loaded_config_path is not None:
18901944
self.save_config(self._loaded_config_path)
18911945

1946+
self._audit(
1947+
"track",
1948+
path=str(rel),
1949+
kind="dir" if dest.is_dir() else "file",
1950+
)
18921951
return self
18931952

18941953
# ---- cleanup ------------------------------------------------------
@@ -2859,13 +2918,23 @@ def decisions_apply(
28592918
else:
28602919
written.append(f.dest)
28612920

2862-
return DecisionsApplyReport(
2921+
report = DecisionsApplyReport(
28632922
pack=name,
28642923
written=written,
28652924
skipped=skipped,
28662925
overwritten=overwritten,
28672926
dry_run=dry_run,
28682927
)
2928+
if not dry_run:
2929+
self._audit(
2930+
"decisions_apply",
2931+
pack=name,
2932+
force=force,
2933+
written=len(written),
2934+
overwritten=len(overwritten),
2935+
skipped=len(skipped),
2936+
)
2937+
return report
28692938

28702939
def _build_pack(self, data: dict[str, Any], pack_dir: Any) -> DecisionPack:
28712940
files = [

tests/test_manager.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,44 @@ def test_decisions_diff_unknown_pack_raises(cfg: ClaudeConfig) -> None:
934934
cfg.decisions_diff("does-not-exist")
935935

936936

937+
# --- audit log (Phase G) --------------------------------------------------
938+
939+
940+
def test_audit_log_records_install(cfg: ClaudeConfig) -> None:
941+
"""install() with dry_run=False writes one JSONL entry."""
942+
cfg.install(dry_run=False)
943+
log = cfg.audit_log_path
944+
assert log.is_file()
945+
lines = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() if line]
946+
install_events = [r for r in lines if r["event"] == "install"]
947+
assert install_events
948+
e = install_events[-1]
949+
assert "ts" in e and e["ts"].endswith("Z")
950+
assert e["content_dir"] == str(cfg._content_dir)
951+
952+
953+
def test_audit_log_skips_install_dry_run(cfg: ClaudeConfig) -> None:
954+
"""Dry-run install does not emit an audit event."""
955+
cfg.install(dry_run=True)
956+
log = cfg.audit_log_path
957+
if log.is_file():
958+
lines = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() if line]
959+
assert not [r for r in lines if r["event"] == "install"]
960+
961+
962+
def test_audit_log_records_decisions_apply(cfg: ClaudeConfig) -> None:
963+
"""decisions_apply() with dry_run=False writes an event including pack name."""
964+
cfg.decisions_apply("script-generation-pattern")
965+
log = cfg.audit_log_path
966+
lines = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() if line]
967+
events = [r for r in lines if r["event"] == "decisions_apply"]
968+
assert events and events[-1]["pack"] == "script-generation-pattern"
969+
970+
971+
def test_audit_log_path_lives_next_to_content_dir(cfg: ClaudeConfig) -> None:
972+
assert cfg.audit_log_path == cfg._content_dir.parent / "audit.log"
973+
974+
937975
def test_decisions_apply_dry_run_writes_nothing(cfg: ClaudeConfig) -> None:
938976
r = cfg.decisions_apply("script-generation-pattern", dry_run=True)
939977
assert r.dry_run

0 commit comments

Comments
 (0)