damage-control-dev 0.1.1__tar.gz → 0.1.3__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 (77) hide show
  1. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/PKG-INFO +6 -3
  2. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/README.md +5 -2
  3. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/__init__.py +1 -1
  4. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/cli.py +1 -1
  5. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/engine.py +124 -31
  6. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/core.json +15 -0
  7. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/core.yaml +20 -0
  8. damage_control_dev-0.1.3/tools/check_links.py +85 -0
  9. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/tools/coverage_matrix.py +6 -2
  10. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/.gitignore +0 -0
  11. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/LICENSE +0 -0
  12. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/NOTICE +0 -0
  13. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/pyproject.toml +0 -0
  14. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/__main__.py +0 -0
  15. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/adapter.py +0 -0
  16. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/audit.py +0 -0
  17. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/breakglass.py +0 -0
  18. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/claude_code.py +0 -0
  19. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/groups.py +0 -0
  20. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/harness_config.py +0 -0
  21. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/hook.py +0 -0
  22. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/install.py +0 -0
  23. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/packs.py +0 -0
  24. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/policy.py +0 -0
  25. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/profile.py +0 -0
  26. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/aws.json +0 -0
  27. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/aws.yaml +0 -0
  28. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/cloud-hosting.json +0 -0
  29. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/cloud-hosting.yaml +0 -0
  30. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/containers.json +0 -0
  31. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/containers.yaml +0 -0
  32. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/control-plane.json +0 -0
  33. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/control-plane.yaml +0 -0
  34. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/databases.json +0 -0
  35. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/databases.yaml +0 -0
  36. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/firebase.json +0 -0
  37. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/firebase.yaml +0 -0
  38. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/gcp.json +0 -0
  39. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/gcp.yaml +0 -0
  40. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/git.json +0 -0
  41. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/git.yaml +0 -0
  42. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/infrastructure.json +0 -0
  43. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/infrastructure.yaml +0 -0
  44. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/mcp-tools.json +0 -0
  45. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/mcp-tools.yaml +0 -0
  46. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/outbound.json +0 -0
  47. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/outbound.yaml +0 -0
  48. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/payloads.json +0 -0
  49. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/payloads.yaml +0 -0
  50. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/publish.json +0 -0
  51. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/publish.yaml +0 -0
  52. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/registry.json +0 -0
  53. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/registry.yaml +0 -0
  54. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/remote.json +0 -0
  55. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/remote.yaml +0 -0
  56. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/tmux.json +0 -0
  57. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/rules/tmux.yaml +0 -0
  58. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/ruleset.py +0 -0
  59. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/aws.json +0 -0
  60. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/aws.yaml +0 -0
  61. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/docker.json +0 -0
  62. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/docker.yaml +0 -0
  63. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/gcp.json +0 -0
  64. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/gcp.yaml +0 -0
  65. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/gh.json +0 -0
  66. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/gh.yaml +0 -0
  67. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/git.json +0 -0
  68. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/git.yaml +0 -0
  69. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/kubectl.json +0 -0
  70. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/kubectl.yaml +0 -0
  71. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/npm.json +0 -0
  72. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/npm.yaml +0 -0
  73. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/terraform.json +0 -0
  74. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/terraform.yaml +0 -0
  75. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/uv.json +0 -0
  76. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/src/damage_control/tooldefs/uv.yaml +0 -0
  77. {damage_control_dev-0.1.1 → damage_control_dev-0.1.3}/tools/build_rules_json.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: damage-control-dev
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
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
5
  Project-URL: Homepage, https://github.com/dotdevdotdev/damage-control-dev
6
6
  Project-URL: Source, https://github.com/dotdevdotdev/damage-control-dev
@@ -43,7 +43,7 @@ Supports **Claude Code on macOS and Linux**, Python 3.9 or newer. Nothing else i
43
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
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
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 (core, git, registry, payloads, remote, MCP tools, control plane) and opt-in vendor packs: `aws`, `gcp`, `firebase`, `cloud-hosting`, `databases`, `containers`, `infrastructure`, `publish`, `tmux`. Every rule has a frozen, stable id.
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
47
  - Offline: after `init` nothing touches the network.
