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.
Files changed (35) hide show
  1. package/.agents/skills/session-continuity/continuity.mjs +1 -3
  2. package/.agents/skills/session-continuity/continuity.test.mjs +12 -4
  3. package/AGENTS.md +12 -2
  4. package/bin/install.agents-home.test.mjs +1 -0
  5. package/bin/install.codex-rules.test.mjs +2 -0
  6. package/bin/install.cursor-rules.test.mjs +1 -0
  7. package/bin/install.mjs +57 -10
  8. package/bin/install.prune.test.mjs +1 -0
  9. package/bin/install.pstack-plugin.test.mjs +1 -0
  10. package/bin/install.settings-defaults.test.mjs +1 -0
  11. package/bin/install.test.mjs +118 -4
  12. package/bin/install.transaction.test.mjs +6 -0
  13. package/bin/install.uninstall-transaction.test.mjs +2 -0
  14. package/bin/pstack.mjs +24 -4
  15. package/bin/pstack.test.mjs +20 -10
  16. package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
  17. package/hooks/blocking/env_var_table_rows.py +130 -0
  18. package/hooks/blocking/pii_scanner.py +19 -29
  19. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
  20. package/hooks/blocking/test_env_var_table_rows.py +100 -0
  21. package/hooks/blocking/test_pii_scanner.py +34 -24
  22. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
  23. package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
  24. package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
  25. package/package.json +1 -1
  26. package/scripts/pstack-adapters/common.md +1 -1
  27. package/scripts/repository_checks/config/constants.py +7 -17
  28. package/scripts/repository_checks/config/email_exemptions.py +10 -0
  29. package/scripts/repository_checks/email_exemptions.py +96 -0
  30. package/scripts/repository_checks/env_var_documentation.py +1 -9
  31. package/scripts/repository_checks/tracked_secrets.py +10 -4
  32. package/scripts/tests/test_email_exemptions.py +121 -0
  33. package/scripts/tests/test_env_var_documentation.py +26 -0
  34. package/scripts/tests/test_rules.py +25 -0
  35. 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
- ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
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 = "Blocked: env-var summary table attributes a variable to a code file that does not read it."
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 = "use pathlib.Path.home() or os.path.expanduser('~') instead of a hardcoded user directory"
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
- ALL_HOME_DIRECTORY_PATH_MARKERS: tuple[str, ...] = ("/users/", "/home/")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "7.3.0",
3
+ "version": "7.4.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
 
@@ -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()