damage-control-dev 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. damage_control/__init__.py +9 -0
  2. damage_control/__main__.py +7 -0
  3. damage_control/adapter.py +179 -0
  4. damage_control/audit.py +432 -0
  5. damage_control/breakglass.py +155 -0
  6. damage_control/claude_code.py +148 -0
  7. damage_control/cli.py +173 -0
  8. damage_control/engine.py +10656 -0
  9. damage_control/groups.py +114 -0
  10. damage_control/harness_config.py +205 -0
  11. damage_control/hook.py +54 -0
  12. damage_control/install.py +618 -0
  13. damage_control/packs.py +219 -0
  14. damage_control/policy.py +323 -0
  15. damage_control/profile.py +177 -0
  16. damage_control/rules/aws.json +85 -0
  17. damage_control/rules/aws.yaml +76 -0
  18. damage_control/rules/cloud-hosting.json +162 -0
  19. damage_control/rules/cloud-hosting.yaml +163 -0
  20. damage_control/rules/containers.json +67 -0
  21. damage_control/rules/containers.yaml +69 -0
  22. damage_control/rules/control-plane.json +7 -0
  23. damage_control/rules/control-plane.yaml +21 -0
  24. damage_control/rules/core.json +332 -0
  25. damage_control/rules/core.yaml +441 -0
  26. damage_control/rules/databases.json +117 -0
  27. damage_control/rules/databases.yaml +123 -0
  28. damage_control/rules/firebase.json +34 -0
  29. damage_control/rules/firebase.yaml +30 -0
  30. damage_control/rules/gcp.json +60 -0
  31. damage_control/rules/gcp.yaml +57 -0
  32. damage_control/rules/git.json +119 -0
  33. damage_control/rules/git.yaml +120 -0
  34. damage_control/rules/infrastructure.json +69 -0
  35. damage_control/rules/infrastructure.yaml +66 -0
  36. damage_control/rules/mcp-tools.json +11 -0
  37. damage_control/rules/mcp-tools.yaml +47 -0
  38. damage_control/rules/outbound.json +40 -0
  39. damage_control/rules/outbound.yaml +58 -0
  40. damage_control/rules/payloads.json +120 -0
  41. damage_control/rules/payloads.yaml +155 -0
  42. damage_control/rules/publish.json +46 -0
  43. damage_control/rules/publish.yaml +46 -0
  44. damage_control/rules/registry.json +10 -0
  45. damage_control/rules/registry.yaml +11 -0
  46. damage_control/rules/remote.json +31 -0
  47. damage_control/rules/remote.yaml +53 -0
  48. damage_control/rules/tmux.json +17 -0
  49. damage_control/rules/tmux.yaml +16 -0
  50. damage_control/ruleset.py +94 -0
  51. damage_control/tooldefs/aws.json +140 -0
  52. damage_control/tooldefs/aws.yaml +119 -0
  53. damage_control/tooldefs/docker.json +146 -0
  54. damage_control/tooldefs/docker.yaml +123 -0
  55. damage_control/tooldefs/gcp.json +186 -0
  56. damage_control/tooldefs/gcp.yaml +158 -0
  57. damage_control/tooldefs/gh.json +172 -0
  58. damage_control/tooldefs/gh.yaml +145 -0
  59. damage_control/tooldefs/git.json +166 -0
  60. damage_control/tooldefs/git.yaml +143 -0
  61. damage_control/tooldefs/kubectl.json +101 -0
  62. damage_control/tooldefs/kubectl.yaml +85 -0
  63. damage_control/tooldefs/npm.json +77 -0
  64. damage_control/tooldefs/npm.yaml +65 -0
  65. damage_control/tooldefs/terraform.json +68 -0
  66. damage_control/tooldefs/terraform.yaml +57 -0
  67. damage_control/tooldefs/uv.json +112 -0
  68. damage_control/tooldefs/uv.yaml +97 -0
  69. damage_control_dev-0.1.0.dist-info/METADATA +15 -0
  70. damage_control_dev-0.1.0.dist-info/RECORD +74 -0
  71. damage_control_dev-0.1.0.dist-info/WHEEL +4 -0
  72. damage_control_dev-0.1.0.dist-info/entry_points.txt +2 -0
  73. damage_control_dev-0.1.0.dist-info/licenses/LICENSE +201 -0
  74. damage_control_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,618 @@
