claude-dev-env 1.93.0 → 1.94.0

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 (220) hide show
  1. package/_shared/pr-loop/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/audit-contract.md +1 -1
  3. package/_shared/pr-loop/gh-payloads.md +3 -3
  4. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  5. package/_shared/pr-loop/scripts/README.md +2 -2
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
  7. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  9. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
  10. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  11. package/_shared/pr-loop/state-schema.md +3 -22
  12. package/bin/CLAUDE.md +1 -1
  13. package/bin/install.mjs +2 -7
  14. package/bin/install.test.mjs +10 -4
  15. package/hooks/blocking/CLAUDE.md +2 -0
  16. package/hooks/blocking/pii_commit_command.py +398 -0
  17. package/hooks/blocking/pii_payload_scan.py +133 -0
  18. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  19. package/hooks/blocking/pii_scanner.py +26 -1
  20. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  21. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  22. package/hooks/blocking/test_pii_scanner.py +27 -2
  23. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  24. package/hooks/hooks_constants/CLAUDE.md +2 -2
  25. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  26. package/hooks/hooks_constants/local_identity.py +65 -15
  27. package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
  28. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  29. package/hooks/hooks_constants/test_local_identity.py +68 -0
  30. package/package.json +1 -1
  31. package/scripts/CLAUDE.md +10 -1
  32. package/scripts/Get-SessionAccount.ps1 +210 -0
  33. package/scripts/tests/CLAUDE.md +15 -1
  34. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  35. package/skills/CLAUDE.md +3 -25
  36. package/skills/anthropic-plan/SKILL.md +1 -13
  37. package/skills/autoconverge/CLAUDE.md +5 -2
  38. package/skills/autoconverge/SKILL.md +112 -387
  39. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  40. package/skills/autoconverge/reference/closing-report.md +9 -2
  41. package/skills/autoconverge/reference/convergence.md +26 -22
  42. package/skills/autoconverge/reference/headless-safety.md +44 -0
  43. package/skills/autoconverge/reference/multi-pr.md +88 -0
  44. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  45. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  46. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  47. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  49. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  50. package/skills/autoconverge/workflow/converge.mjs +284 -94
  51. package/skills/bugteam/CLAUDE.md +1 -1
  52. package/skills/bugteam/CONSTRAINTS.md +3 -3
  53. package/skills/bugteam/PROMPTS.md +8 -19
  54. package/skills/bugteam/SKILL.md +13 -14
  55. package/skills/bugteam/reference/CLAUDE.md +1 -1
  56. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  57. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  58. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  59. package/skills/bugteam/reference/team-setup.md +11 -11
  60. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  61. package/skills/bugteam/scripts/README.md +20 -8
  62. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  63. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  64. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  65. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  66. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  67. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  68. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  69. package/skills/copilot-finding-triage/SKILL.md +80 -28
  70. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  71. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  72. package/skills/everything-search/SKILL.md +1 -2
  73. package/skills/grokify/SKILL.md +58 -0
  74. package/skills/grokify/templates/handoff-template.md +92 -0
  75. package/skills/orchestrator/SKILL.md +12 -8
  76. package/skills/orchestrator-refresh/SKILL.md +3 -3
  77. package/skills/pr-converge/SKILL.md +132 -233
  78. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  79. package/skills/pr-converge/reference/examples.md +37 -25
  80. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  81. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  82. package/skills/pr-converge/reference/per-tick.md +64 -41
  83. package/skills/pr-converge/reference/state-schema.md +27 -2
  84. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  85. package/skills/pr-converge/scripts/README.md +4 -3
  86. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  87. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  88. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  89. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  90. package/skills/pr-converge/scripts/conftest.py +8 -0
  91. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  92. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  93. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  94. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  95. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  96. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  97. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  98. package/skills/privacy-hygiene/SKILL.md +8 -7
  99. package/skills/reviewer-gates/SKILL.md +26 -8
  100. package/skills/session-log/SKILL.md +11 -12
  101. package/skills/session-tidy/SKILL.md +40 -30
  102. package/skills/skill-builder/CLAUDE.md +13 -10
  103. package/skills/skill-builder/SKILL.md +31 -31
  104. package/skills/skill-builder/references/CLAUDE.md +10 -6
  105. package/skills/skill-builder/references/delegation-map.md +13 -4
  106. package/skills/skill-builder/references/description-field.md +113 -0
  107. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  108. package/skills/skill-builder/references/skill-modularity.md +113 -0
  109. package/skills/skill-builder/references/skill-types.md +4 -0
  110. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  111. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  112. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  113. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  114. package/skills/skill-builder/workflows/new-skill.md +34 -10
  115. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  116. package/skills/usage-pause/SKILL.md +10 -14
  117. package/skills/bdd-protocol/CLAUDE.md +0 -26
  118. package/skills/bdd-protocol/SKILL.md +0 -30
  119. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  120. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  121. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  122. package/skills/bg-agent/CLAUDE.md +0 -17
  123. package/skills/bg-agent/SKILL.md +0 -69
  124. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  125. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  127. package/skills/caveman/CLAUDE.md +0 -15
  128. package/skills/caveman/SKILL.md +0 -38
  129. package/skills/code/CLAUDE.md +0 -17
  130. package/skills/code/SKILL.md +0 -178
  131. package/skills/deep-research/CLAUDE.md +0 -17
  132. package/skills/deep-research/SKILL.md +0 -80
  133. package/skills/findbugs/CLAUDE.md +0 -20
  134. package/skills/findbugs/SKILL.md +0 -241
  135. package/skills/fixbugs/CLAUDE.md +0 -19
  136. package/skills/fixbugs/SKILL.md +0 -142
  137. package/skills/gh-paginate/CLAUDE.md +0 -18
  138. package/skills/gh-paginate/SKILL.md +0 -84
  139. package/skills/gotcha/CLAUDE.md +0 -33
  140. package/skills/gotcha/SKILL.md +0 -73
  141. package/skills/implement/CLAUDE.md +0 -27
  142. package/skills/implement/SKILL.md +0 -66
  143. package/skills/implement/scripts/CLAUDE.md +0 -22
  144. package/skills/implement/scripts/append_note.py +0 -133
  145. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  146. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  147. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  148. package/skills/implement/scripts/test_append_note.py +0 -191
  149. package/skills/log-audit/CLAUDE.md +0 -20
  150. package/skills/log-audit/SKILL.md +0 -66
  151. package/skills/log-audit/reference/CLAUDE.md +0 -9
  152. package/skills/log-audit/reference/charter.md +0 -52
  153. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  154. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  155. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  156. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  157. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  158. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  159. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  160. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  161. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  162. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  163. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  164. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  165. package/skills/logifix/CLAUDE.md +0 -36
  166. package/skills/logifix/SKILL.md +0 -69
  167. package/skills/logifix/scripts/CLAUDE.md +0 -16
  168. package/skills/logifix/scripts/logifix.ps1 +0 -205
  169. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  170. package/skills/monitor-open-prs/SKILL.md +0 -88
  171. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  172. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  173. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  174. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  175. package/skills/post-audit-findings/SKILL.md +0 -80
  176. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  177. package/skills/pr-consistency-audit/SKILL.md +0 -116
  178. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  179. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  180. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  181. package/skills/pr-fix-protocol/SKILL.md +0 -65
  182. package/skills/pr-review-responder/CLAUDE.md +0 -35
  183. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  184. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  185. package/skills/pr-review-responder/README.md +0 -168
  186. package/skills/pr-review-responder/SKILL.md +0 -203
  187. package/skills/pr-review-responder/TESTING.md +0 -350
  188. package/skills/pr-scope-resolve/SKILL.md +0 -45
  189. package/skills/pre-compact/CLAUDE.md +0 -24
  190. package/skills/pre-compact/SKILL.md +0 -134
  191. package/skills/qbug/CLAUDE.md +0 -40
  192. package/skills/qbug/SKILL.md +0 -387
  193. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  194. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  195. package/skills/refine/CLAUDE.md +0 -44
  196. package/skills/refine/SKILL.md +0 -262
  197. package/skills/refine/templates/CLAUDE.md +0 -17
  198. package/skills/refine/templates/implementation-notes-template.html +0 -56
  199. package/skills/refine/templates/plan-template.md +0 -60
  200. package/skills/research-mode/CLAUDE.md +0 -35
  201. package/skills/research-mode/SKILL.md +0 -53
  202. package/skills/structure-prompt/CLAUDE.md +0 -42
  203. package/skills/structure-prompt/SKILL.md +0 -44
  204. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  205. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  206. package/skills/structure-prompt/reference/block-classification.md +0 -27
  207. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  208. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  209. package/skills/structure-prompt/reference/cleanup.md +0 -33
  210. package/skills/structure-prompt/reference/constraints.md +0 -33
  211. package/skills/structure-prompt/reference/directives.md +0 -37
  212. package/skills/structure-prompt/reference/examples.md +0 -72
  213. package/skills/structure-prompt/reference/instantiation.md +0 -51
  214. package/skills/structure-prompt/reference/output-contract.md +0 -72
  215. package/skills/structure-prompt/reference/per-category.md +0 -23
  216. package/skills/structure-prompt/reference/persona.md +0 -38
  217. package/skills/structure-prompt/reference/research.md +0 -33
  218. package/skills/structure-prompt/reference/structure.md +0 -28
  219. package/skills/verified-build/CLAUDE.md +0 -33
  220. package/skills/verified-build/SKILL.md +0 -33
