claude-dev-env 7.4.0 → 7.5.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.
Files changed (86) hide show
  1. package/bin/install.mjs +0 -1
  2. package/bin/install.policy-lint.test.mjs +1 -2
  3. package/bin/install.test.mjs +4 -4
  4. package/bin/pstack.mjs +24 -4
  5. package/bin/pstack.test.mjs +20 -10
  6. package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
  7. package/hooks/advisory/pr_done_reminder.py +7 -13
  8. package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
  9. package/hooks/advisory/test_pr_done_reminder.py +1 -0
  10. package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
  11. package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
  12. package/hooks/blocking/env_var_table_rows.py +130 -0
  13. package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
  14. package/hooks/blocking/pii_scanner.py +19 -29
  15. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
  17. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
  18. package/hooks/blocking/test_env_var_table_rows.py +100 -0
  19. package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
  20. package/hooks/blocking/test_pii_scanner.py +34 -24
  21. package/hooks/hooks.json +10 -0
  22. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
  23. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
  24. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
  25. package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
  26. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
  27. package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
  28. package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
  29. package/hooks/hooks_constants/post_tool_use_context.py +34 -0
  30. package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
  31. package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
  32. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
  33. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
  34. package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
  35. package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
  36. package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
  37. package/hooks/observability/test_failure_recorder.py +3 -1
  38. package/package.json +1 -1
  39. package/rules/ask-user-question-required.md +3 -3
  40. package/rules/code-standards.md +3 -2
  41. package/rules/confirm-implementation-forks.md +2 -0
  42. package/rules/destructive-commands.md +19 -19
  43. package/rules/doc-inventory-integrity.md +4 -4
  44. package/rules/docstring-prose-matches-implementation.md +2 -2
  45. package/rules/failure-blast-radius.md +4 -2
  46. package/rules/filesystem-search.md +1 -1
  47. package/rules/git-workflow.md +34 -0
  48. package/rules/long-horizon-autonomy.md +2 -0
  49. package/rules/nas-ssh-invocation.md +3 -3
  50. package/rules/no-cross-skill-duplicate-helpers.md +6 -6
  51. package/rules/orphan-css-class.md +5 -5
  52. package/rules/paired-test-coverage.md +6 -6
  53. package/rules/plain-illustrative-docstrings.md +3 -3
  54. package/rules/re-stage-before-commit.md +7 -7
  55. package/rules/retired-hook-prose.md +16 -0
  56. package/rules/shell-invocation.md +17 -1
  57. package/rules/testing.md +3 -5
  58. package/rules/verify-runtime-state.md +1 -1
  59. package/rules/windows-filesystem-safe.md +2 -2
  60. package/rules/workflow-substitution-slots.md +1 -1
  61. package/scripts/durable_post_lint_config/config/constants.py +0 -3
  62. package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
  63. package/scripts/policy_lint/adapter_configuration.py +24 -0
  64. package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
  65. package/scripts/policy_lint/adapters.py +36 -1
  66. package/scripts/policy_lint/config/constants.py +74 -0
  67. package/scripts/policy_lint/registry.py +5 -0
  68. package/scripts/policy_lint/selection.py +37 -18
  69. package/scripts/policy_lint/selection_git.py +6 -2
  70. package/scripts/pstack-adapters/common.md +1 -1
  71. package/scripts/repository_checks/config/constants.py +7 -17
  72. package/scripts/repository_checks/config/email_exemptions.py +10 -0
  73. package/scripts/repository_checks/email_exemptions.py +96 -0
  74. package/scripts/repository_checks/env_var_documentation.py +1 -9
  75. package/scripts/repository_checks/tracked_secrets.py +10 -4
  76. package/scripts/test_durable_post_lint.py +42 -9
  77. package/scripts/tests/test_adapter_configuration.py +8 -0
  78. package/scripts/tests/test_email_exemptions.py +121 -0
  79. package/scripts/tests/test_env_var_documentation.py +26 -0
  80. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
  81. package/scripts/tests/test_policy_lint_selection.py +107 -0
  82. package/scripts/tests/test_tracked_secrets.py +13 -0
  83. package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
  84. package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
  85. package/hooks/hooks_constants/test_text_stripping.py +0 -39
  86. package/hooks/hooks_constants/text_stripping.py +0 -36
@@ -18,6 +18,7 @@ from .model import (
18
18
  TextDocument,
19
19
  )
