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.
- damage_control/__init__.py +9 -0
- damage_control/__main__.py +7 -0
- damage_control/adapter.py +179 -0
- damage_control/audit.py +432 -0
- damage_control/breakglass.py +155 -0
- damage_control/claude_code.py +148 -0
- damage_control/cli.py +173 -0
- damage_control/engine.py +10656 -0
- damage_control/groups.py +114 -0
- damage_control/harness_config.py +205 -0
- damage_control/hook.py +54 -0
- damage_control/install.py +618 -0
- damage_control/packs.py +219 -0
- damage_control/policy.py +323 -0
- damage_control/profile.py +177 -0
- damage_control/rules/aws.json +85 -0
- damage_control/rules/aws.yaml +76 -0
- damage_control/rules/cloud-hosting.json +162 -0
- damage_control/rules/cloud-hosting.yaml +163 -0
- damage_control/rules/containers.json +67 -0
- damage_control/rules/containers.yaml +69 -0
- damage_control/rules/control-plane.json +7 -0
- damage_control/rules/control-plane.yaml +21 -0
- damage_control/rules/core.json +332 -0
- damage_control/rules/core.yaml +441 -0
- damage_control/rules/databases.json +117 -0
- damage_control/rules/databases.yaml +123 -0
- damage_control/rules/firebase.json +34 -0
- damage_control/rules/firebase.yaml +30 -0
- damage_control/rules/gcp.json +60 -0
- damage_control/rules/gcp.yaml +57 -0
- damage_control/rules/git.json +119 -0
- damage_control/rules/git.yaml +120 -0
- damage_control/rules/infrastructure.json +69 -0
- damage_control/rules/infrastructure.yaml +66 -0
- damage_control/rules/mcp-tools.json +11 -0
- damage_control/rules/mcp-tools.yaml +47 -0
- damage_control/rules/outbound.json +40 -0
- damage_control/rules/outbound.yaml +58 -0
- damage_control/rules/payloads.json +120 -0
- damage_control/rules/payloads.yaml +155 -0
- damage_control/rules/publish.json +46 -0
- damage_control/rules/publish.yaml +46 -0
- damage_control/rules/registry.json +10 -0
- damage_control/rules/registry.yaml +11 -0
- damage_control/rules/remote.json +31 -0
- damage_control/rules/remote.yaml +53 -0
- damage_control/rules/tmux.json +17 -0
- damage_control/rules/tmux.yaml +16 -0
- damage_control/ruleset.py +94 -0
- damage_control/tooldefs/aws.json +140 -0
- damage_control/tooldefs/aws.yaml +119 -0
- damage_control/tooldefs/docker.json +146 -0
- damage_control/tooldefs/docker.yaml +123 -0
- damage_control/tooldefs/gcp.json +186 -0
- damage_control/tooldefs/gcp.yaml +158 -0
- damage_control/tooldefs/gh.json +172 -0
- damage_control/tooldefs/gh.yaml +145 -0
- damage_control/tooldefs/git.json +166 -0
- damage_control/tooldefs/git.yaml +143 -0
- damage_control/tooldefs/kubectl.json +101 -0
- damage_control/tooldefs/kubectl.yaml +85 -0
- damage_control/tooldefs/npm.json +77 -0
- damage_control/tooldefs/npm.yaml +65 -0
- damage_control/tooldefs/terraform.json +68 -0
- damage_control/tooldefs/terraform.yaml +57 -0
- damage_control/tooldefs/uv.json +112 -0
- damage_control/tooldefs/uv.yaml +97 -0
- damage_control_dev-0.1.0.dist-info/METADATA +15 -0
- damage_control_dev-0.1.0.dist-info/RECORD +74 -0
- damage_control_dev-0.1.0.dist-info/WHEEL +4 -0
- damage_control_dev-0.1.0.dist-info/entry_points.txt +2 -0
- damage_control_dev-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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}")
|