48
48
 
49
49
  ```
@@ -65,12 +65,13 @@ See [docs/threat-model.md](docs/threat-model.md).
65
65
 
66
66
  ## Where things live
67
67
 
68
- Everything the installer keeps is under `~/.damage-control/` (or `$DAMAGE_CONTROL_HOME`):
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
69
 
70
70
  - `damagecontrol.yml`: the protected policy (kill switch, `packs:`, `disabled_groups:`)
71
71
  - `damage-control/`, `tooldefs/`: your own rule overlays
72
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
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
74
75
 
75
76
  A policy with no `packs:` key loads the default groups and no vendor packs; an unreadable policy loads everything.
76
77
 
@@ -78,6 +79,8 @@ If the environment breaks (for example a `uv tool install --force` interrupted m
78
79
 
79
80
  ## Docs
80
81
 
82
+ Start at [docs/index.md](docs/index.md).
83
+
81
84
  - [docs/threat-model.md](docs/threat-model.md): what it protects against and the residuals
82
85
  - [docs/coverage.md](docs/coverage.md): which tools are covered and how
83
86
  - [docs/rule-authoring.md](docs/rule-authoring.md): writing rules, packs and overlays
@@ -19,7 +19,7 @@ Supports **Claude Code on macOS and Linux**, Python 3.9 or newer. Nothing else i
19
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
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
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 (core, git, registry, payloads, remote, MCP tools, control plane) and opt-in vendor packs: `aws`, `gcp`, `firebase`, `cloud-hosting`, `databases`, `containers`, `infrastructure`, `publish`, `tmux`. Every rule has a frozen, stable id.
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
23
  - Offline: after `init` nothing touches the network.
24
24
 
25
25
  ```
@@ -41,12 +41,13 @@ See [docs/threat-model.md](docs/threat-model.md).
41
41
 
42
42
  ## Where things live
43
43
 
44
- Everything the installer keeps is under `~/.damage-control/` (or `$DAMAGE_CONTROL_HOME`):
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
45
 
46
46
  - `damagecontrol.yml`: the protected policy (kill switch, `packs:`, `disabled_groups:`)
47
47
  - `damage-control/`, `tooldefs/`: your own rule overlays
48
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
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
50
51
 
51
52
  A policy with no `packs:` key loads the default groups and no vendor packs; an unreadable policy loads everything.
52
53
 
