claude-dev-env 2.2.0 → 2.3.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/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/pyproject.toml +22 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/code-verifier.md +1 -1
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/config/verified_commit_constants.py +11 -0
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
- package/hooks/blocking/test_verification_verdict_store.py +93 -0
- package/hooks/blocking/verification_verdict_store.py +91 -0
- package/hooks/validators/ruff_integration.py +2 -1
- package/hooks/validators/run_all_validators.py +460 -24
- package/hooks/validators/test_ruff_integration.py +21 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/cleanup-command-forms.md +23 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/check.ps1 +32 -6
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/team-advisor/SKILL.md +3 -2
- package/skills/usage-pause/SKILL.md +5 -4
- package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
|
@@ -329,7 +329,9 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
329
329
|
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
330
330
|
all_tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
331
331
|
if tool_name in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
332
|
-
return evaluate_write_edit_payload(
|
|
332
|
+
return evaluate_write_edit_payload(
|
|
333
|
+
tool_name, all_tool_input, hook_payload=payload_by_key
|
|
334
|
+
)
|
|
333
335
|
if tool_name in ALL_SHELL_TOOL_NAMES:
|
|
334
336
|
return _evaluate_shell_tool(all_tool_input, payload_by_key)
|
|
335
337
|
if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Contract: code-verifier cannot message a warm session-advisor."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
|
6
|
+
_CODE_VERIFIER_AGENT_PATH = _PACKAGE_ROOT / "agents" / "code-verifier.md"
|
|
7
|
+
_FRONTMATTER_OPEN = "---\n"
|
|
8
|
+
_FRONTMATTER_CLOSE = "\n---\n"
|
|
9
|
+
_TOOLS_PREFIX = "tools:"
|
|
10
|
+
_SEND_MESSAGE_TOOL_NAME = "SendMessage"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_code_verifier_tools_exclude_sendmessage() -> None:
|
|
14
|
+
"""tools frontmatter must not list SendMessage (warm-advisor path closed)."""
|
|
15
|
+
agent_text = _CODE_VERIFIER_AGENT_PATH.read_text(encoding="utf-8")
|
|
16
|
+
assert agent_text.startswith(_FRONTMATTER_OPEN), "missing frontmatter open"
|
|
17
|
+
frontmatter_end = agent_text.find(_FRONTMATTER_CLOSE, len(_FRONTMATTER_OPEN))
|
|
18
|
+
assert frontmatter_end > 0, "missing frontmatter close"
|
|
19
|
+
frontmatter_body = agent_text[len(_FRONTMATTER_OPEN) : frontmatter_end]
|
|
20
|
+
all_tools_lines = [
|
|
21
|
+
each_line
|
|
22
|
+
for each_line in frontmatter_body.splitlines()
|
|
23
|
+
if each_line.startswith(_TOOLS_PREFIX)
|
|
24
|
+
]
|
|
25
|
+
assert len(all_tools_lines) == 1, all_tools_lines
|
|
26
|
+
tools_value = all_tools_lines[0].removeprefix(_TOOLS_PREFIX).strip()
|
|
27
|
+
all_tool_names = {each_name.strip() for each_name in tools_value.split(",")}
|
|
28
|
+
assert _SEND_MESSAGE_TOOL_NAME not in all_tool_names, all_tool_names
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Behavior tests for the ephemeral-path skip on the Write/Edit PII surface.
|
|
2
|
+
|
|
3
|
+
Sub-defect (ii): the Write/Edit surface scanned files under an ephemeral scratch
|
|
4
|
+
root that sit outside every git repository. Such a draft only becomes durable
|
|
5
|
+
through ``gh --body-file`` or a staged commit, and each of those surfaces keeps
|
|
6
|
+
its own PII scan, so scanning the draft at write time is a pure false positive.
|
|
7
|
+
|
|
8
|
+
The write surface consults the shared ``is_ephemeral_path`` predicate directly.
|
|
9
|
+
These tests drive that real predicate on genuine ephemeral shapes: a harness
|
|
10
|
+
session scratchpad under the user temp directory, and a ``CLAUDE_JOB_DIR``
|
|
11
|
+
scratch tree. A write inside a git repository keeps full scanning, and the
|
|
12
|
+
durable-post and staged-commit surfaces stay gated even from ephemeral sources.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
_HOOK_DIR = Path(__file__).parent
|
|
27
|
+
_HOOKS_DIR = _HOOK_DIR.parent
|
|
28
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
30
|
+
if str(_HOOKS_DIR) not in sys.path:
|
|
31
|
+
sys.path.insert(0, str(_HOOKS_DIR))
|
|
32
|
+
|
|
33
|
+
import tempfile # noqa: E402
|
|
34
|
+
|
|
35
|
+
from pii_payload_scan import evaluate_write_edit_payload # noqa: E402
|
|
36
|
+
from pii_prevention_blocker import evaluate, evaluate_bash_command # noqa: E402
|
|
37
|
+
|
|
38
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
39
|
+
CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME,
|
|
40
|
+
CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY,
|
|
41
|
+
EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME,
|
|
42
|
+
)
|
|
43
|
+
from hooks_constants.harness_scratchpad_constants import ( # noqa: E402
|
|
44
|
+
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
|
|
45
|
+
HARNESS_SCRATCHPAD_USER_DIRECTORY_NAME,
|
|
46
|
+
HOOK_PAYLOAD_SESSION_ID_KEY,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _real_pii_email() -> str:
|
|
51
|
+
return "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _bot_trailer_address() -> str:
|
|
55
|
+
return "noreply" + "@" + "anthropic" + ".com"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_deny_reason(
|
|
59
|
+
target_path: Path,
|
|
60
|
+
sensitive_text: str,
|
|
61
|
+
hook_payload: dict[str, object] | None = None,
|
|
62
|
+
) -> str | None:
|
|
63
|
+
content = "owner contact " + sensitive_text + "\n"
|
|
64
|
+
return evaluate_write_edit_payload(
|
|
65
|
+
"Write",
|
|
66
|
+
{"file_path": str(target_path), "content": content},
|
|
67
|
+
hook_payload=hook_payload,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _enable_job_dir_ephemeral_root(
|
|
72
|
+
monkeypatch: pytest.MonkeyPatch, job_directory: Path
|
|
73
|
+
) -> Path:
|
|
74
|
+
monkeypatch.delenv(EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, raising=False)
|
|
75
|
+
monkeypatch.setenv(CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME, str(job_directory))
|
|
76
|
+
ephemeral_root = job_directory / CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY
|
|
77
|
+
ephemeral_root.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
return ephemeral_root
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture
|
|
82
|
+
def harness_scratchpad() -> Iterator[tuple[Path, str]]:
|
|
83
|
+
session_id = "session-" + uuid.uuid4().hex
|
|
84
|
+
mangled_working_directory = "cwd-" + uuid.uuid4().hex
|
|
85
|
+
temp_root = Path(tempfile.gettempdir())
|
|
86
|
+
user_root = temp_root / HARNESS_SCRATCHPAD_USER_DIRECTORY_NAME
|
|
87
|
+
scratchpad_root = (
|
|
88
|
+
user_root
|
|
89
|
+
/ mangled_working_directory
|
|
90
|
+
/ session_id
|
|
91
|
+
/ HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME
|
|
92
|
+
)
|
|
93
|
+
scratchpad_root.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
try:
|
|
95
|
+
yield scratchpad_root, session_id
|
|
96
|
+
finally:
|
|
97
|
+
try:
|
|
98
|
+
shutil.rmtree(user_root / mangled_working_directory)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _init_repo_with_github_origin(repository_root: Path, origin_slug: str) -> None:
|
|
104
|
+
repository_root.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
106
|
+
origin_url = "https://github.com/" + origin_slug + ".git"
|
|
107
|
+
subprocess.run(
|
|
108
|
+
["git", "remote", "add", "origin", origin_url],
|
|
109
|
+
cwd=repository_root,
|
|
110
|
+
check=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _init_repo_with_staged_email(repository_root: Path, staged_email: str) -> None:
|
|
115
|
+
repository_root.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
117
|
+
subprocess.run(
|
|
118
|
+
["git", "config", "user.email", "dev@example.com"],
|
|
119
|
+
cwd=repository_root,
|
|
120
|
+
check=True,
|
|
121
|
+
)
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["git", "config", "user.name", "Fixture Dev"],
|
|
124
|
+
cwd=repository_root,
|
|
125
|
+
check=True,
|
|
126
|
+
)
|
|
127
|
+
tracked_file = repository_root / "notes.md"
|
|
128
|
+
tracked_file.write_text("owner email " + staged_email + "\n", encoding="utf-8")
|
|
129
|
+
subprocess.run(["git", "add", "notes.md"], cwd=repository_root, check=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_windows_shaped_scratchpad_write_is_skipped(
|
|
133
|
+
harness_scratchpad: tuple[Path, str],
|
|
134
|
+
) -> None:
|
|
135
|
+
scratchpad_root, session_id = harness_scratchpad
|
|
136
|
+
scratch_target = scratchpad_root / "blocker-matrix.txt"
|
|
137
|
+
payload = {HOOK_PAYLOAD_SESSION_ID_KEY: session_id}
|
|
138
|
+
assert _write_deny_reason(scratch_target, _bot_trailer_address(), payload) is None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_write_with_no_scannable_texts_returns_none(tmp_path: Path) -> None:
|
|
142
|
+
target_path = tmp_path / "empty-write.md"
|
|
143
|
+
assert (
|
|
144
|
+
evaluate_write_edit_payload(
|
|
145
|
+
"Write",
|
|
146
|
+
{"file_path": str(target_path), "content": ""},
|
|
147
|
+
)
|
|
148
|
+
is None
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_entry_point_threads_payload_session_id_to_scratchpad_skip(
|
|
153
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
154
|
+
harness_scratchpad: tuple[Path, str],
|
|
155
|
+
) -> None:
|
|
156
|
+
scratchpad_root, session_id = harness_scratchpad
|
|
157
|
+
monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False)
|
|
158
|
+
scratch_target = scratchpad_root / "blocker-matrix.txt"
|
|
159
|
+
content = "owner contact " + _real_pii_email() + "\n"
|
|
160
|
+
payload = {
|
|
161
|
+
"tool_name": "Write",
|
|
162
|
+
"tool_input": {"file_path": str(scratch_target), "content": content},
|
|
163
|
+
HOOK_PAYLOAD_SESSION_ID_KEY: session_id,
|
|
164
|
+
}
|
|
165
|
+
assert evaluate(payload) is None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_job_dir_ephemeral_write_outside_repo_is_skipped(
|
|
169
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
170
|
+
) -> None:
|
|
171
|
+
ephemeral_root = _enable_job_dir_ephemeral_root(monkeypatch, tmp_path)
|
|
172
|
+
scratch_target = ephemeral_root / "drafts" / "draft.md"
|
|
173
|
+
scratch_target.parent.mkdir(parents=True)
|
|
174
|
+
assert _write_deny_reason(scratch_target, _real_pii_email()) is None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_write_inside_ordinary_repository_is_still_denied(
|
|
178
|
+
tmp_path: Path,
|
|
179
|
+
) -> None:
|
|
180
|
+
repository_root = tmp_path / "repo"
|
|
181
|
+
_init_repo_with_github_origin(repository_root, "SomeOwner/some-repo")
|
|
182
|
+
deny_reason = _write_deny_reason(repository_root / "notes.md", _real_pii_email())
|
|
183
|
+
assert deny_reason is not None
|
|
184
|
+
assert "email" in deny_reason
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_ephemeral_path_inside_a_repository_is_still_denied(
|
|
188
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
189
|
+
) -> None:
|
|
190
|
+
ephemeral_root = _enable_job_dir_ephemeral_root(monkeypatch, tmp_path)
|
|
191
|
+
repository_root = ephemeral_root / "repo"
|
|
192
|
+
_init_repo_with_github_origin(repository_root, "SomeOwner/some-repo")
|
|
193
|
+
deny_reason = _write_deny_reason(repository_root / "notes.md", _real_pii_email())
|
|
194
|
+
assert deny_reason is not None
|
|
195
|
+
assert "email" in deny_reason
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_gh_body_file_from_ephemeral_path_is_still_scanned(
|
|
199
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
200
|
+
) -> None:
|
|
201
|
+
ephemeral_root = _enable_job_dir_ephemeral_root(monkeypatch, tmp_path)
|
|
202
|
+
body_path = ephemeral_root / "pr_body.md"
|
|
203
|
+
body_path.write_text("contact " + _real_pii_email() + "\n", encoding="utf-8")
|
|
204
|
+
command = 'gh pr comment 12 --body-file "' + str(body_path) + '"'
|
|
205
|
+
deny_reason = evaluate_bash_command(command, working_directory=None)
|
|
206
|
+
assert deny_reason is not None
|
|
207
|
+
assert "email" in deny_reason
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_staged_commit_from_ephemeral_tree_is_still_scanned(
|
|
211
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
212
|
+
) -> None:
|
|
213
|
+
ephemeral_root = _enable_job_dir_ephemeral_root(monkeypatch, tmp_path)
|
|
214
|
+
repository_root = ephemeral_root / "repo"
|
|
215
|
+
_init_repo_with_staged_email(repository_root, _real_pii_email())
|
|
216
|
+
deny_reason = evaluate_bash_command(
|
|
217
|
+
"git commit -m test", working_directory=str(repository_root)
|
|
218
|
+
)
|
|
219
|
+
assert deny_reason is not None
|
|
220
|
+
assert "email" in deny_reason
|
|
221
|
+
assert "staged commit" in deny_reason
|
|
@@ -808,3 +808,96 @@ def test_store_imports_under_foreign_config_shadow(
|
|
|
808
808
|
)
|
|
809
809
|
assert completed_probe.returncode == 0, completed_probe.stderr
|
|
810
810
|
assert "IMPORT_OK" in completed_probe.stdout
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _ledger_path(fake_home: pathlib.Path) -> pathlib.Path:
|
|
814
|
+
return (
|
|
815
|
+
fake_home
|
|
816
|
+
/ constants_module.CLAUDE_HOME_DIRECTORY_NAME
|
|
817
|
+
/ constants_module.LOGS_DIRECTORY_NAME
|
|
818
|
+
/ constants_module.VERIFIER_VERDICTS_JSONL_FILENAME
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _read_ledger_rows(fake_home: pathlib.Path) -> list[dict]:
|
|
823
|
+
ledger_file = _ledger_path(fake_home)
|
|
824
|
+
all_rows: list[dict] = []
|
|
825
|
+
for each_line in ledger_file.read_text(encoding="utf-8").splitlines():
|
|
826
|
+
if each_line.strip():
|
|
827
|
+
all_rows.append(json.loads(each_line))
|
|
828
|
+
return all_rows
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def test_write_verdict_appends_failing_row_that_survives_later_clean_mint(
|
|
832
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
833
|
+
) -> None:
|
|
834
|
+
"""A failing mint's check names stay readable after a later clean mint."""
|
|
835
|
+
fake_home = tmp_path / "home"
|
|
836
|
+
fake_home.mkdir()
|
|
837
|
+
_isolate_home(monkeypatch, fake_home)
|
|
838
|
+
monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: fake_home))
|
|
839
|
+
work_dir = _make_repo_with_origin(tmp_path)
|
|
840
|
+
failing_findings = [
|
|
841
|
+
{"check": "gates.import", "detail": "ImportError: boom"},
|
|
842
|
+
{"check": "task.item-2", "detail": "missing hunk"},
|
|
843
|
+
]
|
|
844
|
+
write_verdict(
|
|
845
|
+
str(work_dir),
|
|
846
|
+
MATCHING_MANIFEST_SHA256,
|
|
847
|
+
False,
|
|
848
|
+
failing_findings,
|
|
849
|
+
"agent-fail",
|
|
850
|
+
)
|
|
851
|
+
write_verdict(
|
|
852
|
+
str(work_dir),
|
|
853
|
+
MATCHING_MANIFEST_SHA256,
|
|
854
|
+
True,
|
|
855
|
+
[],
|
|
856
|
+
"agent-pass",
|
|
857
|
+
)
|
|
858
|
+
all_rows = _read_ledger_rows(fake_home)
|
|
859
|
+
assert len(all_rows) == 2
|
|
860
|
+
failing_row = all_rows[0]
|
|
861
|
+
clean_row = all_rows[1]
|
|
862
|
+
assert failing_row[constants_module.LEDGER_KEY_ALL_PASS] is False
|
|
863
|
+
assert failing_row[constants_module.LEDGER_KEY_FINDING_COUNT] == 2
|
|
864
|
+
assert failing_row[constants_module.LEDGER_KEY_FINDING_CHECK_NAMES] == [
|
|
865
|
+
"gates.import",
|
|
866
|
+
"task.item-2",
|
|
867
|
+
]
|
|
868
|
+
assert failing_row[constants_module.LEDGER_KEY_MINTED_FROM_AGENT_ID] == "agent-fail"
|
|
869
|
+
assert failing_row[constants_module.LEDGER_KEY_MANIFEST_HASH] == MATCHING_MANIFEST_SHA256
|
|
870
|
+
assert failing_row[constants_module.LEDGER_KEY_REPO_ROOT] == str(work_dir)
|
|
871
|
+
assert failing_row[constants_module.LEDGER_KEY_BRANCH] == "main"
|
|
872
|
+
assert clean_row[constants_module.LEDGER_KEY_ALL_PASS] is True
|
|
873
|
+
assert clean_row[constants_module.LEDGER_KEY_FINDING_COUNT] == 0
|
|
874
|
+
assert clean_row[constants_module.LEDGER_KEY_FINDING_CHECK_NAMES] == []
|
|
875
|
+
assert clean_row[constants_module.LEDGER_KEY_MINTED_FROM_AGENT_ID] == "agent-pass"
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def test_write_verdict_returns_path_when_ledger_append_fails(
|
|
879
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
880
|
+
) -> None:
|
|
881
|
+
"""A ledger IO failure never changes the mint path or verdict file write."""
|
|
882
|
+
fake_home = tmp_path / "home"
|
|
883
|
+
fake_home.mkdir()
|
|
884
|
+
_isolate_home(monkeypatch, fake_home)
|
|
885
|
+
monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: fake_home))
|
|
886
|
+
logs_as_file = (
|
|
887
|
+
fake_home
|
|
888
|
+
/ constants_module.CLAUDE_HOME_DIRECTORY_NAME
|
|
889
|
+
/ constants_module.LOGS_DIRECTORY_NAME
|
|
890
|
+
)
|
|
891
|
+
logs_as_file.parent.mkdir(parents=True, exist_ok=True)
|
|
892
|
+
logs_as_file.write_text("not-a-directory", encoding="utf-8")
|
|
893
|
+
work_dir = _make_repo_with_origin(tmp_path)
|
|
894
|
+
verdict_file = write_verdict(
|
|
895
|
+
str(work_dir),
|
|
896
|
+
MATCHING_MANIFEST_SHA256,
|
|
897
|
+
True,
|
|
898
|
+
[],
|
|
899
|
+
"agent-x",
|
|
900
|
+
)
|
|
901
|
+
assert verdict_file.is_file()
|
|
902
|
+
verdict_record = json.loads(verdict_file.read_text(encoding="utf-8"))
|
|
903
|
+
assert verdict_record[constants_module.VERDICT_KEY_ALL_PASS] is True
|
|
@@ -13,6 +13,7 @@ and reading/writing verdict files.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import ast
|
|
16
|
+
import datetime
|
|
16
17
|
import hashlib
|
|
17
18
|
import json
|
|
18
19
|
import re
|
|
@@ -20,6 +21,7 @@ import subprocess
|
|
|
20
21
|
import sys
|
|
21
22
|
import time
|
|
22
23
|
from pathlib import Path
|
|
24
|
+
from collections.abc import Mapping
|
|
23
25
|
|
|
24
26
|
blocking_directory = str(Path(__file__).resolve().parent)
|
|
25
27
|
if blocking_directory not in sys.path:
|
|
@@ -45,6 +47,15 @@ from config.verified_commit_constants import (
|
|
|
45
47
|
ALL_FALLBACK_BASE_REFERENCES,
|
|
46
48
|
EMPTY_SURFACE_GUARD_MESSAGE,
|
|
47
49
|
GIT_TIMEOUT_SECONDS,
|
|
50
|
+
LEDGER_KEY_ALL_PASS,
|
|
51
|
+
LEDGER_KEY_BRANCH,
|
|
52
|
+
LEDGER_KEY_FINDING_CHECK_NAMES,
|
|
53
|
+
LEDGER_KEY_FINDING_COUNT,
|
|
54
|
+
LEDGER_KEY_MANIFEST_HASH,
|
|
55
|
+
LEDGER_KEY_MINTED_FROM_AGENT_ID,
|
|
56
|
+
LEDGER_KEY_REPO_ROOT,
|
|
57
|
+
LEDGER_KEY_TIMESTAMP,
|
|
58
|
+
LOGS_DIRECTORY_NAME,
|
|
48
59
|
MANIFEST_HASH_CLI_FLAG,
|
|
49
60
|
MANIFEST_HASH_FOR_BRANCH_CLI_FLAG,
|
|
50
61
|
MINIMUM_STATUS_FIELD_COUNT,
|
|
@@ -65,10 +76,12 @@ from config.verified_commit_constants import (
|
|
|
65
76
|
VERDICT_DIRECTORY_NAME,
|
|
66
77
|
VERDICT_FENCE_PATTERN,
|
|
67
78
|
VERDICT_FILE_GLOB,
|
|
79
|
+
VERDICT_FINDING_CHECK_KEY,
|
|
68
80
|
VERDICT_JSON_INDENT,
|
|
69
81
|
VERDICT_KEY_ALL_PASS,
|
|
70
82
|
VERDICT_KEY_FINDINGS,
|
|
71
83
|
VERDICT_KEY_MANIFEST_SHA256,
|
|
84
|
+
VERIFIER_VERDICTS_JSONL_FILENAME,
|
|
72
85
|
WORKTREE_LIST_BRANCH_PREFIX,
|
|
73
86
|
WORKTREE_LIST_PATH_PREFIX,
|
|
74
87
|
)
|
|
@@ -654,6 +667,74 @@ def workflow_verdict_covers_surface(
|
|
|
654
667
|
return False
|
|
655
668
|
|
|
656
669
|
|
|
670
|
+
def _finding_check_names(all_findings: list) -> list[str]:
|
|
671
|
+
"""Collect the ``check`` name from each finding mapping that carries one.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
all_findings: The verifier's findings list (empty when clean).
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Ordered list of non-empty check name strings; entries without a
|
|
678
|
+
string ``check`` field are omitted.
|
|
679
|
+
"""
|
|
680
|
+
all_check_names: list[str] = []
|
|
681
|
+
for each_finding in all_findings:
|
|
682
|
+
if not isinstance(each_finding, Mapping):
|
|
683
|
+
continue
|
|
684
|
+
check_name = each_finding.get(VERDICT_FINDING_CHECK_KEY)
|
|
685
|
+
if isinstance(check_name, str) and check_name:
|
|
686
|
+
all_check_names.append(check_name)
|
|
687
|
+
return all_check_names
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _append_verdict_ledger_row(
|
|
691
|
+
repo_root: str,
|
|
692
|
+
bound_manifest_sha256: str,
|
|
693
|
+
is_all_pass: bool,
|
|
694
|
+
all_findings: list,
|
|
695
|
+
minted_from_agent_id: str,
|
|
696
|
+
) -> None:
|
|
697
|
+
"""Append one fail-safe JSONL row for a minted verdict.
|
|
698
|
+
|
|
699
|
+
Writes to ``~/.claude/logs/verifier-verdicts.jsonl``. Swallows home
|
|
700
|
+
resolution and IO errors so a ledger failure never changes the mint.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
repo_root: The repository top-level directory.
|
|
704
|
+
bound_manifest_sha256: Hash of the surface the verdict covers.
|
|
705
|
+
is_all_pass: Whether the verifier reported a clean verdict.
|
|
706
|
+
all_findings: The verifier's findings list (empty when clean).
|
|
707
|
+
minted_from_agent_id: The subagent invocation id, kept for audit.
|
|
708
|
+
"""
|
|
709
|
+
try:
|
|
710
|
+
home_directory = Path.home()
|
|
711
|
+
except RuntimeError:
|
|
712
|
+
return
|
|
713
|
+
try:
|
|
714
|
+
ledger_path = (
|
|
715
|
+
home_directory
|
|
716
|
+
/ CLAUDE_HOME_DIRECTORY_NAME
|
|
717
|
+
/ LOGS_DIRECTORY_NAME
|
|
718
|
+
/ VERIFIER_VERDICTS_JSONL_FILENAME
|
|
719
|
+
)
|
|
720
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
721
|
+
branch_name = _head_branch_name(repo_root)
|
|
722
|
+
ledger_row = {
|
|
723
|
+
LEDGER_KEY_TIMESTAMP: datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
724
|
+
LEDGER_KEY_REPO_ROOT: repo_root,
|
|
725
|
+
LEDGER_KEY_BRANCH: branch_name,
|
|
726
|
+
LEDGER_KEY_MANIFEST_HASH: bound_manifest_sha256,
|
|
727
|
+
LEDGER_KEY_ALL_PASS: is_all_pass,
|
|
728
|
+
LEDGER_KEY_FINDING_COUNT: len(all_findings),
|
|
729
|
+
LEDGER_KEY_FINDING_CHECK_NAMES: _finding_check_names(all_findings),
|
|
730
|
+
LEDGER_KEY_MINTED_FROM_AGENT_ID: minted_from_agent_id,
|
|
731
|
+
}
|
|
732
|
+
with ledger_path.open("a", encoding="utf-8") as ledger_file:
|
|
733
|
+
ledger_file.write(json.dumps(ledger_row) + "\n")
|
|
734
|
+
except OSError:
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
|
|
657
738
|
def write_verdict(
|
|
658
739
|
repo_root: str,
|
|
659
740
|
bound_manifest_sha256: str,
|
|
@@ -663,6 +744,9 @@ def write_verdict(
|
|
|
663
744
|
) -> Path:
|
|
664
745
|
"""Write a verdict file binding a verification outcome to a surface hash.
|
|
665
746
|
|
|
747
|
+
Also appends one durable JSONL ledger row under the Claude logs directory.
|
|
748
|
+
A ledger write failure never changes the mint outcome.
|
|
749
|
+
|
|
666
750
|
Args:
|
|
667
751
|
repo_root: The repository top-level directory.
|
|
668
752
|
bound_manifest_sha256: Hash of the surface manifest the verdict covers.
|
|
@@ -686,6 +770,13 @@ def write_verdict(
|
|
|
686
770
|
verdict_file.write_text(
|
|
687
771
|
json.dumps(verdict_record, indent=VERDICT_JSON_INDENT), encoding="utf-8"
|
|
688
772
|
)
|
|
773
|
+
_append_verdict_ledger_row(
|
|
774
|
+
repo_root,
|
|
775
|
+
bound_manifest_sha256,
|
|
776
|
+
is_all_pass,
|
|
777
|
+
all_findings,
|
|
778
|
+
minted_from_agent_id,
|
|
779
|
+
)
|
|
689
780
|
return verdict_file
|
|
690
781
|
|
|
691
782
|
|
|
@@ -38,8 +38,9 @@ def run_ruff_check(files: list[Path]) -> RuffResult:
|
|
|
38
38
|
if not py_files:
|
|
39
39
|
return RuffResult(passed=True, output="No Python files", fixed_count=0)
|
|
40
40
|
|
|
41
|
+
concise_output_arguments = ["--output-format", "concise"]
|
|
41
42
|
result = subprocess.run(
|
|
42
|
-
["ruff", "check"] + py_files,
|
|
43
|
+
["ruff", "check", *concise_output_arguments] + py_files,
|
|
43
44
|
check=False,
|
|
44
45
|
capture_output=True,
|
|
45
46
|
text=True,
|