claude-dev-env 1.92.1 → 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 +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 +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 +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,905 @@
|
|
|
1
|
+
"""Behavior tests for the PII prevention PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
_HOOK_DIR = Path(__file__).parent
|
|
13
|
+
_HOOKS_DIR = _HOOK_DIR.parent
|
|
14
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
16
|
+
if str(_HOOKS_DIR) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_HOOKS_DIR))
|
|
18
|
+
|
|
19
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
20
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
21
|
+
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
22
|
+
)
|
|
23
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
24
|
+
ALL_HOSTED_HOOK_ENTRIES,
|
|
25
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
26
|
+
)
|
|
27
|
+
import pii_prevention_blocker as blocker_module # noqa: E402
|
|
28
|
+
from pii_prevention_blocker import ( # noqa: E402
|
|
29
|
+
evaluate,
|
|
30
|
+
evaluate_bash_command,
|
|
31
|
+
evaluate_staged_commit,
|
|
32
|
+
evaluate_write_edit_payload,
|
|
33
|
+
extract_git_commit_working_directory,
|
|
34
|
+
is_git_commit_shell_command,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
HOOK_PATH = _HOOK_DIR / "pii_prevention_blocker.py"
|
|
38
|
+
|
|
39
|
+
SYNTHETIC_GITHUB_TOKEN = "ghp_" + ("C" * 36)
|
|
40
|
+
SYNTHETIC_REAL_EMAIL = "owner.fixture@acme-corp.example.io"
|
|
41
|
+
SYNTHETIC_HOME_PATH = r"C:\Users\fixture_commit_user\secret.txt"
|
|
42
|
+
SYNTHETIC_PRIVATE_IP = "10.44.12.9"
|
|
43
|
+
SYNTHETIC_SAFE_BODY = "All checks pass. Contact user@example.com for docs."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_hook(payload: dict[str, object]) -> tuple[int, str]:
|
|
47
|
+
completed = subprocess.run(
|
|
48
|
+
[sys.executable, str(HOOK_PATH)],
|
|
49
|
+
input=json.dumps(payload),
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=30,
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
return completed.returncode, completed.stdout
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_write_with_real_email_is_denied() -> None:
|
|
59
|
+
deny_reason = evaluate_write_edit_payload(
|
|
60
|
+
"Write",
|
|
61
|
+
{
|
|
62
|
+
"file_path": "docs/notes.md",
|
|
63
|
+
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
assert deny_reason is not None
|
|
67
|
+
assert "email" in deny_reason
|
|
68
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_write_with_example_email_is_allowed() -> None:
|
|
72
|
+
deny_reason = evaluate_write_edit_payload(
|
|
73
|
+
"Write",
|
|
74
|
+
{
|
|
75
|
+
"file_path": "docs/notes.md",
|
|
76
|
+
"content": "Reach me at user@example.com",
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
assert deny_reason is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_edit_introducing_home_path_is_denied() -> None:
|
|
83
|
+
deny_reason = evaluate_write_edit_payload(
|
|
84
|
+
"Edit",
|
|
85
|
+
{
|
|
86
|
+
"file_path": "README.md",
|
|
87
|
+
"old_string": "path here",
|
|
88
|
+
"new_string": f"path is {SYNTHETIC_HOME_PATH}",
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
assert deny_reason is not None
|
|
92
|
+
assert "home-path" in deny_reason
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_write_to_test_file_is_exempt() -> None:
|
|
96
|
+
deny_reason = evaluate_write_edit_payload(
|
|
97
|
+
"Write",
|
|
98
|
+
{
|
|
99
|
+
"file_path": "packages/hooks/blocking/test_fixture_helper.py",
|
|
100
|
+
"content": f"EMAIL = '{SYNTHETIC_REAL_EMAIL}'",
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
assert deny_reason is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_write_to_test_prefixed_markdown_is_not_exempt() -> None:
|
|
107
|
+
deny_reason = evaluate_write_edit_payload(
|
|
108
|
+
"Write",
|
|
109
|
+
{
|
|
110
|
+
"file_path": "test_notes.md",
|
|
111
|
+
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
assert deny_reason is not None
|
|
115
|
+
assert "email" in deny_reason
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_write_with_empty_path_still_scans_content() -> None:
|
|
119
|
+
deny_reason = evaluate_write_edit_payload(
|
|
120
|
+
"Write",
|
|
121
|
+
{
|
|
122
|
+
"file_path": "",
|
|
123
|
+
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
assert deny_reason is not None
|
|
127
|
+
assert "email" in deny_reason
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_write_to_basename_only_self_module_name_is_not_exempt() -> None:
|
|
131
|
+
deny_reason = evaluate_write_edit_payload(
|
|
132
|
+
"Write",
|
|
133
|
+
{
|
|
134
|
+
"file_path": "vendor/pii_scanner.py",
|
|
135
|
+
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
assert deny_reason is not None
|
|
139
|
+
assert "email" in deny_reason
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_write_to_license_prefix_filename_is_not_exempt() -> None:
|
|
143
|
+
deny_reason = evaluate_write_edit_payload(
|
|
144
|
+
"Write",
|
|
145
|
+
{
|
|
146
|
+
"file_path": "LICENSE_leak.env",
|
|
147
|
+
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
assert deny_reason is not None
|
|
151
|
+
assert "email" in deny_reason
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_gh_body_file_fails_closed_when_unreadable(tmp_path: Path) -> None:
|
|
155
|
+
missing_body_file = tmp_path / "missing_pr_body.md"
|
|
156
|
+
command = f'gh pr comment 12 --body-file "{missing_body_file}"'
|
|
157
|
+
deny_reason = evaluate_bash_command(command, working_directory=str(tmp_path))
|
|
158
|
+
assert deny_reason is not None
|
|
159
|
+
assert "body-file" in deny_reason
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_gh_body_file_reads_relative_path_from_working_directory(
|
|
163
|
+
tmp_path: Path,
|
|
164
|
+
) -> None:
|
|
165
|
+
body_file = tmp_path / "pr.md"
|
|
166
|
+
body_file.write_text(f"Reach me at {SYNTHETIC_REAL_EMAIL}\n", encoding="utf-8")
|
|
167
|
+
deny_reason = evaluate_bash_command(
|
|
168
|
+
"gh pr comment 12 --body-file pr.md",
|
|
169
|
+
working_directory=str(tmp_path),
|
|
170
|
+
)
|
|
171
|
+
assert deny_reason is not None
|
|
172
|
+
assert "email" in deny_reason
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_gh_post_body_with_secret_is_denied() -> None:
|
|
176
|
+
command = (
|
|
177
|
+
f'gh pr comment 12 --body "auth material {SYNTHETIC_GITHUB_TOKEN}"'
|
|
178
|
+
)
|
|
179
|
+
deny_reason = evaluate_bash_command(command, working_directory=None)
|
|
180
|
+
assert deny_reason is not None
|
|
181
|
+
assert "secret" in deny_reason
|
|
182
|
+
assert SYNTHETIC_GITHUB_TOKEN not in deny_reason
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_gh_post_clean_body_is_allowed() -> None:
|
|
186
|
+
command = f'gh pr comment 12 --body "{SYNTHETIC_SAFE_BODY}"'
|
|
187
|
+
assert evaluate_bash_command(command, working_directory=None) is None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_mcp_github_body_with_private_ip_is_denied() -> None:
|
|
191
|
+
deny_reason = evaluate(
|
|
192
|
+
{
|
|
193
|
+
"tool_name": "mcp__plugin_github_github__add_issue_comment",
|
|
194
|
+
"tool_input": {
|
|
195
|
+
"body": f"Service is at {SYNTHETIC_PRIVATE_IP}",
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
assert deny_reason is not None
|
|
200
|
+
assert "private-ip" in deny_reason
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_subprocess_hook_denies_write_with_token() -> None:
|
|
204
|
+
payload = {
|
|
205
|
+
"tool_name": "Write",
|
|
206
|
+
"tool_input": {
|
|
207
|
+
"file_path": "src/config.env.example.md",
|
|
208
|
+
"content": f"TOKEN={SYNTHETIC_GITHUB_TOKEN}\n",
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
exit_code, stdout_text = _run_hook(payload)
|
|
212
|
+
assert exit_code == 0
|
|
213
|
+
assert stdout_text.strip()
|
|
214
|
+
payload_out = json.loads(stdout_text)
|
|
215
|
+
decision = payload_out["hookSpecificOutput"]["permissionDecision"]
|
|
216
|
+
reason = payload_out["hookSpecificOutput"]["permissionDecisionReason"]
|
|
217
|
+
assert decision == "deny"
|
|
218
|
+
assert "secret" in reason
|
|
219
|
+
assert SYNTHETIC_GITHUB_TOKEN not in reason
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_subprocess_hook_allows_clean_write() -> None:
|
|
223
|
+
payload = {
|
|
224
|
+
"tool_name": "Write",
|
|
225
|
+
"tool_input": {
|
|
226
|
+
"file_path": "docs/guide.md",
|
|
227
|
+
"content": "Use user@example.com and C:/Users/<you>/ for samples.\n",
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
exit_code, stdout_text = _run_hook(payload)
|
|
231
|
+
assert exit_code == 0
|
|
232
|
+
assert stdout_text.strip() == ""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_staged_commit_with_pii_is_denied(tmp_path: Path) -> None:
|
|
236
|
+
repository_root = tmp_path / "repo"
|
|
237
|
+
repository_root.mkdir()
|
|
238
|
+
subprocess.run(
|
|
239
|
+
["git", "init"],
|
|
240
|
+
cwd=repository_root,
|
|
241
|
+
check=True,
|
|
242
|
+
capture_output=True,
|
|
243
|
+
text=True,
|
|
244
|
+
)
|
|
245
|
+
subprocess.run(
|
|
246
|
+
["git", "config", "user.email", "dev@example.com"],
|
|
247
|
+
cwd=repository_root,
|
|
248
|
+
check=True,
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True,
|
|
251
|
+
)
|
|
252
|
+
subprocess.run(
|
|
253
|
+
["git", "config", "user.name", "Fixture Dev"],
|
|
254
|
+
cwd=repository_root,
|
|
255
|
+
check=True,
|
|
256
|
+
capture_output=True,
|
|
257
|
+
text=True,
|
|
258
|
+
)
|
|
259
|
+
tracked_file = repository_root / "notes.md"
|
|
260
|
+
tracked_file.write_text(
|
|
261
|
+
f"owner email {SYNTHETIC_REAL_EMAIL}\n",
|
|
262
|
+
encoding="utf-8",
|
|
263
|
+
)
|
|
264
|
+
subprocess.run(
|
|
265
|
+
["git", "add", "notes.md"],
|
|
266
|
+
cwd=repository_root,
|
|
267
|
+
check=True,
|
|
268
|
+
capture_output=True,
|
|
269
|
+
text=True,
|
|
270
|
+
)
|
|
271
|
+
deny_reason = evaluate_bash_command(
|
|
272
|
+
"git commit -m test",
|
|
273
|
+
working_directory=str(repository_root),
|
|
274
|
+
)
|
|
275
|
+
assert deny_reason is not None
|
|
276
|
+
assert "email" in deny_reason
|
|
277
|
+
assert "staged commit" in deny_reason
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_staged_commit_with_clean_content_is_allowed(tmp_path: Path) -> None:
|
|
281
|
+
repository_root = tmp_path / "repo"
|
|
282
|
+
repository_root.mkdir()
|
|
283
|
+
subprocess.run(
|
|
284
|
+
["git", "init"],
|
|
285
|
+
cwd=repository_root,
|
|
286
|
+
check=True,
|
|
287
|
+
capture_output=True,
|
|
288
|
+
text=True,
|
|
289
|
+
)
|
|
290
|
+
subprocess.run(
|
|
291
|
+
["git", "config", "user.email", "dev@example.com"],
|
|
292
|
+
cwd=repository_root,
|
|
293
|
+
check=True,
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
)
|
|
297
|
+
subprocess.run(
|
|
298
|
+
["git", "config", "user.name", "Fixture Dev"],
|
|
299
|
+
cwd=repository_root,
|
|
300
|
+
check=True,
|
|
301
|
+
capture_output=True,
|
|
302
|
+
text=True,
|
|
303
|
+
)
|
|
304
|
+
tracked_file = repository_root / "notes.md"
|
|
305
|
+
tracked_file.write_text(
|
|
306
|
+
"Use user@example.com in docs only.\n",
|
|
307
|
+
encoding="utf-8",
|
|
308
|
+
)
|
|
309
|
+
subprocess.run(
|
|
310
|
+
["git", "add", "notes.md"],
|
|
311
|
+
cwd=repository_root,
|
|
312
|
+
check=True,
|
|
313
|
+
capture_output=True,
|
|
314
|
+
text=True,
|
|
315
|
+
)
|
|
316
|
+
deny_reason = evaluate_bash_command(
|
|
317
|
+
"git commit -m test",
|
|
318
|
+
working_directory=str(repository_root),
|
|
319
|
+
)
|
|
320
|
+
assert deny_reason is None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_staged_commit_fails_closed_when_git_list_fails(
|
|
324
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
325
|
+
) -> None:
|
|
326
|
+
monkeypatch.setattr(
|
|
327
|
+
blocker_module,
|
|
328
|
+
"list_staged_file_paths",
|
|
329
|
+
lambda _repository_root: (None, "BLOCKED list failure"),
|
|
330
|
+
)
|
|
331
|
+
deny_reason = evaluate_staged_commit(tmp_path)
|
|
332
|
+
assert deny_reason == "BLOCKED list failure"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_staged_commit_fails_closed_when_blob_unscannable(
|
|
336
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
337
|
+
) -> None:
|
|
338
|
+
monkeypatch.setattr(
|
|
339
|
+
blocker_module,
|
|
340
|
+
"list_staged_file_paths",
|
|
341
|
+
lambda _repository_root: (["binary.bin"], None),
|
|
342
|
+
)
|
|
343
|
+
monkeypatch.setattr(
|
|
344
|
+
blocker_module,
|
|
345
|
+
"read_staged_file_text",
|
|
346
|
+
lambda _repository_root, relative_path: (
|
|
347
|
+
None,
|
|
348
|
+
f"BLOCKED unscannable {relative_path}",
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
deny_reason = evaluate_staged_commit(tmp_path)
|
|
352
|
+
assert deny_reason is not None
|
|
353
|
+
assert "unscannable" in deny_reason
|
|
354
|
+
assert "binary.bin" in deny_reason
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_dispatcher_roster_hosts_pii_blocker() -> None:
|
|
358
|
+
matching_write_entries = [
|
|
359
|
+
each_entry
|
|
360
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
361
|
+
if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
|
|
362
|
+
]
|
|
363
|
+
assert len(matching_write_entries) == 1
|
|
364
|
+
assert matching_write_entries[0].applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES
|
|
365
|
+
assert matching_write_entries[0].is_blocking is True
|
|
366
|
+
matching_bash_entries = [
|
|
367
|
+
each_entry
|
|
368
|
+
for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
|
|
369
|
+
if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
|
|
370
|
+
]
|
|
371
|
+
assert len(matching_bash_entries) == 1
|
|
372
|
+
assert (
|
|
373
|
+
matching_bash_entries[0].applicable_tool_names
|
|
374
|
+
== ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_is_git_commit_shell_command_covers_windows_and_flag_forms() -> None:
|
|
379
|
+
assert is_git_commit_shell_command("git commit -m test")
|
|
380
|
+
assert is_git_commit_shell_command("git.exe commit -m test")
|
|
381
|
+
assert is_git_commit_shell_command("git --no-verify commit -m test")
|
|
382
|
+
assert is_git_commit_shell_command("git -c commit.gpgsign=false commit -m x")
|
|
383
|
+
assert is_git_commit_shell_command('git -C "C:/repo" commit -m x')
|
|
384
|
+
assert is_git_commit_shell_command(r'& "C:\Program Files\Git\cmd\git.exe" commit -m x')
|
|
385
|
+
assert not is_git_commit_shell_command("git status")
|
|
386
|
+
assert not is_git_commit_shell_command("git log --grep commit")
|
|
387
|
+
assert not is_git_commit_shell_command('echo "please git commit"')
|
|
388
|
+
assert not is_git_commit_shell_command("gh pr comment 1 --body 'git commit'")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _init_repo_with_staged_email(repository_root: Path) -> None:
|
|
392
|
+
repository_root.mkdir()
|
|
393
|
+
subprocess.run(
|
|
394
|
+
["git", "init"],
|
|
395
|
+
cwd=repository_root,
|
|
396
|
+
check=True,
|
|
397
|
+
capture_output=True,
|
|
398
|
+
text=True,
|
|
399
|
+
)
|
|
400
|
+
subprocess.run(
|
|
401
|
+
["git", "config", "user.email", "dev@example.com"],
|
|
402
|
+
cwd=repository_root,
|
|
403
|
+
check=True,
|
|
404
|
+
capture_output=True,
|
|
405
|
+
text=True,
|
|
406
|
+
)
|
|
407
|
+
subprocess.run(
|
|
408
|
+
["git", "config", "user.name", "Fixture Dev"],
|
|
409
|
+
cwd=repository_root,
|
|
410
|
+
check=True,
|
|
411
|
+
capture_output=True,
|
|
412
|
+
text=True,
|
|
413
|
+
)
|
|
414
|
+
tracked_file = repository_root / "notes.md"
|
|
415
|
+
tracked_file.write_text(
|
|
416
|
+
f"owner email {SYNTHETIC_REAL_EMAIL}\n",
|
|
417
|
+
encoding="utf-8",
|
|
418
|
+
)
|
|
419
|
+
subprocess.run(
|
|
420
|
+
["git", "add", "notes.md"],
|
|
421
|
+
cwd=repository_root,
|
|
422
|
+
check=True,
|
|
423
|
+
capture_output=True,
|
|
424
|
+
text=True,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _init_repo_with_clean_notes(repository_root: Path) -> None:
|
|
429
|
+
repository_root.mkdir()
|
|
430
|
+
subprocess.run(
|
|
431
|
+
["git", "init"],
|
|
432
|
+
cwd=repository_root,
|
|
433
|
+
check=True,
|
|
434
|
+
capture_output=True,
|
|
435
|
+
text=True,
|
|
436
|
+
)
|
|
437
|
+
subprocess.run(
|
|
438
|
+
["git", "config", "user.email", "dev@example.com"],
|
|
439
|
+
cwd=repository_root,
|
|
440
|
+
check=True,
|
|
441
|
+
capture_output=True,
|
|
442
|
+
text=True,
|
|
443
|
+
)
|
|
444
|
+
subprocess.run(
|
|
445
|
+
["git", "config", "user.name", "Fixture Dev"],
|
|
446
|
+
cwd=repository_root,
|
|
447
|
+
check=True,
|
|
448
|
+
capture_output=True,
|
|
449
|
+
text=True,
|
|
450
|
+
)
|
|
451
|
+
tracked_file = repository_root / "notes.md"
|
|
452
|
+
tracked_file.write_text(
|
|
453
|
+
"Use user@example.com in docs only.\n",
|
|
454
|
+
encoding="utf-8",
|
|
455
|
+
)
|
|
456
|
+
subprocess.run(
|
|
457
|
+
["git", "add", "notes.md"],
|
|
458
|
+
cwd=repository_root,
|
|
459
|
+
check=True,
|
|
460
|
+
capture_output=True,
|
|
461
|
+
text=True,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_git_exe_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
466
|
+
repository_root = tmp_path / "repo"
|
|
467
|
+
_init_repo_with_staged_email(repository_root)
|
|
468
|
+
deny_reason = evaluate_bash_command(
|
|
469
|
+
"git.exe commit -m test",
|
|
470
|
+
working_directory=str(repository_root),
|
|
471
|
+
)
|
|
472
|
+
assert deny_reason is not None
|
|
473
|
+
assert "email" in deny_reason
|
|
474
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_git_config_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
478
|
+
repository_root = tmp_path / "repo"
|
|
479
|
+
_init_repo_with_staged_email(repository_root)
|
|
480
|
+
deny_reason = evaluate_bash_command(
|
|
481
|
+
"git -c commit.gpgsign=false commit -m test",
|
|
482
|
+
working_directory=str(repository_root),
|
|
483
|
+
)
|
|
484
|
+
assert deny_reason is not None
|
|
485
|
+
assert "email" in deny_reason
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_git_no_verify_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
489
|
+
repository_root = tmp_path / "repo"
|
|
490
|
+
_init_repo_with_staged_email(repository_root)
|
|
491
|
+
deny_reason = evaluate_bash_command(
|
|
492
|
+
"git --no-verify commit -m test",
|
|
493
|
+
working_directory=str(repository_root),
|
|
494
|
+
)
|
|
495
|
+
assert deny_reason is not None
|
|
496
|
+
assert "email" in deny_reason
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def test_repository_root_unresolved_fails_closed(
|
|
500
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
501
|
+
) -> None:
|
|
502
|
+
monkeypatch.setattr(blocker_module, "resolve_repository_root", lambda _cwd: None)
|
|
503
|
+
deny_reason = evaluate_bash_command(
|
|
504
|
+
"git commit -m test",
|
|
505
|
+
working_directory=str(Path.cwd()),
|
|
506
|
+
)
|
|
507
|
+
assert deny_reason is not None
|
|
508
|
+
assert "repository root" in deny_reason
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_powershell_tool_commit_with_staged_email_is_denied(tmp_path: Path) -> None:
|
|
512
|
+
repository_root = tmp_path / "repo"
|
|
513
|
+
_init_repo_with_staged_email(repository_root)
|
|
514
|
+
deny_reason = evaluate(
|
|
515
|
+
{
|
|
516
|
+
"tool_name": "PowerShell",
|
|
517
|
+
"tool_input": {
|
|
518
|
+
"command": "git commit -m test",
|
|
519
|
+
"working_directory": str(repository_root),
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
assert deny_reason is not None
|
|
524
|
+
assert "email" in deny_reason
|
|
525
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def test_is_git_commit_shell_command_detects_glued_and_newline_separators() -> None:
|
|
529
|
+
assert is_git_commit_shell_command("git add .;git commit -m x")
|
|
530
|
+
assert is_git_commit_shell_command("cd repo&&git commit -m x")
|
|
531
|
+
assert is_git_commit_shell_command("git add notes.md\ngit commit -m update")
|
|
532
|
+
assert is_git_commit_shell_command("git status\ngit commit -m x")
|
|
533
|
+
assert not is_git_commit_shell_command(
|
|
534
|
+
"gh pr comment 1 --body 'notes\ngit commit -m x'"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_is_git_commit_shell_command_detects_backgrounded_commit() -> None:
|
|
539
|
+
assert is_git_commit_shell_command("npm run build & git commit -am wip")
|
|
540
|
+
assert is_git_commit_shell_command("sleep 1 & git commit -m x")
|
|
541
|
+
assert is_git_commit_shell_command("& git commit -m x")
|
|
542
|
+
assert is_git_commit_shell_command("git commit -m x &")
|
|
543
|
+
assert not is_git_commit_shell_command("sleep 1 & git status")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def test_backgrounded_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
547
|
+
repository_root = tmp_path / "repo"
|
|
548
|
+
_init_repo_with_staged_email(repository_root)
|
|
549
|
+
deny_reason = evaluate_bash_command(
|
|
550
|
+
"npm run build & git commit -m test",
|
|
551
|
+
working_directory=str(repository_root),
|
|
552
|
+
)
|
|
553
|
+
assert deny_reason is not None
|
|
554
|
+
assert "email" in deny_reason
|
|
555
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_newline_separated_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
559
|
+
repository_root = tmp_path / "repo"
|
|
560
|
+
_init_repo_with_staged_email(repository_root)
|
|
561
|
+
deny_reason = evaluate_bash_command(
|
|
562
|
+
"git add notes.md\ngit commit -m update",
|
|
563
|
+
working_directory=str(repository_root),
|
|
564
|
+
)
|
|
565
|
+
assert deny_reason is not None
|
|
566
|
+
assert "email" in deny_reason
|
|
567
|
+
assert "staged commit" in deny_reason
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def test_glued_semicolon_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
571
|
+
repository_root = tmp_path / "repo"
|
|
572
|
+
_init_repo_with_staged_email(repository_root)
|
|
573
|
+
deny_reason = evaluate_bash_command(
|
|
574
|
+
"git add notes.md;git commit -m update",
|
|
575
|
+
working_directory=str(repository_root),
|
|
576
|
+
)
|
|
577
|
+
assert deny_reason is not None
|
|
578
|
+
assert "email" in deny_reason
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def test_is_git_commit_shell_command_detects_shell_keyword_wrapped_commits() -> None:
|
|
582
|
+
assert is_git_commit_shell_command("if true; then git commit -m x; fi")
|
|
583
|
+
assert is_git_commit_shell_command("for f in a b; do git commit -m x; done")
|
|
584
|
+
assert is_git_commit_shell_command("bash -c 'git commit -m x'")
|
|
585
|
+
assert is_git_commit_shell_command('sh -c "git commit -m x"')
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def test_is_git_commit_shell_command_detects_wrapper_prefixed_commits() -> None:
|
|
589
|
+
assert is_git_commit_shell_command("time git commit -m x")
|
|
590
|
+
assert is_git_commit_shell_command("sudo git commit -m x")
|
|
591
|
+
assert is_git_commit_shell_command("env GIT_AUTHOR_NAME=a git commit")
|
|
592
|
+
assert is_git_commit_shell_command("nice git commit -m y")
|
|
593
|
+
assert is_git_commit_shell_command("nice -n 10 git commit")
|
|
594
|
+
assert is_git_commit_shell_command("timeout 30 git commit")
|
|
595
|
+
assert is_git_commit_shell_command("env FOO=bar git commit")
|
|
596
|
+
assert not is_git_commit_shell_command("sudo cat notes.md")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def test_wrapper_prefix_stops_at_intervening_command_word() -> None:
|
|
600
|
+
assert not is_git_commit_shell_command("time echo git commit")
|
|
601
|
+
assert not is_git_commit_shell_command("sudo docker run git commit")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def test_wrapper_prefixed_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
605
|
+
repository_root = tmp_path / "repo"
|
|
606
|
+
_init_repo_with_staged_email(repository_root)
|
|
607
|
+
deny_reason = evaluate_bash_command(
|
|
608
|
+
"env GIT_AUTHOR_NAME=a git commit -m test",
|
|
609
|
+
working_directory=str(repository_root),
|
|
610
|
+
)
|
|
611
|
+
assert deny_reason is not None
|
|
612
|
+
assert "email" in deny_reason
|
|
613
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def test_shell_keyword_wrapped_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
617
|
+
repository_root = tmp_path / "repo"
|
|
618
|
+
_init_repo_with_staged_email(repository_root)
|
|
619
|
+
deny_reason = evaluate_bash_command(
|
|
620
|
+
"time git commit -m test",
|
|
621
|
+
working_directory=str(repository_root),
|
|
622
|
+
)
|
|
623
|
+
assert deny_reason is not None
|
|
624
|
+
assert "email" in deny_reason
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def test_is_git_commit_shell_command_detects_powershell_command_flag() -> None:
|
|
628
|
+
assert is_git_commit_shell_command('pwsh -NoProfile -Command "git commit -m x"')
|
|
629
|
+
assert is_git_commit_shell_command('powershell -Command "git commit -m x"')
|
|
630
|
+
assert is_git_commit_shell_command("pwsh.exe -Command 'git commit -m x'")
|
|
631
|
+
assert is_git_commit_shell_command('pwsh -c "git commit -m x"')
|
|
632
|
+
assert not is_git_commit_shell_command('pwsh -NoProfile -Command "git status"')
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def test_is_git_commit_shell_command_detects_powershell_command_abbreviations() -> None:
|
|
636
|
+
assert is_git_commit_shell_command('pwsh -Com "git commit -m x"')
|
|
637
|
+
assert is_git_commit_shell_command('pwsh -Comm "git commit -m x"')
|
|
638
|
+
assert is_git_commit_shell_command('pwsh -Comma "git commit -m x"')
|
|
639
|
+
assert is_git_commit_shell_command('pwsh -Comman "git commit -m x"')
|
|
640
|
+
assert not is_git_commit_shell_command('pwsh -NoProfile "git commit -m x"')
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def test_is_git_commit_shell_command_detects_flag_carrying_wrappers() -> None:
|
|
644
|
+
assert is_git_commit_shell_command("nice -n 10 git commit -m x")
|
|
645
|
+
assert is_git_commit_shell_command("stdbuf -oL git commit -m x")
|
|
646
|
+
assert is_git_commit_shell_command("env -i git commit -m x")
|
|
647
|
+
assert is_git_commit_shell_command("env -u NAME git commit -m x")
|
|
648
|
+
assert not is_git_commit_shell_command("nice -n 10 cat notes.md")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def test_is_git_commit_shell_command_detects_wrapper_flag_value_matching_binary() -> None:
|
|
652
|
+
assert is_git_commit_shell_command("sudo -u git git commit -m x")
|
|
653
|
+
assert is_git_commit_shell_command("sudo -u alice git commit -m x")
|
|
654
|
+
assert is_git_commit_shell_command("sudo -u sh git commit -m x")
|
|
655
|
+
assert is_git_commit_shell_command("doas -u git git commit -m x")
|
|
656
|
+
assert is_git_commit_shell_command("sudo -u git git commit")
|
|
657
|
+
assert not is_git_commit_shell_command("sudo -u git git status")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_is_git_commit_shell_command_detects_argument_bearing_wrappers() -> None:
|
|
661
|
+
assert is_git_commit_shell_command("timeout 30 git commit -m x")
|
|
662
|
+
assert is_git_commit_shell_command("nohup git commit -m x")
|
|
663
|
+
assert is_git_commit_shell_command("flock /tmp/lock git commit -m x")
|
|
664
|
+
assert is_git_commit_shell_command("ionice git commit -m x")
|
|
665
|
+
assert is_git_commit_shell_command("doas git commit -m x")
|
|
666
|
+
assert is_git_commit_shell_command("setsid git commit -m x")
|
|
667
|
+
assert not is_git_commit_shell_command("timeout 30 cat notes.md")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def test_argument_bearing_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
671
|
+
repository_root = tmp_path / "repo"
|
|
672
|
+
_init_repo_with_staged_email(repository_root)
|
|
673
|
+
deny_reason = evaluate_bash_command(
|
|
674
|
+
"timeout 30 git commit -m test",
|
|
675
|
+
working_directory=str(repository_root),
|
|
676
|
+
)
|
|
677
|
+
assert deny_reason is not None
|
|
678
|
+
assert "email" in deny_reason
|
|
679
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def test_is_git_commit_shell_command_detects_commit_after_hash_bearing_token() -> None:
|
|
683
|
+
assert is_git_commit_shell_command(
|
|
684
|
+
"curl https://example.com/page#section && git commit -am x"
|
|
685
|
+
)
|
|
686
|
+
assert is_git_commit_shell_command("echo done#note && git commit -m x")
|
|
687
|
+
assert is_git_commit_shell_command('git commit -m "fix #123"')
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def test_hash_bearing_token_before_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
691
|
+
repository_root = tmp_path / "repo"
|
|
692
|
+
_init_repo_with_staged_email(repository_root)
|
|
693
|
+
deny_reason = evaluate_bash_command(
|
|
694
|
+
"curl https://example.com/page#section && git commit -m test",
|
|
695
|
+
working_directory=str(repository_root),
|
|
696
|
+
)
|
|
697
|
+
assert deny_reason is not None
|
|
698
|
+
assert "email" in deny_reason
|
|
699
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def test_is_git_commit_shell_command_detects_combined_interpreter_flags() -> None:
|
|
703
|
+
assert is_git_commit_shell_command('bash -lc "git commit -m x"')
|
|
704
|
+
assert is_git_commit_shell_command("sh -ec 'git commit -m x'")
|
|
705
|
+
assert is_git_commit_shell_command('bash -l -c "git commit -m x"')
|
|
706
|
+
assert not is_git_commit_shell_command('bash -lc "git status"')
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def test_is_git_commit_shell_command_detects_c_first_interpreter_clusters() -> None:
|
|
710
|
+
assert is_git_commit_shell_command('bash -cx "git commit -m x"')
|
|
711
|
+
assert is_git_commit_shell_command('bash -cv "git commit -m x"')
|
|
712
|
+
assert is_git_commit_shell_command("sh -cx 'git commit -m x'")
|
|
713
|
+
assert not is_git_commit_shell_command("bash -x 'git status'")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def test_is_git_commit_shell_command_detects_unquoted_powershell_command() -> None:
|
|
717
|
+
assert is_git_commit_shell_command("pwsh -Command git commit -m x")
|
|
718
|
+
assert is_git_commit_shell_command("powershell -Command git commit -m wip")
|
|
719
|
+
assert not is_git_commit_shell_command("pwsh -Command git status")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def test_is_git_commit_shell_command_detects_powershell_preflags_before_command() -> None:
|
|
723
|
+
assert is_git_commit_shell_command(
|
|
724
|
+
'pwsh -ExecutionPolicy Bypass -Command "git commit -m x"'
|
|
725
|
+
)
|
|
726
|
+
assert is_git_commit_shell_command(
|
|
727
|
+
'pwsh -NonInteractive -Command "git commit -m x"'
|
|
728
|
+
)
|
|
729
|
+
assert is_git_commit_shell_command(
|
|
730
|
+
'powershell -ExecutionPolicy Bypass -Command "git commit -m x"'
|
|
731
|
+
)
|
|
732
|
+
assert is_git_commit_shell_command(
|
|
733
|
+
'pwsh -NoProfile -NonInteractive -Command "git commit -m x"'
|
|
734
|
+
)
|
|
735
|
+
assert not is_git_commit_shell_command(
|
|
736
|
+
'pwsh -NonInteractive -Command "git status"'
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def test_is_git_commit_shell_command_detects_subshell_grouped_commit() -> None:
|
|
741
|
+
assert is_git_commit_shell_command("(git commit -m x)")
|
|
742
|
+
assert is_git_commit_shell_command("(cd repo && git commit -m x)")
|
|
743
|
+
assert not is_git_commit_shell_command("(git status)")
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def test_powershell_preflag_command_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
747
|
+
repository_root = tmp_path / "repo"
|
|
748
|
+
_init_repo_with_staged_email(repository_root)
|
|
749
|
+
deny_reason = evaluate_bash_command(
|
|
750
|
+
'pwsh -ExecutionPolicy Bypass -Command "git commit -m test"',
|
|
751
|
+
working_directory=str(repository_root),
|
|
752
|
+
)
|
|
753
|
+
assert deny_reason is not None
|
|
754
|
+
assert "email" in deny_reason
|
|
755
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def test_subshell_grouped_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
759
|
+
repository_root = tmp_path / "repo"
|
|
760
|
+
_init_repo_with_staged_email(repository_root)
|
|
761
|
+
deny_reason = evaluate_bash_command(
|
|
762
|
+
"(git commit -m test)",
|
|
763
|
+
working_directory=str(repository_root),
|
|
764
|
+
)
|
|
765
|
+
assert deny_reason is not None
|
|
766
|
+
assert "email" in deny_reason
|
|
767
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def test_powershell_command_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
771
|
+
repository_root = tmp_path / "repo"
|
|
772
|
+
_init_repo_with_staged_email(repository_root)
|
|
773
|
+
deny_reason = evaluate_bash_command(
|
|
774
|
+
'pwsh -NoProfile -Command "git commit -m test"',
|
|
775
|
+
working_directory=str(repository_root),
|
|
776
|
+
)
|
|
777
|
+
assert deny_reason is not None
|
|
778
|
+
assert "email" in deny_reason
|
|
779
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def test_flag_carrying_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
783
|
+
repository_root = tmp_path / "repo"
|
|
784
|
+
_init_repo_with_staged_email(repository_root)
|
|
785
|
+
deny_reason = evaluate_bash_command(
|
|
786
|
+
"nice -n 10 git commit -m test",
|
|
787
|
+
working_directory=str(repository_root),
|
|
788
|
+
)
|
|
789
|
+
assert deny_reason is not None
|
|
790
|
+
assert "email" in deny_reason
|
|
791
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def test_combined_interpreter_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
795
|
+
repository_root = tmp_path / "repo"
|
|
796
|
+
_init_repo_with_staged_email(repository_root)
|
|
797
|
+
deny_reason = evaluate_bash_command(
|
|
798
|
+
'bash -lc "git commit -m test"',
|
|
799
|
+
working_directory=str(repository_root),
|
|
800
|
+
)
|
|
801
|
+
assert deny_reason is not None
|
|
802
|
+
assert "email" in deny_reason
|
|
803
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def test_c_first_interpreter_cluster_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
807
|
+
repository_root = tmp_path / "repo"
|
|
808
|
+
_init_repo_with_staged_email(repository_root)
|
|
809
|
+
deny_reason = evaluate_bash_command(
|
|
810
|
+
'bash -cx "git commit -m test"',
|
|
811
|
+
working_directory=str(repository_root),
|
|
812
|
+
)
|
|
813
|
+
assert deny_reason is not None
|
|
814
|
+
assert "email" in deny_reason
|
|
815
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def test_unquoted_powershell_command_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
819
|
+
repository_root = tmp_path / "repo"
|
|
820
|
+
_init_repo_with_staged_email(repository_root)
|
|
821
|
+
deny_reason = evaluate_bash_command(
|
|
822
|
+
"pwsh -Command git commit -m test",
|
|
823
|
+
working_directory=str(repository_root),
|
|
824
|
+
)
|
|
825
|
+
assert deny_reason is not None
|
|
826
|
+
assert "email" in deny_reason
|
|
827
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def test_is_git_commit_shell_command_detects_commit_after_apostrophe_line() -> None:
|
|
831
|
+
assert is_git_commit_shell_command("echo Don't forget\ngit commit -am x")
|
|
832
|
+
assert is_git_commit_shell_command("echo can't stop\ngit commit -am 'msg'")
|
|
833
|
+
assert is_git_commit_shell_command("printf Bob's file\ngit commit -m note")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def test_is_git_commit_shell_command_ignores_commit_inside_quoted_body() -> None:
|
|
837
|
+
assert not is_git_commit_shell_command(
|
|
838
|
+
"gh pr comment 1 --body 'notes\ngit commit -m x'"
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def test_apostrophe_line_before_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
843
|
+
repository_root = tmp_path / "repo"
|
|
844
|
+
_init_repo_with_staged_email(repository_root)
|
|
845
|
+
deny_reason = evaluate_bash_command(
|
|
846
|
+
"echo can't stop now\ngit commit -m test",
|
|
847
|
+
working_directory=str(repository_root),
|
|
848
|
+
)
|
|
849
|
+
assert deny_reason is not None
|
|
850
|
+
assert "email" in deny_reason
|
|
851
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def test_is_git_commit_shell_command_detects_powershell_backtick_continuation() -> None:
|
|
855
|
+
assert is_git_commit_shell_command("git `\n commit -m x")
|
|
856
|
+
assert is_git_commit_shell_command("git commit `\n -m x")
|
|
857
|
+
assert not is_git_commit_shell_command("git `\n status")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def test_powershell_backtick_continuation_commit_scans_staged_pii(
|
|
861
|
+
tmp_path: Path,
|
|
862
|
+
) -> None:
|
|
863
|
+
repository_root = tmp_path / "repo"
|
|
864
|
+
_init_repo_with_staged_email(repository_root)
|
|
865
|
+
deny_reason = evaluate_bash_command(
|
|
866
|
+
"git `\n commit -m test",
|
|
867
|
+
working_directory=str(repository_root),
|
|
868
|
+
)
|
|
869
|
+
assert deny_reason is not None
|
|
870
|
+
assert "email" in deny_reason
|
|
871
|
+
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def test_extract_git_commit_working_directory_resolves_dash_c_forms() -> None:
|
|
875
|
+
assert (
|
|
876
|
+
extract_git_commit_working_directory(r"git.exe -C /repoA commit -m x")
|
|
877
|
+
== "/repoA"
|
|
878
|
+
)
|
|
879
|
+
assert (
|
|
880
|
+
extract_git_commit_working_directory(r"git -C /repoA commit -m x") == "/repoA"
|
|
881
|
+
)
|
|
882
|
+
assert (
|
|
883
|
+
extract_git_commit_working_directory(r'git -C "C:/repo" commit -m x')
|
|
884
|
+
== "C:/repo"
|
|
885
|
+
)
|
|
886
|
+
assert extract_git_commit_working_directory("git commit -m x") is None
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def test_git_exe_dash_c_commit_scans_named_repo_not_cwd(tmp_path: Path) -> None:
|
|
890
|
+
repository_with_pii = tmp_path / "repo_with_pii"
|
|
891
|
+
_init_repo_with_staged_email(repository_with_pii)
|
|
892
|
+
clean_repository = tmp_path / "clean_repo"
|
|
893
|
+
_init_repo_with_clean_notes(clean_repository)
|
|
894
|
+
cwd_only_scan = evaluate_bash_command(
|
|
895
|
+
"git.exe commit -m test",
|
|
896
|
+
working_directory=str(clean_repository),
|
|
897
|
+
)
|
|
898
|
+
assert cwd_only_scan is None
|
|
899
|
+
dash_c_scan = evaluate_bash_command(
|
|
900
|
+
f"git.exe -C {repository_with_pii} commit -m test",
|
|
901
|
+
working_directory=str(clean_repository),
|
|
902
|
+
)
|
|
903
|
+
assert dash_c_scan is not None
|
|
904
|
+
assert "email" in dash_c_scan
|
|
905
|
+
assert SYNTHETIC_REAL_EMAIL not in dash_c_scan
|