claude-dev-env 7.4.1 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/.agents/skills/everything-search/scripts/everything_search.py +11 -0
  2. package/.agents/skills/everything-search/scripts/test_everything_search.py +1 -0
  3. package/.agents/skills/fresh-branch/SKILL.md +1 -0
  4. package/.agents/skills/fresh-branch/scripts/fresh_branch_git_commands.py +3 -0
  5. package/.agents/skills/fresh-branch/scripts/subprocess_window_access.py +43 -0
  6. package/.agents/skills/syncing-submodules/SKILL.md +1 -0
  7. package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +4 -0
  8. package/.agents/skills/syncing-submodules/scripts/subprocess_window_access.py +43 -0
  9. package/.agents/skills/test-runner/SKILL.md +1 -0
  10. package/.agents/skills/test-runner/scripts/preflight_checks.py +5 -0
  11. package/.agents/skills/test-runner/scripts/run_tests.py +11 -0
  12. package/.agents/skills/test-runner/scripts/subprocess_window_access.py +43 -0
  13. package/.agents/skills/test-runner/scripts/test_run_tests.py +9 -7
  14. package/_shared/pr-loop/scripts/check_bugbot_ci.py +3 -0
  15. package/_shared/pr-loop/scripts/check_convergence_gates.py +3 -0
  16. package/_shared/pr-loop/scripts/check_pending_reviews.py +3 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +3 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +3 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +5 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +4 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +3 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +3 -0
  23. package/_shared/pr-loop/scripts/codex_usage_probe.py +3 -0
  24. package/_shared/pr-loop/scripts/copilot_quota.py +3 -0
  25. package/_shared/pr-loop/scripts/fetch_copilot_reviews.py +3 -0
  26. package/_shared/pr-loop/scripts/fix_hookspath.py +7 -0
  27. package/_shared/pr-loop/scripts/post_fix_reply.py +4 -0
  28. package/_shared/pr-loop/scripts/preflight.py +7 -0
  29. package/_shared/pr-loop/scripts/preflight_self_heal.py +5 -0
  30. package/_shared/pr-loop/scripts/run_codex_review.py +3 -0
  31. package/_shared/pr-loop/scripts/subprocess_window_access.py +41 -0
  32. package/_shared/pr-loop/scripts/terminology_sweep.py +5 -0
  33. package/_shared/process-tree/scripts/process_tree_kill.py +3 -0
  34. package/_shared/process-tree/scripts/subprocess_window_access.py +41 -0
  35. package/bin/install-constants.mjs +4 -2
  36. package/bin/install.cursor-rules.test.mjs +57 -15
  37. package/bin/install.mjs +10 -16
  38. package/bin/install.policy-lint.test.mjs +1 -2
  39. package/bin/install.test.mjs +0 -1
  40. package/bin/pstack-model-preferences.claude.json +23 -0
  41. package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
  42. package/hooks/advisory/pr_done_reminder.py +14 -13
  43. package/hooks/advisory/refactor_guard.py +8 -0
  44. package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
  45. package/hooks/advisory/test_pr_done_reminder.py +22 -0
  46. package/hooks/advisory/test_refactor_guard.py +40 -0
  47. package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
  48. package/hooks/blocking/block_main_commit.py +4 -0
  49. package/hooks/blocking/code_rules_comments.py +39 -5
  50. package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
  51. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -0
  52. package/hooks/blocking/precommit_code_rules_gate.py +3 -0
  53. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +19 -3
  54. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +4 -0
  55. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +25 -0
  56. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
  57. package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
  58. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +27 -0
  59. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +10 -0
  60. package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
  61. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +0 -2
  62. package/hooks/git-hooks/pull_request_handoff.py +3 -0
  63. package/hooks/git-hooks/subprocess_window_access.py +41 -0
  64. package/hooks/git-hooks/test_verification_start.py +4 -0
  65. package/hooks/git-hooks/verification_notice_context.py +3 -0
  66. package/hooks/git-hooks/verification_start.py +7 -8
  67. package/hooks/hooks.json +10 -0
  68. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
  69. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
  70. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -0
  71. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
  72. package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
  73. package/hooks/hooks_constants/post_tool_use_context.py +34 -0
  74. package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
  75. package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
  76. package/hooks/hooks_constants/subprocess_window.py +141 -0
  77. package/hooks/hooks_constants/subprocess_window_constants.py +9 -0
  78. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +6 -0
  81. package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
  82. package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
  83. package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
  84. package/hooks/hooks_constants/test_subprocess_window.py +128 -0
  85. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +3 -0
  86. package/hooks/observability/test_failure_recorder.py +3 -1
  87. package/hooks/validation/mypy_validator.py +4 -0
  88. package/hooks/validators/README.md +1 -0
  89. package/hooks/validators/git_checks.py +4 -0
  90. package/hooks/validators/health_check.py +3 -0
  91. package/hooks/validators/hook_timing_harness.py +3 -0
  92. package/hooks/validators/mypy_integration.py +4 -0
  93. package/hooks/validators/ruff_integration.py +6 -0
  94. package/hooks/validators/run_all_validators.py +7 -0
  95. package/hooks/validators/subprocess_window_access.py +41 -0
  96. package/hooks/workflow/auto_formatter.py +8 -0
  97. package/hooks/workflow/test_auto_formatter.py +39 -0
  98. package/package.json +1 -1
  99. package/rules/ask-user-question-required.md +3 -3
  100. package/rules/code-standards.md +3 -2
  101. package/rules/confirm-implementation-forks.md +2 -0
  102. package/rules/destructive-commands.md +19 -19
  103. package/rules/doc-inventory-integrity.md +4 -4
  104. package/rules/docstring-prose-matches-implementation.md +2 -2
  105. package/rules/failure-blast-radius.md +4 -2
  106. package/rules/filesystem-search.md +1 -1
  107. package/rules/git-workflow.md +34 -0
  108. package/rules/long-horizon-autonomy.md +2 -0
  109. package/rules/nas-ssh-invocation.md +3 -3
  110. package/rules/no-cross-skill-duplicate-helpers.md +6 -6
  111. package/rules/orphan-css-class.md +5 -5
  112. package/rules/paired-test-coverage.md +6 -6
  113. package/rules/plain-illustrative-docstrings.md +3 -3
  114. package/rules/pstack-models.md +4 -3
  115. package/rules/re-stage-before-commit.md +7 -7
  116. package/rules/retired-hook-prose.md +16 -0
  117. package/rules/shell-invocation.md +17 -1
  118. package/rules/testing.md +3 -5
  119. package/rules/verify-runtime-state.md +1 -1
  120. package/rules/windows-filesystem-safe.md +2 -2
  121. package/rules/workflow-substitution-slots.md +1 -1
  122. package/scripts/automatic_advisory/checkout.py +8 -1
  123. package/scripts/automatic_advisory/cli.py +9 -3
  124. package/scripts/automatic_advisory/config/constants.py +0 -2
  125. package/scripts/automatic_advisory/execution.py +7 -1
  126. package/scripts/automatic_advisory/git.py +9 -1
  127. package/scripts/automatic_advisory/process_host.py +3 -0
  128. package/scripts/claude_chain_runner.py +3 -0
  129. package/scripts/durable_post_lint_config/config/constants.py +0 -3
  130. package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
  131. package/scripts/gh_artifact_upload.py +3 -0
  132. package/scripts/grok_headless_runner.py +3 -0
  133. package/scripts/grok_patch_artifacts.py +4 -0
  134. package/scripts/grok_worker_preflight.py +3 -0
  135. package/scripts/invoke_code_review.py +3 -0
  136. package/scripts/local_verification/command_runner.py +3 -0
  137. package/scripts/local_verification/git_state.py +3 -0
  138. package/scripts/policy_lint/adapter_configuration.py +24 -0
  139. package/scripts/policy_lint/adapter_detectors.py +48 -2
  140. package/scripts/policy_lint/adapter_pairing.py +150 -2
  141. package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
  142. package/scripts/policy_lint/adapters.py +82 -5
  143. package/scripts/policy_lint/config/approved_test_pairs.py +25 -9
  144. package/scripts/policy_lint/config/constants.py +77 -0
  145. package/scripts/policy_lint/registry.py +6 -1
  146. package/scripts/policy_lint/selection.py +37 -18
  147. package/scripts/policy_lint/selection_git.py +17 -2
  148. package/scripts/repository_checks/config/constants.py +5 -0
  149. package/scripts/repository_checks/config/policy_document.py +19 -0
  150. package/scripts/repository_checks/email_exemptions.py +14 -71
  151. package/scripts/repository_checks/path_exemptions.py +35 -0
  152. package/scripts/repository_checks/policy_document.py +120 -0
  153. package/scripts/repository_checks/tracked_secrets.py +27 -4
  154. package/scripts/setup_project_paths.py +3 -0
  155. package/scripts/spawn_grok_batch.py +3 -0
  156. package/scripts/subprocess_window_access.py +41 -0
  157. package/scripts/test_dispatcher_profile_import.py +4 -0
  158. package/scripts/test_durable_post_lint.py +42 -9
  159. package/scripts/tests/test_adapter_configuration.py +8 -0
  160. package/scripts/tests/test_automatic_advisory_window_flags.py +17 -11
  161. package/scripts/tests/test_email_exemptions.py +37 -0
  162. package/scripts/tests/test_path_exemptions.py +164 -0
  163. package/scripts/tests/test_policy_document.py +104 -0
  164. package/scripts/tests/test_policy_lint_rules_function_length_scope.py +101 -0
  165. package/scripts/tests/test_policy_lint_rules_pairing.py +375 -0
  166. package/scripts/tests/test_policy_lint_rules_registry.py +116 -0
  167. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
  168. package/scripts/tests/test_policy_lint_selection.py +141 -0
  169. package/scripts/tests/test_subprocess_window_access.py +118 -0
  170. package/scripts/tests/test_tracked_secrets.py +79 -0
  171. package/scripts/validate_instruction_pairs.py +3 -0
  172. package/scripts/verify_installable_package.py +5 -0
  173. package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
  174. package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
  175. package/hooks/hooks_constants/test_text_stripping.py +0 -39
  176. package/hooks/hooks_constants/text_stripping.py +0 -36
  177. package/scripts/automatic_advisory/window_flags.py +0 -41
  178. package/scripts/repository_checks/config/email_exemptions.py +0 -10