@@ -31,26 +31,7 @@ Adds nothing beyond common. Single subagent loops internally and returns a final
31
31
 
32
32
  ### pr-converge
33
33
 
34
- Adds the same **traffic** fields whether they live in **`state.json`** or in the **conversation state line**; only the **store** differs.
35
-
36
- | Field | Type | Purpose |
37
- |---|---|---|
38
- | `phase` | enum | `BUGBOT`, `BUGTEAM` — which reviewer the current tick drives |
39
- | `current_head` | str | PR `.head.sha` / `git rev-parse` for the PR under work (each tick; from `pull_request_read(method="get")` when no file store) |
40
- | `bugbot_clean_at` | str \| null | HEAD SHA at which Cursor Bugbot last reported clean, or `null` (reset on every push) |
41
- | `copilot_clean_at` | str \| null | HEAD SHA at which the GitHub Copilot reviewer (`copilot-pull-request-reviewer[bot]`) last reported clean (review `state == "APPROVED"`), or `null`. Reset on every push. Convergence gates require this equals `current_head` after bugbot+bugteam are clean (see `skills/pr-converge/SKILL.md` § Convergence gates). |
42
- | `merge_state_status` | str \| null | Last-observed `mergeable_state` from `pull_request_read(method="get")` (e.g., `clean`, `dirty`, `blocked`, `behind`, `unknown`), or `null` before the first check. Reset on every push. `dirty` triggers the rebase invocation; non-`clean` non-`dirty` is a hard blocker per pr-converge `Stop conditions`. |
43
- | `inline_lag_streak` | int | Consecutive ticks where bugbot's review body claims findings but inline-comments API returns zero rows for `current_head` |
44
- | `tick_count` | int | Observability only — **no ceiling**; loop ends on convergence or **Stop conditions** in `pr-converge` |
45
-
46
- **Dual persistence** (normative: `skills/pr-converge/SKILL.md` § State across ticks, § Multi-PR orchestration model):
47
-
48
- | Mode | When it applies | Source of truth | `tick_count` bump |
49
- |---|---|---|---|
50
- | **`state.json`** | File exists at `<TMPDIR>/pr-converge-<session_id>/state.json` (multi-PR orchestration or other file-backed session) | JSON: top-level `session_id`; per-PR objects under `prs[<number>]` with `owner`, `repo`, `branch`, `phase`, `current_head`, `bugbot_clean_at`, `inline_lag_streak`, `tick_count`, `last_action`, `status`, `last_updated`. Optional sibling `converged.log` (append-only; multi-PR only). Writes use lock + atomic replace per skill **Concurrency** | **Orchestrator only** at tick start (locked merge for every non-terminal PR); **never** bump `tick_count` in Step 1 when this file is in use |
51
- | **Conversation state line** | **No** `state.json` (typical single-PR `/pr-converge` in Cursor) | Persist **`phase`**, **`bugbot_clean_at`**, **`inline_lag_streak`**, **`tick_count`** as **plain text** in each assistant turn; next tick reads them from the **most recent assistant message**. **`current_head` is not serialized in that line** — re-resolve each tick via `pull_request_read(method="get")` (same contract as `skills/pr-converge/SKILL.md` § State across ticks). | **Step 1** increments `tick_count` in that line **only** when no `state.json` — must not double-count with any file-backed path |
52
-
53
- **`status` (file-backed `prs[...]` only):** `fresh | in_progress | awaiting_bugbot | awaiting_bugteam | converged | blocked`
34
+ Normative field list, phase enum, dual persistence, and reset semantics: [`../../skills/pr-converge/reference/state-schema.md`](../../skills/pr-converge/reference/state-schema.md). File-backed multi-PR `status` enum: [`../../skills/pr-converge/reference/multi-pr-orchestration.md`](../../skills/pr-converge/reference/multi-pr-orchestration.md).
54
35
 
