claude-dev-env 2.1.0 → 2.2.1
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/pr-loop/scripts/pyproject.toml +22 -0
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -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/check.ps1 +32 -6
- package/skills/_shared/pr-loop/CLAUDE.md +18 -13
- package/skills/_shared/pr-loop/portable-driver.md +150 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
- package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
- package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
- package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
- package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
- package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
- package/skills/autoconverge/CLAUDE.md +5 -3
- package/skills/autoconverge/SKILL.md +185 -74
- package/skills/autoconverge/reference/convergence.md +5 -5
- package/skills/autoconverge/reference/multi-pr.md +33 -2
- package/skills/autoconverge/reference/stop-conditions.md +9 -6
- package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
- package/skills/copilot-finding-triage/SKILL.md +16 -4
- package/skills/pr-converge/CLAUDE.md +4 -3
- package/skills/pr-converge/SKILL.md +236 -44
- package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
- package/skills/pr-converge/reference/per-tick.md +51 -24
- package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
- 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
package/CLAUDE.md
CHANGED
|
@@ -43,7 +43,7 @@ Run every multi-step code task in two phases:
|
|
|
43
43
|
1. **Coders** — one coder agent per scoped assignment writes the code. A coder that hits a decision it can't reasonably solve consults the advisor (see beginning of this file).
|
|
44
44
|
2. **Verification** — when the coders finish, the main session spawns the `code-verifier` agent in a fresh context, but you must first verify that their work is based on upstream's origin main (aka: the commit live on github). It derives and runs the checks itself rather than trusting coder reports: the task's named gates, tests against baselines recorded before the coders ran, and a two-way diff-vs-assignment reading (every task item maps to a hunk, every hunk maps to a task item, nothing missing). A finding must cite a failing command or a named task item. Before it emits the verdict, it puts the draft through one strongest-tier validation subagent — selected per the advisor protocol's host detection and tier ladder — that tries to refute it, and it re-checks and corrects any part the validator overturns. Source: the fresh-context review step in Claude Code best practices (https://code.claude.com/docs/en/best-practices) — the agent doing the work isn't the one grading it.
|
|
45
45
|
|
|
46
|
-
Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff. One exemption is mechanical, not discretionary: a diff whose every changed file is non-code or
|
|
46
|
+
Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff. One exemption is mechanical, not discretionary: a diff whose every changed file is non-code (docs, images), a pytest test file by name (`test_*.py`, `*_test.py`, or `conftest.py`), or a Python file whose docstring-stripped AST is unchanged (docstring, comment, or formatting-only edits). One escape hatch is manual and narrow: appending `# verify-skip` as a trailing shell comment (outside every quoted region) to the blocked commit or push command bypasses the gate for that one command — allowed only when the branch surface is the same code a code-verifier already passed clean and the gate is blocking on a verdict that doesn't cover it (an unminted verdict, staging churn, a reverted concurrent write); any real code change since the clean verdict runs a fresh verification instead. Full rule: `~/.claude/rules/verified-commit-gate-skip.md`.
|
|
47
47
|
|
|
48
48
|
## Sub-agent Output Validation
|
|
49
49
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# mypy configuration for pr-loop production scripts.
|
|
2
|
+
#
|
|
3
|
+
# Scoped separately from hooks/pyproject.toml so pr-loop and hooks stay
|
|
4
|
+
# independent. check.ps1 invokes this via the mypy-pr-loop tool label.
|
|
5
|
+
|
|
6
|
+
[tool.mypy]
|
|
7
|
+
mypy_path = "."
|
|
8
|
+
python_version = "3.13"
|
|
9
|
+
ignore_missing_imports = true
|
|
10
|
+
warn_unused_ignores = false
|
|
11
|
+
warn_redundant_casts = true
|
|
12
|
+
no_implicit_optional = true
|
|
13
|
+
strict_equality = true
|
|
14
|
+
exclude = [
|
|
15
|
+
"tests/",
|
|
16
|
+
"test_",
|
|
17
|
+
"code_rules_gate_parts/tests/",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[[tool.mypy.overrides]]
|
|
21
|
+
module = "tests.*"
|
|
22
|
+
disallow_untyped_defs = false
|
|
@@ -412,8 +412,10 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
|
412
412
|
harness session scratchpad. The session scratchpad match reads the session id
|
|
413
413
|
from the payload when one is supplied, and from the harness environment
|
|
414
414
|
variable otherwise, so a caller that holds no payload still gets the match.
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
The run_all_validators PreToolUse gate calls this predicate to skip a scratch
|
|
416
|
+
target before it validates. The code-rules and TDD gates call the two path
|
|
417
|
+
predicates ``is_ephemeral_script_path`` and ``is_under_session_scratchpad``
|
|
418
|
+
directly.
|
|
417
419
|
|
|
418
420
|
Args:
|
|
419
421
|
file_path: The candidate path to classify.
|
|
@@ -18,6 +18,7 @@ try:
|
|
|
18
18
|
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
19
19
|
if each_bootstrap_directory not in sys.path:
|
|
20
20
|
sys.path.insert(0, each_bootstrap_directory)
|
|
21
|
+
from code_rules_shared import is_ephemeral_path
|
|
21
22
|
from pii_prevention_blocker_parts.repository_exemption import (
|
|
22
23
|
repository_allowlisted_values,
|
|
23
24
|
)
|
|
@@ -47,6 +48,41 @@ except ImportError as import_error:
|
|
|
47
48
|
) from import_error
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
def _target_is_ephemeral_outside_repository(
|
|
52
|
+
file_path: str, hook_payload: dict | None
|
|
53
|
+
) -> bool:
|
|
54
|
+
"""Report whether a write target is a throwaway draft outside every repo.
|
|
55
|
+
|
|
56
|
+
::
|
|
57
|
+
|
|
58
|
+
ephemeral path, no repo tree -> True (write scan skipped)
|
|
59
|
+
ephemeral path inside a repo -> False (write scan kept)
|
|
60
|
+
ordinary path -> False (write scan kept)
|
|
61
|
+
|
|
62
|
+
A draft under the harness scratchpad or an ephemeral scratch root turns
|
|
63
|
+
durable only through a surface that keeps its own scan, so scanning it at
|
|
64
|
+
write time is a false positive. A draft that resolves inside a git tree
|
|
65
|
+
still carries that repository's own allowlist, so the scan stays.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
file_path: The write target path to classify.
|
|
69
|
+
hook_payload: The PreToolUse payload carrying the session id, or None to
|
|
70
|
+
read the session id from the environment alone.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True only when the path is ephemeral and no enclosing git repository
|
|
74
|
+
resolves.
|
|
75
|
+
"""
|
|
76
|
+
if not file_path:
|
|
77
|
+
return False
|
|
78
|
+
if not is_ephemeral_path(file_path, hook_payload):
|
|
79
|
+
return False
|
|
80
|
+
resolution_directory = _existing_ancestor_directory(file_path)
|
|
81
|
+
if resolution_directory is None:
|
|
82
|
+
return True
|
|
83
|
+
return resolve_repository_root(resolution_directory) is None
|
|
84
|
+
|
|
85
|
+
|
|
50
86
|
def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
|
|
51
87
|
"""Return the deny message listing each finding for *gate_surface*.
|
|
52
88
|
|
|
@@ -165,29 +201,16 @@ def _write_path_allowlisted_values(file_path: str) -> frozenset[str]:
|
|
|
165
201
|
return repository_allowlisted_values(repository_root)
|
|
166
202
|
|
|
167
203
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
all_allowlisted_values: frozenset[str]
|
|
204
|
+
def _write_deny_reason_for_texts(
|
|
205
|
+
all_texts: list[str],
|
|
206
|
+
file_path: str,
|
|
207
|
+
all_allowlisted_values: frozenset[str],
|
|
172
208
|
) -> str | None:
|
|
173
|
-
"""Return
|
|
174
|
-
|
|
175
|
-
A value in the target repository's PII allowlist is dropped from the
|
|
176
|
-
findings, so a write under that repository's tree may carry it. Repository
|
|
177
|
-
resolution for that allowlist runs only after a raw PII hit.
|
|
178
|
-
|
|
179
|
-
Args:
|
|
180
|
-
tool_name: The intercepted tool name.
|
|
181
|
-
all_tool_input: The tool input mapping.
|
|
182
|
-
all_allowlisted_values: Extra exact values allowed past the scan,
|
|
183
|
-
unioned with the target repository's own allowlist.
|
|
209
|
+
"""Return the deny reason for the first text carrying non-allowlisted PII.
|
|
184
210
|
|
|
185
|
-
|
|
186
|
-
|
|
211
|
+
Repository allowlist resolution runs once, only after a raw PII hit, so a
|
|
212
|
+
clean payload triggers no git call.
|
|
187
213
|
"""
|
|
188
|
-
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
189
|
-
return None
|
|
190
|
-
file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
|
|
191
214
|
resolved_allowlisted_values: frozenset[str] | None = None
|
|
192
215
|
for each_text in all_texts:
|
|
193
216
|
all_raw_findings = scan_text_for_pii(each_text)
|
|
@@ -208,6 +231,41 @@ def evaluate_write_edit_payload(
|
|
|
208
231
|
return None
|
|
209
232
|
|
|
210
233
|
|
|
234
|
+
def evaluate_write_edit_payload(
|
|
235
|
+
tool_name: str,
|
|
236
|
+
all_tool_input: dict[str, object],
|
|
237
|
+
all_allowlisted_values: frozenset[str] = frozenset(),
|
|
238
|
+
hook_payload: dict | None = None,
|
|
239
|
+
) -> str | None:
|
|
240
|
+
"""Return a deny reason when Write/Edit/MultiEdit content carries PII.
|
|
241
|
+
|
|
242
|
+
A value in the target repository's PII allowlist is dropped from the
|
|
243
|
+
findings, so a write under that repository's tree may carry it. A draft
|
|
244
|
+
under an ephemeral scratch root outside every repository is skipped, since
|
|
245
|
+
it turns durable only through surfaces that keep their own scans.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
tool_name: The intercepted tool name.
|
|
249
|
+
all_tool_input: The tool input mapping.
|
|
250
|
+
all_allowlisted_values: Extra exact values allowed past the scan,
|
|
251
|
+
unioned with the target repository's own allowlist.
|
|
252
|
+
hook_payload: The PreToolUse payload carrying the session id, threaded
|
|
253
|
+
to the ephemeral-path predicate; None reads the session id from the
|
|
254
|
+
environment alone.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Deny reason text, or None when the write is clean or out of scope.
|
|
258
|
+
"""
|
|
259
|
+
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
260
|
+
return None
|
|
261
|
+
file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
|
|
262
|
+
if not all_texts:
|
|
263
|
+
return None
|
|
264
|
+
if _target_is_ephemeral_outside_repository(file_path, hook_payload):
|
|
265
|
+
return None
|
|
266
|
+
return _write_deny_reason_for_texts(all_texts, file_path, all_allowlisted_values)
|
|
267
|
+
|
|
268
|
+
|
|
211
269
|
def evaluate_post_body_texts(
|
|
212
270
|
all_body_texts: list[str],
|
|
213
271
|
all_allowlisted_values: frozenset[str] = frozenset(),
|
|
@@ -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,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
|
|
@@ -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,
|