claude-dev-env 2.0.1 → 2.1.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 +1 -1
- package/agents/clean-coder.md +31 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/bin/install.test.mjs +2 -2
- package/docs/CLAUDE.md +6 -3
- package/docs/CODE_RULES.md +5 -1
- package/docs/agent-spawn-protocol.md +39 -0
- package/docs/nas-ssh-invocation.md +23 -0
- package/docs/worker-completion-gate.md +33 -0
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_constants.py +8 -1
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verification_verdict_store.py +12 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +3 -3
- package/rules/agent-spawn-protocol.md +5 -43
- package/rules/code-standards.md +1 -36
- package/rules/env-var-table-code-drift.md +2 -21
- package/rules/hook-prose-matches-detector.md +5 -16
- package/rules/nas-ssh-invocation.md +3 -15
- package/rules/no-historical-clutter.md +7 -49
- package/rules/no-inline-destructive-literals.md +3 -5
- package/rules/package-inventory-stale-entry.md +7 -32
- package/rules/re-stage-before-commit.md +6 -23
- package/rules/shell-invocation-policy.md +1 -1
- package/rules/vault-context.md +3 -3
- package/rules/verified-commit-gate-skip.md +28 -0
- package/rules/workers-done-before-complete.md +2 -30
- package/scripts/claude_chain_runner.py +39 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
- package/scripts/test_claude_chain_runner.py +112 -0
- package/scripts/test_grok_headless_runner.py +0 -1
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +1 -0
- package/skills/autoconverge/SKILL.md +310 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +5 -7
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/everything-search/SKILL.md +7 -1
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/orchestrator/SKILL.md +161 -147
- package/skills/pr-converge/SKILL.md +277 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
- package/system-prompts/CLAUDE.md +3 -3
- package/docs/agents-md-alignment-plan.md +0 -123
- package/docs/emotion-informed-prompt-design.md +0 -362
- package/rules/es-exe-file-search.md +0 -17
|
@@ -132,3 +132,149 @@ def test_main_reads_cwd_from_top_level_payload(
|
|
|
132
132
|
hook_module.main()
|
|
133
133
|
|
|
134
134
|
assert captured_cwd == [worktree_path]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _capture_convergence_identity(
|
|
138
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
139
|
+
tmp_path: pathlib.Path,
|
|
140
|
+
command: str,
|
|
141
|
+
cwd_owner_repo: tuple[str, str],
|
|
142
|
+
) -> tuple[str, str, int]:
|
|
143
|
+
convergence_script = (
|
|
144
|
+
tmp_path / ".claude" / "skills" / "pr-converge" / "scripts" / "check_convergence.py"
|
|
145
|
+
)
|
|
146
|
+
convergence_script.parent.mkdir(parents=True)
|
|
147
|
+
convergence_script.write_text("")
|
|
148
|
+
monkeypatch.setattr(hook_module.Path, "home", classmethod(lambda _cls: tmp_path))
|
|
149
|
+
monkeypatch.setattr(hook_module, "_resolve_owner_repo", lambda _cwd: cwd_owner_repo)
|
|
150
|
+
|
|
151
|
+
captured_identity: list[tuple[str, str, int]] = []
|
|
152
|
+
|
|
153
|
+
def fake_check(
|
|
154
|
+
_script: str, owner: str, repo: str, pr_number: int, _cwd: str | None
|
|
155
|
+
) -> subprocess.CompletedProcess[str]:
|
|
156
|
+
captured_identity.append((owner, repo, pr_number))
|
|
157
|
+
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
158
|
+
|
|
159
|
+
monkeypatch.setattr(hook_module, "_run_convergence_check", fake_check)
|
|
160
|
+
|
|
161
|
+
payload = {
|
|
162
|
+
"tool_name": "Bash",
|
|
163
|
+
"cwd": str(tmp_path / "worktree"),
|
|
164
|
+
"tool_input": {"command": command},
|
|
165
|
+
}
|
|
166
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
167
|
+
with pytest.raises(SystemExit):
|
|
168
|
+
hook_module.main()
|
|
169
|
+
return captured_identity[0]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_repo_flag_overrides_cwd_repo(
|
|
173
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
174
|
+
) -> None:
|
|
175
|
+
identity = _capture_convergence_identity(
|
|
176
|
+
monkeypatch,
|
|
177
|
+
tmp_path,
|
|
178
|
+
"gh pr ready 161 --repo sample-owner/target-repo",
|
|
179
|
+
("cwd-owner", "cwd-repo"),
|
|
180
|
+
)
|
|
181
|
+
assert identity == ("sample-owner", "target-repo", 161)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_pr_url_yields_repo_and_number(
|
|
185
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
186
|
+
) -> None:
|
|
187
|
+
identity = _capture_convergence_identity(
|
|
188
|
+
monkeypatch,
|
|
189
|
+
tmp_path,
|
|
190
|
+
"gh pr ready https://github.com/sample-owner/target-repo/pull/161",
|
|
191
|
+
("cwd-owner", "cwd-repo"),
|
|
192
|
+
)
|
|
193
|
+
assert identity == ("sample-owner", "target-repo", 161)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_bare_pr_ready_resolves_repo_from_cwd(
|
|
197
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
198
|
+
) -> None:
|
|
199
|
+
identity = _capture_convergence_identity(
|
|
200
|
+
monkeypatch,
|
|
201
|
+
tmp_path,
|
|
202
|
+
"gh pr ready 418",
|
|
203
|
+
("cwd-owner", "cwd-repo"),
|
|
204
|
+
)
|
|
205
|
+
assert identity == ("cwd-owner", "cwd-repo", 418)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_parse_repo_flag_space_form() -> None:
|
|
209
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 --repo sample-owner/target-repo") == (
|
|
210
|
+
"sample-owner",
|
|
211
|
+
"target-repo",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_parse_repo_flag_equals_form() -> None:
|
|
216
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 --repo=sample-owner/target-repo") == (
|
|
217
|
+
"sample-owner",
|
|
218
|
+
"target-repo",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_parse_repo_flag_short_alias() -> None:
|
|
223
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 -R sample-owner/target-repo") == (
|
|
224
|
+
"sample-owner",
|
|
225
|
+
"target-repo",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_parse_repo_flag_absent_returns_none() -> None:
|
|
230
|
+
assert hook_module._parse_repo_flag("gh pr ready 418") is None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_parse_pr_url_yields_owner_repo_number() -> None:
|
|
234
|
+
assert hook_module._parse_pr_url(
|
|
235
|
+
"gh pr ready https://github.com/sample-owner/target-repo/pull/161"
|
|
236
|
+
) == ("sample-owner", "target-repo", 161)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_parse_pr_url_absent_returns_none() -> None:
|
|
240
|
+
assert hook_module._parse_pr_url("gh pr ready 418") is None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_ready_segment_clips_at_command_separator() -> None:
|
|
244
|
+
assert (
|
|
245
|
+
hook_module._ready_command_segment(
|
|
246
|
+
"gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo"
|
|
247
|
+
)
|
|
248
|
+
== "gh pr ready 161 "
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_chained_repo_flag_does_not_misbind_the_gate(
|
|
253
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
254
|
+
) -> None:
|
|
255
|
+
identity = _capture_convergence_identity(
|
|
256
|
+
monkeypatch,
|
|
257
|
+
tmp_path,
|
|
258
|
+
"gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo --body-file note.md",
|
|
259
|
+
("cwd-owner", "cwd-repo"),
|
|
260
|
+
)
|
|
261
|
+
assert identity == ("cwd-owner", "cwd-repo", 161)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_chained_pr_url_does_not_misbind_the_gate(
|
|
265
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
266
|
+
) -> None:
|
|
267
|
+
identity = _capture_convergence_identity(
|
|
268
|
+
monkeypatch,
|
|
269
|
+
tmp_path,
|
|
270
|
+
"gh pr ready 161 && echo https://github.com/other-owner/other-repo/pull/5",
|
|
271
|
+
("cwd-owner", "cwd-repo"),
|
|
272
|
+
)
|
|
273
|
+
assert identity == ("cwd-owner", "cwd-repo", 161)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_ready_segment_skips_a_leading_undo_invocation() -> None:
|
|
277
|
+
assert (
|
|
278
|
+
hook_module._ready_command_segment("gh pr ready --undo && gh pr ready 161")
|
|
279
|
+
== "gh pr ready 161"
|
|
280
|
+
)
|
|
@@ -14,6 +14,8 @@ import sys
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from unittest.mock import patch
|
|
16
16
|
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
17
19
|
HOOK_SCRIPT_PATH = Path(__file__).parent / "plain_language_blocker.py"
|
|
18
20
|
_HOOKS_DIR = str(Path(__file__).resolve().parent)
|
|
19
21
|
_HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
|
|
@@ -185,6 +187,19 @@ def test_write_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
|
|
|
185
187
|
assert _decision_from(completed) == "deny"
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
def test_write_markdown_at_ephemeral_path_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
191
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
192
|
+
payload = {
|
|
193
|
+
"tool_name": "Write",
|
|
194
|
+
"tool_input": {
|
|
195
|
+
"file_path": "/tmp/notes.md",
|
|
196
|
+
"content": "This guide explains how to utilize the new cache layer.",
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
completed = _run_hook_with_payload(payload)
|
|
200
|
+
assert _decision_from(completed) is None
|
|
201
|
+
|
|
202
|
+
|
|
188
203
|
def test_write_non_markdown_is_ignored(tmp_path: Path) -> None:
|
|
189
204
|
target = tmp_path / "notes.txt"
|
|
190
205
|
payload = {
|
|
@@ -7,6 +7,8 @@ import sys
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from unittest.mock import patch
|
|
9
9
|
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
10
12
|
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
11
13
|
_HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
|
|
12
14
|
if _BLOCKING_DIR not in sys.path:
|
|
@@ -207,6 +209,19 @@ def test_detects_now_uses_in_markdown():
|
|
|
207
209
|
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
208
210
|
|
|
209
211
|
|
|
212
|
+
def test_markdown_at_ephemeral_path_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
213
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
214
|
+
result = _run_hook(
|
|
215
|
+
"Write",
|
|
216
|
+
{
|
|
217
|
+
"file_path": "/tmp/api.md",
|
|
218
|
+
"content": VIOLATION_MD_INSTEAD,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
assert result.returncode == 0
|
|
222
|
+
assert result.stdout == ""
|
|
223
|
+
|
|
224
|
+
|
|
210
225
|
def test_detects_edit_new_string():
|
|
211
226
|
result = _run_hook(
|
|
212
227
|
"Edit",
|
|
@@ -211,6 +211,18 @@ def test_corrective_message_scopes_comment_exemption_to_python() -> None:
|
|
|
211
211
|
assert "comment-, and test-only surfaces are exempt" not in lowered_message
|
|
212
212
|
|
|
213
213
|
|
|
214
|
+
def test_corrective_message_names_worktree_surface_keying() -> None:
|
|
215
|
+
lowered_message = CORRECTIVE_MESSAGE.lower()
|
|
216
|
+
assert "work tree" in lowered_message
|
|
217
|
+
assert "byte-identical" in lowered_message
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_corrective_message_gives_cross_worktree_remedy() -> None:
|
|
221
|
+
lowered_message = CORRECTIVE_MESSAGE.lower()
|
|
222
|
+
assert "run this command from the work tree" in lowered_message
|
|
223
|
+
assert "verify this surface here" in lowered_message
|
|
224
|
+
|
|
225
|
+
|
|
214
226
|
def test_untracked_claude_production_hook_is_gated(tmp_path: pathlib.Path) -> None:
|
|
215
227
|
work_dir = _make_repo_with_origin(tmp_path)
|
|
216
228
|
new_hook_dir = work_dir / ".claude" / "hooks" / "blocking"
|
|
@@ -31,6 +31,24 @@ def test_register_binds_dotted_name_to_sibling_constants_file(
|
|
|
31
31
|
assert detached_head_line == "HEAD"
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def test_register_binds_gate_output_constants_module(
|
|
35
|
+
run_under_config_shadow: RunUnderConfigShadow,
|
|
36
|
+
) -> None:
|
|
37
|
+
completed_probe = run_under_config_shadow(
|
|
38
|
+
"import verified_commit_config_bootstrap as bootstrap\n"
|
|
39
|
+
"bootstrap.register_verified_commit_constants()\n"
|
|
40
|
+
"import config.verified_commit_gate_output_constants as loaded\n"
|
|
41
|
+
"print(loaded.__file__)\n"
|
|
42
|
+
"print(loaded.DENY_PERMISSION_DECISION)\n"
|
|
43
|
+
)
|
|
44
|
+
assert completed_probe.returncode == 0, completed_probe.stderr
|
|
45
|
+
resolved_file_line, deny_decision_line = completed_probe.stdout.splitlines()[:2]
|
|
46
|
+
assert resolved_file_line.replace("\\", "/").endswith(
|
|
47
|
+
"blocking/config/verified_commit_gate_output_constants.py"
|
|
48
|
+
)
|
|
49
|
+
assert deny_decision_line == "deny"
|
|
50
|
+
|
|
51
|
+
|
|
34
52
|
def test_register_leaves_an_already_cached_module_untouched(
|
|
35
53
|
run_under_config_shadow: RunUnderConfigShadow,
|
|
36
54
|
) -> None:
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Gate behavior for docs-only deltas and the cross-work-tree deny message.
|
|
2
|
+
|
|
3
|
+
Each test builds a real git repository with a real origin remote and drives
|
|
4
|
+
``deny_reason_for_directory`` — the same decision the verified_commit_gate hook
|
|
5
|
+
runs — so the docs-only exemption, the docs-after-verified-code block, and the
|
|
6
|
+
denial message's work-tree keying are asserted against live git state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import pathlib
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
17
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
19
|
+
|
|
20
|
+
gate_spec = importlib.util.spec_from_file_location(
|
|
21
|
+
"verified_commit_gate",
|
|
22
|
+
_HOOK_DIR / "verified_commit_gate.py",
|
|
23
|
+
)
|
|
24
|
+
assert gate_spec is not None
|
|
25
|
+
assert gate_spec.loader is not None
|
|
26
|
+
gate_module = importlib.util.module_from_spec(gate_spec)
|
|
27
|
+
gate_spec.loader.exec_module(gate_module)
|
|
28
|
+
deny_reason_for_directory = gate_module.deny_reason_for_directory
|
|
29
|
+
|
|
30
|
+
store_spec = importlib.util.spec_from_file_location(
|
|
31
|
+
"verification_verdict_store",
|
|
32
|
+
_HOOK_DIR / "verification_verdict_store.py",
|
|
33
|
+
)
|
|
34
|
+
assert store_spec is not None
|
|
35
|
+
assert store_spec.loader is not None
|
|
36
|
+
store_module = importlib.util.module_from_spec(store_spec)
|
|
37
|
+
store_spec.loader.exec_module(store_module)
|
|
38
|
+
resolve_merge_base = store_module.resolve_merge_base
|
|
39
|
+
branch_surface_manifest = store_module.branch_surface_manifest
|
|
40
|
+
manifest_sha256 = store_module.manifest_sha256
|
|
41
|
+
write_verdict = store_module.write_verdict
|
|
42
|
+
|
|
43
|
+
PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
44
|
+
BEHAVIORAL_EDIT_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_git(repo_dir: pathlib.Path, *git_arguments: str) -> None:
|
|
48
|
+
subprocess.run(
|
|
49
|
+
["git", "-C", str(repo_dir), *git_arguments],
|
|
50
|
+
check=True,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
|
|
57
|
+
home_text = str(fake_home)
|
|
58
|
+
monkeypatch.setenv("HOME", home_text)
|
|
59
|
+
monkeypatch.setenv("USERPROFILE", home_text)
|
|
60
|
+
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
61
|
+
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _empty_hooks_dir(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
65
|
+
hooks_dir = tmp_path / "nohooks"
|
|
66
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
67
|
+
return hooks_dir
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _empty_transcript(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
71
|
+
transcript_path = tmp_path / "projects" / "demo" / "sess1.jsonl"
|
|
72
|
+
transcript_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
transcript_path.write_text("", encoding="utf-8")
|
|
74
|
+
return transcript_path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _init_pushed_repo(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
78
|
+
origin_dir = tmp_path / "origin.git"
|
|
79
|
+
work_dir = tmp_path / "work"
|
|
80
|
+
work_dir.mkdir()
|
|
81
|
+
subprocess.run(
|
|
82
|
+
["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
|
|
83
|
+
check=True,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
)
|
|
87
|
+
_run_git(work_dir, "init", "--initial-branch=main")
|
|
88
|
+
_run_git(work_dir, "config", "user.email", "tests@example.com")
|
|
89
|
+
_run_git(work_dir, "config", "user.name", "Docs Delta Tests")
|
|
90
|
+
_run_git(work_dir, "config", "core.hooksPath", str(_empty_hooks_dir(tmp_path)))
|
|
91
|
+
(work_dir / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
|
|
92
|
+
(work_dir / "README.md").write_text("# base\n", encoding="utf-8")
|
|
93
|
+
_run_git(work_dir, "add", "-A")
|
|
94
|
+
_run_git(work_dir, "commit", "-m", "base")
|
|
95
|
+
_run_git(work_dir, "remote", "add", "origin", str(origin_dir))
|
|
96
|
+
_run_git(work_dir, "push", "-u", "origin", "main")
|
|
97
|
+
return work_dir
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _make_docs_only_repo(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
101
|
+
work_dir = _init_pushed_repo(tmp_path)
|
|
102
|
+
(work_dir / "README.md").write_text("# base\n\nUpdated docs.\n", encoding="utf-8")
|
|
103
|
+
return work_dir
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _make_behavioral_code_repo(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
107
|
+
work_dir = _init_pushed_repo(tmp_path)
|
|
108
|
+
(work_dir / "app.py").write_text(BEHAVIORAL_EDIT_SOURCE, encoding="utf-8")
|
|
109
|
+
return work_dir
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _live_surface_hash(work_dir: pathlib.Path) -> str:
|
|
113
|
+
merge_base_sha = resolve_merge_base(str(work_dir))
|
|
114
|
+
assert merge_base_sha is not None
|
|
115
|
+
surface_manifest_text = branch_surface_manifest(str(work_dir), merge_base_sha)
|
|
116
|
+
assert surface_manifest_text is not None
|
|
117
|
+
return manifest_sha256(surface_manifest_text)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_pure_docs_only_branch_is_allowed_without_verdict(
|
|
121
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
122
|
+
) -> None:
|
|
123
|
+
fake_home = tmp_path / "home"
|
|
124
|
+
fake_home.mkdir()
|
|
125
|
+
_isolate_home(monkeypatch, fake_home)
|
|
126
|
+
work_dir = _make_docs_only_repo(tmp_path)
|
|
127
|
+
transcript_path = _empty_transcript(tmp_path)
|
|
128
|
+
assert deny_reason_for_directory(str(work_dir), str(transcript_path)) is None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_readme_change_after_verified_code_commit_is_denied(
|
|
132
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
133
|
+
) -> None:
|
|
134
|
+
fake_home = tmp_path / "home"
|
|
135
|
+
fake_home.mkdir()
|
|
136
|
+
_isolate_home(monkeypatch, fake_home)
|
|
137
|
+
work_dir = _make_behavioral_code_repo(tmp_path)
|
|
138
|
+
code_surface_hash = _live_surface_hash(work_dir)
|
|
139
|
+
write_verdict(str(work_dir), code_surface_hash, True, [], "agent-x")
|
|
140
|
+
transcript_path = _empty_transcript(tmp_path)
|
|
141
|
+
assert deny_reason_for_directory(str(work_dir), str(transcript_path)) is None
|
|
142
|
+
_run_git(work_dir, "add", "-A")
|
|
143
|
+
_run_git(work_dir, "commit", "-m", "verified code change")
|
|
144
|
+
(work_dir / "README.md").write_text("# added after verification\n", encoding="utf-8")
|
|
145
|
+
deny_reason = deny_reason_for_directory(str(work_dir), str(transcript_path))
|
|
146
|
+
assert deny_reason is not None
|
|
147
|
+
assert "VERIFIED_COMMIT_GATE" in deny_reason
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_readme_added_beside_unverified_code_is_denied(
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
152
|
+
) -> None:
|
|
153
|
+
fake_home = tmp_path / "home"
|
|
154
|
+
fake_home.mkdir()
|
|
155
|
+
_isolate_home(monkeypatch, fake_home)
|
|
156
|
+
work_dir = _make_behavioral_code_repo(tmp_path)
|
|
157
|
+
(work_dir / "README.md").write_text("# docs beside code\n", encoding="utf-8")
|
|
158
|
+
transcript_path = _empty_transcript(tmp_path)
|
|
159
|
+
deny_reason = deny_reason_for_directory(str(work_dir), str(transcript_path))
|
|
160
|
+
assert deny_reason is not None
|
|
161
|
+
assert "VERIFIED_COMMIT_GATE" in deny_reason
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_deny_reason_for_directory_names_worktree_keying_and_remedy(
|
|
165
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
166
|
+
) -> None:
|
|
167
|
+
fake_home = tmp_path / "home"
|
|
168
|
+
fake_home.mkdir()
|
|
169
|
+
_isolate_home(monkeypatch, fake_home)
|
|
170
|
+
work_dir = _make_behavioral_code_repo(tmp_path)
|
|
171
|
+
transcript_path = _empty_transcript(tmp_path)
|
|
172
|
+
deny_reason = deny_reason_for_directory(str(work_dir), str(transcript_path))
|
|
173
|
+
assert deny_reason is not None
|
|
174
|
+
lowered_reason = deny_reason.lower()
|
|
175
|
+
assert "work tree" in lowered_reason
|
|
176
|
+
assert "run this command from the work tree" in lowered_reason
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The verified-commit gate's deny payload teaches the verify-skip policy.
|
|
2
|
+
|
|
3
|
+
A blocked commit prints a PreToolUse ``hookSpecificOutput`` deny whose
|
|
4
|
+
``additionalContext`` carries the ``# verify-skip`` usage rule, so the agent
|
|
5
|
+
learns at the moment of the block when the marker is legitimate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
18
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
20
|
+
|
|
21
|
+
gate_spec = importlib.util.spec_from_file_location(
|
|
22
|
+
"verified_commit_gate",
|
|
23
|
+
_HOOK_DIR / "verified_commit_gate.py",
|
|
24
|
+
)
|
|
25
|
+
assert gate_spec is not None
|
|
26
|
+
assert gate_spec.loader is not None
|
|
27
|
+
gate_module = importlib.util.module_from_spec(gate_spec)
|
|
28
|
+
gate_spec.loader.exec_module(gate_module)
|
|
29
|
+
gate_main = gate_module.main
|
|
30
|
+
|
|
31
|
+
PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
32
|
+
CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_git(work_dir: pathlib.Path, *git_arguments: str) -> None:
|
|
36
|
+
subprocess.run(
|
|
37
|
+
["git", "-C", str(work_dir), *git_arguments],
|
|
38
|
+
check=True,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _make_gated_repo(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
45
|
+
origin_dir = tmp_path / "origin.git"
|
|
46
|
+
work_dir = tmp_path / "work"
|
|
47
|
+
work_dir.mkdir()
|
|
48
|
+
subprocess.run(
|
|
49
|
+
["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
|
|
50
|
+
check=True,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
_run_git(work_dir, "init", "--initial-branch=main")
|
|
55
|
+
_run_git(work_dir, "config", "user.email", "tests@example.com")
|
|
56
|
+
_run_git(work_dir, "config", "user.name", "Gate Tests")
|
|
57
|
+
(work_dir / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
|
|
58
|
+
_run_git(work_dir, "add", "-A")
|
|
59
|
+
_run_git(work_dir, "commit", "-m", "base")
|
|
60
|
+
_run_git(work_dir, "remote", "add", "origin", str(origin_dir))
|
|
61
|
+
_run_git(work_dir, "push", "-u", "origin", "main")
|
|
62
|
+
(work_dir / "app.py").write_text(CHANGED_SOURCE, encoding="utf-8")
|
|
63
|
+
return work_dir
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
|
|
67
|
+
home_text = str(fake_home)
|
|
68
|
+
monkeypatch.setenv("HOME", home_text)
|
|
69
|
+
monkeypatch.setenv("USERPROFILE", home_text)
|
|
70
|
+
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
71
|
+
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run_gate_main(
|
|
75
|
+
monkeypatch: pytest.MonkeyPatch, command_text: str, work_dir: pathlib.Path
|
|
76
|
+
) -> None:
|
|
77
|
+
payload_text = json.dumps(
|
|
78
|
+
{
|
|
79
|
+
"tool_name": "Bash",
|
|
80
|
+
"tool_input": {"command": command_text},
|
|
81
|
+
"cwd": str(work_dir),
|
|
82
|
+
"transcript_path": "",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
|
|
86
|
+
gate_main()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_deny_payload_carries_verify_skip_additional_context(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
91
|
+
capsys: pytest.CaptureFixture[str],
|
|
92
|
+
tmp_path: pathlib.Path,
|
|
93
|
+
) -> None:
|
|
94
|
+
fake_home = tmp_path / "home"
|
|
95
|
+
fake_home.mkdir()
|
|
96
|
+
_isolate_home(monkeypatch, fake_home)
|
|
97
|
+
work_dir = _make_gated_repo(tmp_path)
|
|
98
|
+
_run_gate_main(monkeypatch, "git commit -m x", work_dir)
|
|
99
|
+
deny_payload = json.loads(capsys.readouterr().out)
|
|
100
|
+
hook_specific_output = deny_payload["hookSpecificOutput"]
|
|
101
|
+
assert hook_specific_output["permissionDecision"] == "deny"
|
|
102
|
+
additional_context = hook_specific_output["additionalContext"]
|
|
103
|
+
assert "# verify-skip" in additional_context
|
|
104
|
+
assert "already passed clean" in additional_context
|
|
105
|
+
assert "fresh verification" in additional_context
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_marker_inside_a_quoted_message_still_denies(
|
|
109
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
110
|
+
capsys: pytest.CaptureFixture[str],
|
|
111
|
+
tmp_path: pathlib.Path,
|
|
112
|
+
) -> None:
|
|
113
|
+
fake_home = tmp_path / "home"
|
|
114
|
+
fake_home.mkdir()
|
|
115
|
+
_isolate_home(monkeypatch, fake_home)
|
|
116
|
+
work_dir = _make_gated_repo(tmp_path)
|
|
117
|
+
quoted_marker_command = 'git commit -m "docs: explain the # verify-skip escape hatch"'
|
|
118
|
+
_run_gate_main(monkeypatch, quoted_marker_command, work_dir)
|
|
119
|
+
deny_payload = json.loads(capsys.readouterr().out)
|
|
120
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_marker_as_a_trailing_comment_bypasses(
|
|
124
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
125
|
+
capsys: pytest.CaptureFixture[str],
|
|
126
|
+
tmp_path: pathlib.Path,
|
|
127
|
+
) -> None:
|
|
128
|
+
fake_home = tmp_path / "home"
|
|
129
|
+
fake_home.mkdir()
|
|
130
|
+
_isolate_home(monkeypatch, fake_home)
|
|
131
|
+
work_dir = _make_gated_repo(tmp_path)
|
|
132
|
+
trailing_marker_command = 'git commit -m "wire up feature" # verify-skip'
|
|
133
|
+
_run_gate_main(monkeypatch, trailing_marker_command, work_dir)
|
|
134
|
+
assert capsys.readouterr().out == ""
|
|
@@ -142,7 +142,6 @@ def test_commit_with_missing_local_identity_still_blocks_the_value(
|
|
|
142
142
|
assert deny_reason is not None
|
|
143
143
|
assert "email" in deny_reason
|
|
144
144
|
|
|
145
|
-
|
|
146
145
|
def _init_repo_with_staged_pii(repository_root: Path) -> None:
|
|
147
146
|
repository_root.mkdir(parents=True, exist_ok=True)
|
|
148
147
|
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Entry-boundary behavior of the verified_commit_gate hook process.
|
|
2
|
+
|
|
3
|
+
Runs the hook as a subprocess the way Claude Code does and pins the two
|
|
4
|
+
silent paths: a tool the gate does not cover, and a gated command carrying
|
|
5
|
+
the bypass marker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import pathlib
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
_BLOCKING_DIRECTORY = pathlib.Path(__file__).resolve().parent.parent
|
|
14
|
+
_GATE_SCRIPT_PATH = _BLOCKING_DIRECTORY / "verified_commit_gate.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_gate_process(tool_name: str, command_text: str) -> str:
|
|
18
|
+
payload_text = json.dumps(
|
|
19
|
+
{
|
|
20
|
+
"tool_name": tool_name,
|
|
21
|
+
"tool_input": {"command": command_text},
|
|
22
|
+
"cwd": str(_BLOCKING_DIRECTORY),
|
|
23
|
+
"transcript_path": "",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
completed_process = subprocess.run(
|
|
27
|
+
[sys.executable, str(_GATE_SCRIPT_PATH)],
|
|
28
|
+
input=payload_text,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
check=True,
|
|
32
|
+
)
|
|
33
|
+
return completed_process.stdout
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_uncovered_tool_name_stays_silent() -> None:
|
|
37
|
+
assert _run_gate_process("Read", "git commit -m x") == ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_bypass_marker_stays_silent_at_the_process_boundary() -> None:
|
|
41
|
+
assert _run_gate_process("Bash", "git commit -m x # verify-skip") == ""
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
"""Deterministic loader for the verified-commit constants
|
|
1
|
+
"""Deterministic loader for the verified-commit constants modules.
|
|
2
2
|
|
|
3
3
|
The blocking hooks import their shared constants as
|
|
4
|
-
``from config.verified_commit_constants import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
``from config.verified_commit_constants import ...`` and
|
|
5
|
+
``from config.verified_commit_context_constants import ...``. In the installed
|
|
6
|
+
hook tree a second, unrelated ``config`` package can sit ahead of this package
|
|
7
|
+
on ``sys.path`` and win those dotted names by path order, so the import binds
|
|
8
|
+
to the wrong file and raises ImportError on any constant the stale copy lacks.
|
|
9
|
+
This module binds each dotted name to its sibling ``config/`` file by explicit
|
|
10
|
+
location, so resolution never depends on ``sys.path`` order.
|
|
10
11
|
"""
|
|
11
12
|
|
|
12
13
|
from __future__ import annotations
|
|
@@ -17,7 +18,7 @@ from pathlib import Path
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def register_verified_commit_constants() -> None:
|
|
20
|
-
"""Bind
|
|
21
|
+
"""Bind both verified-commit constants dotted names to their config files.
|
|
21
22
|
|
|
22
23
|
::
|
|
23
24
|
|
|
@@ -35,11 +36,22 @@ def register_verified_commit_constants() -> None:
|
|
|
35
36
|
Returns:
|
|
36
37
|
None. The effect is the ``sys.modules`` registration.
|
|
37
38
|
"""
|
|
38
|
-
|
|
39
|
+
all_constants_module_stems = (
|
|
40
|
+
"verified_commit_constants",
|
|
41
|
+
"verified_commit_context_constants",
|
|
42
|
+
"verified_commit_gate_output_constants",
|
|
43
|
+
)
|
|
44
|
+
for each_module_stem in all_constants_module_stems:
|
|
45
|
+
_register_config_module(each_module_stem)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _register_config_module(module_stem: str) -> None:
|
|
49
|
+
"""Bind one ``config.<module_stem>`` dotted name to its sibling file."""
|
|
50
|
+
config_module_dotted_name = f"config.{module_stem}"
|
|
39
51
|
if config_module_dotted_name in sys.modules:
|
|
40
52
|
return
|
|
41
53
|
constants_file_path = (
|
|
42
|
-
Path(__file__).resolve().parent / "config" / "
|
|
54
|
+
Path(__file__).resolve().parent / "config" / f"{module_stem}.py"
|
|
43
55
|
)
|
|
44
56
|
module_spec = importlib.util.spec_from_file_location(
|
|
45
57
|
config_module_dotted_name, constants_file_path
|