55
36
  ### monitor-many
56
37
 
@@ -71,11 +52,11 @@ Adds per-PR JSON state file at `~/.claude/skills/monitor-many/state/<owner>-<rep
71
52
 
72
53
  - bugteam: cleared on each new `/bugteam` invocation
73
54
  - qbug: cleared on each new `/qbug` invocation
74
- - pr-converge: `bugbot_clean_at`, `copilot_clean_at`, and `merge_state_status` all reset to `null` on every push (a new commit invalidates every reviewer's prior clean and the prior mergeability snapshot by definition); `phase` cycles each tick. With `state.json`, orchestrator reads that file at tick start; without it, rely on the prior conversation state line — **never** mix both increment rules for `tick_count` on the same run
55
+ - pr-converge: see [`../../skills/pr-converge/reference/state-schema.md`](../../skills/pr-converge/reference/state-schema.md)
75
56
  - monitor-many: persists across orchestrator runs; only `last_seen_comment_id` advances monotonically
76
57
 
77
58
  ## Convergence checks
78
59
 
79
60
  - bugteam, qbug: `last_action == "audited"` AND `last_findings.total == 0` → `converged`
80
- - pr-converge: `bugbot_clean_at == current_head` AND most-recent bugteam exit is `converged` AND no push during the bugteam tick AND no outstanding Copilot findings on `current_head` AND `merge_state_status == "clean"` (per `skills/pr-converge/SKILL.md` § Convergence gates) → back-to-back clean → `update_pull_request(draft=false)` (read `current_head` / `bugbot_clean_at` / `copilot_clean_at` / `merge_state_status` from `state.json` when file-backed, else from the conversation state line and Step 1 `pull_request_read(method="get")` output)
61
+ - pr-converge: see [`../../skills/pr-converge/reference/convergence-gates.md`](../../skills/pr-converge/reference/convergence-gates.md)
81
62
  - monitor-many: no unresolved comments requiring code changes AND required checks green AND review policy satisfied → `gh pr ready`
package/bin/CLAUDE.md CHANGED
@@ -26,4 +26,4 @@ The installer and its companion modules. Running `npx claude-dev-env` (or `node
26
26
 
27
27
  ## Install groups
28
28
 
29
- `install.mjs` defines install groups (`core`, `journal`, `research`) plus any dependency groups discovered from `package.json` `dependencies`. The `core` group installs skills, all hooks, and the content directories. `journal` and `research` install only their skill sets.
29
+ `install.mjs` defines install groups (`core`, `journal`) plus any dependency groups discovered from `package.json` `dependencies`. The `core` group installs skills, all hooks, and the content directories. `journal` installs only its skill set.
package/bin/install.mjs CHANGED
@@ -152,11 +152,10 @@ const INSTALL_GROUPS = {
152
152
  core: {
153
153
  description: 'Development standards, hooks, agents, commands',
154
154
  skills: [
155
- 'orchestrator', 'orchestrator-refresh', 'team-advisor',
155
+ 'orchestrator', 'orchestrator-refresh', 'team-advisor', 'grokify',
156
156
  'anthropic-plan', 'everything-search',
157
- 'pr-review-responder',
158
157
  'privacy-hygiene',
159
- 'recall', 'remember', 'task-build', 'verified-build'
158
+ 'recall', 'remember', 'task-build'
160
159
  ],
161
160
  includeDirectories: CORE_INCLUDE_DIRECTORIES,
162
161
  includeAllHooks: true,
@@ -165,10 +164,6 @@ const INSTALL_GROUPS = {
165
164
  description: 'Session logging and memory',
166
165
  skills: ['session-log', 'session-tidy'],
167
166
  },
168
- research: {
169
- description: 'Deep research and citation tools',
170
- skills: ['deep-research', 'research-mode'],
171
- },
172
167
  ...discoverDependencyGroups(),
173
168
  };
174
169
 
@@ -637,6 +637,12 @@ test('commandReferencesManagedHook leaves an unmanaged inline -c command that im
637
637
  });
