claude-dev-env 1.89.0 → 1.92.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/CLAUDE.md +0 -13
- 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/agents/clean-coder.md +2 -2
- package/agents/code-verifier.md +0 -1
- package/agents/test_agent_frontmatter.py +78 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/bin/install.mjs +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +6 -2
- package/hooks/blocking/code_rules_comments.py +2 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
- package/hooks/blocking/config/verified_commit_constants.py +2 -0
- package/hooks/blocking/conftest.py +115 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
- package/hooks/blocking/pr_description_enforcer.py +46 -22
- package/hooks/blocking/pr_description_pr_number.py +5 -3
- package/hooks/blocking/pr_description_proof_of_work.py +367 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
- package/hooks/blocking/test_verification_verdict_store.py +11 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
- package/hooks/blocking/test_verified_commit_gate.py +11 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
- package/hooks/blocking/verification_verdict_store.py +73 -5
- package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
- package/hooks/blocking/verified_commit_gate.py +6 -0
- package/hooks/blocking/verifier_verdict_minter.py +6 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +32 -2
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -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/hooks/validators/run_all_validators.py +216 -4
- package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +3 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/scripts/CLAUDE.md +2 -0
- package/scripts/claude-chain.example.json +8 -0
- package/scripts/claude_chain_runner.py +400 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/team-advisor/SKILL.md +188 -0
- package/skills/team-advisor-refresh/SKILL.md +25 -0
- package/skills/usage-pause/SKILL.md +108 -0
- package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
- package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
- package/system-prompts/software-engineer.xml +3 -2
|
@@ -43,9 +43,11 @@ from verification_verdict_store import ( # noqa: E402
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
|
|
46
|
+
ALL_MERGE_HEAD_PROBE_FLAGS,
|
|
46
47
|
ALL_MERGE_TREE_COMMAND_FLAGS,
|
|
47
48
|
ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
|
|
48
49
|
ALL_UNIFIED_ZERO_DIFF_FLAGS,
|
|
50
|
+
ALL_UNMERGED_PATHS_DIFF_FLAGS,
|
|
49
51
|
CODE_RULES_SECTION_HEADER,
|
|
50
52
|
CODE_VERIFIER_SUBAGENT_TYPE,
|
|
51
53
|
DENY_REASON_LEAD,
|
|
@@ -145,9 +147,49 @@ def _run_trial_merge(repo_root: str, base_ref: str) -> tuple[int, str] | None:
|
|
|
145
147
|
return completed_process.returncode, completed_process.stdout
|
|
146
148
|
|
|
147
149
|
|
|
150
|
+
def _merge_in_progress(repo_root: str) -> bool:
|
|
151
|
+
"""Decide whether an unfinished merge is recorded in the work tree.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
repo_root: The repository top-level directory.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
True when ``MERGE_HEAD`` resolves — a merge is started but not yet
|
|
158
|
+
committed; False when no merge is in progress or git fails.
|
|
159
|
+
"""
|
|
160
|
+
return run_git(repo_root, *ALL_MERGE_HEAD_PROBE_FLAGS) is not None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _staged_merge_conflicts(repo_root: str) -> list[str] | None:
|
|
164
|
+
"""Return the unmerged paths of an in-progress merge from the index.
|
|
165
|
+
|
|
166
|
+
A merge whose conflicts are fully resolved and staged leaves zero unmerged
|
|
167
|
+
index entries, so the resolved state is committable and this returns an
|
|
168
|
+
empty list. A merge with conflicts still open returns those paths, so the
|
|
169
|
+
caller keeps denying until the resolution is staged.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
repo_root: The repository top-level directory.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The still-unmerged paths (empty when the resolution is fully staged),
|
|
176
|
+
or None when git fails — the caller fails OPEN on None.
|
|
177
|
+
"""
|
|
178
|
+
unmerged_text = run_git(repo_root, *ALL_UNMERGED_PATHS_DIFF_FLAGS)
|
|
179
|
+
if unmerged_text is None:
|
|
180
|
+
return None
|
|
181
|
+
return [each_line for each_line in unmerged_text.splitlines() if each_line]
|
|
182
|
+
|
|
183
|
+
|
|
148
184
|
def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
|
|
149
185
|
"""Return the files that conflict when HEAD trial-merges against the base.
|
|
150
186
|
|
|
187
|
+
While a merge is in progress the branch tip has not yet recorded the
|
|
188
|
+
resolution, so the trial-merge of the base against HEAD would re-report
|
|
189
|
+
conflicts the staged index already fixed. In that state the index is the
|
|
190
|
+
authority: an empty unmerged set is a committable resolution, and any
|
|
191
|
+
remaining unmerged path is a live conflict.
|
|
192
|
+
|
|
151
193
|
Args:
|
|
152
194
|
repo_root: The repository top-level directory.
|
|
153
195
|
base_ref: The base ref to trial-merge HEAD against.
|
|
@@ -158,6 +200,8 @@ def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
|
|
|
158
200
|
timeout, or an exit code that is neither clean nor conflict) — the
|
|
159
201
|
caller fails OPEN on None.
|
|
160
202
|
"""
|
|
203
|
+
if _merge_in_progress(repo_root):
|
|
204
|
+
return _staged_merge_conflicts(repo_root)
|
|
161
205
|
merge_outcome = _run_trial_merge(repo_root, base_ref)
|
|
162
206
|
if merge_outcome is None:
|
|
163
207
|
return None
|
|
@@ -86,6 +86,8 @@ TEST_FILE_SUFFIX = "_test.py"
|
|
|
86
86
|
CONFTEST_FILE_NAME = "conftest.py"
|
|
87
87
|
MINIMUM_STATUS_FIELD_COUNT = 2
|
|
88
88
|
ALL_FALLBACK_BASE_REFERENCES = ("origin/main", "origin/master")
|
|
89
|
+
DETACHED_HEAD_LABEL = "HEAD"
|
|
90
|
+
BRANCH_REMOTE_CONFIG_KEY_TEMPLATE = "branch.{branch_name}.remote"
|
|
89
91
|
ALL_TOOLING_STATE_PREFIXES = (
|
|
90
92
|
".claude/verification/",
|
|
91
93
|
".claude/worktrees/",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Pytest fixture that reproduces foreign-``config`` shadowing for the gate family.
|
|
2
|
+
|
|
3
|
+
The verified-commit gate hooks import shared constants as
|
|
4
|
+
``from config.verified_commit_constants import ...``. This fixture mirrors the
|
|
5
|
+
installed layout that breaks that import -- a real ``hooks/blocking`` package
|
|
6
|
+
beside a decoy ``hooks/config`` regular package -- and runs a caller-supplied
|
|
7
|
+
driver as a subprocess whose ``sys.path`` places the decoy ahead of
|
|
8
|
+
``blocking``, the ordering under which the wrong ``config`` wins resolution.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import textwrap
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
_BLOCKING_SOURCE_DIRECTORY = Path(__file__).resolve().parent
|
|
21
|
+
_HOOKS_CONSTANTS_SOURCE_DIRECTORY = _BLOCKING_SOURCE_DIRECTORY.parent / "hooks_constants"
|
|
22
|
+
_SUBPROCESS_TIMEOUT_SECONDS = 60
|
|
23
|
+
_MIRRORED_BLOCKING_FILE_NAMES = (
|
|
24
|
+
"verified_commit_gate.py",
|
|
25
|
+
"verification_verdict_store.py",
|
|
26
|
+
"verifier_verdict_minter.py",
|
|
27
|
+
"verdict_directory_write_blocker.py",
|
|
28
|
+
"verified_commit_config_bootstrap.py",
|
|
29
|
+
)
|
|
30
|
+
_DECOY_CONFIG_INIT_SOURCE = '"""Foreign config package that must never win resolution."""\n'
|
|
31
|
+
_DECOY_CONFIG_CONSTANTS_SOURCE = (
|
|
32
|
+
'"""Stale stand-in carrying none of the real gate constants."""\n\nIS_DECOY_CONFIG = True\n'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
37
|
+
"""Mirror the hook tree with a decoy ``config`` package beside ``blocking``.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
tmp_path: The directory the mirrored tree is built under.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The mirrored ``hooks`` directory and its ``blocking`` subdirectory.
|
|
44
|
+
"""
|
|
45
|
+
mirrored_hooks_directory = tmp_path / "hooks"
|
|
46
|
+
mirrored_blocking_directory = mirrored_hooks_directory / "blocking"
|
|
47
|
+
mirrored_blocking_directory.mkdir(parents=True)
|
|
48
|
+
|
|
49
|
+
for each_file_name in _MIRRORED_BLOCKING_FILE_NAMES:
|
|
50
|
+
source_file = _BLOCKING_SOURCE_DIRECTORY / each_file_name
|
|
51
|
+
if source_file.exists():
|
|
52
|
+
shutil.copy2(source_file, mirrored_blocking_directory / each_file_name)
|
|
53
|
+
|
|
54
|
+
shutil.copytree(
|
|
55
|
+
_BLOCKING_SOURCE_DIRECTORY / "config",
|
|
56
|
+
mirrored_blocking_directory / "config",
|
|
57
|
+
)
|
|
58
|
+
shutil.copytree(
|
|
59
|
+
_HOOKS_CONSTANTS_SOURCE_DIRECTORY,
|
|
60
|
+
mirrored_hooks_directory / "hooks_constants",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
decoy_config_directory = mirrored_hooks_directory / "config"
|
|
64
|
+
decoy_config_directory.mkdir()
|
|
65
|
+
(decoy_config_directory / "__init__.py").write_text(_DECOY_CONFIG_INIT_SOURCE, encoding="utf-8")
|
|
66
|
+
(decoy_config_directory / "verified_commit_constants.py").write_text(
|
|
67
|
+
_DECOY_CONFIG_CONSTANTS_SOURCE, encoding="utf-8"
|
|
68
|
+
)
|
|
69
|
+
return mirrored_hooks_directory, mirrored_blocking_directory
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def run_under_config_shadow(
|
|
74
|
+
tmp_path: Path,
|
|
75
|
+
) -> Callable[[str], subprocess.CompletedProcess[str]]:
|
|
76
|
+
"""Return a runner that executes a driver under a foreign-``config`` sys.path.
|
|
77
|
+
|
|
78
|
+
::
|
|
79
|
+
|
|
80
|
+
completed = run_under_config_shadow("import verified_commit_gate")
|
|
81
|
+
ok: completed.returncode == 0 once the bootstrap seeds the real module
|
|
82
|
+
flag: nonzero with ModuleNotFoundError while the decoy config wins
|
|
83
|
+
|
|
84
|
+
The runner prefixes the driver with a ``sys.path`` preamble that puts the
|
|
85
|
+
mirrored ``hooks`` directory (holding the decoy ``config``) ahead of
|
|
86
|
+
``blocking``, then runs the probe with the active interpreter.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
tmp_path: Pytest's per-test temporary directory, fixture-injected.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A function taking driver source text and returning the finished
|
|
93
|
+
subprocess result.
|
|
94
|
+
"""
|
|
95
|
+
mirrored_hooks_directory, mirrored_blocking_directory = _build_shadowed_hook_tree(tmp_path)
|
|
96
|
+
|
|
97
|
+
def run_driver(driver_body: str) -> subprocess.CompletedProcess[str]:
|
|
98
|
+
probe_script = tmp_path / "probe.py"
|
|
99
|
+
sys_path_preamble = textwrap.dedent(
|
|
100
|
+
f"""\
|
|
101
|
+
import sys
|
|
102
|
+
sys.path.insert(0, {str(mirrored_blocking_directory)!r})
|
|
103
|
+
sys.path.insert(0, {str(mirrored_hooks_directory)!r})
|
|
104
|
+
"""
|
|
105
|
+
)
|
|
106
|
+
probe_script.write_text(sys_path_preamble + textwrap.dedent(driver_body), encoding="utf-8")
|
|
107
|
+
return subprocess.run(
|
|
108
|
+
[sys.executable, str(probe_script)],
|
|
109
|
+
check=False,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
timeout=_SUBPROCESS_TIMEOUT_SECONDS,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return run_driver
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: force the Windows OpenSSH binary for NAS ssh/scp/sftp commands.
|
|
3
|
+
|
|
4
|
+
Root cause: Git Bash's MSYS ssh reads ``~/.ssh/id_ed25519`` as world-readable
|
|
5
|
+
through its ACL mapping, rejects the key as "bad permissions", offers no key, and
|
|
6
|
+
falls back to an interactive password prompt that hangs unattended agent sessions.
|
|
7
|
+
The Windows OpenSSH binary under ``System32/OpenSSH`` authenticates the same key
|
|
8
|
+
without prompting.
|
|
9
|
+
|
|
10
|
+
Detection strategy: this hook fires only on the Bash tool. The command is
|
|
11
|
+
shlex-tokenized (quotes removed), glued shell control operators are exploded off
|
|
12
|
+
the tokens, and the tokens are split into simple-command segments on ``&&`` /
|
|
13
|
+
``||`` / ``;`` / ``|&`` / ``|`` / ``&``. For each segment the effective leading program is
|
|
14
|
+
found by skipping ``VAR=value`` assignments and launcher wrappers (``timeout``,
|
|
15
|
+
``nohup``, ``nice``, ``stdbuf``, ``setsid``, ``env``) with their flags and duration
|
|
16
|
+
arguments. A segment is evaluated only when its leading program's basename is
|
|
17
|
+
``ssh``/``scp``/``sftp`` (with or without a ``.exe`` suffix) AND a token in that same
|
|
18
|
+
segment holds the NAS address ``192.168.1.100`` as a connection host — bare,
|
|
19
|
+
``user@host``, or a ``host:path`` target. The address inside a remote-command
|
|
20
|
+
argument or a remote path component on another host does not count.
|
|
21
|
+
|
|
22
|
+
An evaluated segment is DENIED with the binary-swap message when its leading program
|
|
23
|
+
is a bare ssh-family word rather than a path ending in ``OpenSSH/ssh.exe`` (or
|
|
24
|
+
``scp.exe`` / ``sftp.exe``). An evaluated segment whose leading program is that full
|
|
25
|
+
OpenSSH path is DENIED with the batch-mode message when no token in the segment
|
|
26
|
+
carries ``BatchMode=yes``, so an authentication regression fails loudly rather than
|
|
27
|
+
prompting. Everything else is allowed: ssh to any other host, the full OpenSSH path
|
|
28
|
+
with ``BatchMode=yes``, non-Bash tools, a command that only mentions the address
|
|
29
|
+
without an ssh-family leading program, and a command shlex cannot tokenize.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import shlex
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
38
|
+
if _hooks_dir not in sys.path:
|
|
39
|
+
sys.path.insert(0, _hooks_dir)
|
|
40
|
+
|
|
41
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
42
|
+
from hooks_constants.nas_ssh_binary_enforcer_constants import ( # noqa: E402
|
|
43
|
+
ALL_LAUNCHER_WRAPPER_COMMANDS,
|
|
44
|
+
ALL_OPENSSH_BINARY_PATH_SUFFIXES,
|
|
45
|
+
ALL_SHELL_CONTROL_OPERATOR_TOKENS,
|
|
46
|
+
ALL_SSH_FAMILY_COMMAND_BASENAMES,
|
|
47
|
+
BARE_SSH_BINARY_MESSAGE,
|
|
48
|
+
BASH_TOOL_NAME,
|
|
49
|
+
BATCH_MODE_PATTERN,
|
|
50
|
+
CONTROL_OPERATOR_SPLIT_PATTERN,
|
|
51
|
+
LAUNCHER_DURATION_PATTERN,
|
|
52
|
+
LEADING_ASSIGNMENT_PATTERN,
|
|
53
|
+
MISSING_BATCH_MODE_MESSAGE,
|
|
54
|
+
NAS_HOST_TOKEN_PATTERN,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _token_basename(token: str) -> str:
|
|
59
|
+
return token.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_openssh_binary_path(token: str) -> bool:
|
|
63
|
+
normalized_token = token.replace("\\", "/").lower()
|
|
64
|
+
return any(
|
|
65
|
+
normalized_token.endswith(each_suffix)
|
|
66
|
+
for each_suffix in ALL_OPENSSH_BINARY_PATH_SUFFIXES
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _split_into_segments(all_command_tokens: list[str]) -> list[list[str]]:
|
|
71
|
+
all_exploded_tokens: list[str] = []
|
|
72
|
+
for each_token in all_command_tokens:
|
|
73
|
+
for each_fragment in CONTROL_OPERATOR_SPLIT_PATTERN.split(each_token):
|
|
74
|
+
if each_fragment:
|
|
75
|
+
all_exploded_tokens.append(each_fragment)
|
|
76
|
+
all_segments: list[list[str]] = []
|
|
77
|
+
current_segment: list[str] = []
|
|
78
|
+
for each_token in all_exploded_tokens:
|
|
79
|
+
if each_token in ALL_SHELL_CONTROL_OPERATOR_TOKENS:
|
|
80
|
+
all_segments.append(current_segment)
|
|
81
|
+
current_segment = []
|
|
82
|
+
continue
|
|
83
|
+
current_segment.append(each_token)
|
|
84
|
+
all_segments.append(current_segment)
|
|
85
|
+
return all_segments
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _effective_leading_program(all_segment_tokens: list[str]) -> str | None:
|
|
89
|
+
has_seen_launcher_wrapper = False
|
|
90
|
+
for each_token in all_segment_tokens:
|
|
91
|
+
if LEADING_ASSIGNMENT_PATTERN.match(each_token):
|
|
92
|
+
continue
|
|
93
|
+
if _token_basename(each_token) in ALL_LAUNCHER_WRAPPER_COMMANDS:
|
|
94
|
+
has_seen_launcher_wrapper = True
|
|
95
|
+
continue
|
|
96
|
+
if has_seen_launcher_wrapper and (
|
|
97
|
+
each_token.startswith("-") or LAUNCHER_DURATION_PATTERN.match(each_token)
|
|
98
|
+
):
|
|
99
|
+
continue
|
|
100
|
+
return each_token
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _segment_references_nas(all_segment_tokens: list[str]) -> bool:
|
|
105
|
+
return any(NAS_HOST_TOKEN_PATTERN.search(each_token) for each_token in all_segment_tokens)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _segment_carries_batch_mode(all_segment_tokens: list[str]) -> bool:
|
|
109
|
+
return any(BATCH_MODE_PATTERN.search(each_token) for each_token in all_segment_tokens)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _find_nas_ssh_violation(command: str) -> str | None:
|
|
113
|
+
"""Return the deny message for a NAS ssh-family command, or None to allow.
|
|
114
|
+
|
|
115
|
+
::
|
|
116
|
+
|
|
117
|
+
ssh -p 9222 jon@192.168.1.100 "ls" flag: bare-binary message
|
|
118
|
+
"…/OpenSSH/ssh.exe" -p 9222 jon@192.168… flag: missing-batch-mode message
|
|
119
|
+
"…/OpenSSH/ssh.exe" -o BatchMode=yes …NAS ok: None
|
|
120
|
+
ssh jon@example.com "ls" ok: None
|
|
121
|
+
|
|
122
|
+
Tokenizes the command, splits it into simple-command segments, and evaluates
|
|
123
|
+
each segment whose leading program is an ssh-family word AND whose tokens carry
|
|
124
|
+
the NAS address. A bare ssh-family leader returns the binary-swap message; the
|
|
125
|
+
full OpenSSH path leader without a ``BatchMode=yes`` token returns the batch-mode
|
|
126
|
+
message. Returns None when shlex cannot tokenize the command.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
command: The raw Bash command string from the tool input.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The deny message string when a segment violates the NAS ssh policy, else None.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
all_command_tokens = shlex.split(command)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
for each_segment in _split_into_segments(all_command_tokens):
|
|
139
|
+
leading_program = _effective_leading_program(each_segment)
|
|
140
|
+
if leading_program is None:
|
|
141
|
+
continue
|
|
142
|
+
if _token_basename(leading_program) not in ALL_SSH_FAMILY_COMMAND_BASENAMES:
|
|
143
|
+
continue
|
|
144
|
+
if not _segment_references_nas(each_segment):
|
|
145
|
+
continue
|
|
146
|
+
if not _is_openssh_binary_path(leading_program):
|
|
147
|
+
return BARE_SSH_BINARY_MESSAGE
|
|
148
|
+
if not _segment_carries_batch_mode(each_segment):
|
|
149
|
+
return MISSING_BATCH_MODE_MESSAGE
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> None:
|
|
154
|
+
try:
|
|
155
|
+
hook_input = json.load(sys.stdin)
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
sys.exit(0)
|
|
158
|
+
|
|
159
|
+
tool_name = hook_input.get("tool_name", "")
|
|
160
|
+
if tool_name != BASH_TOOL_NAME:
|
|
161
|
+
sys.exit(0)
|
|
162
|
+
|
|
163
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
164
|
+
if not command:
|
|
165
|
+
sys.exit(0)
|
|
166
|
+
|
|
167
|
+
deny_reason = _find_nas_ssh_violation(command)
|
|
168
|
+
if deny_reason is None:
|
|
169
|
+
sys.exit(0)
|
|
170
|
+
|
|
171
|
+
deny_payload = {
|
|
172
|
+
"hookSpecificOutput": {
|
|
173
|
+
"hookEventName": "PreToolUse",
|
|
174
|
+
"permissionDecision": "deny",
|
|
175
|
+
"permissionDecisionReason": deny_reason,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
log_hook_block(
|
|
179
|
+
calling_hook_name="nas_ssh_binary_enforcer.py",
|
|
180
|
+
hook_event="PreToolUse",
|
|
181
|
+
block_reason=deny_reason,
|
|
182
|
+
tool_name=tool_name,
|
|
183
|
+
offending_input_preview=command,
|
|
184
|
+
)
|
|
185
|
+
print(json.dumps(deny_payload))
|
|
186
|
+
sys.stdout.flush()
|
|
187
|
+
sys.exit(0)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
"""PreToolUse hook gating gh pr create/edit/comment
|
|
2
|
-
|
|
3
|
-
Reads a PreToolUse JSON payload on stdin,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
"""PreToolUse hook gating gh pr create/edit/comment bodies and gh pr ready.
|
|
2
|
+
|
|
3
|
+
Reads a PreToolUse JSON payload on stdin, audits body-carrying gh pr
|
|
4
|
+
create/edit/comment invocations against the Anthropic claude-code style
|
|
5
|
+
rules plus the proof-of-work audit for proof-shaped comment bodies, and
|
|
6
|
+
denies gh pr ready while the PR carries no passing proof comment.
|
|
7
|
+
Readability-management CLI flags short-circuit the stdin path to adjust
|
|
8
|
+
the persisted readability state.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import json
|
|
@@ -32,6 +33,12 @@ from blocking.pr_description_pr_number import ( # noqa: E402
|
|
|
32
33
|
_command_carries_body_flag,
|
|
33
34
|
_extract_pr_number_from_command,
|
|
34
35
|
)
|
|
36
|
+
from blocking.pr_description_proof_of_work import ( # noqa: E402
|
|
37
|
+
audit_proof_comment_body,
|
|
38
|
+
evaluate_pr_ready_gate,
|
|
39
|
+
is_pr_ready_command,
|
|
40
|
+
is_proof_shaped_body,
|
|
41
|
+
)
|
|
35
42
|
from blocking.pr_description_readability import ( # noqa: E402
|
|
36
43
|
_build_readability_escape_hatch_message,
|
|
37
44
|
_dispatch_cli_flag,
|
|
@@ -131,6 +138,28 @@ def validate_pr_body(body: str, pr_number: int | None = None) -> list[str]:
|
|
|
131
138
|
return violations
|
|
132
139
|
|
|
133
140
|
|
|
141
|
+
def _emit_denial(denial_reason: str) -> None:
|
|
142
|
+
"""Print the PreToolUse deny payload for a violation and log the block.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
denial_reason: The permissionDecisionReason text for the deny payload.
|
|
146
|
+
"""
|
|
147
|
+
denial_payload = {
|
|
148
|
+
"hookSpecificOutput": {
|
|
149
|
+
"hookEventName": "PreToolUse",
|
|
150
|
+
"permissionDecision": "deny",
|
|
151
|
+
"permissionDecisionReason": denial_reason,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
log_hook_block(
|
|
155
|
+
calling_hook_name="pr_description_enforcer.py",
|
|
156
|
+
hook_event="PreToolUse",
|
|
157
|
+
block_reason=denial_reason,
|
|
158
|
+
)
|
|
159
|
+
print(json.dumps(denial_payload))
|
|
160
|
+
sys.stdout.flush()
|
|
161
|
+
|
|
162
|
+
|
|
134
163
|
def main() -> None:
|
|
135
164
|
for each_argv_token in sys.argv[1:]:
|
|
136
165
|
if each_argv_token in ALL_READABILITY_CLI_FLAG_TOKENS:
|
|
@@ -154,6 +183,12 @@ def main() -> None:
|
|
|
154
183
|
if not command:
|
|
155
184
|
sys.exit(0)
|
|
156
185
|
|
|
186
|
+
if is_pr_ready_command(command):
|
|
187
|
+
ready_denial_reason = evaluate_pr_ready_gate(command)
|
|
188
|
+
if ready_denial_reason is not None:
|
|
189
|
+
_emit_denial(ready_denial_reason)
|
|
190
|
+
sys.exit(0)
|
|
191
|
+
|
|
157
192
|
has_any_body_flag = _command_carries_body_flag(command)
|
|
158
193
|
is_pr_create = "gh pr create" in command and has_any_body_flag
|
|
159
194
|
is_pr_edit = "gh pr edit" in command and has_any_body_flag
|
|
@@ -173,28 +208,17 @@ def main() -> None:
|
|
|
173
208
|
|
|
174
209
|
violations = validate_pr_body(body, pr_number=extracted_pr_number)
|
|
175
210
|
|
|
211
|
+
if is_pr_comment and is_proof_shaped_body(body):
|
|
212
|
+
violations.extend(audit_proof_comment_body(body, extracted_pr_number))
|
|
213
|
+
|
|
176
214
|
if violations:
|
|
177
215
|
violation_list = "; ".join(violations)
|
|
178
216
|
pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
|
|
179
|
-
|
|
217
|
+
_emit_denial(
|
|
180
218
|
f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
|
|
181
219
|
f"Rewrite the body yourself in Anthropic claude-code style. "
|
|
182
220
|
f"Guide:{pr_guide_reference}"
|
|
183
221
|
)
|
|
184
|
-
denial_payload = {
|
|
185
|
-
"hookSpecificOutput": {
|
|
186
|
-
"hookEventName": "PreToolUse",
|
|
187
|
-
"permissionDecision": "deny",
|
|
188
|
-
"permissionDecisionReason": denial_reason,
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
log_hook_block(
|
|
192
|
-
calling_hook_name="pr_description_enforcer.py",
|
|
193
|
-
hook_event="PreToolUse",
|
|
194
|
-
block_reason=denial_reason,
|
|
195
|
-
)
|
|
196
|
-
print(json.dumps(denial_payload))
|
|
197
|
-
sys.stdout.flush()
|
|
198
222
|
|
|
199
223
|
sys.exit(0)
|
|
200
224
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Reports whether a captured shell command carries any body or body-file flag,
|
|
4
4
|
and extracts the positional PR number (bare integer or GitHub PR URL) from a
|
|
5
|
-
gh pr edit/comment command while skipping value-taking flags and their
|
|
5
|
+
gh pr edit/comment/ready command while skipping value-taking flags and their
|
|
6
|
+
values.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import re
|
|
@@ -29,6 +30,7 @@ from blocking._gh_body_arg_utils import ( # noqa: E402
|
|
|
29
30
|
strip_surrounding_quotes,
|
|
30
31
|
)
|
|
31
32
|
from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
33
|
+
ALL_PR_NUMBER_BEARING_GH_PR_SUBCOMMANDS,
|
|
32
34
|
GH_PR_COMMAND_MIN_TOKEN_COUNT,
|
|
33
35
|
)
|
|
34
36
|
|
|
@@ -59,7 +61,7 @@ def _resolve_positional_pr_number(token: str) -> int | None:
|
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
def _extract_pr_number_from_command(command: str) -> int | None:
|
|
62
|
-
"""Return the PR number positional argument from a `gh pr edit|comment` command.
|
|
64
|
+
"""Return the PR number positional argument from a `gh pr edit|comment|ready` command.
|
|
63
65
|
|
|
64
66
|
Skips value-taking non-body flags (and their value tokens) so that ``--repo owner/r``
|
|
65
67
|
pairs do not consume the trailing PR number. Accepts both a bare integer literal
|
|
@@ -83,7 +85,7 @@ def _extract_pr_number_from_command(command: str) -> int | None:
|
|
|
83
85
|
if all_tokens[0] != "gh" or all_tokens[1] != "pr":
|
|
84
86
|
return None
|
|
85
87
|
subcommand_token = all_tokens[2]
|
|
86
|
-
if subcommand_token not in
|
|
88
|
+
if subcommand_token not in ALL_PR_NUMBER_BEARING_GH_PR_SUBCOMMANDS:
|
|
87
89
|
return None
|
|
88
90
|
all_value_taking_bare_flags: frozenset[str] = (
|
|
89
91
|
non_body_value_flags | all_body_flags | {body_file_flag, body_file_short_flag}
|