20
20
  from .selection_git import (
21
+ GitBlobItemBlocked,
21
22
  GitSelectionError,
22
23
  git_bytes_for,
23
24
  head_revision,
@@ -30,6 +31,10 @@ class SelectionRunFatal(ValueError):
30
31
  """Raised when a source selection cannot be resolved safely."""
31
32
 
32
33
 
34
+ class SelectionItemBlocked(ValueError):
35
+ """Raised when one file holds bytes that are not a policy document."""
36
+
37
+
33
38
  SelectionError = SelectionRunFatal
34
39
 
35
40
 
@@ -54,9 +59,7 @@ def select_documents(request: LintRequest) -> DocumentSet:
54
59
 
55
60
  def _select_source(repository_root: Path, source: object) -> DocumentSet:
56
61
  if isinstance(source, ExplicitFiles):
57
- all_documents = tuple(
58
- _worktree_document(repository_root, each_path) for each_path in source.paths
59
- )
62
+ all_documents = _explicit_documents(repository_root, source.paths)
60
63
  return DocumentSet(all_documents, SelectionKind.FILES, repository_root)
61
64
  if isinstance(source, StagedChanges):
62
65
  return _select_changes(repository_root, True, None)
@@ -89,13 +92,28 @@ def _validated_repository_root(repository_root: Path) -> Path:
89
92
  return resolved_root
90
93
 
91
94
 
95
+ def _explicit_documents(
96
+ repository_root: Path, all_paths: tuple[Path, ...]
97
+ ) -> tuple[Document, ...]:
98
+ all_documents: list[Document] = []
99
+ for each_path in all_paths:
100
+ try:
101
+ all_documents.append(_worktree_document(repository_root, each_path))
102
+ except SelectionItemBlocked:
103
+ continue
104
+ return tuple(all_documents)
105
+
106
+
92
107
  def _select_repository_tree(repository_root: Path) -> DocumentSet:
93
- all_documents = tuple(
94
- _tracked_worktree_document(repository_root, each_path)
95
- for each_path in _tracked_paths(repository_root)
96
- if (repository_root / each_path).is_file()
97
- )
98
- return DocumentSet(all_documents, SelectionKind.REPOSITORY, repository_root)
108
+ all_documents: list[Document] = []
109
+ for each_path in _tracked_paths(repository_root):
110
+ if not (repository_root / each_path).is_file():
111
+ continue
112
+ try:
113
+ all_documents.append(_tracked_worktree_document(repository_root, each_path))
114
+ except SelectionItemBlocked:
115
+ continue
116
+ return DocumentSet(tuple(all_documents), SelectionKind.REPOSITORY, repository_root)
99
117
 
100
118
 
101
119
  def _tracked_worktree_document(repository_root: Path, raw_path: str) -> Document:
@@ -189,13 +207,16 @@ def _append_change(
189
207
  old_path, new_path = all_paths if status[:1] in {"R", "C"} else (all_paths[0], all_paths[0])
190
208
  normalized_old_path = _normalize_path(repository_root, old_path)
191
209
  normalized_new_path = _normalize_path(repository_root, new_path)
192
- document = _change_document(
193
- repository_root, old_path, new_path, normalized_old_path,
194
- normalized_new_path, is_staged, base_revision, status,
195
- )
196
- if document is None:
210
+ if status == "D":
197
211
  all_deleted_paths.append(normalized_old_path)
198
212
  return
213
+ try:
214
+ document = _change_document(
215
+ repository_root, old_path, new_path, normalized_old_path,
216
+ normalized_new_path, is_staged, base_revision, status,
217
+ )
218
+ except (SelectionItemBlocked, GitBlobItemBlocked):
219
+ return
199
220
  all_documents.append(document)
200
221
  if document.prior_path is not None:
201
222
  all_renamed_paths.append((normalized_old_path, normalized_new_path))
@@ -210,9 +231,7 @@ def _change_document(
210
231
  is_staged: bool,
211
232
  base_revision: str | None,
212
233
  status: str,
213
- ) -> Document | None:
214
- if status == "D":
215
- return None
234
+ ) -> Document:
216
235
  is_copy = status[:1] == "C"
217
236
  is_rename = status[:1] == "R"
218
237
  prior_text = _change_prior_text(
@@ -274,7 +293,7 @@ def _read_worktree_text(file_path: Path) -> str:
274
293
  try:
275
294
  return file_path.read_bytes().decode(constants.UTF8_ENCODING)
276
295
  except UnicodeDecodeError as error:
277
- raise SelectionRunFatal(f"File is not UTF-8: {file_path}") from error
296
+ raise SelectionItemBlocked(f"File is not UTF-8: {file_path}") from error
278
297
  except OSError as error:
279
298
  raise SelectionRunFatal(f"File cannot be read: {file_path}") from error
280
299
 
@@ -21,6 +21,10 @@ class GitSelectionError(ValueError):
21
21
  """Raised when Git cannot provide the requested source bytes."""
22
22
 
23
23
 
24
+ class GitBlobItemBlocked(GitSelectionError):
25
+ """Raised when one Git blob holds bytes that are not UTF-8 text."""
26
+
27
+
24
28
  def git_bytes_for(repository_root: Path, all_arguments: tuple[str, ...]) -> bytes:
25
29
  """Run a fixed Git argument vector and return raw stdout bytes.
26
30
 
@@ -89,7 +93,7 @@ def read_blob(repository_root: Path, revision: str, relative_path: str) -> str |
89
93
  Decoded blob text, or None when the blob is absent.
90
94
 
91
95
  Raises:
92
- GitSelectionError: If the blob is not UTF-8.
96
+ GitBlobItemBlocked: If the blob is not UTF-8.
93
97
  """
94
98
  blob_bytes = _read_blob_bytes(repository_root, revision, relative_path)
95
99
  if blob_bytes is None:
@@ -97,7 +101,7 @@ def read_blob(repository_root: Path, revision: str, relative_path: str) -> str |
97
101
  try:
98
102
  return blob_bytes.decode(UTF8_ENCODING)
99
103
  except UnicodeDecodeError as error:
100
- raise GitSelectionError(f"Git blob is not UTF-8: {relative_path}") from error
104
+ raise GitBlobItemBlocked(f"Git blob is not UTF-8: {relative_path}") from error
101
105
 
102
106
 
103
107
  def _git_subprocess_environment() -> dict[str, str]:
@@ -4,7 +4,7 @@ Apply this mapping to the upstream workflow below. User instructions and host pe
4
4
 
5
5
  Determine the active host from the session's native tools. Read host-claude.md, host-codex.md, or host-cursor.md beside this file for the active host. Read pstack-host-mapping.md and pstack-models.md beside this file before delegation. Those files are copied from claude-dev-env's existing rules into this immutable release. Run the selectorPath recorded in release.json and supply its preferencesDirectory in the selector input. This overrides the old global selector path. Model preferences remain outside the release.
6
6
 
7
- Installed skill names have a component prefix. Read the installed inventory in release.json. A pstack request for how, /how, or pstack:how means pstack-how. A cursor-team-kit request for deslop means cursor-team-kit-deslop. Resolve all other skill names through that inventory. Read each leaf skill in full when it applies. Keep each skill's scripts, references, playbooks, and agent definitions at its recorded source root.
7
+ Read the installed inventory in release.json for exact skill identities. Pstack skills use the `pstack:<slug>` plugin namespace. A pstack request for how, /how, or pstack:how means pstack:how. Cursor-team-kit skills keep flat component-prefixed names, so a request for deslop means cursor-team-kit-deslop. Resolve all other skill names through the inventory. Read each leaf skill in full when it applies. Keep each skill's scripts, references, playbooks, and agent definitions at its recorded source root.
8
8
 
9
9
  Every delegation prompt must include this compatibility file's absolute path, the active host file's absolute path, the selected skill's absolute path, and the requested upstream agent definition's absolute path when present. Tell the child to read them before working and to put the complete findings and evidence in its final message. The child receives the same host and model mapping as the parent.
10
10
 
@@ -99,37 +99,27 @@ ALL_TRACKED_SECRET_EXACT_EXEMPTIONS: frozenset[tuple[str, str, str]] = frozenset
99
99
  (
100
100
  "packages/claude-dev-env/_shared/pr-loop/scripts/_claude_permissions_common.py",
101
101
  "home-path",
102
- "9c48917645524d03ea0a4f4d8755aa8f0d59f3108e1aa976202a02e613c1444d",
103
- ),
104
- (
105
- "packages/claude-dev-env/_shared/pr-loop/scripts/_claude_permissions_common.py",
106
- "home-path",
107
- "07ba214cb23325675bc68e5513c030e968eccf2760cb5917d932ae5da29d5af1",
108
- ),
109
- (
110
- "packages/claude-dev-env/_shared/pr-loop/scripts/_claude_permissions_common.py",
111
- "home-path",
112
- "d41ce8d0f21de2d5320bdd8cd4e8693a2f3d6ea59db6df5ea102a7d2be4b2c22",
102
+ "f9e409c5c16ecfdd5883da4f3ad045b4af5677740c9e703ad2d47ba231718350",
113
103
  ),
114
104
  (
115
105
  "packages/claude-dev-env/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md",
116
106
  "home-path",
117
- "550c7b16a2a04e1d05a8a6b4f5b764b8d4fdef65e9d79d09e5dbb3a15a855812",
107
+ "6b98ff7f0398f06452f19c4a93f3d69186401dab40aaa7077b394ef59b3e730e",
118
108
  ),
119
109
  (
120
- "packages/claude-dev-env/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md",
110
+ "packages/claude-dev-env/bin/expand_home_directory_tokens.mjs",
121
111
  "home-path",
122
- "b8bbdd1a7ebdad16f4e51377a3f21d05dd35992d89e35971b28b05f53245c69d",
112
+ "31e989aa0fbf83d6de5d2f0cd6f08b3cd8151592050b71794ffa180b80385ff2",
123
113
  ),
124
114
  (
125
115
  "packages/claude-dev-env/bin/expand_home_directory_tokens.mjs",
126
116
  "home-path",
127
- "10d5571e7126d2018ee7fb06aa29c72a9d503cac79a4e52d6848602ee10e8832",
117
+ "73ff793e488241663bb0c075fd50bd3a18fca8dd79cbd11629c00d2ad7548a8a",
128
118
  ),
129
119
  (
130
120
  "packages/claude-dev-env/bin/expand_home_directory_tokens.mjs",
131
121
  "home-path",
132
- "09f83c224a02c370d3d63de5c330e1a830fc92866330fc7963b5b5fd20ffcb91",
122
+ "fd9ba4ae930ef31ebdfc463b231a093656b8eaa1d8fe65f60715ec2b6fee04a9",
133
123
  ),
134
124
  (
135
125
  "packages/claude-dev-env/hooks/hooks_constants/hardcoded_user_path_constants.py",
@@ -139,7 +129,7 @@ ALL_TRACKED_SECRET_EXACT_EXEMPTIONS: frozenset[tuple[str, str, str]] = frozenset
139
129
  (
140
130
  "packages/claude-dev-env/hooks/hooks_constants/hardcoded_user_path_constants.py",
141
131
  "home-path",
142
- "a541acfac2e649581bca0cd03e4c3dcf306344915017d9cd6be4ad57a8fd7379",
132
+ "5d956228802fbd4a65af8fa7fb4183f5a9f8793dbe218877aefe887b905f726d",
143
133
  ),
144
134
  )
145
135
  )
@@ -0,0 +1,10 @@
1
+ """Repository public-email exception schema."""
2
+
3
+ import re
4
+
5
+ CONFIG_RELATIVE_PATH = "config/repository-policy.json"
6
+ DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}")
7
+ FORBIDDEN_PATH_CHARACTERS = frozenset("\\:*?[]")
8
+ ALL_ENTRY_FIELDS = frozenset({"path", "sha256", "reason"})
9
+ ALL_FORBIDDEN_PATH_SEGMENTS = frozenset({"", ".", ".."})
10
+ FIRST_PRINTABLE_CODEPOINT = 32
@@ -0,0 +1,96 @@
1
+ """Load exact public-email exceptions owned by a repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from repository_checks.config.email_exemptions import (
9
+ ALL_ENTRY_FIELDS,
10
+ ALL_FORBIDDEN_PATH_SEGMENTS,
11
+ CONFIG_RELATIVE_PATH,
12
+ DIGEST_PATTERN,
13
+ FIRST_PRINTABLE_CODEPOINT,
14
+ FORBIDDEN_PATH_CHARACTERS,
15
+ )
16
+
17
+
18
+ class PolicyConfigurationRunFatal(ValueError):
19
+ """Reject the entire policy run when exception configuration is invalid."""
20
+
21
+
22
+ def load_email_exemptions(repository_root: Path) -> frozenset[tuple[str, str, str]]:
23
+ """Read validated email exceptions.
24
+
25
+ Args:
26
+ repository_root: Repository containing the optional policy configuration.
27
+ Returns:
28
+ Exact path, email category, and digest identities.
29
+ Raises:
30
+ ValueError: Configuration contains invalid JSON or schema fields.
31
+ OSError: Configuration cannot be read.
32
+ """
33
+ exemptions: set[tuple[str, str, str]] = set()
34
+ for each_entry in _read_entries(repository_root):
35
+ identity = _entry_identity(each_entry)
36
+ if identity in exemptions:
37
+ raise PolicyConfigurationRunFatal("Duplicate repository email exemption")
38
+ exemptions.add(identity)
39
+ return frozenset(exemptions)
40
+
41
+
42
+ def _read_entries(repository_root: Path) -> list[object]:
43
+ config_path = repository_root / CONFIG_RELATIVE_PATH
44
+ if not config_path.exists() and not config_path.is_symlink():
45
+ return []
46
+ if not config_path.resolve().is_relative_to(repository_root.resolve()):
47
+ raise ValueError(
48
+ "Repository policy configuration must remain inside the repository"
49
+ )
50
+ document = json.loads(
51
+ config_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_fields
52
+ )
53
+ if (
54
+ not isinstance(document, dict)
55
+ or set(document) != {"version", "email_exemptions"}
56
+ or type(document["version"]) is not int
57
+ or document["version"] != 1
58
+ or not isinstance(document["email_exemptions"], list)
59
+ ):
60
+ raise ValueError("Invalid repository policy configuration schema")
61
+ return document["email_exemptions"]
62
+
63
+
64
+ def _unique_fields(all_pairs: list[tuple[str, object]]) -> dict[str, object]:
65
+ fields: dict[str, object] = {}
66
+ for each_key, each_content in all_pairs:
67
+ if each_key in fields:
68
+ raise PolicyConfigurationRunFatal("Duplicate repository policy field")
69
+ fields[each_key] = each_content
70
+ return fields
71
+
72
+
73
+ def _entry_identity(entry: object) -> tuple[str, str, str]:
74
+ if not isinstance(entry, dict) or set(entry) != ALL_ENTRY_FIELDS:
75
+ raise ValueError("Email exemptions require path, sha256, and reason")
76
+ relative_path, digest, reason = entry["path"], entry["sha256"], entry["reason"]
77
+ if not isinstance(relative_path, str) or not relative_path:
78
+ raise ValueError("Email exemption path must name one repository file")
79
+ if (
80
+ any(character in FORBIDDEN_PATH_CHARACTERS for character in relative_path)
81
+ or any(
82
+ segment in ALL_FORBIDDEN_PATH_SEGMENTS
83
+ for segment in relative_path.split("/")
84
+ )
85
+ or any(
86
+ ord(character) < FIRST_PRINTABLE_CODEPOINT for character in relative_path
87
+ )
88
+ ):
89
+ raise ValueError("Email exemption path must be a literal relative file path")
90
+ if not isinstance(digest, str) or DIGEST_PATTERN.fullmatch(digest) is None:
91
+ raise ValueError("Email exemption sha256 must contain 64 lowercase hex digits")
92
+ if not isinstance(reason, str) or not reason.strip():
93
+ raise ValueError(
94
+ "Email exemption reason must explain its public contact purpose"
95
+ )
96
+ return relative_path, "email", digest
@@ -138,15 +138,7 @@ def _iter_env_var_code_references(
138
138
  drift_module: ModuleType,
139
139
  drift_constants: ModuleType,
140
140
  ) -> Iterator[str]:
141
- is_inside_code_fence = False
142
- for each_line in content.splitlines():
143
- if drift_constants.CODE_FENCE_PATTERN.match(each_line) is not None:
144
- is_inside_code_fence = not is_inside_code_fence
145
- continue
146
- if is_inside_code_fence:
147
- continue
148
- if drift_constants.TABLE_ROW_PATTERN.match(each_line) is None:
149
- continue
141
+ for each_line in drift_module.iter_env_var_table_rows(content):
150
142
  maybe_code_reference = _code_reference_for_env_var_row(
151
143
  each_line,
152
144
  drift_module,
@@ -10,6 +10,7 @@ from types import ModuleType
10
10
  from policy_lint.config import constants as policy_constants
11
11
 
12
12
  from repository_checks.config import constants as repository_constants
13
+ from repository_checks.email_exemptions import load_email_exemptions
13
14
  from repository_checks.hook_modules import load_hooks_module
14
15
  from repository_checks.models import RepositoryFinding
15
16
 
@@ -29,6 +30,8 @@ def collect_tracked_secret_findings(
29
30
  scanner = load_hooks_module(repository_constants.PII_SCANNER_MODULE_NAME)
30
31
  exemption = load_hooks_module(repository_constants.REPOSITORY_EXEMPTION_MODULE_NAME)
31
32
  all_allowlisted_literals = exemption.repository_allowlisted_values(repository_root)
33
+ all_exact_exemptions = load_email_exemptions(repository_root)
34
+ all_exact_exemptions |= repository_constants.ALL_TRACKED_SECRET_EXACT_EXEMPTIONS
32
35
  all_findings: list[RepositoryFinding] = []
33
36
  for each_relative_path in all_tracked_paths:
34
37
  all_findings.extend(
@@ -37,6 +40,7 @@ def collect_tracked_secret_findings(
37
40
  each_relative_path,
38
41
  scanner,
39
42
  all_allowlisted_literals,
43
+ all_exact_exemptions,
40
44
  )
41
45
  )
42
46
  return all_findings
@@ -47,6 +51,7 @@ def _find_secrets_for_path(
47
51
  relative_path: str,
48
52
  scanner: ModuleType,
49
53
  all_allowlisted_literals: frozenset[str],
54
+ all_exact_exemptions: frozenset[tuple[str, str, str]],
50
55
  ) -> list[RepositoryFinding]:
51
56
  posix_relative_path = relative_path.replace(
52
57
  repository_constants.WINDOWS_PATH_SEPARATOR,
@@ -65,6 +70,7 @@ def _find_secrets_for_path(
65
70
  maybe_content,
66
71
  scanner,
67
72
  all_allowlisted_literals,
73
+ all_exact_exemptions,
68
74
  )
69
75
 
70
76
 
@@ -73,6 +79,7 @@ def _find_secret_matches(
73
79
  content: str,
74
80
  scanner: ModuleType,
75
81
  all_allowlisted_literals: frozenset[str],
82
+ all_exact_exemptions: frozenset[tuple[str, str, str]],
76
83
  ) -> list[RepositoryFinding]:
77
84
  return [
78
85
  _build_finding(
@@ -86,6 +93,7 @@ def _find_secret_matches(
86
93
  each_match.category,
87
94
  each_match.matched_text,
88
95
  all_allowlisted_literals,
96
+ all_exact_exemptions,
89
97
  )
90
98
  ]
91
99
 
@@ -127,16 +135,14 @@ def _should_report_match(
127
135
  category: str,
128
136
  matched_text: str,
129
137
  all_allowlisted_literals: frozenset[str],
138
+ all_exact_exemptions: frozenset[tuple[str, str, str]],
130
139
  ) -> bool:
131
140
  exact_exemption_identity = (
132
141
  posix_relative_path,
133
142
  category,
134
143
  _secret_digest(matched_text),
135
144
  )
136
- if (
137
- exact_exemption_identity
138
- in repository_constants.ALL_TRACKED_SECRET_EXACT_EXEMPTIONS
139
- ):
145
+ if exact_exemption_identity in all_exact_exemptions:
140
146
  return False
141
147
  return matched_text not in all_allowlisted_literals
142
148
 
@@ -15,21 +15,29 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
15
15
  import durable_post_lint
16
16
 
17
17
  SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
18
- VALID_PR_BODY = """## Summary
18
+ VALID_PR_BODY = """## Why
19
19
 
20
- Summary text.
20
+ Why text.
21
21
 
22
- ## Description
22
+ ## Scope
23
23
 
24
- Description text.
24
+ Scope text.
25
25
 
26
- ## Why
26
+ ## Tradeoffs
27
27
 
28
- Why text.
28
+ Tradeoffs text.
29
+
30
+ ## Blast Radius
31
+
32
+ Blast radius text.
33
+
34
+ ## Verification
29
35
 
30
- ## How
36
+ Verification text.
37
+ """
38
+ MINIMAL_PR_BODY = """## Why
31
39
 
32
- How text.
40
+ Why text.
33
41
 
34
42
  ## Verification
35
43
 
@@ -156,7 +164,7 @@ def test_pr_titles_reject_nonconventional_forms(title: str) -> None:
156
164
 
157
165
  @pytest.mark.parametrize(
158
166
  "missing_heading",
159
- ["Summary", "Description", "Why", "How", "Verification"],
167
+ durable_post_lint.ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
160
168
  )
161
169
  def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
162
170
  body_text = VALID_PR_BODY.replace(f"## {missing_heading}\n", "")
@@ -170,6 +178,31 @@ def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
170
178
  ]
171
179
 
172
180
 
181
+ @pytest.mark.parametrize("action", ["pr-create", "pr-edit"])
182
+ def should_accept_a_body_carrying_only_the_two_required_headings(action: str) -> None:
183
+ all_findings = durable_post_lint.lint_durable_post(
184
+ action=action,
185
+ title="fix(cli): tighten input handling",
186
+ body_text=MINIMAL_PR_BODY,
187
+ )
188
+ assert all_findings == ()
189
+
190
+
191
+ def should_still_accept_a_body_published_under_the_superseded_heading_set() -> None:
192
+ all_findings = durable_post_lint.lint_durable_post(
193
+ action="pr-edit",
194
+ title=None,
195
+ body_text=(
196
+ "## Summary\n\nSummary text.\n\n"
197
+ "## Description\n\nDescription text.\n\n"
198
+ "## Why\n\nWhy text.\n\n"
199
+ "## How\n\nHow text.\n\n"
200
+ "## Verification\n\nVerification text.\n"
201
+ ),
202
+ )
203
+ assert all_findings == ()
204
+
205
+
173
206
  def test_non_pr_body_needs_no_pr_headings() -> None:
174
207
  all_findings = durable_post_lint.lint_durable_post(
175
208
  action="issue-comment",
@@ -42,6 +42,14 @@ def test_incremental_blocker_removal_is_clean(tmp_path: Path) -> None:
42
42
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
43
43
 
44
44
 
45
+ def test_new_bash_pre_tool_use_dispatcher_registration_is_clean(tmp_path: Path) -> None:
46
+ current_document = _hook_document(
47
+ ["hooks/blocking/bash_pre_tool_use_dispatcher.py"],
48
+ '{"hooks": {}}',
49
+ )
50
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
51
+
52
+
45
53
  def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
46
54
  current_document = _hook_document(
47
55
  ["hooks/blocking/new.py"],
@@ -0,0 +1,121 @@
1
+ """Exact public-contact exceptions preserve all other scanner findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from repository_checks.email_exemptions import load_email_exemptions
11
+ from repository_checks.tracked_secrets import collect_tracked_secret_findings
12
+
13
+ CONTACT = "fixture-contact@company.io"
14
+ CHANGED_CONTACT = "changed-contact@company.io"
15
+ DIGEST = hashlib.sha256(CONTACT.encode()).hexdigest()
16
+
17
+
18
+ def _write_config(root: Path, entries: list[object]) -> None:
19
+ directory = root / "config"
20
+ directory.mkdir(exist_ok=True)
21
+ (directory / "repository-policy.json").write_text(
22
+ json.dumps({"version": 1, "email_exemptions": entries}), encoding="utf-8"
23
+ )
24
+
25
+
26
+ def _entry(**changes: object) -> dict[str, object]:
27
+ return {
28
+ "path": "contacts.py",
29
+ "sha256": DIGEST,
30
+ "reason": "Published support contact",
31
+ **changes,
32
+ }
33
+
34
+
35
+ def test_only_matching_email_and_path_are_exempt(tmp_path: Path) -> None:
36
+ _write_config(tmp_path, [_entry()])
37
+ (tmp_path / "contacts.py").write_text(
38
+ f"contact = '{CONTACT}'\nchanged = '{CHANGED_CONTACT}'\n", encoding="utf-8"
39
+ )
40
+ (tmp_path / "other.py").write_text(f"contact = '{CONTACT}'\n", encoding="utf-8")
41
+ findings = collect_tracked_secret_findings(tmp_path, ["contacts.py", "other.py"])
42
+ assert [finding.relative_path for finding in findings] == [
43
+ "contacts.py",
44
+ "other.py",
45
+ ]
46
+ assert all(finding.message.startswith("[email]") for finding in findings)
47
+
48
+
49
+ def test_matching_contact_has_no_findings(tmp_path: Path) -> None:
50
+ _write_config(tmp_path, [_entry()])
51
+ (tmp_path / "contacts.py").write_text(f"contact = '{CONTACT}'\n", encoding="utf-8")
52
+ assert collect_tracked_secret_findings(tmp_path, ["contacts.py"]) == []
53
+
54
+
55
+ @pytest.mark.parametrize(
56
+ "path",
57
+ [
58
+ "/contacts.py",
59
+ "C:/contacts.py",
60
+ "../contacts.py",
61
+ "src/../contacts.py",
62
+ "*.py",
63
+ "src/?.py",
64
+ "src/[a].py",
65
+ "src\\contacts.py",
66
+ "src//contacts.py",
67
+ "./contacts.py",
68
+ "",
69
+ ],
70
+ )
71
+ def test_rejects_nonliteral_or_nonrelative_paths(tmp_path: Path, path: str) -> None:
72
+ _write_config(tmp_path, [_entry(path=path)])
73
+ with pytest.raises(ValueError):
74
+ load_email_exemptions(tmp_path)
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ "entry",
79
+ [
80
+ None,
81
+ {},
82
+ _entry(reason=" "),
83
+ _entry(sha256="f" * 63),
84
+ _entry(sha256="z" * 64),
85
+ _entry(category="secret"),
86
+ ],
87
+ )
88
+ def test_rejects_invalid_entries(tmp_path: Path, entry: object) -> None:
89
+ _write_config(tmp_path, [entry])
90
+ with pytest.raises(ValueError):
91
+ load_email_exemptions(tmp_path)
92
+
93
+
94
+ def test_rejects_duplicate_entries(tmp_path: Path) -> None:
95
+ _write_config(tmp_path, [_entry(), _entry(reason="Another explanation")])
96
+ with pytest.raises(ValueError):
97
+ load_email_exemptions(tmp_path)
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ "content",
102
+ [
103
+ "{",
104
+ "[]",
105
+ '{"version":true,"email_exemptions":[]}',
106
+ '{"version":1,"email_exemptions":{}}',
107
+ '{"version":1,"email_exemptions":[],"extra":0}',
108
+ '{"version":1,"version":1,"email_exemptions":[]}',
109
+ ],
110
+ )
111
+ def test_malformed_config_stops_scanning(tmp_path: Path, content: str) -> None:
112
+ (tmp_path / "config").mkdir()
113
+ (tmp_path / "config" / "repository-policy.json").write_text(
114
+ content, encoding="utf-8"
115
+ )
116
+ with pytest.raises(ValueError):
117
+ collect_tracked_secret_findings(tmp_path, [])
118
+
119
+
120
+ def test_missing_config_has_no_exemptions(tmp_path: Path) -> None:
121
+ assert load_email_exemptions(tmp_path) == frozenset()
@@ -17,6 +17,9 @@ from repository_checks.config.constants import (
17
17
  FINDINGS_EXIT_CODE,
18
18
  SUCCESS_EXIT_CODE,
19
19
  )
