claude-dev-env 1.95.0 → 2.0.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/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +20 -20
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
- package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
- package/_shared/advisor/scripts/tier_model_ids.py +18 -18
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
- package/_shared/pr-loop/worker-spawn.md +186 -0
- package/agents/code-verifier.md +1 -1
- package/bin/ever-shipped-skills.mjs +3 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +5 -2
- package/hooks/advisory/refactor_guard.py +3 -4
- package/hooks/blocking/CLAUDE.md +7 -1
- package/hooks/blocking/block_main_commit.py +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
- package/hooks/blocking/code_rules_mock_completeness.py +1 -1
- package/hooks/blocking/code_rules_optional_params.py +2 -2
- package/hooks/blocking/code_rules_shared.py +1 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
- package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
- package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
- package/hooks/blocking/inventory_intent_records/records.py +271 -0
- package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
- package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
- package/hooks/blocking/pii_payload_scan.py +138 -42
- package/hooks/blocking/pii_prevention_blocker.py +185 -291
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/tdd_enforcer.py +97 -608
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
- package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
- package/hooks/blocking/test_pii_payload_scan.py +168 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +13 -36
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/scripts/CLAUDE.md +4 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
- package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
- package/scripts/dev_env_scripts_constants/timing.py +7 -1
- package/scripts/grok_headless_runner.py +294 -0
- package/scripts/grok_worker_preflight.py +410 -0
- package/scripts/invoke_code_review.py +463 -0
- package/scripts/resolve_worker_spawn.py +619 -0
- package/scripts/spawn_grok_batch.py +672 -0
- package/scripts/test_grok_headless_runner.py +626 -0
- package/scripts/test_grok_worker_preflight.py +1054 -0
- package/scripts/test_invoke_code_review.py +672 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -0
- package/scripts/test_spawn_grok_batch.py +1017 -0
- package/skills/CLAUDE.md +5 -3
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +33 -11
- package/skills/autoconverge/reference/stop-conditions.md +16 -5
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +200 -16
- package/skills/bugteam/CLAUDE.md +2 -2
- package/skills/bugteam/CONSTRAINTS.md +3 -2
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +18 -13
- package/skills/bugteam/reference/audit-and-teammates.md +215 -35
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -2
- package/skills/codex-review/CLAUDE.md +46 -0
- package/skills/codex-review/SKILL.md +181 -0
- package/skills/codex-review/reference/CLAUDE.md +15 -0
- package/skills/codex-review/reference/cli-contract.md +253 -0
- package/skills/codex-review/reference/loop-integration.md +118 -0
- package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
- package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
- package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
- package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
- package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
- package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
- package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
- package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
- package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
- package/skills/codex-review/scripts/run_codex_review.py +415 -0
- package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
- package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
- package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
- package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
- package/skills/codex-review/test_skill_scaffold.py +192 -0
- package/skills/grok-spawn/CLAUDE.md +28 -0
- package/skills/grok-spawn/SKILL.md +226 -0
- package/skills/grok-spawn/reference/flag-profiles.md +132 -0
- package/skills/grok-spawn/reference/worker-briefs.md +152 -0
- package/skills/grokify/SKILL.md +9 -1
- package/skills/grokify/capability-claims.test.mjs +28 -0
- package/skills/grokify/evals/README.md +72 -0
- package/skills/grokify/evals/parse-payload.test.mjs +171 -0
- package/skills/grokify/evals/run-capability-evals.mjs +545 -0
- package/skills/orchestrator/SKILL.md +15 -11
- package/skills/orchestrator-refresh/SKILL.md +5 -5
- package/skills/pr-converge/SKILL.md +34 -13
- package/skills/pr-converge/reference/convergence-gates.md +42 -15
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +130 -42
- package/skills/pr-converge/reference/state-schema.md +10 -0
- package/skills/pr-converge/scripts/CLAUDE.md +2 -0
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
- package/skills/pr-converge/scripts/check_convergence.py +605 -29
- package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
- package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
- package/skills/pr-converge/test_step5_host_branch.py +106 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/team-advisor/SKILL.md +7 -7
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Survey a directory's package inventories and detect a stale omission.
|
|
2
|
+
|
|
3
|
+
Reads each ``README.md`` / ``CLAUDE.md`` / ``SKILL.md`` beside a file, collects
|
|
4
|
+
the bare filenames they name in backticks, and reports whether a maintained
|
|
5
|
+
inventory omits the file being written.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from hooks_constants.package_inventory_stale_blocker_constants import (
|
|
12
|
+
ALL_EXEMPT_BASENAMES,
|
|
13
|
+
ALL_EXEMPT_DIRECTORY_NAMES,
|
|
14
|
+
ALL_INVENTORY_DOCUMENT_NAMES,
|
|
15
|
+
ALL_PRODUCTION_CODE_EXTENSIONS,
|
|
16
|
+
ALL_TEST_FILE_MARKERS,
|
|
17
|
+
BACKTICK_TOKEN_PATTERN,
|
|
18
|
+
CODE_FENCE_PATTERN,
|
|
19
|
+
GLOB_METACHARACTER_PATTERN,
|
|
20
|
+
MAX_INVENTORY_FILE_BYTES,
|
|
21
|
+
MINIMUM_INVENTORY_ENTRY_COUNT,
|
|
22
|
+
NON_FILENAME_TOKEN_PATTERN,
|
|
23
|
+
PYTHON_FILE_EXTENSION,
|
|
24
|
+
SCRIPTS_SUBDIRECTORY_NAME,
|
|
25
|
+
SKILL_INVENTORY_DOCUMENT_NAME,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _basename_token(backtick_inner_text: str) -> str | None:
|
|
30
|
+
"""Return the bare filename a backticked token names, when it names one.
|
|
31
|
+
|
|
32
|
+
A token names a bare filename when it is a single filename or path token
|
|
33
|
+
carrying a known file extension; a path keeps only its final segment. A
|
|
34
|
+
slash-command, a glob/pattern token, a multi-word command-example span, and
|
|
35
|
+
an extension-less token yield None.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
backtick_inner_text: The text between a backtick pair, stripped.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The bare basename the token references, or None when it names no file.
|
|
42
|
+
"""
|
|
43
|
+
inner_text = backtick_inner_text.strip()
|
|
44
|
+
if not inner_text or inner_text.startswith("/"):
|
|
45
|
+
return None
|
|
46
|
+
if GLOB_METACHARACTER_PATTERN.search(inner_text) is not None:
|
|
47
|
+
return None
|
|
48
|
+
if NON_FILENAME_TOKEN_PATTERN.search(inner_text) is not None:
|
|
49
|
+
return None
|
|
50
|
+
basename = os.path.basename(inner_text.replace("\\", "/").rstrip("/"))
|
|
51
|
+
if not basename:
|
|
52
|
+
return None
|
|
53
|
+
_, extension = os.path.splitext(basename)
|
|
54
|
+
if not extension:
|
|
55
|
+
return None
|
|
56
|
+
return basename
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _lines_outside_code_fences(inventory_content: str) -> list[str]:
|
|
60
|
+
"""Return the inventory lines that sit outside any fenced code block.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
inventory_content: The text of a README.md or CLAUDE.md inventory.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The lines that lie outside every code fence, in document order.
|
|
67
|
+
"""
|
|
68
|
+
live_lines: list[str] = []
|
|
69
|
+
is_inside_code_fence = False
|
|
70
|
+
for each_line in inventory_content.splitlines():
|
|
71
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
72
|
+
is_inside_code_fence = not is_inside_code_fence
|
|
73
|
+
continue
|
|
74
|
+
if is_inside_code_fence:
|
|
75
|
+
continue
|
|
76
|
+
live_lines.append(each_line)
|
|
77
|
+
return live_lines
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _line_named_basenames(inventory_line: str) -> set[str]:
|
|
81
|
+
"""Return the bare filenames one inventory line names in backticks.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
inventory_line: One line drawn from outside any code fence.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The set of bare basenames the line's backticked tokens name.
|
|
88
|
+
"""
|
|
89
|
+
line_basenames: set[str] = set()
|
|
90
|
+
for each_match in BACKTICK_TOKEN_PATTERN.finditer(inventory_line):
|
|
91
|
+
each_basename = _basename_token(each_match.group(1))
|
|
92
|
+
if each_basename is not None:
|
|
93
|
+
line_basenames.add(each_basename)
|
|
94
|
+
return line_basenames
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def inventory_named_basenames(inventory_content: str) -> set[str]:
|
|
98
|
+
"""Return every bare filename a package inventory document names in backticks.
|
|
99
|
+
|
|
100
|
+
Lines inside a fenced code block are skipped as example text. Each backticked
|
|
101
|
+
token that names a literal file contributes its basename; a token holding a
|
|
102
|
+
path contributes its final segment.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
inventory_content: The text of a README.md or CLAUDE.md inventory.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The set of bare basenames the inventory names.
|
|
109
|
+
"""
|
|
110
|
+
named_basenames: set[str] = set()
|
|
111
|
+
for each_line in _lines_outside_code_fences(inventory_content):
|
|
112
|
+
named_basenames |= _line_named_basenames(each_line)
|
|
113
|
+
return named_basenames
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_inventory_content(inventory_path: Path) -> str | None:
|
|
117
|
+
"""Return the text of an inventory document, or None when it is unreadable.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
inventory_path: The path of the README.md or CLAUDE.md to read.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The document text, or None when it is missing, oversized, or undecodable.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
if inventory_path.stat().st_size > MAX_INVENTORY_FILE_BYTES:
|
|
127
|
+
return None
|
|
128
|
+
return inventory_path.read_text(encoding="utf-8")
|
|
129
|
+
except (OSError, UnicodeDecodeError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _InventorySurvey:
|
|
134
|
+
"""The inventory documents found beside a file and the files they name.
|
|
135
|
+
|
|
136
|
+
Attributes:
|
|
137
|
+
present_inventory_names: The inventory document basenames present in the
|
|
138
|
+
directory (``README.md``, ``CLAUDE.md``, and/or ``SKILL.md``).
|
|
139
|
+
named_basenames: Every bare filename the present inventories name.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self, all_present_inventory_names: list[str], all_named_basenames: set[str]
|
|
144
|
+
) -> None:
|
|
145
|
+
self.present_inventory_names = all_present_inventory_names
|
|
146
|
+
self.named_basenames = all_named_basenames
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def survey_directory_inventories(package_directory: Path) -> _InventorySurvey:
|
|
150
|
+
"""Return the inventory documents beside a file and the basenames they name.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
package_directory: The directory that holds the file being written.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The survey pairing the present inventory document names with the union of
|
|
157
|
+
the basenames they name.
|
|
158
|
+
"""
|
|
159
|
+
present_inventory_names: list[str] = []
|
|
160
|
+
named_basenames: set[str] = set()
|
|
161
|
+
for each_inventory_name in sorted(ALL_INVENTORY_DOCUMENT_NAMES):
|
|
162
|
+
inventory_path = package_directory / each_inventory_name
|
|
163
|
+
inventory_content = _read_inventory_content(inventory_path)
|
|
164
|
+
if inventory_content is None:
|
|
165
|
+
continue
|
|
166
|
+
present_inventory_names.append(each_inventory_name)
|
|
167
|
+
named_basenames |= inventory_named_basenames(inventory_content)
|
|
168
|
+
return _InventorySurvey(present_inventory_names, named_basenames)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_test_file(basename: str) -> bool:
|
|
172
|
+
"""Return whether *basename* names a test file the inventory need not list.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
basename: The bare filename of the file being written.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
True when the name matches a test-file shape.
|
|
179
|
+
"""
|
|
180
|
+
if basename.startswith("test_") and basename.endswith(PYTHON_FILE_EXTENSION):
|
|
181
|
+
return True
|
|
182
|
+
if basename.endswith("_test" + PYTHON_FILE_EXTENSION):
|
|
183
|
+
return True
|
|
184
|
+
return any(each_marker in basename for each_marker in ALL_TEST_FILE_MARKERS)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_under_exempt_directory(package_directory: Path) -> bool:
|
|
188
|
+
"""Return whether the file's directory is itself an exempt directory.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
package_directory: The directory that holds the file being written.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
True when the directory's own name is an exempt directory name.
|
|
195
|
+
"""
|
|
196
|
+
return package_directory.name in ALL_EXEMPT_DIRECTORY_NAMES
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def is_inventoried_production_file(file_path: str) -> bool:
|
|
200
|
+
"""Return whether *file_path* is a production file an inventory should name.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
file_path: The destination path of the write.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True when the file is one an inventory entry should name.
|
|
207
|
+
"""
|
|
208
|
+
basename = os.path.basename(file_path)
|
|
209
|
+
_, extension = os.path.splitext(basename)
|
|
210
|
+
if extension.lower() not in ALL_PRODUCTION_CODE_EXTENSIONS:
|
|
211
|
+
return False
|
|
212
|
+
if basename in ALL_EXEMPT_BASENAMES:
|
|
213
|
+
return False
|
|
214
|
+
if _is_test_file(basename):
|
|
215
|
+
return False
|
|
216
|
+
return not _is_under_exempt_directory(Path(file_path).resolve().parent)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _sibling_named_basenames(package_directory: Path, all_named_basenames: set[str]) -> set[str]:
|
|
220
|
+
"""Return the named basenames that exist as files in *package_directory*.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
package_directory: The directory that holds the file being written.
|
|
224
|
+
all_named_basenames: Every bare basename the inventory documents name.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The subset of *all_named_basenames* present as a file in the directory.
|
|
228
|
+
"""
|
|
229
|
+
sibling_basenames: set[str] = set()
|
|
230
|
+
for each_basename in all_named_basenames:
|
|
231
|
+
if (package_directory / each_basename).is_file():
|
|
232
|
+
sibling_basenames.add(each_basename)
|
|
233
|
+
return sibling_basenames
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parent_skill_inventory(package_directory: Path) -> _InventorySurvey | None:
|
|
237
|
+
"""Return the parent skill ``SKILL.md`` survey for a ``scripts/`` directory.
|
|
238
|
+
|
|
239
|
+
A production file landing in a skill's ``scripts/`` directory is governed by
|
|
240
|
+
the parent ``SKILL.md`` Layout table one level up. Any other directory and a
|
|
241
|
+
missing or unreadable parent ``SKILL.md`` yield None.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
package_directory: The directory that holds the file being written.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
The parent ``SKILL.md`` survey, or None when there is none to read.
|
|
248
|
+
"""
|
|
249
|
+
if package_directory.name != SCRIPTS_SUBDIRECTORY_NAME:
|
|
250
|
+
return None
|
|
251
|
+
skill_inventory_path = package_directory.parent / SKILL_INVENTORY_DOCUMENT_NAME
|
|
252
|
+
inventory_content = _read_inventory_content(skill_inventory_path)
|
|
253
|
+
if inventory_content is None:
|
|
254
|
+
return None
|
|
255
|
+
return _InventorySurvey(
|
|
256
|
+
[SKILL_INVENTORY_DOCUMENT_NAME], inventory_named_basenames(inventory_content)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _merged_survey_names(
|
|
261
|
+
package_directory: Path, survey: _InventorySurvey
|
|
262
|
+
) -> tuple[list[str], set[str]]:
|
|
263
|
+
"""Return the survey names merged with any parent skill ``SKILL.md`` survey.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
package_directory: The directory that holds the file being written.
|
|
267
|
+
survey: The file's own directory inventory survey.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The present inventory names and named basenames, unioned across the
|
|
271
|
+
directory's own inventories and the parent skill Layout table.
|
|
272
|
+
"""
|
|
273
|
+
present_inventory_names = list(survey.present_inventory_names)
|
|
274
|
+
named_basenames = set(survey.named_basenames)
|
|
275
|
+
parent_skill_survey = _parent_skill_inventory(package_directory)
|
|
276
|
+
if parent_skill_survey is not None:
|
|
277
|
+
present_inventory_names += parent_skill_survey.present_inventory_names
|
|
278
|
+
named_basenames |= parent_skill_survey.named_basenames
|
|
279
|
+
return present_inventory_names, named_basenames
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
|
|
283
|
+
"""Return the maintained inventory survey a new file is absent from, or None.
|
|
284
|
+
|
|
285
|
+
The survey reports a stale inventory only when at least one inventory document
|
|
286
|
+
is present, those documents name at least the minimum count of on-disk sibling
|
|
287
|
+
files, and no inventory already names this file's basename.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
file_path: The destination path of the write.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The inventory survey when the file is a stale omission, or None.
|
|
294
|
+
"""
|
|
295
|
+
package_directory = Path(file_path).resolve().parent
|
|
296
|
+
if not package_directory.is_dir():
|
|
297
|
+
return None
|
|
298
|
+
survey = survey_directory_inventories(package_directory)
|
|
299
|
+
present_inventory_names, named_basenames = _merged_survey_names(package_directory, survey)
|
|
300
|
+
if not present_inventory_names:
|
|
301
|
+
return None
|
|
302
|
+
sibling_basenames = _sibling_named_basenames(package_directory, named_basenames)
|
|
303
|
+
if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
|
|
304
|
+
return None
|
|
305
|
+
if os.path.basename(file_path) in named_basenames:
|
|
306
|
+
return None
|
|
307
|
+
return _InventorySurvey(present_inventory_names, sibling_basenames)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Put the blocking and hooks directories on sys.path for the parts tests."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_blocking_directory = str(Path(__file__).resolve().parents[2])
|
|
7
|
+
_hooks_directory = str(Path(__file__).resolve().parents[3])
|
|
8
|
+
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
9
|
+
if each_bootstrap_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tests for the package-inventory blocker decision parts module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from package_inventory_stale_blocker_parts import decision as decision_module
|
|
7
|
+
from package_inventory_stale_blocker_parts.config.inventory_blocker_constants import (
|
|
8
|
+
FILE_FIRST_RETRY_HINT,
|
|
9
|
+
)
|
|
10
|
+
from package_inventory_stale_blocker_parts.decision import (
|
|
11
|
+
build_block_payload,
|
|
12
|
+
deny_stale_inventory,
|
|
13
|
+
)
|
|
14
|
+
from package_inventory_stale_blocker_parts.inventory_detection import _InventorySurvey
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_deny_stale_inventory_emits_deny(
|
|
18
|
+
capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
19
|
+
) -> None:
|
|
20
|
+
monkeypatch.setattr(decision_module, "log_hook_block", lambda **each_argument: None)
|
|
21
|
+
survey = _InventorySurvey(["README.md"], {"alpha.py", "beta.py"})
|
|
22
|
+
deny_stale_inventory("/pkg/gamma.py", survey)
|
|
23
|
+
emitted_payload = json.loads(capsys.readouterr().out)
|
|
24
|
+
assert emitted_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_build_block_payload_names_the_file_and_denies() -> None:
|
|
28
|
+
survey = _InventorySurvey(["README.md"], {"alpha.py", "beta.py"})
|
|
29
|
+
block_payload = build_block_payload("/pkg/gamma.py", survey)
|
|
30
|
+
hook_output = block_payload["hookSpecificOutput"]
|
|
31
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
32
|
+
assert "gamma.py" in hook_output["permissionDecisionReason"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_build_block_payload_appends_retry_hint() -> None:
|
|
36
|
+
survey = _InventorySurvey(["README.md"], {"alpha.py", "beta.py"})
|
|
37
|
+
block_payload = build_block_payload("/pkg/gamma.py", survey)
|
|
38
|
+
assert FILE_FIRST_RETRY_HINT in block_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tests for the package-inventory detection parts module."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from package_inventory_stale_blocker_parts.inventory_detection import (
|
|
6
|
+
find_stale_inventory,
|
|
7
|
+
inventory_named_basenames,
|
|
8
|
+
is_inventoried_production_file,
|
|
9
|
+
survey_directory_inventories,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
README_TWO_FILES = (
|
|
13
|
+
"# Pipeline\n\n"
|
|
14
|
+
"| Path | Role |\n"
|
|
15
|
+
"|---|---|\n"
|
|
16
|
+
"| `dialer_compose.py` | Composes a dialer strip. |\n"
|
|
17
|
+
"| `compose_dialer_cli.py` | CLI for the dialer strip. |\n"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _package_with_readme(tmp_path: Path, readme_content: str) -> Path:
|
|
22
|
+
package_directory = tmp_path / "package_directory"
|
|
23
|
+
package_directory.mkdir()
|
|
24
|
+
(package_directory / "README.md").write_text(readme_content, encoding="utf-8")
|
|
25
|
+
return package_directory
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_inventory_named_basenames_reads_table_cells() -> None:
|
|
29
|
+
named_basenames = inventory_named_basenames(README_TWO_FILES)
|
|
30
|
+
assert named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_survey_directory_inventories_unions_named_files(tmp_path: Path) -> None:
|
|
34
|
+
package_directory = _package_with_readme(tmp_path, README_TWO_FILES)
|
|
35
|
+
survey = survey_directory_inventories(package_directory)
|
|
36
|
+
assert survey.present_inventory_names == ["README.md"]
|
|
37
|
+
assert survey.named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_is_inventoried_production_file_accepts_python_module(tmp_path: Path) -> None:
|
|
41
|
+
assert is_inventoried_production_file(str(tmp_path / "dialer_compose.py")) is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_is_inventoried_production_file_rejects_test_file(tmp_path: Path) -> None:
|
|
45
|
+
assert is_inventoried_production_file(str(tmp_path / "test_dialer.py")) is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_find_stale_inventory_reports_omission(tmp_path: Path) -> None:
|
|
49
|
+
package_directory = _package_with_readme(tmp_path, README_TWO_FILES)
|
|
50
|
+
for each_basename in ("dialer_compose.py", "compose_dialer_cli.py"):
|
|
51
|
+
(package_directory / each_basename).write_text("x = 1\n", encoding="utf-8")
|
|
52
|
+
survey = find_stale_inventory(str(package_directory / "seam_continuity.py"))
|
|
53
|
+
assert survey is not None
|
|
54
|
+
assert survey.named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_find_stale_inventory_allows_named_file(tmp_path: Path) -> None:
|
|
58
|
+
package_directory = _package_with_readme(tmp_path, README_TWO_FILES)
|
|
59
|
+
for each_basename in ("dialer_compose.py", "compose_dialer_cli.py"):
|
|
60
|
+
(package_directory / each_basename).write_text("x = 1\n", encoding="utf-8")
|
|
61
|
+
assert find_stale_inventory(str(package_directory / "compose_dialer_cli.py")) is None
|
|
@@ -12,30 +12,39 @@ from __future__ import annotations
|
|
|
12
12
|
import sys
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
from
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
15
|
+
try:
|
|
16
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
17
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
19
|
+
if each_bootstrap_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
21
|
+
from pii_prevention_blocker_parts.repository_exemption import (
|
|
22
|
+
repository_allowlisted_values,
|
|
23
|
+
)
|
|
24
|
+
from pii_scanner import (
|
|
25
|
+
PiiFinding,
|
|
26
|
+
is_path_exempt_from_pii_scan,
|
|
27
|
+
scan_text_for_pii,
|
|
28
|
+
)
|
|
29
|
+
from precommit_code_rules_gate import resolve_repository_root
|
|
30
|
+
|
|
31
|
+
from hooks_constants.local_identity import pii_allowlisted_values_by_repository
|
|
32
|
+
from hooks_constants.multi_edit_reconstruction import edits_for_tool
|
|
33
|
+
from hooks_constants.pii_prevention_constants import (
|
|
34
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
35
|
+
CORRECTIVE_MESSAGE_FOOTER,
|
|
36
|
+
CORRECTIVE_MESSAGE_HEADER,
|
|
37
|
+
EDIT_TOOL_NAME,
|
|
38
|
+
FINDING_LINE_TEMPLATE,
|
|
39
|
+
MESSAGE_LINE_SEPARATOR,
|
|
40
|
+
MULTI_EDIT_TOOL_NAME,
|
|
41
|
+
WRITE_TOOL_NAME,
|
|
42
|
+
)
|
|
43
|
+
except ImportError as import_error:
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"pii_payload_scan: cannot import its sibling modules; "
|
|
46
|
+
"ensure the blocking and hooks directories are importable."
|
|
47
|
+
) from import_error
|
|
39
48
|
|
|
40
49
|
|
|
41
50
|
def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
|
|
@@ -64,6 +73,20 @@ def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
|
|
|
64
73
|
return message_line_separator.join(all_lines)
|
|
65
74
|
|
|
66
75
|
|
|
76
|
+
def _new_strings_from_edits(
|
|
77
|
+
tool_name: str, all_tool_input: dict[str, object]
|
|
78
|
+
) -> list[str]:
|
|
79
|
+
"""Return each non-empty ``new_string`` from an Edit/MultiEdit payload."""
|
|
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 all_texts
|
|
88
|
+
|
|
89
|
+
|
|
67
90
|
def _collect_write_edit_texts(
|
|
68
91
|
tool_name: str, all_tool_input: dict[str, object]
|
|
69
92
|
) -> tuple[str, list[str]]:
|
|
@@ -77,33 +100,87 @@ def _collect_write_edit_texts(
|
|
|
77
100
|
return file_path, [write_content]
|
|
78
101
|
return file_path, []
|
|
79
102
|
if tool_name in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
|
|
80
|
-
|
|
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
|
|
103
|
+
return file_path, _new_strings_from_edits(tool_name, all_tool_input)
|
|
88
104
|
return file_path, []
|
|
89
105
|
|
|
90
106
|
|
|
91
|
-
def
|
|
107
|
+
def _findings_after_allowlist(
|
|
108
|
+
text: str, all_allowlisted_values: frozenset[str]
|
|
109
|
+
) -> list[PiiFinding]:
|
|
110
|
+
"""Return PII findings in *text* whose matched text is not allowlisted."""
|
|
111
|
+
return [
|
|
112
|
+
each_finding
|
|
113
|
+
for each_finding in scan_text_for_pii(text)
|
|
114
|
+
if each_finding.matched_text not in all_allowlisted_values
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _first_findings_in_texts(
|
|
119
|
+
all_texts: list[str], all_allowlisted_values: frozenset[str] = frozenset()
|
|
120
|
+
) -> list[PiiFinding]:
|
|
92
121
|
for each_text in all_texts:
|
|
93
|
-
all_findings =
|
|
122
|
+
all_findings = _findings_after_allowlist(each_text, all_allowlisted_values)
|
|
94
123
|
if all_findings:
|
|
95
124
|
return all_findings
|
|
96
125
|
return []
|
|
97
126
|
|
|
98
127
|
|
|
128
|
+
def _existing_ancestor_directory(file_path: str) -> str | None:
|
|
129
|
+
"""Return the nearest existing ancestor directory of *file_path*, or None.
|
|
130
|
+
|
|
131
|
+
PreToolUse runs before Write creates nested parents, so the immediate parent
|
|
132
|
+
may not exist yet; git resolution needs a directory that is already on disk.
|
|
133
|
+
"""
|
|
134
|
+
starting_directory = Path(file_path).parent
|
|
135
|
+
for each_ancestor in (starting_directory, *starting_directory.parents):
|
|
136
|
+
if each_ancestor.exists():
|
|
137
|
+
return str(each_ancestor)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _write_path_allowlisted_values(file_path: str) -> frozenset[str]:
|
|
142
|
+
"""Return the exact values allowlisted for the repository *file_path* sits in.
|
|
143
|
+
|
|
144
|
+
::
|
|
145
|
+
|
|
146
|
+
no path, or no allowlist configured -> frozenset() (no git call)
|
|
147
|
+
path inside an allowlisted repo tree -> that repo's frozenset of values
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
file_path: The write target path whose repository keys the allowlist.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The exact values the repository allows past the scan, or an empty set.
|
|
154
|
+
"""
|
|
155
|
+
if not file_path:
|
|
156
|
+
return frozenset()
|
|
157
|
+
if not pii_allowlisted_values_by_repository():
|
|
158
|
+
return frozenset()
|
|
159
|
+
resolution_directory = _existing_ancestor_directory(file_path)
|
|
160
|
+
if resolution_directory is None:
|
|
161
|
+
return frozenset()
|
|
162
|
+
repository_root = resolve_repository_root(resolution_directory)
|
|
163
|
+
if repository_root is None:
|
|
164
|
+
return frozenset()
|
|
165
|
+
return repository_allowlisted_values(repository_root)
|
|
166
|
+
|
|
167
|
+
|
|
99
168
|
def evaluate_write_edit_payload(
|
|
100
|
-
tool_name: str,
|
|
169
|
+
tool_name: str,
|
|
170
|
+
all_tool_input: dict[str, object],
|
|
171
|
+
all_allowlisted_values: frozenset[str] = frozenset(),
|
|
101
172
|
) -> str | None:
|
|
102
173
|
"""Return a deny reason when Write/Edit/MultiEdit content carries PII.
|
|
103
174
|
|
|
175
|
+
A value in the target repository's PII allowlist is dropped from the
|
|
176
|
+
findings, so a write under that repository's tree may carry it. Repository
|
|
177
|
+
resolution for that allowlist runs only after a raw PII hit.
|
|
178
|
+
|
|
104
179
|
Args:
|
|
105
180
|
tool_name: The intercepted tool name.
|
|
106
181
|
all_tool_input: The tool input mapping.
|
|
182
|
+
all_allowlisted_values: Extra exact values allowed past the scan,
|
|
183
|
+
unioned with the target repository's own allowlist.
|
|
107
184
|
|
|
108
185
|
Returns:
|
|
109
186
|
Deny reason text, or None when the write is clean or out of scope.
|
|
@@ -111,23 +188,42 @@ def evaluate_write_edit_payload(
|
|
|
111
188
|
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
112
189
|
return None
|
|
113
190
|
file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
191
|
+
resolved_allowlisted_values: frozenset[str] | None = None
|
|
192
|
+
for each_text in all_texts:
|
|
193
|
+
all_raw_findings = scan_text_for_pii(each_text)
|
|
194
|
+
if not all_raw_findings:
|
|
195
|
+
continue
|
|
196
|
+
if resolved_allowlisted_values is None:
|
|
197
|
+
resolved_allowlisted_values = (
|
|
198
|
+
all_allowlisted_values | _write_path_allowlisted_values(file_path)
|
|
199
|
+
)
|
|
200
|
+
all_findings = [
|
|
201
|
+
each_finding
|
|
202
|
+
for each_finding in all_raw_findings
|
|
203
|
+
if each_finding.matched_text not in resolved_allowlisted_values
|
|
204
|
+
]
|
|
205
|
+
if all_findings:
|
|
206
|
+
gate_surface = f"file write ({file_path or 'unknown path'})"
|
|
207
|
+
return build_deny_reason(all_findings, gate_surface)
|
|
208
|
+
return None
|
|
119
209
|
|
|
120
210
|
|
|
121
|
-
def evaluate_post_body_texts(
|
|
211
|
+
def evaluate_post_body_texts(
|
|
212
|
+
all_body_texts: list[str],
|
|
213
|
+
all_allowlisted_values: frozenset[str] = frozenset(),
|
|
214
|
+
) -> str | None:
|
|
122
215
|
"""Return a deny reason when any durable post body carries PII.
|
|
123
216
|
|
|
124
217
|
Args:
|
|
125
218
|
all_body_texts: Body strings extracted from a gh or MCP post tool.
|
|
219
|
+
all_allowlisted_values: Exact values the caller's repository allows;
|
|
220
|
+
a finding matching one is not a finding. Callers that pass
|
|
221
|
+
nothing keep every post fully scanned.
|
|
126
222
|
|
|
127
223
|
Returns:
|
|
128
224
|
Deny reason text, or None when every body is clean.
|
|
129
225
|
"""
|
|
130
|
-
all_findings = _first_findings_in_texts(all_body_texts)
|
|
226
|
+
all_findings = _first_findings_in_texts(all_body_texts, all_allowlisted_values)
|
|
131
227
|
if not all_findings:
|
|
132
228
|
return None
|
|
133
229
|
return build_deny_reason(all_findings, "durable GitHub post body")
|