@@ -0,0 +1,120 @@
1
+ """Read and validate the repository policy document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from repository_checks.config.policy_document import (
9
+ ALL_DOCUMENT_FIELDS,
10
+ ALL_ENTRY_FIELDS,
11
+ ALL_EXEMPTION_FIELDS,
12
+ ALL_FORBIDDEN_PATH_SEGMENTS,
13
+ CONFIG_RELATIVE_PATH,
14
+ DIGEST_PATTERN,
15
+ FIRST_PRINTABLE_CODEPOINT,
16
+ FORBIDDEN_PATH_CHARACTERS,
17
+ SUPPORTED_DOCUMENT_VERSION,
18
+ VERSION_FIELD,
19
+ )
20
+
21
+
22
+ class PolicyConfigurationRunFatal(ValueError):
23
+ """Reject the entire policy run when exception configuration is invalid."""
24
+
25
+
26
+ def read_policy_entries(repository_root: Path, field_name: str) -> list[object]:
27
+ """Read one exception list from the validated policy document.
28
+
29
+ Args:
30
+ repository_root: Repository containing the optional policy configuration.
31
+ field_name: Top-level document field holding the exception list.
32
+
33
+ Returns:
34
+ Raw entries recorded under the requested field.
35
+
36
+ Raises:
37
+ ValueError: Configuration contains invalid JSON or schema fields.
38
+ OSError: Configuration cannot be read.
39
+ """
40
+ config_path = repository_root / CONFIG_RELATIVE_PATH
41
+ if not config_path.exists() and not config_path.is_symlink():
42
+ return []
43
+ if not config_path.resolve().is_relative_to(repository_root.resolve()):
44
+ raise ValueError(
45
+ "Repository policy configuration must remain inside the repository"
46
+ )
47
+ document = json.loads(
48
+ config_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_fields
49
+ )
50
+ _require_valid_document(document)
51
+ return document.get(field_name, [])
52
+
53
+
54
+ def entry_path_and_digest(entry: object, subject: str) -> tuple[str, str]:
55
+ """Return the validated path and digest of one exception entry.
56
+
57
+ Args:
58
+ entry: Raw entry read from the policy document.
59
+ subject: Exception family named in each rejection message.
60
+
61
+ Returns:
62
+ Repository-relative path and its recorded sha256 digest.
63
+
64
+ Raises:
65
+ ValueError: The entry omits a field or carries an invalid value.
66
+ """
67
+ if not isinstance(entry, dict) or set(entry) != ALL_ENTRY_FIELDS:
68
+ raise ValueError(f"{subject}s require path, sha256, and reason")
69
+ relative_path, digest, reason = entry["path"], entry["sha256"], entry["reason"]
70
+ if not isinstance(relative_path, str) or not relative_path:
71
+ raise ValueError(f"{subject} path must name one repository file")
72
+ if _is_nonliteral_relative_path(relative_path):
73
+ raise ValueError(f"{subject} path must be a literal relative file path")
74
+ if not isinstance(digest, str) or DIGEST_PATTERN.fullmatch(digest) is None:
75
+ raise ValueError(f"{subject} sha256 must contain 64 lowercase hex digits")
76
+ if not isinstance(reason, str) or not reason.strip():
77
+ raise ValueError(f"{subject} reason must explain why the exception is owned")
78
+ return relative_path, digest
79
+
80
+
81
+ def _require_valid_document(document: object) -> None:
82
+ if (
83
+ not isinstance(document, dict)
84
+ or not set(document) <= ALL_DOCUMENT_FIELDS
85
+ or VERSION_FIELD not in document
86
+ or type(document[VERSION_FIELD]) is not int
87
+ or document[VERSION_FIELD] != SUPPORTED_DOCUMENT_VERSION
88
+ or not _has_only_list_exemption_fields(document)
89
+ ):
90
+ raise ValueError("Invalid repository policy configuration schema")
91
+
92
+
93
+ def _has_only_list_exemption_fields(all_document_fields: dict[str, object]) -> bool:
94
+ return all(
95
+ isinstance(all_document_fields[each_field], list)
96
+ for each_field in ALL_EXEMPTION_FIELDS
97
+ if each_field in all_document_fields
98
+ )
99
+
100
+
101
+ def _is_nonliteral_relative_path(relative_path: str) -> bool:
102
+ return (
103
+ any(character in FORBIDDEN_PATH_CHARACTERS for character in relative_path)
104
+ or any(
105
+ segment in ALL_FORBIDDEN_PATH_SEGMENTS
106
+ for segment in relative_path.split("/")
107
+ )
108
+ or any(
109
+ ord(character) < FIRST_PRINTABLE_CODEPOINT for character in relative_path
110
+ )
111
+ )
112
+
113
+
114
+ def _unique_fields(all_pairs: list[tuple[str, object]]) -> dict[str, object]:
115
+ fields: dict[str, object] = {}
116
+ for each_key, each_content in all_pairs:
117
+ if each_key in fields:
118
+ raise PolicyConfigurationRunFatal("Duplicate repository policy field")
119
+ fields[each_key] = each_content
120
+ return fields
@@ -13,6 +13,7 @@ from repository_checks.config import constants as repository_constants
13
13
  from repository_checks.email_exemptions import load_email_exemptions
14
14
  from repository_checks.hook_modules import load_hooks_module
15
15
  from repository_checks.models import RepositoryFinding
16
+ from repository_checks.path_exemptions import load_path_exemptions
16
17
 
17
18
 
18
19
  def collect_tracked_secret_findings(
@@ -32,6 +33,7 @@ def collect_tracked_secret_findings(
32
33
  all_allowlisted_literals = exemption.repository_allowlisted_values(repository_root)
33
34
  all_exact_exemptions = load_email_exemptions(repository_root)
34
35
  all_exact_exemptions |= repository_constants.ALL_TRACKED_SECRET_EXACT_EXEMPTIONS
36
+ all_path_exemptions = load_path_exemptions(repository_root)
35
37
  all_findings: list[RepositoryFinding] = []
36
38
  for each_relative_path in all_tracked_paths:
37
39
  all_findings.extend(
@@ -41,6 +43,7 @@ def collect_tracked_secret_findings(
41
43
  scanner,
42
44
  all_allowlisted_literals,
43
45
  all_exact_exemptions,
46
+ all_path_exemptions,
44
47
  )
45
48
  )
46
49
  return all_findings
@@ -52,6 +55,7 @@ def _find_secrets_for_path(
52
55
  scanner: ModuleType,
53
56
  all_allowlisted_literals: frozenset[str],
54
57
  all_exact_exemptions: frozenset[tuple[str, str, str]],
58
+ all_path_exemptions: frozenset[tuple[str, str]],
55
59
  ) -> list[RepositoryFinding]:
56
60
  posix_relative_path = relative_path.replace(
57
61
  repository_constants.WINDOWS_PATH_SEPARATOR,
@@ -59,12 +63,12 @@ def _find_secrets_for_path(
59
63
  )
60
64
  if scanner.is_path_exempt_from_pii_scan(posix_relative_path):
61
65
  return []
62
- maybe_content = _read_utf8_text(
63
- repository_root,
64
- repository_root / relative_path,
65
- )
66
+ absolute_path = repository_root / relative_path
67
+ maybe_content = _read_utf8_text(repository_root, absolute_path)
66
68
  if maybe_content is None:
67
69
  return []
70
+ if _is_frozen_file_exempt(absolute_path, posix_relative_path, all_path_exemptions):
71
+ return []
68
72
  return _find_secret_matches(
69
73
  posix_relative_path,
70
74
  maybe_content,
@@ -147,6 +151,25 @@ def _should_report_match(
147
151
  return matched_text not in all_allowlisted_literals
148
152
 
149
153
 
154
+ def _is_frozen_file_exempt(
155
+ absolute_path: Path,
156
+ posix_relative_path: str,
157
+ all_path_exemptions: frozenset[tuple[str, str]],
158
+ ) -> bool:
159
+ all_recorded_digests = {
160
+ each_digest
161
+ for each_exempt_path, each_digest in all_path_exemptions
162
+ if each_exempt_path == posix_relative_path
163
+ }
164
+ if not all_recorded_digests:
165
+ return False
166
+ return _file_digest(absolute_path) in all_recorded_digests
167
+
168
+
169
+ def _file_digest(absolute_path: Path) -> str:
170
+ return hashlib.sha256(absolute_path.read_bytes()).hexdigest()
171
+
172
+
150
173
  def _secret_digest(matched_text: str) -> str:
151
174
  return hashlib.sha256(
152
175
  matched_text.encode(policy_constants.UTF8_ENCODING)
@@ -38,6 +38,8 @@ from hooks_constants.setup_project_paths_constants import ( # noqa: E402
38
38
  WROTE_ENTRIES_STATUS_TEMPLATE,
39
39
  )
40
40
 
41
+ from subprocess_window_access import hidden_window_creation_flags
42
+
41
43
 
42
44
  class SchemaMismatchError(Exception):
43
45
  """Raised when the on-disk config declares a schema newer than this script supports."""
@@ -238,6 +240,7 @@ def _run_es_exe_folders_query() -> list[str]:
238
240
  text=True,
239
241
  encoding=UTF8_ENCODING,
240
242
  check=False,
243
+ creationflags=hidden_window_creation_flags(),
241
244
  )
242
245
  if completion.returncode != 0:
243
246
  truncated_stderr = completion.stderr[:STDERR_TRUNCATION_LENGTH].strip()
@@ -117,6 +117,8 @@ from dev_env_scripts_constants.timing import WORKER_STAGGER_SECONDS
117
117
  from grok_headless_runner import GrokRunnerOutcome, run_headless_worker
118
118
  from grok_worker_preflight import PreflightOutcome, run_preflight
119
119
 
120
+ from subprocess_window_access import hidden_window_creation_flags
121
+
120
122
  class AdvisorFailureError(ValueError):
121
123
  """Raised when an advisor bind, resume, or launcher call fails closed."""
122
124
 
@@ -263,6 +265,7 @@ def invoke_advisor_launcher(
263
265
  encoding=UTF8_ENCODING,
264
266
  check=False,
265
267
  timeout=MAXIMUM_ADVISOR_TIMEOUT_SECONDS,
268
+ creationflags=hidden_window_creation_flags(),
266
269
  )
267
270
  except FileNotFoundError as missing_launcher:
268
271
  raise AdvisorFailureError(
@@ -0,0 +1,41 @@
1
+ """Reach the hidden-window helpers from the scripts this directory deploys.
2
+
3
+ The installer copies this directory into a profile that holds no installed
4
+ Python distribution, and this repository's own tests run the deployed scripts
5
+ under ``python -S -E``, which hides every installed package from the child
6
+ process. On both routes ``hooks_constants`` is reachable only through the
7
+ deployed ``hooks`` directory, so this module puts that directory on
8
+ ``sys.path`` and re-exports the helpers a caller asks for::
9
+
10
+ from subprocess_window_access import hidden_window_creation_flags
11
+
12
+ The directory goes on the end of ``sys.path``. ``PathFinder`` runs ahead of
13
+ every editable-install finder, so a trailing entry still selects the deployed
14
+ copy, and the head of ``sys.path`` stays exactly as the caller left it. A
15
+ caller that prepended its own roots keeps them first.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ _subprocess_window_hooks_directory = str(
24
+ Path(__file__).resolve().parents[1] / "hooks"
25
+ )
26
+ if _subprocess_window_hooks_directory not in sys.path:
27
+ sys.path.append(_subprocess_window_hooks_directory)
28
+
29
+ from hooks_constants.subprocess_window import (
30
+ HiddenWindowStartupInfo,
31
+ detached_hidden_window_creation_flags,
32
+ hidden_window_creation_flags,
33
+ inherited_stream_startup_info,
34
+ )
35
+
36
+ __all__ = [
37
+ "HiddenWindowStartupInfo",
38
+ "detached_hidden_window_creation_flags",
39
+ "hidden_window_creation_flags",
40
+ "inherited_stream_startup_info",
41
+ ]
@@ -28,6 +28,10 @@ def _stage_profile_tree(
28
28
  target_profile_directory / "_shared" / each_shared_name
29
29
  )