@@ -54,6 +55,8 @@ If the environment breaks (for example a `uv tool install --force` interrupted m
54
55
 
55
56
  ## Docs
56
57
 
58
+ Start at [docs/index.md](docs/index.md).
59
+
57
60
  - [docs/threat-model.md](docs/threat-model.md): what it protects against and the residuals
58
61
  - [docs/coverage.md](docs/coverage.md): which tools are covered and how
59
62
  - [docs/rule-authoring.md](docs/rule-authoring.md): writing rules, packs and overlays
@@ -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.1"
9
+ __version__ = "0.1.3"
@@ -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
@@ -857,7 +857,11 @@ def _path_context_regex(path: str) -> str:
857
857
  return r'["\']?(?:[^\s"\';|&<>]*/)?' + glob_to_regex(path)
858
858
  expanded = os.path.expanduser(path)
859
859
  if expanded == path:
860
- return re.escape(path)
860
+ if path.startswith("/"):
861
+ return re.escape(path)
862
+ # A bare name (`.git/hooks/`, `node_modules/`, `CLAUDE.md`) is a path
863
+ # component: the same entry guards `/repo/.git/hooks/x` as `.git/hooks/x`.
864
+ return r'["\']?(?:[^\s"\';|&<>]*/)?' + re.escape(path)
861
865
  return r'(?:' + re.escape(expanded) + r'|' + re.escape(path) + r')'
862
866
 
863
867
 
@@ -917,7 +921,18 @@ def path_ladder_haystacks(command: str, path: str) -> List[str]:
917
921
  canon = canonicalize_command(token)
918
922
  return canon != token and bool(keep.search(canon))
919
923
 
920
- return masked_subcommands(mask_sed_substitutions(command), keep=_mentions_path)
924
+ # ``~``/``$HOME``/``${HOME}`` name the home dir and ``{a,b}`` expands before the
925
+ # verb runs: the ladders judge those spellings too, as the zero-access scan does.
926
+ spellings = [command]
927
+ canon = canonicalize_command(command)
928
+ if canon != command:
929
+ spellings.append(canon)
930
+ for text in list(spellings):
931
+ spellings.extend(v for v in brace_variants(text) or () if v not in spellings)
932
+ haystacks: List[str] = []
933
+ for text in spellings:
934
+ haystacks += [h for h in masked_subcommands(mask_sed_substitutions(text), keep=_mentions_path) if h not in haystacks]
935
+ return haystacks
921
936
 
922
937
 
923
938
  def check_path_patterns(
@@ -1500,7 +1515,7 @@ def _record_load_failure(
1500
1515
  detail = getattr(exc, "problem", None) or str(exc) or type(exc).__name__
1501
1516
  where = f"{path}:{line}" if line else str(path)
1502
1517
  try:
1503
- print(f"agentwire damage-control: SKIPPED {where}: {detail}", file=sys.stderr)
1518
+ print(f"damage-control: SKIPPED {where}: {detail}", file=sys.stderr)
1504
1519
  except Exception:
1505
1520
  # A dead stderr must not turn a reporting line into a changed verdict.
1506
1521
  pass
@@ -2782,7 +2797,7 @@ def unattended_grant_allows(
2782
2797
  # to a looser layer — see parse_unattended_allow.
2783
2798
  return False, (
2784
2799
  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)"
2800
+ f"so it grants nothing"
2786
2801
  )
2787
2802
  if any(not s for s in scopes):
2788
2803
  return True, f"rule '{rule_id}' is granted unattended (unscoped)"
@@ -5594,11 +5609,11 @@ def _ansi_c_fold(text: str) -> str:
5594
5609
  return _ANSI_C.sub(_dec, text)
5595
5610
 
5596
5611
 
5597
- def _resolve_word(word: str, cwd: str) -> str:
5598
- """``word`` (already unquoted) made absolute when it names a protected file under ``cwd``."""
5612
+ def _resolve_word(word: str, cwd: str, guarded: Callable[[str], bool]) -> str:
5613
+ """``word`` (already unquoted) made absolute when it names a ``guarded`` path under ``cwd``."""
5599
5614
  opt = _OPT_VALUE.fullmatch(word)
5600
5615
  if opt: # of=<file>, --output=<file>, --output-document=<file>, -o=<file>
5601
- return opt.group(1) + _resolve_word(opt.group(2), cwd)
5616
+ return opt.group(1) + _resolve_word(opt.group(2), cwd, guarded)
5602
5617
  if word.startswith("-"):
5603
5618
  return word
5604
5619
  if _PLAIN_WORD.fullmatch(word):
@@ -5606,22 +5621,16 @@ def _resolve_word(word: str, cwd: str) -> str:
5606
5621
  if re.search(r"[*?\[]", word):
5607
5622
  hits = _glob_hits(joined)
5608
5623
  return hits[0] if hits else word
5609
- return joined if is_protected_control_plane(joined) else word
5624
+ return joined if guarded(joined) else word
5610
5625
 
5611
5626
  def _sub(m: "re.Match[str]") -> str: # a word with quotes inside (python -c "open('x')")
5612
5627
  cand = posixpath.normpath(posixpath.join(cwd, m.group(1)))
5613
- return cand if is_protected_control_plane(cand) else m.group(0)
5628
+ return cand if guarded(cand) else m.group(0)
5614
5629
 
5615
5630
  return _REL_WORD.sub(_sub, word)
5616
5631
 
5617
5632
 
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
- """
5633
+ def _absolutize_line(text: str, cwd: str, guarded: Callable[[str], bool]) -> str:
5625
5634
  folded = _ansi_c_fold(text).replace("${PWD}", cwd).replace("$PWD", cwd)
5626
5635
  try:
5627
5636
  lex = shlex.shlex(folded, posix=True, punctuation_chars=True)
@@ -5629,14 +5638,26 @@ def _absolutize(text: str, cwd: str) -> str:
5629
5638
  words = list(lex)
5630
5639
  except ValueError:
5631
5640
  return _REL_WORD.sub(
5632
- lambda m: _resolve_word(m.group(1), cwd), folded
5641
+ lambda m: _resolve_word(m.group(1), cwd, guarded), folded
5633
5642
  )
5634
- resolved = [w if _PUNCT_WORD.fullmatch(w) else _resolve_word(w, cwd) for w in words]
5643
+ resolved = [w if _PUNCT_WORD.fullmatch(w) else _resolve_word(w, cwd, guarded) for w in words]
5635
5644
  if resolved == words and folded == text:
5636
5645
  return text
5637
5646
  return " ".join(w if _PUNCT_WORD.fullmatch(w) else _quote_word(w) for w in resolved)
5638
5647
 
5639
5648
 
5649
+ def _absolutize(text: str, cwd: str, guarded: Callable[[str], bool] = is_protected_control_plane) -> str:
5650
+ """Rewrite words in ``text`` that name a ``guarded`` path under ``cwd`` (default: the control plane).
5651
+
5652
+ The text is first folded through what the shell does to a word before the
5653
+ program sees it — ANSI-C ``$'…'``, ``$PWD``, adjacent quotes, backslashes —
5654
+ so the relative spelling reaches the same check the absolute one does.
5655
+ Each line is rewritten on its own: a newline separates commands, and
5656
+ flattening it would pull a later command's verb out of command position.
5657
+ """
5658
+ return "\n".join(_absolutize_line(line, cwd, guarded) for line in text.split("\n"))
5659
+
5660
+
5640
5661
  _ASSIGN_STMT = _rx(
5641
5662
  r"(?:^|[;&|\n({]|\b(?:then|do|else))[ \t]*(?:(?:export|local|declare|readonly)[ \t]+)?(?P<n>[A-Za-z_]\w*)=(?P<v>(?:'[^']*'|\"[^\"]*\"|[^\s;&|()<>'\"])*)(?=[ \t]*(?:[;&|\n]|$))"
5642
5663
  )
@@ -5938,6 +5959,19 @@ def _alias_target(path: str) -> Optional[str]:
5938
5959
  return None
5939
5960
 
5940
5961
 
5962
+ def _alias_variants(rest: str, cwd: str) -> List[str]:
5963
+ """``rest`` with each relative word that is an existing alias of a protected file spelled as that file."""
5964
+ out: List[str] = []
5965
+ for m in itertools.islice(_PATH_WORD.finditer(rest), _REALPATH_WORD_CAP):
5966
+ word = m.group(0)
5967
+ if word.startswith(("/", "~", "-")) or len(word) < 2:
5968
+ continue
5969
+ target = _alias_target(posixpath.normpath(posixpath.join(cwd, word)))
5970
+ if target:
5971
+ out.append(rest.replace(word, _quote_word(target)))
5972
+ return out
5973
+
5974
+
5941
5975
  def _cwd_realpath_variants(command: str) -> List[str]:
5942
5976
  """``_realpath_variants`` for a relative word after a literal ``cd <dir> &&`` (#1065).
5943
5977
 
@@ -5945,16 +5979,64 @@ def _cwd_realpath_variants(command: str) -> List[str]:
5945
5979
  ``_cwd_variants`` only rewrites words that are lexically protected. The
5946
5980
  aliased word is resolved against the same cwd and judged as what it is.
5947
5981
  """
5982
+ return [new for cwd, rest in _cwd_scopes(command)[0] for new in _alias_variants(rest, cwd)]
5983
+
5984
+
5985
+ def _guarded_path_test(config: Dict[str, Any]) -> Callable[[str], bool]:
5986
+ """Whether an absolute path is guarded by ANY class: control plane, zero-access, read-only, no-delete."""
5987
+ patterns = [
5988
+ p
5989
+ for key in ("zeroAccessPaths", "readOnlyPaths", "noDeletePaths")
5990
+ for p in (config.get(key) or [])
5991
+ if isinstance(p, str)
5992
+ ]
5993
+ # A bare directory entry (`.git/`, `node_modules/`) is a path COMPONENT, not an
5994
+ # absolute prefix: a file inside it is guarded wherever the directory sits, so
5995
+ # `rm hooks/x` run from `<repo>/.git` names a guarded path without saying `.git`.
5996
+ bare_dirs = [
5997
+ "/" + p for p in patterns
5998
+ if p.endswith("/") and not p.startswith(("/", "~")) and not is_glob_pattern(p)
5999
+ ]
6000
+
6001
+ def guarded(path: str) -> bool:
6002
+ if is_protected_control_plane(path) or any(match_path(path, p) for p in patterns):
6003
+ return True
6004
+ fold = str.lower if _casefolds_paths(path) else str
6005
+ probe = fold(path) + "/"
6006
+ return any(fold(d) in probe for d in bare_dirs)
6007
+
6008
+ return guarded
6009
+
6010
+
6011
+ def _before_first_cd(command: str) -> str:
6012
+ """The text that runs in the payload cwd: everything before the first ``cd`` / ``pushd`` / ``popd``."""
6013
+ starts = [m.start() for rx in (_CD_RE, _POPD_RE) for m in itertools.islice(rx.finditer(command), 1)]
6014
+ return command[: min(starts)] if starts else command
6015
+
6016
+
6017
+ def _cwd_resolved_variants(command: str, cwd: Optional[str], config: Dict[str, Any]) -> List[str]:
6018
+ """``command`` with each relative operand that names a guarded path spelled absolutely.
6019
+
6020
+ Relative operands are resolved against the payload ``cwd`` — and its
6021
+ ``realpath``, when the cwd is itself a symlink — for the text that runs
6022
+ there, and against each literal ``cd <dir> &&`` / ``git -C`` / ``env -C``
6023
+ scope for the text that runs in it. The absolute spelling is then judged by
6024
+ the same rules as any other, so ``> .claude/settings.json`` from ``$HOME`` is
6025
+ ``> ~/.claude/settings.json``. A missing cwd adds nothing: what a relative
6026
+ word means is then unknown, which the unverifiable-target verdict owns.
6027
+ """
6028
+ scopes = list(_cwd_scopes(command)[0])
6029
+ if cwd and os.path.isabs(cwd):
6030
+ lead = _before_first_cd(command)
6031
+ scopes += [(base, lead) for base in dict.fromkeys((posixpath.normpath(cwd), os.path.realpath(cwd)))]
6032
+ guarded = _guarded_path_test(config)
5948
6033
  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
6034
+ for base, text in scopes:
6035
+ new = _absolutize(text, base, guarded)
6036
+ if new != text:
6037
+ out.append(new)
6038
+ out.extend(_alias_variants(text, base))
6039
+ return list(dict.fromkeys(out))
5958
6040
 
5959
6041
 
5960
6042
  def _cwd_variants(command: str) -> List[str]:
@@ -9920,7 +10002,7 @@ def audit_best_effort(log: Any) -> Any:
9920
10002
  log(*args, **kwargs)
9921
10003
  except Exception as e:
9922
10004
  try:
9923
- print(f"agentwire: audit log failed ({type(e).__name__}) — decision unchanged",
10005
+ print(f"damage-control: audit log failed ({type(e).__name__}) — decision unchanged",
9924
10006
  file=sys.stderr)
9925
10007
  except Exception:
9926
10008
  pass
@@ -10020,6 +10102,15 @@ def _decide_command(
10020
10102
  outer one.
10021
10103
  """
10022
10104
  verdict = _decide_rules(command, config, cwd)
10105
+ resolved = []
10106
+ if not verdict.get("protected"):
10107
+ # The control plane outranks the escape hatch and the kill switch, so a
10108
+ # relative spelling of it is judged before either can end the walk.
10109
+ for variant in _cwd_resolved_variants(command, cwd, config):
10110
+ inner = _decide_rules(variant, config, cwd)
10111
+ if inner.get("protected"):
10112
+ return dict(inner, command=command)
10113
+ resolved.append(inner)
10023
10114
  if (
10024
10115
  _depth >= _RESCAN_DEPTH
10025
10116
  or verdict.get("protected")
@@ -10028,8 +10119,10 @@ def _decide_command(
10028
10119
  or verdict["decision"] == "block"
10029
10120
  ):
10030
10121
  return verdict
10031
- for hidden in _hidden_commands(command, rules=True):
10032
- inner = _decide_command(hidden, config, cwd, _depth + 1)
10122
+ for inner in itertools.chain(
10123
+ resolved,
10124
+ (_decide_command(hidden, config, cwd, _depth + 1) for hidden in _hidden_commands(command, rules=True)),
10125
+ ):
10033
10126
  if _DECISION_RANK[inner["decision"]] > _DECISION_RANK[verdict["decision"]] and not (
10034
10127
  inner.get("escape") or inner.get("disabled")
10035
10128
  ):
@@ -191,6 +191,11 @@
191
191
  "~/.pypirc",
192
192
  "~/.git-credentials",
193
193
  ".git-credentials",
194
+ "~/.config/gh/hosts.yml",
195
+ "~/.cargo/credentials.toml",
196
+ "~/.cargo/credentials",
197
+ "~/.gem/credentials",
198
+ "~/.terraform.d/credentials.tfrc.json",
194
199
  "dump.sql",
195
200
  "backup.sql",
196
201
  "*.dump"
@@ -209,6 +214,16 @@
209
214
  "~/.zshrc",
210
215
  "~/.profile",
211
216
  "~/.bash_profile",
217
+ "~/.bash_login",
218
+ "~/.bash_logout",
219
+ "~/.zshenv",
220
+ "~/.zprofile",
221
+ "~/.zlogin",
222
+ "~/.zlogout",
223
+ "~/.gitconfig",
224
+ "~/.config/git/",
225
+ ".git/config",
226
+ ".git/hooks/",
212
227
  "package-lock.json",
213
228
  "yarn.lock",
214
229
  "pnpm-lock.yaml",
@@ -255,6 +255,11 @@ zeroAccessPaths:
255
255
  - "~/.pypirc"
256
256
  - "~/.git-credentials"
257
257
  - ".git-credentials"
258
+ - "~/.config/gh/hosts.yml"
259
+ - "~/.cargo/credentials.toml"
260
+ - "~/.cargo/credentials"
261
+ - "~/.gem/credentials"
262
+ - "~/.terraform.d/credentials.tfrc.json"
258
263
 
259
264
  # ---------------------------------------------------------------------------
260
265
  # DATABASE DUMPS (may contain production data)
@@ -292,6 +297,21 @@ readOnlyPaths:
292
297
  - ~/.zshrc
293
298
  - ~/.profile
294
299
  - ~/.bash_profile
300
+ - ~/.bash_login
301
+ - ~/.bash_logout
302
+ - ~/.zshenv
303
+ - ~/.zprofile
304
+ - ~/.zlogin
305
+ - ~/.zlogout
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # GIT CONFIG AND HOOKS (#1255) - keys such as core.fsmonitor, core.pager and
309
+ # alias.x=!... run programs, and a hook runs on the next git operation
310
+ # ---------------------------------------------------------------------------
311
+ - ~/.gitconfig
312
+ - ~/.config/git/
313
+ - .git/config
314
+ - .git/hooks/
295
315
 
296
316
  # ---------------------------------------------------------------------------
297
317
  # LOCK FILES - Never manually edit, use package managers
@@ -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
  "|---|---|---|---|---|---|",