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.
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/audit-contract.md +1 -1
- package/_shared/pr-loop/gh-payloads.md +3 -3
- package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
- package/_shared/pr-loop/scripts/README.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
- package/_shared/pr-loop/state-schema.md +3 -22
- package/bin/CLAUDE.md +1 -1
- package/bin/install.mjs +2 -7
- package/bin/install.test.mjs +10 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/pii_commit_command.py +398 -0
- package/hooks/blocking/pii_payload_scan.py +133 -0
- package/hooks/blocking/pii_prevention_blocker.py +151 -449
- package/hooks/blocking/pii_scanner.py +26 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_pii_scanner.py +27 -2
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/package.json +1 -1
- package/scripts/CLAUDE.md +10 -1
- package/scripts/Get-SessionAccount.ps1 +210 -0
- package/scripts/tests/CLAUDE.md +15 -1
- package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
- package/skills/CLAUDE.md +3 -25
- package/skills/anthropic-plan/SKILL.md +1 -13
- package/skills/autoconverge/CLAUDE.md +5 -2
- package/skills/autoconverge/SKILL.md +112 -387
- package/skills/autoconverge/reference/CLAUDE.md +4 -1
- package/skills/autoconverge/reference/closing-report.md +9 -2
- package/skills/autoconverge/reference/convergence.md +26 -22
- package/skills/autoconverge/reference/headless-safety.md +44 -0
- package/skills/autoconverge/reference/multi-pr.md +88 -0
- package/skills/autoconverge/reference/self-closing-loop.md +84 -0
- package/skills/autoconverge/reference/stop-conditions.md +21 -13
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
- package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +284 -94
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/CONSTRAINTS.md +3 -3
- package/skills/bugteam/PROMPTS.md +8 -19
- package/skills/bugteam/SKILL.md +13 -14
- package/skills/bugteam/reference/CLAUDE.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
- package/skills/bugteam/reference/github-pr-reviews.md +56 -38
- package/skills/bugteam/reference/team-setup.md +11 -11
- package/skills/bugteam/scripts/CLAUDE.md +6 -6
- package/skills/bugteam/scripts/README.md +20 -8
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
- package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
- package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
- package/skills/copilot-finding-triage/SKILL.md +80 -28
- package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
- package/skills/copilot-finding-triage/templates/notification.md +8 -3
- package/skills/everything-search/SKILL.md +1 -2
- package/skills/grokify/SKILL.md +58 -0
- package/skills/grokify/templates/handoff-template.md +92 -0
- package/skills/orchestrator/SKILL.md +12 -8
- package/skills/orchestrator-refresh/SKILL.md +3 -3
- package/skills/pr-converge/SKILL.md +132 -233
- package/skills/pr-converge/reference/convergence-gates.md +102 -66
- package/skills/pr-converge/reference/examples.md +37 -25
- package/skills/pr-converge/reference/fix-protocol.md +7 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
- package/skills/pr-converge/reference/per-tick.md +64 -41
- package/skills/pr-converge/reference/state-schema.md +27 -2
- package/skills/pr-converge/scripts/CLAUDE.md +8 -0
- package/skills/pr-converge/scripts/README.md +4 -3
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
- package/skills/pr-converge/scripts/check_convergence.py +189 -645
- package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
- package/skills/pr-converge/scripts/conftest.py +8 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
- package/skills/privacy-hygiene/SKILL.md +8 -7
- package/skills/reviewer-gates/SKILL.md +26 -8
- package/skills/session-log/SKILL.md +11 -12
- package/skills/session-tidy/SKILL.md +40 -30
- package/skills/skill-builder/CLAUDE.md +13 -10
- package/skills/skill-builder/SKILL.md +31 -31
- package/skills/skill-builder/references/CLAUDE.md +10 -6
- package/skills/skill-builder/references/delegation-map.md +13 -4
- package/skills/skill-builder/references/description-field.md +113 -0
- package/skills/skill-builder/references/self-audit-checklist.md +21 -4
- package/skills/skill-builder/references/skill-modularity.md +113 -0
- package/skills/skill-builder/references/skill-types.md +4 -0
- package/skills/skill-builder/templates/CLAUDE.md +4 -4
- package/skills/skill-builder/templates/gap-analysis.md +25 -1
- package/skills/skill-builder/workflows/CLAUDE.md +6 -4
- package/skills/skill-builder/workflows/improve-skill.md +17 -16
- package/skills/skill-builder/workflows/new-skill.md +34 -10
- package/skills/skill-builder/workflows/polish-skill.md +59 -43
- package/skills/usage-pause/SKILL.md +10 -14
- package/skills/bdd-protocol/CLAUDE.md +0 -26
- package/skills/bdd-protocol/SKILL.md +0 -30
- package/skills/bdd-protocol/references/CLAUDE.md +0 -10
- package/skills/bdd-protocol/references/anti-patterns.md +0 -26
- package/skills/bdd-protocol/references/example-mapping.md +0 -23
- package/skills/bg-agent/CLAUDE.md +0 -17
- package/skills/bg-agent/SKILL.md +0 -69
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
- package/skills/caveman/CLAUDE.md +0 -15
- package/skills/caveman/SKILL.md +0 -38
- package/skills/code/CLAUDE.md +0 -17
- package/skills/code/SKILL.md +0 -178
- package/skills/deep-research/CLAUDE.md +0 -17
- package/skills/deep-research/SKILL.md +0 -80
- package/skills/findbugs/CLAUDE.md +0 -20
- package/skills/findbugs/SKILL.md +0 -241
- package/skills/fixbugs/CLAUDE.md +0 -19
- package/skills/fixbugs/SKILL.md +0 -142
- package/skills/gh-paginate/CLAUDE.md +0 -18
- package/skills/gh-paginate/SKILL.md +0 -84
- package/skills/gotcha/CLAUDE.md +0 -33
- package/skills/gotcha/SKILL.md +0 -73
- package/skills/implement/CLAUDE.md +0 -27
- package/skills/implement/SKILL.md +0 -66
- package/skills/implement/scripts/CLAUDE.md +0 -22
- package/skills/implement/scripts/append_note.py +0 -133
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
- package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
- package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
- package/skills/implement/scripts/test_append_note.py +0 -191
- package/skills/log-audit/CLAUDE.md +0 -20
- package/skills/log-audit/SKILL.md +0 -66
- package/skills/log-audit/reference/CLAUDE.md +0 -9
- package/skills/log-audit/reference/charter.md +0 -52
- package/skills/log-audit/scripts/CLAUDE.md +0 -27
- package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
- package/skills/log-audit/scripts/collect_log_window.py +0 -199
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
- package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
- package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
- package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
- package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
- package/skills/logifix/CLAUDE.md +0 -36
- package/skills/logifix/SKILL.md +0 -69
- package/skills/logifix/scripts/CLAUDE.md +0 -16
- package/skills/logifix/scripts/logifix.ps1 +0 -205
- package/skills/monitor-open-prs/CLAUDE.md +0 -34
- package/skills/monitor-open-prs/SKILL.md +0 -88
- package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
- package/skills/monitor-open-prs/test_skill_contract.py +0 -27
- package/skills/post-audit-findings/SKILL.md +0 -80
- package/skills/pr-consistency-audit/CLAUDE.md +0 -34
- package/skills/pr-consistency-audit/SKILL.md +0 -116
- package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
- package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
- package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
- package/skills/pr-fix-protocol/SKILL.md +0 -65
- package/skills/pr-review-responder/CLAUDE.md +0 -35
- package/skills/pr-review-responder/EXAMPLES.md +0 -590
- package/skills/pr-review-responder/PRINCIPLES.md +0 -533
- package/skills/pr-review-responder/README.md +0 -168
- package/skills/pr-review-responder/SKILL.md +0 -203
- package/skills/pr-review-responder/TESTING.md +0 -350
- package/skills/pr-scope-resolve/SKILL.md +0 -45
- package/skills/pre-compact/CLAUDE.md +0 -24
- package/skills/pre-compact/SKILL.md +0 -134
- package/skills/qbug/CLAUDE.md +0 -40
- package/skills/qbug/SKILL.md +0 -387
- package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
- package/skills/refine/CLAUDE.md +0 -44
- package/skills/refine/SKILL.md +0 -262
- package/skills/refine/templates/CLAUDE.md +0 -17
- package/skills/refine/templates/implementation-notes-template.html +0 -56
- package/skills/refine/templates/plan-template.md +0 -60
- package/skills/research-mode/CLAUDE.md +0 -35
- package/skills/research-mode/SKILL.md +0 -53
- package/skills/structure-prompt/CLAUDE.md +0 -42
- package/skills/structure-prompt/SKILL.md +0 -44
- package/skills/structure-prompt/reference/CLAUDE.md +0 -28
- package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
- package/skills/structure-prompt/reference/block-classification.md +0 -27
- package/skills/structure-prompt/reference/canonical-case.md +0 -48
- package/skills/structure-prompt/reference/citation-depth.md +0 -70
- package/skills/structure-prompt/reference/cleanup.md +0 -33
- package/skills/structure-prompt/reference/constraints.md +0 -33
- package/skills/structure-prompt/reference/directives.md +0 -37
- package/skills/structure-prompt/reference/examples.md +0 -72
- package/skills/structure-prompt/reference/instantiation.md +0 -51
- package/skills/structure-prompt/reference/output-contract.md +0 -72
- package/skills/structure-prompt/reference/per-category.md +0 -23
- package/skills/structure-prompt/reference/persona.md +0 -38
- package/skills/structure-prompt/reference/research.md +0 -33
- package/skills/structure-prompt/reference/structure.md +0 -28
- package/skills/verified-build/CLAUDE.md +0 -33
- 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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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'
|
|
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
|
|
package/bin/install.test.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
});
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -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")
|