30
30
  shutil.copytree(source_shared_directory, target_shared_directory)
31
+ shutil.copytree(
32
+ source_package_directory / "hooks" / "hooks_constants",
33
+ target_profile_directory / "hooks" / "hooks_constants",
34
+ )
31
35
  return target_scripts_directory
32
36
 
33
37
 
@@ -15,21 +15,29 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
15
15
  import durable_post_lint
16
16
 
17
17
  SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
18
- VALID_PR_BODY = """## Summary
18
+ VALID_PR_BODY = """## Why
19
19
 
20
- Summary text.
20
+ Why text.
21
21
 
22
- ## Description
22
+ ## Scope
23
23
 
24
- Description text.
24
+ Scope text.
25
25
 
26
- ## Why
26
+ ## Tradeoffs
27
27
 
28
- Why text.
28
+ Tradeoffs text.
29
+
30
+ ## Blast Radius
31
+
32
+ Blast radius text.
33
+
34
+ ## Verification
29
35
 
30
- ## How
36
+ Verification text.
37
+ """
38
+ MINIMAL_PR_BODY = """## Why
31
39
 
32
- How text.
40
+ Why text.
33
41
 
34
42
  ## Verification
35
43
 
@@ -156,7 +164,7 @@ def test_pr_titles_reject_nonconventional_forms(title: str) -> None:
156
164
 
