claude-dev-env 8.42.4 → 8.42.6
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.
|
@@ -436,3 +436,32 @@ def should_print_probe_reason_on_the_bypassed_copilot_gate_line(
|
|
|
436
436
|
assert captured_stdout.count(probe_line) == 2
|
|
437
437
|
assert exit_code == 0
|
|
438
438
|
|
|
439
|
+
|
|
440
|
+
@pytest.mark.parametrize(
|
|
441
|
+
("accepted_exit_codes", "broker_exit_code", "expected_tier"),
|
|
442
|
+
[
|
|
443
|
+
((0,), 3, None),
|
|
444
|
+
((0, 2), 2, "wait"),
|
|
445
|
+
],
|
|
446
|
+
)
|
|
447
|
+
def should_accept_only_the_broker_exit_codes_the_named_constant_lists(
|
|
448
|
+
accepted_exit_codes: tuple[int, ...],
|
|
449
|
+
broker_exit_code: int,
|
|
450
|
+
expected_tier: str | None,
|
|
451
|
+
tmp_path: Path,
|
|
452
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
453
|
+
) -> None:
|
|
454
|
+
broker_path = tmp_path / "account_broker.py"
|
|
455
|
+
broker_path.write_text(
|
|
456
|
+
"import json, sys\n"
|
|
457
|
+
"print(json.dumps({'decision': {'tier': 'wait'}, 'accounts': []}))\n"
|
|
458
|
+
f"sys.exit({broker_exit_code})\n",
|
|
459
|
+
encoding="utf-8",
|
|
460
|
+
)
|
|
461
|
+
monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
|
|
462
|
+
monkeypatch.setattr(
|
|
463
|
+
check_convergence, "ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES", accepted_exit_codes
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
assert check_convergence._read_codex_tier() == expected_tier
|
|
467
|
+
|
|
@@ -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,23 +1,9 @@
|
|
|
1
|
-
# Shell
|
|
1
|
+
# Shell invocation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**When:** Issuing a Bash-tool shell command, or writing a `settings.json` permission rule.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|