638
638
 
639
639
 
640
+
641
+ function isPreToolUseDispatcherCommand(command) {
642
+ // Basename-anchored match: bash_pre_tool_use_dispatcher.py must not count.
643
+ return /(?:^|[/\\])pre_tool_use_dispatcher\.py(?![A-Za-z0-9_])/.test(command);
644
+ }
645
+
640
646
  function countManagedRunAllValidatorsHooks(settings) {
641
647
  const writeEditGroups = (settings.hooks.PreToolUse || []).filter(
642
648
  group => group.matcher === 'Write|Edit'
@@ -996,7 +1002,7 @@ test('mergeHooksIntoSettings into old folded-hooks settings yields exactly one d
996
1002
  const allPreToolUseGroups = settings.hooks.PreToolUse || [];
997
1003
  const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
998
1004
 
999
- const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
1005
+ const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
1000
1006
  assert.equal(allDispatcherCommands.length, 1, 'exactly one dispatcher entry must be present');
1001
1007
 
1002
1008
  for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
@@ -1029,7 +1035,7 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
1029
1035
  const allPreToolUseGroups = settings.hooks.PreToolUse || [];
1030
1036
  const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
1031
1037
 
1032
- const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
1038
+ const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
1033
1039
  assert.equal(allDispatcherCommands.length, 1, 'dispatcher must appear exactly once after two merges');
1034
1040
  assert.equal(countManagedRunAllValidatorsHooks(settings), 1, 'run_all_validators must appear exactly once after two merges');
1035
1041
  });
@@ -1042,7 +1048,7 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
1042
1048
 
1043
1049
  const allPreToolUseGroups = shippedHooksConfig.hooks.PreToolUse || [];
1044
1050
  const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
1045
- const preDispatcherCommands = allPreCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
1051
+ const preDispatcherCommands = allPreCommands.filter(isPreToolUseDispatcherCommand);
1046
1052
  assert.equal(preDispatcherCommands.length, 1, 'shipped hooks.json must register the PreToolUse dispatcher exactly once');
1047
1053
 
1048
1054
  assert.equal(
@@ -1222,6 +1228,6 @@ test('mergeHooksIntoSettings prunes the inline run_all_validators runner when th
1222
1228
  }
1223
1229
 
1224
1230
  const dispatcherGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit|MultiEdit');
1225
- const dispatcherCommands = dispatcherGroup.hooks.filter(hook => hook.command.includes('pre_tool_use_dispatcher.py'));
1231
+ const dispatcherCommands = dispatcherGroup.hooks.filter(hook => isPreToolUseDispatcherCommand(hook.command));
1226
1232
  assert.equal(dispatcherCommands.length, 1, 'the PreToolUse dispatcher must remain exactly once');
1227
1233
  });
@@ -81,6 +81,8 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
81
81
  | `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
82
82
  | `nas_ssh_binary_enforcer.py` | PreToolUse (Bash) | A bare `ssh`/`scp`/`sftp` command word targeting the NAS (Git Bash's MSYS ssh stalls on an interactive password prompt), or the full `System32/OpenSSH` binary to that host without `-o BatchMode=yes` |
83
83
  | `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
84
+ | `pii_commit_command.py` | library | Token-aware git-commit detection reused by `pii_prevention_blocker.py` |
85
+ | `pii_payload_scan.py` | library | Write/Edit and durable post-body PII evaluation reused by `pii_prevention_blocker.py` |
84
86
  | `pii_prevention_blocker.py` | PreToolUse (Write/Edit/MultiEdit/Bash/PowerShell/MCP GitHub) | Content that carries high-confidence personal data or secrets (real emails, home-dir paths, private IPs, credential material) on write, durable GitHub posts, or staged commit paths |
85
87
  | `pii_scanner.py` | library | Pure text scanners shared by `pii_prevention_blocker.py` |