157
165
  @pytest.mark.parametrize(
158
166
  "missing_heading",
159
- ["Summary", "Description", "Why", "How", "Verification"],
167
+ durable_post_lint.ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
160
168
  )
161
169
  def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
162
170
  body_text = VALID_PR_BODY.replace(f"## {missing_heading}\n", "")
@@ -170,6 +178,31 @@ def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
170
178
  ]
171
179
 
172
180
 
181
+ @pytest.mark.parametrize("action", ["pr-create", "pr-edit"])
182
+ def should_accept_a_body_carrying_only_the_two_required_headings(action: str) -> None:
183
+ all_findings = durable_post_lint.lint_durable_post(
184
+ action=action,
185
+ title="fix(cli): tighten input handling",
186
+ body_text=MINIMAL_PR_BODY,
187
+ )
188
+ assert all_findings == ()
189
+
190
+
191
+ def should_still_accept_a_body_published_under_the_superseded_heading_set() -> None:
192
+ all_findings = durable_post_lint.lint_durable_post(
193
+ action="pr-edit",
194
+ title=None,
195
+ body_text=(
196
+ "## Summary\n\nSummary text.\n\n"
197
+ "## Description\n\nDescription text.\n\n"
198
+ "## Why\n\nWhy text.\n\n"
199
+ "## How\n\nHow text.\n\n"
200
+ "## Verification\n\nVerification text.\n"
201
+ ),
202
+ )
203
+ assert all_findings == ()
204
+
205
+
173
206
  def test_non_pr_body_needs_no_pr_headings() -> None:
