claude-dev-env 8.42.4 → 8.42.5

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.
@@ -0,0 +1,25 @@
1
+ Back to the [rule entry](../../rules/shell-invocation.md).
2
+
3
+ # Shell Invocation
4
+
5
+ Two constraints govern every shell command an agent issues: which shell runs it, and what the command string may contain.
6
+
7
+ ## Use pwsh
8
+
9
+ Every Bash-tool shell command on Windows uses `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for scripts, `pwsh -NoProfile -Command "..."` (or a literal `@'...'@` here-string) for inline work, or the built-in `PowerShell` tool for pure-PowerShell workflows (it supports `run_in_background`). Never wrap a script path in `-Command "& '...'"`. The `-File` form keeps `permissions.allow` matching. The `&` call operator is fine for invoking an executable at a path (`& '<venv>\Scripts\python.exe' script.py`).
10
+
11
+ The mandate covers the shell a command runs through. A direct interpreter invocation, such as a `python` call on a repo script, conforms as written.
12
+
13
+ Keep `powershell`, `powershell.exe`, `cmd /c`, and `bash -c` out of the `settings.json` permission rules. `Audit-ShellPolicy.ps1` reports those forms and `Migrate-ShellPolicy.ps1` rewrites them to `pwsh`. Both ship in the claude-dev-env repo at `packages/claude-dev-env/scripts/` and run on demand. No hook runs them.
14
+
15
+ ## No shell substitution
16
+
17
+ No `$(...)`, unescaped backticks, or `<(...)` / `>(...)` process substitution in Bash tool commands. The allowlist matcher reads the raw command string, so a substitution wrapper forces a permission prompt even when every inner segment is auto-allowed. Split into separate tool calls, or use flag forms like `git -C "<path>" rev-parse HEAD`. Arithmetic `$((...))` passes: it spawns no subshell.
18
+
19
+ When a script file's literal body needs `$(...)`, author it with the Write tool.
20
+
21
+ ## Enforcement
22
+
23
+ No PreToolUse hook denies a Bash command for its shell form. The substitution constraint above is guidance a reader follows, and a permission prompt on a wrapped command is the signal that one slipped through.
24
+
25
+ `blocking/msys_rev_path_rewriter.py` only rewrites a Bash command. It keeps Git Bash from converting a `<rev>:<path>` argument when the revision holds a slash. A test pins the dispatcher roster to this one hook.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.42.4",
3
+ "version": "8.42.5",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,23 +1,9 @@
1
- # Shell Invocation
1
+ # Shell invocation
2
2
 
3
- Two constraints govern every shell command an agent issues: which shell runs it, and what the command string may contain.
3
+ **When:** Issuing a Bash-tool shell command, or writing a `settings.json` permission rule.
4
4
 
5
- ## Use pwsh
5
+ On Windows, run shell work through `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for a script, and `pwsh -NoProfile -Command "..."` or the `PowerShell` tool for inline work. A direct interpreter call such as `python script.py` conforms as written. Keep `powershell`, `cmd /c`, and `bash -c` out of permission rules. Keep `$(...)`, unescaped backticks, and `<(...)` or `>(...)` out of Bash commands; split the call or use a flag form such as `git -C "<path>"`. Arithmetic `$((...))` passes.
6
6
 
