claude-dev-env 7.3.0 → 7.4.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/.agents/skills/session-continuity/continuity.mjs +1 -3
- package/.agents/skills/session-continuity/continuity.test.mjs +12 -4
- package/AGENTS.md +12 -2
- package/bin/install.agents-home.test.mjs +1 -0
- package/bin/install.codex-rules.test.mjs +2 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +57 -10
- package/bin/install.prune.test.mjs +1 -0
- package/bin/install.pstack-plugin.test.mjs +1 -0
- package/bin/install.settings-defaults.test.mjs +1 -0
- package/bin/install.test.mjs +118 -4
- package/bin/install.transaction.test.mjs +6 -0
- package/bin/install.uninstall-transaction.test.mjs +2 -0
- package/bin/pstack.mjs +24 -4
- package/bin/pstack.test.mjs +20 -10
- 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/pii_scanner.py +19 -29
- 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_pii_scanner.py +34 -24
- 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/pii_prevention_constants.py +10 -25
- package/package.json +1 -1
- 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/tests/test_email_exemptions.py +121 -0
- package/scripts/tests/test_env_var_documentation.py +26 -0
- package/scripts/tests/test_rules.py +25 -0
- package/scripts/tests/test_tracked_secrets.py +13 -0
|
@@ -55,17 +55,13 @@ def test_allows_hygiene_placeholder_username_example() -> None:
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def test_flags_unix_home_path() -> None:
|
|
58
|
-
all_unix_hits = scan_text_for_pii(
|
|
59
|
-
"config lives in /Users/fixture_real_user/.config"
|
|
60
|
-
)
|
|
58
|
+
all_unix_hits = scan_text_for_pii("config lives in /Users/fixture_real_user/.config")
|
|
61
59
|
assert any(each.category == "home-path" for each in all_unix_hits)
|
|
62
60
|
|
|
63
61
|
|
|
64
62
|
def test_flags_raw_escaped_windows_home_path() -> None:
|
|
65
63
|
all_home_hits = scan_text_for_pii(r"path is C:\\Users\\concrete-user\\file")
|
|
66
|
-
assert [each.matched_text for each in all_home_hits] == [
|
|
67
|
-
r"C:\\Users\\concrete-user"
|
|
68
|
-
]
|
|
64
|
+
assert [each.matched_text for each in all_home_hits] == [r"C:\\Users\\concrete-user"]
|
|
69
65
|
|
|
70
66
|
|
|
71
67
|
def test_flags_unc_windows_home_path_at_each_share_depth() -> None:
|
|
@@ -82,9 +78,7 @@ def test_flags_unc_windows_home_path_at_each_share_depth() -> None:
|
|
|
82
78
|
|
|
83
79
|
def test_flags_forward_slash_unc_home_paths() -> None:
|
|
84
80
|
all_home_hits = scan_text_for_pii(
|
|
85
|
-
"path is //server/Users/concrete-user/file"
|
|
86
|
-
"\n"
|
|
87
|
-
"path is //server/share/Users/other-user/file"
|
|
81
|
+
"path is //server/Users/concrete-user/file\npath is //server/share/Users/other-user/file"
|
|
88
82
|
)
|
|
89
83
|
assert [each.matched_text for each in all_home_hits] == [
|
|
90
84
|
"//server/Users/concrete-user",
|
|
@@ -112,9 +106,7 @@ def test_flags_absolute_and_file_uri_home_paths() -> None:
|
|
|
112
106
|
all_home_hits += scan_text_for_pii("path is file:///home/concrete-user/file")
|
|
113
107
|
all_home_hits += scan_text_for_pii("path is file://server/home/concrete-user/file")
|
|
114
108
|
all_home_hits += scan_text_for_pii("path is FILE://server/home/other-user/file")
|
|
115
|
-
all_home_hits += scan_text_for_pii(
|
|
116
|
-
"path is FILE://server/share/home/third-user/file"
|
|
117
|
-
)
|
|
109
|
+
all_home_hits += scan_text_for_pii("path is FILE://server/share/home/third-user/file")
|
|
118
110
|
assert [each.matched_text for each in all_home_hits] == [
|
|
119
111
|
"/home/concrete-user",
|
|
120
112
|
"file:///home/concrete-user",
|
|
@@ -191,9 +183,7 @@ def test_flags_github_token_aws_key_and_pem_header() -> None:
|
|
|
191
183
|
def test_secret_and_email_previews_are_redacted() -> None:
|
|
192
184
|
all_secret_hits = scan_text_for_pii(f"export TOKEN={SYNTHETIC_GITHUB_TOKEN}")
|
|
193
185
|
all_email_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
|
|
194
|
-
secret_finding = next(
|
|
195
|
-
each for each in all_secret_hits if each.category == "secret"
|
|
196
|
-
)
|
|
186
|
+
secret_finding = next(each for each in all_secret_hits if each.category == "secret")
|
|
197
187
|
email_finding = next(each for each in all_email_hits if each.category == "email")
|
|
198
188
|
assert secret_finding.matched_text == SYNTHETIC_GITHUB_TOKEN
|
|
199
189
|
assert SYNTHETIC_GITHUB_TOKEN not in secret_finding.preview
|
|
@@ -217,9 +207,7 @@ def test_flags_genuine_human_home_username() -> None:
|
|
|
217
207
|
|
|
218
208
|
def test_short_nonsafe_email_preview_is_fully_redacted() -> None:
|
|
219
209
|
all_email_hits = scan_text_for_pii("reach owner@acme.io")
|
|
220
|
-
email_finding = next(
|
|
221
|
-
each for each in all_email_hits if each.category == "email"
|
|
222
|
-
)
|
|
210
|
+
email_finding = next(each for each in all_email_hits if each.category == "email")
|
|
223
211
|
assert email_finding.matched_text == "owner@acme.io"
|
|
224
212
|
assert email_finding.preview == "[redacted]"
|
|
225
213
|
assert "owner" not in email_finding.preview
|
|
@@ -227,9 +215,7 @@ def test_short_nonsafe_email_preview_is_fully_redacted() -> None:
|
|
|
227
215
|
|
|
228
216
|
def test_home_path_preview_redacts_only_the_username() -> None:
|
|
229
217
|
all_home_hits = scan_text_for_pii(r"path is C:\Users\johnsmith\secret.txt")
|
|
230
|
-
home_finding = next(
|
|
231
|
-
each for each in all_home_hits if each.category == "home-path"
|
|
232
|
-
)
|
|
218
|
+
home_finding = next(each for each in all_home_hits if each.category == "home-path")
|
|
233
219
|
assert "johnsmith" not in home_finding.preview
|
|
234
220
|
assert "[redacted]" in home_finding.preview
|
|
235
221
|
assert "Users" in home_finding.preview
|
|
@@ -237,9 +223,7 @@ def test_home_path_preview_redacts_only_the_username() -> None:
|
|
|
237
223
|
|
|
238
224
|
def test_home_path_preview_redacts_home_segment_when_name_repeats_earlier() -> None:
|
|
239
225
|
all_home_hits = scan_text_for_pii(r"path is C:\Users\Users\secret.txt")
|
|
240
|
-
home_finding = next(
|
|
241
|
-
each for each in all_home_hits if each.category == "home-path"
|
|
242
|
-
)
|
|
226
|
+
home_finding = next(each for each in all_home_hits if each.category == "home-path")
|
|
243
227
|
assert home_finding.preview == r"C:\Users\[redacted]"
|
|
244
228
|
|
|
245
229
|
|
|
@@ -269,3 +253,29 @@ def test_path_exemptions_for_tests_license_and_self_modules() -> None:
|
|
|
269
253
|
def test_empty_path_still_scans_payload_text() -> None:
|
|
270
254
|
all_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
|
|
271
255
|
assert any(each.category == "email" for each in all_hits)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_home_match_stops_at_source_string_boundary() -> None:
|
|
259
|
+
findings = scan_text_for_pii('path = "C:/Users/concrete-user"\nnext_path = "C:/data"')
|
|
260
|
+
assert [(item.category, item.matched_text) for item in findings] == [
|
|
261
|
+
("home-path", "C:/Users/concrete-user")
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_allows_interpolated_home_username() -> None:
|
|
266
|
+
assert scan_text_for_pii('path = f"C:/Users/{username}/Documents"') == []
|
|
267
|
+
assert scan_text_for_pii('path = f"C:/Users/{username}"\nnext_path = "C:/data"') == []
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_template_username_does_not_exempt_literal_username() -> None:
|
|
271
|
+
findings = scan_text_for_pii('path = "C:/Users/concrete-user{suffix}/Documents"')
|
|
272
|
+
assert [item.category for item in findings] == ["home-path"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_preserves_apostrophes_and_parentheses_in_usernames() -> None:
|
|
276
|
+
findings = scan_text_for_pii("C:/Users/O'Neil/Documents\nC:/Users/Jane (Work)/Documents")
|
|
277
|
+
assert [item.matched_text for item in findings] == ["C:/Users/O'Neil", "C:/Users/Jane (Work)"]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_escaped_placeholder_username_uses_complete_separator() -> None:
|
|
281
|
+
assert scan_text_for_pii(r"C:\\Users\\example\\Documents") == []
|
|
@@ -16,6 +16,9 @@ __all__ = [
|
|
|
16
16
|
"SEPARATOR_CELL_PATTERN",
|
|
17
17
|
"BACKTICK_TOKEN_PATTERN",
|
|
18
18
|
"ENV_VAR_NAME_PATTERN",
|
|
19
|
+
"ENV_VAR_HEADER_PATTERN",
|
|
20
|
+
"ALL_GENERIC_ENV_VAR_HEADERS",
|
|
21
|
+
"ENV_VAR_HEADING_PATTERN",
|
|
19
22
|
"ALL_CODE_FILE_EXTENSIONS",
|
|
20
23
|
"ALL_NOISE_DIRECTORY_NAMES",
|
|
21
24
|
"GIT_DIRECTORY_NAME",
|
|
@@ -34,10 +37,13 @@ CODE_FENCE_PATTERN = re.compile(r"^\s*(```|~~~)")
|
|
|
34
37
|
SEPARATOR_CELL_PATTERN = re.compile(r"^[:\-\s]+$")
|
|
35
38
|
BACKTICK_TOKEN_PATTERN = re.compile(r"`([^`]+)`")
|
|
36
39
|
ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,}$")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
{".py", ".mjs", ".js", ".ts", ".ps1", ".sh"}
|
|
40
|
+
ENV_VAR_HEADER_PATTERN = re.compile(
|
|
41
|
+
r"^(?:(?:environment|env)[ -]+)?(?:variables?|vars?)(?:[ -]+(?:name|key))?$", re.I
|
|
40
42
|
)
|
|
43
|
+
ALL_GENERIC_ENV_VAR_HEADERS = frozenset({"name", "key"})
|
|
44
|
+
ENV_VAR_HEADING_PATTERN = re.compile(r"\b(?:environment|env)[ -]+(?:variables?|vars?)\b", re.I)
|
|
45
|
+
|
|
46
|
+
ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset({".py", ".mjs", ".js", ".ts", ".ps1", ".sh"})
|
|
41
47
|
ALL_NOISE_DIRECTORY_NAMES: frozenset[str] = frozenset(
|
|
42
48
|
{".git", "__pycache__", "node_modules", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
|
|
43
49
|
)
|
|
@@ -61,4 +67,6 @@ DRIFT_ADDITIONAL_CONTEXT = (
|
|
|
61
67
|
"its source never references the variable name, the row is stale. Drop the "
|
|
62
68
|
"row, or point it at the variable the file actually reads."
|
|
63
69
|
)
|
|
64
|
-
DRIFT_SYSTEM_MESSAGE =
|
|
70
|
+
DRIFT_SYSTEM_MESSAGE = (
|
|
71
|
+
"Blocked: env-var summary table attributes a variable to a code file that does not read it."
|
|
72
|
+
)
|
|
@@ -4,15 +4,17 @@ import re
|
|
|
4
4
|
|
|
5
5
|
HARDCODED_USER_PATH_PATTERN: re.Pattern[str] = re.compile(
|
|
6
6
|
r"(?:"
|
|
7
|
-
r"[A-Za-z]:[\\/]+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[
|
|
8
|
-
r"|\\{2,}(?:[^\\/\r\n]+[\\/]+)+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[
|
|
9
|
-
r"|(?<!:)//(?:[^\\/\r\n]+/)+(?i:users)/(?!(?i:Public|Shared|All Users)(?:/|$))[
|
|
10
|
-
r"|(?i:file):///Users/(?!(?i:Shared|Public)(?:/|$))[
|
|
11
|
-
r"|(?i:file)://(?:[^/\r\n]+/)+(?i:Users)/(?!(?i:Shared|Public)(?:/|$))[
|
|
12
|
-
r"|(?i:file):///home/[
|
|
13
|
-
r"|(?i:file)://(?:[^/\r\n]+/)+(?i:home)/[
|
|
14
|
-
r"|(?<![A-Za-z0-9._:/\-\]])/Users/(?!(?i:Shared|Public)(?:/|$))[
|
|
15
|
-
r"|(?<![A-Za-z0-9._:/\-\]])/home/[
|
|
7
|
+
r"[A-Za-z]:[\\/]+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))(?:[^\\/\r\n\"`]+(?=[\\/])|[^\\/\r\n\"'`()]+(?=[\\/\r\n\"'`()]|$))"
|
|
8
|
+
r"|\\{2,}(?:[^\\/\r\n]+[\\/]+)+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))(?:[^\\/\r\n\"`]+(?=[\\/])|[^\\/\r\n\"'`()]+(?=[\\/\r\n\"'`()]|$))"
|
|
9
|
+
r"|(?<!:)//(?:[^\\/\r\n]+/)+(?i:users)/(?!(?i:Public|Shared|All Users)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
|
|
10
|
+
r"|(?i:file):///Users/(?!(?i:Shared|Public)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
|
|
11
|
+
r"|(?i:file)://(?:[^/\r\n]+/)+(?i:Users)/(?!(?i:Shared|Public)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
|
|
12
|
+
r"|(?i:file):///home/(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
|
|
13
|
+
r"|(?i:file)://(?:[^/\r\n]+/)+(?i:home)/(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
|
|
14
|
+
r"|(?<![A-Za-z0-9._:/\-\]])/Users/(?!(?i:Shared|Public)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
|
|
15
|
+
r"|(?<![A-Za-z0-9._:/\-\]])/home/(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$)))"
|
|
16
16
|
)
|
|
17
17
|
MAX_HARDCODED_USER_PATH_ISSUES: int = 25
|
|
18
|
-
HARDCODED_USER_PATH_GUIDANCE: str =
|
|
18
|
+
HARDCODED_USER_PATH_GUIDANCE: str = (
|
|
19
|
+
"use pathlib.Path.home() or os.path.expanduser('~') instead of a hardcoded user directory"
|
|
20
|
+
)
|
|
@@ -18,9 +18,7 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
|
|
|
18
18
|
BASH_TOOL_NAME: str = "Bash"
|
|
19
19
|
POWERSHELL_TOOL_NAME: str = "PowerShell"
|
|
20
20
|
|
|
21
|
-
ALL_SHELL_TOOL_NAMES: frozenset[str] = frozenset(
|
|
22
|
-
{BASH_TOOL_NAME, POWERSHELL_TOOL_NAME}
|
|
23
|
-
)
|
|
21
|
+
ALL_SHELL_TOOL_NAMES: frozenset[str] = frozenset({BASH_TOOL_NAME, POWERSHELL_TOOL_NAME})
|
|
24
22
|
|
|
25
23
|
ALL_GIT_BINARY_BASENAMES: frozenset[str] = frozenset({"git", "git.exe"})
|
|
26
24
|
GIT_COMMIT_SUBCOMMAND: str = "commit"
|
|
@@ -29,12 +27,8 @@ ALL_VALUE_TAKING_GIT_OPTIONS: frozenset[str] = frozenset(
|
|
|
29
27
|
{GIT_WORKING_DIRECTORY_OPTION, "-c", "--git-dir", "--work-tree", "--namespace"}
|
|
30
28
|
)
|
|
31
29
|
GIT_OPTION_WITH_VALUE_STEP: int = 2
|
|
32
|
-
ALL_SHELL_COMMAND_SEPARATOR_TOKENS: frozenset[str] = frozenset(
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
ALL_SHELL_KEYWORD_TOKENS: frozenset[str] = frozenset(
|
|
36
|
-
{"then", "do", "else", "elif"}
|
|
37
|
-
)
|
|
30
|
+
ALL_SHELL_COMMAND_SEPARATOR_TOKENS: frozenset[str] = frozenset({"&&", "||", ";", "|", "&"})
|
|
31
|
+
ALL_SHELL_KEYWORD_TOKENS: frozenset[str] = frozenset({"then", "do", "else", "elif"})
|
|
38
32
|
ALL_COMMAND_WRAPPER_TOKENS: frozenset[str] = frozenset(
|
|
39
33
|
{
|
|
40
34
|
"sudo",
|
|
@@ -85,9 +79,7 @@ SINGLE_DASH_OPTION_PREFIX: str = "-"
|
|
|
85
79
|
DOUBLE_DASH_OPTION_PREFIX: str = "--"
|
|
86
80
|
ALL_SHELL_QUOTE_CHARACTERS: frozenset[str] = frozenset({'"', "'"})
|
|
87
81
|
ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS: frozenset[str] = frozenset({"\n", "\r"})
|
|
88
|
-
ENVIRONMENT_ASSIGNMENT_PATTERN: re.Pattern[str] = re.compile(
|
|
89
|
-
r"^[A-Za-z_][A-Za-z0-9_]*="
|
|
90
|
-
)
|
|
82
|
+
ENVIRONMENT_ASSIGNMENT_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
91
83
|
LINE_CONTINUATION_PATTERN: re.Pattern[str] = re.compile(r"\\\r?\n")
|
|
92
84
|
POWERSHELL_LINE_CONTINUATION_PATTERN: re.Pattern[str] = re.compile(r"`\r?\n")
|
|
93
85
|
|
|
@@ -223,9 +215,7 @@ CATEGORY_HOME_PATH: str = "home-path"
|
|
|
223
215
|
CATEGORY_PRIVATE_IP: str = "private-ip"
|
|
224
216
|
CATEGORY_SECRET: str = "secret"
|
|
225
217
|
|
|
226
|
-
ALL_REDACTED_PREVIEW_CATEGORIES: frozenset[str] = frozenset(
|
|
227
|
-
{CATEGORY_EMAIL, CATEGORY_SECRET}
|
|
228
|
-
)
|
|
218
|
+
ALL_REDACTED_PREVIEW_CATEGORIES: frozenset[str] = frozenset({CATEGORY_EMAIL, CATEGORY_SECRET})
|
|
229
219
|
REDACTED_PREVIEW_PREFIX_LENGTH: int = 4
|
|
230
220
|
REDACTED_PREVIEW_SUFFIX_LENGTH: int = 4
|
|
231
221
|
REDACTED_PREVIEW_REVEALED_LENGTH: int = (
|
|
@@ -238,9 +228,7 @@ MINIMUM_LENGTH_FOR_PARTIAL_REDACTION: int = (
|
|
|
238
228
|
REDACTED_PREVIEW_REVEALED_LENGTH + REDACTED_PREVIEW_MINIMUM_HIDDEN_LENGTH
|
|
239
229
|
)
|
|
240
230
|
|
|
241
|
-
EMAIL_PATTERN: re.Pattern[str] = re.compile(
|
|
242
|
-
r"(?i)\b([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})\b"
|
|
243
|
-
)
|
|
231
|
+
EMAIL_PATTERN: re.Pattern[str] = re.compile(r"(?i)\b([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})\b")
|
|
244
232
|
|
|
245
233
|
IPV4_PATTERN: re.Pattern[str] = re.compile(
|
|
246
234
|
r"\b((?:25[0-5]|2[0-4]\d|1?\d?\d)\."
|
|
@@ -249,13 +237,9 @@ IPV4_PATTERN: re.Pattern[str] = re.compile(
|
|
|
249
237
|
r"(?:25[0-5]|2[0-4]\d|1?\d?\d))\b"
|
|
250
238
|
)
|
|
251
239
|
|
|
252
|
-
GITHUB_TOKEN_PATTERN: re.Pattern[str] = re.compile(
|
|
253
|
-
r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b"
|
|
254
|
-
)
|
|
240
|
+
GITHUB_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b")
|
|
255
241
|
|
|
256
|
-
GITHUB_FINE_GRAINED_TOKEN_PATTERN: re.Pattern[str] = re.compile(
|
|
257
|
-
r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"
|
|
258
|
-
)
|
|
242
|
+
GITHUB_FINE_GRAINED_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b")
|
|
259
243
|
|
|
260
244
|
AWS_ACCESS_KEY_PATTERN: re.Pattern[str] = re.compile(r"\bAKIA[0-9A-Z]{16}\b")
|
|
261
245
|
|
|
@@ -264,8 +248,9 @@ PEM_PRIVATE_KEY_HEADER_PATTERN: re.Pattern[str] = re.compile(
|
|
|
264
248
|
)
|
|
265
249
|
|
|
266
250
|
HOME_PATH_PATTERN: re.Pattern[str] = HARDCODED_USER_PATH_PATTERN
|
|
251
|
+
INTERPOLATED_HOME_USERNAME_PATTERN: re.Pattern[str] = re.compile(r"^\{[A-Za-z_][A-Za-z0-9_]*\}$")
|
|
267
252
|
|
|
268
|
-
|
|
253
|
+
HOME_USERNAME_MARKER_PATTERN: re.Pattern[str] = re.compile(r"[\\/]+(?:users|home)[\\/]+", re.I)
|
|
269
254
|
|
|
270
255
|
ALL_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
271
256
|
GITHUB_TOKEN_PATTERN,
|
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -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()
|