174
207
  all_findings = durable_post_lint.lint_durable_post(
175
208
  action="issue-comment",
@@ -42,6 +42,14 @@ def test_incremental_blocker_removal_is_clean(tmp_path: Path) -> None:
42
42
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
43
43
 
44
44
 
45
+ def test_new_bash_pre_tool_use_dispatcher_registration_is_clean(tmp_path: Path) -> None:
46
+ current_document = _hook_document(
47
+ ["hooks/blocking/bash_pre_tool_use_dispatcher.py"],
48
+ '{"hooks": {}}',
49
+ )
50
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
51
+
52
+
45
53
  def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
46
54
  current_document = _hook_document(
47
55
  ["hooks/blocking/new.py"],
@@ -15,7 +15,13 @@ if str(TESTS_DIRECTORY) not in sys.path:
15
15
  sys.path.insert(0, str(TESTS_DIRECTORY))
16
16
 
17
17
  import test_automatic_advisory as support
18
- from automatic_advisory import checkout, cli, execution, git, window_flags
18
+ from automatic_advisory import checkout, cli, execution, git
19
+
20
+ _subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[3] / "hooks")
21
+ if _subprocess_window_hooks_directory not in sys.path:
22
+ sys.path.append(_subprocess_window_hooks_directory)
23
+
24
+ from hooks_constants import subprocess_window
19
25
 
20
26
  WINDOWS_ONLY = pytest.mark.skipif(
21
27
  sys.platform != "win32", reason="Windows console flags"
@@ -41,19 +47,19 @@ Path(sys.argv[1]).write_text(completed.stdout, encoding="utf-8")
41
47
 
42
48
 
43
49
  def test_flags_are_zero_off_windows(monkeypatch: pytest.MonkeyPatch) -> None:
44
- monkeypatch.setattr(window_flags.sys, "platform", "linux")
45
- assert window_flags.hidden_window_creation_flags() == 0
46
- assert window_flags.detached_poller_creation_flags() == 0
50
+ monkeypatch.setattr(subprocess_window.sys, "platform", "linux")
51
+ assert subprocess_window.hidden_window_creation_flags() == 0
52
+ assert subprocess_window.detached_hidden_window_creation_flags() == 0
47
53
 
48
54
 
49
55
  @WINDOWS_ONLY
50
56
  def test_hidden_window_creation_flags_is_create_no_window() -> None:
51
- assert window_flags.hidden_window_creation_flags() == subprocess.CREATE_NO_WINDOW
57
+ assert subprocess_window.hidden_window_creation_flags() == subprocess.CREATE_NO_WINDOW
52
58
 
53
59
 
54
60
  @WINDOWS_ONLY
55
61
  def test_poller_flags_keep_a_hidden_console_instead_of_none() -> None:
56
- poller_flags = window_flags.detached_poller_creation_flags()
62
+ poller_flags = subprocess_window.detached_hidden_window_creation_flags()
57
63
  assert poller_flags & subprocess.DETACHED_PROCESS == 0
58
64
  assert poller_flags & subprocess.CREATE_NO_WINDOW
59
65
  assert poller_flags & subprocess.CREATE_NEW_PROCESS_GROUP
@@ -71,7 +77,7 @@ def test_poller_grandchild_gets_no_visible_console(tmp_path: Path) -> None:
71
77
  stdin=subprocess.DEVNULL,
72
78
  stdout=subprocess.DEVNULL,
73
79
  stderr=subprocess.DEVNULL,
74
- creationflags=window_flags.detached_poller_creation_flags(),
80
+ creationflags=subprocess_window.detached_hidden_window_creation_flags(),
75
81
  )
76
82
  assert _read_probe_report(report_path) == {"visible": False}
77
83
 
@@ -102,7 +108,7 @@ def test_git_fetch_hides_its_window(
102
108
 
103
109
  assert git._fetch_base(registration, candidate) is True
104
110
  assert (
105
- captured_options["creationflags"] == window_flags.hidden_window_creation_flags()
111
+ captured_options["creationflags"] == subprocess_window.hidden_window_creation_flags()
106
112
  )
107
113
 
108
114
 
@@ -122,7 +128,7 @@ def test_checkout_inspection_hides_its_window(
122
128
  local_checkout = checkout.read_local_checkout(tmp_path)
123
129
 
124
130
  assert local_checkout.head_sha == "abc123"
125
- assert all_captured_flags == [window_flags.hidden_window_creation_flags()] * 2
131
+ assert all_captured_flags == [subprocess_window.hidden_window_creation_flags()] * 2
126
132
 
127
133
 
128
134
  def test_windows_process_host_spawn_hides_its_window(
@@ -139,7 +145,7 @@ def test_windows_process_host_spawn_hides_its_window(
139
145
 
140
146
  execution._spawn_windows_process_host(("python", "-c", "pass"), registration, {})
141
147
 
142
- expected_flags = window_flags.hidden_window_creation_flags() | getattr(
148
+ expected_flags = subprocess_window.hidden_window_creation_flags() | getattr(
143
149
  subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0
144
150
  )
145
151
  assert captured_options["creationflags"] == expected_flags
@@ -159,5 +165,5 @@ def test_start_polling_launches_with_the_poller_flags(
159
165
  assert cli.start_polling(tmp_path / "settings.json") == 0
160
166
  assert (
161
167
  captured_options["creationflags"]
162
- == window_flags.detached_poller_creation_flags()
168
+ == subprocess_window.detached_hidden_window_creation_flags()
163
169
  )
@@ -119,3 +119,40 @@ def test_malformed_config_stops_scanning(tmp_path: Path, content: str) -> None:
119
119
 
120
120
  def test_missing_config_has_no_exemptions(tmp_path: Path) -> None:
121
121
  assert load_email_exemptions(tmp_path) == frozenset()
122
+
123
+
124
+ @pytest.mark.parametrize(
125
+ "document",
126
+ [
127
+ {"version": 1},
128
+ {
129
+ "version": 1,
130
+ "path_exemptions": [
131
+ {
132
+ "path": "evidence/manifest.json",
133
+ "sha256": DIGEST,
134
+ "reason": "Frozen evidence file pinned by its consumers",
135
+ }
136
+ ],
137
+ },
138
+ ],
139
+ )
140
+ def test_document_without_the_email_list_loads_no_email_exemptions(
141
+ tmp_path: Path, document: dict[str, object]
142
+ ) -> None:
143
+ """A document may carry the other family, or no exception list at all."""
144
+ (tmp_path / "config").mkdir()
145
+ (tmp_path / "config" / "repository-policy.json").write_text(
146
+ json.dumps(document), encoding="utf-8"
147
+ )
148
+ assert load_email_exemptions(tmp_path) == frozenset()
149
+
150
+
151
+ def test_reason_rejection_names_the_ownership_requirement(tmp_path: Path) -> None:
152
+ _write_config(tmp_path, [_entry(reason=" ")])
153
+ with pytest.raises(ValueError) as rejection:
154
+ load_email_exemptions(tmp_path)
155
+ assert (
156
+ str(rejection.value)
157
+ == "Email exemption reason must explain why the exception is owned"
158
+ )
@@ -0,0 +1,164 @@
1
+ """Frozen-file path exceptions preserve every other scanner finding."""
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.path_exemptions import load_path_exemptions
11
+ from repository_checks.tracked_secrets import collect_tracked_secret_findings
12
+
13
+ FROZEN_RELATIVE_PATH = "evidence/manifest.json"
14
+ OTHER_RELATIVE_PATH = "evidence/other.json"
15
+ HOME_PATH = "C:/Users/fixture-owner/source-document.md"
16
+ FROZEN_CONTENT = f'{{"source": "{HOME_PATH}"}}\n'
17
+ STALE_DIGEST = hashlib.sha256(b"different bytes").hexdigest()
18
+
19
+
20
+ def _write_config(root: Path, document: dict[str, object]) -> None:
21
+ directory = root / "config"
22
+ directory.mkdir(exist_ok=True)
23
+ (directory / "repository-policy.json").write_text(
24
+ json.dumps(document), encoding="utf-8"
25
+ )
26
+
27
+
28
+ def _write_frozen_file(root: Path, relative_path: str) -> str:
29
+ file_path = root / relative_path
30
+ file_path.parent.mkdir(parents=True, exist_ok=True)
31
+ file_path.write_text(FROZEN_CONTENT, encoding="utf-8")
32
+ return hashlib.sha256(file_path.read_bytes()).hexdigest()
33
+
34
+
35
+ def _entry(**changes: object) -> dict[str, object]:
36
+ return {
37
+ "path": FROZEN_RELATIVE_PATH,
38
+ "sha256": STALE_DIGEST,
39
+ "reason": "Frozen evidence file pinned by its consumers",
40
+ **changes,
41
+ }
42
+
43
+
44
+ def test_matching_digest_removes_every_finding(tmp_path: Path) -> None:
45
+ digest = _write_frozen_file(tmp_path, FROZEN_RELATIVE_PATH)
46
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [_entry(sha256=digest)]})
47
+ assert collect_tracked_secret_findings(tmp_path, [FROZEN_RELATIVE_PATH]) == []
48
+
49
+
50
+ def test_stale_digest_keeps_reporting_the_path(tmp_path: Path) -> None:
51
+ _write_frozen_file(tmp_path, FROZEN_RELATIVE_PATH)
52
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [_entry()]})
53
+ findings = collect_tracked_secret_findings(tmp_path, [FROZEN_RELATIVE_PATH])
54
+ assert [finding.relative_path for finding in findings] == [FROZEN_RELATIVE_PATH]
55
+ assert all(finding.message.startswith("[home-path]") for finding in findings)
56
+
57
+
58
+ def test_unlisted_path_keeps_reporting(tmp_path: Path) -> None:
59
+ digest = _write_frozen_file(tmp_path, FROZEN_RELATIVE_PATH)
60
+ _write_frozen_file(tmp_path, OTHER_RELATIVE_PATH)
61
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [_entry(sha256=digest)]})
62
+ findings = collect_tracked_secret_findings(
63
+ tmp_path, [FROZEN_RELATIVE_PATH, OTHER_RELATIVE_PATH]
64
+ )
65
+ assert [finding.relative_path for finding in findings] == [OTHER_RELATIVE_PATH]
66
+
67
+
68
+ def test_exempt_path_stays_out_of_the_email_exemption_identities(
69
+ tmp_path: Path,
70
+ ) -> None:
71
+ digest = _write_frozen_file(tmp_path, FROZEN_RELATIVE_PATH)
72
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [_entry(sha256=digest)]})
73
+ assert load_path_exemptions(tmp_path) == frozenset({(FROZEN_RELATIVE_PATH, digest)})
74
+
75
+
76
+ @pytest.mark.parametrize(
77
+ "path",
78
+ [
79
+ "/manifest.json",
80
+ "C:/manifest.json",
81
+ "../manifest.json",
82
+ "evidence/../manifest.json",
83
+ "*.json",
84
+ "evidence/?.json",
85
+ "evidence/[a].json",
86
+ "evidence\\manifest.json",
87
+ "evidence//manifest.json",
88
+ "./manifest.json",
89
+ "",
90
+ ],
91
+ )
92
+ def test_rejects_nonliteral_or_nonrelative_paths(tmp_path: Path, path: str) -> None:
93
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [_entry(path=path)]})
94
+ with pytest.raises(ValueError):
95
+ load_path_exemptions(tmp_path)
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ "entry",
100
+ [
101
+ None,
102
+ {},
103
+ _entry(reason=" "),
104
+ _entry(sha256="f" * 63),
105
+ _entry(sha256="z" * 64),
106
+ _entry(category="secret"),
107
+ ],
108
+ )
109
+ def test_rejects_invalid_entries(tmp_path: Path, entry: object) -> None:
110
+ _write_config(tmp_path, {"version": 1, "path_exemptions": [entry]})
111
+ with pytest.raises(ValueError):
112
+ load_path_exemptions(tmp_path)
113
+
114
+
115
+ def test_rejects_duplicate_entries(tmp_path: Path) -> None:
116
+ _write_config(
117
+ tmp_path,
118
+ {
119
+ "version": 1,
120
+ "path_exemptions": [_entry(), _entry(reason="Another explanation")],
121
+ },
122
+ )
123
+ with pytest.raises(ValueError):
124
+ load_path_exemptions(tmp_path)
125
+
126
+
127
+ @pytest.mark.parametrize(
128
+ "content",
129
+ [
130
+ "{",
131
+ "[]",
132
+ '{"version":true,"path_exemptions":[]}',
133
+ '{"version":1,"path_exemptions":{}}',
134
+ '{"version":1,"path_exemptions":[],"extra":0}',
135
+ '{"version":1,"path_exemptions":[],"path_exemptions":[]}',
136
+ ],
137
+ )
138
+ def test_malformed_config_stops_scanning(tmp_path: Path, content: str) -> None:
139
+ (tmp_path / "config").mkdir()
140
+ (tmp_path / "config" / "repository-policy.json").write_text(
141
+ content, encoding="utf-8"
142
+ )
143
+ with pytest.raises(ValueError):
144
+ collect_tracked_secret_findings(tmp_path, [])
145
+
146
+
147
+ @pytest.mark.parametrize(
148
+ "document",
149
+ [
150
+ {"version": 1},
151
+ {"version": 1, "email_exemptions": []},
152
+ {"version": 1, "path_exemptions": []},
153
+ {"version": 1, "email_exemptions": [], "path_exemptions": []},
154
+ ],
155
+ )
156
+ def test_documents_missing_one_list_still_load(
157
+ tmp_path: Path, document: dict[str, object]
158
+ ) -> None:
159
+ _write_config(tmp_path, document)
160
+ assert load_path_exemptions(tmp_path) == frozenset()
161
+
162
+
163
+ def test_missing_config_has_no_exemptions(tmp_path: Path) -> None:
164
+ assert load_path_exemptions(tmp_path) == frozenset()