claude-dev-env 1.92.1 → 1.93.1
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/CLAUDE.md +6 -2
- package/_shared/CLAUDE.md +2 -1
- package/_shared/advisor/CLAUDE.md +21 -0
- package/_shared/advisor/advisor-protocol.md +142 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
- package/_shared/advisor/scripts/pyproject.toml +3 -0
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
- package/_shared/advisor/scripts/tier_model_ids.py +164 -0
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/precatch-rubric.md +65 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +260 -14
- package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
- package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
- package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
- package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +292 -2
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
- package/agents/CLAUDE.md +3 -2
- package/agents/clasp-deployment-orchestrator.md +2 -2
- package/agents/code-advisor.md +7 -5
- package/agents/code-verifier.md +6 -2
- package/agents/session-advisor.md +27 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
- package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
- package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
- package/bin/CLAUDE.md +1 -0
- package/bin/expand_home_directory_tokens.mjs +75 -0
- package/bin/install.mjs +21 -7
- package/bin/install.test.mjs +220 -10
- package/hooks/CLAUDE.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -1
- package/hooks/blocking/_path_setup.py +13 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
- package/hooks/blocking/code_rules_enforcer.py +6 -0
- package/hooks/blocking/code_rules_imports_logging.py +34 -1
- package/hooks/blocking/code_rules_paths_syspath.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +272 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
- package/hooks/blocking/pii_prevention_blocker.py +781 -0
- package/hooks/blocking/pii_scanner.py +365 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/stop_dispatcher.py +84 -0
- package/hooks/blocking/test__path_setup.py +39 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
- package/hooks/blocking/test_destructive_command_blocker.py +29 -9
- package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
- package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
- package/hooks/blocking/test_pii_scanner.py +190 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
- package/hooks/blocking/test_stop_dispatcher.py +187 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
- package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
- package/hooks/diagnostic/migrations/README.md +25 -26
- package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
- package/hooks/hooks.json +10 -120
- package/hooks/hooks_constants/CLAUDE.md +12 -3
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
- package/hooks/hooks_constants/local_identity.py +182 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
- package/hooks/hooks_constants/pii_prevention_constants.py +295 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
- package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
- package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
- package/hooks/hooks_constants/test_layout_constants.py +28 -0
- package/hooks/hooks_constants/test_local_identity.py +88 -0
- package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
- package/hooks/validators/README.md +5 -6
- package/hooks/validators/__init__.py +2 -2
- package/hooks/validators/python_style_checks.py +298 -243
- package/hooks/validators/run_all_validators.py +6 -0
- package/hooks/validators/test_python_style_checks.py +278 -163
- package/package.json +2 -2
- package/rules/CLAUDE.md +2 -1
- package/rules/bdd.md +1 -1
- package/rules/nas-ssh-invocation.md +6 -4
- package/rules/no-justification-noise.md +61 -0
- package/scripts/test_setup_project_paths.py +1 -1
- package/skills/CLAUDE.md +6 -3
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +113 -35
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +44 -22
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/reference/stop-conditions.md +23 -13
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
- package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +329 -68
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
- package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
- package/skills/bdd-protocol/SKILL.md +4 -5
- package/skills/bdd-protocol/references/anti-patterns.md +1 -1
- package/skills/bugteam/SKILL.md +8 -0
- package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
- package/skills/bugteam/reference/team-setup.md +7 -5
- package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
- package/skills/copilot-finding-triage/SKILL.md +124 -0
- package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
- package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
- package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
- package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
- package/skills/copilot-finding-triage/templates/notification.md +34 -0
- package/skills/copilot-review/SKILL.md +8 -6
- package/skills/findbugs/SKILL.md +4 -0
- package/skills/fixbugs/SKILL.md +8 -7
- package/skills/gotcha/CLAUDE.md +2 -2
- package/skills/gotcha/SKILL.md +4 -4
- package/skills/log-audit/SKILL.md +4 -6
- package/skills/monitor-open-prs/CLAUDE.md +1 -1
- package/skills/monitor-open-prs/SKILL.md +6 -1
- package/skills/orchestrator/SKILL.md +74 -78
- package/skills/orchestrator-refresh/SKILL.md +24 -12
- package/skills/post-audit-findings/SKILL.md +5 -9
- package/skills/pr-consistency-audit/SKILL.md +5 -1
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +86 -47
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +20 -17
- package/skills/pr-converge/reference/examples.md +63 -47
- package/skills/pr-converge/reference/fix-protocol.md +15 -15
- package/skills/pr-converge/reference/ground-rules.md +11 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
- package/skills/pr-converge/reference/per-tick.md +129 -107
- package/skills/pr-converge/reference/state-schema.md +15 -10
- package/skills/pr-converge/scripts/check_convergence.py +5 -4
- package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
- package/skills/pr-fix-protocol/SKILL.md +3 -8
- package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
- package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
- package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
- package/skills/pr-loop-lifecycle/SKILL.md +5 -9
- package/skills/pr-scope-resolve/SKILL.md +3 -6
- package/skills/privacy-hygiene/SKILL.md +114 -0
- package/skills/qbug/SKILL.md +8 -8
- package/skills/rebase/SKILL.md +5 -1
- package/skills/refine/SKILL.md +4 -5
- package/skills/reviewer-gates/SKILL.md +7 -11
- package/skills/session-log/SKILL.md +4 -1
- package/skills/skill-builder/SKILL.md +3 -6
- package/skills/structure-prompt/SKILL.md +4 -5
- package/skills/team-advisor/SKILL.md +56 -0
- package/skills/test_markdown_link_integrity.py +10 -6
- package/skills/update/SKILL.md +5 -1
- package/skills/usage-pause/SKILL.md +14 -5
- package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
- package/skills/verified-build/SKILL.md +4 -9
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Pure text scanners for high-confidence PII and secret material.
|
|
2
|
+
|
|
3
|
+
Returns structured findings only. Callers decide whether to deny a Write,
|
|
4
|
+
a durable GitHub post, or a git commit that carries the match.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if _hooks_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, _hooks_directory)
|
|
19
|
+
|
|
20
|
+
from hooks_constants.local_identity import nas_host # noqa: E402
|
|
21
|
+
from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
22
|
+
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES,
|
|
23
|
+
ALL_EXACT_LEGAL_NOTICE_BASENAMES,
|
|
24
|
+
ALL_HOME_DIRECTORY_PATH_MARKERS,
|
|
25
|
+
ALL_PLACEHOLDER_HOME_USERNAMES,
|
|
26
|
+
ALL_REDACTED_PREVIEW_CATEGORIES,
|
|
27
|
+
ALL_RFC1918_NETWORK_CIDRS,
|
|
28
|
+
ALL_SAFE_EMAIL_DOMAINS,
|
|
29
|
+
ALL_SECRET_PATTERNS,
|
|
30
|
+
ALL_SELF_MODULE_PATH_SUFFIXES,
|
|
31
|
+
ALL_SOURCE_TEST_FILE_SUFFIXES,
|
|
32
|
+
ANGLE_BRACKET_PLACEHOLDER_PATTERN,
|
|
33
|
+
CATEGORY_EMAIL,
|
|
34
|
+
CATEGORY_HOME_PATH,
|
|
35
|
+
CATEGORY_PRIVATE_IP,
|
|
36
|
+
CATEGORY_SECRET,
|
|
37
|
+
CONFTEST_BASENAME,
|
|
38
|
+
EMAIL_PATTERN,
|
|
39
|
+
HOME_PATH_PATTERN,
|
|
40
|
+
IPV4_PATTERN,
|
|
41
|
+
IPV4_VERSION_NUMBER,
|
|
42
|
+
MAXIMUM_FINDINGS_PER_SCAN,
|
|
43
|
+
MAXIMUM_OFFENDING_PREVIEW_LENGTH,
|
|
44
|
+
MINIMUM_ENV_STYLE_USERNAME_LENGTH,
|
|
45
|
+
MINIMUM_LENGTH_FOR_PARTIAL_REDACTION,
|
|
46
|
+
PYTHON_SOURCE_FILE_SUFFIX,
|
|
47
|
+
REDACTED_PREVIEW_ELLIPSIS,
|
|
48
|
+
REDACTED_PREVIEW_PREFIX_LENGTH,
|
|
49
|
+
REDACTED_PREVIEW_SUFFIX_LENGTH,
|
|
50
|
+
REDACTED_SHORT_PREVIEW,
|
|
51
|
+
SPEC_BASENAME_MARKER,
|
|
52
|
+
TEST_BASENAME_MARKER,
|
|
53
|
+
TEST_MODULE_BASENAME_PREFIX,
|
|
54
|
+
TEST_MODULE_BASENAME_SUFFIX,
|
|
55
|
+
TESTS_PATH_PREFIX,
|
|
56
|
+
TESTS_PATH_SEGMENT,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class PiiFinding:
|
|
62
|
+
"""One high-confidence PII or secret match inside scanned text.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
category: Stable category label (email, home-path, private-ip, secret).
|
|
66
|
+
matched_text: The exact substring that matched.
|
|
67
|
+
preview: Short, display-safe form of the match for deny messages.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
category: str
|
|
71
|
+
matched_text: str
|
|
72
|
+
preview: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_path_exempt_from_pii_scan(file_path: str) -> bool:
|
|
76
|
+
"""Return whether *file_path* must never be scanned for PII.
|
|
77
|
+
|
|
78
|
+
Exemptions cover Python test modules (synthetic fixtures), this scanner
|
|
79
|
+
family (pattern source text), and license/copyright notice files that
|
|
80
|
+
intentionally name legal identity. Empty paths are **not** exempt — callers
|
|
81
|
+
must still scan payload text when no real path is supplied.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
file_path: Absolute or relative path of the file under review.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True when the path is out of scope for PII scanning.
|
|
88
|
+
"""
|
|
89
|
+
if not file_path:
|
|
90
|
+
return False
|
|
91
|
+
normalized_path = file_path.replace("\\", "/").lower()
|
|
92
|
+
path_for_suffix_match = (
|
|
93
|
+
normalized_path
|
|
94
|
+
if normalized_path.startswith("/")
|
|
95
|
+
else f"/{normalized_path}"
|
|
96
|
+
)
|
|
97
|
+
basename = os.path.basename(file_path)
|
|
98
|
+
basename_lower = basename.lower()
|
|
99
|
+
for each_suffix in ALL_SELF_MODULE_PATH_SUFFIXES:
|
|
100
|
+
if path_for_suffix_match.endswith(each_suffix):
|
|
101
|
+
return True
|
|
102
|
+
if basename_lower == CONFTEST_BASENAME:
|
|
103
|
+
return True
|
|
104
|
+
if basename_lower.endswith(PYTHON_SOURCE_FILE_SUFFIX) and (
|
|
105
|
+
basename_lower.startswith(TEST_MODULE_BASENAME_PREFIX)
|
|
106
|
+
or basename_lower.endswith(TEST_MODULE_BASENAME_SUFFIX)
|
|
107
|
+
):
|
|
108
|
+
return True
|
|
109
|
+
if TESTS_PATH_SEGMENT in normalized_path or normalized_path.startswith(
|
|
110
|
+
TESTS_PATH_PREFIX
|
|
111
|
+
):
|
|
112
|
+
return True
|
|
113
|
+
if basename_lower.endswith(ALL_SOURCE_TEST_FILE_SUFFIXES) and (
|
|
114
|
+
SPEC_BASENAME_MARKER in basename_lower or TEST_BASENAME_MARKER in basename_lower
|
|
115
|
+
):
|
|
116
|
+
return True
|
|
117
|
+
if basename_lower in ALL_EXACT_LEGAL_NOTICE_BASENAMES:
|
|
118
|
+
return True
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _redact_sensitive_preview(matched_text: str) -> str:
|
|
123
|
+
"""Return a preview that redacts the middle of a secret or email body.
|
|
124
|
+
|
|
125
|
+
A short match collapses to a fixed placeholder. A longer match keeps a
|
|
126
|
+
bounded prefix and suffix around an ellipsis, so the preview reveals at
|
|
127
|
+
most a few leading and trailing characters rather than the whole body.
|
|
128
|
+
"""
|
|
129
|
+
if len(matched_text) < MINIMUM_LENGTH_FOR_PARTIAL_REDACTION:
|
|
130
|
+
return REDACTED_SHORT_PREVIEW
|
|
131
|
+
prefix_length = REDACTED_PREVIEW_PREFIX_LENGTH
|
|
132
|
+
suffix_length = REDACTED_PREVIEW_SUFFIX_LENGTH
|
|
133
|
+
return (
|
|
134
|
+
matched_text[:prefix_length]
|
|
135
|
+
+ REDACTED_PREVIEW_ELLIPSIS
|
|
136
|
+
+ matched_text[-suffix_length:]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_preview(matched_text: str, category: str) -> str:
|
|
141
|
+
"""Build a deny-message preview, redacting secret, email, and home-path matches."""
|
|
142
|
+
if category in ALL_REDACTED_PREVIEW_CATEGORIES:
|
|
143
|
+
return _redact_sensitive_preview(matched_text)
|
|
144
|
+
if category == CATEGORY_HOME_PATH:
|
|
145
|
+
return _redact_home_path_username(matched_text)
|
|
146
|
+
maximum_preview_length = MAXIMUM_OFFENDING_PREVIEW_LENGTH
|
|
147
|
+
if len(matched_text) <= maximum_preview_length:
|
|
148
|
+
return matched_text
|
|
149
|
+
return matched_text[: maximum_preview_length - 3] + "..."
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _finding(category: str, matched_text: str) -> PiiFinding:
|
|
153
|
+
return PiiFinding(
|
|
154
|
+
category=category,
|
|
155
|
+
matched_text=matched_text,
|
|
156
|
+
preview=_build_preview(matched_text, category),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_safe_email_domain(domain_name: str) -> bool:
|
|
161
|
+
if domain_name in ALL_SAFE_EMAIL_DOMAINS:
|
|
162
|
+
return True
|
|
163
|
+
return any(
|
|
164
|
+
domain_name.endswith("." + each_safe_domain)
|
|
165
|
+
for each_safe_domain in ALL_SAFE_EMAIL_DOMAINS
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_safe_example_email(email_address: str) -> bool:
|
|
170
|
+
domain_name = email_address.rsplit("@", 1)[-1].lower()
|
|
171
|
+
return _is_safe_email_domain(domain_name)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _home_username_offset(matched_path: str) -> tuple[int, str] | None:
|
|
175
|
+
"""Return the (start offset, value) of the home-username segment, or None.
|
|
176
|
+
|
|
177
|
+
The offset locates the username within *matched_path* by its home marker,
|
|
178
|
+
so a same-named directory earlier in the path never shadows the real
|
|
179
|
+
segment::
|
|
180
|
+
|
|
181
|
+
C:\\Users\\Users\\file.txt -> (9, "Users") the second Users, not the first
|
|
182
|
+
|
|
183
|
+
Replacing backslashes with forward slashes preserves length, so the marker
|
|
184
|
+
offset computed on the normalized path indexes *matched_path* directly.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
matched_path: The matched home-path substring.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The username's start index and value, or None when no marker is found.
|
|
191
|
+
"""
|
|
192
|
+
normalized_path = matched_path.replace("\\", "/")
|
|
193
|
+
lowered_path = normalized_path.lower()
|
|
194
|
+
for each_marker in ALL_HOME_DIRECTORY_PATH_MARKERS:
|
|
195
|
+
marker_index = lowered_path.find(each_marker)
|
|
196
|
+
if marker_index < 0:
|
|
197
|
+
continue
|
|
198
|
+
username_start = marker_index + len(each_marker)
|
|
199
|
+
username = normalized_path[username_start:].split("/", 1)[0]
|
|
200
|
+
if not username:
|
|
201
|
+
return None
|
|
202
|
+
return username_start, username
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _username_from_home_path(matched_path: str) -> str | None:
|
|
207
|
+
"""Extract the home username segment from a matched home-path string."""
|
|
208
|
+
username_offset = _home_username_offset(matched_path)
|
|
209
|
+
if username_offset is None:
|
|
210
|
+
return None
|
|
211
|
+
_username_start, username = username_offset
|
|
212
|
+
return username
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _redact_home_path_username(matched_text: str) -> str:
|
|
216
|
+
"""Redact only the username segment of a home path, keeping its shape."""
|
|
217
|
+
username_offset = _home_username_offset(matched_text)
|
|
218
|
+
if username_offset is None:
|
|
219
|
+
return matched_text
|
|
220
|
+
username_start, username = username_offset
|
|
221
|
+
return (
|
|
222
|
+
matched_text[:username_start]
|
|
223
|
+
+ REDACTED_SHORT_PREVIEW
|
|
224
|
+
+ matched_text[username_start + len(username) :]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_placeholder_home_username(username: str) -> bool:
|
|
229
|
+
if ANGLE_BRACKET_PLACEHOLDER_PATTERN.match(username) is not None:
|
|
230
|
+
return True
|
|
231
|
+
lowered_username = username.lower()
|
|
232
|
+
if lowered_username in ALL_PLACEHOLDER_HOME_USERNAMES:
|
|
233
|
+
return True
|
|
234
|
+
if lowered_username.startswith("your"):
|
|
235
|
+
return True
|
|
236
|
+
minimum_env_style_username_length = MINIMUM_ENV_STYLE_USERNAME_LENGTH
|
|
237
|
+
if username.isupper() and len(username) >= minimum_env_style_username_length:
|
|
238
|
+
return True
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_private_ipv4_address(address_text: str) -> bool:
|
|
243
|
+
"""Return True only for RFC1918 LAN addresses (not loopback/link-local/etc.)."""
|
|
244
|
+
try:
|
|
245
|
+
parsed_address = ipaddress.ip_address(address_text)
|
|
246
|
+
except ValueError:
|
|
247
|
+
return False
|
|
248
|
+
if parsed_address.version != IPV4_VERSION_NUMBER:
|
|
249
|
+
return False
|
|
250
|
+
for each_cidr in ALL_RFC1918_NETWORK_CIDRS:
|
|
251
|
+
if parsed_address in ipaddress.ip_network(each_cidr, strict=False):
|
|
252
|
+
return True
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _find_emails(text: str) -> list[PiiFinding]:
|
|
257
|
+
all_findings: list[PiiFinding] = []
|
|
258
|
+
for each_match in EMAIL_PATTERN.finditer(text):
|
|
259
|
+
email_address = each_match.group(1)
|
|
260
|
+
if _is_safe_example_email(email_address):
|
|
261
|
+
continue
|
|
262
|
+
all_findings.append(_finding(CATEGORY_EMAIL, email_address))
|
|
263
|
+
return all_findings
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _find_home_paths(text: str) -> list[PiiFinding]:
|
|
267
|
+
all_findings: list[PiiFinding] = []
|
|
268
|
+
for each_match in HOME_PATH_PATTERN.finditer(text):
|
|
269
|
+
matched_path = each_match.group(0)
|
|
270
|
+
username = _username_from_home_path(matched_path)
|
|
271
|
+
if username is not None and _is_placeholder_home_username(username):
|
|
272
|
+
continue
|
|
273
|
+
all_findings.append(_finding(CATEGORY_HOME_PATH, matched_path))
|
|
274
|
+
return all_findings
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _all_allowlisted_private_ip_addresses() -> frozenset[str]:
|
|
278
|
+
"""Return every private IPv4 address the scanner must leave unflagged.
|
|
279
|
+
|
|
280
|
+
::
|
|
281
|
+
|
|
282
|
+
CLAUDE_NAS_HOST=10.20.30.40 -> static set + {"10.20.30.40"}
|
|
283
|
+
CLAUDE_NAS_HOST=nas.local -> static set only (a name, not an address)
|
|
284
|
+
(env unset, no file) -> static set only
|
|
285
|
+
|
|
286
|
+
The committed set carries no real address. The maintainer's own NAS host
|
|
287
|
+
resolves at scan time from ``CLAUDE_NAS_HOST`` or
|
|
288
|
+
``~/.claude/local-identity.json``, so their ssh traffic and git-ignored
|
|
289
|
+
local config stay unflagged while the repository stays publishable.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
The static allowlist, joined with the locally configured NAS host when
|
|
293
|
+
that host is itself a private IPv4 address.
|
|
294
|
+
"""
|
|
295
|
+
configured_nas_host = nas_host()
|
|
296
|
+
if not _is_private_ipv4_address(configured_nas_host):
|
|
297
|
+
return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES
|
|
298
|
+
return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES | {configured_nas_host}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _find_private_ips(text: str) -> list[PiiFinding]:
|
|
302
|
+
all_findings: list[PiiFinding] = []
|
|
303
|
+
allowlisted_addresses = _all_allowlisted_private_ip_addresses()
|
|
304
|
+
for each_match in IPV4_PATTERN.finditer(text):
|
|
305
|
+
address_text = each_match.group(1)
|
|
306
|
+
if address_text in allowlisted_addresses:
|
|
307
|
+
continue
|
|
308
|
+
if not _is_private_ipv4_address(address_text):
|
|
309
|
+
continue
|
|
310
|
+
all_findings.append(_finding(CATEGORY_PRIVATE_IP, address_text))
|
|
311
|
+
return all_findings
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _find_secrets(text: str) -> list[PiiFinding]:
|
|
315
|
+
all_findings: list[PiiFinding] = []
|
|
316
|
+
for each_pattern in ALL_SECRET_PATTERNS:
|
|
317
|
+
for each_match in each_pattern.finditer(text):
|
|
318
|
+
all_findings.append(_finding(CATEGORY_SECRET, each_match.group(0)))
|
|
319
|
+
return all_findings
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def scan_text_for_pii(text: str) -> list[PiiFinding]:
|
|
323
|
+
"""Return high-confidence PII and secret findings in *text*.
|
|
324
|
+
|
|
325
|
+
Safe residual examples (``user@example.com``, placeholder home users such
|
|
326
|
+
as ``example``/``alice``, allowlisted private IPs) are omitted. Findings
|
|
327
|
+
are capped so deny messages stay readable.
|
|
328
|
+
|
|
329
|
+
::
|
|
330
|
+
|
|
331
|
+
ok: "contact user@example.com" -> []
|
|
332
|
+
ok: r"C:\\Users\\example\\notes.txt" -> []
|
|
333
|
+
flag: "contact person@company.io" -> [email]
|
|
334
|
+
flag: r"C:\\Users\\realname\\notes.txt" -> [home-path]
|
|
335
|
+
flag: "NAS at 10.0.0.5" -> [private-ip]
|
|
336
|
+
flag: "ghp_" + ("x" * 36) -> [secret]
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
text: Arbitrary text that would be written, posted, or committed.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Zero or more findings in first-seen order, deduplicated by
|
|
343
|
+
(category, matched_text), capped at ``MAXIMUM_FINDINGS_PER_SCAN``.
|
|
344
|
+
"""
|
|
345
|
+
if not text:
|
|
346
|
+
return []
|
|
347
|
+
all_category_finders: tuple[Callable[[str], list[PiiFinding]], ...] = (
|
|
348
|
+
_find_emails,
|
|
349
|
+
_find_home_paths,
|
|
350
|
+
_find_private_ips,
|
|
351
|
+
_find_secrets,
|
|
352
|
+
)
|
|
353
|
+
all_findings: list[PiiFinding] = []
|
|
354
|
+
all_seen_keys: set[tuple[str, str]] = set()
|
|
355
|
+
maximum_findings_per_scan = MAXIMUM_FINDINGS_PER_SCAN
|
|
356
|
+
for each_finder in all_category_finders:
|
|
357
|
+
for each_finding in each_finder(text):
|
|
358
|
+
category_and_match = (each_finding.category, each_finding.matched_text)
|
|
359
|
+
if category_and_match in all_seen_keys:
|
|
360
|
+
continue
|
|
361
|
+
all_seen_keys.add(category_and_match)
|
|
362
|
+
all_findings.append(each_finding)
|
|
363
|
+
if len(all_findings) >= maximum_findings_per_scan:
|
|
364
|
+
return all_findings
|
|
365
|
+
return all_findings
|
|
@@ -8,9 +8,9 @@ decision when any hook denied (carrying every denying reason) or exits zero to
|
|
|
8
8
|
allow.
|
|
9
9
|
|
|
10
10
|
The per-hook coverage matrix:
|
|
11
|
-
- Write ->
|
|
12
|
-
- Edit ->
|
|
13
|
-
- MultiEdit ->
|
|
11
|
+
- Write -> 20 hooks from ALL_HOSTED_HOOK_ENTRIES that list Write
|
|
12
|
+
- Edit -> 21 hooks from ALL_HOSTED_HOOK_ENTRIES that list Edit
|
|
13
|
+
- MultiEdit -> 9 hooks from ALL_HOSTED_HOOK_ENTRIES that list MultiEdit
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop-hook dispatcher that hosts the five Stop-chain hooks in one process.
|
|
3
|
+
|
|
4
|
+
Reads the Stop payload from stdin once, runs each hosted hook in registration
|
|
5
|
+
order via the shared hosted-hook runner, and emits the first block decision the
|
|
6
|
+
chain produced. Later hooks still run so side-effect hooks (the log extractor
|
|
7
|
+
wrapper) fire even when an earlier hook blocked. A single hosted hook crash
|
|
8
|
+
fails open and does not suppress the remaining hooks.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import _path_setup # noqa: F401
|
|
18
|
+
|
|
19
|
+
from hooks_constants.hosted_hook_runner import HostedHookRun, run_hook_capturing_output
|
|
20
|
+
from hooks_constants.stop_dispatcher_constants import (
|
|
21
|
+
ALL_STOP_HOSTED_HOOK_PATHS,
|
|
22
|
+
BLOCK_DECISION,
|
|
23
|
+
DECISION_KEY,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
28
|
+
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
29
|
+
hooks_root = Path(__file__).resolve().parent.parent
|
|
30
|
+
return str(hooks_root / relative_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_block_decision(stdout_text: str) -> bool:
|
|
34
|
+
"""Return True when stdout carries a Stop decision of block."""
|
|
35
|
+
stripped_text = stdout_text.strip()
|
|
36
|
+
if not stripped_text:
|
|
37
|
+
return False
|
|
38
|
+
try:
|
|
39
|
+
parsed_output = json.loads(stripped_text)
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return False
|
|
42
|
+
if not isinstance(parsed_output, dict):
|
|
43
|
+
return False
|
|
44
|
+
return parsed_output.get(DECISION_KEY) == BLOCK_DECISION
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def select_first_block_stdout(all_runs: list[HostedHookRun]) -> str:
|
|
48
|
+
"""Return the first non-crashed block stdout, or empty when none blocked."""
|
|
49
|
+
for each_run in all_runs:
|
|
50
|
+
if each_run.did_crash:
|
|
51
|
+
continue
|
|
52
|
+
if _is_block_decision(each_run.captured_stdout):
|
|
53
|
+
return each_run.captured_stdout.strip()
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dispatch(payload_text: str) -> None:
|
|
58
|
+
"""Run every Stop hosted hook and emit the first block decision if any."""
|
|
59
|
+
all_runs: list[HostedHookRun] = []
|
|
60
|
+
for each_relative_path in ALL_STOP_HOSTED_HOOK_PATHS:
|
|
61
|
+
script_path = _resolve_hook_script_path(each_relative_path)
|
|
62
|
+
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
63
|
+
all_runs.append(hook_run)
|
|
64
|
+
block_stdout = select_first_block_stdout(all_runs)
|
|
65
|
+
if block_stdout:
|
|
66
|
+
sys.stdout.write(block_stdout + "\n")
|
|
67
|
+
sys.stdout.flush()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
"""Read stdin once and dispatch the Stop hosted-hook chain."""
|
|
72
|
+
payload_text = sys.stdin.read()
|
|
73
|
+
if not payload_text.strip():
|
|
74
|
+
sys.exit(0)
|
|
75
|
+
try:
|
|
76
|
+
json.loads(payload_text)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
sys.exit(0)
|
|
79
|
+
dispatch(payload_text)
|
|
80
|
+
sys.exit(0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Behavior test for the sibling-import path bootstrap.
|
|
2
|
+
|
|
3
|
+
Importing _path_setup must place the hooks directory (its own parent) on
|
|
4
|
+
sys.path so an entry-point dispatcher in blocking/ can then import hooks_constants
|
|
5
|
+
with all imports kept at module top. The check runs in a subprocess with only
|
|
6
|
+
blocking/ on PYTHONPATH, proving the bootstrap adds the hooks directory itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
17
|
+
_HOOKS_DIR = _BLOCKING_DIR.parent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_importing_path_setup_puts_the_hooks_directory_on_sys_path() -> None:
|
|
21
|
+
"""A subprocess that imports _path_setup finds the hooks directory on sys.path."""
|
|
22
|
+
driver = (
|
|
23
|
+
"import _path_setup\n"
|
|
24
|
+
"import sys\n"
|
|
25
|
+
"from pathlib import Path\n"
|
|
26
|
+
"hooks_dir = str(Path(_path_setup.__file__).resolve().parent.parent)\n"
|
|
27
|
+
"assert hooks_dir in sys.path, hooks_dir\n"
|
|
28
|
+
"print('ok')\n"
|
|
29
|
+
)
|
|
30
|
+
subprocess_environment = {**os.environ, "PYTHONPATH": str(_BLOCKING_DIR)}
|
|
31
|
+
completed = subprocess.run(
|
|
32
|
+
[sys.executable, "-c", driver],
|
|
33
|
+
check=False,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
env=subprocess_environment,
|
|
37
|
+
)
|
|
38
|
+
assert completed.returncode == 0, completed.stderr
|
|
39
|
+
assert completed.stdout.strip() == "ok"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Benchmark: consolidated Bash dispatcher starts one interpreter, not N.
|
|
2
|
+
|
|
3
|
+
Standalone registration starts one Python process per hosted hook. The
|
|
4
|
+
dispatcher replaces that chain with a single process that hosts every hook
|
|
5
|
+
in-process. This test pins the before/after interpreter-start counts so a
|
|
6
|
+
regression that re-splits the chain fails CI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if _HOOKS_DIR not in sys.path:
|
|
18
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
19
|
+
|
|
20
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
21
|
+
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
22
|
+
BASH_TOOL_NAME,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
26
|
+
_HOOKS_ROOT = _BLOCKING_DIR.parent
|
|
27
|
+
_DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "bash_pre_tool_use_dispatcher.py")
|
|
28
|
+
|
|
29
|
+
STANDALONE_INTERPRETER_STARTS_PER_BASH_CALL = len(ALL_BASH_HOSTED_HOOK_ENTRIES)
|
|
30
|
+
DISPATCHER_INTERPRETER_STARTS_PER_BASH_CALL = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_roster_is_large_enough_that_consolidation_saves_starts() -> None:
|
|
34
|
+
"""Consolidation only pays off when the chain hosts more than one hook."""
|
|
35
|
+
assert STANDALONE_INTERPRETER_STARTS_PER_BASH_CALL >= 10
|
|
36
|
+
assert DISPATCHER_INTERPRETER_STARTS_PER_BASH_CALL == 1
|
|
37
|
+
assert STANDALONE_INTERPRETER_STARTS_PER_BASH_CALL > DISPATCHER_INTERPRETER_STARTS_PER_BASH_CALL
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_dispatcher_handles_a_bash_call_in_one_process() -> None:
|
|
41
|
+
"""One subprocess run of the dispatcher covers the full Bash hosted chain."""
|
|
42
|
+
payload_text = json.dumps(
|
|
43
|
+
{"tool_name": BASH_TOOL_NAME, "tool_input": {"command": "echo interpreter-start-probe"}}
|
|
44
|
+
)
|
|
45
|
+
completed = subprocess.run(
|
|
46
|
+
[sys.executable, _DISPATCHER_SCRIPT],
|
|
47
|
+
check=False,
|
|
48
|
+
input=payload_text,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
)
|
|
53
|
+
assert completed.returncode == 0
|
|
54
|
+
assert DISPATCHER_INTERPRETER_STARTS_PER_BASH_CALL == 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_standalone_chain_would_start_one_process_per_roster_entry() -> None:
|
|
58
|
+
"""Each roster path is a real script that would own its own interpreter start."""
|
|
59
|
+
for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES:
|
|
60
|
+
assert (_HOOKS_ROOT / each_entry.script_relative_path).is_file()
|
|
61
|
+
assert len(ALL_BASH_HOSTED_HOOK_ENTRIES) == STANDALONE_INTERPRETER_STARTS_PER_BASH_CALL
|