claude-dev-env 1.89.0 → 1.90.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/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
- package/hooks/lifecycle/CLAUDE.md +3 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
|
@@ -15,6 +15,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
15
15
|
ALL_CODE_FILE_EXTENSIONS,
|
|
16
16
|
ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
|
|
17
17
|
ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
|
|
18
|
+
ALL_PYTEST_CONFIG_FILE_SECTIONS,
|
|
18
19
|
ALL_PYTEST_MODULE_INVOCATION,
|
|
19
20
|
ALL_TEST_FILENAME_GLOB_SUFFIXES,
|
|
20
21
|
ALL_TEST_FILENAME_SUFFIXES,
|
|
@@ -38,6 +39,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
38
39
|
PYTHON_FILE_EXTENSION,
|
|
39
40
|
STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
40
41
|
STAGED_TEST_FAILURE_HEADER,
|
|
42
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE,
|
|
41
43
|
TEST_CONFTEST_FILENAME,
|
|
42
44
|
TEST_FILENAME_PREFIX,
|
|
43
45
|
TESTS_PATH_SEGMENT,
|
|
@@ -1559,39 +1561,129 @@ def _staged_test_file_paths(repository_root: Path) -> list[Path]:
|
|
|
1559
1561
|
return all_test_paths
|
|
1560
1562
|
|
|
1561
1563
|
|
|
1562
|
-
def
|
|
1563
|
-
"""
|
|
1564
|
+
def _directory_holds_pytest_config(directory: Path) -> bool:
|
|
1565
|
+
"""Return True when *directory* holds a recognized pytest configuration file."""
|
|
1566
|
+
for each_filename, each_required_section in ALL_PYTEST_CONFIG_FILE_SECTIONS:
|
|
1567
|
+
config_path = directory / each_filename
|
|
1568
|
+
if not config_path.is_file():
|
|
1569
|
+
continue
|
|
1570
|
+
if each_required_section is None:
|
|
1571
|
+
return True
|
|
1572
|
+
try:
|
|
1573
|
+
config_text = config_path.read_text(encoding="utf-8", errors="replace")
|
|
1574
|
+
except OSError:
|
|
1575
|
+
continue
|
|
1576
|
+
if each_required_section in config_text:
|
|
1577
|
+
return True
|
|
1578
|
+
return False
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
|
|
1582
|
+
"""Return the nearest ancestor of *test_file_path* that owns a pytest config.
|
|
1583
|
+
|
|
1584
|
+
Walks up from the test file to the first directory holding a pytest config,
|
|
1585
|
+
stopping at *repository_root*, which is the fallback when none is found.
|
|
1564
1586
|
|
|
1565
1587
|
Args:
|
|
1566
|
-
|
|
1588
|
+
test_file_path: The staged test file to resolve a root for.
|
|
1589
|
+
repository_root: The repository root that bounds the upward walk.
|
|
1567
1590
|
|
|
1568
1591
|
Returns:
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1592
|
+
The resolved owning test root directory.
|
|
1593
|
+
"""
|
|
1594
|
+
resolved_repository_root = repository_root.resolve()
|
|
1595
|
+
for each_ancestor in test_file_path.resolve().parents:
|
|
1596
|
+
if _directory_holds_pytest_config(each_ancestor):
|
|
1597
|
+
return each_ancestor
|
|
1598
|
+
if each_ancestor == resolved_repository_root:
|
|
1599
|
+
return resolved_repository_root
|
|
1600
|
+
return resolved_repository_root
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _group_staged_tests_by_root(
|
|
1604
|
+
all_test_paths: list[Path],
|
|
1605
|
+
repository_root: Path,
|
|
1606
|
+
) -> dict[Path, list[Path]]:
|
|
1607
|
+
"""Group *all_test_paths* by their owning pytest-config root.
|
|
1608
|
+
|
|
1609
|
+
Args:
|
|
1610
|
+
all_test_paths: The staged test files to partition.
|
|
1611
|
+
repository_root: The repository root that bounds each upward walk.
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
A mapping from owning test root to the staged test files under it.
|
|
1615
|
+
"""
|
|
1616
|
+
tests_by_root: dict[Path, list[Path]] = {}
|
|
1617
|
+
for each_test_path in all_test_paths:
|
|
1618
|
+
owning_root = _resolve_owning_test_root(each_test_path, repository_root)
|
|
1619
|
+
tests_by_root.setdefault(owning_root, []).append(each_test_path)
|
|
1620
|
+
return tests_by_root
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def _run_pytest_for_group(group_root: Path, all_group_test_paths: list[Path]) -> int:
|
|
1624
|
+
"""Run pytest over one group's test files with the working directory at its root.
|
|
1625
|
+
|
|
1626
|
+
Args:
|
|
1627
|
+
group_root: The owning test root used as the pytest working directory.
|
|
1628
|
+
all_group_test_paths: The staged test files that share *group_root*.
|
|
1629
|
+
|
|
1630
|
+
Returns:
|
|
1631
|
+
0 when the group's tests pass or collect nothing; pytest's non-zero
|
|
1632
|
+
exit code otherwise.
|
|
1573
1633
|
"""
|
|
1574
|
-
all_test_paths = _staged_test_file_paths(repository_root)
|
|
1575
|
-
if not all_test_paths:
|
|
1576
|
-
return 0
|
|
1577
1634
|
pytest_process = subprocess.run(
|
|
1578
1635
|
[
|
|
1579
1636
|
sys.executable,
|
|
1580
1637
|
*ALL_PYTEST_MODULE_INVOCATION,
|
|
1581
|
-
*[str(each_path) for each_path in
|
|
1638
|
+
*[str(each_path) for each_path in all_group_test_paths],
|
|
1582
1639
|
],
|
|
1583
|
-
cwd=str(
|
|
1640
|
+
cwd=str(group_root),
|
|
1584
1641
|
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
1585
1642
|
check=False,
|
|
1586
1643
|
env=repository_environment(),
|
|
1587
1644
|
)
|
|
1588
1645
|
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
1589
1646
|
return 0
|
|
1590
|
-
if pytest_process.returncode != 0:
|
|
1591
|
-
print(STAGED_TEST_FAILURE_HEADER, file=sys.stderr)
|
|
1592
1647
|
return pytest_process.returncode
|
|
1593
1648
|
|
|
1594
1649
|
|
|
1650
|
+
def run_staged_test_files(repository_root: Path) -> int:
|
|
1651
|
+
"""Run pytest over the staged test files and return the gate exit code.
|
|
1652
|
+
|
|
1653
|
+
Groups the staged test files by their owning pytest-config root and runs one
|
|
1654
|
+
pytest session per group with the working directory at that root, so two
|
|
1655
|
+
packages that expose a same-named top-level package never share one session
|
|
1656
|
+
and shadow each other's imports.
|
|
1657
|
+
|
|
1658
|
+
Args:
|
|
1659
|
+
repository_root: The repository root the staged test files belong to.
|
|
1660
|
+
|
|
1661
|
+
Returns:
|
|
1662
|
+
0 when no test file is staged, when every group collects no tests, or
|
|
1663
|
+
when every group passes. The first failing group's non-zero pytest exit
|
|
1664
|
+
code otherwise, which blocks the commit.
|
|
1665
|
+
"""
|
|
1666
|
+
all_test_paths = _staged_test_file_paths(repository_root)
|
|
1667
|
+
if not all_test_paths:
|
|
1668
|
+
return 0
|
|
1669
|
+
tests_by_root = _group_staged_tests_by_root(all_test_paths, repository_root)
|
|
1670
|
+
first_failing_exit_code = 0
|
|
1671
|
+
for each_group_root in sorted(tests_by_root):
|
|
1672
|
+
group_exit_code = _run_pytest_for_group(
|
|
1673
|
+
each_group_root, tests_by_root[each_group_root]
|
|
1674
|
+
)
|
|
1675
|
+
if group_exit_code != 0:
|
|
1676
|
+
print(
|
|
1677
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root),
|
|
1678
|
+
file=sys.stderr,
|
|
1679
|
+
)
|
|
1680
|
+
if first_failing_exit_code == 0:
|
|
1681
|
+
first_failing_exit_code = group_exit_code
|
|
1682
|
+
if first_failing_exit_code != 0:
|
|
1683
|
+
print(STAGED_TEST_FAILURE_HEADER, file=sys.stderr)
|
|
1684
|
+
return first_failing_exit_code
|
|
1685
|
+
|
|
1686
|
+
|
|
1595
1687
|
def _report_terminology_findings(all_findings: list[str]) -> None:
|
|
1596
1688
|
"""Print the terminology-sweep findings, when any, to standard error.
|
|
1597
1689
|
|
|
@@ -93,3 +93,29 @@ STAGED_PYTEST_TIMEOUT_SECONDS: int = 600
|
|
|
93
93
|
STAGED_TEST_FAILURE_HEADER: str = (
|
|
94
94
|
"code_rules_gate: staged test file(s) failed under pytest; commit blocked."
|
|
95
95
|
)
|
|
96
|
+
|
|
97
|
+
PYTEST_INI_FILENAME: str = "pytest.ini"
|
|
98
|
+
|
|
99
|
+
PYPROJECT_TOML_FILENAME: str = "pyproject.toml"
|
|
100
|
+
|
|
101
|
+
SETUP_CFG_FILENAME: str = "setup.cfg"
|
|
102
|
+
|
|
103
|
+
TOX_INI_FILENAME: str = "tox.ini"
|
|
104
|
+
|
|
105
|
+
PYPROJECT_PYTEST_CONFIG_SECTION: str = "[tool.pytest.ini_options]"
|
|
106
|
+
|
|
107
|
+
SETUP_CFG_PYTEST_CONFIG_SECTION: str = "[tool:pytest]"
|
|
108
|
+
|
|
109
|
+
TOX_INI_PYTEST_CONFIG_SECTION: str = "[pytest]"
|
|
110
|
+
|
|
111
|
+
ALL_PYTEST_CONFIG_FILE_SECTIONS: tuple[tuple[str, str | None], ...] = (
|
|
112
|
+
(PYTEST_INI_FILENAME, None),
|
|
113
|
+
(PYPROJECT_TOML_FILENAME, PYPROJECT_PYTEST_CONFIG_SECTION),
|
|
114
|
+
(SETUP_CFG_FILENAME, SETUP_CFG_PYTEST_CONFIG_SECTION),
|
|
115
|
+
(TOX_INI_FILENAME, TOX_INI_PYTEST_CONFIG_SECTION),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE: str = (
|
|
119
|
+
"code_rules_gate: staged test group rooted at {group_root} "
|
|
120
|
+
"failed under pytest; commit blocked."
|
|
121
|
+
)
|
|
@@ -2418,3 +2418,163 @@ def test_run_staged_test_files_runs_pytest_without_git_environment(
|
|
|
2418
2418
|
monkeypatch.setenv("GIT_AUTHOR_NAME", "Hostile Hook Environment")
|
|
2419
2419
|
|
|
2420
2420
|
assert gate_module.run_staged_test_files(repository_root) == 0
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def _build_shadowing_skill(
|
|
2424
|
+
repository_root: Path,
|
|
2425
|
+
skill_name: str,
|
|
2426
|
+
module_name: str,
|
|
2427
|
+
) -> Path:
|
|
2428
|
+
skill_directory = repository_root / skill_name
|
|
2429
|
+
write_file(skill_directory / "pytest.ini", "[pytest]\n")
|
|
2430
|
+
write_file(skill_directory / "scripts" / "__init__.py", "")
|
|
2431
|
+
write_file(
|
|
2432
|
+
skill_directory / "scripts" / f"{module_name}.py",
|
|
2433
|
+
f"{module_name}_marker = 1\n",
|
|
2434
|
+
)
|
|
2435
|
+
test_relative_path = f"{skill_name}/test_{module_name}.py"
|
|
2436
|
+
write_file(
|
|
2437
|
+
repository_root / test_relative_path,
|
|
2438
|
+
f"from scripts.{module_name} import {module_name}_marker\n"
|
|
2439
|
+
"\n"
|
|
2440
|
+
"\n"
|
|
2441
|
+
f"def test_{module_name}_marker_is_one() -> None:\n"
|
|
2442
|
+
f" assert {module_name}_marker == 1\n",
|
|
2443
|
+
)
|
|
2444
|
+
stage_file(repository_root, test_relative_path)
|
|
2445
|
+
return repository_root / test_relative_path
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
def test_run_staged_test_files_passes_when_same_named_package_would_shadow(
|
|
2449
|
+
temporary_git_repository: Path,
|
|
2450
|
+
) -> None:
|
|
2451
|
+
"""Two skill roots each own a top-level ``scripts`` package. One combined
|
|
2452
|
+
pytest session binds ``scripts`` to whichever imports first and the other
|
|
2453
|
+
skill's ``from scripts.<module>`` then raises ImportError. Per-root sessions
|
|
2454
|
+
keep each ``scripts`` isolated, so the gate passes."""
|
|
2455
|
+
first_test_path = _build_shadowing_skill(
|
|
2456
|
+
temporary_git_repository, "skill_alpha", "widget"
|
|
2457
|
+
)
|
|
2458
|
+
second_test_path = _build_shadowing_skill(
|
|
2459
|
+
temporary_git_repository, "skill_beta", "gadget"
|
|
2460
|
+
)
|
|
2461
|
+
|
|
2462
|
+
combined_session = subprocess.run(
|
|
2463
|
+
[sys.executable, "-m", "pytest", "-q", str(first_test_path), str(second_test_path)],
|
|
2464
|
+
cwd=str(temporary_git_repository),
|
|
2465
|
+
capture_output=True,
|
|
2466
|
+
text=True,
|
|
2467
|
+
encoding="utf-8",
|
|
2468
|
+
errors="replace",
|
|
2469
|
+
check=False,
|
|
2470
|
+
)
|
|
2471
|
+
assert combined_session.returncode != 0, (
|
|
2472
|
+
"one combined pytest session must fail on the same-named scripts package "
|
|
2473
|
+
"shadow; if it does not, the shadow scenario is not being reproduced"
|
|
2474
|
+
)
|
|
2475
|
+
|
|
2476
|
+
assert gate_module.run_staged_test_files(temporary_git_repository) == 0, (
|
|
2477
|
+
"per-root pytest sessions must keep each scripts package isolated so both "
|
|
2478
|
+
"staged suites pass"
|
|
2479
|
+
)
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
def test_resolve_owning_test_root_returns_pyproject_config_directory(
|
|
2483
|
+
tmp_path: Path,
|
|
2484
|
+
) -> None:
|
|
2485
|
+
package_root = tmp_path / "package_root"
|
|
2486
|
+
write_file(
|
|
2487
|
+
package_root / "pyproject.toml",
|
|
2488
|
+
"[tool.pytest.ini_options]\ntestpaths = ['tests']\n",
|
|
2489
|
+
)
|
|
2490
|
+
test_path = package_root / "tests" / "test_behavior.py"
|
|
2491
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2492
|
+
|
|
2493
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2494
|
+
|
|
2495
|
+
assert resolved_root == package_root.resolve()
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
def test_resolve_owning_test_root_returns_pytest_ini_directory(
|
|
2499
|
+
tmp_path: Path,
|
|
2500
|
+
) -> None:
|
|
2501
|
+
package_root = tmp_path / "package_root"
|
|
2502
|
+
write_file(package_root / "pytest.ini", "[pytest]\n")
|
|
2503
|
+
test_path = package_root / "suite" / "test_behavior.py"
|
|
2504
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2505
|
+
|
|
2506
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2507
|
+
|
|
2508
|
+
assert resolved_root == package_root.resolve()
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def test_resolve_owning_test_root_falls_back_to_repository_root(
|
|
2512
|
+
tmp_path: Path,
|
|
2513
|
+
) -> None:
|
|
2514
|
+
test_path = tmp_path / "nested" / "deeper" / "test_behavior.py"
|
|
2515
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2516
|
+
|
|
2517
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2518
|
+
|
|
2519
|
+
assert resolved_root == tmp_path.resolve()
|
|
2520
|
+
|
|
2521
|
+
|
|
2522
|
+
def test_resolve_owning_test_root_skips_pyproject_without_pytest_section(
|
|
2523
|
+
tmp_path: Path,
|
|
2524
|
+
) -> None:
|
|
2525
|
+
outer_root = tmp_path / "outer"
|
|
2526
|
+
write_file(
|
|
2527
|
+
outer_root / "pyproject.toml",
|
|
2528
|
+
"[tool.pytest.ini_options]\ntestpaths = ['tests']\n",
|
|
2529
|
+
)
|
|
2530
|
+
write_file(outer_root / "inner" / "pyproject.toml", "[project]\nname = 'inner'\n")
|
|
2531
|
+
test_path = outer_root / "inner" / "test_behavior.py"
|
|
2532
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2533
|
+
|
|
2534
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2535
|
+
|
|
2536
|
+
assert resolved_root == outer_root.resolve()
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def test_group_staged_tests_by_root_shares_a_session_for_same_root(
|
|
2540
|
+
tmp_path: Path,
|
|
2541
|
+
) -> None:
|
|
2542
|
+
package_root = tmp_path / "package_root"
|
|
2543
|
+
write_file(package_root / "pytest.ini", "[pytest]\n")
|
|
2544
|
+
first_test_path = package_root / "tests" / "test_first.py"
|
|
2545
|
+
second_test_path = package_root / "tests" / "test_second.py"
|
|
2546
|
+
write_file(first_test_path, "def test_first() -> None:\n assert True\n")
|
|
2547
|
+
write_file(second_test_path, "def test_second() -> None:\n assert True\n")
|
|
2548
|
+
|
|
2549
|
+
tests_by_root = gate_module._group_staged_tests_by_root(
|
|
2550
|
+
[first_test_path, second_test_path], tmp_path
|
|
2551
|
+
)
|
|
2552
|
+
|
|
2553
|
+
assert set(tests_by_root) == {package_root.resolve()}
|
|
2554
|
+
assert sorted(tests_by_root[package_root.resolve()]) == sorted(
|
|
2555
|
+
[first_test_path, second_test_path]
|
|
2556
|
+
)
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
def test_run_staged_test_files_fails_when_one_group_fails(
|
|
2560
|
+
temporary_git_repository: Path,
|
|
2561
|
+
capsys: pytest.CaptureFixture[str],
|
|
2562
|
+
) -> None:
|
|
2563
|
+
write_file(temporary_git_repository / "passing_skill" / "pytest.ini", "[pytest]\n")
|
|
2564
|
+
write_file(
|
|
2565
|
+
temporary_git_repository / "passing_skill" / "test_pass.py",
|
|
2566
|
+
"def test_pass() -> None:\n assert True\n",
|
|
2567
|
+
)
|
|
2568
|
+
stage_file(temporary_git_repository, "passing_skill/test_pass.py")
|
|
2569
|
+
write_file(temporary_git_repository / "failing_skill" / "pytest.ini", "[pytest]\n")
|
|
2570
|
+
write_file(
|
|
2571
|
+
temporary_git_repository / "failing_skill" / "test_fail.py",
|
|
2572
|
+
"def test_fail() -> None:\n assert False\n",
|
|
2573
|
+
)
|
|
2574
|
+
stage_file(temporary_git_repository, "failing_skill/test_fail.py")
|
|
2575
|
+
|
|
2576
|
+
exit_code = gate_module.run_staged_test_files(temporary_git_repository)
|
|
2577
|
+
|
|
2578
|
+
assert exit_code != 0
|
|
2579
|
+
captured_error = capsys.readouterr().err
|
|
2580
|
+
assert "failing_skill" in captured_error
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -98,6 +98,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
98
98
|
| `verified_commit_gate.py` | PreToolUse (Bash/PowerShell) | `git commit`/`git push` without a passing verifier verdict |
|
|
99
99
|
| `verified_commit_message_accuracy_blocker.py` | PreToolUse | Commit messages that misstate what the diff has |
|
|
100
100
|
| `verifier_verdict_minter.py` | SubagentStop | Mints a passing verdict file when a `code-verifier` agent finishes cleanly |
|
|
101
|
+
| `volatile_path_in_post_blocker.py` | PreToolUse (Bash/MCP GitHub) | `gh` post commands and GitHub MCP post tools whose body references a volatile path (job scratch dir, worktree, or system temp) that outlives the durable post |
|
|
101
102
|
| `windows_rmtree_blocker.py` | PreToolUse (Write/Edit) | `shutil.rmtree` with `ignore_errors=True` on Windows |
|
|
102
103
|
| `workflow_substitution_slot_blocker.py` | PreToolUse (Write/Edit) | Workflow templates with bare per-iteration tokens missing angle-bracket slots |
|
|
103
104
|
| `write_existing_file_blocker.py` | PreToolUse (Write) | Write to a path where a file already exists |
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Unit and integration tests for the volatile_path_in_post_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
10
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
12
|
+
|
|
13
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
14
|
+
"volatile_path_in_post_blocker",
|
|
15
|
+
_HOOK_DIR / "volatile_path_in_post_blocker.py",
|
|
16
|
+
)
|
|
17
|
+
assert hook_spec is not None
|
|
18
|
+
assert hook_spec.loader is not None
|
|
19
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
20
|
+
hook_spec.loader.exec_module(hook_module)
|
|
21
|
+
|
|
22
|
+
scan_text_for_volatile_marker = hook_module.scan_text_for_volatile_marker
|
|
23
|
+
extract_gh_post_body_texts = hook_module.extract_gh_post_body_texts
|
|
24
|
+
extract_mcp_body_texts = hook_module.extract_mcp_body_texts
|
|
25
|
+
_collect_body_texts = hook_module._collect_body_texts
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _body_names_volatile_path(tool_name: str, tool_input: dict[str, object]) -> bool:
|
|
29
|
+
all_body_texts = _collect_body_texts(tool_name, tool_input)
|
|
30
|
+
return hook_module._first_volatile_marker(all_body_texts) is not None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_scan_detects_job_scratch_path_backslash() -> None:
|
|
34
|
+
text = r"See C:\Users\jon\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"
|
|
35
|
+
assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_scan_detects_job_scratch_path_forward_slash() -> None:
|
|
39
|
+
text = "artifact at /home/user/.claude-editor/jobs/abc/tmp/out.png"
|
|
40
|
+
assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_scan_detects_worktree_path() -> None:
|
|
44
|
+
text = r"edited .claude\worktrees\feature\file.py"
|
|
45
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_scan_detects_appdata_temp_case_insensitive() -> None:
|
|
49
|
+
text = r"C:\Users\jon\AppData\Local\Temp\bugteam\worktree"
|
|
50
|
+
assert scan_text_for_volatile_marker(text) == "appdata/local/temp"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_scan_detects_unix_tmp() -> None:
|
|
54
|
+
assert scan_text_for_volatile_marker("output written to /tmp/run/out.log") == "/tmp/"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_scan_detects_percent_temp_token() -> None:
|
|
58
|
+
assert scan_text_for_volatile_marker("saved to %TEMP%\\out.txt") == "%temp%"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_scan_detects_env_temp_token() -> None:
|
|
62
|
+
assert scan_text_for_volatile_marker("path is $env:TEMP\\out.txt") == "$env:temp"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_scan_detects_claude_job_dir_token() -> None:
|
|
66
|
+
assert scan_text_for_volatile_marker("see $CLAUDE_JOB_DIR/staging/report.md") == "$claude_job_dir"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_scan_clean_body_returns_none() -> None:
|
|
70
|
+
text = "The failing table is pasted below:\n| case | result |\n| a | pass |"
|
|
71
|
+
assert scan_text_for_volatile_marker(text) is None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_gh_comment_with_job_scratch_path_is_blocked() -> None:
|
|
75
|
+
command = (
|
|
76
|
+
'gh pr comment 669 --body "Contact sheet at '
|
|
77
|
+
r'C:\Users\jon\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"'
|
|
78
|
+
)
|
|
79
|
+
assert _body_names_volatile_path("Bash", {"command": command})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_gh_comment_with_clean_body_is_allowed() -> None:
|
|
83
|
+
command = 'gh pr comment 669 --body "All checks pass. Table pasted inline above."'
|
|
84
|
+
assert not _body_names_volatile_path("Bash", {"command": command})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_gh_pr_create_short_flag_blocked() -> None:
|
|
88
|
+
command = 'gh pr create --title "T" -b "logs under /tmp/run/out.log"'
|
|
89
|
+
assert _body_names_volatile_path("Bash", {"command": command})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_gh_issue_create_body_equals_form_blocked() -> None:
|
|
93
|
+
command = 'gh issue create --title "T" --body="dump in %TEMP%\\dump.json"'
|
|
94
|
+
assert _body_names_volatile_path("Bash", {"command": command})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_body_file_content_is_scanned_and_blocked(tmp_path: pathlib.Path) -> None:
|
|
98
|
+
body_file = tmp_path / "body.md"
|
|
99
|
+
body_file.write_text(
|
|
100
|
+
"Artifact: $CLAUDE_JOB_DIR/tmp/contact_sheet.png",
|
|
101
|
+
encoding="utf-8",
|
|
102
|
+
)
|
|
103
|
+
command = f"gh pr comment 669 --body-file {body_file}"
|
|
104
|
+
assert _body_names_volatile_path("Bash", {"command": command})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_body_file_clean_content_is_allowed(tmp_path: pathlib.Path) -> None:
|
|
108
|
+
body_file = tmp_path / "body.md"
|
|
109
|
+
body_file.write_text("Everything is green. Results inline above.", encoding="utf-8")
|
|
110
|
+
command = f"gh pr comment 669 --body-file {body_file}"
|
|
111
|
+
assert not _body_names_volatile_path("Bash", {"command": command})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_non_post_gh_command_is_untouched() -> None:
|
|
115
|
+
assert not _body_names_volatile_path("Bash", {"command": "gh pr list --repo owner/repo"})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_gh_pr_view_with_tmp_in_flag_is_untouched() -> None:
|
|
119
|
+
command = "gh pr view 10 --json body --jq .body > /tmp/out.json"
|
|
120
|
+
assert not _body_names_volatile_path("Bash", {"command": command})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_substring_mention_of_gh_post_does_not_classify() -> None:
|
|
124
|
+
command = 'echo "example: gh pr comment 42 --body \\"see /tmp/x\\"" >> notes.txt'
|
|
125
|
+
assert not _body_names_volatile_path("Bash", {"command": command})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_gh_word_not_first_token_does_not_classify() -> None:
|
|
129
|
+
command = r'echo gh pr comment 42 --body "see C:\.claude-editor\jobs\x"'
|
|
130
|
+
assert not _body_names_volatile_path("Bash", {"command": command})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_env_assignment_prefix_still_classifies() -> None:
|
|
134
|
+
command = 'GH_TOKEN=abc gh pr comment 669 --body "log at /tmp/run.log"'
|
|
135
|
+
assert _body_names_volatile_path("Bash", {"command": command})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_unparseable_command_is_allowed() -> None:
|
|
139
|
+
assert not _body_names_volatile_path(
|
|
140
|
+
"Bash", {"command": "gh pr comment 1 --body 'unterminated"}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_mcp_issue_comment_body_blocked() -> None:
|
|
145
|
+
tool_input: dict[str, object] = {"body": "artifact at $CLAUDE_JOB_DIR/tmp/out.png"}
|
|
146
|
+
assert _body_names_volatile_path("mcp__plugin_github_github__add_issue_comment", tool_input)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_mcp_review_comment_param_blocked() -> None:
|
|
150
|
+
tool_input: dict[str, object] = {"comment": r"see .claude\worktrees\x\file"}
|
|
151
|
+
assert _body_names_volatile_path(
|
|
152
|
+
"mcp__plugin_github_github__add_reply_to_pull_request_comment", tool_input
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_mcp_clean_body_allowed() -> None:
|
|
157
|
+
tool_input: dict[str, object] = {"body": "LGTM, results pasted inline."}
|
|
158
|
+
assert not _body_names_volatile_path("mcp__plugin_github_github__add_issue_comment", tool_input)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_mcp_read_tool_without_body_allowed() -> None:
|
|
162
|
+
tool_input: dict[str, object] = {"pullNumber": 10}
|
|
163
|
+
assert not _body_names_volatile_path("mcp__plugin_github_github__pull_request_read", tool_input)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_extract_mcp_body_texts_skips_non_string_values() -> None:
|
|
167
|
+
tool_input: dict[str, object] = {"body": None, "comment": 42}
|
|
168
|
+
assert extract_mcp_body_texts(tool_input) == []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_extract_gh_post_body_texts_returns_inline_and_file(tmp_path: pathlib.Path) -> None:
|
|
172
|
+
body_file = tmp_path / "b.md"
|
|
173
|
+
body_file.write_text("file body text", encoding="utf-8")
|
|
174
|
+
command = f'gh pr create --title "T" --body "inline body" --body-file {body_file}'
|
|
175
|
+
all_texts = extract_gh_post_body_texts(command)
|
|
176
|
+
assert "inline body" in all_texts
|
|
177
|
+
assert "file body text" in all_texts
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_hook_subprocess_denies_volatile_gh_comment() -> None:
|
|
181
|
+
payload = {
|
|
182
|
+
"tool_name": "Bash",
|
|
183
|
+
"tool_input": {
|
|
184
|
+
"command": 'gh pr comment 669 --body "art at $CLAUDE_JOB_DIR/tmp/x.png"',
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
completion = subprocess.run(
|
|
188
|
+
[sys.executable, str(_HOOK_DIR / "volatile_path_in_post_blocker.py")],
|
|
189
|
+
input=json.dumps(payload),
|
|
190
|
+
capture_output=True,
|
|
191
|
+
text=True,
|
|
192
|
+
check=False,
|
|
193
|
+
)
|
|
194
|
+
decision = json.loads(completion.stdout)
|
|
195
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_hook_subprocess_allows_clean_gh_comment() -> None:
|
|
199
|
+
payload = {
|
|
200
|
+
"tool_name": "Bash",
|
|
201
|
+
"tool_input": {"command": 'gh pr comment 669 --body "all green, results inline"'},
|
|
202
|
+
}
|
|
203
|
+
completion = subprocess.run(
|
|
204
|
+
[sys.executable, str(_HOOK_DIR / "volatile_path_in_post_blocker.py")],
|
|
205
|
+
input=json.dumps(payload),
|
|
206
|
+
capture_output=True,
|
|
207
|
+
text=True,
|
|
208
|
+
check=False,
|
|
209
|
+
)
|
|
210
|
+
assert completion.stdout.strip() == ""
|