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
damage_control/groups.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Rule groups and packs: which bundled rule/tooldef files belong to what (#1141).
|
|
2
|
+
|
|
3
|
+
Every bundled rules file (generic ``rules/*`` or a host layer such as AgentWire's) and
|
|
4
|
+
every ``tooldefs/*`` belongs to exactly one entry here (entries name the authored ``.yaml``;
|
|
5
|
+
the JSON a hook reads shares its stem). File names must be unique across the rules dirs.
|
|
6
|
+
``kind="group"`` entries are default-on ("os-focused"); a human may opt one out. ``kernel`` groups can never be opted out. ``kind="pack"`` entries are vendor/tool
|
|
7
|
+
sets a human enables (``skipped_stems`` says what an undecided policy loads: no packs but the host's defaults).
|
|
8
|
+
|
|
9
|
+
The generic registry lives here; what the HOST adds (its own groups, its layer files
|
|
10
|
+
attached to generic groups, the packs it turns on) comes from the installed profile
|
|
11
|
+
(#1209 M3), so the registry has no host names in its logic.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, FrozenSet, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from damage_control import engine
|
|
20
|
+
from damage_control.profile import GroupSpec as Entry
|
|
21
|
+
|
|
22
|
+
_GENERIC_ENTRIES = (
|
|
23
|
+
# --- default groups -----------------------------------------------------
|
|
24
|
+
Entry("control-plane", "group", "Protected damage-control/agent config paths", ("control-plane.yaml",), kernel=True),
|
|
25
|
+
Entry("core", "group", "Filesystem, process and history destruction", ("core.yaml",), kernel=True),
|
|
26
|
+
Entry("git", "group", "Destructive git operations, gh repo delete + git/gh tooldefs", ("git.yaml",), ("git.yaml", "gh.yaml")),
|
|
27
|
+
Entry("uv", "group", "uv run/publish ask-tier (own group: opting out of git must not drop it)", (), ("uv.yaml",)),
|
|
28
|
+
Entry("registry", "group", "npm unpublish + npm publish ask-tier", ("registry.yaml",), ("npm.yaml",)),
|
|
29
|
+
Entry("payloads", "group", "Rules scanning payloads wrapped in other commands", ("payloads.yaml",)),
|
|
30
|
+
Entry("remote", "group", "ssh-only remote hazards (reboot, service stop)", ("remote.yaml",)),
|
|
31
|
+
Entry("mcp-tools", "group", "Per-tool policy for MCP tool calls", ("mcp-tools.yaml",)),
|
|
32
|
+
Entry("outbound", "group", "Outward-facing sends (email, SMS, push)", ("outbound.yaml",)),
|
|
33
|
+
# --- packs --------------------------------------------------------------
|
|
34
|
+
Entry("aws", "pack", "AWS CLI", ("aws.yaml",), ("aws.yaml",), binaries=("aws",)),
|
|
35
|
+
Entry("gcp", "pack", "gcloud", ("gcp.yaml",), ("gcp.yaml",), binaries=("gcloud",)),
|
|
36
|
+
Entry("firebase", "pack", "Firebase CLI", ("firebase.yaml",), binaries=("firebase",)),
|
|
37
|
+
Entry("cloud-hosting", "pack", "Vercel, Netlify, Cloudflare, Heroku, Fly, DO, Supabase", ("cloud-hosting.yaml",), binaries=("vercel", "netlify", "wrangler", "heroku", "flyctl", "doctl", "supabase")),
|
|
38
|
+
Entry("databases", "pack", "Database CLIs and SQL", ("databases.yaml",), binaries=("psql", "mysql", "mongosh", "redis-cli", "prisma", "rails", "alembic", "flyway", "knex", "sequelize")),
|
|
39
|
+
Entry("containers", "pack", "Docker and Kubernetes", ("containers.yaml",), ("docker.yaml", "kubectl.yaml"), binaries=("docker", "kubectl")),
|
|
40
|
+
Entry("infrastructure", "pack", "Terraform, Pulumi, Serverless, SAM", ("infrastructure.yaml",), ("terraform.yaml",), binaries=("terraform", "pulumi", "serverless", "sam")),
|
|
41
|
+
Entry("tmux", "pack", "tmux kill-server / kill-session -a", ("tmux.yaml",), binaries=("tmux",)),
|
|
42
|
+
Entry("publish", "pack", "cargo/poetry/twine/pnpm/yarn/gem/mvn publish", ("publish.yaml",), binaries=("cargo", "poetry", "twine", "pnpm", "yarn", "gem", "mvn")),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _with_profile(generic: Tuple[Entry, ...], profile) -> Tuple[Entry, ...]:
|
|
47
|
+
"""``generic`` plus what ``profile`` adds: layer files on generic groups, then its own groups."""
|
|
48
|
+
layers = dict(profile.group_layer_rules)
|
|
49
|
+
unknown = sorted(set(layers) - {e.name for e in generic})
|
|
50
|
+
if unknown:
|
|
51
|
+
raise ValueError(f"profile {profile.name!r} adds rule files to unknown groups: {unknown}")
|
|
52
|
+
attached = tuple(e._replace(layer_rules=e.layer_rules + layers.get(e.name, ())) for e in generic)
|
|
53
|
+
return attached + tuple(profile.groups)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def entries() -> Tuple[Entry, ...]:
|
|
57
|
+
"""Every group and pack of THIS process: the generic registry plus what its profile adds."""
|
|
58
|
+
return _with_profile(_GENERIC_ENTRIES, engine.PROFILE)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def registry() -> Dict[str, Entry]:
|
|
62
|
+
return {e.name: e for e in entries()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def default_packs() -> Tuple[str, ...]:
|
|
66
|
+
"""Packs the host turns on for itself."""
|
|
67
|
+
return tuple(engine.PROFILE.default_packs)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def skipped_stems(packs: Optional[List[str]], disabled_groups: List[str]) -> Tuple[FrozenSet[str], FrozenSet[str]]:
|
|
71
|
+
"""``(rules stems, tooldef stems)`` of the bundled files switched off by the policy.
|
|
72
|
+
|
|
73
|
+
``packs`` None means the policy has no ``packs:`` key (undecided; an unreadable policy never gets here, the loader supplies every pack): the
|
|
74
|
+
default groups are on and only the HOST's default packs load (none for the generic profile),
|
|
75
|
+
never "everything off" (#1209 M5, section 4.5). A machine that has decided lists its packs.
|
|
76
|
+
A kernel group is never skipped.
|
|
77
|
+
"""
|
|
78
|
+
if packs is None:
|
|
79
|
+
packs = list(default_packs())
|
|
80
|
+
rules: set = set()
|
|
81
|
+
tooldefs: set = set()
|
|
82
|
+
for e in entries():
|
|
83
|
+
if e.kind == "pack":
|
|
84
|
+
active = e.name in packs
|
|
85
|
+
else:
|
|
86
|
+
active = e.kernel or e.name not in disabled_groups
|
|
87
|
+
if not active:
|
|
88
|
+
rules.update(Path(f).stem for f in e.files("rules"))
|
|
89
|
+
tooldefs.update(Path(f).stem for f in e.files("tooldefs"))
|
|
90
|
+
return frozenset(rules), frozenset(tooldefs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def bundled_files() -> Dict[str, set]:
|
|
94
|
+
"""The ``{"rules": {...}, "tooldefs": {...}}`` file stems actually bundled (generic + profile layers)."""
|
|
95
|
+
return {
|
|
96
|
+
"rules": {p.stem for d in engine.bundled_rule_sources() for p in engine._data_files(d)},
|
|
97
|
+
"tooldefs": {p.stem for p in engine._data_files(engine.bundled_tooldefs_dir())},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def unclassified() -> Dict[str, list]:
|
|
102
|
+
"""Bundled files owned by no entry, or by more than one, per section."""
|
|
103
|
+
bundled = bundled_files()
|
|
104
|
+
out: Dict[str, list] = {}
|
|
105
|
+
for section in ("rules", "tooldefs"):
|
|
106
|
+
owners: Dict[str, list] = {}
|
|
107
|
+
for e in entries():
|
|
108
|
+
for f in e.files(section):
|
|
109
|
+
owners.setdefault(Path(f).stem, []).append(e.name)
|
|
110
|
+
bad = [f"{f}: owned by {owners.get(f, [])}" for f in sorted(bundled[section] | set(owners))
|
|
111
|
+
if f not in bundled[section] or len(owners.get(f, [])) != 1]
|
|
112
|
+
if bad:
|
|
113
|
+
out[section] = bad
|
|
114
|
+
return out
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The one read-modify-write path for a harness's JSON settings file (#1209 M5, section 5).
|
|
2
|
+
|
|
3
|
+
Generalizes what #1220 fixed in agentwire. The file belongs to the user and to other tools,
|
|
4
|
+
never to us, so every edit goes through :func:`edit_json_config`:
|
|
5
|
+
|
|
6
|
+
* **Unparseable input is a refusal, never a rewrite** (:class:`ConfigError`). Replacing a file
|
|
7
|
+
we could not read with ``{}`` deletes every other setting the user has.
|
|
8
|
+
* **Structural merge.** The caller's ``mutate`` edits the parsed dict in place and returns True
|
|
9
|
+
when it changed something; nothing here knows what a hook is.
|
|
10
|
+
* **The user's formatting survives**: BOM, CRLF, indent width and a missing trailing newline are
|
|
11
|
+
detected and re-applied, so an edit changes the lines it means to change.
|
|
12
|
+
* **Atomic write**: temp file in the same directory, ``fsync``, ``os.replace``, ``fsync`` of the
|
|
13
|
+
directory. A crash (or ``kill -9``) leaves the old file or the new one, never a partial one.
|
|
14
|
+
* **Concurrent-writer detection**: the bytes are re-read just before the replace and again
|
|
15
|
+
after it. If another writer got in between, the edit is recomputed from the new bytes once,
|
|
16
|
+
then refused.
|
|
17
|
+
* **Backup** of the original, once, never overwritten.
|
|
18
|
+
|
|
19
|
+
Standard library only.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import tempfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
_ATTEMPTS = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigError(RuntimeError):
|
|
35
|
+
"""The config file could not be safely edited; nothing was written."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EditResult(NamedTuple):
|
|
39
|
+
changed: bool
|
|
40
|
+
created: bool # the file did not exist before this edit
|
|
41
|
+
sha256: Optional[str] # of the bytes now on disk (None if the file is absent)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Style(NamedTuple):
|
|
45
|
+
bom: bool = False
|
|
46
|
+
crlf: bool = False
|
|
47
|
+
indent: int = 2
|
|
48
|
+
trailing_newline: bool = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sha256_hex(data: bytes) -> str:
|
|
52
|
+
return hashlib.sha256(data).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve(path: Path) -> Path:
|
|
56
|
+
"""Edit through a symlink (dotfile managers) instead of replacing it."""
|
|
57
|
+
return path.resolve() if path.is_symlink() else path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_bytes(path: Path) -> Optional[bytes]:
|
|
61
|
+
try:
|
|
62
|
+
return path.read_bytes()
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return None
|
|
65
|
+
except OSError as e:
|
|
66
|
+
raise ConfigError(f"cannot read {path}: {e}") from e
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _strict_constant(name: str) -> Any:
|
|
70
|
+
raise ValueError(f"{name} is not valid JSON")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse(raw: Optional[bytes], path: Path) -> Dict[str, Any]:
|
|
74
|
+
"""The JSON object in ``raw`` ({} for absent or empty). Anything else is a refusal."""
|
|
75
|
+
if raw is None or not raw.strip():
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
parsed = json.loads(raw.decode("utf-8-sig"), parse_constant=_strict_constant)
|
|
79
|
+
except ValueError as e: # JSONDecodeError and UnicodeDecodeError
|
|
80
|
+
raise ConfigError(f"{path} is not valid JSON ({e}); left untouched. Fix the file and re-run.") from e
|
|
81
|
+
if not isinstance(parsed, dict):
|
|
82
|
+
raise ConfigError(f"{path} is not a JSON object; left untouched. Fix the file and re-run.")
|
|
83
|
+
return parsed
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_config(path: Path) -> Tuple[Optional[bytes], Dict[str, Any]]:
|
|
87
|
+
"""``(raw bytes or None if absent, parsed object)``. Read-only; refuses what it cannot parse."""
|
|
88
|
+
raw = read_bytes(resolve(path))
|
|
89
|
+
return raw, parse(raw, path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detect_style(raw: Optional[bytes]) -> Style:
|
|
93
|
+
if not raw or not raw.strip():
|
|
94
|
+
return Style()
|
|
95
|
+
bom = raw.startswith(b"\xef\xbb\xbf")
|
|
96
|
+
text = raw.decode("utf-8-sig", errors="replace")
|
|
97
|
+
crlf = "\r\n" in text
|
|
98
|
+
indent = 2
|
|
99
|
+
for line in text.splitlines()[1:]:
|
|
100
|
+
stripped = line.lstrip(" ")
|
|
101
|
+
if stripped and stripped != line:
|
|
102
|
+
indent = len(line) - len(stripped)
|
|
103
|
+
break
|
|
104
|
+
return Style(bom=bom, crlf=crlf, indent=indent, trailing_newline=text.endswith("\n"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render(settings: Dict[str, Any], style: Style) -> bytes:
|
|
108
|
+
text = json.dumps(settings, indent=style.indent, ensure_ascii=False)
|
|
109
|
+
if style.trailing_newline:
|
|
110
|
+
text += "\n"
|
|
111
|
+
if style.crlf:
|
|
112
|
+
text = text.replace("\n", "\r\n")
|
|
113
|
+
data = text.encode("utf-8")
|
|
114
|
+
return b"\xef\xbb\xbf" + data if style.bom else data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def atomic_replace(path: Path, data: bytes, expected: Optional[bytes]) -> bool:
|
|
118
|
+
"""Write ``data`` over ``path`` atomically iff its bytes are still ``expected``.
|
|
119
|
+
|
|
120
|
+
Returns False (writing nothing) when the file changed since it was read. Preserves the
|
|
121
|
+
existing file's mode. The temp file never outlives the call.
|
|
122
|
+
"""
|
|
123
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
125
|
+
tmp = Path(tmp_name)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(fd, "wb") as f:
|
|
128
|
+
f.write(data)
|
|
129
|
+
f.flush()
|
|
130
|
+
os.fsync(f.fileno())
|
|
131
|
+
if path.exists():
|
|
132
|
+
shutil.copymode(path, tmp)
|
|
133
|
+
if read_bytes(path) != expected:
|
|
134
|
+
return False
|
|
135
|
+
os.replace(tmp, path)
|
|
136
|
+
_fsync_dir(path.parent)
|
|
137
|
+
return True
|
|
138
|
+
finally:
|
|
139
|
+
tmp.unlink(missing_ok=True)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _fsync_dir(directory: Path) -> None:
|
|
143
|
+
with contextlib.suppress(OSError):
|
|
144
|
+
fd = os.open(directory, os.O_RDONLY)
|
|
145
|
+
try:
|
|
146
|
+
os.fsync(fd)
|
|
147
|
+
finally:
|
|
148
|
+
os.close(fd)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _backup_once(path: Path, raw: Optional[bytes], backup: Optional[Path]) -> None:
|
|
152
|
+
if backup is None or raw is None or backup.exists():
|
|
153
|
+
return
|
|
154
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
shutil.copy2(path, backup)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def edit_json_config(
|
|
159
|
+
path: Path,
|
|
160
|
+
mutate: Callable[[Dict[str, Any]], bool],
|
|
161
|
+
backup: Optional[Path] = None,
|
|
162
|
+
) -> EditResult:
|
|
163
|
+
"""Apply ``mutate`` to the parsed config at ``path`` and persist it if it reports a change.
|
|
164
|
+
|
|
165
|
+
Raises :class:`ConfigError` (writing nothing) for unparseable input, a read-only file,
|
|
166
|
+
or a file that keeps changing underneath the edit. A write is re-read and verified.
|
|
167
|
+
"""
|
|
168
|
+
path = resolve(Path(path))
|
|
169
|
+
for _ in range(_ATTEMPTS):
|
|
170
|
+
raw = read_bytes(path)
|
|
171
|
+
settings = parse(raw, path)
|
|
172
|
+
if raw is not None and not os.access(path, os.W_OK):
|
|
173
|
+
raise ConfigError(f"{path} is not writable; left untouched.")
|
|
174
|
+
if not mutate(settings):
|
|
175
|
+
return EditResult(False, False, None if raw is None else sha256_hex(raw))
|
|
176
|
+
data = render(settings, detect_style(raw))
|
|
177
|
+
_backup_once(path, raw, backup)
|
|
178
|
+
try:
|
|
179
|
+
replaced = atomic_replace(path, data, raw)
|
|
180
|
+
except OSError as e:
|
|
181
|
+
raise ConfigError(f"cannot write {path}: {e}; nothing written.") from e
|
|
182
|
+
if not replaced:
|
|
183
|
+
continue # changed since we read it: recompute from the new bytes
|
|
184
|
+
if read_bytes(path) != data:
|
|
185
|
+
raise ConfigError(f"{path} was changed by another writer right after the edit; re-run to verify.")
|
|
186
|
+
return EditResult(True, raw is None, sha256_hex(data))
|
|
187
|
+
raise ConfigError(f"{path} kept changing while it was being edited; nothing written. Re-run.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def restore_bytes(path: Path, data: Optional[bytes], expected_sha256: str) -> bool:
|
|
191
|
+
"""Put ``data`` back (or delete the file if ``data`` is None) iff the file is still what we wrote.
|
|
192
|
+
|
|
193
|
+
The byte-exact half of uninstall. Returns False when the file has changed since
|
|
194
|
+
``expected_sha256``, so the caller falls back to a structural removal.
|
|
195
|
+
"""
|
|
196
|
+
path = resolve(Path(path))
|
|
197
|
+
current = read_bytes(path)
|
|
198
|
+
if current is None or sha256_hex(current) != expected_sha256:
|
|
199
|
+
return False
|
|
200
|
+
if data is None:
|
|
201
|
+
if read_bytes(path) != current:
|
|
202
|
+
return False
|
|
203
|
+
path.unlink()
|
|
204
|
+
return True
|
|
205
|
+
return atomic_replace(path, data, current)
|
damage_control/hook.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The Claude Code hook entry: ``python -I -m damage_control.hook <kind> [--profile PATH]``.
|
|
2
|
+
|
|
3
|
+
This file is the bootstrap wrapper, and it imports nothing but ``os`` and ``sys`` at module
|
|
4
|
+
level. Python exits 1 when an import fails or an exception escapes, and Claude Code treats
|
|
5
|
+
exit 1 as "no decision" (the tool call RUNS). The only exit that stops a call is 2, so every
|
|
6
|
+
way this process can fail before the real hook is running is caught here and turned into 2:
|
|
7
|
+
an unimportable engine, a half-upgraded install, a syntax error in a rules module.
|
|
8
|
+
|
|
9
|
+
What it cannot catch is a failure BEFORE this file runs (no interpreter, no ``damage_control``
|
|
10
|
+
package). That is what ``"onFailure": "block"`` in the registration is for.
|
|
11
|
+
|
|
12
|
+
``-I`` is mandatory, not a tidy-up: ``python -m`` puts the working directory first on
|
|
13
|
+
``sys.path``, and that tree is writable by the agent being policed, so a planted ``json.py``
|
|
14
|
+
would be imported as the hook's own ``json``. ``-I`` also drops ``PYTHON*`` variables and the
|
|
15
|
+
user site directory.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _block(reason: str) -> None:
|
|
23
|
+
"""Exit 2 whatever state stderr is in."""
|
|
24
|
+
try:
|
|
25
|
+
sys.stderr.write(f"SECURITY: Blocked: damage-control hook could not start ({reason}) — failing closed\n")
|
|
26
|
+
sys.stderr.flush()
|
|
27
|
+
finally:
|
|
28
|
+
os._exit(2)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _excepthook(exc_type, exc, tb) -> None:
|
|
32
|
+
try:
|
|
33
|
+
import traceback
|
|
34
|
+
|
|
35
|
+
traceback.print_exception(exc_type, exc, tb)
|
|
36
|
+
except BaseException:
|
|
37
|
+
pass
|
|
38
|
+
_block(f"{exc_type.__name__}: {exc}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
sys.excepthook = _excepthook
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv) -> None:
|
|
45
|
+
try:
|
|
46
|
+
from damage_control.adapter import run_hook
|
|
47
|
+
except BaseException as e: # SystemExit and KeyboardInterrupt included: nothing may exit 0 or 1 here
|
|
48
|
+
_block(f"import failed: {type(e).__name__}: {e}")
|
|
49
|
+
return
|
|
50
|
+
run_hook(argv)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main(sys.argv[1:])
|