1
+ """Install, status, doctor and uninstall for the Claude Code hook registration (#1209 M5, section 5).
2
+
3
+ Every function here either does what it says or raises :class:`InstallError` having changed
4
+ nothing it could not put back. The settings file is only ever touched through
5
+ ``harness_config``; ``install.json`` (under ``<config dir>/state/``) records what we wrote so
6
+ ``status`` can tell drift from intent and ``uninstall`` can restore the original bytes.
7
+
8
+ Ownership: an entry is ours iff the package module name is in its ``args``. An entry carrying
9
+ a ``--profile`` belongs to whoever installed that profile (a host such as agentwire): ``init``
10
+ refuses to rewrite an entry whose profile differs from the one requested, so two installers
11
+ never fight over the same matcher.
12
+ """
13
+
14
+ import difflib
15
+ import importlib.metadata
16
+ import json
17
+ import os
18
+ import re
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple
25
+
26
+ from damage_control import __version__, engine, packs, ruleset
27
+ from damage_control import claude_code as cc
28
+ from damage_control import harness_config as hc
29
+
30
+ ENGINE_SCHEMA = 1
31
+ RECORD_SCHEMA = 1
32
+ FAIL_OPEN_KEY = "fail_open"
33
+ SELFTEST_PROBE = "rm -rf /damage-control-selftest"
34
+ DEV_SOURCES = ("editable", "vcs", "ephemeral")
35
+
36
+
37
+ class InstallError(RuntimeError):
38
+ """A refusal: the message says why, and nothing was changed."""
39
+
40
+
41
+ _MARKS = {"ok": "ok", "info": "..", "bad": "!!"}
42
+
43
+
44
+ class Finding(NamedTuple):
45
+ level: str # "ok" | "info" | "bad"
46
+ code: str
47
+ message: str
48
+
49
+ def line(self) -> str:
50
+ return f"[{_MARKS[self.level]}] {self.message}"
51
+
52
+
53
+ # ----------------------------------------------------------------------------
54
+ # Paths and the install record
55
+ # ----------------------------------------------------------------------------
56
+
57
+ def config_home() -> Path:
58
+ return engine.config_override_dir()
59
+
60
+
61
+ def state_dir() -> Path:
62
+ return config_home() / "state"
63
+
64
+
65
+ def record_path() -> Path:
66
+ return state_dir() / "install.json"
67
+
68
+
69
+ def backup_path(scope: str, settings: Path) -> Path:
70
+ return state_dir() / "backups" / f"{scope}-{settings.name}.orig"
71
+
72
+
73
+ def breakglass_path() -> Path:
74
+ return config_home() / "bin" / "damage-control-breakglass"
75
+
76
+
77
+ def harness_key(scope: str) -> str:
78
+ return f"{cc.HARNESS}:{scope}"
79
+
80
+
81
+ def load_record() -> Dict[str, Any]:
82
+ try:
83
+ data = json.loads(record_path().read_text())
84
+ except (OSError, ValueError):
85
+ return {}
86
+ return data if isinstance(data, dict) else {}
87
+
88
+
89
+ def save_record(record: Dict[str, Any]) -> None:
90
+ path = record_path()
91
+ data = (json.dumps(record, indent=2, sort_keys=True) + "\n").encode()
92
+ expected = hc.read_bytes(path)
93
+ if not hc.atomic_replace(path, data, expected):
94
+ raise InstallError(f"{path} changed while it was being written; re-run.")
95
+
96
+
97
+ def version_tuple(text: str) -> Tuple[int, ...]:
98
+ return tuple(int(p) for p in re.findall(r"\d+", text.split("+")[0])[:4]) or (0,)
99
+
100
+
101
+ # ----------------------------------------------------------------------------
102
+ # Provenance (minimal): where is this package running from?
103
+ # ----------------------------------------------------------------------------
104
+
105
+ def install_source() -> str:
106
+ """``pypi`` | ``vcs`` | ``editable`` | ``ephemeral`` (a uvx-style throwaway environment)."""
107
+ try:
108
+ raw = importlib.metadata.distribution("damage-control-dev").read_text("direct_url.json")
109
+ except importlib.metadata.PackageNotFoundError:
110
+ return "editable" # imported from a source tree, not an installed distribution
111
+ if raw:
112
+ try:
113
+ info = json.loads(raw)
114
+ except ValueError:
115
+ info = {}
116
+ if "vcs_info" in info:
117
+ return "vcs"
118
+ if (info.get("dir_info") or {}).get("editable"):
119
+ return "editable"
120
+ prefix = sys.prefix.replace(os.sep, "/")
121
+ if "/uv/archive-v" in prefix or "/uv/environments-v" in prefix:
122
+ return "ephemeral"
123
+ return "pypi"
124
+
125
+
126
+ def interpreter_runs(interpreter: str) -> bool:
127
+ try:
128
+ return subprocess.run([interpreter, "-I", "-c", "import damage_control"],
129
+ capture_output=True, timeout=30).returncode == 0
130
+ except (OSError, subprocess.SubprocessError):
131
+ return False
132
+
133
+
134
+ # ----------------------------------------------------------------------------
135
+ # Self-test: run the registered command the way Claude Code does
136
+ # ----------------------------------------------------------------------------
137
+
138
+ def selftest(interpreter: str, profile: Optional[str] = None) -> List[str]:
139
+ """What failed when each hook kind was run as registered (empty = all good).
140
+
141
+ Every kind must start and exit 0 for a tool it does not gate; the shell hook must also
142
+ BLOCK (exit 2) a destructive command, which proves the rules actually loaded.
143
+ """
144
+ probes = [(kind, {"tool_name": "DamageControlSelftest", "tool_input": {}}, 0)
145
+ for kind in dict.fromkeys(cc.MATCHERS.values())]
146
+ probes += [("bash", {"tool_name": tool, "tool_input": {"command": SELFTEST_PROBE}}, 2)
147
+ for tool in ("Bash", "PowerShell")]
148
+ failures = []
149
+ for kind, payload, want in probes:
150
+ try:
151
+ got = subprocess.run(cc.selftest_command(interpreter, kind, profile), input=json.dumps(payload),
152
+ capture_output=True, text=True, timeout=engine.HOOK_TIMEOUT_SECONDS).returncode
153
+ except (OSError, subprocess.SubprocessError) as e:
154
+ failures.append(f"{kind}: {type(e).__name__}: {e}")
155
+ continue
156
+ if got != want:
157
+ failures.append(f"{kind}: exit {got}, expected {want}")
158
+ return failures
159
+
160
+
161
+ # ----------------------------------------------------------------------------
162
+ # Structural edits of the settings dict
163
+ # ----------------------------------------------------------------------------
164
+
165
+ def _owned_under(pre: List[Any], matcher: str, kind: str) -> List[Dict[str, Any]]:
166
+ owned: List[Dict[str, Any]] = []
167
+ for entry in pre:
168
+ inner = entry.get("hooks") if isinstance(entry, dict) and entry.get("matcher") == matcher else None
169
+ if isinstance(inner, list):
170
+ owned.extend(h for h in inner if cc.is_ours(h) and cc.kind_of(h) == kind)
171
+ return owned
172
+
173
+
174
+ def register(settings: Dict[str, Any], desired: Dict[str, Dict[str, Any]], created: List[str]) -> bool:
175
+ """Make ``settings`` carry exactly ``desired`` (matcher -> hook) as OUR entries. True if it changed.
176
+
177
+ Idempotent. Adds what is missing, rewrites an entry of ours whose interpreter, arguments,
178
+ ``timeout`` or ``onFailure`` drifted, drops duplicates and entries of ours under a matcher
179
+ that is not ours to register. Every entry that is not ours is left exactly as it was.
180
+ Raises ConfigError on a malformed ``hooks`` tree (rewriting it would destroy what is there).
181
+ """
182
+ changed = False
183
+ hooks = settings.get("hooks")
184
+ if hooks is None:
185
+ hooks = settings["hooks"] = {}
186
+ created.append("hooks")
187
+ elif not isinstance(hooks, dict):
188
+ raise hc.ConfigError("settings has a malformed `hooks` (not an object); damage control not registered")
189
+ pre = hooks.get("PreToolUse")
190
+ if pre is None:
191
+ pre = hooks["PreToolUse"] = []
192
+ created.append("hooks.PreToolUse")
193
+ elif not isinstance(pre, list):
194
+ raise hc.ConfigError("settings has a malformed `hooks.PreToolUse` (not a list); damage control not registered")
195
+
196
+ # Entries of ours that sit under a matcher/kind pair we do not register.
197
+ for entry in list(pre):
198
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
199
+ if not isinstance(inner, list):
200
+ continue
201
+ matcher = entry.get("matcher")
202
+ expected_kind = cc.MATCHERS.get(matcher) if isinstance(matcher, str) else None
203
+ stray = [h for h in inner if cc.is_ours(h) and expected_kind != cc.kind_of(h)]
204
+ if stray:
205
+ changed = True
206
+ entry["hooks"] = [h for h in inner if h not in stray]
207
+ if not entry["hooks"]:
208
+ pre.remove(entry)
209
+
210
+ for matcher, wanted in desired.items():
211
+ kind = cc.MATCHERS[matcher]
212
+ owned = _owned_under(pre, matcher, kind)
213
+ if not owned:
214
+ pre.append({"matcher": matcher, "hooks": [dict(wanted)]})
215
+ changed = True
216
+ continue
217
+ first, extras = owned[0], owned[1:]
218
+ if first != wanted:
219
+ first.clear()
220
+ first.update(wanted)
221
+ changed = True
222
+ for dup in extras:
223
+ for entry in list(pre):
224
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
225
+ if isinstance(inner, list) and any(h is dup for h in inner):
226
+ entry["hooks"] = [h for h in inner if h is not dup]
227
+ if not entry["hooks"]:
228
+ pre.remove(entry)
229
+ changed = True
230
+ return changed
231
+
232
+
233
+ def unregister(settings: Dict[str, Any], created: List[str]) -> bool:
234
+ """Remove exactly our entries; prune containers we created and nothing else. True if it changed."""
235
+ pre = cc.pre_tool_use(settings)
236
+ if pre is None or not cc.owned_entries(settings):
237
+ return False
238
+ for entry in list(pre):
239
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
240
+ if not isinstance(inner, list) or not any(cc.is_ours(h) for h in inner):
241
+ continue
242
+ remaining = [h for h in inner if not cc.is_ours(h)]
243
+ if remaining:
244
+ entry["hooks"] = remaining
245
+ else:
246
+ pre.remove(entry)
247
+ if not pre and "hooks.PreToolUse" in created:
248
+ del settings["hooks"]["PreToolUse"]
249
+ if not settings["hooks"] and "hooks" in created:
250
+ del settings["hooks"]
251
+ return True
252
+
253
+
254
+ # ----------------------------------------------------------------------------
255
+ # init
256
+ # ----------------------------------------------------------------------------
257
+
258
+ def _resolve_settings(scope: str, settings: Optional[str], cwd: Optional[Path]) -> Path:
259
+ if scope not in cc.SCOPES:
260
+ raise InstallError(f"unknown scope {scope!r}; expected one of {', '.join(cc.SCOPES)}")
261
+ return Path(settings) if settings else cc.settings_path(scope, cwd)
262
+
263
+
264
+ def _unified_diff(path: Path, before: Optional[bytes], after: bytes) -> List[str]:
265
+ old = (before or b"").decode("utf-8", "replace").splitlines()
266
+ new = after.decode("utf-8", "replace").splitlines()
267
+ return list(difflib.unified_diff(old, new, f"{path} (current)", f"{path} (after init)", lineterm=""))
268
+
269
+
270
+ def init(
271
+ scope: str = "user",
272
+ settings: Optional[str] = None,
273
+ dry_run: bool = False,
274
+ refresh: bool = False,
275
+ force: bool = False,
276
+ fail_open: bool = False,
277
+ allow_dev_source: bool = False,
278
+ apply: bool = False,
279
+ profile: Optional[str] = None,
280
+ cwd: Optional[Path] = None,
281
+ log: Callable[[str], None] = print,
282
+ ) -> int:
283
+ """Register the hooks, prove they run, record what was done. Idempotent. Returns 0."""
284
+ path = _resolve_settings(scope, settings, cwd)
285
+ if scope == "managed" and not apply:
286
+ dry_run = True
287
+ log("managed scope is a dry run unless --apply is given (it needs elevation and applies to every user).")
288
+
289
+ source = install_source()
290
+ if source in DEV_SOURCES and not allow_dev_source and not dry_run:
291
+ raise InstallError(f"running from a {source} install; machine-global state may be written from one "
292
+ "only with --allow-dev-source (a throwaway checkout or environment can vanish "
293
+ "and, with onFailure=block, take every tool call with it).")
294
+
295
+ record = load_record()
296
+ recorded = version_tuple(str(record.get("package_version", "0")))
297
+ if recorded > version_tuple(__version__) and not force:
298
+ raise InstallError(f"installed record is from damage-control-dev {record['package_version']}, newer than "
299
+ f"this {__version__}; refusing to downgrade the registration (--force to override).")
300
+
301
+ version = cc.detect_version()
302
+ supported = cc.supports_fail_closed(version)
303
+ if supported is not True and not fail_open:
304
+ why = ("could not detect Claude Code (`claude --version` failed)" if version is None
305
+ else f"Claude Code {cc.version_text(version)} is older than {cc.version_text(cc.ONFAILURE_MIN_VERSION)}")
306
+ raise InstallError(f"{why}: `onFailure: block` is unavailable, so a hook crash or a missing interpreter "
307
+ "would let tool calls run. Upgrade Claude Code, or pass --fail-open to register the "
308
+ "weaker config explicitly (recorded in the protected policy).")
309
+ fail_closed = not fail_open
310
+ interpreter = cc.absolute_interpreter()
311
+
312
+ raw, current = hc.read_config(path) # unparseable => ConfigError, nothing touched
313
+ for owned in cc.owned_entries(current):
314
+ if cc.profile_of(owned.hook) != profile:
315
+ raise InstallError(f"{path} already registers damage control for profile "
316
+ f"{cc.profile_of(owned.hook) or '(none)'}, not {profile or '(none)'}: another "
317
+ "installer owns those entries. Use that installer, or uninstall it first.")
318
+
319
+ desired = {m: cc.wanted_hook(interpreter, k, engine.HOOK_TIMEOUT_SECONDS, fail_closed, profile)
320
+ for m, k in cc.MATCHERS.items()}
321
+ created: List[str] = []
322
+ preview = json.loads(json.dumps(current))
323
+ would_change = register(preview, desired, [])
324
+
325
+ if dry_run:
326
+ log(f"dry run: {'would change' if would_change else 'no change to'} {path}")
327
+ for line in _unified_diff(path, raw, hc.render(preview, hc.detect_style(raw))):
328
+ log(line)
329
+ return 0
330
+
331
+ failures = selftest(interpreter, profile)
332
+ if failures:
333
+ raise InstallError("self-test failed; nothing registered:\n " + "\n ".join(failures))
334
+
335
+ key = harness_key(scope)
336
+ previous = (record.get("harnesses") or {}).get(key, {})
337
+
338
+ def mutate(data: Dict[str, Any]) -> bool:
339
+ return register(data, desired, created)
340
+
341
+ result = hc.edit_json_config(path, mutate, backup=backup_path(scope, path))
342
+
343
+ config = engine.load_effective_config()
344
+ digest = ruleset.digest(config)
345
+ grants = sorted(engine.PROFILE.default_unattended_allow)
346
+ old_digest, old_grants = record.get("rules"), record.get("default_grants", [])
347
+ first_install = old_digest is None
348
+ if not first_install:
349
+ changes = ruleset.diff(old_digest, digest, old_grants, grants)
350
+ if changes and not refresh:
351
+ total, loose = ruleset.summary(changes)
352
+ log(f"note: the ruleset changed since last acknowledged ({total} changes, {loose} loosening); "
353
+ "run `init --refresh` to see and acknowledge them.")
354
+ digest, grants = old_digest, old_grants
355
+ elif changes:
356
+ log(f"ruleset diff ({len(changes)} changes):")
357
+ for line in ruleset.render(changes):
358
+ log(f" {line}")
359
+ if ruleset.has_loosening(changes):
360
+ log("!! this refresh WIDENS what is permitted (lines marked !!); review them.")
361
+
362
+ policy = engine.policy_file_path()
363
+ if fail_open:
364
+ packs.write_flag(policy, FAIL_OPEN_KEY, True)
365
+ elif packs.read_flag(policy, FAIL_OPEN_KEY):
366
+ packs.write_flag(policy, FAIL_OPEN_KEY, False)
367
+
368
+ install_breakglass()
369
+ # Byte-exact uninstall is valid only while the file is exactly what OUR writes produced.
370
+ prev_sha = previous.get("written_sha256")
371
+ if not previous:
372
+ written = result.sha256 if result.changed else None
373
+ elif result.changed:
374
+ untouched = prev_sha and raw is not None and hc.sha256_hex(raw) == prev_sha
375
+ written = result.sha256 if untouched else None
376
+ else:
377
+ written = prev_sha
378
+ backup = backup_path(scope, path)
379
+ harnesses = dict(record.get("harnesses") or {})
380
+ harnesses[key] = {
381
+ "harness": cc.HARNESS, "scope": scope, "config_path": str(hc.resolve(path)),
382
+ "entry_markers": [cc.HOOK_MODULE], "profile": profile,
383
+ "backup": str(backup) if backup.exists() else None,
384
+ "harness_version": cc.version_text(version) if version else None,
385
+ "installed_at": previous.get("installed_at") or datetime.now().astimezone().isoformat(timespec="seconds"),
386
+ "fail_mode": "closed" if fail_closed else "open",
387
+ "created_file": bool(previous.get("created_file") or (not previous and result.created and result.changed)),
388
+ "created_keys": previous.get("created_keys") if previous else created,
389
+ "written_sha256": written,
390
+ }
391
+ save_record({
392
+ "schema": RECORD_SCHEMA, "package_version": __version__, "engine_schema": ENGINE_SCHEMA,
393
+ "ruleset_version": f"{__version__}+{ruleset_id(digest)}", "interpreter": interpreter,
394
+ "source": source, "rules": digest, "default_grants": grants, "harnesses": harnesses,
395
+ })
396
+ log(f"{'registered' if result.changed else 'already registered'}: {len(desired)} matchers in {path} "
397
+ f"(scope {scope}, {'fail-closed' if fail_closed else 'FAIL-OPEN'}, interpreter {interpreter})")
398
+ return 0
399
+
400
+
401
+ def ruleset_id(digest: Dict[str, str]) -> str:
402
+ return ruleset._fingerprint(digest)
403
+
404
+
405
+ def install_breakglass() -> Path:
406
+ """Copy the stdlib-only break-glass script out of the venv, to where a broken venv cannot hide it."""
407
+ source = Path(__file__).with_name("breakglass.py")
408
+ target = breakglass_path()
409
+ data = source.read_bytes()
410
+ if hc.read_bytes(target) != data:
411
+ hc.atomic_replace(target, data, hc.read_bytes(target))
412
+ target.chmod(0o755)
413
+ return target
414
+
415
+
416
+ # ----------------------------------------------------------------------------
417
+ # status / doctor
418
+ # ----------------------------------------------------------------------------
419
+
420
+ def _mode_findings(paths: List[Path]) -> List[Finding]:
421
+ bad = []
422
+ for p in paths:
423
+ try:
424
+ mode = p.stat().st_mode
425
+ except OSError:
426
+ continue
427
+ if mode & (stat.S_IWGRP | stat.S_IWOTH):
428
+ bad.append(f"{p} is writable by group/other ({stat.filemode(mode)})")
429
+ return [Finding("bad", "perms", m) for m in bad] or [Finding("ok", "perms", "protected paths are owner-writable only")]
430
+
431
+
432
+ def _overlay_findings() -> List[Finding]:
433
+ rules, tooldefs = engine.overlay_dirs()
434
+ findings: List[Finding] = []
435
+ count = 0
436
+ for directory in (rules, tooldefs):
437
+ for file in sorted(directory.iterdir()) if directory else []:
438
+ if file.suffix not in (".json", ".yaml", ".yml"):
439
+ continue
440
+ count += 1
441
+ try:
442
+ if file.suffix == ".json":
443
+ json.loads(file.read_text())
444
+ else:
445
+ import yaml
446
+
447
+ yaml.safe_load(file.read_text())
448
+ except ImportError:
449
+ findings.append(Finding("info", "overlay-yaml", f"PyYAML not installed: {file.name} cannot be read"))
450
+ except Exception as e:
451
+ findings.append(Finding("bad", "overlay-parse", f"overlay file {file} does not parse: {type(e).__name__}"))
452
+ if count and not findings:
453
+ findings.append(Finding("info", "overlay", f"{count} local overlay file(s) in effect"))
454
+ return findings
455
+
456
+
457
+ def _drift(owned: List[cc.Owned], interpreter: str, profile: Optional[str], fail_closed: bool) -> List[Finding]:
458
+ findings: List[Finding] = []
459
+ claimed: List[int] = []
460
+ missing = []
461
+ for matcher, kind in cc.MATCHERS.items():
462
+ hooks = [o.hook for o in owned if o.matcher == matcher and cc.kind_of(o.hook) == kind]
463
+ if not hooks:
464
+ missing.append(matcher)
465
+ continue
466
+ claimed.extend(id(h) for h in hooks)
467
+ hook = hooks[0]
468
+ if len(hooks) > 1:
469
+ findings.append(Finding("info", "duplicate", f"{matcher}: {len(hooks)} entries of ours (one is enough)"))
470
+ if hook.get("command") != interpreter:
471
+ findings.append(Finding("bad", "command", f"{matcher}: command is {hook.get('command')!r}, expected {interpreter!r}"))
472
+ if hook.get("args") != cc.hook_args(kind, profile):
473
+ findings.append(Finding("bad", "args", f"{matcher}: args are {hook.get('args')!r}, expected {cc.hook_args(kind, profile)!r}"))
474
+ if hook.get("timeout") != engine.HOOK_TIMEOUT_SECONDS:
475
+ findings.append(Finding("bad", "timeout", f"{matcher}: timeout is {hook.get('timeout')!r}, expected {engine.HOOK_TIMEOUT_SECONDS} (lost)"))
476
+ if fail_closed and hook.get("onFailure") != "block":
477
+ findings.append(Finding("bad", "onFailure", f"{matcher}: onFailure is {hook.get('onFailure')!r}, expected 'block' (lost)"))
478
+ if missing:
479
+ findings.append(Finding("bad", "missing", "missing entry for matcher(s): " + ", ".join(missing)))
480
+ for o in owned:
481
+ if id(o.hook) not in claimed:
482
+ findings.append(Finding("bad", "matcher", f"wrong matcher: {o.matcher!r} runs the {cc.kind_of(o.hook)!r} hook "
483
+ f"(expected {[m for m, k in cc.MATCHERS.items() if k == cc.kind_of(o.hook)] or 'none'})"))
484
+ return findings
485
+
486
+
487
+ def inspect(scope: str = "user", settings: Optional[str] = None, deep: bool = False,
488
+ cwd: Optional[Path] = None, profile_hint: Optional[str] = None) -> List[Finding]:
489
+ """Findings for one registration. ``deep`` (doctor) adds the self-test and a live Claude Code check."""
490
+ path = _resolve_settings(scope, settings, cwd)
491
+ record = load_record()
492
+ hrec = (record.get("harnesses") or {}).get(harness_key(scope), {})
493
+ findings: List[Finding] = []
494
+ try:
495
+ _, current = hc.read_config(path)
496
+ except hc.ConfigError as e:
497
+ return [Finding("bad", "unreadable", str(e))]
498
+ owned = cc.owned_entries(current)
499
+ if not owned and not hrec:
500
+ return [Finding("bad", "not-installed", f"damage control is not registered in {path}; run `damage-control-dev init`")]
501
+
502
+ profile = hrec.get("profile") if hrec else (profile_hint or (cc.profile_of(owned[0].hook) if owned else None))
503
+ interpreter = record.get("interpreter") or (owned[0].hook.get("command") if owned else cc.absolute_interpreter())
504
+ fail_closed = (hrec.get("fail_mode", "closed") == "closed") if hrec else True
505
+ if not hrec:
506
+ findings.append(Finding("info", "no-record", "no install record for this scope (registered by a host installer?)"))
507
+ fail_closed = all(o.hook.get("onFailure") == "block" for o in owned)
508
+
509
+ drift = _drift(owned, interpreter, profile, fail_closed)
510
+ findings += drift or [Finding("ok", "registration", f"{len(cc.MATCHERS)} matchers registered correctly in {path}")]
511
+
512
+ for command in sorted({o.hook.get("command", "") for o in owned}):
513
+ alive = interpreter_runs(command) if command else False
514
+ findings.append(Finding("ok" if alive else "bad", "interpreter",
515
+ f"interpreter {command} runs and imports damage_control" if alive
516
+ else f"interpreter {command!r} is dead or cannot import damage_control: "
517
+ "every tool call is being blocked; run the break-glass script, then repair"))
518
+
519
+ if record:
520
+ running, recorded = version_tuple(__version__), version_tuple(str(record.get("package_version", "0")))
521
+ if recorded > running:
522
+ findings.append(Finding("bad", "downgrade", f"running {__version__} is older than the recorded {record['package_version']}"))
523
+ elif recorded < running:
524
+ findings.append(Finding("info", "upgraded", f"package {__version__} is newer than the recorded {record['package_version']}"))
525
+ if record.get("rules") is not None:
526
+ changes = ruleset.diff(record["rules"], ruleset.digest(engine.load_effective_config()),
527
+ record.get("default_grants", []), sorted(engine.PROFILE.default_unattended_allow))
528
+ if changes:
529
+ total, loose = ruleset.summary(changes)
530
+ findings.append(Finding("bad" if loose else "info", "stale-ruleset",
531
+ f"ruleset changed since last acknowledged ({total} changes, {loose} loosening); "
532
+ "run `damage-control-dev init --refresh`"))
533
+ else:
534
+ findings.append(Finding("ok", "ruleset", "ruleset matches the last acknowledged one"))
535
+
536
+ foreign = cc.foreign_entry_count(current)
537
+ if foreign:
538
+ findings.append(Finding("info", "foreign-hooks", f"{foreign} PreToolUse hook(s) in {path} are not ours (left alone)"))
539
+ findings += _overlay_findings()
540
+ protected = [config_home(), engine.policy_file_path(), record_path()]
541
+ findings += _mode_findings([p for p in protected if p.exists()])
542
+
543
+ if deep:
544
+ version = cc.detect_version()
545
+ supported = cc.supports_fail_closed(version)
546
+ if fail_closed and supported is False:
547
+ findings.append(Finding("bad", "harness-version", f"Claude Code {cc.version_text(version)} predates `onFailure`; fail-closed is not honored"))
548
+ elif not fail_closed and supported:
549
+ findings.append(Finding("info", "harness-version", f"Claude Code {cc.version_text(version)} supports fail-closed; re-run `init` without --fail-open"))
550
+ elif version is not None:
551
+ findings.append(Finding("ok", "harness-version", f"Claude Code {cc.version_text(version)}"))
552
+ failures = selftest(interpreter, profile)
553
+ findings += ([Finding("bad", "selftest", "self-test failed: " + "; ".join(failures))] if failures
554
+ else [Finding("ok", "selftest", "self-test passed (every hook starts, destructive command blocked)")])
555
+ return findings
556
+
557
+
558
+ def exit_code(findings: List[Finding]) -> int:
559
+ return 1 if any(f.level == "bad" for f in findings) else 0
560
+
561
+
562
+ # ----------------------------------------------------------------------------
563
+ # uninstall
564
+ # ----------------------------------------------------------------------------
565
+
566
+ def uninstall(scope: str = "user", settings: Optional[str] = None, purge: bool = False,
567
+ cwd: Optional[Path] = None, log: Callable[[str], None] = print) -> int:
568
+ """Remove exactly our entries. Byte-exact when the file is still what init wrote."""
569
+ path = _resolve_settings(scope, settings, cwd)
570
+ record = load_record()
571
+ key = harness_key(scope)
572
+ hrec = (record.get("harnesses") or {}).get(key, {})
573
+ raw, current = hc.read_config(path)
574
+
575
+ if not cc.owned_entries(current):
576
+ log(f"nothing to remove: no damage-control entries in {path}")
577
+ else:
578
+ restored = False
579
+ backup = Path(hrec["backup"]) if hrec.get("backup") else None
580
+ sha = hrec.get("written_sha256")
581
+ if raw is not None and sha and hc.sha256_hex(raw) == sha:
582
+ if hrec.get("created_file"):
583
+ restored = hc.restore_bytes(path, None, sha)
584
+ elif backup and backup.exists():
585
+ restored = hc.restore_bytes(path, backup.read_bytes(), sha)
586
+ if restored:
587
+ log(f"restored {path} byte-for-byte to its state before init")
588
+ else:
589
+ hc.edit_json_config(path, lambda data: unregister(data, hrec.get("created_keys") or []))
590
+ log(f"removed our entries from {path}; every other key left as it was")
591
+
592
+ harnesses = {k: v for k, v in (record.get("harnesses") or {}).items() if k != key}
593
+ if record and harnesses:
594
+ save_record(dict(record, harnesses=harnesses))
595
+ elif record_path().exists():
596
+ record_path().unlink()
597
+ if not harnesses:
598
+ for leftover in (breakglass_path(),):
599
+ leftover.unlink(missing_ok=True)
600
+ log("DAMAGE CONTROL IS NOW OFF for this scope." if not purge else "damage control is off and its local state is purged.")
601
+ if purge:
602
+ purge_local_state(log)
603
+ return 0
604
+
605
+
606
+ def purge_local_state(log: Callable[[str], None]) -> None:
607
+ """Delete the policy, overlays and state we keep (never the settings file)."""
608
+ import shutil
609
+
610
+ rules, tooldefs = engine.overlay_dirs()
611
+ for directory in (rules, tooldefs, state_dir(), config_home() / "bin"):
612
+ if directory and directory.exists():
613
+ shutil.rmtree(directory)
614
+ log(f"purged {directory}")
615
+ policy = engine.policy_file_path()
616
+ if policy.exists():
617
+ policy.unlink()
618
+ log(f"purged {policy}")