damage-control-dev 0.1.0__tar.gz → 0.1.2__tar.gz

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 (78) hide show
  1. damage_control_dev-0.1.2/PKG-INFO +95 -0
  2. damage_control_dev-0.1.2/README.md +71 -0
  3. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/pyproject.toml +11 -1
  4. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/__init__.py +1 -1
  5. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/cli.py +1 -1
  6. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/engine.py +92 -29
  7. damage_control_dev-0.1.2/tools/check_links.py +85 -0
  8. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/tools/coverage_matrix.py +6 -2
  9. damage_control_dev-0.1.0/PKG-INFO +0 -15
  10. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/.gitignore +0 -0
  11. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/LICENSE +0 -0
  12. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/NOTICE +0 -0
  13. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/__main__.py +0 -0
  14. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/adapter.py +0 -0
  15. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/audit.py +0 -0
  16. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/breakglass.py +0 -0
  17. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/claude_code.py +0 -0
  18. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/groups.py +0 -0
  19. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/harness_config.py +0 -0
  20. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/hook.py +0 -0
  21. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/install.py +0 -0
  22. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/packs.py +0 -0
  23. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/policy.py +0 -0
  24. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/profile.py +0 -0
  25. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/aws.json +0 -0
  26. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/aws.yaml +0 -0
  27. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/cloud-hosting.json +0 -0
  28. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/cloud-hosting.yaml +0 -0
  29. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/containers.json +0 -0
  30. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/containers.yaml +0 -0
  31. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/control-plane.json +0 -0
  32. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/control-plane.yaml +0 -0
  33. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/core.json +0 -0
  34. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/core.yaml +0 -0
  35. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/databases.json +0 -0
  36. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/databases.yaml +0 -0
  37. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/firebase.json +0 -0
  38. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/firebase.yaml +0 -0
  39. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/gcp.json +0 -0
  40. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/gcp.yaml +0 -0
  41. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/git.json +0 -0
  42. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/git.yaml +0 -0
  43. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/infrastructure.json +0 -0
  44. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/infrastructure.yaml +0 -0
  45. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/mcp-tools.json +0 -0
  46. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/mcp-tools.yaml +0 -0
  47. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/outbound.json +0 -0
  48. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/outbound.yaml +0 -0
  49. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/payloads.json +0 -0
  50. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/payloads.yaml +0 -0
  51. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/publish.json +0 -0
  52. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/publish.yaml +0 -0
  53. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/registry.json +0 -0
  54. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/registry.yaml +0 -0
  55. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/remote.json +0 -0
  56. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/remote.yaml +0 -0
  57. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/tmux.json +0 -0
  58. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/rules/tmux.yaml +0 -0
  59. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/ruleset.py +0 -0
  60. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/aws.json +0 -0
  61. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/aws.yaml +0 -0
  62. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/docker.json +0 -0
  63. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/docker.yaml +0 -0
  64. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/gcp.json +0 -0
  65. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/gcp.yaml +0 -0
  66. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/gh.json +0 -0
  67. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/gh.yaml +0 -0
  68. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/git.json +0 -0
  69. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/git.yaml +0 -0
  70. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/kubectl.json +0 -0
  71. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/kubectl.yaml +0 -0
  72. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/npm.json +0 -0
  73. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/npm.yaml +0 -0
  74. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/terraform.json +0 -0
  75. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/terraform.yaml +0 -0
  76. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/uv.json +0 -0
  77. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/src/damage_control/tooldefs/uv.yaml +0 -0
  78. {damage_control_dev-0.1.0 → damage_control_dev-0.1.2}/tools/build_rules_json.py +0 -0
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: damage-control-dev
3
+ Version: 0.1.2
4
+ Summary: Damage control for AI coding agents: a PreToolUse guard that blocks or asks before destructive commands and protected-path writes (Claude Code).
5
+ Project-URL: Homepage, https://github.com/dotdevdotdev/damage-control-dev
6
+ Project-URL: Source, https://github.com/dotdevdotdev/damage-control-dev
7
+ Project-URL: Issues, https://github.com/dotdevdotdev/damage-control-dev/issues
8
+ Project-URL: Security, https://github.com/dotdevdotdev/damage-control-dev/security/policy
9
+ Author-email: dotdev <dev@dotdev.dev>
10
+ License: Apache-2.0
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Keywords: agent,ai,claude-code,guardrails,hooks,safety
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: pyyaml>=6.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # damage-control-dev
26
+
27
+ A guardrail layer for AI coding agents. It runs as a Claude Code `PreToolUse` hook and **blocks or asks before destructive commands, secret access, protected-path writes and risky MCP calls**, before the tool runs.
28
+
29
+ It is a seatbelt, not a sandbox. See [What it is not](#what-it-is-not).
30
+
31
+ ```
32
+ uv tool install damage-control-dev # or: pipx install damage-control-dev
33
+ damage-control-dev init # register the hooks, self-test them, record the install
34
+ damage-control-dev status # read-only drift report
35
+ ```
36
+
37
+ Supports **Claude Code on macOS and Linux**, Python 3.9 or newer. Nothing else is supported or planned.
38
+
39
+ ## What it does
40
+
41
+ - Guards Bash, Edit, Write, Read and MCP tool calls through one policy ladder (allow, ask, block).
42
+ - Re-scans wrapped commands as commands in their own right: `ssh host "..."`, `sh -c`, database clients (`psql -c`, `mysql -e`), `git -c` keys that run programs.
43
+ - Protects its own control plane: the hook scripts, the settings that register them, and its policy file cannot be edited by the agent it guards.
44
+ - Fails closed: any crash or import failure in the hook exits 2 (block), and `init` registers the hook with `onFailure: block` (Claude Code 2.1.295 or newer) so a missing interpreter blocks the call instead of silently allowing it.
45
+ - Unattended mode: when no human can answer a prompt, an `ask` becomes a block unless a rule carries an explicit grant.
46
+ - Rules ship as groups that are on by default (control-plane, core, git, uv, registry, payloads, remote, mcp-tools, outbound) and opt-in vendor packs: `aws`, `gcp`, `firebase`, `cloud-hosting`, `databases`, `containers`, `infrastructure`, `publish`, `tmux`. Every rule has a frozen, stable id.
47
+ - Offline: after `init` nothing touches the network.
48
+
49
+ ```
50
+ damage-control-dev check "git push --force origin main" # what would the hooks decide?
51
+ damage-control-dev rules # list enforced rule ids
52
+ damage-control-dev packs # list packs; enabling is human-terminal only
53
+ damage-control-dev doctor --fix # self-test and repair
54
+ damage-control-dev uninstall # remove exactly our entries
55
+ ```
56
+
57
+ ## What it is not
58
+
59
+ - Not a sandbox, and not a boundary against a determined adversary. A process running as the same OS user can edit anything this tool can.
60
+ - Only as strong as the harness's hook surface: commands you type yourself with `!`, tools that have no hook event, and a hook that times out are outside its reach.
61
+ - No network egress filtering, containment or resource limits.
62
+ - Expect false positives and false negatives. The policy is: ask, then learn.
63
+
64
+ See [docs/threat-model.md](docs/threat-model.md).
65
+
66
+ ## Where things live
67
+
68
+ Everything the installer keeps is under `~/.damage-control/` (or `$DAMAGE_CONTROL_HOME`), except the policy file, which is always `~/.damage-control/damagecontrol.yml`:
69
+
70
+ - `damagecontrol.yml`: the protected policy (kill switch, `packs:`, `disabled_groups:`)
71
+ - `damage-control/`, `tooldefs/`: your own rule overlays
72
+ - `state/install.json`: what `init` recorded (interpreter, ruleset digest, per-scope write hash); `state/backups/` holds first-write backups of your settings file
73
+ - `bin/damage-control-breakglass`: a stdlib-only script that unregisters the hooks if the Python environment is ever broken
74
+ - `logs/damage-control/<date>.jsonl`: the audit log, one JSON line per decision
75
+
76
+ A policy with no `packs:` key loads the default groups and no vendor packs; an unreadable policy loads everything.
77
+
78
+ If the environment breaks (for example a `uv tool install --force` interrupted mid-upgrade) and the hook cannot start, every tool call is blocked by design. Run `python3 ~/.damage-control/bin/damage-control-breakglass unregister` from a normal terminal, then reinstall and run `init` again.
79
+
80
+ ## Docs
81
+
82
+ Start at [docs/index.md](docs/index.md).
83
+
84
+ - [docs/threat-model.md](docs/threat-model.md): what it protects against and the residuals
85
+ - [docs/coverage.md](docs/coverage.md): which tools are covered and how
86
+ - [docs/rule-authoring.md](docs/rule-authoring.md): writing rules, packs and overlays
87
+ - [docs/extending.md](docs/extending.md): profiles, the hook runtime, registration and repair
88
+
89
+ ## Security
90
+
91
+ A bypass of a shipped rule that the threat model says should be caught is a vulnerability. Report it privately, see [SECURITY.md](SECURITY.md).
92
+
93
+ ## License
94
+
95
+ Apache-2.0. Contributions are signed off under the DCO, see [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,71 @@
1
+ # damage-control-dev
2
+
3
+ A guardrail layer for AI coding agents. It runs as a Claude Code `PreToolUse` hook and **blocks or asks before destructive commands, secret access, protected-path writes and risky MCP calls**, before the tool runs.
4
+
5
+ It is a seatbelt, not a sandbox. See [What it is not](#what-it-is-not).
6
+
7
+ ```
8
+ uv tool install damage-control-dev # or: pipx install damage-control-dev
9
+ damage-control-dev init # register the hooks, self-test them, record the install
10
+ damage-control-dev status # read-only drift report
11
+ ```
12
+
13
+ Supports **Claude Code on macOS and Linux**, Python 3.9 or newer. Nothing else is supported or planned.
14
+
15
+ ## What it does
16
+
17
+ - Guards Bash, Edit, Write, Read and MCP tool calls through one policy ladder (allow, ask, block).
18
+ - Re-scans wrapped commands as commands in their own right: `ssh host "..."`, `sh -c`, database clients (`psql -c`, `mysql -e`), `git -c` keys that run programs.
19
+ - Protects its own control plane: the hook scripts, the settings that register them, and its policy file cannot be edited by the agent it guards.
20
+ - Fails closed: any crash or import failure in the hook exits 2 (block), and `init` registers the hook with `onFailure: block` (Claude Code 2.1.295 or newer) so a missing interpreter blocks the call instead of silently allowing it.
21
+ - Unattended mode: when no human can answer a prompt, an `ask` becomes a block unless a rule carries an explicit grant.
22
+ - Rules ship as groups that are on by default (control-plane, core, git, uv, registry, payloads, remote, mcp-tools, outbound) and opt-in vendor packs: `aws`, `gcp`, `firebase`, `cloud-hosting`, `databases`, `containers`, `infrastructure`, `publish`, `tmux`. Every rule has a frozen, stable id.
23
+ - Offline: after `init` nothing touches the network.
24
+
25
+ ```
26
+ damage-control-dev check "git push --force origin main" # what would the hooks decide?
27
+ damage-control-dev rules # list enforced rule ids
28
+ damage-control-dev packs # list packs; enabling is human-terminal only
29
+ damage-control-dev doctor --fix # self-test and repair
30
+ damage-control-dev uninstall # remove exactly our entries
31
+ ```
32
+
33
+ ## What it is not
34
+
35
+ - Not a sandbox, and not a boundary against a determined adversary. A process running as the same OS user can edit anything this tool can.
36
+ - Only as strong as the harness's hook surface: commands you type yourself with `!`, tools that have no hook event, and a hook that times out are outside its reach.
37
+ - No network egress filtering, containment or resource limits.
38
+ - Expect false positives and false negatives. The policy is: ask, then learn.
39
+
40
+ See [docs/threat-model.md](docs/threat-model.md).
41
+
42
+ ## Where things live
43
+
44
+ Everything the installer keeps is under `~/.damage-control/` (or `$DAMAGE_CONTROL_HOME`), except the policy file, which is always `~/.damage-control/damagecontrol.yml`:
45
+
46
+ - `damagecontrol.yml`: the protected policy (kill switch, `packs:`, `disabled_groups:`)
47
+ - `damage-control/`, `tooldefs/`: your own rule overlays
48
+ - `state/install.json`: what `init` recorded (interpreter, ruleset digest, per-scope write hash); `state/backups/` holds first-write backups of your settings file
49
+ - `bin/damage-control-breakglass`: a stdlib-only script that unregisters the hooks if the Python environment is ever broken
50
+ - `logs/damage-control/<date>.jsonl`: the audit log, one JSON line per decision
51
+
52
+ A policy with no `packs:` key loads the default groups and no vendor packs; an unreadable policy loads everything.
53
+
54
+ If the environment breaks (for example a `uv tool install --force` interrupted mid-upgrade) and the hook cannot start, every tool call is blocked by design. Run `python3 ~/.damage-control/bin/damage-control-breakglass unregister` from a normal terminal, then reinstall and run `init` again.
55
+
56
+ ## Docs
57
+
58
+ Start at [docs/index.md](docs/index.md).
59
+
60
+ - [docs/threat-model.md](docs/threat-model.md): what it protects against and the residuals
61
+ - [docs/coverage.md](docs/coverage.md): which tools are covered and how
62
+ - [docs/rule-authoring.md](docs/rule-authoring.md): writing rules, packs and overlays
63
+ - [docs/extending.md](docs/extending.md): profiles, the hook runtime, registration and repair
64
+
65
+ ## Security
66
+
67
+ A bypass of a shipped rule that the threat model says should be caught is a vulnerability. Report it privately, see [SECURITY.md](SECURITY.md).
68
+
69
+ ## License
70
+
71
+ Apache-2.0. Contributions are signed off under the DCO, see [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -2,11 +2,15 @@
2
2
  name = "damage-control-dev"
3
3
  dynamic = ["version"]
4
4
  description = "Damage control for AI coding agents: a PreToolUse guard that blocks or asks before destructive commands and protected-path writes (Claude Code)."
5
+ readme = "README.md"
5
6
  requires-python = ">=3.9"
6
7
  license = { text = "Apache-2.0" }
7
8
  authors = [{ name = "dotdev", email = "dev@dotdev.dev" }]
8
- keywords = ["ai", "agent", "claude-code", "safety", "hooks"]
9
+ keywords = ["ai", "agent", "claude-code", "safety", "hooks", "guardrails"]
9
10
  classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "License :: OSI Approved :: Apache Software License",
13
+ "Intended Audience :: Developers",
10
14
  "Operating System :: MacOS",
11
15
  "Operating System :: POSIX :: Linux",
12
16
  "Programming Language :: Python :: 3",
@@ -16,6 +20,12 @@ classifiers = [
16
20
  # bundled rules ship as JSON, so a hook with no PyYAML still enforces every bundled rule.
17
21
  dependencies = ["pyyaml>=6.0"]
18
22
 
23
+ [project.urls]
24
+ Homepage = "https://github.com/dotdevdotdev/damage-control-dev"
25
+ Source = "https://github.com/dotdevdotdev/damage-control-dev"
26
+ Issues = "https://github.com/dotdevdotdev/damage-control-dev/issues"
27
+ Security = "https://github.com/dotdevdotdev/damage-control-dev/security/policy"
28
+
19
29
  [project.scripts]
20
30
  damage-control-dev = "damage_control.cli:main"
21
31
 
@@ -6,4 +6,4 @@ escape the hook's fail-closed wrapper (a Python crash exits 1, which Claude Code
6
6
  as non-blocking).
7
7
  """
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.1.2"
@@ -1,4 +1,4 @@
1
- """``damage-control-dev``: install, inspect and operate the Claude Code hooks (#1209 M5).
1
+ """``damage-control-dev``: install, inspect and operate the Claude Code hooks.
2
2
 
3
3
  damage-control-dev init [--scope user|project|managed] [--dry-run] [--refresh] [--fail-open]
4
4
  damage-control-dev status [--json] # read-only drift report
@@ -24,7 +24,7 @@ import signal
24
24
  import sys
25
25
  import threading
26
26
  from pathlib import Path
27
- from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple
27
+ from typing import Any, Callable, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple
28
28
 
29
29
  from damage_control import audit
30
30
  from damage_control.profile import GENERIC
@@ -1500,7 +1500,7 @@ def _record_load_failure(
1500
1500
  detail = getattr(exc, "problem", None) or str(exc) or type(exc).__name__
1501
1501
  where = f"{path}:{line}" if line else str(path)
1502
1502
  try:
1503
- print(f"agentwire damage-control: SKIPPED {where}: {detail}", file=sys.stderr)
1503
+ print(f"damage-control: SKIPPED {where}: {detail}", file=sys.stderr)
1504
1504
  except Exception:
1505
1505
  # A dead stderr must not turn a reporting line into a changed verdict.
1506
1506
  pass
@@ -2782,7 +2782,7 @@ def unattended_grant_allows(
2782
2782
  # to a looser layer — see parse_unattended_allow.
2783
2783
  return False, (
2784
2784
  f"rule '{rule_id}' is named in unattended_allow but its entry is malformed, "
2785
- f"so it grants nothing (check `agentwire tasks review` for the reason)"
2785
+ f"so it grants nothing"
2786
2786
  )
2787
2787
  if any(not s for s in scopes):
2788
2788
  return True, f"rule '{rule_id}' is granted unattended (unscoped)"
@@ -5594,11 +5594,11 @@ def _ansi_c_fold(text: str) -> str:
5594
5594
  return _ANSI_C.sub(_dec, text)
5595
5595
 
5596
5596
 
5597
- def _resolve_word(word: str, cwd: str) -> str:
5598
- """``word`` (already unquoted) made absolute when it names a protected file under ``cwd``."""
5597
+ def _resolve_word(word: str, cwd: str, guarded: Callable[[str], bool]) -> str:
5598
+ """``word`` (already unquoted) made absolute when it names a ``guarded`` path under ``cwd``."""
5599
5599
  opt = _OPT_VALUE.fullmatch(word)
5600
5600
  if opt: # of=<file>, --output=<file>, --output-document=<file>, -o=<file>
5601
- return opt.group(1) + _resolve_word(opt.group(2), cwd)
5601
+ return opt.group(1) + _resolve_word(opt.group(2), cwd, guarded)
5602
5602
  if word.startswith("-"):
5603
5603
  return word
5604
5604
  if _PLAIN_WORD.fullmatch(word):
@@ -5606,22 +5606,16 @@ def _resolve_word(word: str, cwd: str) -> str:
5606
5606
  if re.search(r"[*?\[]", word):
5607
5607
  hits = _glob_hits(joined)
5608
5608
  return hits[0] if hits else word
5609
- return joined if is_protected_control_plane(joined) else word
5609
+ return joined if guarded(joined) else word
5610
5610
 
5611
5611
  def _sub(m: "re.Match[str]") -> str: # a word with quotes inside (python -c "open('x')")
5612
5612
  cand = posixpath.normpath(posixpath.join(cwd, m.group(1)))
5613
- return cand if is_protected_control_plane(cand) else m.group(0)
5613
+ return cand if guarded(cand) else m.group(0)
5614
5614
 
5615
5615
  return _REL_WORD.sub(_sub, word)
5616
5616
 
5617
5617
 
5618
- def _absolutize(text: str, cwd: str) -> str:
5619
- """Rewrite words in ``text`` that name a protected file under ``cwd``.
5620
-
5621
- The text is first folded through what the shell does to a word before the
5622
- program sees it — ANSI-C ``$'…'``, ``$PWD``, adjacent quotes, backslashes —
5623
- so the relative spelling reaches the same check the absolute one does.
5624
- """
5618
+ def _absolutize_line(text: str, cwd: str, guarded: Callable[[str], bool]) -> str:
5625
5619
  folded = _ansi_c_fold(text).replace("${PWD}", cwd).replace("$PWD", cwd)
5626
5620
  try:
5627
5621
  lex = shlex.shlex(folded, posix=True, punctuation_chars=True)
@@ -5629,14 +5623,26 @@ def _absolutize(text: str, cwd: str) -> str:
5629
5623
  words = list(lex)
5630
5624
  except ValueError:
5631
5625
  return _REL_WORD.sub(
5632
- lambda m: _resolve_word(m.group(1), cwd), folded
5626
+ lambda m: _resolve_word(m.group(1), cwd, guarded), folded
5633
5627
  )
5634
- resolved = [w if _PUNCT_WORD.fullmatch(w) else _resolve_word(w, cwd) for w in words]
5628
+ resolved = [w if _PUNCT_WORD.fullmatch(w) else _resolve_word(w, cwd, guarded) for w in words]
5635
5629
  if resolved == words and folded == text:
5636
5630
  return text
5637
5631
  return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved)
5638
5632
 
5639
5633
 
5634
+ def _absolutize(text: str, cwd: str, guarded: Callable[[str], bool] = is_protected_control_plane) -> str:
5635
+ """Rewrite words in ``text`` that name a ``guarded`` path under ``cwd`` (default: the control plane).
5636
+
5637
+ The text is first folded through what the shell does to a word before the
5638
+ program sees it — ANSI-C ``$'…'``, ``$PWD``, adjacent quotes, backslashes —
5639
+ so the relative spelling reaches the same check the absolute one does.
5640
+ Each line is rewritten on its own: a newline separates commands, and
5641
+ flattening it would pull a later command's verb out of command position.
5642
+ """
5643
+ return "\n".join(_absolutize_line(line, cwd, guarded) for line in text.split("\n"))
5644
+
5645
+
5640
5646
  _ASSIGN_STMT = _rx(
5641
5647
  r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P<n>[A-Za-z_]\w*)=(?P<v>(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))"
5642
5648
  )
@@ -5938,6 +5944,19 @@ def _alias_target(path: str) -> Optional[str]:
5938
5944
  return None
5939
5945
 
5940
5946
 
5947
+ def _alias_variants(rest: str, cwd: str) -> List[str]:
5948
+ """``rest`` with each relative word that is an existing alias of a protected file spelled as that file."""
5949
+ out: List[str] = []
5950
+ for m in itertools.islice(_PATH_WORD.finditer(rest), _REALPATH_WORD_CAP):
5951
+ word = m.group(0)
5952
+ if word.startswith(("/", "~", "-")) or len(word) < 2:
5953
+ continue
5954
+ target = _alias_target(posixpath.normpath(posixpath.join(cwd, word)))
5955
+ if target:
5956
+ out.append(rest.replace(word, _quote_word(target)))
5957
+ return out
5958
+
5959
+
5941
5960
  def _cwd_realpath_variants(command: str) -> List[str]:
5942
5961
  """``_realpath_variants`` for a relative word after a literal ``cd <dir> &&`` (#1065).
5943
5962
 
@@ -5945,16 +5964,49 @@ def _cwd_realpath_variants(command: str) -> List[str]:
5945
5964
  ``_cwd_variants`` only rewrites words that are lexically protected. The
5946
5965
  aliased word is resolved against the same cwd and judged as what it is.
5947
5966
  """
5967
+ return [new for cwd, rest in _cwd_scopes(command)[0] for new in _alias_variants(rest, cwd)]
5968
+
5969
+
5970
+ def _guarded_path_test(config: Dict[str, Any]) -> Callable[[str], bool]:
5971
+ """Whether an absolute path is guarded by ANY class: control plane, zero-access, read-only, no-delete."""
5972
+ patterns = [
5973
+ p
5974
+ for key in ("zeroAccessPaths", "readOnlyPaths", "noDeletePaths")
5975
+ for p in (config.get(key) or [])
5976
+ if isinstance(p, str)
5977
+ ]
5978
+ return lambda path: is_protected_control_plane(path) or any(match_path(path, p) for p in patterns)
5979
+
5980
+
5981
+ def _before_first_cd(command: str) -> str:
5982
+ """The text that runs in the payload cwd: everything before the first ``cd`` / ``pushd`` / ``popd``."""
5983
+ starts = [m.start() for rx in (_CD_RE, _POPD_RE) for m in itertools.islice(rx.finditer(command), 1)]
5984
+ return command[: min(starts)] if starts else command
5985
+
5986
+
5987
+ def _cwd_resolved_variants(command: str, cwd: Optional[str], config: Dict[str, Any]) -> List[str]:
5988
+ """``command`` with each relative operand that names a guarded path spelled absolutely.
5989
+
5990
+ Relative operands are resolved against the payload ``cwd`` — and its
5991
+ ``realpath``, when the cwd is itself a symlink — for the text that runs
5992
+ there, and against each literal ``cd <dir> &&`` / ``git -C`` / ``env -C``
5993
+ scope for the text that runs in it. The absolute spelling is then judged by
5994
+ the same rules as any other, so ``> .claude/settings.json`` from ``$HOME`` is
5995
+ ``> ~/.claude/settings.json``. A missing cwd adds nothing: what a relative
5996
+ word means is then unknown, which the unverifiable-target verdict owns.
5997
+ """
5998
+ scopes = list(_cwd_scopes(command)[0])
5999
+ if cwd and os.path.isabs(cwd):
6000
+ lead = _before_first_cd(command)
6001
+ scopes += [(base, lead) for base in dict.fromkeys((posixpath.normpath(cwd), os.path.realpath(cwd)))]
6002
+ guarded = _guarded_path_test(config)
5948
6003
  out: List[str] = []
5949
- for cwd, rest in _cwd_scopes(command)[0]:
5950
- for m in itertools.islice(_PATH_WORD.finditer(rest), _REALPATH_WORD_CAP):
5951
- word = m.group(0)
5952
- if word.startswith(("/", "~", "-")) or len(word) < 2:
5953
- continue
5954
- target = _alias_target(posixpath.normpath(posixpath.join(cwd, word)))
5955
- if target:
5956
- out.append(rest.replace(word, _quote_word(target)))
5957
- return out
6004
+ for base, text in scopes:
6005
+ new = _absolutize(text, base, guarded)
6006
+ if new != text:
6007
+ out.append(new)
6008
+ out.extend(_alias_variants(text, base))
6009
+ return list(dict.fromkeys(out))
5958
6010
 
5959
6011
 
5960
6012
  def _cwd_variants(command: str) -> List[str]:
@@ -9920,7 +9972,7 @@ def audit_best_effort(log: Any) -> Any:
9920
9972
  log(*args, **kwargs)
9921
9973
  except Exception as e:
9922
9974
  try:
9923
- print(f"agentwire: audit log failed ({type(e).__name__}) — decision unchanged",
9975
+ print(f"damage-control: audit log failed ({type(e).__name__}) — decision unchanged",
9924
9976
  file=sys.stderr)
9925
9977
  except Exception:
9926
9978
  pass
@@ -10020,6 +10072,15 @@ def _decide_command(
10020
10072
  outer one.
10021
10073
  """
10022
10074
  verdict = _decide_rules(command, config, cwd)
10075
+ resolved = []
10076
+ if not verdict.get("protected"):
10077
+ # The control plane outranks the escape hatch and the kill switch, so a
10078
+ # relative spelling of it is judged before either can end the walk.
10079
+ for variant in _cwd_resolved_variants(command, cwd, config):
10080
+ inner = _decide_rules(variant, config)
10081
+ if inner.get("protected"):
10082
+ return dict(inner, command=command)
10083
+ resolved.append(inner)
10023
10084
  if (
10024
10085
  _depth >= _RESCAN_DEPTH
10025
10086
  or verdict.get("protected")
@@ -10028,8 +10089,10 @@ def _decide_command(
10028
10089
  or verdict["decision"] == "block"
10029
10090
  ):
10030
10091
  return verdict
10031
- for hidden in _hidden_commands(command, rules=True):
10032
- inner = _decide_command(hidden, config, cwd, _depth + 1)
10092
+ for inner in itertools.chain(
10093
+ resolved,
10094
+ (_decide_command(hidden, config, cwd, _depth + 1) for hidden in _hidden_commands(command, rules=True)),
10095
+ ):
10033
10096
  if _DECISION_RANK[inner["decision"]] > _DECISION_RANK[verdict["decision"]] and not (
10034
10097
  inner.get("escape") or inner.get("disabled")
10035
10098
  ):
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """Fail if a relative link in the repository's Markdown files does not resolve.
3
+
4
+ Checks ``[text](target)`` links in README.md, SECURITY.md, CONTRIBUTING.md and docs/*.md:
5
+ the target file must exist, and a ``#fragment`` must match a heading in that file (GitHub's
6
+ slug rules) or an explicit ``<a id=...>``. External ``http(s)``/``mailto`` links are not fetched.
7
+
8
+ check_links.py # exit 1 and list every broken link
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import List, Set
17
+
18
+ ROOT = Path(__file__).resolve().parent.parent
19
+ LINK_RE = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)\)")
20
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
21
+ ANCHOR_RE = re.compile(r'<a\s+(?:id|name)="([^"]+)"')
22
+
23
+
24
+ def slug(heading: str) -> str:
25
+ """GitHub's heading anchor: lowercase, drop punctuation except ``-`` and ``_``, spaces to ``-``."""
26
+ text = re.sub(r"`", "", heading.strip().lower())
27
+ text = re.sub(r"[^\w\- ]", "", text)
28
+ return text.replace(" ", "-")
29
+
30
+
31
+ def anchors(path: Path) -> Set[str]:
32
+ found: Set[str] = set()
33
+ counts: dict = {}
34
+ in_fence = False
35
+ for line in path.read_text().splitlines():
36
+ if line.startswith("```"):
37
+ in_fence = not in_fence
38
+ continue
39
+ if in_fence:
40
+ continue
41
+ m = HEADING_RE.match(line)
42
+ if m:
43
+ base = slug(m.group(2))
44
+ n = counts.get(base, 0)
45
+ counts[base] = n + 1
46
+ found.add(base if n == 0 else f"{base}-{n}")
47
+ found.update(ANCHOR_RE.findall(line))
48
+ return found
49
+
50
+
51
+ def markdown_files() -> List[Path]:
52
+ return sorted([*ROOT.glob("*.md"), *ROOT.glob("docs/*.md")])
53
+
54
+
55
+ def check(path: Path) -> List[str]:
56
+ problems: List[str] = []
57
+ in_fence = False
58
+ for number, line in enumerate(path.read_text().splitlines(), 1):
59
+ if line.startswith("```"):
60
+ in_fence = not in_fence
61
+ continue
62
+ if in_fence:
63
+ continue
64
+ for target in LINK_RE.findall(line):
65
+ if re.match(r"[a-z][a-z0-9+.-]*:", target):
66
+ continue
67
+ name, _, fragment = target.partition("#")
68
+ dest = (path.parent / name).resolve() if name else path
69
+ where = f"{path.relative_to(ROOT)}:{number}"
70
+ if not dest.exists():
71
+ problems.append(f"{where}: {target}: no such file")
72
+ elif fragment and dest.suffix == ".md" and fragment not in anchors(dest):
73
+ problems.append(f"{where}: {target}: no heading '#{fragment}' in {dest.relative_to(ROOT)}")
74
+ return problems
75
+
76
+
77
+ def main() -> int:
78
+ problems = [p for f in markdown_files() for p in check(f)]
79
+ for problem in problems:
80
+ print(problem, file=sys.stderr)
81
+ return 1 if problems else 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ sys.exit(main())
@@ -30,7 +30,7 @@ def render() -> str:
30
30
  for matcher, hook in claude_code.MATCHERS.items():
31
31
  tools, shell = adapter.HOOK_KINDS[hook]
32
32
  if tools is None:
33
- kind, judges = "mcp", "the tool name and its path-valued arguments, plus a synthesized shell command for outbound tools"
33
+ kind, judges = "mcp", "the tool name and its path-valued arguments, plus, where a host profile maps the tool, the shell command it runs"
34
34
  else:
35
35
  names = [t for t, (_, _) in tools.items() if matcher == "mcp__.*" or t == matcher]
36
36
  kind = tools[names[0]][0] if names else "?"
@@ -42,7 +42,11 @@ def render() -> str:
42
42
  return "\n".join([
43
43
  "# Coverage",
44
44
  "",
45
- "Generated by `tools/coverage_matrix.py` from the hooks' own tables; `--check` fails if it is stale.",
45
+ "Which Claude Code tools damage control judges, and how. Anything not listed is not judged; see the",
46
+ "[threat model](threat-model.md#what-it-does-not-protect-against).",
47
+ "",
48
+ "This page is generated by `tools/coverage_matrix.py` from the hooks' own tables; do not edit it by hand.",
49
+ "CI fails if the committed file is stale. Regenerate with `python tools/coverage_matrix.py`.",
46
50
  "",
47
51
  "| Tool matcher | Hook | Judged on | Block | Ask | Audited |",
48
52
  "|---|---|---|---|---|---|",
@@ -1,15 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: damage-control-dev
3
- Version: 0.1.0
4
- Summary: Damage control for AI coding agents: a PreToolUse guard that blocks or asks before destructive commands and protected-path writes (Claude Code).
5
- Author-email: dotdev <dev@dotdev.dev>
6
- License: Apache-2.0
7
- License-File: LICENSE
8
- License-File: NOTICE
9
- Keywords: agent,ai,claude-code,hooks,safety
10
- Classifier: Operating System :: MacOS
11
- Classifier: Operating System :: POSIX :: Linux
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.9
14
- Requires-Python: >=3.9
15
- Requires-Dist: pyyaml>=6.0