claude-dev-env 2.2.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 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 has an unchanged Python AST once docstrings are stripped (docs, docstrings, comments). 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`.
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
- One call answers path exemption for both families, so a gate that skips
416
- throwaway paths need not repeat the two checks itself.
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 evaluate_write_edit_payload(
169
- tool_name: str,
170
- all_tool_input: dict[str, object],
171
- all_allowlisted_values: frozenset[str] = frozenset(),
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 a deny reason when Write/Edit/MultiEdit content carries PII.
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
- Returns:
186
- Deny reason text, or None when the write is clean or out of scope.
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(tool_name, all_tool_input)
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,