claude-dev-env 2.36.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/_shared/advisor/scripts/README.md +1 -1
- package/.agents/skills/team-advisor/SKILL.md +11 -11
- package/_shared/advisor/advisor-protocol.md +51 -51
- package/_shared/advisor/reference/advisor-block.md +9 -9
- package/_shared/advisor/reference/astra-rung.md +39 -0
- package/_shared/advisor/reference/identity.md +4 -4
- package/_shared/advisor/reference/lifecycle.md +6 -6
- package/_shared/advisor/reference/spawn-walk-log.md +9 -9
- package/_shared/advisor/reference/third-party-bind.md +11 -11
- package/_shared/advisor/reference/warm-up.md +13 -13
- package/_shared/advisor/scripts/codex_astra_advisor.py +308 -0
- package/_shared/advisor/scripts/codex_astra_preflight.py +115 -0
- package/_shared/advisor/scripts/codex_astra_reply.py +159 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +3 -3
- package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +38 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +17 -18
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +271 -0
- package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +24 -0
- package/_shared/advisor/scripts/tests/test_codex_astra_reply.py +42 -0
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +55 -30
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +14 -4
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +14 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +138 -50
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -1
- package/docs/references/team-advisor-skill.md +3 -3
- package/hooks/validators/README.md +35 -1
- package/package.json +1 -1
- package/scripts/check.ps1 +18 -6
- package/scripts/policy_lint/adapter_detectors.py +42 -2
- package/scripts/policy_lint/adapters.py +24 -0
- package/scripts/policy_lint/config/constants.py +11 -0
- package/scripts/policy_lint/engine.py +1 -1
- package/scripts/policy_lint/model.py +1 -0
- package/scripts/policy_lint/registry.py +10 -1
- package/scripts/repository_checks/__init__.py +1 -0
- package/scripts/repository_checks/claude_md.py +108 -0
- package/scripts/repository_checks/config/__init__.py +1 -0
- package/scripts/repository_checks/config/constants.py +150 -0
- package/scripts/repository_checks/env_var_documentation.py +243 -0
- package/scripts/repository_checks/hook_modules.py +34 -0
- package/scripts/repository_checks/models.py +37 -0
- package/scripts/repository_checks/package_inventory.py +118 -0
- package/scripts/repository_checks/pytest_testpaths.py +84 -0
- package/scripts/repository_checks/reporting.py +39 -0
- package/scripts/repository_checks/runner.py +93 -0
- package/scripts/repository_checks/tracked_secrets.py +147 -0
- package/scripts/repository_checks/tracked_tree.py +28 -0
- package/scripts/repository_policy.py +120 -0
- package/scripts/tests/repository_policy_test_support.py +89 -0
- package/scripts/tests/test_cde_lint.py +25 -1
- package/scripts/tests/test_claude_md.py +68 -0
- package/scripts/tests/test_env_var_documentation.py +133 -0
- package/scripts/tests/test_hook_modules.py +17 -0
- package/scripts/tests/test_models.py +26 -0
- package/scripts/tests/test_package_inventory.py +96 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +117 -0
- package/scripts/tests/test_pytest_testpaths.py +66 -0
- package/scripts/tests/test_reporting.py +54 -0
- package/scripts/tests/test_repository_policy.py +71 -0
- package/scripts/tests/test_runner.py +59 -0
- package/scripts/tests/test_tracked_secrets.py +175 -0
- package/scripts/tests/test_tracked_tree.py +28 -0
- package/_shared/advisor/reference/sol-rung.md +0 -39
- package/_shared/advisor/scripts/codex_sol_advisor.py +0 -581
- package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +0 -38
- package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +0 -740
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Package inventory checks for committed trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
from policy_lint.config.constants import PATH_SEPARATOR, UTF8_ENCODING
|
|
10
|
+
|
|
11
|
+
from repository_checks.config.constants import (
|
|
12
|
+
ALL_ARCHIVED_SKILL_DIRECTORY_SEGMENTS,
|
|
13
|
+
CHECK_ID_PACKAGE_INVENTORY,
|
|
14
|
+
PACKAGE_INVENTORY_CONSTANTS_MODULE_NAME,
|
|
15
|
+
PACKAGE_INVENTORY_DETECTION_MODULE_NAME,
|
|
16
|
+
PACKAGE_INVENTORY_MESSAGE_TEMPLATE,
|
|
17
|
+
WINDOWS_PATH_SEPARATOR,
|
|
18
|
+
)
|
|
19
|
+
from repository_checks.hook_modules import load_hooks_module
|
|
20
|
+
from repository_checks.models import RepositoryFinding
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def collect_package_inventory_findings(
|
|
24
|
+
repository_root: Path, all_tracked_paths: Sequence[str]
|
|
25
|
+
) -> list[RepositoryFinding]:
|
|
26
|
+
"""Return tracked production files missing from package inventories.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
repository_root: Git repository root.
|
|
30
|
+
all_tracked_paths: Repository-relative tracked paths.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Findings for production files absent from their inventories.
|
|
34
|
+
"""
|
|
35
|
+
detection = load_hooks_module(PACKAGE_INVENTORY_DETECTION_MODULE_NAME)
|
|
36
|
+
inventory_constants = load_hooks_module(PACKAGE_INVENTORY_CONSTANTS_MODULE_NAME)
|
|
37
|
+
all_findings: list[RepositoryFinding] = []
|
|
38
|
+
for each_relative_path in all_tracked_paths:
|
|
39
|
+
maybe_finding = find_inventory_finding_for_path(
|
|
40
|
+
repository_root,
|
|
41
|
+
each_relative_path,
|
|
42
|
+
detection,
|
|
43
|
+
inventory_constants,
|
|
44
|
+
)
|
|
45
|
+
if maybe_finding is not None:
|
|
46
|
+
all_findings.append(maybe_finding)
|
|
47
|
+
return all_findings
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_inventory_finding_for_path(
|
|
51
|
+
repository_root: Path,
|
|
52
|
+
relative_path: str,
|
|
53
|
+
detection: ModuleType,
|
|
54
|
+
inventory_constants: ModuleType,
|
|
55
|
+
) -> RepositoryFinding | None:
|
|
56
|
+
"""Return an inventory finding for one tracked production file.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
repository_root: Git repository root.
|
|
60
|
+
relative_path: Repository-relative production path.
|
|
61
|
+
detection: Existing inventory detector module.
|
|
62
|
+
inventory_constants: Existing detector constants module.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The finding when the file is missing from its inventory.
|
|
66
|
+
"""
|
|
67
|
+
if _is_archived_skill_path(relative_path):
|
|
68
|
+
return None
|
|
69
|
+
absolute_path = repository_root / relative_path
|
|
70
|
+
if not absolute_path.is_file():
|
|
71
|
+
return None
|
|
72
|
+
if not detection.is_inventoried_production_file(str(absolute_path)):
|
|
73
|
+
return None
|
|
74
|
+
_require_readable_inventory_documents(absolute_path.parent, inventory_constants)
|
|
75
|
+
if detection.find_stale_inventory(str(absolute_path)) is None:
|
|
76
|
+
return None
|
|
77
|
+
return _build_finding(relative_path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_finding(relative_path: str) -> RepositoryFinding:
|
|
81
|
+
return RepositoryFinding(
|
|
82
|
+
CHECK_ID_PACKAGE_INVENTORY,
|
|
83
|
+
relative_path.replace(WINDOWS_PATH_SEPARATOR, PATH_SEPARATOR),
|
|
84
|
+
PACKAGE_INVENTORY_MESSAGE_TEMPLATE,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_archived_skill_path(relative_path: str) -> bool:
|
|
89
|
+
all_path_segments = relative_path.replace(
|
|
90
|
+
WINDOWS_PATH_SEPARATOR, PATH_SEPARATOR
|
|
91
|
+
).split(PATH_SEPARATOR)
|
|
92
|
+
segment_count = len(ALL_ARCHIVED_SKILL_DIRECTORY_SEGMENTS)
|
|
93
|
+
last_start_index = len(all_path_segments) - segment_count
|
|
94
|
+
for each_start_index in range(max(last_start_index + 1, 0)):
|
|
95
|
+
compared_segments = tuple(
|
|
96
|
+
all_path_segments[each_start_index : each_start_index + segment_count]
|
|
97
|
+
)
|
|
98
|
+
if compared_segments == ALL_ARCHIVED_SKILL_DIRECTORY_SEGMENTS:
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _require_readable_inventory_documents(
|
|
104
|
+
package_directory: Path, inventory_constants: ModuleType
|
|
105
|
+
) -> None:
|
|
106
|
+
for each_inventory_name in inventory_constants.ALL_INVENTORY_DOCUMENT_NAMES:
|
|
107
|
+
inventory_path = package_directory / each_inventory_name
|
|
108
|
+
if not inventory_path.is_file():
|
|
109
|
+
continue
|
|
110
|
+
inventory_path.read_text(encoding=UTF8_ENCODING)
|
|
111
|
+
if package_directory.name != inventory_constants.SCRIPTS_SUBDIRECTORY_NAME:
|
|
112
|
+
return
|
|
113
|
+
skill_inventory_path = (
|
|
114
|
+
package_directory.parent / inventory_constants.SKILL_INVENTORY_DOCUMENT_NAME
|
|
115
|
+
)
|
|
116
|
+
if not skill_inventory_path.is_file():
|
|
117
|
+
return
|
|
118
|
+
skill_inventory_path.read_text(encoding=UTF8_ENCODING)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pytest testpath checks for committed trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
import tomllib
|
|
10
|
+
from policy_lint.config import constants as policy_constants
|
|
11
|
+
|
|
12
|
+
from repository_checks.config import constants as repository_constants
|
|
13
|
+
from repository_checks.hook_modules import load_hooks_module
|
|
14
|
+
from repository_checks.models import RepositoryFinding
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def collect_pytest_testpath_findings(
|
|
18
|
+
repository_root: Path, all_tracked_paths: Sequence[str]
|
|
19
|
+
) -> list[RepositoryFinding]:
|
|
20
|
+
"""Return tracked tests outside explicit package testpaths.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
repository_root: Git repository root.
|
|
24
|
+
all_tracked_paths: Repository-relative tracked paths.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Findings for tests excluded by their package testpaths.
|
|
28
|
+
"""
|
|
29
|
+
pytest_module = load_hooks_module(repository_constants.PYTEST_TESTPATHS_MODULE_NAME)
|
|
30
|
+
all_findings: list[RepositoryFinding] = []
|
|
31
|
+
for each_relative_path in all_tracked_paths:
|
|
32
|
+
maybe_finding = _find_testpath_finding(
|
|
33
|
+
repository_root, each_relative_path, pytest_module
|
|
34
|
+
)
|
|
35
|
+
if maybe_finding is not None:
|
|
36
|
+
all_findings.append(maybe_finding)
|
|
37
|
+
return all_findings
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_testpath_finding(
|
|
41
|
+
repository_root: Path,
|
|
42
|
+
relative_path: str,
|
|
43
|
+
pytest_module: ModuleType,
|
|
44
|
+
) -> RepositoryFinding | None:
|
|
45
|
+
if not pytest_module.is_test_file(relative_path):
|
|
46
|
+
return None
|
|
47
|
+
absolute_path = repository_root / relative_path
|
|
48
|
+
if not absolute_path.is_file():
|
|
49
|
+
return None
|
|
50
|
+
_require_readable_inputs(absolute_path, repository_root)
|
|
51
|
+
if pytest_module.find_unregistered_test_directory(str(absolute_path)) is None:
|
|
52
|
+
return None
|
|
53
|
+
return RepositoryFinding(
|
|
54
|
+
repository_constants.CHECK_ID_PYTEST_TESTPATHS,
|
|
55
|
+
_to_posix_path(relative_path),
|
|
56
|
+
repository_constants.PYTEST_TESTPATH_MESSAGE_TEMPLATE,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _to_posix_path(relative_path: str) -> str:
|
|
61
|
+
return relative_path.replace(
|
|
62
|
+
repository_constants.WINDOWS_PATH_SEPARATOR,
|
|
63
|
+
policy_constants.PATH_SEPARATOR,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _require_readable_inputs(test_file: Path, repository_root: Path) -> None:
|
|
68
|
+
test_file.read_bytes()
|
|
69
|
+
resolved_repository_root = repository_root.resolve()
|
|
70
|
+
for each_directory in test_file.parents:
|
|
71
|
+
if not _is_inside_repository(each_directory, resolved_repository_root):
|
|
72
|
+
break
|
|
73
|
+
pyproject_path = each_directory / repository_constants.PYPROJECT_FILENAME
|
|
74
|
+
if not pyproject_path.is_file():
|
|
75
|
+
continue
|
|
76
|
+
tomllib.loads(pyproject_path.read_text(encoding=policy_constants.UTF8_ENCODING))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_inside_repository(directory: Path, resolved_repository_root: Path) -> bool:
|
|
80
|
+
try:
|
|
81
|
+
directory.resolve().relative_to(resolved_repository_root)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Stable text reports for committed-tree repository checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from repository_checks.config import constants as repository_constants
|
|
6
|
+
from repository_checks.models import RepositoryCheckReport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_report(report: RepositoryCheckReport) -> str:
|
|
10
|
+
"""Render findings and failed checks as newline-terminated text.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
report: Findings and failed check identifiers.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
The report body, or an empty string when the tree is clean.
|
|
17
|
+
"""
|
|
18
|
+
all_lines = [
|
|
19
|
+
repository_constants.FINDING_LINE_TEMPLATE.format(
|
|
20
|
+
check_id=each_finding.check_id,
|
|
21
|
+
relative_path=each_finding.relative_path,
|
|
22
|
+
message=each_finding.message,
|
|
23
|
+
)
|
|
24
|
+
for each_finding in report.all_findings
|
|
25
|
+
]
|
|
26
|
+
all_lines.extend(_failed_check_lines(report))
|
|
27
|
+
if not all_lines:
|
|
28
|
+
return repository_constants.EMPTY_REPORT_TEXT
|
|
29
|
+
return (
|
|
30
|
+
repository_constants.REPORT_LINE_SEPARATOR.join(all_lines)
|
|
31
|
+
+ repository_constants.REPORT_LINE_SEPARATOR
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _failed_check_lines(report: RepositoryCheckReport) -> list[str]:
|
|
36
|
+
return [
|
|
37
|
+
repository_constants.RULE_FAILED_LINE_TEMPLATE.format(check_id=each_check_id)
|
|
38
|
+
for each_check_id in report.all_failed_check_ids
|
|
39
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Fail-closed runner for committed-tree repository checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from repository_checks import tracked_tree
|
|
9
|
+
from repository_checks.claude_md import collect_claude_md_orphan_findings
|
|
10
|
+
from repository_checks.config import constants as repository_constants
|
|
11
|
+
from repository_checks.env_var_documentation import (
|
|
12
|
+
collect_env_var_documentation_findings,
|
|
13
|
+
)
|
|
14
|
+
from repository_checks.models import RepositoryCheckReport, RepositoryFinding
|
|
15
|
+
from repository_checks.package_inventory import collect_package_inventory_findings
|
|
16
|
+
from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
|
|
17
|
+
from repository_checks.tracked_secrets import collect_tracked_secret_findings
|
|
18
|
+
|
|
19
|
+
RepositoryCollector = Callable[[Path, Sequence[str]], list[RepositoryFinding]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_repository_checks(repository_root: Path) -> RepositoryCheckReport:
|
|
23
|
+
"""Run every committed-tree check and fail closed on collector errors.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
repository_root: Git repository root whose tracked files are scanned.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Sorted findings and any check identifiers that failed closed.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
all_tracked_paths = tracked_tree.tracked_relative_paths(repository_root)
|
|
33
|
+
except repository_constants.ALL_FAIL_CLOSED_EXCEPTION_TYPES:
|
|
34
|
+
return RepositoryCheckReport((), repository_constants.ALL_CHECK_IDS)
|
|
35
|
+
all_findings, all_failed_check_ids = _run_collectors(
|
|
36
|
+
repository_root, all_tracked_paths
|
|
37
|
+
)
|
|
38
|
+
return RepositoryCheckReport(
|
|
39
|
+
_sort_findings(all_findings), tuple(all_failed_check_ids)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run_collectors(
|
|
44
|
+
repository_root: Path, all_tracked_paths: Sequence[str]
|
|
45
|
+
) -> tuple[list[RepositoryFinding], list[str]]:
|
|
46
|
+
all_findings: list[RepositoryFinding] = []
|
|
47
|
+
all_failed_check_ids: list[str] = []
|
|
48
|
+
for each_check_id, each_collector in _all_collectors():
|
|
49
|
+
try:
|
|
50
|
+
all_findings.extend(each_collector(repository_root, all_tracked_paths))
|
|
51
|
+
except repository_constants.ALL_FAIL_CLOSED_EXCEPTION_TYPES:
|
|
52
|
+
all_failed_check_ids.append(each_check_id)
|
|
53
|
+
return all_findings, all_failed_check_ids
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sort_findings(
|
|
57
|
+
all_findings: list[RepositoryFinding],
|
|
58
|
+
) -> tuple[RepositoryFinding, ...]:
|
|
59
|
+
return tuple(
|
|
60
|
+
sorted(
|
|
61
|
+
all_findings,
|
|
62
|
+
key=lambda each_finding: (
|
|
63
|
+
each_finding.check_id,
|
|
64
|
+
each_finding.relative_path,
|
|
65
|
+
each_finding.message,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
|
|
72
|
+
return (
|
|
73
|
+
(
|
|
74
|
+
repository_constants.CHECK_ID_CLAUDE_MD_ORPHANS,
|
|
75
|
+
collect_claude_md_orphan_findings,
|
|
76
|
+
),
|
|
77
|
+
(
|
|
78
|
+
repository_constants.CHECK_ID_ENV_VAR_DOCUMENTATION,
|
|
79
|
+
collect_env_var_documentation_findings,
|
|
80
|
+
),
|
|
81
|
+
(
|
|
82
|
+
repository_constants.CHECK_ID_PACKAGE_INVENTORY,
|
|
83
|
+
collect_package_inventory_findings,
|
|
84
|
+
),
|
|
85
|
+
(
|
|
86
|
+
repository_constants.CHECK_ID_PYTEST_TESTPATHS,
|
|
87
|
+
collect_pytest_testpath_findings,
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
repository_constants.CHECK_ID_TRACKED_PERSONAL_DATA,
|
|
91
|
+
collect_tracked_secret_findings,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Tracked-secret checks for committed trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
|
|
10
|
+
from policy_lint.config import constants as policy_constants
|
|
11
|
+
|
|
12
|
+
from repository_checks.config import constants as repository_constants
|
|
13
|
+
from repository_checks.hook_modules import load_hooks_module
|
|
14
|
+
from repository_checks.models import RepositoryFinding
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def collect_tracked_secret_findings(
|
|
18
|
+
repository_root: Path, all_tracked_paths: Sequence[str]
|
|
19
|
+
) -> list[RepositoryFinding]:
|
|
20
|
+
"""Return non-exempt secrets found in tracked UTF-8 files.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
repository_root: Git repository root.
|
|
24
|
+
all_tracked_paths: Repository-relative tracked paths.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Findings with redacted previews for non-exempt secrets.
|
|
28
|
+
"""
|
|
29
|
+
scanner = load_hooks_module(repository_constants.PII_SCANNER_MODULE_NAME)
|
|
30
|
+
exemption = load_hooks_module(repository_constants.REPOSITORY_EXEMPTION_MODULE_NAME)
|
|
31
|
+
all_allowlisted_literals = exemption.repository_allowlisted_values(repository_root)
|
|
32
|
+
all_findings: list[RepositoryFinding] = []
|
|
33
|
+
for each_relative_path in all_tracked_paths:
|
|
34
|
+
all_findings.extend(
|
|
35
|
+
_find_secrets_for_path(
|
|
36
|
+
repository_root,
|
|
37
|
+
each_relative_path,
|
|
38
|
+
scanner,
|
|
39
|
+
all_allowlisted_literals,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
return all_findings
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_secrets_for_path(
|
|
46
|
+
repository_root: Path,
|
|
47
|
+
relative_path: str,
|
|
48
|
+
scanner: ModuleType,
|
|
49
|
+
all_allowlisted_literals: frozenset[str],
|
|
50
|
+
) -> list[RepositoryFinding]:
|
|
51
|
+
posix_relative_path = relative_path.replace(
|
|
52
|
+
repository_constants.WINDOWS_PATH_SEPARATOR,
|
|
53
|
+
policy_constants.PATH_SEPARATOR,
|
|
54
|
+
)
|
|
55
|
+
if scanner.is_path_exempt_from_pii_scan(posix_relative_path):
|
|
56
|
+
return []
|
|
57
|
+
maybe_content = _read_utf8_text(
|
|
58
|
+
repository_root,
|
|
59
|
+
repository_root / relative_path,
|
|
60
|
+
)
|
|
61
|
+
if maybe_content is None:
|
|
62
|
+
return []
|
|
63
|
+
return _find_secret_matches(
|
|
64
|
+
posix_relative_path,
|
|
65
|
+
maybe_content,
|
|
66
|
+
scanner,
|
|
67
|
+
all_allowlisted_literals,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_secret_matches(
|
|
72
|
+
posix_relative_path: str,
|
|
73
|
+
content: str,
|
|
74
|
+
scanner: ModuleType,
|
|
75
|
+
all_allowlisted_literals: frozenset[str],
|
|
76
|
+
) -> list[RepositoryFinding]:
|
|
77
|
+
return [
|
|
78
|
+
_build_finding(
|
|
79
|
+
posix_relative_path,
|
|
80
|
+
category=each_match.category,
|
|
81
|
+
preview=each_match.preview,
|
|
82
|
+
)
|
|
83
|
+
for each_match in scanner.scan_text_for_pii(content)
|
|
84
|
+
if _should_report_match(
|
|
85
|
+
posix_relative_path,
|
|
86
|
+
each_match.category,
|
|
87
|
+
each_match.matched_text,
|
|
88
|
+
all_allowlisted_literals,
|
|
89
|
+
)
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_utf8_text(repository_root: Path, absolute_path: Path) -> str | None:
|
|
94
|
+
if not absolute_path.is_file():
|
|
95
|
+
return None
|
|
96
|
+
_require_path_inside_repository(repository_root, absolute_path)
|
|
97
|
+
try:
|
|
98
|
+
return absolute_path.read_text(encoding=policy_constants.UTF8_ENCODING)
|
|
99
|
+
except UnicodeDecodeError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _require_path_inside_repository(repository_root: Path, absolute_path: Path) -> None:
|
|
104
|
+
resolved_repository_root = repository_root.resolve(strict=True)
|
|
105
|
+
resolved_absolute_path = absolute_path.resolve(strict=True)
|
|
106
|
+
try:
|
|
107
|
+
resolved_absolute_path.relative_to(resolved_repository_root)
|
|
108
|
+
except ValueError as e:
|
|
109
|
+
raise OSError(absolute_path) from e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_finding(
|
|
113
|
+
posix_relative_path: str, category: str, preview: str
|
|
114
|
+
) -> RepositoryFinding:
|
|
115
|
+
return RepositoryFinding(
|
|
116
|
+
repository_constants.CHECK_ID_TRACKED_PERSONAL_DATA,
|
|
117
|
+
posix_relative_path,
|
|
118
|
+
repository_constants.TRACKED_MATCH_MESSAGE_TEMPLATE.format(
|
|
119
|
+
category=category,
|
|
120
|
+
preview=preview,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _should_report_match(
|
|
126
|
+
posix_relative_path: str,
|
|
127
|
+
category: str,
|
|
128
|
+
matched_text: str,
|
|
129
|
+
all_allowlisted_literals: frozenset[str],
|
|
130
|
+
) -> bool:
|
|
131
|
+
exact_exemption_identity = (
|
|
132
|
+
posix_relative_path,
|
|
133
|
+
category,
|
|
134
|
+
_secret_digest(matched_text),
|
|
135
|
+
)
|
|
136
|
+
if (
|
|
137
|
+
exact_exemption_identity
|
|
138
|
+
in repository_constants.ALL_TRACKED_SECRET_EXACT_EXEMPTIONS
|
|
139
|
+
):
|
|
140
|
+
return False
|
|
141
|
+
return matched_text not in all_allowlisted_literals
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _secret_digest(matched_text: str) -> str:
|
|
145
|
+
return hashlib.sha256(
|
|
146
|
+
matched_text.encode(policy_constants.UTF8_ENCODING)
|
|
147
|
+
).hexdigest()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""List tracked worktree paths for the committed-tree checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from policy_lint.selection_git import git_bytes_for, split_nul_tokens
|
|
8
|
+
|
|
9
|
+
from repository_checks.config.constants import ALL_GIT_LS_FILES_ARGUMENTS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def tracked_relative_paths(repository_root: Path) -> tuple[str, ...]:
|
|
13
|
+
"""Return tracked paths as repository-relative posix strings.
|
|
14
|
+
|
|
15
|
+
::
|
|
16
|
+
|
|
17
|
+
git ls-files -z -> ("notes/CLAUDE.md", "src/app.py")
|
|
18
|
+
ok: paths are relative
|
|
19
|
+
flag: an absolute Windows path
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
repository_root: Git repository root.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Tracked relative paths in Git order.
|
|
26
|
+
"""
|
|
27
|
+
raw_paths = git_bytes_for(repository_root, ALL_GIT_LS_FILES_ARGUMENTS)
|
|
28
|
+
return split_nul_tokens(raw_paths)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Public command for the committed-tree repository checker."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Never, TextIO
|
|
11
|
+
|
|
12
|
+
from policy_lint.config.constants import ALL_GIT_ROOT_ARGUMENTS, UTF8_ENCODING
|
|
13
|
+
from policy_lint.selection_git import GitSelectionError, git_bytes_for
|
|
14
|
+
from repository_checks.config.constants import (
|
|
15
|
+
ALL_CHECK_IDS,
|
|
16
|
+
REPOSITORY_ROOT_FLAG,
|
|
17
|
+
USAGE_EXIT_CODE,
|
|
18
|
+
)
|
|
19
|
+
from repository_checks.reporting import render_report
|
|
20
|
+
from repository_checks.runner import run_repository_checks
|
|
21
|
+
|
|
22
|
+
__all__ = ["ALL_CHECK_IDS", "main"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _RepositoryPolicyUsageError(ValueError):
|
|
26
|
+
"""Raised when command arguments cannot form a repository scan."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _RepositoryPolicyArgumentParser(argparse.ArgumentParser):
|
|
30
|
+
def error(self, message: str) -> Never:
|
|
31
|
+
"""Raise a usage error for the caller.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
message: Parser error text.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
_RepositoryPolicyUsageError: Always.
|
|
38
|
+
"""
|
|
39
|
+
raise _RepositoryPolicyUsageError(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _discover_repository_root(starting_directory: Path) -> Path:
|
|
43
|
+
"""Find the Git root that contains the command's current directory.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
starting_directory: Directory where the command started.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The resolved Git repository root.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
_RepositoryPolicyUsageError: If the directory is not inside a Git repository.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
root_bytes = git_bytes_for(starting_directory.resolve(), ALL_GIT_ROOT_ARGUMENTS)
|
|
56
|
+
except (GitSelectionError, OSError) as error:
|
|
57
|
+
raise _RepositoryPolicyUsageError(
|
|
58
|
+
"Current directory is not inside a Git repository"
|
|
59
|
+
) from error
|
|
60
|
+
root_text = root_bytes.decode(UTF8_ENCODING).strip()
|
|
61
|
+
if not root_text:
|
|
62
|
+
raise _RepositoryPolicyUsageError("Git returned an empty repository root")
|
|
63
|
+
return Path(root_text).resolve()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
67
|
+
parser = _RepositoryPolicyArgumentParser(prog="repository_policy")
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
REPOSITORY_ROOT_FLAG,
|
|
70
|
+
dest="repository_root",
|
|
71
|
+
default=None,
|
|
72
|
+
help="Git repository root to scan.",
|
|
73
|
+
)
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(
|
|
78
|
+
all_arguments: Sequence[str],
|
|
79
|
+
*,
|
|
80
|
+
repository_root: Path,
|
|
81
|
+
stdout: TextIO = sys.stdout,
|
|
82
|
+
stderr: TextIO = sys.stderr,
|
|
83
|
+
) -> int:
|
|
84
|
+
"""Run committed-tree repository checks.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
all_arguments: Command arguments.
|
|
88
|
+
repository_root: Repository root used when the flag is omitted.
|
|
89
|
+
stdout: Output stream.
|
|
90
|
+
stderr: Error stream.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Zero when the tree is clean, one for findings, two for usage errors,
|
|
94
|
+
and three when a check fails closed.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
namespace = _build_parser().parse_args(list(all_arguments))
|
|
98
|
+
except _RepositoryPolicyUsageError as usage_error:
|
|
99
|
+
stderr.write(f"{usage_error}\n")
|
|
100
|
+
return USAGE_EXIT_CODE
|
|
101
|
+
selected_root = repository_root
|
|
102
|
+
if namespace.repository_root is not None:
|
|
103
|
+
selected_root = Path(namespace.repository_root).resolve()
|
|
104
|
+
report = run_repository_checks(selected_root)
|
|
105
|
+
stdout.write(render_report(report))
|
|
106
|
+
return report.exit_code
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
try:
|
|
111
|
+
shell_directory = Path.cwd()
|
|
112
|
+
parsed_namespace = _build_parser().parse_args(sys.argv[1:])
|
|
113
|
+
if parsed_namespace.repository_root is not None:
|
|
114
|
+
shell_repository_root = Path(parsed_namespace.repository_root).resolve()
|
|
115
|
+
else:
|
|
116
|
+
shell_repository_root = _discover_repository_root(shell_directory)
|
|
117
|
+
except _RepositoryPolicyUsageError as usage_error:
|
|
118
|
+
sys.stderr.write(f"{usage_error}\n")
|
|
119
|
+
raise SystemExit(USAGE_EXIT_CODE) from usage_error
|
|
120
|
+
raise SystemExit(main(sys.argv[1:], repository_root=shell_repository_root))
|