claude-dev-env 7.0.0 → 7.2.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/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +213 -0
- package/.agents/skills/session-continuity/SKILL.md +125 -0
- package/.agents/skills/session-continuity/continuity.mjs +447 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +88 -0
- package/bin/install-session-continuity.test.mjs +55 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
|
@@ -1,1222 +0,0 @@
|
|
|
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
|
-
from typing import Callable
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
_HOOK_DIR = Path(__file__).parent
|
|
14
|
-
_HOOKS_DIR = _HOOK_DIR.parent
|
|
15
|
-
if str(_HOOK_DIR) not in sys.path:
|
|
16
|
-
sys.path.insert(0, str(_HOOK_DIR))
|
|
17
|
-
if str(_HOOKS_DIR) not in sys.path:
|
|
18
|
-
sys.path.insert(0, str(_HOOKS_DIR))
|
|
19
|
-
|
|
20
|
-
from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
21
|
-
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
22
|
-
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
23
|
-
)
|
|
24
|
-
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
25
|
-
ALL_HOSTED_HOOK_ENTRIES,
|
|
26
|
-
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
27
|
-
)
|
|
28
|
-
import pii_prevention_blocker as blocker_module # noqa: E402
|
|
29
|
-
from pii_prevention_blocker import ( # noqa: E402
|
|
30
|
-
evaluate,
|
|
31
|
-
evaluate_bash_command,
|
|
32
|
-
evaluate_post_body_texts,
|
|
33
|
-
evaluate_staged_commit,
|
|
34
|
-
evaluate_write_edit_payload,
|
|
35
|
-
extract_git_commit_working_directory,
|
|
36
|
-
is_git_commit_shell_command,
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
HOOK_PATH = _HOOK_DIR / "pii_prevention_blocker.py"
|
|
40
|
-
|
|
41
|
-
SYNTHETIC_REAL_EMAIL = "owner.fixture@acme-corp.example.io"
|
|
42
|
-
SYNTHETIC_HOME_PATH = r"C:\Users\fixture_commit_user\secret.txt"
|
|
43
|
-
SYNTHETIC_PRIVATE_IP = "10.44.12.9"
|
|
44
|
-
SYNTHETIC_SAFE_BODY = "All checks pass. Contact user@example.com for docs."
|
|
45
|
-
HASH_MARKER = "#"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_post_body_extraction_has_a_neutral_module_owner() -> None:
|
|
49
|
-
assert (
|
|
50
|
-
blocker_module.extract_gh_post_body_texts_for_privacy_gate.__module__
|
|
51
|
-
== "gh_post_body_texts"
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _run_hook(payload: dict[str, object]) -> tuple[int, str]:
|
|
56
|
-
completed = subprocess.run(
|
|
57
|
-
[sys.executable, str(HOOK_PATH)],
|
|
58
|
-
input=json.dumps(payload),
|
|
59
|
-
capture_output=True,
|
|
60
|
-
text=True,
|
|
61
|
-
timeout=30,
|
|
62
|
-
check=False,
|
|
63
|
-
)
|
|
64
|
-
return completed.returncode, completed.stdout
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_write_with_real_email_is_denied() -> None:
|
|
68
|
-
deny_reason = evaluate_write_edit_payload(
|
|
69
|
-
"Write",
|
|
70
|
-
{
|
|
71
|
-
"file_path": "docs/notes.md",
|
|
72
|
-
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
73
|
-
},
|
|
74
|
-
)
|
|
75
|
-
assert deny_reason is not None
|
|
76
|
-
assert "email" in deny_reason
|
|
77
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_write_with_example_email_is_allowed() -> None:
|
|
81
|
-
deny_reason = evaluate_write_edit_payload(
|
|
82
|
-
"Write",
|
|
83
|
-
{
|
|
84
|
-
"file_path": "docs/notes.md",
|
|
85
|
-
"content": "Reach me at user@example.com",
|
|
86
|
-
},
|
|
87
|
-
)
|
|
88
|
-
assert deny_reason is None
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def test_edit_introducing_home_path_is_denied() -> None:
|
|
92
|
-
deny_reason = evaluate_write_edit_payload(
|
|
93
|
-
"Edit",
|
|
94
|
-
{
|
|
95
|
-
"file_path": "README.md",
|
|
96
|
-
"old_string": "path here",
|
|
97
|
-
"new_string": f"path is {SYNTHETIC_HOME_PATH}",
|
|
98
|
-
},
|
|
99
|
-
)
|
|
100
|
-
assert deny_reason is not None
|
|
101
|
-
assert "home-path" in deny_reason
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_write_to_test_file_is_exempt() -> None:
|
|
105
|
-
deny_reason = evaluate_write_edit_payload(
|
|
106
|
-
"Write",
|
|
107
|
-
{
|
|
108
|
-
"file_path": "packages/hooks/blocking/test_fixture_helper.py",
|
|
109
|
-
"content": f"EMAIL = '{SYNTHETIC_REAL_EMAIL}'",
|
|
110
|
-
},
|
|
111
|
-
)
|
|
112
|
-
assert deny_reason is None
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_write_to_test_prefixed_markdown_is_not_exempt() -> None:
|
|
116
|
-
deny_reason = evaluate_write_edit_payload(
|
|
117
|
-
"Write",
|
|
118
|
-
{
|
|
119
|
-
"file_path": "test_notes.md",
|
|
120
|
-
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
121
|
-
},
|
|
122
|
-
)
|
|
123
|
-
assert deny_reason is not None
|
|
124
|
-
assert "email" in deny_reason
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def test_write_with_empty_path_still_scans_content() -> None:
|
|
128
|
-
deny_reason = evaluate_write_edit_payload(
|
|
129
|
-
"Write",
|
|
130
|
-
{
|
|
131
|
-
"file_path": "",
|
|
132
|
-
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
133
|
-
},
|
|
134
|
-
)
|
|
135
|
-
assert deny_reason is not None
|
|
136
|
-
assert "email" in deny_reason
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def test_write_to_basename_only_self_module_name_is_not_exempt() -> None:
|
|
140
|
-
deny_reason = evaluate_write_edit_payload(
|
|
141
|
-
"Write",
|
|
142
|
-
{
|
|
143
|
-
"file_path": "vendor/pii_scanner.py",
|
|
144
|
-
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
145
|
-
},
|
|
146
|
-
)
|
|
147
|
-
assert deny_reason is not None
|
|
148
|
-
assert "email" in deny_reason
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def test_write_to_license_prefix_filename_is_not_exempt() -> None:
|
|
152
|
-
deny_reason = evaluate_write_edit_payload(
|
|
153
|
-
"Write",
|
|
154
|
-
{
|
|
155
|
-
"file_path": "LICENSE_leak.env",
|
|
156
|
-
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
157
|
-
},
|
|
158
|
-
)
|
|
159
|
-
assert deny_reason is not None
|
|
160
|
-
assert "email" in deny_reason
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def test_gh_body_file_fails_closed_when_unreadable(tmp_path: Path) -> None:
|
|
164
|
-
missing_body_file = tmp_path / "missing_pr_body.md"
|
|
165
|
-
command = f'gh pr comment 12 --body-file "{missing_body_file}"'
|
|
166
|
-
deny_reason = evaluate_bash_command(command, working_directory=str(tmp_path))
|
|
167
|
-
assert deny_reason is not None
|
|
168
|
-
assert "body-file" in deny_reason
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def test_gh_body_file_reads_relative_path_from_working_directory(
|
|
172
|
-
tmp_path: Path,
|
|
173
|
-
) -> None:
|
|
174
|
-
body_file = tmp_path / "pr.md"
|
|
175
|
-
body_file.write_text(f"Reach me at {SYNTHETIC_REAL_EMAIL}\n", encoding="utf-8")
|
|
176
|
-
deny_reason = evaluate_bash_command(
|
|
177
|
-
"gh pr comment 12 --body-file pr.md",
|
|
178
|
-
working_directory=str(tmp_path),
|
|
179
|
-
)
|
|
180
|
-
assert deny_reason is not None
|
|
181
|
-
assert "email" in deny_reason
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def test_gh_post_body_with_secret_is_denied(synthetic_github_token: str) -> None:
|
|
185
|
-
command = f'gh pr comment 12 --body "auth material {synthetic_github_token}"'
|
|
186
|
-
deny_reason = evaluate_bash_command(command, working_directory=None)
|
|
187
|
-
assert deny_reason is not None
|
|
188
|
-
assert "secret" in deny_reason
|
|
189
|
-
assert synthetic_github_token not in deny_reason
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def test_gh_post_clean_body_is_allowed() -> None:
|
|
193
|
-
command = f'gh pr comment 12 --body "{SYNTHETIC_SAFE_BODY}"'
|
|
194
|
-
assert evaluate_bash_command(command, working_directory=None) is None
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def test_mcp_github_body_with_private_ip_is_denied() -> None:
|
|
198
|
-
deny_reason = evaluate(
|
|
199
|
-
{
|
|
200
|
-
"tool_name": "mcp__plugin_github_github__add_issue_comment",
|
|
201
|
-
"tool_input": {
|
|
202
|
-
"body": f"Service is at {SYNTHETIC_PRIVATE_IP}",
|
|
203
|
-
},
|
|
204
|
-
}
|
|
205
|
-
)
|
|
206
|
-
assert deny_reason is not None
|
|
207
|
-
assert "private-ip" in deny_reason
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def test_subprocess_hook_denies_write_with_token(synthetic_github_token: str) -> None:
|
|
211
|
-
payload = {
|
|
212
|
-
"tool_name": "Write",
|
|
213
|
-
"tool_input": {
|
|
214
|
-
"file_path": "src/config.env.example.md",
|
|
215
|
-
"content": f"TOKEN={synthetic_github_token}\n",
|
|
216
|
-
},
|
|
217
|
-
}
|
|
218
|
-
exit_code, stdout_text = _run_hook(payload)
|
|
219
|
-
assert exit_code == 0
|
|
220
|
-
assert stdout_text.strip()
|
|
221
|
-
payload_out = json.loads(stdout_text)
|
|
222
|
-
decision = payload_out["hookSpecificOutput"]["permissionDecision"]
|
|
223
|
-
reason = payload_out["hookSpecificOutput"]["permissionDecisionReason"]
|
|
224
|
-
assert decision == "deny"
|
|
225
|
-
assert "secret" in reason
|
|
226
|
-
assert synthetic_github_token not in reason
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def test_subprocess_hook_allows_clean_write() -> None:
|
|
230
|
-
payload = {
|
|
231
|
-
"tool_name": "Write",
|
|
232
|
-
"tool_input": {
|
|
233
|
-
"file_path": "docs/guide.md",
|
|
234
|
-
"content": "Use user@example.com and C:/Users/<you>/ for samples.\n",
|
|
235
|
-
},
|
|
236
|
-
}
|
|
237
|
-
exit_code, stdout_text = _run_hook(payload)
|
|
238
|
-
assert exit_code == 0
|
|
239
|
-
assert stdout_text.strip() == ""
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def test_staged_commit_with_pii_is_denied(tmp_path: Path) -> None:
|
|
243
|
-
repository_root = tmp_path / "repo"
|
|
244
|
-
repository_root.mkdir()
|
|
245
|
-
subprocess.run(
|
|
246
|
-
["git", "init"],
|
|
247
|
-
cwd=repository_root,
|
|
248
|
-
check=True,
|
|
249
|
-
capture_output=True,
|
|
250
|
-
text=True,
|
|
251
|
-
)
|
|
252
|
-
subprocess.run(
|
|
253
|
-
["git", "config", "user.email", "dev@example.com"],
|
|
254
|
-
cwd=repository_root,
|
|
255
|
-
check=True,
|
|
256
|
-
capture_output=True,
|
|
257
|
-
text=True,
|
|
258
|
-
)
|
|
259
|
-
subprocess.run(
|
|
260
|
-
["git", "config", "user.name", "Fixture Dev"],
|
|
261
|
-
cwd=repository_root,
|
|
262
|
-
check=True,
|
|
263
|
-
capture_output=True,
|
|
264
|
-
text=True,
|
|
265
|
-
)
|
|
266
|
-
tracked_file = repository_root / "notes.md"
|
|
267
|
-
tracked_file.write_text(
|
|
268
|
-
f"owner email {SYNTHETIC_REAL_EMAIL}\n",
|
|
269
|
-
encoding="utf-8",
|
|
270
|
-
)
|
|
271
|
-
subprocess.run(
|
|
272
|
-
["git", "add", "notes.md"],
|
|
273
|
-
cwd=repository_root,
|
|
274
|
-
check=True,
|
|
275
|
-
capture_output=True,
|
|
276
|
-
text=True,
|
|
277
|
-
)
|
|
278
|
-
deny_reason = evaluate_bash_command(
|
|
279
|
-
"git commit -m test",
|
|
280
|
-
working_directory=str(repository_root),
|
|
281
|
-
)
|
|
282
|
-
assert deny_reason is not None
|
|
283
|
-
assert "email" in deny_reason
|
|
284
|
-
assert "staged commit" in deny_reason
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def test_staged_commit_with_clean_content_is_allowed(tmp_path: Path) -> None:
|
|
288
|
-
repository_root = tmp_path / "repo"
|
|
289
|
-
repository_root.mkdir()
|
|
290
|
-
subprocess.run(
|
|
291
|
-
["git", "init"],
|
|
292
|
-
cwd=repository_root,
|
|
293
|
-
check=True,
|
|
294
|
-
capture_output=True,
|
|
295
|
-
text=True,
|
|
296
|
-
)
|
|
297
|
-
subprocess.run(
|
|
298
|
-
["git", "config", "user.email", "dev@example.com"],
|
|
299
|
-
cwd=repository_root,
|
|
300
|
-
check=True,
|
|
301
|
-
capture_output=True,
|
|
302
|
-
text=True,
|
|
303
|
-
)
|
|
304
|
-
subprocess.run(
|
|
305
|
-
["git", "config", "user.name", "Fixture Dev"],
|
|
306
|
-
cwd=repository_root,
|
|
307
|
-
check=True,
|
|
308
|
-
capture_output=True,
|
|
309
|
-
text=True,
|
|
310
|
-
)
|
|
311
|
-
tracked_file = repository_root / "notes.md"
|
|
312
|
-
tracked_file.write_text(
|
|
313
|
-
"Use user@example.com in docs only.\n",
|
|
314
|
-
encoding="utf-8",
|
|
315
|
-
)
|
|
316
|
-
subprocess.run(
|
|
317
|
-
["git", "add", "notes.md"],
|
|
318
|
-
cwd=repository_root,
|
|
319
|
-
check=True,
|
|
320
|
-
capture_output=True,
|
|
321
|
-
text=True,
|
|
322
|
-
)
|
|
323
|
-
deny_reason = evaluate_bash_command(
|
|
324
|
-
"git commit -m test",
|
|
325
|
-
working_directory=str(repository_root),
|
|
326
|
-
)
|
|
327
|
-
assert deny_reason is None
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def test_staged_commit_fails_closed_when_git_list_fails(
|
|
331
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
332
|
-
) -> None:
|
|
333
|
-
monkeypatch.setattr(
|
|
334
|
-
blocker_module,
|
|
335
|
-
"list_staged_file_paths",
|
|
336
|
-
lambda _repository_root: (None, "BLOCKED list failure"),
|
|
337
|
-
)
|
|
338
|
-
deny_reason = evaluate_staged_commit(tmp_path)
|
|
339
|
-
assert deny_reason == "BLOCKED list failure"
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def test_staged_commit_fails_closed_when_blob_unscannable(
|
|
343
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
344
|
-
) -> None:
|
|
345
|
-
monkeypatch.setattr(
|
|
346
|
-
blocker_module,
|
|
347
|
-
"list_staged_file_paths",
|
|
348
|
-
lambda _repository_root: (["binary.bin"], None),
|
|
349
|
-
)
|
|
350
|
-
monkeypatch.setattr(
|
|
351
|
-
blocker_module,
|
|
352
|
-
"read_staged_file_text",
|
|
353
|
-
lambda _repository_root, relative_path: (
|
|
354
|
-
None,
|
|
355
|
-
f"BLOCKED unscannable {relative_path}",
|
|
356
|
-
),
|
|
357
|
-
)
|
|
358
|
-
deny_reason = evaluate_staged_commit(tmp_path)
|
|
359
|
-
assert deny_reason is not None
|
|
360
|
-
assert "unscannable" in deny_reason
|
|
361
|
-
assert "binary.bin" in deny_reason
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def test_dispatcher_roster_hosts_pii_blocker() -> None:
|
|
365
|
-
matching_write_entries = [
|
|
366
|
-
each_entry
|
|
367
|
-
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
368
|
-
if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
|
|
369
|
-
]
|
|
370
|
-
assert len(matching_write_entries) == 1
|
|
371
|
-
assert matching_write_entries[0].applicable_tool_names == (
|
|
372
|
-
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {"apply_patch"}
|
|
373
|
-
)
|
|
374
|
-
assert matching_write_entries[0].is_blocking is True
|
|
375
|
-
matching_bash_entries = [
|
|
376
|
-
each_entry
|
|
377
|
-
for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
|
|
378
|
-
if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
|
|
379
|
-
]
|
|
380
|
-
assert len(matching_bash_entries) == 1
|
|
381
|
-
assert matching_bash_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def test_is_git_commit_shell_command_covers_windows_and_flag_forms() -> None:
|
|
385
|
-
assert is_git_commit_shell_command("git commit -m test")
|
|
386
|
-
assert is_git_commit_shell_command("git.exe commit -m test")
|
|
387
|
-
assert is_git_commit_shell_command("git --no-verify commit -m test")
|
|
388
|
-
assert is_git_commit_shell_command("git -c commit.gpgsign=false commit -m x")
|
|
389
|
-
assert is_git_commit_shell_command('git -C "C:/repo" commit -m x')
|
|
390
|
-
assert is_git_commit_shell_command(r'& "C:\Program Files\Git\cmd\git.exe" commit -m x')
|
|
391
|
-
assert not is_git_commit_shell_command("git status")
|
|
392
|
-
assert not is_git_commit_shell_command("git log --grep commit")
|
|
393
|
-
assert not is_git_commit_shell_command('echo "please git commit"')
|
|
394
|
-
assert not is_git_commit_shell_command("gh pr comment 1 --body 'git commit'")
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def _init_repo_with_staged_email(repository_root: Path) -> None:
|
|
398
|
-
repository_root.mkdir()
|
|
399
|
-
subprocess.run(
|
|
400
|
-
["git", "init"],
|
|
401
|
-
cwd=repository_root,
|
|
402
|
-
check=True,
|
|
403
|
-
capture_output=True,
|
|
404
|
-
text=True,
|
|
405
|
-
)
|
|
406
|
-
subprocess.run(
|
|
407
|
-
["git", "config", "user.email", "dev@example.com"],
|
|
408
|
-
cwd=repository_root,
|
|
409
|
-
check=True,
|
|
410
|
-
capture_output=True,
|
|
411
|
-
text=True,
|
|
412
|
-
)
|
|
413
|
-
subprocess.run(
|
|
414
|
-
["git", "config", "user.name", "Fixture Dev"],
|
|
415
|
-
cwd=repository_root,
|
|
416
|
-
check=True,
|
|
417
|
-
capture_output=True,
|
|
418
|
-
text=True,
|
|
419
|
-
)
|
|
420
|
-
tracked_file = repository_root / "notes.md"
|
|
421
|
-
tracked_file.write_text(
|
|
422
|
-
f"owner email {SYNTHETIC_REAL_EMAIL}\n",
|
|
423
|
-
encoding="utf-8",
|
|
424
|
-
)
|
|
425
|
-
subprocess.run(
|
|
426
|
-
["git", "add", "notes.md"],
|
|
427
|
-
cwd=repository_root,
|
|
428
|
-
check=True,
|
|
429
|
-
capture_output=True,
|
|
430
|
-
text=True,
|
|
431
|
-
)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def _init_repo_with_clean_notes(repository_root: Path) -> None:
|
|
435
|
-
repository_root.mkdir()
|
|
436
|
-
subprocess.run(
|
|
437
|
-
["git", "init"],
|
|
438
|
-
cwd=repository_root,
|
|
439
|
-
check=True,
|
|
440
|
-
capture_output=True,
|
|
441
|
-
text=True,
|
|
442
|
-
)
|
|
443
|
-
subprocess.run(
|
|
444
|
-
["git", "config", "user.email", "dev@example.com"],
|
|
445
|
-
cwd=repository_root,
|
|
446
|
-
check=True,
|
|
447
|
-
capture_output=True,
|
|
448
|
-
text=True,
|
|
449
|
-
)
|
|
450
|
-
subprocess.run(
|
|
451
|
-
["git", "config", "user.name", "Fixture Dev"],
|
|
452
|
-
cwd=repository_root,
|
|
453
|
-
check=True,
|
|
454
|
-
capture_output=True,
|
|
455
|
-
text=True,
|
|
456
|
-
)
|
|
457
|
-
tracked_file = repository_root / "notes.md"
|
|
458
|
-
tracked_file.write_text(
|
|
459
|
-
"Use user@example.com in docs only.\n",
|
|
460
|
-
encoding="utf-8",
|
|
461
|
-
)
|
|
462
|
-
subprocess.run(
|
|
463
|
-
["git", "add", "notes.md"],
|
|
464
|
-
cwd=repository_root,
|
|
465
|
-
check=True,
|
|
466
|
-
capture_output=True,
|
|
467
|
-
text=True,
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
def test_git_exe_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
472
|
-
repository_root = tmp_path / "repo"
|
|
473
|
-
_init_repo_with_staged_email(repository_root)
|
|
474
|
-
deny_reason = evaluate_bash_command(
|
|
475
|
-
"git.exe commit -m test",
|
|
476
|
-
working_directory=str(repository_root),
|
|
477
|
-
)
|
|
478
|
-
assert deny_reason is not None
|
|
479
|
-
assert "email" in deny_reason
|
|
480
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def test_git_config_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
484
|
-
repository_root = tmp_path / "repo"
|
|
485
|
-
_init_repo_with_staged_email(repository_root)
|
|
486
|
-
deny_reason = evaluate_bash_command(
|
|
487
|
-
"git -c commit.gpgsign=false commit -m test",
|
|
488
|
-
working_directory=str(repository_root),
|
|
489
|
-
)
|
|
490
|
-
assert deny_reason is not None
|
|
491
|
-
assert "email" in deny_reason
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def test_git_no_verify_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
495
|
-
repository_root = tmp_path / "repo"
|
|
496
|
-
_init_repo_with_staged_email(repository_root)
|
|
497
|
-
deny_reason = evaluate_bash_command(
|
|
498
|
-
"git --no-verify commit -m test",
|
|
499
|
-
working_directory=str(repository_root),
|
|
500
|
-
)
|
|
501
|
-
assert deny_reason is not None
|
|
502
|
-
assert "email" in deny_reason
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
def test_repository_root_unresolved_fails_closed(
|
|
506
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
507
|
-
) -> None:
|
|
508
|
-
monkeypatch.setattr(blocker_module, "resolve_repository_root", lambda _cwd: None)
|
|
509
|
-
deny_reason = evaluate_bash_command(
|
|
510
|
-
"git commit -m test",
|
|
511
|
-
working_directory=str(Path.cwd()),
|
|
512
|
-
)
|
|
513
|
-
assert deny_reason is not None
|
|
514
|
-
assert "repository root" in deny_reason
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
def test_powershell_tool_commit_with_staged_email_is_denied(tmp_path: Path) -> None:
|
|
518
|
-
repository_root = tmp_path / "repo"
|
|
519
|
-
_init_repo_with_staged_email(repository_root)
|
|
520
|
-
deny_reason = evaluate(
|
|
521
|
-
{
|
|
522
|
-
"tool_name": "PowerShell",
|
|
523
|
-
"tool_input": {
|
|
524
|
-
"command": "git commit -m test",
|
|
525
|
-
"working_directory": str(repository_root),
|
|
526
|
-
},
|
|
527
|
-
}
|
|
528
|
-
)
|
|
529
|
-
assert deny_reason is not None
|
|
530
|
-
assert "email" in deny_reason
|
|
531
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def test_is_git_commit_shell_command_detects_glued_and_newline_separators() -> None:
|
|
535
|
-
assert is_git_commit_shell_command("git add .;git commit -m x")
|
|
536
|
-
assert is_git_commit_shell_command("cd repo&&git commit -m x")
|
|
537
|
-
assert is_git_commit_shell_command("git add notes.md\ngit commit -m update")
|
|
538
|
-
assert is_git_commit_shell_command("git status\ngit commit -m x")
|
|
539
|
-
assert not is_git_commit_shell_command("gh pr comment 1 --body 'notes\ngit commit -m x'")
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
def test_is_git_commit_shell_command_detects_backgrounded_commit() -> None:
|
|
543
|
-
assert is_git_commit_shell_command("npm run build & git commit -am wip")
|
|
544
|
-
assert is_git_commit_shell_command("sleep 1 & git commit -m x")
|
|
545
|
-
assert is_git_commit_shell_command("& git commit -m x")
|
|
546
|
-
assert is_git_commit_shell_command("git commit -m x &")
|
|
547
|
-
assert not is_git_commit_shell_command("sleep 1 & git status")
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
def test_backgrounded_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
551
|
-
repository_root = tmp_path / "repo"
|
|
552
|
-
_init_repo_with_staged_email(repository_root)
|
|
553
|
-
deny_reason = evaluate_bash_command(
|
|
554
|
-
"npm run build & git commit -m test",
|
|
555
|
-
working_directory=str(repository_root),
|
|
556
|
-
)
|
|
557
|
-
assert deny_reason is not None
|
|
558
|
-
assert "email" in deny_reason
|
|
559
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def test_newline_separated_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
563
|
-
repository_root = tmp_path / "repo"
|
|
564
|
-
_init_repo_with_staged_email(repository_root)
|
|
565
|
-
deny_reason = evaluate_bash_command(
|
|
566
|
-
"git add notes.md\ngit commit -m update",
|
|
567
|
-
working_directory=str(repository_root),
|
|
568
|
-
)
|
|
569
|
-
assert deny_reason is not None
|
|
570
|
-
assert "email" in deny_reason
|
|
571
|
-
assert "staged commit" in deny_reason
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def test_glued_semicolon_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
575
|
-
repository_root = tmp_path / "repo"
|
|
576
|
-
_init_repo_with_staged_email(repository_root)
|
|
577
|
-
deny_reason = evaluate_bash_command(
|
|
578
|
-
"git add notes.md;git commit -m update",
|
|
579
|
-
working_directory=str(repository_root),
|
|
580
|
-
)
|
|
581
|
-
assert deny_reason is not None
|
|
582
|
-
assert "email" in deny_reason
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
def test_is_git_commit_shell_command_detects_shell_keyword_wrapped_commits() -> None:
|
|
586
|
-
assert is_git_commit_shell_command("if true; then git commit -m x; fi")
|
|
587
|
-
assert is_git_commit_shell_command("for f in a b; do git commit -m x; done")
|
|
588
|
-
assert is_git_commit_shell_command("bash -c 'git commit -m x'")
|
|
589
|
-
assert is_git_commit_shell_command('sh -c "git commit -m x"')
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
def test_is_git_commit_shell_command_detects_wrapper_prefixed_commits() -> None:
|
|
593
|
-
assert is_git_commit_shell_command("time git commit -m x")
|
|
594
|
-
assert is_git_commit_shell_command("sudo git commit -m x")
|
|
595
|
-
assert is_git_commit_shell_command("env GIT_AUTHOR_NAME=a git commit")
|
|
596
|
-
assert is_git_commit_shell_command("nice git commit -m y")
|
|
597
|
-
assert is_git_commit_shell_command("nice -n 10 git commit")
|
|
598
|
-
assert is_git_commit_shell_command("timeout 30 git commit")
|
|
599
|
-
assert is_git_commit_shell_command("env FOO=bar git commit")
|
|
600
|
-
assert not is_git_commit_shell_command("sudo cat notes.md")
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
def test_wrapper_prefix_stops_at_intervening_command_word() -> None:
|
|
604
|
-
assert not is_git_commit_shell_command("time echo git commit")
|
|
605
|
-
assert not is_git_commit_shell_command("sudo docker run git commit")
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
def test_wrapper_prefixed_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
609
|
-
repository_root = tmp_path / "repo"
|
|
610
|
-
_init_repo_with_staged_email(repository_root)
|
|
611
|
-
deny_reason = evaluate_bash_command(
|
|
612
|
-
"env GIT_AUTHOR_NAME=a git commit -m test",
|
|
613
|
-
working_directory=str(repository_root),
|
|
614
|
-
)
|
|
615
|
-
assert deny_reason is not None
|
|
616
|
-
assert "email" in deny_reason
|
|
617
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
def test_shell_keyword_wrapped_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
621
|
-
repository_root = tmp_path / "repo"
|
|
622
|
-
_init_repo_with_staged_email(repository_root)
|
|
623
|
-
deny_reason = evaluate_bash_command(
|
|
624
|
-
"time git commit -m test",
|
|
625
|
-
working_directory=str(repository_root),
|
|
626
|
-
)
|
|
627
|
-
assert deny_reason is not None
|
|
628
|
-
assert "email" in deny_reason
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
def test_is_git_commit_shell_command_detects_powershell_command_flag() -> None:
|
|
632
|
-
assert is_git_commit_shell_command('pwsh -NoProfile -Command "git commit -m x"')
|
|
633
|
-
assert is_git_commit_shell_command('powershell -Command "git commit -m x"')
|
|
634
|
-
assert is_git_commit_shell_command("pwsh.exe -Command 'git commit -m x'")
|
|
635
|
-
assert is_git_commit_shell_command('pwsh -c "git commit -m x"')
|
|
636
|
-
assert not is_git_commit_shell_command('pwsh -NoProfile -Command "git status"')
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
def test_is_git_commit_shell_command_detects_powershell_command_abbreviations() -> None:
|
|
640
|
-
assert is_git_commit_shell_command('pwsh -Com "git commit -m x"')
|
|
641
|
-
assert is_git_commit_shell_command('pwsh -Comm "git commit -m x"')
|
|
642
|
-
assert is_git_commit_shell_command('pwsh -Comma "git commit -m x"')
|
|
643
|
-
assert is_git_commit_shell_command('pwsh -Comman "git commit -m x"')
|
|
644
|
-
assert not is_git_commit_shell_command('pwsh -NoProfile "git commit -m x"')
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
def test_is_git_commit_shell_command_detects_flag_carrying_wrappers() -> None:
|
|
648
|
-
assert is_git_commit_shell_command("nice -n 10 git commit -m x")
|
|
649
|
-
assert is_git_commit_shell_command("stdbuf -oL git commit -m x")
|
|
650
|
-
assert is_git_commit_shell_command("env -i git commit -m x")
|
|
651
|
-
assert is_git_commit_shell_command("env -u NAME git commit -m x")
|
|
652
|
-
assert not is_git_commit_shell_command("nice -n 10 cat notes.md")
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
def test_is_git_commit_shell_command_detects_wrapper_flag_value_matching_binary() -> None:
|
|
656
|
-
assert is_git_commit_shell_command("sudo -u git git commit -m x")
|
|
657
|
-
assert is_git_commit_shell_command("sudo -u alice git commit -m x")
|
|
658
|
-
assert is_git_commit_shell_command("sudo -u sh git commit -m x")
|
|
659
|
-
assert is_git_commit_shell_command("doas -u git git commit -m x")
|
|
660
|
-
assert is_git_commit_shell_command("sudo -u git git commit")
|
|
661
|
-
assert not is_git_commit_shell_command("sudo -u git git status")
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
def test_is_git_commit_shell_command_detects_argument_bearing_wrappers() -> None:
|
|
665
|
-
assert is_git_commit_shell_command("timeout 30 git commit -m x")
|
|
666
|
-
assert is_git_commit_shell_command("nohup git commit -m x")
|
|
667
|
-
assert is_git_commit_shell_command("flock /tmp/lock git commit -m x")
|
|
668
|
-
assert is_git_commit_shell_command("ionice git commit -m x")
|
|
669
|
-
assert is_git_commit_shell_command("doas git commit -m x")
|
|
670
|
-
assert is_git_commit_shell_command("setsid git commit -m x")
|
|
671
|
-
assert not is_git_commit_shell_command("timeout 30 cat notes.md")
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
def test_argument_bearing_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
675
|
-
repository_root = tmp_path / "repo"
|
|
676
|
-
_init_repo_with_staged_email(repository_root)
|
|
677
|
-
deny_reason = evaluate_bash_command(
|
|
678
|
-
"timeout 30 git commit -m test",
|
|
679
|
-
working_directory=str(repository_root),
|
|
680
|
-
)
|
|
681
|
-
assert deny_reason is not None
|
|
682
|
-
assert "email" in deny_reason
|
|
683
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
def test_is_git_commit_shell_command_detects_commit_after_hash_bearing_token() -> None:
|
|
687
|
-
command = f"curl https://example.com/page{HASH_MARKER}section && git commit -am x"
|
|
688
|
-
assert is_git_commit_shell_command(command)
|
|
689
|
-
assert is_git_commit_shell_command("echo done#note && git commit -m x")
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
def test_hash_bearing_token_before_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
693
|
-
repository_root = tmp_path / "repo"
|
|
694
|
-
_init_repo_with_staged_email(repository_root)
|
|
695
|
-
deny_reason = evaluate_bash_command(
|
|
696
|
-
"curl https://example.com/page#section && git commit -m test",
|
|
697
|
-
working_directory=str(repository_root),
|
|
698
|
-
)
|
|
699
|
-
assert deny_reason is not None
|
|
700
|
-
assert "email" in deny_reason
|
|
701
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
def test_is_git_commit_shell_command_detects_combined_interpreter_flags() -> None:
|
|
705
|
-
assert is_git_commit_shell_command('bash -lc "git commit -m x"')
|
|
706
|
-
assert is_git_commit_shell_command("sh -ec 'git commit -m x'")
|
|
707
|
-
assert is_git_commit_shell_command('bash -l -c "git commit -m x"')
|
|
708
|
-
assert not is_git_commit_shell_command('bash -lc "git status"')
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
def test_is_git_commit_shell_command_detects_c_first_interpreter_clusters() -> None:
|
|
712
|
-
assert is_git_commit_shell_command('bash -cx "git commit -m x"')
|
|
713
|
-
assert is_git_commit_shell_command('bash -cv "git commit -m x"')
|
|
714
|
-
assert is_git_commit_shell_command("sh -cx 'git commit -m x'")
|
|
715
|
-
assert not is_git_commit_shell_command("bash -x 'git status'")
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
def test_is_git_commit_shell_command_detects_unquoted_powershell_command() -> None:
|
|
719
|
-
assert is_git_commit_shell_command("pwsh -Command git commit -m x")
|
|
720
|
-
assert is_git_commit_shell_command("powershell -Command git commit -m wip")
|
|
721
|
-
assert not is_git_commit_shell_command("pwsh -Command git status")
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
def test_is_git_commit_shell_command_detects_powershell_preflags_before_command() -> None:
|
|
725
|
-
assert is_git_commit_shell_command('pwsh -ExecutionPolicy Bypass -Command "git commit -m x"')
|
|
726
|
-
assert is_git_commit_shell_command('pwsh -NonInteractive -Command "git commit -m x"')
|
|
727
|
-
assert is_git_commit_shell_command(
|
|
728
|
-
'powershell -ExecutionPolicy Bypass -Command "git commit -m x"'
|
|
729
|
-
)
|
|
730
|
-
assert is_git_commit_shell_command('pwsh -NoProfile -NonInteractive -Command "git commit -m x"')
|
|
731
|
-
assert not is_git_commit_shell_command('pwsh -NonInteractive -Command "git status"')
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
def test_is_git_commit_shell_command_detects_subshell_grouped_commit() -> None:
|
|
735
|
-
assert is_git_commit_shell_command("(git commit -m x)")
|
|
736
|
-
assert is_git_commit_shell_command("(cd repo && git commit -m x)")
|
|
737
|
-
assert not is_git_commit_shell_command("(git status)")
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def test_powershell_preflag_command_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
741
|
-
repository_root = tmp_path / "repo"
|
|
742
|
-
_init_repo_with_staged_email(repository_root)
|
|
743
|
-
deny_reason = evaluate_bash_command(
|
|
744
|
-
'pwsh -ExecutionPolicy Bypass -Command "git commit -m test"',
|
|
745
|
-
working_directory=str(repository_root),
|
|
746
|
-
)
|
|
747
|
-
assert deny_reason is not None
|
|
748
|
-
assert "email" in deny_reason
|
|
749
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
def test_subshell_grouped_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
753
|
-
repository_root = tmp_path / "repo"
|
|
754
|
-
_init_repo_with_staged_email(repository_root)
|
|
755
|
-
deny_reason = evaluate_bash_command(
|
|
756
|
-
"(git commit -m test)",
|
|
757
|
-
working_directory=str(repository_root),
|
|
758
|
-
)
|
|
759
|
-
assert deny_reason is not None
|
|
760
|
-
assert "email" in deny_reason
|
|
761
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
def test_powershell_command_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
765
|
-
repository_root = tmp_path / "repo"
|
|
766
|
-
_init_repo_with_staged_email(repository_root)
|
|
767
|
-
deny_reason = evaluate_bash_command(
|
|
768
|
-
'pwsh -NoProfile -Command "git commit -m test"',
|
|
769
|
-
working_directory=str(repository_root),
|
|
770
|
-
)
|
|
771
|
-
assert deny_reason is not None
|
|
772
|
-
assert "email" in deny_reason
|
|
773
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
def test_flag_carrying_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
777
|
-
repository_root = tmp_path / "repo"
|
|
778
|
-
_init_repo_with_staged_email(repository_root)
|
|
779
|
-
deny_reason = evaluate_bash_command(
|
|
780
|
-
"nice -n 10 git commit -m test",
|
|
781
|
-
working_directory=str(repository_root),
|
|
782
|
-
)
|
|
783
|
-
assert deny_reason is not None
|
|
784
|
-
assert "email" in deny_reason
|
|
785
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
def test_combined_interpreter_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
789
|
-
repository_root = tmp_path / "repo"
|
|
790
|
-
_init_repo_with_staged_email(repository_root)
|
|
791
|
-
deny_reason = evaluate_bash_command(
|
|
792
|
-
'bash -lc "git commit -m test"',
|
|
793
|
-
working_directory=str(repository_root),
|
|
794
|
-
)
|
|
795
|
-
assert deny_reason is not None
|
|
796
|
-
assert "email" in deny_reason
|
|
797
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
def test_c_first_interpreter_cluster_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
801
|
-
repository_root = tmp_path / "repo"
|
|
802
|
-
_init_repo_with_staged_email(repository_root)
|
|
803
|
-
deny_reason = evaluate_bash_command(
|
|
804
|
-
'bash -cx "git commit -m test"',
|
|
805
|
-
working_directory=str(repository_root),
|
|
806
|
-
)
|
|
807
|
-
assert deny_reason is not None
|
|
808
|
-
assert "email" in deny_reason
|
|
809
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
def test_unquoted_powershell_command_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
813
|
-
repository_root = tmp_path / "repo"
|
|
814
|
-
_init_repo_with_staged_email(repository_root)
|
|
815
|
-
deny_reason = evaluate_bash_command(
|
|
816
|
-
"pwsh -Command git commit -m test",
|
|
817
|
-
working_directory=str(repository_root),
|
|
818
|
-
)
|
|
819
|
-
assert deny_reason is not None
|
|
820
|
-
assert "email" in deny_reason
|
|
821
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
def test_is_git_commit_shell_command_detects_commit_after_apostrophe_line() -> None:
|
|
825
|
-
assert is_git_commit_shell_command("echo Don't forget\ngit commit -am x")
|
|
826
|
-
assert is_git_commit_shell_command("echo can't stop\ngit commit -am 'msg'")
|
|
827
|
-
assert is_git_commit_shell_command("printf Bob's file\ngit commit -m note")
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def test_is_git_commit_shell_command_ignores_commit_inside_quoted_body() -> None:
|
|
831
|
-
assert not is_git_commit_shell_command("gh pr comment 1 --body 'notes\ngit commit -m x'")
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
def test_apostrophe_line_before_commit_scans_staged_pii(tmp_path: Path) -> None:
|
|
835
|
-
repository_root = tmp_path / "repo"
|
|
836
|
-
_init_repo_with_staged_email(repository_root)
|
|
837
|
-
deny_reason = evaluate_bash_command(
|
|
838
|
-
"echo can't stop now\ngit commit -m test",
|
|
839
|
-
working_directory=str(repository_root),
|
|
840
|
-
)
|
|
841
|
-
assert deny_reason is not None
|
|
842
|
-
assert "email" in deny_reason
|
|
843
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
def test_is_git_commit_shell_command_detects_powershell_backtick_continuation() -> None:
|
|
847
|
-
assert is_git_commit_shell_command("git `\n commit -m x")
|
|
848
|
-
assert is_git_commit_shell_command("git commit `\n -m x")
|
|
849
|
-
assert not is_git_commit_shell_command("git `\n status")
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
def test_powershell_backtick_continuation_commit_scans_staged_pii(
|
|
853
|
-
tmp_path: Path,
|
|
854
|
-
) -> None:
|
|
855
|
-
repository_root = tmp_path / "repo"
|
|
856
|
-
_init_repo_with_staged_email(repository_root)
|
|
857
|
-
deny_reason = evaluate_bash_command(
|
|
858
|
-
"git `\n commit -m test",
|
|
859
|
-
working_directory=str(repository_root),
|
|
860
|
-
)
|
|
861
|
-
assert deny_reason is not None
|
|
862
|
-
assert "email" in deny_reason
|
|
863
|
-
assert SYNTHETIC_REAL_EMAIL not in deny_reason
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
def test_extract_git_commit_working_directory_resolves_dash_c_forms() -> None:
|
|
867
|
-
assert extract_git_commit_working_directory(r"git.exe -C /repoA commit -m x") == "/repoA"
|
|
868
|
-
assert extract_git_commit_working_directory(r"git -C /repoA commit -m x") == "/repoA"
|
|
869
|
-
assert extract_git_commit_working_directory(r'git -C "C:/repo" commit -m x') == "C:/repo"
|
|
870
|
-
assert extract_git_commit_working_directory("git commit -m x") is None
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
def test_git_exe_dash_c_commit_scans_named_repo_not_cwd(tmp_path: Path) -> None:
|
|
874
|
-
repository_with_pii = tmp_path / "repo_with_pii"
|
|
875
|
-
_init_repo_with_staged_email(repository_with_pii)
|
|
876
|
-
clean_repository = tmp_path / "clean_repo"
|
|
877
|
-
_init_repo_with_clean_notes(clean_repository)
|
|
878
|
-
cwd_only_scan = evaluate_bash_command(
|
|
879
|
-
"git.exe commit -m test",
|
|
880
|
-
working_directory=str(clean_repository),
|
|
881
|
-
)
|
|
882
|
-
assert cwd_only_scan is None
|
|
883
|
-
dash_c_scan = evaluate_bash_command(
|
|
884
|
-
f"git.exe -C {repository_with_pii} commit -m test",
|
|
885
|
-
working_directory=str(clean_repository),
|
|
886
|
-
)
|
|
887
|
-
assert dash_c_scan is not None
|
|
888
|
-
assert "email" in dash_c_scan
|
|
889
|
-
assert SYNTHETIC_REAL_EMAIL not in dash_c_scan
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
def _add_repository_origin(repository_root: Path, origin_url: str) -> None:
|
|
893
|
-
subprocess.run(
|
|
894
|
-
["git", "remote", "add", "origin", origin_url],
|
|
895
|
-
cwd=repository_root,
|
|
896
|
-
check=True,
|
|
897
|
-
capture_output=True,
|
|
898
|
-
text=True,
|
|
899
|
-
)
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
def test_exempt_repository_commit_skips_staged_pii_scan(
|
|
903
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
904
|
-
) -> None:
|
|
905
|
-
repository_root = tmp_path / "repo"
|
|
906
|
-
_init_repo_with_staged_email(repository_root)
|
|
907
|
-
_add_repository_origin(repository_root, "https://github.com/ExemptOwner/exempt-repo.git")
|
|
908
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
909
|
-
deny_reason = evaluate_bash_command(
|
|
910
|
-
"git commit -m test",
|
|
911
|
-
working_directory=str(repository_root),
|
|
912
|
-
)
|
|
913
|
-
assert deny_reason is None
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
def test_exempt_repository_matches_ssh_origin_url(
|
|
917
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
918
|
-
) -> None:
|
|
919
|
-
repository_root = tmp_path / "repo"
|
|
920
|
-
_init_repo_with_staged_email(repository_root)
|
|
921
|
-
_add_repository_origin(repository_root, "git@github.com:ExemptOwner/exempt-repo.git")
|
|
922
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "exemptowner/EXEMPT-REPO")
|
|
923
|
-
deny_reason = evaluate_bash_command(
|
|
924
|
-
"git commit -m test",
|
|
925
|
-
working_directory=str(repository_root),
|
|
926
|
-
)
|
|
927
|
-
assert deny_reason is None
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
def test_non_exempt_repository_commit_still_scans_staged_pii(
|
|
931
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
932
|
-
) -> None:
|
|
933
|
-
repository_root = tmp_path / "repo"
|
|
934
|
-
_init_repo_with_staged_email(repository_root)
|
|
935
|
-
_add_repository_origin(repository_root, "https://github.com/OtherOwner/other-repo.git")
|
|
936
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
937
|
-
deny_reason = evaluate_bash_command(
|
|
938
|
-
"git commit -m test",
|
|
939
|
-
working_directory=str(repository_root),
|
|
940
|
-
)
|
|
941
|
-
assert deny_reason is not None
|
|
942
|
-
assert "email" in deny_reason
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
def test_repository_without_origin_is_not_exempt(
|
|
946
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
947
|
-
) -> None:
|
|
948
|
-
repository_root = tmp_path / "repo"
|
|
949
|
-
_init_repo_with_staged_email(repository_root)
|
|
950
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
951
|
-
deny_reason = evaluate_bash_command(
|
|
952
|
-
"git commit -m test",
|
|
953
|
-
working_directory=str(repository_root),
|
|
954
|
-
)
|
|
955
|
-
assert deny_reason is not None
|
|
956
|
-
assert "email" in deny_reason
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
def test_owner_repo_slug_from_https_origin_with_git_suffix() -> None:
|
|
960
|
-
assert (
|
|
961
|
-
blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo.git")
|
|
962
|
-
== "owner/repo"
|
|
963
|
-
)
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
def test_owner_repo_slug_from_https_origin_without_git_suffix() -> None:
|
|
967
|
-
assert (
|
|
968
|
-
blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo")
|
|
969
|
-
== "owner/repo"
|
|
970
|
-
)
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
def test_owner_repo_slug_from_scp_ssh_origin() -> None:
|
|
974
|
-
assert (
|
|
975
|
-
blocker_module._owner_repo_slug_from_origin_url("git@github.com:Owner/repo.git")
|
|
976
|
-
== "owner/repo"
|
|
977
|
-
)
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
def test_owner_repo_slug_from_ssh_scheme_origin() -> None:
|
|
981
|
-
assert (
|
|
982
|
-
blocker_module._owner_repo_slug_from_origin_url("ssh://git@github.com/Owner/repo.git")
|
|
983
|
-
== "owner/repo"
|
|
984
|
-
)
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
def test_owner_repo_slug_from_trailing_slash_git_suffix() -> None:
|
|
988
|
-
assert (
|
|
989
|
-
blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo.git/")
|
|
990
|
-
== "owner/repo"
|
|
991
|
-
)
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
def test_owner_repo_slug_rejects_spoofed_evil_host() -> None:
|
|
995
|
-
assert (
|
|
996
|
-
blocker_module._owner_repo_slug_from_origin_url(
|
|
997
|
-
"https://evil.test/ExemptOwner/exempt-repo.git"
|
|
998
|
-
)
|
|
999
|
-
is None
|
|
1000
|
-
)
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
def test_owner_repo_slug_rejects_notgithub_com_host() -> None:
|
|
1004
|
-
assert (
|
|
1005
|
-
blocker_module._owner_repo_slug_from_origin_url("https://notgithub.com/Owner/repo.git")
|
|
1006
|
-
is None
|
|
1007
|
-
)
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
def test_owner_repo_slug_rejects_github_com_suffix_host() -> None:
|
|
1011
|
-
assert (
|
|
1012
|
-
blocker_module._owner_repo_slug_from_origin_url(
|
|
1013
|
-
"https://github.com.evil.test/Owner/repo.git"
|
|
1014
|
-
)
|
|
1015
|
-
is None
|
|
1016
|
-
)
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
def test_owner_repo_slug_rejects_invalid_https_port_authority() -> None:
|
|
1020
|
-
assert (
|
|
1021
|
-
blocker_module._owner_repo_slug_from_origin_url(
|
|
1022
|
-
"https://github.com:443.evil.test/Owner/repo.git"
|
|
1023
|
-
)
|
|
1024
|
-
is None
|
|
1025
|
-
)
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
def test_owner_repo_slug_rejects_invalid_ssh_port_authority() -> None:
|
|
1029
|
-
assert (
|
|
1030
|
-
blocker_module._owner_repo_slug_from_origin_url(
|
|
1031
|
-
"ssh://git@github.com:22.evil.test/Owner/repo.git"
|
|
1032
|
-
)
|
|
1033
|
-
is None
|
|
1034
|
-
)
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
def test_owner_repo_slug_from_https_origin_with_valid_port() -> None:
|
|
1038
|
-
assert (
|
|
1039
|
-
blocker_module._owner_repo_slug_from_origin_url("https://github.com:443/Owner/repo.git")
|
|
1040
|
-
== "owner/repo"
|
|
1041
|
-
)
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
def test_owner_repo_slug_from_ssh_origin_with_valid_port() -> None:
|
|
1045
|
-
assert (
|
|
1046
|
-
blocker_module._owner_repo_slug_from_origin_url("ssh://git@github.com:22/Owner/repo.git")
|
|
1047
|
-
== "owner/repo"
|
|
1048
|
-
)
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
def test_owner_repo_slug_rejects_path_shaped_origin() -> None:
|
|
1052
|
-
assert (
|
|
1053
|
-
blocker_module._owner_repo_slug_from_origin_url("C:/repos/ExemptOwner/exempt-repo") is None
|
|
1054
|
-
)
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
def test_spoofed_origin_host_still_denies_staged_email(
|
|
1058
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1059
|
-
) -> None:
|
|
1060
|
-
repository_root = tmp_path / "repo"
|
|
1061
|
-
_init_repo_with_staged_email(repository_root)
|
|
1062
|
-
_add_repository_origin(repository_root, "https://evil.test/ExemptOwner/exempt-repo.git")
|
|
1063
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1064
|
-
deny_reason = evaluate_bash_command(
|
|
1065
|
-
"git commit -m test",
|
|
1066
|
-
working_directory=str(repository_root),
|
|
1067
|
-
)
|
|
1068
|
-
assert deny_reason is not None
|
|
1069
|
-
assert "email" in deny_reason
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
def test_invalid_port_authority_origin_still_denies_staged_email(
|
|
1073
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1074
|
-
) -> None:
|
|
1075
|
-
repository_root = tmp_path / "repo"
|
|
1076
|
-
_init_repo_with_staged_email(repository_root)
|
|
1077
|
-
_add_repository_origin(
|
|
1078
|
-
repository_root,
|
|
1079
|
-
"https://github.com:443.evil.test/ExemptOwner/exempt-repo.git",
|
|
1080
|
-
)
|
|
1081
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1082
|
-
deny_reason = evaluate_bash_command(
|
|
1083
|
-
"git commit -m test",
|
|
1084
|
-
working_directory=str(repository_root),
|
|
1085
|
-
)
|
|
1086
|
-
assert deny_reason is not None
|
|
1087
|
-
assert "email" in deny_reason
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
def test_path_shaped_origin_still_denies_staged_email(
|
|
1091
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1092
|
-
) -> None:
|
|
1093
|
-
repository_root = tmp_path / "repo"
|
|
1094
|
-
_init_repo_with_staged_email(repository_root)
|
|
1095
|
-
_add_repository_origin(repository_root, "C:/repos/ExemptOwner/exempt-repo")
|
|
1096
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1097
|
-
deny_reason = evaluate_bash_command(
|
|
1098
|
-
"git commit -m test",
|
|
1099
|
-
working_directory=str(repository_root),
|
|
1100
|
-
)
|
|
1101
|
-
assert deny_reason is not None
|
|
1102
|
-
assert "email" in deny_reason
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
def test_trailing_slash_git_suffix_origin_still_exempts(
|
|
1106
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1107
|
-
) -> None:
|
|
1108
|
-
repository_root = tmp_path / "repo"
|
|
1109
|
-
_init_repo_with_staged_email(repository_root)
|
|
1110
|
-
_add_repository_origin(repository_root, "https://github.com/ExemptOwner/exempt-repo.git/")
|
|
1111
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1112
|
-
deny_reason = evaluate_bash_command(
|
|
1113
|
-
"git commit -m test",
|
|
1114
|
-
working_directory=str(repository_root),
|
|
1115
|
-
)
|
|
1116
|
-
assert deny_reason is None
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
def test_ssh_scheme_origin_still_exempts(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
1120
|
-
repository_root = tmp_path / "repo"
|
|
1121
|
-
_init_repo_with_staged_email(repository_root)
|
|
1122
|
-
_add_repository_origin(repository_root, "ssh://git@github.com/ExemptOwner/exempt-repo.git")
|
|
1123
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1124
|
-
deny_reason = evaluate_bash_command(
|
|
1125
|
-
"git commit -m test",
|
|
1126
|
-
working_directory=str(repository_root),
|
|
1127
|
-
)
|
|
1128
|
-
assert deny_reason is None
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
def test_write_still_scans_when_repository_is_exempt(
|
|
1132
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
1133
|
-
) -> None:
|
|
1134
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1135
|
-
deny_reason = evaluate_write_edit_payload(
|
|
1136
|
-
"Write",
|
|
1137
|
-
{
|
|
1138
|
-
"file_path": "notes.md",
|
|
1139
|
-
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
1140
|
-
},
|
|
1141
|
-
)
|
|
1142
|
-
assert deny_reason is not None
|
|
1143
|
-
assert "email" in deny_reason
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
def test_post_body_still_scans_when_repository_is_exempt(
|
|
1147
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
1148
|
-
) -> None:
|
|
1149
|
-
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1150
|
-
deny_reason = evaluate_post_body_texts([f"Reach me at {SYNTHETIC_REAL_EMAIL}"])
|
|
1151
|
-
assert deny_reason is not None
|
|
1152
|
-
assert "email" in deny_reason
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
def test_apply_patch_add_with_real_email_is_denied(
|
|
1156
|
-
tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
|
|
1157
|
-
) -> None:
|
|
1158
|
-
repository_root = tmp_path / "repo"
|
|
1159
|
-
init_bare_git_repo(repository_root)
|
|
1160
|
-
deny_reason = evaluate(
|
|
1161
|
-
{
|
|
1162
|
-
"tool_name": "apply_patch",
|
|
1163
|
-
"cwd": str(repository_root),
|
|
1164
|
-
"tool_input": {
|
|
1165
|
-
"command": (
|
|
1166
|
-
"*** Begin Patch\n"
|
|
1167
|
-
"*** Add File: notes.md\n"
|
|
1168
|
-
f"+Reach me at {SYNTHETIC_REAL_EMAIL}\n"
|
|
1169
|
-
"*** End Patch"
|
|
1170
|
-
)
|
|
1171
|
-
},
|
|
1172
|
-
}
|
|
1173
|
-
)
|
|
1174
|
-
assert deny_reason is not None
|
|
1175
|
-
assert "email" in deny_reason
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
def test_apply_patch_add_with_clean_content_is_allowed(
|
|
1179
|
-
tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
|
|
1180
|
-
) -> None:
|
|
1181
|
-
repository_root = tmp_path / "repo"
|
|
1182
|
-
init_bare_git_repo(repository_root)
|
|
1183
|
-
deny_reason = evaluate(
|
|
1184
|
-
{
|
|
1185
|
-
"tool_name": "apply_patch",
|
|
1186
|
-
"cwd": str(repository_root),
|
|
1187
|
-
"tool_input": {
|
|
1188
|
-
"command": (
|
|
1189
|
-
"*** Begin Patch\n"
|
|
1190
|
-
"*** Add File: notes.md\n"
|
|
1191
|
-
"+Reach me at user@example.com\n"
|
|
1192
|
-
"*** End Patch"
|
|
1193
|
-
)
|
|
1194
|
-
},
|
|
1195
|
-
}
|
|
1196
|
-
)
|
|
1197
|
-
assert deny_reason is None
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
def test_subprocess_hook_denies_apply_patch_add_with_token(
|
|
1201
|
-
tmp_path: Path,
|
|
1202
|
-
init_bare_git_repo: Callable[[Path], None],
|
|
1203
|
-
synthetic_github_token: str,
|
|
1204
|
-
) -> None:
|
|
1205
|
-
"""The standalone hook process denies an apply_patch payload carrying a token."""
|
|
1206
|
-
repository_root = tmp_path / "repo"
|
|
1207
|
-
init_bare_git_repo(repository_root)
|
|
1208
|
-
payload = {
|
|
1209
|
-
"tool_name": "apply_patch",
|
|
1210
|
-
"cwd": str(repository_root),
|
|
1211
|
-
"tool_input": {
|
|
1212
|
-
"command": (
|
|
1213
|
-
"*** Begin Patch\n"
|
|
1214
|
-
"*** Add File: notes.md\n"
|
|
1215
|
-
f"+token is {synthetic_github_token}\n"
|
|
1216
|
-
"*** End Patch"
|
|
1217
|
-
)
|
|
1218
|
-
},
|
|
1219
|
-
}
|
|
1220
|
-
returncode, stdout = _run_hook(payload)
|
|
1221
|
-
assert returncode == 0
|
|
1222
|
-
assert json.loads(stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
|