7
- Every Bash-tool shell command on Windows uses `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for scripts, `pwsh -NoProfile -Command "..."` (or a literal `@'...'@` here-string) for inline work, or the built-in `PowerShell` tool for pure-PowerShell workflows (it supports `run_in_background`). Never wrap a script path in `-Command "& '...'"` — `-File` keeps `permissions.allow` matching. The `&` call operator is fine for invoking an executable at a path (`& '<venv>\Scripts\python.exe' script.py`).
7
+ **Enforcement:** none, the agent applies it; a permission prompt on a wrapped command is the signal. `blocking/msys_rev_path_rewriter.py` only rewrites a `<rev>:<path>` argument.
8
8
 
9
- The mandate covers the shell a command runs through. A direct interpreter invocation, such as a `python` call on a repo script, conforms as written.
10
-
11
- Keep `powershell`, `powershell.exe`, `cmd /c`, and `bash -c` out of the `settings.json` permission rules. `Audit-ShellPolicy.ps1` reports those forms and `Migrate-ShellPolicy.ps1` rewrites them to `pwsh`. Both ship in the claude-dev-env repo at `packages/claude-dev-env/scripts/` and run on demand, not as a live gate.
12
-
13
- ## No shell substitution
14
-
15
- No `$(...)`, unescaped backticks, or `<(...)` / `>(...)` process substitution in Bash tool commands. The allowlist matcher reads the raw command string, so a substitution wrapper forces a permission prompt even when every inner segment is auto-allowed. Split into separate tool calls, or use flag forms like `git -C "<path>" rev-parse HEAD`. Arithmetic `$((...))` passes: it spawns no subshell.
16
-
17
- When a script file's literal body needs `$(...)`, author it with the Write tool, not a Bash heredoc.
18
-
19
- ## Enforcement
20
-
21
- No PreToolUse hook denies a Bash command for its shell form. The substitution constraint above is guidance a reader follows, and a permission prompt on a wrapped command is the signal that one slipped through.
22
-
23
- `blocking/msys_rev_path_rewriter.py` only rewrites a Bash command. It keeps Git Bash from converting a `<rev>:<path>` argument when the revision holds a slash. A test pins the dispatcher roster to this one hook.
9
+ **Full text:** [`docs/rule-guides/shell-invocation.md`](../docs/rule-guides/shell-invocation.md). Read it before writing a permission rule or a multi-step shell command.
@@ -0,0 +1,138 @@
1
+ """Each rule entry keeps the index shape, and the always-loaded entries stay inside a byte budget."""
2
+
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
8
+ RULES_DIRECTORY = PACKAGE_ROOT / "rules"
9
+ RULE_GUIDES_DIRECTORY = PACKAGE_ROOT / "docs" / "rule-guides"
10
+
11
+ module_directory = str(Path(__file__).parents[1])
12
+ if module_directory not in sys.path:
13
+ sys.path.insert(0, module_directory)
14
+
15
+ from codex_compat_materializer import (
16
+ codex_instruction_rule_relative_paths,
17
+ instruction_alias_filenames,
18
+ )
19
+
20
+ FRONTMATTER_DELIMITER = "---"
21
+ FULL_TEXT_LINK_PATTERN = re.compile(
22
+ r"^\*\*Full text:\*\*.*?\]\((\.\./docs/rule-guides/[^)#\s]+\.md)\)", re.MULTILINE
23
+ )
24
+ TITLE_PATTERN = re.compile(r"^# \S", re.MULTILINE)
25
+
26
+ MAXIMUM_ENTRY_BYTES = 1_500
27
+ MAXIMUM_ALWAYS_ON_BYTES = 12_000
28
+ POINTER_ENTRY_NAMES = frozenset({"skill-pointers.md"})
29
+ CODEX_VERBATIM_ENTRY_NAMES = frozenset({"question-presentation.md"})
30
+ CODEX_MATERIALIZED_GUIDE_NAMES = frozenset(
31
+ Path(each_path).name for each_path in codex_instruction_rule_relative_paths
32
+ )
33
+
34
+
35
+ def _entry_paths() -> list[Path]:
36
+ return sorted(
37
+ each_path
38
+ for each_path in RULES_DIRECTORY.glob("*.md")
39
+ if each_path.name not in instruction_alias_filenames
40
+ )
41
+
42
+
43
+ def _split_frontmatter(entry_text: str) -> tuple[list[str], str]:
44
+ all_lines = entry_text.splitlines()
45
+ if not all_lines or all_lines[0] != FRONTMATTER_DELIMITER:
46
+ return [], entry_text
47
+ for each_index, each_line in enumerate(all_lines[1:], start=1):
48
+ if each_line == FRONTMATTER_DELIMITER:
49
+ return all_lines[1:each_index], "\n".join(all_lines[each_index + 1 :])
50
+ return [], entry_text
51
+
52
+
53
+ def _loads_in_every_session(entry_text: str) -> bool:
54
+ all_frontmatter_lines, _ = _split_frontmatter(entry_text)
55
+ return not any(
56
+ each_line.startswith("paths:") for each_line in all_frontmatter_lines
57
+ )
58
+
59
+
60
+ def _linked_guide_names(entry_text: str) -> set[str]:
61
+ return {
62
+ Path(each_target).name
63
+ for each_target in FULL_TEXT_LINK_PATTERN.findall(entry_text)
64
+ }
65
+
66
+
67
+ def _entry_text(entry_path: Path) -> str:
68
+ return entry_path.read_text(encoding="utf-8")
69
+
70
+
71
+ def test_each_entry_stays_inside_the_entry_byte_budget() -> None:
72
+ over_budget = {
73
+ each_path.name: len(each_path.read_bytes())
74
+ for each_path in _entry_paths()
75
+ if len(each_path.read_bytes()) > MAXIMUM_ENTRY_BYTES
76
+ }
77
+ assert over_budget == {}
78
+
79
+
80
+ def test_each_entry_opens_with_a_title_after_its_frontmatter() -> None:
81
+ untitled = [
82
+ each_path.name
83
+ for each_path in _entry_paths()
84
+ if not TITLE_PATTERN.match(
85
+ _split_frontmatter(_entry_text(each_path))[1].lstrip()
86
+ )
87
+ ]
88
+ assert untitled == []
89
+
90
+
91
+ def _full_text_link_problems(entry_path: Path) -> list[str]:
92
+ all_guide_names = _linked_guide_names(_entry_text(entry_path))
93
+ if not all_guide_names:
94
+ return [f"{entry_path.name}: no Full text link"]
95
+ return [
96
+ f"{entry_path.name}: {each_guide_name} does not exist"
97
+ for each_guide_name in sorted(all_guide_names)
98
+ if not (RULE_GUIDES_DIRECTORY / each_guide_name).is_file()
99
+ ]
100
+
101
+
102
+ def test_each_entry_links_a_full_text_guide_that_exists() -> None:
103
+ all_problems = [
104
+ each_problem
105
+ for each_path in _entry_paths()
106
+ if each_path.name not in POINTER_ENTRY_NAMES | CODEX_VERBATIM_ENTRY_NAMES
107
+ for each_problem in _full_text_link_problems(each_path)
108
+ ]
109
+ assert all_problems == []
110
+
111
+
112
+ def test_each_guide_has_exactly_one_owner() -> None:
113
+ owners_by_guide: dict[str, list[str]] = {}
114
+ for each_path in _entry_paths():
115
+ for each_guide_name in _linked_guide_names(_entry_text(each_path)):
116
+ owners_by_guide.setdefault(each_guide_name, []).append(each_path.name)
117
+ unowned = sorted(
118
+ each_guide.name
119
+ for each_guide in RULE_GUIDES_DIRECTORY.glob("*.md")
120
+ if each_guide.name not in owners_by_guide
121
+ and each_guide.name not in CODEX_MATERIALIZED_GUIDE_NAMES
122
+ )
123
+ shared = {
124
+ each_guide_name: all_owner_names
125
+ for each_guide_name, all_owner_names in owners_by_guide.items()
126
+ if len(all_owner_names) > 1
127
+ }
128
+ assert unowned == []
129
+ assert shared == {}
130
+
131
+
132
+ def test_always_loaded_entries_stay_inside_the_total_byte_budget() -> None:
133
+ always_on_bytes = sum(
134
+ len(each_path.read_bytes())
135
+ for each_path in _entry_paths()
136
+ if _loads_in_every_session(_entry_text(each_path))
137
+ )
138
+ assert always_on_bytes <= MAXIMUM_ALWAYS_ON_BYTES