20
+ from repository_checks.env_var_documentation import (
21
+ collect_env_var_documentation_findings,
22
+ )
20
23
  from repository_policy_test_support import (
21
24
  commit_tracked_files,
22
25
  initialize_repository,
@@ -131,3 +134,26 @@ def record_read_paths(monkeypatch: pytest.MonkeyPatch) -> list[Path]:
131
134
 
132
135
  monkeypatch.setattr(Path, "read_text", record_read_path)
133
136
  return all_read_paths
137
+
138
+
139
+ @pytest.mark.parametrize("header", ["Symbol", "Component", "Constant"])
140
+ def test_should_ignore_source_tables_before_reading_named_code(
141
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, header: str
142
+ ) -> None:
143
+ repository_root = tmp_path / "repo"
144
+ initialize_repository(repository_root)
145
+ write_text(repository_root / "workflow.py", "def run():\n return\n")
146
+ write_text(
147
+ repository_root / "docs" / "source-map.md",
148
+ f"| {header} | Source |\n"
149
+ "| --- | --- |\n"
150
+ "| `PROCESSING_VISIBLE` | `workflow.py` |\n",
151
+ )
152
+ commit_tracked_files(repository_root)
153
+ patch_unreadable_named_file(
154
+ monkeypatch, "workflow.py", "unrelated source unreadable"
155
+ )
156
+ assert (
157
+ collect_env_var_documentation_findings(repository_root, ["docs/source-map.md"])
158
+ == []
159
+ )