claude-dev-env 1.92.0 → 1.93.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/CLAUDE.md +5 -39
- 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 +137 -5
- 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 +16 -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 +146 -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 +340 -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_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 +165 -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 +299 -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 +7 -1
- package/rules/bdd.md +1 -1
- package/rules/nas-ssh-invocation.md +6 -4
- package/rules/no-justification-noise.md +61 -0
- package/rules/testing.md +0 -2
- 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 +184 -0
- package/skills/orchestrator-refresh/SKILL.md +37 -0
- 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 +32 -164
- 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
- package/skills/team-advisor-refresh/SKILL.md +0 -25
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Windows path detection tests for the PII prevention commit gate.
|
|
2
|
+
|
|
3
|
+
Guards the unquoted absolute Windows git path shape on the PowerShell surface::
|
|
4
|
+
|
|
5
|
+
is_git_commit_shell_command(r"C:\\tools\\git.exe commit")
|
|
6
|
+
ok: True -- the git.exe basename survives tokenization
|
|
7
|
+
flag: False -- backslash escaping mangles the basename token
|
|
8
|
+
|
|
9
|
+
An unquoted absolute path to the git binary must still be recognized as a
|
|
10
|
+
commit so a staged blob carrying PII cannot slip past the gate.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
20
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
22
|
+
|
|
23
|
+
_blocker_module = importlib.import_module("pii_prevention_blocker")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_unquoted_windows_git_exe_path_is_detected_as_commit() -> None:
|
|
27
|
+
assert _blocker_module.is_git_commit_shell_command(r"C:\tools\git.exe commit -m x")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_unquoted_windows_git_path_without_extension_is_detected() -> None:
|
|
31
|
+
assert _blocker_module.is_git_commit_shell_command(r"D:\bin\git commit -m note")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_quoted_windows_git_path_stays_detected_as_commit() -> None:
|
|
35
|
+
assert _blocker_module.is_git_commit_shell_command(
|
|
36
|
+
r'& "C:\Program Files\Git\cmd\git.exe" commit -m x'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_unquoted_windows_git_status_is_not_a_commit() -> None:
|
|
41
|
+
assert not _blocker_module.is_git_commit_shell_command(r"C:\tools\git.exe status")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Behavior tests for the pure PII scanner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_HOOK_DIR = Path(__file__).parent
|
|
9
|
+
_HOOKS_DIR = _HOOK_DIR.parent
|
|
10
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
12
|
+
if str(_HOOKS_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_HOOKS_DIR))
|
|
14
|
+
|
|
15
|
+
from pii_scanner import ( # noqa: E402
|
|
16
|
+
is_path_exempt_from_pii_scan,
|
|
17
|
+
scan_text_for_pii,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SYNTHETIC_GITHUB_TOKEN = "ghp_" + ("A" * 36)
|
|
21
|
+
SYNTHETIC_AWS_KEY = "AKIA" + ("B" * 16)
|
|
22
|
+
SYNTHETIC_PEM_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
|
|
23
|
+
SYNTHETIC_REAL_EMAIL = "person.fixture@company-example.io"
|
|
24
|
+
SYNTHETIC_SAFE_EMAIL = "user@example.com"
|
|
25
|
+
SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
|
|
26
|
+
SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
|
|
27
|
+
SYNTHETIC_PRIVATE_IP = "192.168.42.17"
|
|
28
|
+
SYNTHETIC_PUBLIC_IP = "8.8.8.8"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_flags_real_email_and_allows_example_domain() -> None:
|
|
32
|
+
all_email_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
|
|
33
|
+
all_safe_hits = scan_text_for_pii(f"contact {SYNTHETIC_SAFE_EMAIL}")
|
|
34
|
+
assert any(each.category == "email" for each in all_email_hits)
|
|
35
|
+
assert all_email_hits[0].matched_text == SYNTHETIC_REAL_EMAIL
|
|
36
|
+
assert all_safe_hits == []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_flags_home_path_and_allows_placeholder_user() -> None:
|
|
40
|
+
all_home_hits = scan_text_for_pii(f"path is {SYNTHETIC_HOME_PATH}")
|
|
41
|
+
all_placeholder_hits = scan_text_for_pii(f"path is {SYNTHETIC_PLACEHOLDER_HOME}")
|
|
42
|
+
assert any(each.category == "home-path" for each in all_home_hits)
|
|
43
|
+
assert all_placeholder_hits == []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_allows_hygiene_placeholder_username_example() -> None:
|
|
47
|
+
assert scan_text_for_pii(r"path is C:\Users\example\notes.txt") == []
|
|
48
|
+
assert scan_text_for_pii("path is C:/Users/example/.claude/hooks") == []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_flags_unix_home_path() -> None:
|
|
52
|
+
all_unix_hits = scan_text_for_pii(
|
|
53
|
+
"config lives in /Users/fixture_real_user/.config"
|
|
54
|
+
)
|
|
55
|
+
assert any(each.category == "home-path" for each in all_unix_hits)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_flags_private_ip_and_allows_public_ip() -> None:
|
|
59
|
+
all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
|
|
60
|
+
all_dns_hits = scan_text_for_pii(f"dns {SYNTHETIC_PUBLIC_IP}")
|
|
61
|
+
assert any(each.category == "private-ip" for each in all_lan_hits)
|
|
62
|
+
assert all_lan_hits[0].matched_text == SYNTHETIC_PRIVATE_IP
|
|
63
|
+
assert all_dns_hits == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_allows_loopback_unspecified_and_link_local_addresses() -> None:
|
|
67
|
+
assert scan_text_for_pii("http://127.0.0.1:8080/health") == []
|
|
68
|
+
assert scan_text_for_pii("Listen on 0.0.0.0:8000") == []
|
|
69
|
+
assert scan_text_for_pii("peer 169.254.10.20") == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_allows_product_nas_private_ip() -> None:
|
|
73
|
+
assert scan_text_for_pii("ssh -p 9222 jon@192.168.1.100 uptime") == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_flags_github_token_aws_key_and_pem_header() -> None:
|
|
77
|
+
all_github_hits = scan_text_for_pii(f"export TOKEN={SYNTHETIC_GITHUB_TOKEN}")
|
|
78
|
+
all_aws_hits = scan_text_for_pii(f"key={SYNTHETIC_AWS_KEY}")
|
|
79
|
+
all_pem_hits = scan_text_for_pii(SYNTHETIC_PEM_HEADER + "\nabc\n")
|
|
80
|
+
assert any(each.category == "secret" for each in all_github_hits)
|
|
81
|
+
assert any(each.category == "secret" for each in all_aws_hits)
|
|
82
|
+
assert any(each.category == "secret" for each in all_pem_hits)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_secret_and_email_previews_are_redacted() -> None:
|
|
86
|
+
all_secret_hits = scan_text_for_pii(f"export TOKEN={SYNTHETIC_GITHUB_TOKEN}")
|
|
87
|
+
all_email_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
|
|
88
|
+
secret_finding = next(
|
|
89
|
+
each for each in all_secret_hits if each.category == "secret"
|
|
90
|
+
)
|
|
91
|
+
email_finding = next(each for each in all_email_hits if each.category == "email")
|
|
92
|
+
assert secret_finding.matched_text == SYNTHETIC_GITHUB_TOKEN
|
|
93
|
+
assert SYNTHETIC_GITHUB_TOKEN not in secret_finding.preview
|
|
94
|
+
assert "…" in secret_finding.preview
|
|
95
|
+
assert email_finding.matched_text == SYNTHETIC_REAL_EMAIL
|
|
96
|
+
assert SYNTHETIC_REAL_EMAIL not in email_finding.preview
|
|
97
|
+
assert "…" in email_finding.preview
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_allows_common_cloud_and_system_home_usernames() -> None:
|
|
101
|
+
assert scan_text_for_pii(r"C:\Users\ubuntu\notes.txt") == []
|
|
102
|
+
assert scan_text_for_pii(r"C:\Users\admin\notes.txt") == []
|
|
103
|
+
assert scan_text_for_pii("/home/runner/work/repo") == []
|
|
104
|
+
assert scan_text_for_pii("/home/container/app") == []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_flags_genuine_human_home_username() -> None:
|
|
108
|
+
all_human_hits = scan_text_for_pii(r"C:\Users\johnsmith\notes.txt")
|
|
109
|
+
assert any(each.category == "home-path" for each in all_human_hits)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_short_nonsafe_email_preview_is_fully_redacted() -> None:
|
|
113
|
+
all_email_hits = scan_text_for_pii("reach owner@acme.io")
|
|
114
|
+
email_finding = next(
|
|
115
|
+
each for each in all_email_hits if each.category == "email"
|
|
116
|
+
)
|
|
117
|
+
assert email_finding.matched_text == "owner@acme.io"
|
|
118
|
+
assert email_finding.preview == "[redacted]"
|
|
119
|
+
assert "owner" not in email_finding.preview
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_home_path_preview_redacts_only_the_username() -> None:
|
|
123
|
+
all_home_hits = scan_text_for_pii(r"path is C:\Users\johnsmith\secret.txt")
|
|
124
|
+
home_finding = next(
|
|
125
|
+
each for each in all_home_hits if each.category == "home-path"
|
|
126
|
+
)
|
|
127
|
+
assert "johnsmith" not in home_finding.preview
|
|
128
|
+
assert "[redacted]" in home_finding.preview
|
|
129
|
+
assert "Users" in home_finding.preview
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_home_path_preview_redacts_home_segment_when_name_repeats_earlier() -> None:
|
|
133
|
+
all_home_hits = scan_text_for_pii(r"path is C:\Users\Users\secret.txt")
|
|
134
|
+
home_finding = next(
|
|
135
|
+
each for each in all_home_hits if each.category == "home-path"
|
|
136
|
+
)
|
|
137
|
+
assert home_finding.preview == r"C:\Users\[redacted]"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_clean_prose_returns_no_findings() -> None:
|
|
141
|
+
prose = "Ship the fix. Use user@example.com in docs. Path is C:/Users/<you>/."
|
|
142
|
+
assert scan_text_for_pii(prose) == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_path_exemptions_for_tests_license_and_self_modules() -> None:
|
|
146
|
+
assert is_path_exempt_from_pii_scan("packages/hooks/blocking/test_pii_scanner.py")
|
|
147
|
+
assert is_path_exempt_from_pii_scan("LICENSE")
|
|
148
|
+
assert is_path_exempt_from_pii_scan("LICENSE.md")
|
|
149
|
+
assert is_path_exempt_from_pii_scan("hooks/blocking/pii_scanner.py")
|
|
150
|
+
assert is_path_exempt_from_pii_scan(
|
|
151
|
+
"packages/claude-dev-env/hooks/hooks_constants/pii_prevention_constants.py"
|
|
152
|
+
)
|
|
153
|
+
assert not is_path_exempt_from_pii_scan("vendor/pii_scanner.py")
|
|
154
|
+
assert not is_path_exempt_from_pii_scan("not_hooks/blocking/pii_scanner.py")
|
|
155
|
+
assert not is_path_exempt_from_pii_scan("xhooks/blocking/pii_scanner.py")
|
|
156
|
+
assert not is_path_exempt_from_pii_scan("LICENSE_leak.env")
|
|
157
|
+
assert not is_path_exempt_from_pii_scan("src/app/settings.md")
|
|
158
|
+
assert not is_path_exempt_from_pii_scan("test_notes.md")
|
|
159
|
+
assert not is_path_exempt_from_pii_scan("test_secrets.env")
|
|
160
|
+
assert not is_path_exempt_from_pii_scan("")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_empty_path_still_scans_payload_text() -> None:
|
|
164
|
+
all_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
|
|
165
|
+
assert any(each.category == "email" for each in all_hits)
|
|
@@ -33,7 +33,7 @@ def test_extract_pr_number_from_gh_pr_comment() -> None:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def test_extract_pr_number_from_gh_pr_create_returns_none() -> None:
|
|
36
|
-
command = 'gh pr create --repo jl-cmd/claude-
|
|
36
|
+
command = 'gh pr create --repo jl-cmd/claude-dev-env --body "some body"'
|
|
37
37
|
assert hook_module._extract_pr_number_from_command(command) is None
|
|
38
38
|
|
|
39
39
|
|
|
@@ -20,9 +20,12 @@ from pathlib import Path
|
|
|
20
20
|
|
|
21
21
|
import pytest
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
24
|
+
_HOOKS_ROOT = _BLOCKING_DIR.parent
|
|
25
|
+
if str(_BLOCKING_DIR) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(_BLOCKING_DIR))
|
|
27
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
28
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
26
29
|
|
|
27
30
|
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
28
31
|
ALL_HOSTED_HOOK_ENTRIES,
|
|
@@ -41,8 +44,6 @@ from pre_tool_use_dispatcher import ( # noqa: E402, I001
|
|
|
41
44
|
run_hosted_hook,
|
|
42
45
|
)
|
|
43
46
|
|
|
44
|
-
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
45
|
-
_HOOKS_ROOT = _BLOCKING_DIR.parent
|
|
46
47
|
_DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
|
|
47
48
|
|
|
48
49
|
_TEMP_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.txt")
|
|
@@ -632,8 +633,8 @@ def test_dispatcher_write_applies_both_groups() -> None:
|
|
|
632
633
|
assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
|
|
633
634
|
"plain_language_blocker (Group B) must be in Write applicable set"
|
|
634
635
|
)
|
|
635
|
-
assert len(all_write_entries) ==
|
|
636
|
-
f"Write tool must apply to all
|
|
636
|
+
assert len(all_write_entries) == 20, (
|
|
637
|
+
f"Write tool must apply to all 20 hosted hooks, got {len(all_write_entries)}"
|
|
637
638
|
)
|
|
638
639
|
|
|
639
640
|
|
|
@@ -646,16 +647,16 @@ def test_dispatcher_edit_applies_both_groups() -> None:
|
|
|
646
647
|
assert "blocking/stale_comment_reference_blocker.py" in all_edit_script_paths, (
|
|
647
648
|
"stale_comment_reference_blocker belongs in the Edit applicable set"
|
|
648
649
|
)
|
|
649
|
-
assert len(all_edit_entries) ==
|
|
650
|
-
f"expected
|
|
650
|
+
assert len(all_edit_entries) == 21, (
|
|
651
|
+
f"expected 21 Edit entries, got {len(all_edit_entries)}"
|
|
651
652
|
)
|
|
652
653
|
|
|
653
654
|
|
|
654
655
|
def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
655
|
-
"""MultiEdit tool triggers only Group B (
|
|
656
|
+
"""MultiEdit tool triggers only Group B (9 hooks), not Group A."""
|
|
656
657
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
657
|
-
assert len(all_multi_edit_entries) ==
|
|
658
|
-
f"MultiEdit tool must apply to exactly
|
|
658
|
+
assert len(all_multi_edit_entries) == 9, (
|
|
659
|
+
f"MultiEdit tool must apply to exactly 9 Group-B hooks, got {len(all_multi_edit_entries)}"
|
|
659
660
|
)
|
|
660
661
|
|
|
661
662
|
|
|
@@ -666,7 +667,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
|
|
|
666
667
|
it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
|
|
667
668
|
A Python Write payload that run_all_validators would have flagged (mypy errors, for
|
|
668
669
|
instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
|
|
669
|
-
dispatcher covers only its
|
|
670
|
+
dispatcher covers only its 21 hosted blocking hooks — none of which includes the
|
|
670
671
|
validators runner.
|
|
671
672
|
"""
|
|
672
673
|
python_content_with_type_error = (
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Behavior tests for the Stop-hook dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
15
|
+
if _HOOKS_DIR not in sys.path:
|
|
16
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
17
|
+
_blocking_dir_text = str(_BLOCKING_DIR)
|
|
18
|
+
if _blocking_dir_text not in sys.path:
|
|
19
|
+
sys.path.insert(0, _blocking_dir_text)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.hosted_hook_runner import HostedHookRun # noqa: E402
|
|
22
|
+
from stop_dispatcher import dispatch, select_first_block_stdout # noqa: E402
|
|
23
|
+
|
|
24
|
+
_DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "stop_dispatcher.py")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _block_json(
|
|
28
|
+
reason: str, *, system_message: str = "", suppress_output: bool = False
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Build a Stop block stdout string with optional supplementary fields."""
|
|
31
|
+
payload: dict[str, object] = {"decision": "block", "reason": reason}
|
|
32
|
+
if system_message:
|
|
33
|
+
payload["systemMessage"] = system_message
|
|
34
|
+
if suppress_output:
|
|
35
|
+
payload["suppressOutput"] = True
|
|
36
|
+
return json.dumps(payload)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_first_block_wins_among_non_crashed_runs() -> None:
|
|
40
|
+
"""The first non-crashed block decision is the one the dispatcher emits."""
|
|
41
|
+
all_runs = [
|
|
42
|
+
HostedHookRun(captured_stdout="", did_crash=False),
|
|
43
|
+
HostedHookRun(captured_stdout=_block_json("first"), did_crash=False),
|
|
44
|
+
HostedHookRun(captured_stdout=_block_json("second"), did_crash=False),
|
|
45
|
+
]
|
|
46
|
+
selected = select_first_block_stdout(all_runs)
|
|
47
|
+
assert json.loads(selected)["reason"] == "first"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_crashed_hook_before_block_is_skipped() -> None:
|
|
51
|
+
"""A crash before a block fails open so a later block still surfaces."""
|
|
52
|
+
all_runs = [
|
|
53
|
+
HostedHookRun(captured_stdout="", did_crash=True),
|
|
54
|
+
HostedHookRun(captured_stdout=_block_json("later"), did_crash=False),
|
|
55
|
+
]
|
|
56
|
+
selected = select_first_block_stdout(all_runs)
|
|
57
|
+
assert json.loads(selected)["reason"] == "later"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_no_block_when_every_hook_is_silent() -> None:
|
|
61
|
+
"""A silent chain produces no block stdout."""
|
|
62
|
+
assert select_first_block_stdout([HostedHookRun("", False), HostedHookRun("", False)]) == ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_select_preserves_system_message_and_suppress_output() -> None:
|
|
66
|
+
"""select_first_block_stdout keeps supplementary fields on the winning block."""
|
|
67
|
+
all_runs = [
|
|
68
|
+
HostedHookRun(captured_stdout="", did_crash=True),
|
|
69
|
+
HostedHookRun(
|
|
70
|
+
captured_stdout=_block_json(
|
|
71
|
+
"keep fields",
|
|
72
|
+
system_message="rewrite the ending",
|
|
73
|
+
suppress_output=True,
|
|
74
|
+
),
|
|
75
|
+
did_crash=False,
|
|
76
|
+
),
|
|
77
|
+
]
|
|
78
|
+
selected = json.loads(select_first_block_stdout(all_runs))
|
|
79
|
+
assert selected["reason"] == "keep fields"
|
|
80
|
+
assert selected["systemMessage"] == "rewrite the ending"
|
|
81
|
+
assert selected["suppressOutput"] is True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_dispatch_re_emits_first_block_with_system_message_and_suppress(
|
|
85
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
86
|
+
capsys: pytest.CaptureFixture[str],
|
|
87
|
+
) -> None:
|
|
88
|
+
"""dispatch re-emits the first block's full JSON including supplementary fields."""
|
|
89
|
+
all_responses = [
|
|
90
|
+
HostedHookRun(captured_stdout="", did_crash=False),
|
|
91
|
+
HostedHookRun(
|
|
92
|
+
captured_stdout=_block_json(
|
|
93
|
+
"hedging blocked",
|
|
94
|
+
system_message="rewrite the ending",
|
|
95
|
+
suppress_output=True,
|
|
96
|
+
),
|
|
97
|
+
did_crash=False,
|
|
98
|
+
),
|
|
99
|
+
HostedHookRun(captured_stdout=_block_json("later block"), did_crash=False),
|
|
100
|
+
]
|
|
101
|
+
response_iterator = iter(all_responses)
|
|
102
|
+
|
|
103
|
+
def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
|
|
104
|
+
del script_path, payload_text
|
|
105
|
+
return next(response_iterator)
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr("stop_dispatcher.run_hook_capturing_output", _fake_run_hook)
|
|
108
|
+
monkeypatch.setattr(
|
|
109
|
+
"stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
|
|
110
|
+
(
|
|
111
|
+
"blocking/hedging_language_blocker.py",
|
|
112
|
+
"blocking/question_to_user_enforcer.py",
|
|
113
|
+
"diagnostic/hook_log_stop_wrapper.py",
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
dispatch('{"session_id": "test"}')
|
|
117
|
+
emitted_payload = json.loads(capsys.readouterr().out.strip())
|
|
118
|
+
assert emitted_payload["decision"] == "block"
|
|
119
|
+
assert emitted_payload["reason"] == "hedging blocked"
|
|
120
|
+
assert emitted_payload["systemMessage"] == "rewrite the ending"
|
|
121
|
+
assert emitted_payload["suppressOutput"] is True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_dispatch_writes_nothing_when_no_hook_blocks(
|
|
125
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
126
|
+
capsys: pytest.CaptureFixture[str],
|
|
127
|
+
) -> None:
|
|
128
|
+
"""dispatch leaves stdout empty when every hosted hook is silent or allows."""
|
|
129
|
+
monkeypatch.setattr(
|
|
130
|
+
"stop_dispatcher.run_hook_capturing_output",
|
|
131
|
+
lambda script_path, payload_text: HostedHookRun(captured_stdout="", did_crash=False),
|
|
132
|
+
)
|
|
133
|
+
monkeypatch.setattr(
|
|
134
|
+
"stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
|
|
135
|
+
("blocking/hedging_language_blocker.py",),
|
|
136
|
+
)
|
|
137
|
+
dispatch('{"session_id": "test"}')
|
|
138
|
+
assert capsys.readouterr().out == ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_dispatcher_exits_zero_on_empty_payload() -> None:
|
|
142
|
+
"""The dispatcher exits zero when stdin is empty."""
|
|
143
|
+
completed = subprocess.run(
|
|
144
|
+
[sys.executable, _DISPATCHER_SCRIPT],
|
|
145
|
+
check=False,
|
|
146
|
+
input="",
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
)
|
|
150
|
+
assert completed.returncode == 0
|
|
151
|
+
assert completed.stdout.strip() == ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_dispatcher_blocks_hedging_message_matching_standalone() -> None:
|
|
155
|
+
"""A hedging last_assistant_message blocks through the dispatcher."""
|
|
156
|
+
payload_text = json.dumps(
|
|
157
|
+
{
|
|
158
|
+
"stop_hook_active": False,
|
|
159
|
+
"last_assistant_message": "This is probably correct without a source.",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
completed = subprocess.run(
|
|
163
|
+
[sys.executable, _DISPATCHER_SCRIPT],
|
|
164
|
+
check=False,
|
|
165
|
+
input=payload_text,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
text=True,
|
|
168
|
+
encoding="utf-8",
|
|
169
|
+
)
|
|
170
|
+
assert completed.returncode == 0
|
|
171
|
+
parsed = json.loads(completed.stdout)
|
|
172
|
+
assert parsed["decision"] == "block"
|
|
173
|
+
assert "probably" in parsed["reason"].lower() or "hedging" in parsed["reason"].lower()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_dispatcher_imports_standalone_with_only_blocking_on_the_path() -> None:
|
|
177
|
+
"""The dispatcher's bootstrap resolves hooks_constants without hooks/ on PYTHONPATH."""
|
|
178
|
+
subprocess_environment = {**os.environ, "PYTHONPATH": str(_BLOCKING_DIR)}
|
|
179
|
+
completed = subprocess.run(
|
|
180
|
+
[sys.executable, "-c", "import stop_dispatcher; print('ok')"],
|
|
181
|
+
check=False,
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
env=subprocess_environment,
|
|
185
|
+
)
|
|
186
|
+
assert completed.returncode == 0, completed.stderr
|
|
187
|
+
assert completed.stdout.strip() == "ok"
|
|
@@ -78,7 +78,7 @@ def test_powershell_out_file_into_verdict_directory_is_flagged() -> None:
|
|
|
78
78
|
def test_backslash_verdict_path_is_flagged() -> None:
|
|
79
79
|
assert (
|
|
80
80
|
references_verdict_directory(
|
|
81
|
-
"echo forged > C:\\Users\\
|
|
81
|
+
"echo forged > C:\\Users\\example\\.claude\\verification\\abc.json"
|
|
82
82
|
)
|
|
83
83
|
is True
|
|
84
84
|
)
|
|
@@ -31,7 +31,7 @@ def _body_names_volatile_path(tool_name: str, tool_input: dict[str, object]) ->
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def test_scan_detects_job_scratch_path_backslash() -> None:
|
|
34
|
-
text = r"See C:\Users\
|
|
34
|
+
text = r"See C:\Users\example\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"
|
|
35
35
|
assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
|
|
36
36
|
|
|
37
37
|
|
|
@@ -46,7 +46,7 @@ def test_scan_detects_worktree_path() -> None:
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def test_scan_detects_appdata_temp_case_insensitive() -> None:
|
|
49
|
-
text = r"C:\Users\
|
|
49
|
+
text = r"C:\Users\example\AppData\Local\Temp\bugteam\worktree"
|
|
50
50
|
assert scan_text_for_volatile_marker(text) == "appdata/local/temp"
|
|
51
51
|
|
|
52
52
|
|
|
@@ -74,7 +74,7 @@ def test_scan_clean_body_returns_none() -> None:
|
|
|
74
74
|
def test_gh_comment_with_job_scratch_path_is_blocked() -> None:
|
|
75
75
|
command = (
|
|
76
76
|
'gh pr comment 669 --body "Contact sheet at '
|
|
77
|
-
r'C:\Users\
|
|
77
|
+
r'C:\Users\example\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"'
|
|
78
78
|
)
|
|
79
79
|
assert _body_names_volatile_path("Bash", {"command": command})
|
|
80
80
|
|
|
@@ -220,11 +220,14 @@ def _collect_body_flag_values(
|
|
|
220
220
|
return all_inline_bodies, all_body_file_paths
|
|
221
221
|
|
|
222
222
|
|
|
223
|
-
def _read_body_file(
|
|
223
|
+
def _read_body_file(
|
|
224
|
+
body_file_path: str, working_directory: str | None = None
|
|
225
|
+
) -> str | None:
|
|
224
226
|
"""Return the contents of a body-file path, or None when it cannot be read.
|
|
225
227
|
|
|
226
228
|
Args:
|
|
227
229
|
body_file_path: The path given to ``--body-file``/``-F``.
|
|
230
|
+
working_directory: Optional base directory for relative body-file paths.
|
|
228
231
|
|
|
229
232
|
Returns:
|
|
230
233
|
The file text, or None for an unresolvable shell value (such as ``-`` for
|
|
@@ -232,21 +235,28 @@ def _read_body_file(body_file_path: str) -> str | None:
|
|
|
232
235
|
"""
|
|
233
236
|
if is_unresolvable_shell_value(body_file_path):
|
|
234
237
|
return None
|
|
238
|
+
resolved_path = Path(body_file_path)
|
|
239
|
+
if not resolved_path.is_absolute() and working_directory:
|
|
240
|
+
resolved_path = Path(working_directory) / body_file_path
|
|
235
241
|
try:
|
|
236
|
-
return
|
|
242
|
+
return resolved_path.read_text(encoding=BODY_FILE_ENCODING)
|
|
237
243
|
except OSError:
|
|
238
244
|
return None
|
|
239
245
|
|
|
240
246
|
|
|
241
|
-
def extract_gh_post_body_texts(
|
|
247
|
+
def extract_gh_post_body_texts(
|
|
248
|
+
command: str, working_directory: str | None = None
|
|
249
|
+
) -> list[str]:
|
|
242
250
|
"""Return every post body text an affected ``gh`` command would send.
|
|
243
251
|
|
|
244
252
|
Non-post ``gh`` commands and unparseable command lines yield an empty list.
|
|
245
253
|
Body-file contents are read from disk so the embedded text is scanned rather
|
|
246
|
-
than the file path.
|
|
254
|
+
than the file path. Relative body-file paths resolve against
|
|
255
|
+
*working_directory* when provided.
|
|
247
256
|
|
|
248
257
|
Args:
|
|
249
258
|
command: The raw Bash tool command string.
|
|
259
|
+
working_directory: Optional base directory for relative body-file paths.
|
|
250
260
|
|
|
251
261
|
Returns:
|
|
252
262
|
Inline ``--body`` strings plus the contents of each readable body-file.
|
|
@@ -265,12 +275,51 @@ def extract_gh_post_body_texts(command: str) -> list[str]:
|
|
|
265
275
|
)
|
|
266
276
|
all_body_texts = list(all_inline_bodies)
|
|
267
277
|
for each_path in all_body_file_paths:
|
|
268
|
-
file_text = _read_body_file(each_path)
|
|
278
|
+
file_text = _read_body_file(each_path, working_directory=working_directory)
|
|
269
279
|
if file_text is not None:
|
|
270
280
|
all_body_texts.append(file_text)
|
|
271
281
|
return all_body_texts
|
|
272
282
|
|
|
273
283
|
|
|
284
|
+
def extract_gh_post_body_texts_for_privacy_gate(
|
|
285
|
+
command: str, working_directory: str | None = None
|
|
286
|
+
) -> tuple[list[str], str | None]:
|
|
287
|
+
"""Like ``extract_gh_post_body_texts``, but fail-closed on unreadable body-files.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
command: The raw Bash tool command string.
|
|
291
|
+
working_directory: Optional base directory for relative body-file paths.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
``(all_body_texts, None)`` when every declared body-file was read (or
|
|
295
|
+
none were declared), else ``([], deny_reason)`` when a body-file flag is
|
|
296
|
+
present but its contents could not be loaded for scanning.
|
|
297
|
+
"""
|
|
298
|
+
logical_line = get_logical_first_line(command)
|
|
299
|
+
if not logical_line:
|
|
300
|
+
return [], None
|
|
301
|
+
try:
|
|
302
|
+
all_command_tokens = shlex.split(logical_line, posix=False)
|
|
303
|
+
except ValueError:
|
|
304
|
+
return [], None
|
|
305
|
+
if not _tokens_name_gh_post_command(all_command_tokens):
|
|
306
|
+
return [], None
|
|
307
|
+
all_inline_bodies, all_body_file_paths = _collect_body_flag_values(
|
|
308
|
+
all_command_tokens
|
|
309
|
+
)
|
|
310
|
+
all_body_texts = list(all_inline_bodies)
|
|
311
|
+
for each_path in all_body_file_paths:
|
|
312
|
+
file_text = _read_body_file(each_path, working_directory=working_directory)
|
|
313
|
+
if file_text is None:
|
|
314
|
+
return [], (
|
|
315
|
+
"BLOCKED [pii_prevention_blocker]: durable post uses --body-file "
|
|
316
|
+
f"but '{each_path}' could not be read for PII scanning. Use an "
|
|
317
|
+
"absolute path, ensure the file exists, or pass --body text."
|
|
318
|
+
)
|
|
319
|
+
all_body_texts.append(file_text)
|
|
320
|
+
return all_body_texts, None
|
|
321
|
+
|
|
322
|
+
|
|
274
323
|
def extract_mcp_body_texts(all_tool_input: dict[str, object]) -> list[str]:
|
|
275
324
|
"""Return the body and comment strings from a GitHub MCP post tool input.
|
|
276
325
|
|