claude-dev-env 1.73.0 → 1.74.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.
Files changed (78) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +3 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +10 -4
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  14. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  15. package/hooks/blocking/hedging_language_blocker.py +16 -10
  16. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  17. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  18. package/hooks/blocking/md_to_html_blocker.py +10 -2
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  20. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  21. package/hooks/blocking/plain_language_blocker.py +6 -0
  22. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  23. package/hooks/blocking/pr_description_enforcer.py +6 -0
  24. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  25. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  26. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  27. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  28. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  29. package/hooks/blocking/sensitive_file_protector.py +15 -1
  30. package/hooks/blocking/session_handoff_blocker.py +14 -8
  31. package/hooks/blocking/state_description_blocker.py +6 -0
  32. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  33. package/hooks/blocking/tdd_enforcer.py +6 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  36. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +45 -0
  37. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  38. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  39. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  40. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  41. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  42. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  43. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  44. package/hooks/blocking/test_state_description_blocker.py +41 -0
  45. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  47. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  48. package/hooks/blocking/verified_commit_gate.py +11 -0
  49. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  50. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  51. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  52. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  53. package/hooks/hooks.json +10 -0
  54. package/hooks/hooks_constants/CLAUDE.md +4 -0
  55. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  57. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  58. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  59. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  60. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  61. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +1 -2
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
  63. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  64. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  65. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  66. package/hooks/lifecycle/config_change_guard.py +12 -0
  67. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  68. package/hooks/validation/hook_format_validator.py +13 -0
  69. package/hooks/validation/mypy_validator.py +30 -1
  70. package/hooks/validation/test_hook_format_validator.py +64 -0
  71. package/hooks/validation/test_mypy_validator.py +22 -0
  72. package/package.json +1 -1
  73. package/rules/CLAUDE.md +1 -0
  74. package/rules/docstring-prose-matches-implementation.md +2 -1
  75. package/rules/package-inventory-stale-entry.md +24 -0
  76. package/skills/autoconverge/SKILL.md +18 -1
  77. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  78. package/skills/autoconverge/workflow/converge.mjs +2 -1
@@ -35,6 +35,7 @@ if _hooks_directory not in sys.path:
35
35
 
36
36
  from mypy_integration import find_pyproject_with_mypy_config # noqa: E402
37
37
 
38
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
38
39
  from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
39
40
  CACHE_FILE_ENCODING,
40
41
  CONTENT_HASH_CACHE_PASSING_EXIT_CODE,
@@ -293,6 +294,28 @@ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -
293
294
  ]
294
295
 
295
296
 
297
+ def project_relative_path(target_file: str, project_root: str) -> str:
298
+ """Return *target_file* relative to *project_root*, or its absolute path.
299
+
300
+ On Windows ``os.path.relpath`` raises ``ValueError`` when the two paths sit
301
+ on different mounts (for example a ``Y:`` drive file against a project root
302
+ that resolved to its backing UNC share), so no relative path can span them.
303
+ The absolute target path is then handed to mypy unchanged, which accepts it.
304
+
305
+ Args:
306
+ target_file: The absolute path of the file to type-check.
307
+ project_root: The directory mypy runs from.
308
+
309
+ Returns:
310
+ The path relative to *project_root* when one exists, otherwise the
311
+ absolute path of *target_file*.
312
+ """
313
+ try:
314
+ return os.path.relpath(target_file, project_root)
315
+ except ValueError:
316
+ return os.path.abspath(target_file)
317
+
318
+
296
319
  def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
297
320
  """Run mypy on one file from the project root and return its result.
298
321
 
@@ -325,7 +348,7 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
325
348
  Returns:
326
349
  The mypy exit code paired with its combined stdout and stderr text.
327
350
  """
328
- relative_file_path = os.path.relpath(target_file, project_root)
351
+ relative_file_path = project_relative_path(target_file, project_root)
329
352
  mypy_config_file = discover_mypy_config(Path(target_file))
330
353
 
331
354
  content_hash = _composite_content_hash(target_file, mypy_config_file)
@@ -457,6 +480,12 @@ def main() -> None:
457
480
  error_summary = format_error_summary(all_error_lines)
458
481
  send_block_notification(error_summary)
459
482
  block_response = build_block_response(error_summary)
