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.
- package/bin/install.mjs +0 -1
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +4 -4
- package/bin/pstack.mjs +24 -4
- package/bin/pstack.test.mjs +20 -10
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +7 -13
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +1 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
- package/hooks/blocking/env_var_table_rows.py +130 -0
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/pii_scanner.py +19 -29
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
- package/hooks/blocking/test_env_var_table_rows.py +100 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/blocking/test_pii_scanner.py +34 -24
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +36 -1
- package/scripts/policy_lint/config/constants.py +74 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +6 -2
- package/scripts/pstack-adapters/common.md +1 -1
- package/scripts/repository_checks/config/constants.py +7 -17
- package/scripts/repository_checks/config/email_exemptions.py +10 -0
- package/scripts/repository_checks/email_exemptions.py +96 -0
- package/scripts/repository_checks/env_var_documentation.py +1 -9
- package/scripts/repository_checks/tracked_secrets.py +10 -4
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_email_exemptions.py +121 -0
- package/scripts/tests/test_env_var_documentation.py +26 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +107 -0
- package/scripts/tests/test_tracked_secrets.py +13 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- 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 =
|
|
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 =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
107
|
+
"6b98ff7f0398f06452f19c4a93f3d69186401dab40aaa7077b394ef59b3e730e",
|
|
118
108
|
),
|
|
119
109
|
(
|
|
120
|
-
"packages/claude-dev-env/
|
|
110
|
+
"packages/claude-dev-env/bin/expand_home_directory_tokens.mjs",
|
|
121
111
|
"home-path",
|
|
122
|
-
"
|
|
112
|
+
"31e989aa0fbf83d6de5d2f0cd6f08b3cd8151592050b71794ffa180b80385ff2",
|
|
123
113
|
),
|
|
124
114
|
(
|
|
125
115
|
"packages/claude-dev-env/bin/expand_home_directory_tokens.mjs",
|
|
126
116
|
"home-path",
|
|
127
|
-
"
|
|
117
|
+
"73ff793e488241663bb0c075fd50bd3a18fca8dd79cbd11629c00d2ad7548a8a",
|
|
128
118
|
),
|
|
129
119
|
(
|
|
130
120
|
"packages/claude-dev-env/bin/expand_home_directory_tokens.mjs",
|
|
131
121
|
"home-path",
|
|
132
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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 = """##
|
|
18
|
+
VALID_PR_BODY = """## Why
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Why text.
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Scope
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Scope text.
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Tradeoffs
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Tradeoffs text.
|
|
29
|
+
|
|
30
|
+
## Blast Radius
|
|
31
|
+
|
|
32
|
+
Blast radius text.
|
|
33
|
+
|
|
34
|
+
## Verification
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
Verification text.
|
|
37
|
+
"""
|
|
38
|
+
MINIMAL_PR_BODY = """## Why
|
|
31
39
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
+
)
|