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.
@@ -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 run_staged_test_files(repository_root: Path) -> int:
1563
- """Run pytest over the staged test files and return the gate exit code.
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
- repository_root: The repository root the staged test files belong to.
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
- 0 when no test file is staged, when the staged test files collect
1570
- no tests (pytest's no-tests-collected exit code), or when every staged
1571
- test passes. pytest's non-zero exit code otherwise, which blocks the
1572
- commit.
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 all_test_paths],
1638
+ *[str(each_path) for each_path in all_group_test_paths],
1582
1639
  ],
1583
- cwd=str(repository_root),
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
@@ -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() == ""