claude-dev-env 8.4.0 → 8.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/hooks/blocking/pii_payload_scan.py +0 -348
- package/hooks/blocking/precommit_code_rules_gate.py +0 -50
- package/hooks/blocking/test_pii_payload_scan.py +0 -231
- package/hooks/blocking/test_precommit_code_rules_gate.py +0 -63
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +0 -12
- package/scripts/tests/test_code_review_constants.py +0 -80
package/package.json
CHANGED
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Scan Write/Edit payloads and durable post bodies for high-confidence PII.
|
|
3
|
-
|
|
4
|
-
Reused by ``pii_prevention_blocker``: ``build_deny_reason`` composes the deny
|
|
5
|
-
message, and ``evaluate_write_edit_payload`` / ``evaluate_post_body_texts`` judge
|
|
6
|
-
new file content and durable GitHub post bodies against the pure scanners in
|
|
7
|
-
``pii_scanner``.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import sys
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
try:
|
|
16
|
-
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
17
|
-
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
18
|
-
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
19
|
-
if each_bootstrap_directory not in sys.path:
|
|
20
|
-
sys.path.insert(0, each_bootstrap_directory)
|
|
21
|
-
from code_rules_shared import is_ephemeral_path
|
|
22
|
-
from codex_apply_patch import (
|
|
23
|
-
CodexPatchError,
|
|
24
|
-
parse_codex_apply_patch,
|
|
25
|
-
payload_patch_command,
|
|
26
|
-
)
|
|
27
|
-
from pii_prevention_blocker_parts.repository_exemption import (
|
|
28
|
-
repository_allowlisted_values,
|
|
29
|
-
)
|
|
30
|
-
from pii_scanner import (
|
|
31
|
-
PiiFinding,
|
|
32
|
-
is_path_exempt_from_pii_scan,
|
|
33
|
-
scan_text_for_pii,
|
|
34
|
-
)
|
|
35
|
-
from precommit_code_rules_gate import resolve_repository_root
|
|
36
|
-
|
|
37
|
-
from hooks_constants.local_identity import pii_allowlisted_values_by_repository
|
|
38
|
-
from hooks_constants.multi_edit_reconstruction import edits_for_tool
|
|
39
|
-
from hooks_constants.pii_prevention_constants import (
|
|
40
|
-
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
41
|
-
CORRECTIVE_MESSAGE_FOOTER,
|
|
42
|
-
CORRECTIVE_MESSAGE_HEADER,
|
|
43
|
-
EDIT_TOOL_NAME,
|
|
44
|
-
FINDING_LINE_TEMPLATE,
|
|
45
|
-
MESSAGE_LINE_SEPARATOR,
|
|
46
|
-
MULTI_EDIT_TOOL_NAME,
|
|
47
|
-
WRITE_TOOL_NAME,
|
|
48
|
-
)
|
|
49
|
-
except ImportError as import_error:
|
|
50
|
-
raise ImportError(
|
|
51
|
-
"pii_payload_scan: cannot import its sibling modules; "
|
|
52
|
-
"ensure the blocking and hooks directories are importable."
|
|
53
|
-
) from import_error
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _apply_patch_post_contents(
|
|
57
|
-
command: str, working_directory: str | None
|
|
58
|
-
) -> list[tuple[str, str]]:
|
|
59
|
-
"""Return (file_path, post_content) pairs for a Codex apply_patch command."""
|
|
60
|
-
if not command:
|
|
61
|
-
return []
|
|
62
|
-
try:
|
|
63
|
-
all_patch_files = parse_codex_apply_patch(command, working_directory)
|
|
64
|
-
except CodexPatchError:
|
|
65
|
-
return []
|
|
66
|
-
return [
|
|
67
|
-
(each_patch_file.file_path, each_patch_file.post_content)
|
|
68
|
-
for each_patch_file in all_patch_files
|
|
69
|
-
if each_patch_file.post_content
|
|
70
|
-
]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _apply_patch_target_deny_reason(
|
|
74
|
-
each_file_path: str,
|
|
75
|
-
each_post_content: str,
|
|
76
|
-
hook_payload: dict | None,
|
|
77
|
-
) -> str | None:
|
|
78
|
-
"""Return the deny reason for one apply_patch target, or None when it is clean or exempt."""
|
|
79
|
-
if is_path_exempt_from_pii_scan(each_file_path):
|
|
80
|
-
return None
|
|
81
|
-
if _target_is_ephemeral_outside_repository(each_file_path, hook_payload):
|
|
82
|
-
return None
|
|
83
|
-
return _write_deny_reason_for_texts([each_post_content], each_file_path, frozenset())
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def evaluate_apply_patch_payload(
|
|
87
|
-
all_tool_input: dict[str, object],
|
|
88
|
-
hook_payload: dict | None = None,
|
|
89
|
-
) -> str | None:
|
|
90
|
-
"""Return a deny reason when a Codex apply_patch payload's post content carries PII.
|
|
91
|
-
|
|
92
|
-
Args:
|
|
93
|
-
all_tool_input: The apply_patch payload's ``command`` and other input fields.
|
|
94
|
-
hook_payload: The PreToolUse payload carrying ``cwd`` and the session id.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
Deny reason text for the first target carrying non-allowlisted PII, or
|
|
98
|
-
None when every target is clean or out of scope.
|
|
99
|
-
"""
|
|
100
|
-
command, working_directory = payload_patch_command(hook_payload, all_tool_input)
|
|
101
|
-
for each_file_path, each_post_content in _apply_patch_post_contents(
|
|
102
|
-
command, working_directory
|
|
103
|
-
):
|
|
104
|
-
deny_reason = _apply_patch_target_deny_reason(
|
|
105
|
-
each_file_path, each_post_content, hook_payload
|
|
106
|
-
)
|
|
107
|
-
if deny_reason is not None:
|
|
108
|
-
return deny_reason
|
|
109
|
-
return None
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _target_is_ephemeral_outside_repository(
|
|
113
|
-
file_path: str, hook_payload: dict | None
|
|
114
|
-
) -> bool:
|
|
115
|
-
"""Report whether a write target is a throwaway draft outside every repo.
|
|
116
|
-
|
|
117
|
-
::
|
|
118
|
-
|
|
119
|
-
ephemeral path, no repo tree -> True (write scan skipped)
|
|
120
|
-
ephemeral path inside a repo -> False (write scan kept)
|
|
121
|
-
ordinary path -> False (write scan kept)
|
|
122
|
-
|
|
123
|
-
A draft under the harness scratchpad or an ephemeral scratch root turns
|
|
124
|
-
durable only through a surface that keeps its own scan, so scanning it at
|
|
125
|
-
write time is a false positive. A draft that resolves inside a git tree
|
|
126
|
-
still carries that repository's own allowlist, so the scan stays.
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
file_path: The write target path to classify.
|
|
130
|
-
hook_payload: The PreToolUse payload carrying the session id, or None to
|
|
131
|
-
read the session id from the environment alone.
|
|
132
|
-
|
|
133
|
-
Returns:
|
|
134
|
-
True only when the path is ephemeral and no enclosing git repository
|
|
135
|
-
resolves.
|
|
136
|
-
"""
|
|
137
|
-
if not file_path:
|
|
138
|
-
return False
|
|
139
|
-
if not is_ephemeral_path(file_path, hook_payload):
|
|
140
|
-
return False
|
|
141
|
-
resolution_directory = _existing_ancestor_directory(file_path)
|
|
142
|
-
if resolution_directory is None:
|
|
143
|
-
return True
|
|
144
|
-
return resolve_repository_root(resolution_directory) is None
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
|
|
148
|
-
"""Return the deny message listing each finding for *gate_surface*.
|
|
149
|
-
|
|
150
|
-
Args:
|
|
151
|
-
all_findings: Findings returned by ``scan_text_for_pii``.
|
|
152
|
-
gate_surface: Human-readable surface (write, post body, staged commit).
|
|
153
|
-
|
|
154
|
-
Returns:
|
|
155
|
-
Multi-line deny reason for ``permissionDecisionReason``.
|
|
156
|
-
"""
|
|
157
|
-
all_lines = [
|
|
158
|
-
CORRECTIVE_MESSAGE_HEADER,
|
|
159
|
-
f"Surface: {gate_surface}",
|
|
160
|
-
]
|
|
161
|
-
for each_finding in all_findings:
|
|
162
|
-
all_lines.append(
|
|
163
|
-
FINDING_LINE_TEMPLATE.format(
|
|
164
|
-
category=each_finding.category,
|
|
165
|
-
preview=each_finding.preview,
|
|
166
|
-
)
|
|
167
|
-
)
|
|
168
|
-
all_lines.append(CORRECTIVE_MESSAGE_FOOTER)
|
|
169
|
-
message_line_separator = MESSAGE_LINE_SEPARATOR
|
|
170
|
-
return message_line_separator.join(all_lines)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _new_strings_from_edits(
|
|
174
|
-
tool_name: str, all_tool_input: dict[str, object]
|
|
175
|
-
) -> list[str]:
|
|
176
|
-
"""Return each non-empty ``new_string`` from an Edit/MultiEdit payload."""
|
|
177
|
-
all_texts: list[str] = []
|
|
178
|
-
for each_edit in edits_for_tool(tool_name, all_tool_input):
|
|
179
|
-
if not isinstance(each_edit, dict):
|
|
180
|
-
continue
|
|
181
|
-
new_string = each_edit.get("new_string", "")
|
|
182
|
-
if isinstance(new_string, str) and new_string:
|
|
183
|
-
all_texts.append(new_string)
|
|
184
|
-
return all_texts
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _collect_write_edit_texts(
|
|
188
|
-
tool_name: str, all_tool_input: dict[str, object]
|
|
189
|
-
) -> tuple[str, list[str]]:
|
|
190
|
-
raw_file_path = all_tool_input.get("file_path", "")
|
|
191
|
-
file_path = raw_file_path if isinstance(raw_file_path, str) else ""
|
|
192
|
-
if is_path_exempt_from_pii_scan(file_path):
|
|
193
|
-
return file_path, []
|
|
194
|
-
if tool_name == WRITE_TOOL_NAME:
|
|
195
|
-
write_content = all_tool_input.get("content", "")
|
|
196
|
-
if isinstance(write_content, str) and write_content:
|
|
197
|
-
return file_path, [write_content]
|
|
198
|
-
return file_path, []
|
|
199
|
-
if tool_name in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
|
|
200
|
-
return file_path, _new_strings_from_edits(tool_name, all_tool_input)
|
|
201
|
-
return file_path, []
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def _findings_after_allowlist(
|
|
205
|
-
text: str, all_allowlisted_values: frozenset[str]
|
|
206
|
-
) -> list[PiiFinding]:
|
|
207
|
-
"""Return PII findings in *text* whose matched text is not allowlisted."""
|
|
208
|
-
return [
|
|
209
|
-
each_finding
|
|
210
|
-
for each_finding in scan_text_for_pii(text)
|
|
211
|
-
if each_finding.matched_text not in all_allowlisted_values
|
|
212
|
-
]
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def _first_findings_in_texts(
|
|
216
|
-
all_texts: list[str], all_allowlisted_values: frozenset[str] = frozenset()
|
|
217
|
-
) -> list[PiiFinding]:
|
|
218
|
-
for each_text in all_texts:
|
|
219
|
-
all_findings = _findings_after_allowlist(each_text, all_allowlisted_values)
|
|
220
|
-
if all_findings:
|
|
221
|
-
return all_findings
|
|
222
|
-
return []
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _existing_ancestor_directory(file_path: str) -> str | None:
|
|
226
|
-
"""Return the nearest existing ancestor directory of *file_path*, or None.
|
|
227
|
-
|
|
228
|
-
PreToolUse runs before Write creates nested parents, so the immediate parent
|
|
229
|
-
may not exist yet; git resolution needs a directory that is already on disk.
|
|
230
|
-
"""
|
|
231
|
-
starting_directory = Path(file_path).parent
|
|
232
|
-
for each_ancestor in (starting_directory, *starting_directory.parents):
|
|
233
|
-
if each_ancestor.exists():
|
|
234
|
-
return str(each_ancestor)
|
|
235
|
-
return None
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def _write_path_allowlisted_values(file_path: str) -> frozenset[str]:
|
|
239
|
-
"""Return the exact values allowlisted for the repository *file_path* sits in.
|
|
240
|
-
|
|
241
|
-
::
|
|
242
|
-
|
|
243
|
-
no path, or no allowlist configured -> frozenset() (no git call)
|
|
244
|
-
path inside an allowlisted repo tree -> that repo's frozenset of values
|
|
245
|
-
|
|
246
|
-
Args:
|
|
247
|
-
file_path: The write target path whose repository keys the allowlist.
|
|
248
|
-
|
|
249
|
-
Returns:
|
|
250
|
-
The exact values the repository allows past the scan, or an empty set.
|
|
251
|
-
"""
|
|
252
|
-
if not file_path:
|
|
253
|
-
return frozenset()
|
|
254
|
-
if not pii_allowlisted_values_by_repository():
|
|
255
|
-
return frozenset()
|
|
256
|
-
resolution_directory = _existing_ancestor_directory(file_path)
|
|
257
|
-
if resolution_directory is None:
|
|
258
|
-
return frozenset()
|
|
259
|
-
repository_root = resolve_repository_root(resolution_directory)
|
|
260
|
-
if repository_root is None:
|
|
261
|
-
return frozenset()
|
|
262
|
-
return repository_allowlisted_values(repository_root)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _write_deny_reason_for_texts(
|
|
266
|
-
all_texts: list[str],
|
|
267
|
-
file_path: str,
|
|
268
|
-
all_allowlisted_values: frozenset[str],
|
|
269
|
-
) -> str | None:
|
|
270
|
-
"""Return the deny reason for the first text carrying non-allowlisted PII.
|
|
271
|
-
|
|
272
|
-
Repository allowlist resolution runs once, only after a raw PII hit, so a
|
|
273
|
-
clean payload triggers no git call.
|
|
274
|
-
"""
|
|
275
|
-
resolved_allowlisted_values: frozenset[str] | None = None
|
|
276
|
-
for each_text in all_texts:
|
|
277
|
-
all_raw_findings = scan_text_for_pii(each_text)
|
|
278
|
-
if not all_raw_findings:
|
|
279
|
-
continue
|
|
280
|
-
if resolved_allowlisted_values is None:
|
|
281
|
-
resolved_allowlisted_values = (
|
|
282
|
-
all_allowlisted_values | _write_path_allowlisted_values(file_path)
|
|
283
|
-
)
|
|
284
|
-
all_findings = [
|
|
285
|
-
each_finding
|
|
286
|
-
for each_finding in all_raw_findings
|
|
287
|
-
if each_finding.matched_text not in resolved_allowlisted_values
|
|
288
|
-
]
|
|
289
|
-
if all_findings:
|
|
290
|
-
gate_surface = f"file write ({file_path or 'unknown path'})"
|
|
291
|
-
return build_deny_reason(all_findings, gate_surface)
|
|
292
|
-
return None
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def evaluate_write_edit_payload(
|
|
296
|
-
tool_name: str,
|
|
297
|
-
all_tool_input: dict[str, object],
|
|
298
|
-
all_allowlisted_values: frozenset[str] = frozenset(),
|
|
299
|
-
hook_payload: dict | None = None,
|
|
300
|
-
) -> str | None:
|
|
301
|
-
"""Return a deny reason when Write/Edit/MultiEdit content carries PII.
|
|
302
|
-
|
|
303
|
-
A value in the target repository's PII allowlist is dropped from the
|
|
304
|
-
findings, so a write under that repository's tree may carry it. A draft
|
|
305
|
-
under an ephemeral scratch root outside every repository is skipped, since
|
|
306
|
-
it turns durable only through surfaces that keep their own scans.
|
|
307
|
-
|
|
308
|
-
Args:
|
|
309
|
-
tool_name: The intercepted tool name.
|
|
310
|
-
all_tool_input: The tool input mapping.
|
|
311
|
-
all_allowlisted_values: Extra exact values allowed past the scan,
|
|
312
|
-
unioned with the target repository's own allowlist.
|
|
313
|
-
hook_payload: The PreToolUse payload carrying the session id, threaded
|
|
314
|
-
to the ephemeral-path predicate; None reads the session id from the
|
|
315
|
-
environment alone.
|
|
316
|
-
|
|
317
|
-
Returns:
|
|
318
|
-
Deny reason text, or None when the write is clean or out of scope.
|
|
319
|
-
"""
|
|
320
|
-
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
321
|
-
return None
|
|
322
|
-
file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
|
|
323
|
-
if not all_texts:
|
|
324
|
-
return None
|
|
325
|
-
if _target_is_ephemeral_outside_repository(file_path, hook_payload):
|
|
326
|
-
return None
|
|
327
|
-
return _write_deny_reason_for_texts(all_texts, file_path, all_allowlisted_values)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def evaluate_post_body_texts(
|
|
331
|
-
all_body_texts: list[str],
|
|
332
|
-
all_allowlisted_values: frozenset[str] = frozenset(),
|
|
333
|
-
) -> str | None:
|
|
334
|
-
"""Return a deny reason when any durable post body carries PII.
|
|
335
|
-
|
|
336
|
-
Args:
|
|
337
|
-
all_body_texts: Body strings extracted from a gh or MCP post tool.
|
|
338
|
-
all_allowlisted_values: Exact values the caller's repository allows;
|
|
339
|
-
a finding matching one is not a finding. Callers that pass
|
|
340
|
-
nothing keep every post fully scanned.
|
|
341
|
-
|
|
342
|
-
Returns:
|
|
343
|
-
Deny reason text, or None when every body is clean.
|
|
344
|
-
"""
|
|
345
|
-
all_findings = _first_findings_in_texts(all_body_texts, all_allowlisted_values)
|
|
346
|
-
if not all_findings:
|
|
347
|
-
return None
|
|
348
|
-
return build_deny_reason(all_findings, "durable GitHub post body")
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
"""Library helper that resolves a directory to its Git repository root.
|
|
2
|
-
|
|
3
|
-
``pii_payload_scan.py`` imports ``resolve_repository_root`` for staged scans.
|
|
4
|
-
This module has no hook entry point. Native ``git-hooks/pre_commit.py`` shows
|
|
5
|
-
the local verification advisory and does not enforce CODE_RULES.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
13
|
-
if _hooks_directory not in sys.path:
|
|
14
|
-
sys.path.insert(0, _hooks_directory)
|
|
15
|
-
|
|
16
|
-
from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
|
|
17
|
-
ALL_GIT_REPOSITORY_ROOT_COMMAND,
|
|
18
|
-
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def resolve_repository_root(working_directory: str | None) -> Path | None:
|
|
25
|
-
"""Resolve the Git repository root for a directory.
|
|
26
|
-
|
|
27
|
-
Args:
|
|
28
|
-
working_directory: Directory inside the repository.
|
|
29
|
-
|
|
30
|
-
Returns:
|
|
31
|
-
Repository root. Returns None when Git cannot resolve the directory.
|
|
32
|
-
"""
|
|
33
|
-
try:
|
|
34
|
-
completed_process = subprocess.run(
|
|
35
|
-
list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
|
|
36
|
-
check=False,
|
|
37
|
-
capture_output=True,
|
|
38
|
-
text=True,
|
|
39
|
-
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
40
|
-
cwd=working_directory,
|
|
41
|
-
creationflags=hidden_window_creation_flags(),
|
|
42
|
-
)
|
|
43
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
44
|
-
return None
|
|
45
|
-
if completed_process.returncode != 0:
|
|
46
|
-
return None
|
|
47
|
-
top_level_text = completed_process.stdout.strip()
|
|
48
|
-
if not top_level_text:
|
|
49
|
-
return None
|
|
50
|
-
return Path(top_level_text)
|
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for the Write/Edit payload PII evaluation."""
|
|
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
|
-
|
|
15
|
-
try:
|
|
16
|
-
from pii_payload_scan import (
|
|
17
|
-
evaluate_apply_patch_payload,
|
|
18
|
-
evaluate_post_body_texts,
|
|
19
|
-
evaluate_write_edit_payload,
|
|
20
|
-
)
|
|
21
|
-
except ImportError:
|
|
22
|
-
for each_bootstrap_directory in (str(_HOOK_DIR), str(_HOOKS_DIR)):
|
|
23
|
-
if each_bootstrap_directory not in sys.path:
|
|
24
|
-
sys.path.insert(0, each_bootstrap_directory)
|
|
25
|
-
from pii_payload_scan import (
|
|
26
|
-
evaluate_apply_patch_payload,
|
|
27
|
-
evaluate_post_body_texts,
|
|
28
|
-
evaluate_write_edit_payload,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
32
|
-
|
|
33
|
-
_ALLOW_SLUG = "AllowOwner/allow-repo"
|
|
34
|
-
_OTHER_SLUG = "OtherOwner/other-repo"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_post_body_with_pii_is_blocked_by_default() -> None:
|
|
38
|
-
deny_reason = evaluate_post_body_texts([_assembled_fixture_email()])
|
|
39
|
-
assert deny_reason is not None
|
|
40
|
-
assert "post body" in deny_reason
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_post_body_allowlisted_values_plumb_through_to_the_scan() -> None:
|
|
44
|
-
allowed_value = _assembled_fixture_email()
|
|
45
|
-
deny_reason = evaluate_post_body_texts(
|
|
46
|
-
[allowed_value], all_allowlisted_values=frozenset({allowed_value})
|
|
47
|
-
)
|
|
48
|
-
assert deny_reason is None
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_post_body_unlisted_value_still_blocked_beside_allowlist() -> None:
|
|
52
|
-
deny_reason = evaluate_post_body_texts(
|
|
53
|
-
[_assembled_unlisted_email()],
|
|
54
|
-
all_allowlisted_values=frozenset({_assembled_fixture_email()}),
|
|
55
|
-
)
|
|
56
|
-
assert deny_reason is not None
|
|
57
|
-
assert "post body" in deny_reason
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _assembled_fixture_email() -> str:
|
|
61
|
-
return "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _assembled_unlisted_email() -> str:
|
|
65
|
-
return "other.person" + "@" + "different-co" + ".example" + ".io"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _write_allowlist_identity(identity_path: Path, slug: str, value: str) -> None:
|
|
69
|
-
identity_path.write_text(
|
|
70
|
-
json.dumps({"pii_allowlisted_values": {slug: [value]}}),
|
|
71
|
-
encoding="utf-8",
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _init_repo_with_github_origin(repository_root: Path, origin_slug: str) -> None:
|
|
76
|
-
repository_root.mkdir(parents=True, exist_ok=True)
|
|
77
|
-
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
78
|
-
origin_url = "https://github.com/" + origin_slug + ".git"
|
|
79
|
-
subprocess.run(
|
|
80
|
-
["git", "remote", "add", "origin", origin_url],
|
|
81
|
-
cwd=repository_root,
|
|
82
|
-
check=True,
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _write_deny_reason(target_path: Path, value: str) -> str | None:
|
|
87
|
-
content = "owner email " + value + "\n"
|
|
88
|
-
return evaluate_write_edit_payload(
|
|
89
|
-
"Write", {"file_path": str(target_path), "content": content}
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def test_write_payload_with_a_fixture_email_is_flagged(tmp_path: Path) -> None:
|
|
94
|
-
deny_reason = _write_deny_reason(tmp_path / "notes.md", _assembled_fixture_email())
|
|
95
|
-
assert deny_reason is not None
|
|
96
|
-
assert "email" in deny_reason
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def test_allowlisted_value_under_the_allowlisted_repo_is_allowed(
|
|
100
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
101
|
-
) -> None:
|
|
102
|
-
allowed_value = _assembled_fixture_email()
|
|
103
|
-
identity_path = tmp_path / "local-identity.json"
|
|
104
|
-
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
105
|
-
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
106
|
-
repository_root = tmp_path / "repo"
|
|
107
|
-
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
108
|
-
assert _write_deny_reason(repository_root / "notes.md", allowed_value) is None
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def test_allowlisted_value_in_a_different_repo_is_blocked(
|
|
112
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
113
|
-
) -> None:
|
|
114
|
-
allowed_value = _assembled_fixture_email()
|
|
115
|
-
identity_path = tmp_path / "local-identity.json"
|
|
116
|
-
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
117
|
-
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
118
|
-
repository_root = tmp_path / "repo"
|
|
119
|
-
_init_repo_with_github_origin(repository_root, _OTHER_SLUG)
|
|
120
|
-
deny_reason = _write_deny_reason(repository_root / "notes.md", allowed_value)
|
|
121
|
-
assert deny_reason is not None
|
|
122
|
-
assert "email" in deny_reason
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def test_unlisted_value_in_the_allowlisted_repo_is_blocked(
|
|
126
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
127
|
-
) -> None:
|
|
128
|
-
identity_path = tmp_path / "local-identity.json"
|
|
129
|
-
_write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
|
|
130
|
-
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
131
|
-
repository_root = tmp_path / "repo"
|
|
132
|
-
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
133
|
-
deny_reason = _write_deny_reason(
|
|
134
|
-
repository_root / "notes.md", _assembled_unlisted_email()
|
|
135
|
-
)
|
|
136
|
-
assert deny_reason is not None
|
|
137
|
-
assert "email" in deny_reason
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def test_clean_write_payload_skips_repository_resolution(
|
|
141
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
142
|
-
) -> None:
|
|
143
|
-
identity_path = tmp_path / "local-identity.json"
|
|
144
|
-
_write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
|
|
145
|
-
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
146
|
-
repository_root = tmp_path / "repo"
|
|
147
|
-
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
148
|
-
|
|
149
|
-
def _fail_if_called(_working_directory: str | None) -> None:
|
|
150
|
-
raise AssertionError(
|
|
151
|
-
"resolve_repository_root should not run for a payload with no PII"
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
monkeypatch.setattr(
|
|
155
|
-
sys.modules["pii_payload_scan"], "resolve_repository_root", _fail_if_called
|
|
156
|
-
)
|
|
157
|
-
deny_reason = evaluate_write_edit_payload(
|
|
158
|
-
"Write",
|
|
159
|
-
{
|
|
160
|
-
"file_path": str(repository_root / "notes.md"),
|
|
161
|
-
"content": "nothing sensitive here\n",
|
|
162
|
-
},
|
|
163
|
-
)
|
|
164
|
-
assert deny_reason is None
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def test_allowlisted_value_under_missing_nested_parent_is_allowed(
|
|
168
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
169
|
-
) -> None:
|
|
170
|
-
allowed_value = _assembled_fixture_email()
|
|
171
|
-
identity_path = tmp_path / "local-identity.json"
|
|
172
|
-
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
173
|
-
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
174
|
-
repository_root = tmp_path / "repo"
|
|
175
|
-
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
176
|
-
nested_target = repository_root / "new_dir" / "deeper" / "notes.md"
|
|
177
|
-
assert not nested_target.parent.exists()
|
|
178
|
-
assert _write_deny_reason(nested_target, allowed_value) is None
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def test_apply_patch_add_with_pii_is_denied(tmp_path: Path) -> None:
|
|
182
|
-
repository_root = tmp_path / "repo"
|
|
183
|
-
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
184
|
-
deny_reason = evaluate_apply_patch_payload(
|
|
185
|
-
{
|
|
186
|
-
"command": (
|
|
187
|
-
"*** Begin Patch\n"
|
|
188
|
-
"*** Add File: notes.md\n"
|
|
189
|
-
f"+Reach me at {_assembled_fixture_email()}\n"
|
|
190
|
-
"*** End Patch"
|
|
191
|
-
)
|
|
192
|
-
},
|
|
193
|
-
hook_payload={"cwd": str(repository_root)},
|
|
194
|
-
)
|
|
195
|
-
assert deny_reason is not None
|
|
196
|
-
assert "email" in deny_reason
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def test_apply_patch_add_with_clean_content_is_allowed(tmp_path: Path) -> None:
|
|
200
|
-
repository_root = tmp_path / "repo"
|
|
201
|
-
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
202
|
-
deny_reason = evaluate_apply_patch_payload(
|
|
203
|
-
{
|
|
204
|
-
"command": (
|
|
205
|
-
"*** Begin Patch\n"
|
|
206
|
-
"*** Add File: notes.md\n"
|
|
207
|
-
"+nothing sensitive here\n"
|
|
208
|
-
"*** End Patch"
|
|
209
|
-
)
|
|
210
|
-
},
|
|
211
|
-
hook_payload={"cwd": str(repository_root)},
|
|
212
|
-
)
|
|
213
|
-
assert deny_reason is None
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def test_apply_patch_payload_accepts_the_tool_input_keyword(tmp_path: Path) -> None:
|
|
217
|
-
"""evaluate_apply_patch_payload's first parameter is named all_tool_input."""
|
|
218
|
-
repository_root = tmp_path / "repo"
|
|
219
|
-
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
220
|
-
deny_reason = evaluate_apply_patch_payload(
|
|
221
|
-
all_tool_input={
|
|
222
|
-
"command": (
|
|
223
|
-
"*** Begin Patch\n"
|
|
224
|
-
"*** Add File: notes.md\n"
|
|
225
|
-
"+nothing sensitive here\n"
|
|
226
|
-
"*** End Patch"
|
|
227
|
-
)
|
|
228
|
-
},
|
|
229
|
-
hook_payload={"cwd": str(repository_root)},
|
|
230
|
-
)
|
|
231
|
-
assert deny_reason is None
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for the precommit_code_rules_gate library module.
|
|
2
|
-
|
|
3
|
-
The module carries no hook entry point of its own; it exports
|
|
4
|
-
``resolve_repository_root`` for its importers (``pii_prevention_blocker.py``,
|
|
5
|
-
``pii_payload_scan.py``, ``session_edit_stage_gate.py``).
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
try:
|
|
13
|
-
import precommit_code_rules_gate as gate_module
|
|
14
|
-
from precommit_code_rules_gate import resolve_repository_root
|
|
15
|
-
except ModuleNotFoundError:
|
|
16
|
-
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
17
|
-
if _BLOCKING_DIR not in sys.path:
|
|
18
|
-
sys.path.insert(0, _BLOCKING_DIR)
|
|
19
|
-
import precommit_code_rules_gate as gate_module
|
|
20
|
-
from precommit_code_rules_gate import resolve_repository_root
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def run_git(repository_root: Path, *git_arguments: str) -> None:
|
|
24
|
-
subprocess.run(
|
|
25
|
-
["git", "-C", str(repository_root), *git_arguments],
|
|
26
|
-
check=True,
|
|
27
|
-
capture_output=True,
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def initialize_repository(repository_root: Path) -> None:
|
|
32
|
-
run_git(repository_root, "init")
|
|
33
|
-
run_git(repository_root, "config", "user.email", "tests@example.com")
|
|
34
|
-
run_git(repository_root, "config", "user.name", "Gate Tests")
|
|
35
|
-
run_git(repository_root, "commit", "--allow-empty", "-m", "initial")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_resolve_repository_root_returns_root_for_directory_inside_repository(
|
|
39
|
-
tmp_path: Path,
|
|
40
|
-
) -> None:
|
|
41
|
-
initialize_repository(tmp_path)
|
|
42
|
-
nested_directory = tmp_path / "nested"
|
|
43
|
-
nested_directory.mkdir()
|
|
44
|
-
resolved_root = resolve_repository_root(str(nested_directory))
|
|
45
|
-
assert resolved_root == tmp_path.resolve()
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_resolve_repository_root_returns_none_outside_a_repository(
|
|
49
|
-
tmp_path: Path,
|
|
50
|
-
) -> None:
|
|
51
|
-
non_repository_directory = tmp_path / "not_a_repo"
|
|
52
|
-
non_repository_directory.mkdir()
|
|
53
|
-
assert resolve_repository_root(str(non_repository_directory)) is None
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_module_carries_no_main_entry_point() -> None:
|
|
57
|
-
"""The module has no dispatcher-facing hook entry point."""
|
|
58
|
-
assert not hasattr(gate_module, "main")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_module_carries_no_commit_invocation_detector() -> None:
|
|
62
|
-
"""The commit-detection helper lives in block_main_commit, not duplicated here."""
|
|
63
|
-
assert not hasattr(gate_module, "is_git_commit_invocation")
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
"""Behavioral tests for the host-aware code-review invoker constants module."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dev_env_scripts_constants import code_review_constants as review_constants
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_default_effort_is_a_known_token() -> None:
|
|
9
|
-
assert (
|
|
10
|
-
review_constants.DEFAULT_CODE_REVIEW_EFFORT
|
|
11
|
-
in review_constants.ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
|
|
12
|
-
)
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""Specifications for the permission modes the review binary is asked for.
|
|
2
|
-
|
|
3
|
-
The review binary accepts one permission mode for an ordinary caller and a
|
|
4
|
-
different one for root. Naming the wrong mode means the binary refuses the
|
|
5
|
-
call, no review runs, and no stamp is ever minted.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import importlib
|
|
11
|
-
import os
|
|
12
|
-
|
|
13
|
-
import pytest
|
|
14
|
-
|
|
15
|
-
from dev_env_scripts_constants import code_review_constants
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
BYPASS_MODE_TOKEN: str = "bypassPermissions"
|
|
19
|
-
ACCEPT_EDITS_MODE_TOKEN: str = "acceptEdits"
|
|
20
|
-
ROOT_USER_ID: int = 0
|
|
21
|
-
UNPRIVILEGED_USER_ID: int = 1000
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _permission_mode_seen_by(
|
|
25
|
-
monkeypatch: pytest.MonkeyPatch, effective_user_id: int
|
|
26
|
-
) -> str:
|
|
27
|
-
"""Read the review permission mode a caller with this user id resolves."""
|
|
28
|
-
monkeypatch.setattr(os, "geteuid", lambda: effective_user_id)
|
|
29
|
-
reloaded_constants = importlib.reload(code_review_constants)
|
|
30
|
-
return str(reloaded_constants.REVIEW_PERMISSION_MODE)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_root_detection_agrees_with_this_process(
|
|
34
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
35
|
-
) -> None:
|
|
36
|
-
monkeypatch.setattr(os, "geteuid", lambda: ROOT_USER_ID)
|
|
37
|
-
reloaded_constants = importlib.reload(code_review_constants)
|
|
38
|
-
|
|
39
|
-
assert reloaded_constants.IS_ROOT_CALLER is True
|
|
40
|
-
assert reloaded_constants.ROOT_EFFECTIVE_USER_ID == ROOT_USER_ID
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_bypass_mode_names_the_token_the_binary_reads() -> None:
|
|
44
|
-
assert code_review_constants.PERMISSION_MODE_BYPASS == BYPASS_MODE_TOKEN
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def test_accept_edits_mode_names_the_token_the_binary_reads() -> None:
|
|
48
|
-
assert code_review_constants.PERMISSION_MODE_ACCEPT_EDITS == ACCEPT_EDITS_MODE_TOKEN
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_a_root_caller_resolves_the_mode_the_binary_accepts(
|
|
52
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
53
|
-
) -> None:
|
|
54
|
-
assert _permission_mode_seen_by(monkeypatch, ROOT_USER_ID) == (
|
|
55
|
-
ACCEPT_EDITS_MODE_TOKEN
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_an_ordinary_caller_resolves_the_bypass_mode(
|
|
60
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
61
|
-
) -> None:
|
|
62
|
-
assert _permission_mode_seen_by(monkeypatch, UNPRIVILEGED_USER_ID) == (
|
|
63
|
-
BYPASS_MODE_TOKEN
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_a_platform_without_a_user_id_still_loads_the_constants(
|
|
68
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
69
|
-
) -> None:
|
|
70
|
-
"""Windows exposes no ``os.geteuid``, and the module still imports.
|
|
71
|
-
|
|
72
|
-
Reading a missing ``os.geteuid`` raises, and every caller of the review
|
|
73
|
-
invoker dies at import. A platform that reports no user id is never the
|
|
74
|
-
root the review binary refuses, so it reads as an ordinary caller.
|
|
75
|
-
"""
|
|
76
|
-
monkeypatch.delattr(os, "geteuid", raising=False)
|
|
77
|
-
reloaded_constants = importlib.reload(code_review_constants)
|
|
78
|
-
|
|
79
|
-
assert reloaded_constants.IS_ROOT_CALLER is False
|
|
80
|
-
assert reloaded_constants.REVIEW_PERMISSION_MODE == BYPASS_MODE_TOKEN
|