483
+ log_hook_block(
484
+ calling_hook_name="mypy_validator.py",
485
+ hook_event="PostToolUse",
486
+ block_reason=f"[MYPY] Type errors: {error_summary}",
487
+ offending_input_preview=target_file_path,
488
+ )
460
489
  print(json.dumps(block_response))
461
490
  sys.exit(0)
462
491
 
@@ -0,0 +1,64 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ HOOK_PATH = Path(__file__).parent / "hook_format_validator.py"
8
+
9
+
10
+ def _run_hook(
11
+ payload: dict[str, object],
12
+ extra_env: dict[str, str] | None = None,
13
+ ) -> subprocess.CompletedProcess[str]:
14
+ env = {**os.environ, **(extra_env or {})}
15
+ return subprocess.run(
16
+ [sys.executable, str(HOOK_PATH)],
17
+ input=json.dumps(payload),
18
+ text=True,
19
+ capture_output=True,
20
+ check=False,
21
+ env=env,
22
+ )
23
+
24
+
25
+ def test_simple_pattern_blocks_with_deny_payload(tmp_path: Path) -> None:
26
+ settings_path = tmp_path / ".claude" / "settings.json"
27
+ payload = {
28
+ "tool_name": "Edit",
29
+ "tool_input": {
30
+ "file_path": str(settings_path),
31
+ "new_string": "python3 ~/.claude/hooks/blocking/my-hook.py",
32
+ },
33
+ }
34
+
35
+ hook_run = _run_hook(payload)
36
+
37
+ assert hook_run.returncode == 0
38
+ deny_payload = json.loads(hook_run.stdout)
39
+ hook_specific_output = deny_payload["hookSpecificOutput"]
40
+ assert hook_specific_output["hookEventName"] == "PreToolUse"
41
+ assert hook_specific_output["permissionDecision"] == "deny"
42
+
43
+
44
+ def test_block_logs_pre_tool_use_event(tmp_path: Path) -> None:
45
+ fake_home = tmp_path / "home"
46
+ fake_home.mkdir()
47
+ settings_path = tmp_path / ".claude" / "settings.json"
48
+ payload = {
49
+ "tool_name": "Edit",
50
+ "tool_input": {
51
+ "file_path": str(settings_path),
52
+ "new_string": "python3 ~/.claude/hooks/blocking/my-hook.py",
53
+ },
54
+ }
55
+
56
+ hook_run = _run_hook(
57
+ payload,
58
+ extra_env={"HOME": str(fake_home), "USERPROFILE": str(fake_home)},
59
+ )
60
+
61
+ assert hook_run.returncode == 0
62
+ log_path = fake_home / ".claude" / "logs" / "hook-blocks.log"
63
+ logged_record = json.loads(log_path.read_text(encoding="utf-8").splitlines()[-1])
64
+ assert logged_record["event"] == "PreToolUse"
@@ -15,6 +15,8 @@ the cache directory redirected to a temporary directory.
15
15
  """
16
16
 
17
17
  import importlib.util
18
+ import os
19
+ import sys
18
20
  from pathlib import Path
19
21
  from types import ModuleType
20
22
 
@@ -275,3 +277,23 @@ def test_content_hash_skip_invalidated_when_mypy_config_tightens(
275
277
  )
276
278
  assert tightened_exit_code != 0
277
279
  assert ": error:" in tightened_output
280
+
281
+
282
+ def test_project_relative_path_within_root_returns_relative() -> None:
283
+ validator = _load_validator()
284
+ project_root = os.path.join("base", "project")
285
+ target_file = os.path.join(project_root, "package", "module.py")
286
+ assert validator.project_relative_path(target_file, project_root) == os.path.join(
287
+ "package", "module.py"
288
+ )
289
+
290
+
291
+ def test_project_relative_path_across_mounts_falls_back_to_absolute() -> None:
292
+ if sys.platform != "win32":
293
+ pytest.skip("cross-mount relpath ValueError only occurs on Windows")
294
+ validator = _load_validator()
295
+ target_file = "Y:\\repository\\package\\module.py"
296
+ project_root = "C:\\other\\root"
297
+ assert validator.project_relative_path(target_file, project_root) == os.path.abspath(
298
+ target_file
299
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.73.0",
3
+ "version": "1.74.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/CLAUDE.md CHANGED
@@ -28,6 +28,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
28
28
  | `no-historical-clutter.md` | Documentation describes current state only; no historical or transitional language |
29
29
  | `no-inline-destructive-literals.md` | No destructive-command literals in Bash tool command strings, even as data |
30
30
  | `orphan-css-class.md` | Every `class="..."` attribute in Python-generated markup has a matching selector in the `<style>` block |
31
+ | `package-inventory-stale-entry.md` | A new production code file added to a directory carries an entry in that directory's `README.md`/`CLAUDE.md` file inventory |
31
32
  | `parallel-tools.md` | Make all independent tool calls in a single response |
32
33
  | `plain-language.md` | Everyday words, short active sentences, lead with the answer |
33
34
  | `prompt-workflow-context-controls.md` | Keep prompt-workflow instruction layers small and stable; load heavy skills on demand |
@@ -6,7 +6,7 @@
6
6
 
7
7
  When a docstring enumerates the behaviors a body applies, the enumeration covers every behavior the body applies. A reader trusts the list to be complete: an item the code applies but the prose omits is a silent gap that misleads every future reader and reviewer.
8
8
 
9
- The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Three more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the four gated slices.
9
+ The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Four more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. `check_docstring_returns_plural_cardinality` covers a `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) while the returned dict literal holds exactly one key in that family (`sheen_mid`) — the drift where a single-key family carries a plural noun, so the prose claims a cardinality of two or more that the dict does not hold. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the five gated slices.
10
10
 
11
11
  ## What to check before you write the docstring
12
12
 
@@ -16,6 +16,7 @@ Read the body and the docstring side by side:
16
16
  - **Suppressor / skip lists.** A body with several early returns that suppress the check names each suppressor in the prose.
17
17
  - **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
18
18
  - **Step order.** A docstring that says `A then B then C` matches the call order in the body. A step enumeration that names the body's linear steps also names every corrective step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`). The `check_docstring_step_enumeration_dispatch_coverage` gate blocks the branch-guarded-dispatch form of this drift — a step-enumeration docstring that omits a two-or-more-token dispatch step the body guards inside a branch — at Write/Edit time.
19
+ - **Returns-clause cardinality.** A `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) matches the count of keys in that family in the returned dict literal. When the dict holds one key in the family (`sheen_mid`), the noun is singular (`the sheen stop`); a plural noun there claims two or more entries the dict does not hold. The `check_docstring_returns_plural_cardinality` gate blocks the single-key-with-plural-noun form of this drift at Write/Edit time.
19
20
  - **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
20
21
  - **Exclusion-clause distinguisher.** A docstring sentence that says a named category of input "are not" / "is not" the thing the function flags (`plain logging, screenshot, or method-on-local calls inside a branch are not dispatch steps`) keys the exclusion to the same axis the body's classification keys on. When the body decides on one axis (a call sits in an `If.test` guard versus a plain statement) but the prose excludes on a different axis (the call's receiver shape — a method on a local), the exclusion clause names a category the body still flags: a guarded method-on-local call is flagged even though the prose lists method-on-local calls as excluded. Read the body's actual branch condition, then state the exclusion on that same axis (`plain (unguarded) calls inside a branch body are not dispatch steps`), so every member the prose excludes is a member the body also excludes.
21
22
  - **Companion-doc ordering and content claims.** A `SKILL.md` (or sibling `.md`) sentence that names a produced artifact and claims its order (`sorted`, `alphabetical`, `in sorted order`) or its content (`the at-risk names`, `just the current set`) matches the producer function's docstring and body for that same artifact. A producer that builds the artifact by merging stored names with new names and appending — preserving file order, not re-sorting the union — leaves a doc that still says `sorted` drifted on both counts: the order claim is wrong, and the content claim hides the merged-in prior entries. When the producer's ordering or union changes, the same change updates the companion doc. The two move together in one commit, even when the producer edit does not touch the `.md` file.
@@ -0,0 +1,24 @@
1
+ # New Production File Absent From Its Package Inventory
2
+
3
+ **When this applies:** Any Write that creates a new production code file (`.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`) in a directory whose sibling `README.md` or `CLAUDE.md` already names two or more of the directory's files in backticks.
4
+
5
+ ## Rule
6
+
7
+ A package directory that documents its own files in a `README.md` Layout table or a `CLAUDE.md` "Key files" list keeps that inventory in step with the directory. A new production file the inventory does not name leaves the inventory and the directory disagreeing on the package's file set: a reader who trusts the inventory to map the directory misses the new file.
8
+
9
+ When you create a new production file in such a directory, add an entry naming it — a row in the `README.md` table, a bullet in the `CLAUDE.md` list — in the same change. The entry names the file in backticks and says what it does.
10
+
11
+ ## What the gate checks
12
+
13
+ The `package_inventory_stale_blocker.py` hook runs on every Write whose target is a new file (a path not yet on disk). It:
14
+
15
+ 1. Skips a target that is not a production code file (`.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`), an exempt basename (`__init__.py`, `conftest.py`, `setup.py`, `_path_setup.py`), a test file (`test_*.py`, `*_test.py`, `*.spec.*`, `*.test.*`), or a file directly inside a `config/` or `tests/` directory.
16
+ 2. Reads each `README.md` and `CLAUDE.md` present in the target's own directory and collects every bare filename they name in backticks. A backticked token holding a path contributes its final segment, so `pipeline/seam_continuity.py` in an inventory counts as naming `seam_continuity.py`. A multi-word command-example span — one carrying whitespace or shell punctuation (`:`, `$`, `<`, `>`), such as `parent:node_modules package.json` or `python <file>.py` — names no literal file and is dropped.
17
+ 3. Filters the named basenames to those that exist as a file in the target's own directory — the inventory's own sibling files — and treats the directory as carrying a maintained inventory only when two or more such sibling files are named. A directory with no inventory, one whose `README.md` mentions a single file in passing, or one whose inventory prose names only files living in other directories (so no named basename is an on-disk sibling) is out of scope.
18
+ 4. Blocks the write when the new file's basename appears in no present inventory. An unreadable or oversized inventory document is skipped, so a missing inventory never blocks a write.
19
+
20
+ The check fires on Write only — editing an existing file adds no new inventory entry — and stays quiet for a directory with no inventory document, an inventory naming too few siblings to be a maintained list, an exempt or test file, and a file the inventory already names.
21
+
22
+ ## Why this is a hook, not a lint pass
23
+
24
+ A package inventory that omits a file reads as a complete map of the directory while leaving one file off it. A reader trusting the inventory to list the package misses the new file, and the gap survives review because the inventory still looks complete. Catching it as the new file is written keeps the inventory and the directory in step. This is the counterpart to `claude-md-orphan-file.md`, which catches the reverse drift: an inventory entry naming a file the directory does not hold.
@@ -261,7 +261,24 @@ agents never inline a destructive-command literal (`rm -rf`, `git reset --hard`,
261
261
  `dd`) into a Bash command — the `destructive_command_blocker` hook matches those
262
262
  patterns as raw text, and a confirmation prompt no human can answer would stall
263
263
  the run. Agents verify destructive-blocker behavior through the committed test
264
- suite (`python -m pytest`) and keep scratch work in ephemeral temp dirs.
264
+ suite (`python -m pytest`) and keep scratch work in the OS temp dir. The preamble
265
+ describes the narrowest rm auto-allow path — a standalone Bash call whose target
266
+ resolves inside the ephemeral namespace (`/tmp`, `/temp`, the OS temp root, or the
267
+ run worktree) — and a compound path that accepts an rm joined with benign
268
+ reporting segments when every rm target is an absolute ephemeral path. Both of
269
+ those paths fail closed on `$(...)` substitution, backtick subshells, and any `$`
270
+ in the target — including `$CLAUDE_JOB_DIR` — so neither resolves an environment
271
+ variable. A third, broad path matches only when the command itself declares an
272
+ ephemeral working directory (it `cd`s into one, or runs under one): that
273
+ cwd-scoped path resolves the target against the declared cwd, fails closed on
274
+ `$(...)`, backticks, and unknown variables, and resolves the known temporary
275
+ variables `TEMP`, `TMP`, `TMPDIR`, and `CLAUDE_JOB_DIR` to the OS temp root, so
276
+ under that declared ephemeral cwd a bare `$CLAUDE_JOB_DIR/tmp/<name>` target and a
277
+ relative target after a `cd` are auto-allowed. Even so, for any cleanup whose path
278
+ is variable-built or whose teardown spans multiple steps, agents author a Python
279
+ helper file and run it as `python <file>.py` — keeping every destructive literal
280
+ out of a Bash command string entirely and independent of which auto-allow path
281
+ matches.
265
282
 
266
283
  - **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
267
284
  the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
@@ -10,6 +10,10 @@ const gotchasSource = readFileSync(
10
10
  join(workflowDirectory, '..', 'reference', 'gotchas.md'),
11
11
  'utf8',
12
12
  );
13
+ const skillSource = readFileSync(
14
+ join(workflowDirectory, '..', 'SKILL.md'),
15
+ 'utf8',
16
+ );
13
17
 
14
18
  function lensPromptBody(builderName) {
15
19
  const builderStart = convergeSource.indexOf(`function ${builderName}(`);
@@ -547,3 +551,105 @@ for (const builderName of editStepBuilders) {
547
551
  );
548
552
  });
549
553
  }
554
+
555
+ function preambleText() {
556
+ const preambleStart = convergeSource.indexOf('const HEADLESS_SAFETY_PREAMBLE =');
557
+ assert.notEqual(preambleStart, -1, 'expected HEADLESS_SAFETY_PREAMBLE to exist');
558
+ const preambleEnd = convergeSource.indexOf('\n\nlet ', preambleStart);
559
+ return convergeSource.slice(preambleStart, preambleEnd === -1 ? undefined : preambleEnd);
560
+ }
561
+
562
+ test('preamble prescribes authoring a Python helper for variable-built or multi-step sandboxes', () => {
563
+ assert.match(
564
+ preambleText(),
565
+ /python\s+<file>\.py|python\s+<.*>\.py|author.*python.*helper|python.*helper.*sandbox|sandbox.*python.*helper/i,
566
+ 'expected the preamble to prescribe running a Python helper file for multi-step sandbox teardown',
567
+ );
568
+ });
569
+
570
+ test('preamble does not claim any $ in the rm target makes the gate fail closed', () => {
571
+ assert.doesNotMatch(
572
+ preambleText(),
573
+ /any\s+\$[^\n]*fail closed/i,
574
+ 'the hook resolves known temp variables (TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR), so a bare $ does not always fail closed',
575
+ );
576
+ });
577
+
578
+ test('preamble does not claim $CLAUDE_JOB_DIR/tmp is blocked', () => {
579
+ assert.doesNotMatch(
580
+ preambleText(),
581
+ /CLAUDE_JOB_DIR\/tmp is NOT auto-allowed/i,
582
+ 'under an ephemeral cwd the hook auto-allows rm targeting $CLAUDE_JOB_DIR/tmp',
583
+ );
584
+ });
585
+
586
+ test('preamble scopes its rm-shape claim to the narrowest auto-allow path, not the full set', () => {
587
+ assert.doesNotMatch(
588
+ preambleText(),
589
+ /auto-allows rm only when ALL of these hold/i,
590
+ 'the hook has three rm auto-allow paths, so the preamble must not assert one narrow shape is the complete set',
591
+ );
592
+ });
593
+
594
+ test('SKILL.md does not claim any $ in the rm target makes the gate fail closed', () => {
595
+ assert.doesNotMatch(
596
+ skillSource,
597
+ /any\s+`?\$`?[^\n]*fail closed/i,
598
+ 'the hook resolves known temp variables (TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR), so a bare $ does not always fail closed',
599
+ );
600
+ });
601
+
602
+ test('SKILL.md does not claim it enforces the exact rm shape the hook auto-allows', () => {
603
+ assert.doesNotMatch(
604
+ skillSource,
605
+ /exact rm shape the hook auto-allows/i,
606
+ 'the hook has multiple rm auto-allow paths, so SKILL.md must not assert one narrow shape is the exact set',
607
+ );
608
+ });
609
+
610
+ test('preamble does not attribute the known-temp-var resolution to the standalone or compound paths', () => {
611
+ assert.doesNotMatch(
612
+ preambleText().replace(/\s+/g, ' '),
613
+ /Across these paths[\s\S]*?CLAUDE_JOB_DIR/i,
614
+ 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths fail closed on any $',
615
+ );
616
+ });
617
+
618
+ test('preamble attributes the known-temp-var resolution to a third cwd-scoped auto-allow path', () => {
619
+ const text = preambleText().replace(/\s+/g, ' ');
620
+ const tempVarSentenceMatch =
621
+ /[^.]*\bTMPDIR\b[^.]*CLAUDE_JOB_DIR[^.]*\./i.exec(text);
622
+ assert.notEqual(
623
+ tempVarSentenceMatch,
624
+ null,
625
+ 'expected a sentence describing the TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR resolution',
626
+ );
627
+ assert.match(
628
+ tempVarSentenceMatch[0],
629
+ /declares? an ephemeral cwd|declared ephemeral cwd|ephemeral-cwd path|third (?:auto-allow )?path|cwd-scoped path/i,
630
+ 'expected the temp-var resolution to be tied to the cwd-scoped path that declares an ephemeral working directory, not the standalone or compound paths',
631
+ );
632
+ });
633
+
634
+ test('SKILL.md does not attribute the known-temp-var resolution to the standalone or compound paths', () => {
635
+ assert.doesNotMatch(
636
+ skillSource.replace(/\s+/g, ' '),
637
+ /Across those paths[\s\S]*?CLAUDE_JOB_DIR/i,
638
+ 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths fail closed on any $',
639
+ );
640
+ });
641
+
642
+ test('SKILL.md attributes the known-temp-var resolution to the cwd-scoped auto-allow path', () => {
643
+ const tempVarSentenceMatch =
644
+ /[^.]*\bTMPDIR\b[^.]*CLAUDE_JOB_DIR[^.]*\./i.exec(skillSource.replace(/\s+/g, ' '));
645
+ assert.notEqual(
646
+ tempVarSentenceMatch,
647
+ null,
648
+ 'expected a sentence describing the TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR resolution',
649
+ );
650
+ assert.match(
651
+ tempVarSentenceMatch[0],
652
+ /declares? an ephemeral cwd|declared ephemeral cwd|ephemeral-cwd path|third (?:auto-allow )?path|cwd-scoped path/i,
653
+ 'expected the temp-var resolution to be tied to the cwd-scoped path that declares an ephemeral working directory, not the standalone or compound paths',
654
+ );
655
+ });
@@ -33,7 +33,8 @@ const HEADLESS_SAFETY_PREAMBLE =
33
33
  'HEADLESS RUN — you run unattended: no human can answer a permission or confirmation prompt, and any such prompt stalls the entire convergence run. The destructive_command_blocker hook matches dangerous patterns (rm -rf, git reset --hard, dd, mkfs, chmod -R, fork bombs) as raw text anywhere in a Bash command, with no quote-awareness — so a destructive string stalls you even when it is only data you never execute. Therefore:\n' +
34
34
  '- Never place a destructive-command literal inside a Bash command — not in echo, not in a heredoc, and not as an argument to python -c, node -e, or awk. To exercise or verify destructive_command_blocker (or any hook) behavior, run the committed test suite, e.g. python -m pytest <test_file>, which passes the command strings as in-language data rather than as a shell command.\n' +
35
35
  '- When a commit message, or a PR / issue / review-comment body, must describe destructive-command behavior, write that text to a file and pass it by path (git commit -F <file>, gh ... --body-file <file>); never inline it with git commit -m or gh ... -b, where the literal lands in the Bash command and stalls you.\n' +
36
- '- Keep scratch files and cleanup inside the OS temp dir or $CLAUDE_JOB_DIR/tmp (auto-allowed as ephemeral); never target a repository or worktree path with rm -rf.\n' +
36
+ '- Keep scratch files and cleanup inside the OS temp dir; never target a repository or worktree path.\n' +
37
+ '- rm shape rules — the hook grants several rm auto-allow paths. The simplest one accepts a standalone Bash call whose target resolves inside the ephemeral namespace (/tmp, /temp, the OS temp root, or the run worktree); a compound path accepts an rm joined with benign reporting segments when every rm target is an absolute ephemeral path. Both of those paths fail closed on $(...) command substitution, on backtick subshells, and on any $ in the target — including $CLAUDE_JOB_DIR — so neither resolves an environment variable. A third, broad path matches only when the command itself declares an ephemeral working directory (it cds into one, or runs under one): that cwd-scoped path resolves the target against the declared cwd, fails closed on $(...) , backticks, and unknown variables, and resolves the known temporary variables TEMP, TMP, TMPDIR, and CLAUDE_JOB_DIR to the OS temp root, so under that declared ephemeral cwd a bare $CLAUDE_JOB_DIR/tmp/<name> target and a relative target after a cd are auto-allowed. Even so, prefer a Python helper for any cleanup whose path is variable-built or whose setup/teardown spans multiple steps: author the helper file and run it as python <file>.py, which keeps every destructive literal out of a Bash command string entirely and never depends on which auto-allow path matches.\n' +
37
38
  '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n'
38
39
 
39
40
  let activeRepoPath = null