claude-dev-env 7.5.0 → 7.6.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/everything-search/scripts/everything_search.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +1 -0
- package/.agents/skills/fresh-branch/SKILL.md +1 -0
- package/.agents/skills/fresh-branch/scripts/fresh_branch_git_commands.py +3 -0
- package/.agents/skills/fresh-branch/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/session-continuity/README.md +19 -9
- package/.agents/skills/syncing-submodules/SKILL.md +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +4 -0
- package/.agents/skills/syncing-submodules/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/SKILL.md +1 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +5 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +11 -0
- package/.agents/skills/test-runner/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +9 -7
- package/_shared/pr-loop/scripts/check_bugbot_ci.py +3 -0
- package/_shared/pr-loop/scripts/check_convergence_gates.py +3 -0
- package/_shared/pr-loop/scripts/check_pending_reviews.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +5 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +4 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +3 -0
- package/_shared/pr-loop/scripts/codex_usage_probe.py +3 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +3 -0
- package/_shared/pr-loop/scripts/fetch_copilot_reviews.py +3 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +7 -0
- package/_shared/pr-loop/scripts/post_fix_reply.py +4 -0
- package/_shared/pr-loop/scripts/preflight.py +7 -0
- package/_shared/pr-loop/scripts/preflight_self_heal.py +5 -0
- package/_shared/pr-loop/scripts/run_codex_review.py +3 -0
- package/_shared/pr-loop/scripts/subprocess_window_access.py +41 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +5 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +3 -0
- package/_shared/process-tree/scripts/subprocess_window_access.py +41 -0
- package/bin/install-constants.mjs +4 -2
- package/bin/install-session-continuity.mjs +73 -30
- package/bin/install-session-continuity.test.mjs +52 -1
- package/bin/install.cursor-rules.test.mjs +57 -15
- package/bin/install.mjs +80 -15
- package/bin/install.test.mjs +57 -0
- package/bin/pstack-model-preferences.claude.json +23 -0
- package/hooks/advisory/pr_done_reminder.py +7 -0
- package/hooks/advisory/refactor_guard.py +8 -0
- package/hooks/advisory/test_pr_done_reminder.py +21 -0
- package/hooks/advisory/test_refactor_guard.py +40 -0
- package/hooks/blocking/block_main_commit.py +4 -0
- package/hooks/blocking/code_rules_comments.py +39 -5
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +3 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +19 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +4 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +25 -0
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +10 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +0 -2
- package/hooks/git-hooks/pull_request_handoff.py +3 -0
- package/hooks/git-hooks/subprocess_window_access.py +41 -0
- package/hooks/git-hooks/test_verification_start.py +4 -0
- package/hooks/git-hooks/verification_notice_context.py +3 -0
- package/hooks/git-hooks/verification_start.py +7 -8
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/subprocess_window.py +141 -0
- package/hooks/hooks_constants/subprocess_window_constants.py +9 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/test_subprocess_window.py +128 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +3 -0
- package/hooks/validation/mypy_validator.py +4 -0
- package/hooks/validators/README.md +1 -0
- package/hooks/validators/git_checks.py +4 -0
- package/hooks/validators/health_check.py +3 -0
- package/hooks/validators/hook_timing_harness.py +3 -0
- package/hooks/validators/mypy_integration.py +4 -0
- package/hooks/validators/ruff_integration.py +6 -0
- package/hooks/validators/run_all_validators.py +7 -0
- package/hooks/validators/subprocess_window_access.py +41 -0
- package/hooks/workflow/auto_formatter.py +8 -0
- package/hooks/workflow/test_auto_formatter.py +39 -0
- package/package.json +1 -1
- package/rules/pstack-models.md +4 -3
- package/scripts/automatic_advisory/checkout.py +8 -1
- package/scripts/automatic_advisory/cli.py +9 -3
- package/scripts/automatic_advisory/config/constants.py +0 -2
- package/scripts/automatic_advisory/execution.py +7 -1
- package/scripts/automatic_advisory/git.py +9 -1
- package/scripts/automatic_advisory/process_host.py +3 -0
- package/scripts/claude_chain_runner.py +3 -0
- package/scripts/gh_artifact_upload.py +3 -0
- package/scripts/grok_headless_runner.py +3 -0
- package/scripts/grok_patch_artifacts.py +4 -0
- package/scripts/grok_worker_preflight.py +3 -0
- package/scripts/invoke_code_review.py +3 -0
- package/scripts/local_verification/command_runner.py +3 -0
- package/scripts/local_verification/git_state.py +3 -0
- package/scripts/policy_lint/adapter_detectors.py +48 -2
- package/scripts/policy_lint/adapter_pairing.py +150 -2
- package/scripts/policy_lint/adapters.py +46 -4
- package/scripts/policy_lint/config/approved_test_pairs.py +25 -9
- package/scripts/policy_lint/config/constants.py +3 -0
- package/scripts/policy_lint/registry.py +1 -1
- package/scripts/policy_lint/selection_git.py +11 -0
- package/scripts/repository_checks/config/constants.py +5 -0
- package/scripts/repository_checks/config/policy_document.py +19 -0
- package/scripts/repository_checks/email_exemptions.py +14 -71
- package/scripts/repository_checks/path_exemptions.py +35 -0
- package/scripts/repository_checks/policy_document.py +120 -0
- package/scripts/repository_checks/tracked_secrets.py +27 -4
- package/scripts/setup_project_paths.py +3 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/subprocess_window_access.py +41 -0
- package/scripts/test_dispatcher_profile_import.py +4 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +17 -11
- package/scripts/tests/test_email_exemptions.py +37 -0
- package/scripts/tests/test_path_exemptions.py +164 -0
- package/scripts/tests/test_policy_document.py +104 -0
- package/scripts/tests/test_policy_lint_rules_function_length_scope.py +101 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +375 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +116 -0
- package/scripts/tests/test_policy_lint_selection.py +34 -0
- package/scripts/tests/test_subprocess_window_access.py +118 -0
- package/scripts/tests/test_tracked_secrets.py +79 -0
- package/scripts/validate_instruction_pairs.py +3 -0
- package/scripts/verify_installable_package.py +5 -0
- package/scripts/automatic_advisory/window_flags.py +0 -41
- package/scripts/repository_checks/config/email_exemptions.py +0 -10
|
@@ -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
|
-
|
|
63
|
-
|
|
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,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
|
|
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(
|
|
45
|
-
assert
|
|
46
|
-
assert
|
|
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
|
|
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 =
|
|
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=
|
|
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"] ==
|
|
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 == [
|
|
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 =
|
|
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
|
-
==
|
|
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()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The shared policy-document reader and entry validator."""
|
|
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.policy_document import (
|
|
11
|
+
entry_path_and_digest,
|
|
12
|
+
read_policy_entries,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
RELATIVE_PATH = "evidence/manifest.json"
|
|
16
|
+
DIGEST = hashlib.sha256(b"frozen bytes").hexdigest()
|
|
17
|
+
CONFIG_RELATIVE_PATH = "config/repository-policy.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_config(root: Path, document: object) -> None:
|
|
21
|
+
config_path = root / CONFIG_RELATIVE_PATH
|
|
22
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
config_path.write_text(json.dumps(document), encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _entry(**changes: object) -> dict[str, object]:
|
|
27
|
+
return {
|
|
28
|
+
"path": RELATIVE_PATH,
|
|
29
|
+
"sha256": DIGEST,
|
|
30
|
+
"reason": "Frozen evidence file pinned by its consumers",
|
|
31
|
+
**changes,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_reader_returns_the_recorded_entries_without_validating_them(
|
|
36
|
+
tmp_path: Path,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""The reader validates the document, and the caller validates each entry."""
|
|
39
|
+
_write_config(tmp_path, {"version": 1, "path_exemptions": [{"anything": 1}]})
|
|
40
|
+
assert read_policy_entries(tmp_path, "path_exemptions") == [{"anything": 1}]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_reader_returns_no_entries_for_a_field_the_document_omits(
|
|
44
|
+
tmp_path: Path,
|
|
45
|
+
) -> None:
|
|
46
|
+
_write_config(tmp_path, {"version": 1, "path_exemptions": [_entry()]})
|
|
47
|
+
assert read_policy_entries(tmp_path, "email_exemptions") == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_reader_rejects_a_configuration_symlinked_outside_the_repository(
|
|
51
|
+
tmp_path: Path,
|
|
52
|
+
) -> None:
|
|
53
|
+
outside_path = tmp_path / "outside-policy.json"
|
|
54
|
+
outside_path.write_text(json.dumps({"version": 1}), encoding="utf-8")
|
|
55
|
+
repository_root = tmp_path / "repo"
|
|
56
|
+
(repository_root / "config").mkdir(parents=True)
|
|
57
|
+
(repository_root / CONFIG_RELATIVE_PATH).symlink_to(outside_path)
|
|
58
|
+
with pytest.raises(ValueError) as rejection:
|
|
59
|
+
read_policy_entries(repository_root, "path_exemptions")
|
|
60
|
+
assert (
|
|
61
|
+
str(rejection.value)
|
|
62
|
+
== "Repository policy configuration must remain inside the repository"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_entry_validator_returns_the_path_and_digest_without_the_reason() -> None:
|
|
67
|
+
assert entry_path_and_digest(_entry(), "Path exemption") == (
|
|
68
|
+
RELATIVE_PATH,
|
|
69
|
+
DIGEST,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
("entry", "expected_message"),
|
|
75
|
+
[
|
|
76
|
+
({}, "Path exemptions require path, sha256, and reason"),
|
|
77
|
+
(_entry(path=1), "Path exemption path must name one repository file"),
|
|
78
|
+
(
|
|
79
|
+
_entry(path="../manifest.json"),
|
|
80
|
+
"Path exemption path must be a literal relative file path",
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
_entry(sha256="F" * 64),
|
|
84
|
+
"Path exemption sha256 must contain 64 lowercase hex digits",
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
_entry(reason=" "),
|
|
88
|
+
"Path exemption reason must explain why the exception is owned",
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
def test_entry_validator_names_the_subject_in_every_rejection(
|
|
93
|
+
entry: object, expected_message: str
|
|
94
|
+
) -> None:
|
|
95
|
+
"""One validator serves both families, so each message names its subject."""
|
|
96
|
+
with pytest.raises(ValueError) as rejection:
|
|
97
|
+
entry_path_and_digest(entry, "Path exemption")
|
|
98
|
+
assert str(rejection.value) == expected_message
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_entry_validator_uses_the_subject_it_is_given() -> None:
|
|
102
|
+
with pytest.raises(ValueError) as rejection:
|
|
103
|
+
entry_path_and_digest({}, "Email exemption")
|
|
104
|
+
assert str(rejection.value) == "Email exemptions require path, sha256, and reason"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Function-length diagnostics report only functions the change is responsible for.
|
|
2
|
+
|
|
3
|
+
The code-rule adapter runs the enforcer with ``defer_scope_to_caller`` set, so
|
|
4
|
+
the enforcer hands back every over-long function in the document and the caller
|
|
5
|
+
owns the scope decision. The engine's own changed-line filter keys on a
|
|
6
|
+
diagnostic's start line, and a function-length message carries no ``Line N:``
|
|
7
|
+
prefix, so before this scope pass a pre-existing long function blocked any
|
|
8
|
+
change that touched its file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
from policy_lint import adapters
|
|
16
|
+
from policy_lint.model import ContentOrigin, Diagnostic, Document
|
|
17
|
+
from policy_lint.selection import _changed_lines
|
|
18
|
+
|
|
19
|
+
FUNCTION_LENGTH_MESSAGE_FRAGMENT = "exceeds blocking threshold"
|
|
20
|
+
PRODUCTION_RELATIVE_PATH = "packages/claude-dev-env/scripts/sample_long_module.py"
|
|
21
|
+
OVER_THRESHOLD_BODY_LINE_COUNT = 70
|
|
22
|
+
UNDER_THRESHOLD_BODY_LINE_COUNT = 40
|
|
23
|
+
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _counting_function_source(function_name: str, body_line_count: int) -> str:
|
|
27
|
+
all_body_lines = [
|
|
28
|
+
f" running_total = running_total + {each_index}"
|
|
29
|
+
for each_index in range(body_line_count)
|
|
30
|
+
]
|
|
31
|
+
signature_lines = [f"def {function_name}(running_total: int) -> int:"]
|
|
32
|
+
return (
|
|
33
|
+
"\n".join(signature_lines + all_body_lines + [" return running_total"])
|
|
34
|
+
+ "\n"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _messages_for_document(document: Document) -> tuple[str, ...]:
|
|
39
|
+
all_diagnostics: tuple[Diagnostic, ...] = adapters.code_rule_diagnostics(
|
|
40
|
+
document, REPOSITORY_ROOT
|
|
41
|
+
)
|
|
42
|
+
return tuple(
|
|
43
|
+
each_diagnostic.message
|
|
44
|
+
for each_diagnostic in all_diagnostics
|
|
45
|
+
if FUNCTION_LENGTH_MESSAGE_FRAGMENT in each_diagnostic.message
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _function_length_messages(
|
|
50
|
+
prior_text: str | None, current_text: str
|
|
51
|
+
) -> tuple[str, ...]:
|
|
52
|
+
return _messages_for_document(
|
|
53
|
+
Document(
|
|
54
|
+
PurePosixPath(PRODUCTION_RELATIVE_PATH),
|
|
55
|
+
current_text,
|
|
56
|
+
prior_text,
|
|
57
|
+
_changed_lines(prior_text, current_text),
|
|
58
|
+
ContentOrigin.REVISION_DIFF,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_should_leave_an_untouched_long_function_alone() -> None:
|
|
64
|
+
prior_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
65
|
+
current_text = f"import os\nimport sys\n{prior_text}"
|
|
66
|
+
assert _function_length_messages(prior_text, current_text) == ()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_should_report_a_function_the_change_grew_past_the_threshold() -> None:
|
|
70
|
+
prior_text = _counting_function_source("main", UNDER_THRESHOLD_BODY_LINE_COUNT)
|
|
71
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
72
|
+
assert _function_length_messages(prior_text, current_text) != ()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_should_report_an_over_long_function_the_change_adds() -> None:
|
|
76
|
+
prior_text = _counting_function_source("existing", UNDER_THRESHOLD_BODY_LINE_COUNT)
|
|
77
|
+
added_text = _counting_function_source("added", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
78
|
+
assert _function_length_messages(prior_text, f"{prior_text}\n{added_text}") != ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_should_report_every_over_long_function_in_a_wholly_new_document() -> None:
|
|
82
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
83
|
+
assert _function_length_messages(None, current_text) != ()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_should_leave_a_long_function_alone_when_the_change_only_deletes() -> None:
|
|
87
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
88
|
+
prior_text = f"REMOVED_CONSTANT = 1\n{current_text}"
|
|
89
|
+
assert _function_length_messages(prior_text, current_text) == ()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_should_report_a_long_function_when_the_selection_names_no_changed_lines() -> None:
|
|
93
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
94
|
+
document = Document(
|
|
95
|
+
PurePosixPath(PRODUCTION_RELATIVE_PATH),
|
|
96
|
+
current_text,
|
|
97
|
+
None,
|
|
98
|
+
None,
|
|
99
|
+
ContentOrigin.WORKTREE,
|
|
100
|
+
)
|
|
101
|
+
assert _messages_for_document(document) != ()
|