claude-dev-env 2.31.0 → 2.32.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/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +2 -1
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_duplicate_body.py +280 -102
- package/hooks/blocking/code_rules_enforcer.py +32 -14
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/pr_description_writer_gate.py +18 -25
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +11 -5
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
- package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +84 -53
- package/hooks/blocking/test_tdd_enforcer.py +131 -1
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
- package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/abbreviation_checks.py +3 -3
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +42 -8
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +23 -80
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +3 -2
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +247 -148
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +16 -16
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +104 -0
- package/scripts/test_shared_tree_paths.py +173 -0
|
@@ -247,11 +247,11 @@ def _validated_phase(phase: str) -> str:
|
|
|
247
247
|
return phase
|
|
248
248
|
|
|
249
249
|
|
|
250
|
-
def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
|
|
250
|
+
def _codex_patch_issues(each_patch_file: CodexPatchFile, repository_root: str = "") -> list[str]:
|
|
251
251
|
"""Run the existing code-rules verdict over one Codex patch view."""
|
|
252
252
|
if not each_patch_file.post_content and each_patch_file.operation == "delete":
|
|
253
253
|
return []
|
|
254
|
-
if _is_hook_infrastructure_python_target(each_patch_file.file_path):
|
|
254
|
+
if _is_hook_infrastructure_python_target(each_patch_file.file_path, repository_root):
|
|
255
255
|
all_issues = _hook_infrastructure_blocking_issues(
|
|
256
256
|
each_patch_file.post_content,
|
|
257
257
|
each_patch_file.file_path,
|
|
@@ -259,7 +259,7 @@ def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
|
|
|
259
259
|
each_patch_file.prior_content,
|
|
260
260
|
phase=EDIT_LANE_PHASE,
|
|
261
261
|
)
|
|
262
|
-
elif _is_validated_target(each_patch_file.file_path):
|
|
262
|
+
elif _is_validated_target(each_patch_file.file_path, repository_root):
|
|
263
263
|
all_issues = validate_content_for_edit_lane(
|
|
264
264
|
each_patch_file.post_content,
|
|
265
265
|
each_patch_file.file_path,
|
|
@@ -306,7 +306,7 @@ def _report_codex_patch_payload(
|
|
|
306
306
|
all_issues = [
|
|
307
307
|
each_issue
|
|
308
308
|
for each_patch_file in all_patch_files
|
|
309
|
-
for each_issue in _codex_patch_issues(each_patch_file)
|
|
309
|
+
for each_issue in _codex_patch_issues(each_patch_file, working_directory or "")
|
|
310
310
|
]
|
|
311
311
|
if all_issues:
|
|
312
312
|
_write_deny_payload(
|
|
@@ -708,14 +708,16 @@ def validate_content_for_full_gate(
|
|
|
708
708
|
|
|
709
709
|
|
|
710
710
|
def prior_and_post_edit_content(
|
|
711
|
-
file_path: str, old_string: str, new_string: str,
|
|
711
|
+
file_path: str, old_string: str, new_string: str, is_replace_all: bool = False,
|
|
712
712
|
) -> tuple[str | None, str | None]:
|
|
713
713
|
"""Return the pre-edit and post-edit file content from a single disk read.
|
|
714
714
|
|
|
715
715
|
Reads ``file_path`` once and derives both views from that single read so the
|
|
716
716
|
prior and the reconstruction never diverge across two independent reads.
|
|
717
|
-
The post-edit view
|
|
718
|
-
|
|
717
|
+
The post-edit view comes from apply_edits, the same reconstruction the
|
|
718
|
+
MultiEdit lane uses, so one payload never reconstructs two ways. It replaces
|
|
719
|
+
the first occurrence of ``old_string`` with ``new_string``, or every occurrence
|
|
720
|
+
when the Edit carries ``replace_all``, mirroring how the Edit tool applies a
|
|
719
721
|
replacement.
|
|
720
722
|
|
|
721
723
|
Returns ``(None, None)`` when the file cannot be read, ``old_string`` is
|
|
@@ -729,6 +731,7 @@ def prior_and_post_edit_content(
|
|
|
729
731
|
file_path: The path of the file the Edit targets.
|
|
730
732
|
old_string: The Edit's ``old_string`` fragment.
|
|
731
733
|
new_string: The Edit's ``new_string`` fragment.
|
|
734
|
+
is_replace_all: Whether the Edit carries ``replace_all``.
|
|
732
735
|
|
|
733
736
|
Returns:
|
|
734
737
|
A ``(prior_content, post_edit_content)`` pair, or ``(None, None)`` when
|
|
@@ -741,14 +744,22 @@ def prior_and_post_edit_content(
|
|
|
741
744
|
return None, None
|
|
742
745
|
if old_string not in existing_content:
|
|
743
746
|
return None, None
|
|
744
|
-
|
|
747
|
+
one_edit = {
|
|
748
|
+
"old_string": old_string,
|
|
749
|
+
"new_string": new_string,
|
|
750
|
+
"replace_all": is_replace_all,
|
|
751
|
+
}
|
|
752
|
+
return existing_content, apply_edits(existing_content, [one_edit])
|
|
745
753
|
|
|
746
754
|
|
|
747
|
-
def _is_validated_target(file_path: str) -> bool:
|
|
755
|
+
def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
|
|
748
756
|
"""Return whether the path is subject to code-rules validation.
|
|
749
757
|
|
|
750
758
|
Args:
|
|
751
759
|
file_path: The destination path of the write, edit, or pre-check target.
|
|
760
|
+
repository_root: The session's working directory (the PreToolUse
|
|
761
|
+
payload's ``cwd``), so a path inside it is never exempted as
|
|
762
|
+
scratch merely because the repository sits under ``/tmp``.
|
|
752
763
|
|
|
753
764
|
Returns:
|
|
754
765
|
True when the path is non-empty, outside hook infrastructure, and
|
|
@@ -756,14 +767,14 @@ def _is_validated_target(file_path: str) -> bool:
|
|
|
756
767
|
"""
|
|
757
768
|
if not file_path:
|
|
758
769
|
return False
|
|
759
|
-
if is_ephemeral_script_path(file_path):
|
|
770
|
+
if is_ephemeral_script_path(file_path, repository_root):
|
|
760
771
|
return False
|
|
761
772
|
if is_hook_infrastructure(file_path):
|
|
762
773
|
return False
|
|
763
774
|
return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
|
|
764
775
|
|
|
765
776
|
|
|
766
|
-
def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
777
|
+
def _is_hook_infrastructure_python_target(file_path: str, repository_root: str = "") -> bool:
|
|
767
778
|
"""Return whether the path is a hook-infrastructure Python file.
|
|
768
779
|
|
|
769
780
|
The full code-rules suite exempts hook-infrastructure files, but the
|
|
@@ -773,13 +784,16 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
|
773
784
|
|
|
774
785
|
Args:
|
|
775
786
|
file_path: The destination path of the write, edit, or pre-check target.
|
|
787
|
+
repository_root: The session's working directory (the PreToolUse
|
|
788
|
+
payload's ``cwd``), so a path inside it is never exempted as
|
|
789
|
+
scratch merely because the repository sits under ``/tmp``.
|
|
776
790
|
|
|
777
791
|
Returns:
|
|
778
792
|
True when the path names a Python file inside hook infrastructure.
|
|
779
793
|
"""
|
|
780
794
|
if not file_path:
|
|
781
795
|
return False
|
|
782
|
-
if is_ephemeral_script_path(file_path):
|
|
796
|
+
if is_ephemeral_script_path(file_path, repository_root):
|
|
783
797
|
return False
|
|
784
798
|
if not is_hook_infrastructure(file_path):
|
|
785
799
|
return False
|
|
@@ -1170,6 +1184,7 @@ def _contents_for_validation(
|
|
|
1170
1184
|
if tool_name == "Edit":
|
|
1171
1185
|
prior_content, full_file_content_after_edit = prior_and_post_edit_content(
|
|
1172
1186
|
file_path, old_string, new_string,
|
|
1187
|
+
is_replace_all=all_tool_input.get("replace_all") is True,
|
|
1173
1188
|
)
|
|
1174
1189
|
if full_file_content_after_edit is None:
|
|
1175
1190
|
full_file_content_after_edit = existing_file_content(file_path)
|
|
@@ -1372,8 +1387,11 @@ def main(all_arguments: list[str]) -> None:
|
|
|
1372
1387
|
if is_under_session_scratchpad(file_path, pretooluse_payload):
|
|
1373
1388
|
sys.exit(0)
|
|
1374
1389
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1390
|
+
repository_root = str(pretooluse_payload.get("cwd") or "")
|
|
1391
|
+
runs_full_verdict = _is_validated_target(file_path, repository_root)
|
|
1392
|
+
if not runs_full_verdict and not _is_hook_infrastructure_python_target(
|
|
1393
|
+
file_path, repository_root
|
|
1394
|
+
):
|
|
1377
1395
|
sys.exit(0)
|
|
1378
1396
|
|
|
1379
1397
|
validation_contents = _contents_for_validation(
|
|
@@ -14,6 +14,45 @@ from pathlib import Path
|
|
|
14
14
|
_ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def repository_root() -> Path:
|
|
18
|
+
"""Return the root of the checkout these tests run inside.
|
|
19
|
+
|
|
20
|
+
::
|
|
21
|
+
|
|
22
|
+
<checkout>/packages/claude-dev-env/hooks/blocking/test_x.py -> <checkout>
|
|
23
|
+
|
|
24
|
+
Found by walking up for a ``.git`` entry rather than by counting
|
|
25
|
+
directories, so moving a test file does not silently anchor it somewhere
|
|
26
|
+
else. Falls back to the four-levels-up ancestor when no marker is found,
|
|
27
|
+
which is where the checkout root sits for this directory.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The checkout root directory.
|
|
31
|
+
"""
|
|
32
|
+
all_ancestors = Path(__file__).resolve().parents
|
|
33
|
+
for each_ancestor in all_ancestors:
|
|
34
|
+
if (each_ancestor / ".git").exists():
|
|
35
|
+
return each_ancestor
|
|
36
|
+
return all_ancestors[3]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def repository_file_path(relative_path: str) -> str:
|
|
40
|
+
"""Return the absolute path of a repository-relative file in this checkout.
|
|
41
|
+
|
|
42
|
+
A hook resolves a bare relative path against the process working
|
|
43
|
+
directory, so a payload carrying one answers to wherever pytest was
|
|
44
|
+
invoked from. An absolute path under the checkout root keeps the
|
|
45
|
+
decision stable however the suite is launched.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
relative_path: The target file's path relative to the checkout root.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
That file's absolute path inside this checkout.
|
|
52
|
+
"""
|
|
53
|
+
return str(repository_root() / relative_path)
|
|
54
|
+
|
|
55
|
+
|
|
17
56
|
def build_write_payload(file_path: str, content: str) -> str:
|
|
18
57
|
"""Build the JSON payload sent by a Write tool call."""
|
|
19
58
|
return json.dumps(
|
|
@@ -251,10 +251,50 @@ def _extract_fstring_literal_parts(
|
|
|
251
251
|
return "".join(display_segments), "".join(shape_segments)
|
|
252
252
|
|
|
253
253
|
|
|
254
|
-
def
|
|
254
|
+
def _normalized_absolute_comparison_path(raw_path: str) -> str:
|
|
255
|
+
"""Return raw_path as an absolute, lowercase, forward-slash path for root comparison.
|
|
256
|
+
|
|
257
|
+
Strips a leading Windows drive letter after normalizing, so a POSIX root
|
|
258
|
+
check and a Windows root check compare on the same shape.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
raw_path: The path to normalize.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The absolute, lowercase, forward-slash path with any leading drive
|
|
265
|
+
letter removed.
|
|
266
|
+
"""
|
|
267
|
+
return LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(raw_path).replace("\\", "/").lower())
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _is_at_or_under_root(normalized_candidate: str, normalized_root: str) -> bool:
|
|
271
|
+
"""Return whether normalized_candidate sits at or under normalized_root.
|
|
272
|
+
|
|
273
|
+
Requires a path-segment boundary (an exact match or a ``/`` before the
|
|
274
|
+
remainder), so ``/tmp/repo-sibling`` never counts as under ``/tmp/repo``.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
normalized_candidate: A path already run through
|
|
278
|
+
``_normalized_absolute_comparison_path``.
|
|
279
|
+
normalized_root: A root path already run through
|
|
280
|
+
``_normalized_absolute_comparison_path``.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True when normalized_candidate equals normalized_root or names a
|
|
284
|
+
descendant of it.
|
|
285
|
+
"""
|
|
286
|
+
return normalized_candidate == normalized_root or normalized_candidate.startswith(normalized_root + "/")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def is_ephemeral_script_path(file_path: str, repository_root: str = "") -> bool:
|
|
255
290
|
"""Return True when the path is rooted at a throwaway scratch directory.
|
|
256
291
|
|
|
257
292
|
Checks these sources in order:
|
|
293
|
+
- ``repository_root`` — when given, a path inside it is never ephemeral,
|
|
294
|
+
whatever directory the repository itself is checked out under. This
|
|
295
|
+
keeps a repository placed under ``/tmp`` fully gated: the exemption
|
|
296
|
+
below is for scratch outside every repository, not for the repository's
|
|
297
|
+
own files.
|
|
258
298
|
- ``$CLAUDE_JOB_DIR/tmp`` — only when ``CLAUDE_JOB_DIR`` is set.
|
|
259
299
|
- Root-anchored ``/tmp`` and ``/temp`` (drive-letter tolerant).
|
|
260
300
|
|
|
@@ -262,20 +302,28 @@ def is_ephemeral_script_path(file_path: str) -> bool:
|
|
|
262
302
|
its sandbox fixtures there, so matching it would exempt the suite's own
|
|
263
303
|
targets. Returns False when ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT``
|
|
264
304
|
is truthy, when ``file_path`` is empty, and when no root matches. Path
|
|
265
|
-
classification is string-only;
|
|
305
|
+
classification is string-only; neither path need exist.
|
|
266
306
|
|
|
267
307
|
Args:
|
|
268
308
|
file_path: The candidate path to classify.
|
|
309
|
+
repository_root: The session's working directory — typically the
|
|
310
|
+
PreToolUse payload's ``cwd`` — or empty when no such directory is
|
|
311
|
+
known. A path outside this directory is unaffected by it.
|
|
269
312
|
|
|
270
313
|
Returns:
|
|
271
|
-
True when the path is rooted at a recognized ephemeral scratch
|
|
314
|
+
True when the path is rooted at a recognized ephemeral scratch
|
|
315
|
+
directory and does not sit inside ``repository_root``.
|
|
272
316
|
"""
|
|
273
317
|
if not file_path:
|
|
274
318
|
return False
|
|
275
319
|
disable_value = os.environ.get(EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "").strip().lower()
|
|
276
320
|
if disable_value in ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES:
|
|
277
321
|
return False
|
|
278
|
-
normalized =
|
|
322
|
+
normalized = _normalized_absolute_comparison_path(file_path)
|
|
323
|
+
if repository_root and _is_at_or_under_root(
|
|
324
|
+
normalized, _normalized_absolute_comparison_path(repository_root)
|
|
325
|
+
):
|
|
326
|
+
return False
|
|
279
327
|
all_temp_roots: list[str] = []
|
|
280
328
|
job_dir = os.environ.get(CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME)
|
|
281
329
|
if job_dir:
|
|
@@ -286,7 +334,7 @@ def is_ephemeral_script_path(file_path: str) -> bool:
|
|
|
286
334
|
for each_root in ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES:
|
|
287
335
|
all_temp_roots.append(each_root)
|
|
288
336
|
for each_temp_root in all_temp_roots:
|
|
289
|
-
if normalized
|
|
337
|
+
if _is_at_or_under_root(normalized, each_temp_root):
|
|
290
338
|
return True
|
|
291
339
|
return False
|
|
292
340
|
|
|
@@ -426,23 +474,25 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
|
426
474
|
Combines the path families a repo gate skips: the root-anchored ephemeral
|
|
427
475
|
scratch directories (``/tmp`` and ``$CLAUDE_JOB_DIR/tmp``), the harness
|
|
428
476
|
session scratchpad, and a coding agent's own home-directory tooling such as
|
|
429
|
-
``~/.grok/runs/``.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
477
|
+
``~/.grok/runs/``. A path inside the payload's own working directory
|
|
478
|
+
(``cwd``) is never treated as scratch, so a repository checked out under
|
|
479
|
+
``/tmp`` still receives full enforcement. The scratchpad match reads the
|
|
480
|
+
session id from the payload when supplied, else the harness environment
|
|
481
|
+
variable. The code-rules and TDD gates call the underlying predicates
|
|
482
|
+
``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
|
|
435
483
|
``is_under_session_scratchpad`` directly.
|
|
436
484
|
|
|
437
485
|
Args:
|
|
438
486
|
file_path: The candidate path to classify.
|
|
439
|
-
hook_payload: The PreToolUse payload carrying the session id
|
|
440
|
-
read the session id from
|
|
487
|
+
hook_payload: The PreToolUse payload carrying the session id and the
|
|
488
|
+
working directory (``cwd``), or None to read the session id from
|
|
489
|
+
the environment alone.
|
|
441
490
|
|
|
442
491
|
Returns:
|
|
443
492
|
True when the path is scratch, agent tooling, or the session scratchpad.
|
|
444
493
|
"""
|
|
445
|
-
|
|
494
|
+
repository_root = str((hook_payload or {}).get("cwd") or "")
|
|
495
|
+
if is_ephemeral_script_path(file_path, repository_root):
|
|
446
496
|
return True
|
|
447
497
|
if is_agent_home_tooling(file_path):
|
|
448
498
|
return True
|
|
@@ -26,7 +26,6 @@ from __future__ import annotations
|
|
|
26
26
|
import json
|
|
27
27
|
import shlex
|
|
28
28
|
import sys
|
|
29
|
-
import tempfile
|
|
30
29
|
from pathlib import Path
|
|
31
30
|
|
|
32
31
|
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
@@ -41,6 +40,9 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E4
|
|
|
41
40
|
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
42
41
|
)
|
|
43
42
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
43
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
44
|
+
read_hook_input_dictionary_from_stdin,
|
|
45
|
+
)
|
|
44
46
|
from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
|
|
45
47
|
ALL_GH_EXECUTABLE_BASENAMES,
|
|
46
48
|
COMMAND_FIELD_NAME,
|
|
@@ -49,15 +51,14 @@ from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
|
|
|
49
51
|
GH_PR_CREATE_MINIMUM_TOKEN_COUNT,
|
|
50
52
|
PR_SUBCOMMAND_TOKEN,
|
|
51
53
|
SPAWN_BYPASS_MARKER,
|
|
52
|
-
SPAWN_MARKER_FILE_PREFIX,
|
|
53
|
-
SPAWN_MARKER_FILE_SUFFIX,
|
|
54
54
|
TOOL_INPUT_FIELD_NAME,
|
|
55
55
|
TOOL_NAME_FIELD_NAME,
|
|
56
|
+
spawn_marker_path,
|
|
56
57
|
)
|
|
57
58
|
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
58
59
|
ALL_COMMAND_SEPARATOR_TOKENS,
|
|
59
|
-
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
60
60
|
)
|
|
61
|
+
from sensitive_file_protector import build_deny_response # noqa: E402
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
def _all_command_tokens(shell_command: str) -> list[str] | None:
|
|
@@ -149,7 +150,9 @@ def _all_pull_request_creation_segment_indexes(all_segments: list[list[str]]) ->
|
|
|
149
150
|
|
|
150
151
|
|
|
151
152
|
def _only_final_create_carries_bypass(
|
|
152
|
-
all_tokens: list[str],
|
|
153
|
+
all_tokens: list[str],
|
|
154
|
+
all_segments: list[list[str]],
|
|
155
|
+
all_creation_segment_indexes: list[int],
|
|
153
156
|
) -> bool:
|
|
154
157
|
"""Report whether one final gh pr create carries the final bypass comment.
|
|
155
158
|
|
|
@@ -159,6 +162,7 @@ def _only_final_create_carries_bypass(
|
|
|
159
162
|
|
|
160
163
|
Args:
|
|
161
164
|
all_tokens: The tokens of the whole command.
|
|
165
|
+
all_segments: The command's token segments, already split by the caller.
|
|
162
166
|
all_creation_segment_indexes: Indexes of segments that create pull requests.
|
|
163
167
|
|
|
164
168
|
Returns:
|
|
@@ -166,7 +170,7 @@ def _only_final_create_carries_bypass(
|
|
|
166
170
|
"""
|
|
167
171
|
if not _carries_trailing_bypass_marker(all_tokens):
|
|
168
172
|
return False
|
|
169
|
-
final_segment_index = len(
|
|
173
|
+
final_segment_index = len(all_segments) - 1
|
|
170
174
|
return all_creation_segment_indexes == [final_segment_index]
|
|
171
175
|
|
|
172
176
|
|
|
@@ -182,11 +186,8 @@ def _spawn_is_recorded(session_id: str) -> bool:
|
|
|
182
186
|
Returns:
|
|
183
187
|
True when this session's marker file is present.
|
|
184
188
|
"""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return False
|
|
188
|
-
file_name = f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
|
|
189
|
-
return (Path(tempfile.gettempdir()) / file_name).exists()
|
|
189
|
+
marker_file = spawn_marker_path(session_id)
|
|
190
|
+
return marker_file is not None and marker_file.exists()
|
|
190
191
|
|
|
191
192
|
|
|
192
193
|
def _shell_command_or_none(all_hook_payload: dict[str, object]) -> str | None:
|
|
@@ -211,19 +212,12 @@ def _shell_command_or_none(all_hook_payload: dict[str, object]) -> str | None:
|
|
|
211
212
|
|
|
212
213
|
def _emit_denial() -> None:
|
|
213
214
|
"""Write the PreToolUse deny payload to stdout and log the block."""
|
|
214
|
-
deny_payload = {
|
|
215
|
-
"hookSpecificOutput": {
|
|
216
|
-
"hookEventName": "PreToolUse",
|
|
217
|
-
"permissionDecision": "deny",
|
|
218
|
-
"permissionDecisionReason": CORRECTIVE_MESSAGE,
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
215
|
log_hook_block(
|
|
222
216
|
calling_hook_name="pr_description_writer_gate.py",
|
|
223
217
|
hook_event="PreToolUse",
|
|
224
218
|
block_reason=CORRECTIVE_MESSAGE,
|
|
225
219
|
)
|
|
226
|
-
sys.stdout.write(json.dumps(
|
|
220
|
+
sys.stdout.write(json.dumps(build_deny_response(CORRECTIVE_MESSAGE)) + "\n")
|
|
227
221
|
sys.stdout.flush()
|
|
228
222
|
|
|
229
223
|
|
|
@@ -235,11 +229,8 @@ def main() -> None:
|
|
|
235
229
|
tokenize, a command opening no pull request, a trailing bypass marker, and
|
|
236
230
|
a recorded spawn each pass through untouched.
|
|
237
231
|
"""
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
except json.JSONDecodeError:
|
|
241
|
-
sys.exit(0)
|
|
242
|
-
if not isinstance(hook_payload, dict):
|
|
232
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
233
|
+
if hook_payload is None:
|
|
243
234
|
sys.exit(0)
|
|
244
235
|
shell_command = _shell_command_or_none(hook_payload)
|
|
245
236
|
if shell_command is None:
|
|
@@ -251,7 +242,9 @@ def main() -> None:
|
|
|
251
242
|
all_creation_segment_indexes = _all_pull_request_creation_segment_indexes(all_segments)
|
|
252
243
|
if not all_creation_segment_indexes:
|
|
253
244
|
sys.exit(0)
|
|
254
|
-
if _only_final_create_carries_bypass(
|
|
245
|
+
if _only_final_create_carries_bypass(
|
|
246
|
+
all_tokens, all_segments, all_creation_segment_indexes
|
|
247
|
+
):
|
|
255
248
|
sys.exit(0)
|
|
256
249
|
if _spawn_is_recorded(str(hook_payload.get("session_id") or "")):
|
|
257
250
|
sys.exit(0)
|
|
@@ -23,6 +23,12 @@ ignored because bash performs no substitution inside them (for example
|
|
|
23
23
|
are treated as literal only when the count of preceding backslashes is odd;
|
|
24
24
|
an even count (including zero) means the backtick is a live substitution.
|
|
25
25
|
|
|
26
|
+
A quoted heredoc body is stripped for the same reason. `<<'EOF'`, `<<"EOF"`
|
|
27
|
+
and `<<\\EOF` each tell bash to expand nothing between the opener and the
|
|
28
|
+
terminator, so a backtick there is text the file receives rather than a
|
|
29
|
+
command the shell runs. A bare `<<EOF` expands its body and keeps its scan,
|
|
30
|
+
and text outside any heredoc is scanned either way.
|
|
31
|
+
|
|
26
32
|
Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
|
|
27
33
|
spawn a subshell and does not defeat the allowlist matcher. Telling it apart
|
|
28
34
|
from a disguised command substitution takes more than counting two open
|
|
@@ -41,11 +47,6 @@ another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
|
|
|
41
47
|
the closer that brings depth to zero is a lone ``)``, bash has fallen back to
|
|
42
48
|
running a parenthesized command inside the substitution, so it is blocked.
|
|
43
49
|
This hook applies that walk, tracking paren depth from the opening ``$((``.
|
|
44
|
-
|
|
45
|
-
False positives are still possible when the user is intentionally writing
|
|
46
|
-
shell-script content (for example, authoring a .sh file via heredoc) and the
|
|
47
|
-
substitution is literal payload rather than something to execute. In that
|
|
48
|
-
case, author the file with the Write tool, not a Bash heredoc.
|
|
49
50
|
"""
|
|
50
51
|
|
|
51
52
|
from __future__ import annotations
|
|
@@ -53,6 +54,7 @@ from __future__ import annotations
|
|
|
53
54
|
import json
|
|
54
55
|
import sys
|
|
55
56
|
from pathlib import Path
|
|
57
|
+
from re import Match
|
|
56
58
|
|
|
57
59
|
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
58
60
|
if _hooks_dir not in sys.path:
|
|
@@ -74,6 +76,11 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
|
74
76
|
PERMISSION_DECISION_KEY,
|
|
75
77
|
PERMISSION_DECISION_REASON_KEY,
|
|
76
78
|
PROCESS_SUBSTITUTION_PATTERN,
|
|
79
|
+
BACKSLASH_CHARACTER,
|
|
80
|
+
COMMAND_LINE_SEPARATOR,
|
|
81
|
+
DOUBLE_QUOTE_CHARACTER,
|
|
82
|
+
NO_TERMINATOR_INDEX,
|
|
83
|
+
SINGLE_QUOTE_CHARACTER,
|
|
77
84
|
SINGLE_QUOTED_RUN_PATTERN,
|
|
78
85
|
STRIPPED_RUN_REPLACEMENT,
|
|
79
86
|
TOOL_INPUT_KEY,
|
|
@@ -81,10 +88,169 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
|
81
88
|
)
|
|
82
89
|
|
|
83
90
|
|
|
91
|
+
from hooks_constants.piped_pytest_blocker_constants import ( # noqa: E402
|
|
92
|
+
HEREDOC_ESCAPE_GROUP,
|
|
93
|
+
HEREDOC_OPENER_PATTERN,
|
|
94
|
+
HEREDOC_QUOTE_GROUP,
|
|
95
|
+
HEREDOC_TAB_STRIP_GROUP,
|
|
96
|
+
HEREDOC_TAB_STRIP_MARKER,
|
|
97
|
+
HEREDOC_TERMINATOR_GROUP,
|
|
98
|
+
HEREDOC_UNQUOTED_MARKER,
|
|
99
|
+
)
|
|
100
|
+
from hooks_constants.shell_command_pipeline import ( # noqa: E402
|
|
101
|
+
PendingHeredoc,
|
|
102
|
+
closes_the_heredoc,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
84
106
|
def _strip_single_quoted_runs(command: str) -> str:
|
|
85
107
|
return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
|
|
86
108
|
|
|
87
109
|
|
|
110
|
+
def _opener_suppresses_expansion(opener_match: Match[str]) -> bool:
|
|
111
|
+
"""Return whether an opener's delimiter is quoted or backslash-escaped.
|
|
112
|
+
|
|
113
|
+
::
|
|
114
|
+
|
|
115
|
+
<<'EOF' -> True quoted, the body is literal
|
|
116
|
+
<<EOF -> False bare, bash expands the body
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
opener_match: One HEREDOC_OPENER_PATTERN match from a command line.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True when bash expands nothing down to this opener's terminator.
|
|
123
|
+
"""
|
|
124
|
+
is_quoted = opener_match.group(HEREDOC_QUOTE_GROUP) != HEREDOC_UNQUOTED_MARKER
|
|
125
|
+
is_escaped = opener_match.group(HEREDOC_ESCAPE_GROUP) != HEREDOC_UNQUOTED_MARKER
|
|
126
|
+
return is_quoted or is_escaped
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sits_outside_quotes(command_line: str, character_index: int) -> bool:
|
|
130
|
+
"""Return whether a position on a line sits outside every quoted run.
|
|
131
|
+
|
|
132
|
+
::
|
|
133
|
+
|
|
134
|
+
cat <<'EOF' index of << -> True bash reads an opener
|
|
135
|
+
echo "<<'EOF'" index of << -> False bash reads a string
|
|
136
|
+
|
|
137
|
+
Counting unescaped quotes before the position tells the two apart: an even
|
|
138
|
+
count of each quote character means every run opened before it also closed.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
command_line: One line of the command.
|
|
142
|
+
character_index: Position of the candidate opener on that line.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True when bash reads this position as shell syntax.
|
|
146
|
+
"""
|
|
147
|
+
single_quote_count = 0
|
|
148
|
+
double_quote_count = 0
|
|
149
|
+
is_escaped = False
|
|
150
|
+
for each_character in command_line[:character_index]:
|
|
151
|
+
if is_escaped:
|
|
152
|
+
is_escaped = False
|
|
153
|
+
continue
|
|
154
|
+
if each_character == BACKSLASH_CHARACTER:
|
|
155
|
+
is_escaped = True
|
|
156
|
+
continue
|
|
157
|
+
if each_character == SINGLE_QUOTE_CHARACTER:
|
|
158
|
+
single_quote_count += 1
|
|
159
|
+
elif each_character == DOUBLE_QUOTE_CHARACTER:
|
|
160
|
+
double_quote_count += 1
|
|
161
|
+
return single_quote_count % 2 == 0 and double_quote_count % 2 == 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _first_literal_opener(command_line: str) -> Match[str] | None:
|
|
165
|
+
"""Return the line's first heredoc opener whose body bash leaves literal.
|
|
166
|
+
|
|
167
|
+
An opener spelled inside a quoted string is text bash passes on, so it
|
|
168
|
+
opens nothing and the lines below it stay in the scan.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
command_line: One line of the command.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The matching opener, or None when the line opens no literal heredoc.
|
|
175
|
+
"""
|
|
176
|
+
for each_opener in HEREDOC_OPENER_PATTERN.finditer(command_line):
|
|
177
|
+
if not _sits_outside_quotes(command_line, each_opener.start()):
|
|
178
|
+
continue
|
|
179
|
+
if _opener_suppresses_expansion(each_opener):
|
|
180
|
+
return each_opener
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _index_past_literal_body(
|
|
185
|
+
all_lines: list[str], body_start_index: int, opener_match: Match[str]
|
|
186
|
+
) -> int:
|
|
187
|
+
"""Return the index of the line that closes a literal heredoc body.
|
|
188
|
+
|
|
189
|
+
The terminator match, including the ``<<-`` tab-strip rule, comes from
|
|
190
|
+
``closes_the_heredoc`` so this hook and the shared pipeline agree on what
|
|
191
|
+
ends a body.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
all_lines: Every line of the command.
|
|
195
|
+
body_start_index: Index of the body's first line.
|
|
196
|
+
opener_match: The opener whose terminator closes this body.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The index of the terminator line, or NO_TERMINATOR_INDEX when the
|
|
200
|
+
command holds none.
|
|
201
|
+
"""
|
|
202
|
+
pending_heredoc = PendingHeredoc(
|
|
203
|
+
terminator=opener_match.group(HEREDOC_TERMINATOR_GROUP),
|
|
204
|
+
allows_leading_tabs=(
|
|
205
|
+
opener_match.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
for each_index in range(body_start_index, len(all_lines)):
|
|
209
|
+
if closes_the_heredoc(all_lines[each_index], pending_heredoc):
|
|
210
|
+
return each_index
|
|
211
|
+
return NO_TERMINATOR_INDEX
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _strip_quoted_heredoc_bodies(command: str) -> str:
|
|
215
|
+
"""Return the command holding its openers, terminators, and shell text.
|
|
216
|
+
|
|
217
|
+
::
|
|
218
|
+
|
|
219
|
+
cat <<'EOF' -> cat <<'EOF'
|
|
220
|
+
inert text EOF
|
|
221
|
+
EOF
|
|
222
|
+
|
|
223
|
+
A quoted or escaped delimiter makes the lines below it text the shell hands
|
|
224
|
+
on, so a scan of those lines reports a substitution bash never runs. A bare
|
|
225
|
+
delimiter keeps its body, and text outside any heredoc keeps its scan.
|
|
226
|
+
|
|
227
|
+
An opener with no terminator returns the command whole. Bash consumes the
|
|
228
|
+
rest of the input in that case, and dropping those lines would hide a live
|
|
229
|
+
substitution among them.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
command: The raw Bash command string from the tool input.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The command with each literal heredoc body dropped.
|
|
236
|
+
"""
|
|
237
|
+
all_lines = command.split(COMMAND_LINE_SEPARATOR)
|
|
238
|
+
all_kept_lines: list[str] = []
|
|
239
|
+
each_index = 0
|
|
240
|
+
while each_index < len(all_lines):
|
|
241
|
+
current_line = all_lines[each_index]
|
|
242
|
+
all_kept_lines.append(current_line)
|
|
243
|
+
each_index += 1
|
|
244
|
+
opener_match = _first_literal_opener(current_line)
|
|
245
|
+
if opener_match is None:
|
|
246
|
+
continue
|
|
247
|
+
terminator_index = _index_past_literal_body(all_lines, each_index, opener_match)
|
|
248
|
+
if terminator_index == NO_TERMINATOR_INDEX:
|
|
249
|
+
return command
|
|
250
|
+
each_index = terminator_index
|
|
251
|
+
return COMMAND_LINE_SEPARATOR.join(all_kept_lines)
|
|
252
|
+
|
|
253
|
+
|
|
88
254
|
def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
|
|
89
255
|
"""Return True when a ``$((`` expansion closes on an ``))`` pair.
|
|
90
256
|
|
|
@@ -132,8 +298,9 @@ def has_shell_substitution(command: str) -> bool:
|
|
|
132
298
|
echo '$(not-executed)' ok: single-quoted, inert
|
|
133
299
|
echo $((2 + 2)) ok: arithmetic expansion
|
|
134
300
|
|
|
135
|
-
|
|
136
|
-
|
|
301
|
+
Literal heredoc bodies are dropped first, then single-quoted runs are
|
|
302
|
+
stripped, because bash substitutes nothing inside either. A backtick
|
|
303
|
+
preceded by an odd backslash count is escaped.
|
|
137
304
|
A `$((` pair is walked to its terminator (module docstring) to tell
|
|
138
305
|
real arithmetic from a disguised subshell.
|
|
139
306
|
|
|
@@ -143,7 +310,7 @@ def has_shell_substitution(command: str) -> bool:
|
|
|
143
310
|
Returns:
|
|
144
311
|
True when a substitution the matcher cannot descend into is present.
|
|
145
312
|
"""
|
|
146
|
-
scannable_command = _strip_single_quoted_runs(command)
|
|
313
|
+
scannable_command = _strip_single_quoted_runs(_strip_quoted_heredoc_bodies(command))
|
|
147
314
|
if _has_unsafe_dollar_paren_expansion(scannable_command):
|
|
148
315
|
return True
|
|
149
316
|
if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
|