86
88
  | `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env python3
2
+ """Token-aware git-commit detection reused by ``pii_prevention_blocker``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import shlex
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ _blocking_directory = str(Path(__file__).resolve().parent)
12
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
13
+ if _blocking_directory not in sys.path:
14
+ sys.path.insert(0, _blocking_directory)
15
+ if _hooks_directory not in sys.path:
16
+ sys.path.insert(0, _hooks_directory)
17
+
18
+ from block_main_commit import extract_git_working_directory # noqa: E402
19
+
20
+ from hooks_constants.pii_prevention_constants import ( # noqa: E402
21
+ ALL_BASH_FAMILY_INTERPRETER_BASENAMES,
22
+ ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS,
23
+ ALL_GIT_BINARY_BASENAMES,
24
+ ALL_LEADING_SKIPPABLE_COMMAND_TOKENS,
25
+ ALL_ONE_OPERAND_WRAPPER_TOKENS,
26
+ ALL_SHELL_COMMAND_SEPARATOR_TOKENS,
27
+ ALL_SHELL_INTERPRETER_BASENAMES,
28
+ ALL_SHELL_QUOTE_CHARACTERS,
29
+ ALL_VALUE_TAKING_GIT_OPTIONS,
30
+ DOUBLE_DASH_OPTION_PREFIX,
31
+ ENVIRONMENT_ASSIGNMENT_PATTERN,
32
+ GIT_COMMIT_SUBCOMMAND,
33
+ GIT_OPTION_WITH_VALUE_STEP,
34
+ GIT_WORKING_DIRECTORY_OPTION,
35
+ INLINE_COMMAND_FLAG_CLUSTER_CHARACTER,
36
+ INLINE_COMMAND_TOKEN_JOINER,
37
+ LINE_CONTINUATION_PATTERN,
38
+ POWERSHELL_INLINE_COMMAND_FLAG,
39
+ POWERSHELL_LINE_CONTINUATION_PATTERN,
40
+ SHELL_INLINE_COMMAND_FLAG,
41
+ SINGLE_DASH_OPTION_PREFIX,
42
+ SUBSHELL_GROUP_OPEN_TOKEN,
43
+ )
44
+
45
+
46
+ def _strip_token_edge_quotes(token_text: str) -> str:
47
+ return token_text.strip("\"'")
48
+
49
+
50
+ def _token_basename_lower(token_text: str) -> str:
51
+ stripped_token = _strip_token_edge_quotes(token_text)
52
+ return re.split(r"[\\/]", stripped_token)[-1].lower()
53
+
54
+
55
+ def _token_is_git_binary(token_text: str) -> bool:
56
+ return _token_basename_lower(token_text) in ALL_GIT_BINARY_BASENAMES
57
+
58
+
59
+ def _token_is_shell_interpreter(token_text: str) -> bool:
60
+ return _token_basename_lower(token_text) in ALL_SHELL_INTERPRETER_BASENAMES
61
+
62
+
63
+ def _token_is_bash_family_interpreter(token_text: str) -> bool:
64
+ return _token_basename_lower(token_text) in ALL_BASH_FAMILY_INTERPRETER_BASENAMES
65
+
66
+
67
+ def _token_is_subshell_group_open(token_text: str) -> bool:
68
+ return token_text == SUBSHELL_GROUP_OPEN_TOKEN
69
+
70
+
71
+ def _token_is_skippable_prefix(token_text: str) -> bool:
72
+ if ENVIRONMENT_ASSIGNMENT_PATTERN.match(token_text):
73
+ return True
74
+ return _token_basename_lower(token_text) in ALL_LEADING_SKIPPABLE_COMMAND_TOKENS
75
+
76
+
77
+ def _following_tokens_invoke_commit(all_following_tokens: list[str]) -> bool:
78
+ token_index = 0
79
+ option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
80
+ while token_index < len(all_following_tokens):
81
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
82
+ option_name = each_token
83
+ has_attached_value = False
84
+ if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
85
+ option_name, _, _attached_value = each_token.partition("=")
86
+ has_attached_value = True
87
+ if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
88
+ if has_attached_value:
89
+ token_index += 1
90
+ else:
91
+ token_index += option_with_value_step
92
+ continue
93
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
94
+ token_index += 1
95
+ continue
96
+ return each_token.lower() == GIT_COMMIT_SUBCOMMAND
97
+ return False
98
+
99
+
100
+ def _split_shell_command_segments(all_tokens: list[str]) -> list[list[str]]:
101
+ all_segments: list[list[str]] = [[]]
102
+ shell_separators = ALL_SHELL_COMMAND_SEPARATOR_TOKENS
103
+ for each_token in all_tokens:
104
+ if each_token in shell_separators:
105
+ all_segments.append([])
106
+ continue
107
+ all_segments[-1].append(each_token)
108
+ return all_segments
109
+
110
+
111
+ def _tokenize_shell_command(shell_command_piece: str) -> list[str] | None:
112
+ lexer = shlex.shlex(shell_command_piece, posix=True, punctuation_chars=True)
113
+ lexer.whitespace_split = True
114
+ lexer.escape = ""
115
+ lexer.commenters = ""
116
+ try:
117
+ return list(lexer)
118
+ except ValueError:
119
+ return None
120
+
121
+
122
+ def _fallback_segments_by_physical_line(shell_command_piece: str) -> list[list[str]]:
123
+ all_segments: list[list[str]] = []
124
+ for each_physical_line in shell_command_piece.splitlines():
125
+ line_tokens = each_physical_line.split()
126
+ all_segments.extend(_split_shell_command_segments(line_tokens))
127
+ return all_segments
128
+
129
+
130
+ def _segments_for_piece(shell_command_piece: str) -> list[list[str]]:
131
+ piece_tokens = _tokenize_shell_command(shell_command_piece)
132
+ if piece_tokens is None:
133
+ return _fallback_segments_by_physical_line(shell_command_piece)
134
+ return _split_shell_command_segments(piece_tokens)
135
+
136
+
137
+ def _split_on_unquoted_newlines(shell_command: str) -> list[str]:
138
+ all_pieces: list[str] = []
139
+ current_characters: list[str] = []
140
+ active_quote_character = ""
141
+ for each_character in shell_command:
142
+ if active_quote_character:
143
+ if each_character == active_quote_character:
144
+ active_quote_character = ""
145
+ current_characters.append(each_character)
146
+ continue
147
+ if each_character in ALL_SHELL_QUOTE_CHARACTERS:
148
+ active_quote_character = each_character
149
+ current_characters.append(each_character)
150
+ continue
151
+ if each_character in ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS:
152
+ all_pieces.append("".join(current_characters))
153
+ current_characters = []
154
+ continue
155
+ current_characters.append(each_character)
156
+ all_pieces.append("".join(current_characters))
157
+ return all_pieces
158
+
159
+
160
+ def _all_command_segments(shell_command: str) -> list[list[str]]:
161
+ collapsed_command = LINE_CONTINUATION_PATTERN.sub("", shell_command)
162
+ collapsed_command = POWERSHELL_LINE_CONTINUATION_PATTERN.sub("", collapsed_command)
163
+ all_segments: list[list[str]] = []
164
+ for each_piece in _split_on_unquoted_newlines(collapsed_command):
165
+ all_segments.extend(_segments_for_piece(each_piece))
166
+ return all_segments
167
+
168
+
169
+ def _token_is_leading_skip_target(token_text: str) -> bool:
170
+ return _token_is_git_binary(token_text) or _token_is_shell_interpreter(token_text)
171
+
172
+
173
+ def _token_is_wrapper_option_flag(token_text: str) -> bool:
174
+ return token_text.startswith(SINGLE_DASH_OPTION_PREFIX)
175
+
176
+
177
+ def _wrapper_leading_operand_count(token_text: str) -> int:
178
+ if _token_basename_lower(token_text) in ALL_ONE_OPERAND_WRAPPER_TOKENS:
179
+ return 1
180
+ return 0
181
+
182
+
183
+ def _flag_value_token_follows(all_segment_tokens: list[str], value_index: int) -> bool:
184
+ if value_index >= len(all_segment_tokens):
185
+ return False
186
+ if not _token_is_leading_skip_target(all_segment_tokens[value_index]):
187
+ return True
188
+ return _later_token_is_leading_skip_target(all_segment_tokens, value_index + 1)
189
+
190
+
191
+ def _later_token_is_leading_skip_target(
192
+ all_segment_tokens: list[str], search_start_index: int
193
+ ) -> bool:
194
+ for each_token in all_segment_tokens[search_start_index:]:
195
+ if _token_is_leading_skip_target(each_token):
196
+ return True
197
+ return False
198
+
199
+
200
+ def _skip_leading_noop_tokens(all_segment_tokens: list[str]) -> int:
201
+ token_index = 0
202
+ has_skipped_wrapper_prefix = False
203
+ pending_operand_budget = 0
204
+ while token_index < len(all_segment_tokens):
205
+ each_token = all_segment_tokens[token_index]
206
+ if _token_is_subshell_group_open(each_token):
207
+ token_index += 1
208
+ continue
209
+ if _token_is_skippable_prefix(each_token):
210
+ has_skipped_wrapper_prefix = True
211
+ pending_operand_budget += _wrapper_leading_operand_count(each_token)
212
+ token_index += 1
213
+ continue
214
+ if not has_skipped_wrapper_prefix:
215
+ break
216
+ if _token_is_leading_skip_target(each_token):
217
+ break
218
+ if _token_is_wrapper_option_flag(each_token):
219
+ token_index += 1
220
+ if _flag_value_token_follows(all_segment_tokens, token_index):
221
+ token_index += 1
222
+ continue
223
+ if pending_operand_budget > 0:
224
+ pending_operand_budget -= 1
225
+ token_index += 1
226
+ continue
227
+ break
228
+ return token_index
229
+
230
+
231
+ def _token_is_powershell_command_flag_prefix(lowered_token: str) -> bool:
232
+ if lowered_token == SINGLE_DASH_OPTION_PREFIX:
233
+ return False
234
+ return POWERSHELL_INLINE_COMMAND_FLAG.startswith(lowered_token)
235
+
236
+
237
+ def _token_is_interpreter_inline_command_flag(
238
+ token_text: str, interpreter_allows_short_flag_cluster: bool
239
+ ) -> bool:
240
+ if not token_text.startswith(SINGLE_DASH_OPTION_PREFIX):
241
+ return False
242
+ if token_text.startswith(DOUBLE_DASH_OPTION_PREFIX):
243
+ return False
244
+ lowered_token = token_text.lower()
245
+ if lowered_token == SHELL_INLINE_COMMAND_FLAG:
246
+ return True
247
+ if _token_is_powershell_command_flag_prefix(lowered_token):
248
+ return True
249
+ if not interpreter_allows_short_flag_cluster:
250
+ return False
251
+ clustered_flag_characters = lowered_token[len(SINGLE_DASH_OPTION_PREFIX) :]
252
+ return INLINE_COMMAND_FLAG_CLUSTER_CHARACTER in clustered_flag_characters
253
+
254
+
255
+ def _interpreter_inline_command_invokes_commit(
256
+ interpreter_token: str, all_following_tokens: list[str]
257
+ ) -> bool:
258
+ allows_short_flag_cluster = _token_is_bash_family_interpreter(interpreter_token)
259
+ token_index = 0
260
+ while token_index < len(all_following_tokens):
261
+ each_token = all_following_tokens[token_index]
262
+ if _token_is_interpreter_inline_command_flag(
263
+ each_token, allows_short_flag_cluster
264
+ ):
265
+ argument_index = token_index + 1
266
+ if argument_index >= len(all_following_tokens):
267
+ return False
268
+ inline_command = INLINE_COMMAND_TOKEN_JOINER.join(
269
+ all_following_tokens[argument_index:]
270
+ )
271
+ return is_git_commit_shell_command(inline_command)
272
+ token_index += 1
273
+ return False
274
+
275
+
276
+ def _segment_invokes_git_commit(all_segment_tokens: list[str]) -> bool:
277
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
278
+ if command_index >= len(all_segment_tokens):
279
+ return False
280
+ all_following_tokens = all_segment_tokens[command_index + 1 :]
281
+ command_token = all_segment_tokens[command_index]
282
+ if _token_is_shell_interpreter(command_token):
283
+ return _interpreter_inline_command_invokes_commit(
284
+ command_token, all_following_tokens
285
+ )
286
+ if not _token_is_git_binary(command_token):
287
+ return False
288
+ return _following_tokens_invoke_commit(all_following_tokens)
289
+
290
+
291
+ def is_git_commit_shell_command(shell_command: str) -> bool:
292
+ """Report whether *shell_command* invokes git commit (token-aware).
293
+
294
+ Each segment is read past its leading noise to the real command word::
295
+
296
+ sudo git commit -m x -> skip the wrapper, then match commit
297
+ nice -n 10 git commit -> skip the wrapper and its flag value
298
+ then git commit -m x -> skip the keyword, then match commit
299
+ build & git commit -m x -> split on the background operator
300
+ bash -cx "git commit" -> unwrap the bash cluster
301
+ pwsh -ExecutionPolicy Bypass -Command ... -> skip past the pwsh flag
302
+ (git commit -m x) -> step over the group open
303
+
304
+ Skipped leading tokens: a subshell-group open ``(``, env-assignments, shell
305
+ keywords (then, do, else, elif), and wrapper commands (sudo, env, time,
306
+ nice, xargs, command, stdbuf) together with each wrapper's own option flags,
307
+ flag values, and the single leading operand that timeout and flock take
308
+ before their command. A non-wrapper command word between a wrapper and a
309
+ later git commit stops the scan, so the wrapper's own payload command is
310
+ read rather than the trailing git token. Segments split on unquoted control
311
+ separators (including
312
+ a lone ``&`` background operator) and newlines, and the git binary may be
313
+ path-prefixed and carry global flags (no-verify, config, and
314
+ working-directory) before its subcommand. A shell interpreter is unwrapped
315
+ at its inline-command flag by interpreter family: bash and sh take an
316
+ isolated ``-c`` or any short-flag cluster carrying ``c`` (``-lc``, ``-cx``),
317
+ and PowerShell (pwsh, powershell) takes only ``-Command`` or ``-c``, so a
318
+ leading pwsh flag whose name merely contains ``c`` (``-ExecutionPolicy``,
319
+ ``-NonInteractive``) is stepped over until the real ``-Command`` is reached.
320
+ The inline command's remaining tokens rejoin into one string, so an
321
+ unquoted multi-token command is read whole.
322
+
323
+ Args:
324
+ shell_command: Bash or PowerShell tool command string.
325
+
326
+ Returns:
327
+ True when a command segment invokes git with a commit subcommand.
328
+ """
329
+ if not shell_command or not shell_command.strip():
330
+ return False
331
+ for each_segment in _all_command_segments(shell_command):
332
+ if _segment_invokes_git_commit(each_segment):
333
+ return True
334
+ return False
335
+
336
+
337
+ def _following_tokens_working_directory(all_following_tokens: list[str]) -> str | None:
338
+ token_index = 0
339
+ option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
340
+ while token_index < len(all_following_tokens):
341
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
342
+ if each_token == GIT_WORKING_DIRECTORY_OPTION:
343
+ value_index = token_index + 1
344
+ if value_index >= len(all_following_tokens):
345
+ return None
346
+ return _strip_token_edge_quotes(all_following_tokens[value_index])
347
+ option_name = each_token
348
+ has_attached_value = False
349
+ if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
350
+ option_name, _, _attached_value = each_token.partition("=")
351
+ has_attached_value = True
352
+ if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
353
+ token_index += 1 if has_attached_value else option_with_value_step
354
+ continue
355
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
356
+ token_index += 1
357
+ continue
358
+ return None
359
+ return None
360
+
361
+
362
+ def _segment_git_commit_working_directory(
363
+ all_segment_tokens: list[str],
364
+ ) -> str | None:
365
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
366
+ if command_index >= len(all_segment_tokens):
367
+ return None
368
+ command_token = all_segment_tokens[command_index]
369
+ if not _token_is_git_binary(command_token):
370
+ return None
371
+ all_following_tokens = all_segment_tokens[command_index + 1 :]
372
+ return _following_tokens_working_directory(all_following_tokens)
373
+
374
+
375
+ def extract_git_commit_working_directory(shell_command: str) -> str | None:
376
+ """Return the directory a git-commit command runs in, or None for the CWD.
377
+
378
+ Reads the ``-C`` value off the same commit-invoking segment the detector
379
+ matches, so every git-binary shape the detector recognizes resolves too::
380
+
381
+ git.exe -C /repoA commit -m x -> /repoA
382
+ git -C "C:/repo" commit -m x -> C:/repo
383
+ cd /repoB && git commit -m x -> /repoB (cd/pushd fallback)
384
+ git commit -m x -> None (runs in the CWD)
385
+
386
+ Args:
387
+ shell_command: Bash or PowerShell tool command string.
388
+
389
+ Returns:
390
+ The working directory the commit targets, or None when it uses the CWD.
391
+ """
392
+ for each_segment in _all_command_segments(shell_command):
393
+ if not _segment_invokes_git_commit(each_segment):
394
+ continue
395
+ segment_directory = _segment_git_commit_working_directory(each_segment)
396
+ if segment_directory is not None:
397
+ return segment_directory
398
+ return extract_git_working_directory(shell_command)
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Scan Write/Edit payloads and durable post bodies for high-confidence PII.
3
+
4
+ Reused by ``pii_prevention_blocker``: ``build_deny_reason`` composes the deny
5
+ message, and ``evaluate_write_edit_payload`` / ``evaluate_post_body_texts`` judge
6
+ new file content and durable GitHub post bodies against the pure scanners in
7
+ ``pii_scanner``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ _blocking_directory = str(Path(__file__).resolve().parent)
16
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
17
+ if _blocking_directory not in sys.path:
18
+ sys.path.insert(0, _blocking_directory)
19
+ if _hooks_directory not in sys.path:
20
+ sys.path.insert(0, _hooks_directory)
21
+
22
+ from pii_scanner import ( # noqa: E402
23
+ PiiFinding,
24
+ is_path_exempt_from_pii_scan,
25
+ scan_text_for_pii,
26
+ )
27
+
28
+ from hooks_constants.multi_edit_reconstruction import edits_for_tool # noqa: E402
29
+ from hooks_constants.pii_prevention_constants import ( # noqa: E402
30
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
31
+ CORRECTIVE_MESSAGE_FOOTER,
32
+ CORRECTIVE_MESSAGE_HEADER,
33
+ EDIT_TOOL_NAME,
34
+ FINDING_LINE_TEMPLATE,
35
+ MESSAGE_LINE_SEPARATOR,
36
+ MULTI_EDIT_TOOL_NAME,
37
+ WRITE_TOOL_NAME,
38
+ )
39
+
40
+
41
+ def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
42
+ """Return the deny message listing each finding for *gate_surface*.
43
+
44
+ Args:
45
+ all_findings: Findings returned by ``scan_text_for_pii``.
46
+ gate_surface: Human-readable surface (write, post body, staged commit).
47
+
48
+ Returns:
49
+ Multi-line deny reason for ``permissionDecisionReason``.
50
+ """
51
+ all_lines = [
52
+ CORRECTIVE_MESSAGE_HEADER,
53
+ f"Surface: {gate_surface}",
54
+ ]
55
+ for each_finding in all_findings:
56
+ all_lines.append(
57
+ FINDING_LINE_TEMPLATE.format(
58
+ category=each_finding.category,
59
+ preview=each_finding.preview,
60
+ )
61
+ )
62
+ all_lines.append(CORRECTIVE_MESSAGE_FOOTER)
63
+ message_line_separator = MESSAGE_LINE_SEPARATOR
64
+ return message_line_separator.join(all_lines)
65
+
66
+
67
+ def _collect_write_edit_texts(
68
+ tool_name: str, all_tool_input: dict[str, object]
69
+ ) -> tuple[str, list[str]]:
70
+ raw_file_path = all_tool_input.get("file_path", "")
71
+ file_path = raw_file_path if isinstance(raw_file_path, str) else ""
72
+ if is_path_exempt_from_pii_scan(file_path):
73
+ return file_path, []
74
+ if tool_name == WRITE_TOOL_NAME:
75
+ write_content = all_tool_input.get("content", "")
76
+ if isinstance(write_content, str) and write_content:
77
+ return file_path, [write_content]
78
+ return file_path, []
79
+ if tool_name in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
80
+ all_texts: list[str] = []
81
+ for each_edit in edits_for_tool(tool_name, all_tool_input):
82
+ if not isinstance(each_edit, dict):
83
+ continue
84
+ new_string = each_edit.get("new_string", "")
85
+ if isinstance(new_string, str) and new_string:
86
+ all_texts.append(new_string)
87
+ return file_path, all_texts
88
+ return file_path, []
89
+
90
+
91
+ def _first_findings_in_texts(all_texts: list[str]) -> list[PiiFinding]:
92
+ for each_text in all_texts:
93
+ all_findings = scan_text_for_pii(each_text)
94
+ if all_findings:
95
+ return all_findings
96
+ return []
97
+
98
+
99
+ def evaluate_write_edit_payload(
100
+ tool_name: str, all_tool_input: dict[str, object]
101
+ ) -> str | None:
102
+ """Return a deny reason when Write/Edit/MultiEdit content carries PII.
103
+
104
+ Args:
105
+ tool_name: The intercepted tool name.
106
+ all_tool_input: The tool input mapping.
107
+
108
+ Returns:
109
+ Deny reason text, or None when the write is clean or out of scope.
110
+ """
111
+ if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
112
+ return None
113
+ file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
114
+ all_findings = _first_findings_in_texts(all_texts)
115
+ if not all_findings:
116
+ return None
117
+ gate_surface = f"file write ({file_path or 'unknown path'})"
118
+ return build_deny_reason(all_findings, gate_surface)
119
+
120
+
121
+ def evaluate_post_body_texts(all_body_texts: list[str]) -> str | None:
122
+ """Return a deny reason when any durable post body carries PII.
123
+
124
+ Args:
125
+ all_body_texts: Body strings extracted from a gh or MCP post tool.
126
+
127
+ Returns:
128
+ Deny reason text, or None when every body is clean.
129
+ """
130
+ all_findings = _first_findings_in_texts(all_body_texts)
131
+ if not all_findings:
132
+ return None
133
+ return build_deny_reason(all_findings, "durable GitHub post body")