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,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Break-glass for the damage-control hooks: works when the venv they run from does not.
|
|
3
|
+
|
|
4
|
+
The hooks run ``<venv python> -I -m damage_control.hook <kind>`` with ``"onFailure": "block"``,
|
|
5
|
+
so a venv that is gone (``uv tool install --force`` died halfway, a managed Python was
|
|
6
|
+
uninstalled) makes Claude Code refuse EVERY tool call, including the ones you would use to
|
|
7
|
+
repair it. This file is the way out. It is standard library only and ``init`` copies it to
|
|
8
|
+
``<config dir>/bin/damage-control-breakglass``. Run it with the system ``python3`` from an
|
|
9
|
+
ordinary terminal (not through Claude Code):
|
|
10
|
+
|
|
11
|
+
python3 damage-control-breakglass status [--settings PATH]
|
|
12
|
+
python3 damage-control-breakglass unregister [--settings PATH]
|
|
13
|
+
|
|
14
|
+
``unregister`` removes exactly the damage-control entries (the file is backed up first,
|
|
15
|
+
written atomically, and an unparseable file is refused, never rewritten). With them gone
|
|
16
|
+
Claude Code stops calling the hooks, so **damage control is OFF** until you repair the
|
|
17
|
+
install and run ``damage-control-dev init``.
|
|
18
|
+
|
|
19
|
+
It is a human's tool: the hook guards ``~/.claude/settings.json``, so a policed agent
|
|
20
|
+
cannot rewrite it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
HOOK_MODULE = "damage_control.hook"
|
|
32
|
+
BACKUP_SUFFIX = ".breakglass.bak"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def default_settings() -> Path:
|
|
36
|
+
return Path.home() / ".claude" / "settings.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_ours(hook) -> bool:
|
|
40
|
+
args = hook.get("args") if isinstance(hook, dict) else None
|
|
41
|
+
return isinstance(args, list) and HOOK_MODULE in args
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load(path: Path):
|
|
45
|
+
try:
|
|
46
|
+
raw = path.read_bytes()
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return None, {}
|
|
49
|
+
parsed = json.loads(raw.decode("utf-8-sig")) # ValueError propagates: an unparseable file is refused
|
|
50
|
+
if not isinstance(parsed, dict):
|
|
51
|
+
raise ValueError(f"{path} is not a JSON object")
|
|
52
|
+
return raw, parsed
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def owned(settings) -> list:
|
|
56
|
+
hooks = settings.get("hooks")
|
|
57
|
+
pre = hooks.get("PreToolUse") if isinstance(hooks, dict) else None
|
|
58
|
+
found = []
|
|
59
|
+
for entry in pre if isinstance(pre, list) else []:
|
|
60
|
+
inner = entry.get("hooks") if isinstance(entry, dict) else None
|
|
61
|
+
for hook in inner if isinstance(inner, list) else []:
|
|
62
|
+
if is_ours(hook):
|
|
63
|
+
found.append((entry.get("matcher"), hook))
|
|
64
|
+
return found
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def runs(interpreter: str) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
return subprocess.run([interpreter, "-I", "-c", "import damage_control"],
|
|
70
|
+
capture_output=True, timeout=20).returncode == 0
|
|
71
|
+
except (OSError, subprocess.SubprocessError):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_status(path: Path) -> int:
|
|
76
|
+
try:
|
|
77
|
+
_, settings = load(path)
|
|
78
|
+
except (ValueError, OSError) as e:
|
|
79
|
+
print(f"{path}: cannot read it ({e}); nothing to report")
|
|
80
|
+
return 1
|
|
81
|
+
entries = owned(settings)
|
|
82
|
+
if not entries:
|
|
83
|
+
print(f"No damage-control hooks are registered in {path}.")
|
|
84
|
+
return 0
|
|
85
|
+
print(f"{len(entries)} damage-control hook entries in {path}")
|
|
86
|
+
code = 0
|
|
87
|
+
for interpreter in sorted({h.get("command", "") for _, h in entries}):
|
|
88
|
+
ok = runs(interpreter)
|
|
89
|
+
print(f" interpreter {interpreter}: {'ok' if ok else 'DEAD — every tool call is being blocked'}")
|
|
90
|
+
code |= 0 if ok else 1
|
|
91
|
+
if code:
|
|
92
|
+
print("Fix: run `unregister`, repair the install, then `damage-control-dev init`.")
|
|
93
|
+
return code
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_unregister(path: Path) -> int:
|
|
97
|
+
try:
|
|
98
|
+
raw, settings = load(path)
|
|
99
|
+
except (ValueError, OSError) as e:
|
|
100
|
+
print(f"REFUSED: {path} cannot be read as JSON ({e}). Edit it by hand: delete the "
|
|
101
|
+
f"PreToolUse entries whose command runs `-m {HOOK_MODULE}`.")
|
|
102
|
+
return 1
|
|
103
|
+
if not owned(settings):
|
|
104
|
+
print("Nothing to remove.")
|
|
105
|
+
return 0
|
|
106
|
+
kept = []
|
|
107
|
+
for entry in settings["hooks"]["PreToolUse"]:
|
|
108
|
+
inner = entry.get("hooks") if isinstance(entry, dict) else None
|
|
109
|
+
if isinstance(inner, list):
|
|
110
|
+
remaining = [h for h in inner if not is_ours(h)]
|
|
111
|
+
if len(remaining) != len(inner):
|
|
112
|
+
if not remaining:
|
|
113
|
+
continue
|
|
114
|
+
entry = dict(entry, hooks=remaining)
|
|
115
|
+
kept.append(entry)
|
|
116
|
+
settings["hooks"]["PreToolUse"] = kept
|
|
117
|
+
backup = path.with_name(path.name + BACKUP_SUFFIX)
|
|
118
|
+
if raw is not None and not backup.exists():
|
|
119
|
+
shutil.copy2(path, backup)
|
|
120
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
121
|
+
try:
|
|
122
|
+
with os.fdopen(fd, "w") as f:
|
|
123
|
+
f.write(json.dumps(settings, indent=2))
|
|
124
|
+
f.flush()
|
|
125
|
+
os.fsync(f.fileno())
|
|
126
|
+
shutil.copymode(path, tmp)
|
|
127
|
+
os.replace(tmp, path)
|
|
128
|
+
finally:
|
|
129
|
+
if os.path.exists(tmp):
|
|
130
|
+
os.unlink(tmp)
|
|
131
|
+
print(f"Removed the damage-control hooks from {path} (backup: {backup.name}).")
|
|
132
|
+
print("DAMAGE CONTROL IS NOW OFF. Repair the install, then re-register:")
|
|
133
|
+
print(" damage-control-dev init")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv) -> int:
|
|
138
|
+
args = list(argv)
|
|
139
|
+
path = default_settings()
|
|
140
|
+
if "--settings" in args:
|
|
141
|
+
i = args.index("--settings")
|
|
142
|
+
if i + 1 >= len(args):
|
|
143
|
+
print(__doc__)
|
|
144
|
+
return 2
|
|
145
|
+
path = Path(args[i + 1])
|
|
146
|
+
del args[i:i + 2]
|
|
147
|
+
commands = {"status": cmd_status, "unregister": cmd_unregister}
|
|
148
|
+
if len(args) != 1 or args[0] not in commands:
|
|
149
|
+
print(__doc__)
|
|
150
|
+
return 2
|
|
151
|
+
return commands[args[0]](path)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Claude Code as an install target (#1209 M5): where its settings live, what a registration is.
|
|
2
|
+
|
|
3
|
+
A registration is the exec form Claude Code runs without a shell:
|
|
4
|
+
``{"command": <absolute interpreter>, "args": ["-I", "-m", "damage_control.hook", kind, ...],
|
|
5
|
+
"timeout": N, "onFailure": "block"}``. Our entries are recognized by the package module name
|
|
6
|
+
in ``args`` and by nothing else.
|
|
7
|
+
|
|
8
|
+
Standard library only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
HOOK_MODULE = "damage_control.hook"
|
|
19
|
+
HARNESS = "claude-code"
|
|
20
|
+
|
|
21
|
+
# ``onFailure: "block"`` and exec-form ``args`` need Claude Code >= this. Below it a hook crash,
|
|
22
|
+
# timeout or missing interpreter lets the tool call RUN, so registering anyway is a weaker
|
|
23
|
+
# config than the one asked for.
|
|
24
|
+
ONFAILURE_MIN_VERSION = (2, 1, 295)
|
|
25
|
+
|
|
26
|
+
# matcher -> hook kind. Several matchers share a kind (Read/Grep/Glob), PowerShell is a
|
|
27
|
+
# separate tool name with Bash's input shape.
|
|
28
|
+
MATCHERS: Dict[str, str] = {
|
|
29
|
+
"Bash": "bash",
|
|
30
|
+
"PowerShell": "bash",
|
|
31
|
+
"Edit": "edit",
|
|
32
|
+
"NotebookEdit": "edit",
|
|
33
|
+
"Write": "write",
|
|
34
|
+
"Read": "read",
|
|
35
|
+
"Grep": "read",
|
|
36
|
+
"Glob": "read",
|
|
37
|
+
"mcp__.*": "mcp",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
SCOPES = ("user", "project", "managed")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def settings_path(scope: str, cwd: Optional[Path] = None) -> Path:
|
|
44
|
+
"""The settings file ``scope`` registers into."""
|
|
45
|
+
if scope == "user":
|
|
46
|
+
return Path.home() / ".claude" / "settings.json"
|
|
47
|
+
if scope == "project":
|
|
48
|
+
return (cwd or Path.cwd()) / ".claude" / "settings.json"
|
|
49
|
+
if scope == "managed":
|
|
50
|
+
base = (Path("/Library/Application Support/ClaudeCode") if sys.platform == "darwin"
|
|
51
|
+
else Path("/etc/claude-code"))
|
|
52
|
+
return base / "managed-settings.d" / "50-damage-control.json"
|
|
53
|
+
raise ValueError(f"unknown scope {scope!r}; expected one of {SCOPES}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_version(text: str) -> Optional[Tuple[int, ...]]:
|
|
57
|
+
m = re.match(r"\s*(\d+)\.(\d+)\.(\d+)", text or "")
|
|
58
|
+
return tuple(int(g) for g in m.groups()) if m else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_version() -> Optional[Tuple[int, ...]]:
|
|
62
|
+
"""The installed Claude Code version, or None when ``claude`` is absent or unreadable."""
|
|
63
|
+
try:
|
|
64
|
+
out = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=15).stdout
|
|
65
|
+
except (OSError, subprocess.SubprocessError):
|
|
66
|
+
return None
|
|
67
|
+
return parse_version(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def version_text(version: Optional[Tuple[int, ...]]) -> str:
|
|
71
|
+
return ".".join(map(str, version)) if version else "unknown"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def supports_fail_closed(version: Optional[Tuple[int, ...]]) -> Optional[bool]:
|
|
75
|
+
"""True/False once the version is known; None when it could not be read."""
|
|
76
|
+
return None if version is None else version >= ONFAILURE_MIN_VERSION
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def hook_args(kind: str, profile: Optional[str] = None) -> List[str]:
|
|
80
|
+
args = ["-I", "-m", HOOK_MODULE, kind]
|
|
81
|
+
if profile:
|
|
82
|
+
args += ["--profile", profile]
|
|
83
|
+
return args
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_ours(hook: Any) -> bool:
|
|
87
|
+
"""A hook dict that runs this package's hook module."""
|
|
88
|
+
args = hook.get("args") if isinstance(hook, dict) else None
|
|
89
|
+
return isinstance(args, list) and HOOK_MODULE in args
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def profile_of(hook: Dict[str, Any]) -> Optional[str]:
|
|
93
|
+
"""The ``--profile`` path in one of our hook dicts, if any."""
|
|
94
|
+
args = hook.get("args") or []
|
|
95
|
+
return args[args.index("--profile") + 1] if "--profile" in args and args.index("--profile") + 1 < len(args) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def kind_of(hook: Dict[str, Any]) -> Optional[str]:
|
|
99
|
+
args = hook.get("args") or []
|
|
100
|
+
i = args.index(HOOK_MODULE) + 1 if HOOK_MODULE in args else len(args)
|
|
101
|
+
return args[i] if i < len(args) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Owned(NamedTuple):
|
|
105
|
+
matcher: Any
|
|
106
|
+
hook: Dict[str, Any]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pre_tool_use(settings: Dict[str, Any]) -> Optional[List[Any]]:
|
|
110
|
+
hooks = settings.get("hooks")
|
|
111
|
+
pre = hooks.get("PreToolUse") if isinstance(hooks, dict) else None
|
|
112
|
+
return pre if isinstance(pre, list) else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def owned_entries(settings: Dict[str, Any]) -> List[Owned]:
|
|
116
|
+
"""Every hook of ours in ``settings``, with the matcher it sits under. Malformed entries are skipped."""
|
|
117
|
+
found: List[Owned] = []
|
|
118
|
+
for entry in pre_tool_use(settings) or []:
|
|
119
|
+
inner = entry.get("hooks") if isinstance(entry, dict) else None
|
|
120
|
+
if isinstance(inner, list):
|
|
121
|
+
found.extend(Owned(entry.get("matcher"), h) for h in inner if is_ours(h))
|
|
122
|
+
return found
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def foreign_entry_count(settings: Dict[str, Any]) -> int:
|
|
126
|
+
"""PreToolUse hooks that are not ours: the user's own, left strictly alone."""
|
|
127
|
+
count = 0
|
|
128
|
+
for entry in pre_tool_use(settings) or []:
|
|
129
|
+
inner = entry.get("hooks") if isinstance(entry, dict) else None
|
|
130
|
+
count += sum(1 for h in inner if not is_ours(h)) if isinstance(inner, list) else 0
|
|
131
|
+
return count
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def wanted_hook(interpreter: str, kind: str, timeout: int, fail_closed: bool,
|
|
135
|
+
profile: Optional[str] = None) -> Dict[str, Any]:
|
|
136
|
+
hook: Dict[str, Any] = {"type": "command", "command": interpreter,
|
|
137
|
+
"args": hook_args(kind, profile), "timeout": timeout}
|
|
138
|
+
if fail_closed:
|
|
139
|
+
hook["onFailure"] = "block"
|
|
140
|
+
return hook
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def selftest_command(interpreter: str, kind: str, profile: Optional[str] = None) -> List[str]:
|
|
144
|
+
return [interpreter, *hook_args(kind, profile)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def absolute_interpreter() -> str:
|
|
148
|
+
return os.path.abspath(sys.executable)
|
damage_control/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""``damage-control-dev``: install, inspect and operate the Claude Code hooks (#1209 M5).
|
|
2
|
+
|
|
3
|
+
damage-control-dev init [--scope user|project|managed] [--dry-run] [--refresh] [--fail-open]
|
|
4
|
+
damage-control-dev status [--json] # read-only drift report
|
|
5
|
+
damage-control-dev doctor [--fix] [--json]
|
|
6
|
+
damage-control-dev uninstall [--purge]
|
|
7
|
+
damage-control-dev check "<command>" # what would the hook decide?
|
|
8
|
+
damage-control-dev rules # the enforced rule ids
|
|
9
|
+
damage-control-dev packs|groups list|enable|disable [name]
|
|
10
|
+
|
|
11
|
+
Exit status: 0 = fine, 1 = a refusal or a finding marked ``[!!]``, 2 = bad usage.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from damage_control import __version__, engine, install, packs
|
|
21
|
+
from damage_control import claude_code as cc
|
|
22
|
+
from damage_control import harness_config as hc
|
|
23
|
+
from damage_control.profile import ProfileError, load_profile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _common(parser: argparse.ArgumentParser) -> None:
|
|
27
|
+
parser.add_argument("--harness", default=cc.HARNESS, choices=[cc.HARNESS], help="the agent harness (Claude Code only)")
|
|
28
|
+
parser.add_argument("--scope", default="user", choices=cc.SCOPES, help="which settings file to manage (default: user)")
|
|
29
|
+
parser.add_argument("--settings", metavar="PATH", help="manage this settings file instead of the scope's")
|
|
30
|
+
parser.add_argument("--profile", metavar="PATH", help="a host profile manifest the hooks load (for host installers)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(prog="damage-control-dev", description=__doc__.split("\n\n")[0])
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"damage-control-dev {__version__}")
|
|
36
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
37
|
+
|
|
38
|
+
p = sub.add_parser("init", help="register the hooks, prove they run, record the install")
|
|
39
|
+
_common(p)
|
|
40
|
+
p.add_argument("--dry-run", action="store_true", help="show the diff and write nothing")
|
|
41
|
+
p.add_argument("--refresh", action="store_true", help="print and acknowledge the ruleset diff (loosening is marked !!)")
|
|
42
|
+
p.add_argument("--force", action="store_true", help="override the downgrade refusal")
|
|
43
|
+
p.add_argument("--fail-open", action="store_true", help="register without onFailure=block (recorded in the policy)")
|
|
44
|
+
p.add_argument("--allow-dev-source", action="store_true", help="allow an editable/vcs/uvx install to write machine-global state")
|
|
45
|
+
p.add_argument("--apply", action="store_true", help="managed scope only: actually write (otherwise a dry run)")
|
|
46
|
+
|
|
47
|
+
p = sub.add_parser("status", help="read-only drift report")
|
|
48
|
+
_common(p)
|
|
49
|
+
p.add_argument("--json", action="store_true")
|
|
50
|
+
|
|
51
|
+
p = sub.add_parser("doctor", help="status plus self-test, harness version and repair")
|
|
52
|
+
_common(p)
|
|
53
|
+
p.add_argument("--json", action="store_true")
|
|
54
|
+
p.add_argument("--fix", action="store_true", help="re-run init to repair the registration")
|
|
55
|
+
p.add_argument("--allow-dev-source", action="store_true")
|
|
56
|
+
|
|
57
|
+
p = sub.add_parser("uninstall", help="remove exactly our entries")
|
|
58
|
+
_common(p)
|
|
59
|
+
p.add_argument("--purge", action="store_true", help="also delete the policy, overlays and install state")
|
|
60
|
+
|
|
61
|
+
p = sub.add_parser("check", help="what would the hooks decide for a shell command?")
|
|
62
|
+
p.add_argument("--profile", metavar="PATH")
|
|
63
|
+
p.add_argument("text")
|
|
64
|
+
p.add_argument("--json", action="store_true")
|
|
65
|
+
|
|
66
|
+
p = sub.add_parser("rules", help="list the enforced rule ids")
|
|
67
|
+
p.add_argument("--profile", metavar="PATH")
|
|
68
|
+
p.add_argument("--json", action="store_true")
|
|
69
|
+
|
|
70
|
+
for noun in ("packs", "groups"):
|
|
71
|
+
p = sub.add_parser(noun, help=f"list, enable or disable {noun} (enable/disable: human terminal only)")
|
|
72
|
+
p.add_argument("--profile", metavar="PATH")
|
|
73
|
+
p.add_argument("action", choices=["list", "enable", "disable"])
|
|
74
|
+
p.add_argument("name", nargs="?")
|
|
75
|
+
return parser
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_profile(path: Optional[str]) -> None:
|
|
79
|
+
if path:
|
|
80
|
+
engine.install_profile(load_profile(Path(path)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _print_findings(findings: List[install.Finding], as_json: bool) -> int:
|
|
84
|
+
code = install.exit_code(findings)
|
|
85
|
+
if as_json:
|
|
86
|
+
print(json.dumps({"ok": code == 0, "version": __version__, "profile": engine.PROFILE.name,
|
|
87
|
+
"findings": [f._asdict() for f in findings]}, indent=2))
|
|
88
|
+
else:
|
|
89
|
+
for f in findings:
|
|
90
|
+
print(f.line())
|
|
91
|
+
return code
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
95
|
+
result = engine.check_command(args.text, engine.load_effective_config(), cwd=str(Path.cwd()))
|
|
96
|
+
if args.json:
|
|
97
|
+
print(json.dumps(result, indent=2, default=str))
|
|
98
|
+
else:
|
|
99
|
+
print(f"{result.get('decision')}: {result.get('reason') or 'no rule matched'}"
|
|
100
|
+
+ (f" [{result['id']}]" if result.get("id") else ""))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_rules(args: argparse.Namespace) -> int:
|
|
105
|
+
config = engine.load_effective_config()
|
|
106
|
+
rows = [{"id": r["id"], "tier": "ask" if r.get("ask") else "block", "source": r.get("source")}
|
|
107
|
+
for r in config["bashToolPatterns"]]
|
|
108
|
+
if args.json:
|
|
109
|
+
print(json.dumps(rows, indent=2))
|
|
110
|
+
else:
|
|
111
|
+
for r in rows:
|
|
112
|
+
print(f"{r['tier']:5} {r['id']} ({r['source']})")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cmd_pack_group(args: argparse.Namespace) -> int:
|
|
117
|
+
kind = "pack" if args.command == "packs" else "group"
|
|
118
|
+
policy = engine.policy_file_path()
|
|
119
|
+
if args.action == "list":
|
|
120
|
+
for row in packs.overview(policy, *engine.overlay_dirs()):
|
|
121
|
+
if row["kind"] == kind:
|
|
122
|
+
print(f"{'on ' if row['enabled'] else 'off'} {row['name']:16} {row['description']}")
|
|
123
|
+
return 0
|
|
124
|
+
if not sys.stdin.isatty():
|
|
125
|
+
print(f"REFUSED: changing damage-control {kind}s is a policy change; run it from your own terminal.", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
try:
|
|
128
|
+
packs.set_enabled(policy, kind, args.name or "", args.action == "enable")
|
|
129
|
+
except packs.MembershipError as e:
|
|
130
|
+
print(f"REFUSED: {e}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
print(f"{args.name}: {'enabled' if args.action == 'enable' else 'disabled'}")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run(args: argparse.Namespace) -> int:
|
|
137
|
+
_load_profile(getattr(args, "profile", None))
|
|
138
|
+
profile = getattr(args, "profile", None)
|
|
139
|
+
command = args.command
|
|
140
|
+
if command == "init":
|
|
141
|
+
return install.init(scope=args.scope, settings=args.settings, dry_run=args.dry_run, refresh=args.refresh,
|
|
142
|
+
force=args.force, fail_open=args.fail_open, allow_dev_source=args.allow_dev_source,
|
|
143
|
+
apply=args.apply, profile=profile)
|
|
144
|
+
if command == "status":
|
|
145
|
+
return _print_findings(install.inspect(args.scope, args.settings, profile_hint=profile), args.json)
|
|
146
|
+
if command == "doctor":
|
|
147
|
+
findings = install.inspect(args.scope, args.settings, deep=True, profile_hint=profile)
|
|
148
|
+
code = _print_findings(findings, args.json)
|
|
149
|
+
if args.fix and code:
|
|
150
|
+
print("--fix: re-running init to repair the registration", file=sys.stderr)
|
|
151
|
+
return install.init(scope=args.scope, settings=args.settings, allow_dev_source=args.allow_dev_source,
|
|
152
|
+
profile=profile, force=False)
|
|
153
|
+
return code
|
|
154
|
+
if command == "uninstall":
|
|
155
|
+
return install.uninstall(args.scope, args.settings, purge=args.purge)
|
|
156
|
+
if command == "check":
|
|
157
|
+
return _cmd_check(args)
|
|
158
|
+
if command == "rules":
|
|
159
|
+
return _cmd_rules(args)
|
|
160
|
+
return _cmd_pack_group(args)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
164
|
+
args = build_parser().parse_args(argv)
|
|
165
|
+
try:
|
|
166
|
+
return run(args)
|
|
167
|
+
except (install.InstallError, hc.ConfigError, ProfileError) as e:
|
|
168
|
+
print(f"REFUSED: {e}", file=sys.stderr)
|
|
169
|
+
return 1
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
sys.exit(main())
|