claude-dev-env 2.34.0 → 2.36.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/autoconverge/SKILL.md +19 -15
- package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
- package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
- package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
- package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
- package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
- package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
- package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
- package/bin/cde.mjs +175 -0
- package/bin/cde.test.mjs +222 -0
- package/bin/install-constants.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +233 -2
- package/bin/install.mjs +49 -3
- package/bin/pstack-model-preferences.codex.json +23 -0
- package/bin/select-pstack-models.test.mjs +378 -0
- package/hooks/hooks_constants/tool_names.py +20 -0
- package/hooks/validators/fast_save_validators.py +63 -89
- package/hooks/validators/run_all_validators.py +16 -8
- package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
- package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
- package/package.json +3 -2
- package/rules/pstack-models.md +119 -0
- package/scripts/audit-hooks-cli.mjs +54 -0
- package/scripts/audit-hooks.mjs +397 -0
- package/scripts/cde_lint.py +258 -0
- package/scripts/policy_lint/__init__.py +41 -0
- package/scripts/policy_lint/adapter_configuration.py +91 -0
- package/scripts/policy_lint/adapter_detectors.py +328 -0
- package/scripts/policy_lint/adapter_pairing.py +145 -0
- package/scripts/policy_lint/adapter_support.py +97 -0
- package/scripts/policy_lint/adapters.py +352 -0
- package/scripts/policy_lint/config/__init__.py +1 -0
- package/scripts/policy_lint/config/constants.py +66 -0
- package/scripts/policy_lint/engine.py +224 -0
- package/scripts/policy_lint/model.py +387 -0
- package/scripts/policy_lint/registry.py +162 -0
- package/scripts/policy_lint/render.py +91 -0
- package/scripts/policy_lint/selection.py +372 -0
- package/scripts/policy_lint/selection_git.py +147 -0
- package/scripts/select_pstack_models.mjs +304 -0
- package/scripts/tests/test-audit-hooks.mjs +379 -0
- package/scripts/tests/test_cde_lint.py +374 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
- package/scripts/tests/test_policy_lint_selection.py +373 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run the policy linter."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Never, TextIO
|
|
11
|
+
|
|
12
|
+
from policy_lint.config.constants import INVALID_INPUT_EXIT_CODE
|
|
13
|
+
from policy_lint.engine import lint
|
|
14
|
+
from policy_lint.model import LintReport, LintRequest, SelectionKind, TextDocument
|
|
15
|
+
from policy_lint.render import ReportFormat, render
|
|
16
|
+
from policy_lint.selection import SelectionRunFatal
|
|
17
|
+
from policy_lint.selection_git import GitSelectionError, git_bytes_for
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _LintUsageError(ValueError):
|
|
21
|
+
"""Raised when command arguments cannot form a source selection."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _LintArgumentParser(argparse.ArgumentParser):
|
|
25
|
+
def error(self, message: str) -> Never:
|
|
26
|
+
"""Raise a usage error instead of printing and exiting.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
message: Parser error text.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
_LintUsageError: Always.
|
|
33
|
+
"""
|
|
34
|
+
raise _LintUsageError(message)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _discover_repository_root(starting_directory: Path) -> Path:
|
|
38
|
+
"""Find the Git root that contains the command's current directory.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
starting_directory: Directory where the command started.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The resolved Git repository root.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
_LintUsageError: If the directory is not inside a Git repository.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
root_bytes = git_bytes_for(
|
|
51
|
+
starting_directory.resolve(), ("rev-parse", "--show-toplevel")
|
|
52
|
+
)
|
|
53
|
+
except (GitSelectionError, OSError) as error:
|
|
54
|
+
raise _LintUsageError("Current directory is not inside a Git repository") from error
|
|
55
|
+
root_text = root_bytes.decode("utf-8").strip()
|
|
56
|
+
if not root_text:
|
|
57
|
+
raise _LintUsageError("Git returned an empty repository root")
|
|
58
|
+
return Path(root_text).resolve()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_shell_file_arguments(
|
|
62
|
+
all_arguments: Sequence[str], starting_directory: Path
|
|
63
|
+
) -> list[str]:
|
|
64
|
+
all_resolved_arguments: list[str] = []
|
|
65
|
+
is_reading_file_paths = False
|
|
66
|
+
for each_argument in all_arguments:
|
|
67
|
+
if each_argument in ("--files", "--text-as"):
|
|
68
|
+
is_reading_file_paths = True
|
|
69
|
+
all_resolved_arguments.append(each_argument)
|
|
70
|
+
continue
|
|
71
|
+
if each_argument.startswith("--"):
|
|
72
|
+
is_reading_file_paths = False
|
|
73
|
+
all_resolved_arguments.append(each_argument)
|
|
74
|
+
continue
|
|
75
|
+
if not is_reading_file_paths:
|
|
76
|
+
all_resolved_arguments.append(each_argument)
|
|
77
|
+
continue
|
|
78
|
+
candidate_path = Path(each_argument)
|
|
79
|
+
all_resolved_arguments.append(
|
|
80
|
+
str(
|
|
81
|
+
candidate_path
|
|
82
|
+
if candidate_path.is_absolute()
|
|
83
|
+
else starting_directory / candidate_path
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return all_resolved_arguments
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _add_file_sources(source_group: argparse._MutuallyExclusiveGroup) -> None:
|
|
90
|
+
source_group.add_argument(
|
|
91
|
+
"--files",
|
|
92
|
+
nargs="+",
|
|
93
|
+
dest="all_file_paths",
|
|
94
|
+
metavar="PATH",
|
|
95
|
+
default=None,
|
|
96
|
+
help="Lint current worktree files.",
|
|
97
|
+
)
|
|
98
|
+
source_group.add_argument(
|
|
99
|
+
"--staged",
|
|
100
|
+
action="store_const",
|
|
101
|
+
const=SelectionKind.STAGED,
|
|
102
|
+
dest="selection_kind",
|
|
103
|
+
help="Lint staged index content.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _add_repository_sources(
|
|
108
|
+
source_group: argparse._MutuallyExclusiveGroup,
|
|
109
|
+
) -> None:
|
|
110
|
+
source_group.add_argument(
|
|
111
|
+
"--base",
|
|
112
|
+
dest="base_revision",
|
|
113
|
+
metavar="REVISION",
|
|
114
|
+
default=None,
|
|
115
|
+
help="Lint changes from a base revision.",
|
|
116
|
+
)
|
|
117
|
+
source_group.add_argument(
|
|
118
|
+
"--repository",
|
|
119
|
+
action="store_const",
|
|
120
|
+
const=SelectionKind.REPOSITORY,
|
|
121
|
+
dest="selection_kind",
|
|
122
|
+
help="Lint tracked worktree files.",
|
|
123
|
+
)
|
|
124
|
+
source_group.add_argument(
|
|
125
|
+
"--text-as",
|
|
126
|
+
dest="text_path",
|
|
127
|
+
metavar="PATH",
|
|
128
|
+
default=None,
|
|
129
|
+
help="Lint one editor buffer from standard input.",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
134
|
+
parser = _LintArgumentParser(prog="cde lint")
|
|
135
|
+
source_group = parser.add_mutually_exclusive_group(required=True)
|
|
136
|
+
_add_file_sources(source_group)
|
|
137
|
+
_add_repository_sources(source_group)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--format",
|
|
140
|
+
dest="report_format",
|
|
141
|
+
choices=tuple(each_format.value for each_format in ReportFormat),
|
|
142
|
+
default=ReportFormat.TEXT.value,
|
|
143
|
+
help="Report format.",
|
|
144
|
+
)
|
|
145
|
+
parser.set_defaults(selection_kind=None)
|
|
146
|
+
return parser
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _request_from_namespace(
|
|
150
|
+
namespace: argparse.Namespace,
|
|
151
|
+
stdin: TextIO,
|
|
152
|
+
repository_root: Path,
|
|
153
|
+
) -> LintRequest:
|
|
154
|
+
if namespace.all_file_paths is not None:
|
|
155
|
+
return LintRequest.files(
|
|
156
|
+
repository_root,
|
|
157
|
+
[Path(each_path) for each_path in namespace.all_file_paths],
|
|
158
|
+
)
|
|
159
|
+
if namespace.base_revision is not None:
|
|
160
|
+
return LintRequest.base(repository_root, namespace.base_revision)
|
|
161
|
+
if namespace.text_path is not None:
|
|
162
|
+
return LintRequest(
|
|
163
|
+
repository_root,
|
|
164
|
+
TextDocument(Path(namespace.text_path), stdin.read()),
|
|
165
|
+
)
|
|
166
|
+
if namespace.selection_kind is SelectionKind.STAGED:
|
|
167
|
+
return LintRequest.staged(repository_root)
|
|
168
|
+
if namespace.selection_kind is SelectionKind.REPOSITORY:
|
|
169
|
+
return LintRequest.repository(repository_root)
|
|
170
|
+
raise _LintUsageError("A source selection is required")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_lint_invocation(
|
|
174
|
+
all_arguments: Sequence[str],
|
|
175
|
+
stdin: TextIO,
|
|
176
|
+
repository_root: Path,
|
|
177
|
+
) -> tuple[LintRequest, ReportFormat]:
|
|
178
|
+
namespace = _build_parser().parse_args(list(all_arguments))
|
|
179
|
+
return (
|
|
180
|
+
_request_from_namespace(namespace, stdin, repository_root),
|
|
181
|
+
ReportFormat(namespace.report_format),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_linter(
|
|
186
|
+
all_arguments: Sequence[str],
|
|
187
|
+
stdin: TextIO,
|
|
188
|
+
repository_root: Path,
|
|
189
|
+
lint_runner: Callable[[LintRequest], LintReport],
|
|
190
|
+
) -> tuple[LintReport, ReportFormat]:
|
|
191
|
+
request, report_format = _parse_lint_invocation(
|
|
192
|
+
list(all_arguments),
|
|
193
|
+
stdin,
|
|
194
|
+
repository_root,
|
|
195
|
+
)
|
|
196
|
+
return lint_runner(request), report_format
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _write_invalid_input(error: Exception, stderr: TextIO) -> int:
|
|
200
|
+
stderr.write(f"{error}\n")
|
|
201
|
+
return INVALID_INPUT_EXIT_CODE
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _render_lint(
|
|
205
|
+
all_arguments: Sequence[str],
|
|
206
|
+
repository_root: Path,
|
|
207
|
+
stdin: TextIO,
|
|
208
|
+
stdout: TextIO,
|
|
209
|
+
lint_runner: Callable[[LintRequest], LintReport],
|
|
210
|
+
) -> int:
|
|
211
|
+
report, report_format = _run_linter(
|
|
212
|
+
all_arguments, stdin, repository_root, lint_runner
|
|
213
|
+
)
|
|
214
|
+
stdout.write(render(report, report_format))
|
|
215
|
+
return report.exit_code
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main(
|
|
219
|
+
all_arguments: Sequence[str],
|
|
220
|
+
repository_root: Path,
|
|
221
|
+
*,
|
|
222
|
+
stdin: TextIO = sys.stdin,
|
|
223
|
+
stdout: TextIO = sys.stdout,
|
|
224
|
+
stderr: TextIO = sys.stderr,
|
|
225
|
+
lint_runner: Callable[[LintRequest], LintReport] = lint,
|
|
226
|
+
) -> int:
|
|
227
|
+
"""Run the policy-lint command.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
all_arguments: Command arguments.
|
|
231
|
+
repository_root: Repository root.
|
|
232
|
+
stdin: Input stream.
|
|
233
|
+
stdout: Output stream.
|
|
234
|
+
stderr: Error stream.
|
|
235
|
+
lint_runner: Policy-lint engine.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The process exit code.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
return _render_lint(
|
|
242
|
+
all_arguments, repository_root, stdin, stdout, lint_runner
|
|
243
|
+
)
|
|
244
|
+
except _LintUsageError as usage_error:
|
|
245
|
+
return _write_invalid_input(usage_error, stderr)
|
|
246
|
+
except SelectionRunFatal as selection_error:
|
|
247
|
+
return _write_invalid_input(selection_error, stderr)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
try:
|
|
252
|
+
shell_directory = Path.cwd()
|
|
253
|
+
shell_repository_root = _discover_repository_root(shell_directory)
|
|
254
|
+
except _LintUsageError as usage_error:
|
|
255
|
+
sys.stderr.write(f"{usage_error}\n")
|
|
256
|
+
raise SystemExit(INVALID_INPUT_EXIT_CODE) from usage_error
|
|
257
|
+
all_shell_arguments = _resolve_shell_file_arguments(sys.argv[1:], shell_directory)
|
|
258
|
+
raise SystemExit(main(all_shell_arguments, shell_repository_root))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from .engine import lint
|
|
2
|
+
from .model import (
|
|
3
|
+
BaseChanges,
|
|
4
|
+
ChangeSetRule,
|
|
5
|
+
ContentOrigin,
|
|
6
|
+
Diagnostic,
|
|
7
|
+
Document,
|
|
8
|
+
DocumentRule,
|
|
9
|
+
DocumentSet,
|
|
10
|
+
ExplicitFiles,
|
|
11
|
+
LintReport,
|
|
12
|
+
LintRequest,
|
|
13
|
+
Location,
|
|
14
|
+
RepositoryRule,
|
|
15
|
+
RepositoryTree,
|
|
16
|
+
SelectionKind,
|
|
17
|
+
Severity,
|
|
18
|
+
StagedChanges,
|
|
19
|
+
TextDocument,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BaseChanges",
|
|
24
|
+
"ChangeSetRule",
|
|
25
|
+
"ContentOrigin",
|
|
26
|
+
"Diagnostic",
|
|
27
|
+
"Document",
|
|
28
|
+
"DocumentRule",
|
|
29
|
+
"DocumentSet",
|
|
30
|
+
"ExplicitFiles",
|
|
31
|
+
"LintReport",
|
|
32
|
+
"LintRequest",
|
|
33
|
+
"Location",
|
|
34
|
+
"RepositoryRule",
|
|
35
|
+
"RepositoryTree",
|
|
36
|
+
"SelectionKind",
|
|
37
|
+
"Severity",
|
|
38
|
+
"StagedChanges",
|
|
39
|
+
"TextDocument",
|
|
40
|
+
"lint",
|
|
41
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import adapter_support
|
|
7
|
+
from .config import constants
|
|
8
|
+
from .model import Diagnostic, Document
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hook_configuration_diagnostics(
|
|
12
|
+
document: Document, repository_root: Path
|
|
13
|
+
) -> tuple[Diagnostic, ...]:
|
|
14
|
+
"""Check hook configuration for policy decision fields.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
document: Current hook configuration text.
|
|
18
|
+
repository_root: Request repository root for document-path resolution.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Hook configuration diagnostics.
|
|
22
|
+
"""
|
|
23
|
+
adapter_support._document_path(repository_root, document)
|
|
24
|
+
if document.path.name != "hooks.json":
|
|
25
|
+
return ()
|
|
26
|
+
all_messages = _hook_configuration_messages(document.text)
|
|
27
|
+
return adapter_support._diagnostics_for_messages(
|
|
28
|
+
document, "hook-configuration", all_messages
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hook_configuration_messages(configuration_text: str) -> tuple[str, ...]:
|
|
33
|
+
try:
|
|
34
|
+
parsed_configuration = json.loads(configuration_text)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return ("Hook configuration is not valid JSON",)
|
|
37
|
+
if not isinstance(parsed_configuration, dict):
|
|
38
|
+
return ("Hook configuration must be an object",)
|
|
39
|
+
all_registered_strings = _registered_hook_strings(parsed_configuration)
|
|
40
|
+
if any(
|
|
41
|
+
_contains_action_boundary_marker(each_string)
|
|
42
|
+
for each_string in all_registered_strings
|
|
43
|
+
):
|
|
44
|
+
return ("Hook configuration registers a deny, block, or ask policy path",)
|
|
45
|
+
return ()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _registered_hook_strings(
|
|
49
|
+
all_configuration_entries: dict[object, object]
|
|
50
|
+
) -> tuple[str, ...]:
|
|
51
|
+
all_registered_strings: list[str] = []
|
|
52
|
+
for each_key, each_entry in all_configuration_entries.items():
|
|
53
|
+
all_registered_strings.extend(
|
|
54
|
+
_registered_strings_for_entry(each_key, each_entry)
|
|
55
|
+
)
|
|
56
|
+
return tuple(all_registered_strings)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _registered_strings_for_entry(
|
|
60
|
+
each_key: object, each_entry: object
|
|
61
|
+
) -> tuple[str, ...]:
|
|
62
|
+
all_registered_strings: list[str] = []
|
|
63
|
+
if each_key in constants.ALL_HOOK_REGISTRATION_KEYS and isinstance(each_entry, str):
|
|
64
|
+
all_registered_strings.append(each_entry)
|
|
65
|
+
if each_key in constants.ALL_DECISION_REGISTRATION_KEYS and isinstance(each_entry, str):
|
|
66
|
+
all_registered_strings.append(each_entry)
|
|
67
|
+
all_registered_strings.extend(_registered_nested_strings(each_entry))
|
|
68
|
+
return tuple(all_registered_strings)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _registered_nested_strings(each_entry: object) -> tuple[str, ...]:
|
|
72
|
+
if isinstance(each_entry, dict):
|
|
73
|
+
return _registered_hook_strings(each_entry)
|
|
74
|
+
if not isinstance(each_entry, list):
|
|
75
|
+
return ()
|
|
76
|
+
all_registered_strings: list[str] = []
|
|
77
|
+
for each_nested_entry in each_entry:
|
|
78
|
+
if isinstance(each_nested_entry, dict):
|
|
79
|
+
all_registered_strings.extend(_registered_hook_strings(each_nested_entry))
|
|
80
|
+
return tuple(all_registered_strings)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _contains_action_boundary_marker(registered_string: str) -> bool:
|
|
84
|
+
normalized_string = registered_string.lower().replace("\\", "/")
|
|
85
|
+
normalized_string = normalized_string.replace(".", "/").replace("-", "/")
|
|
86
|
+
return any(
|
|
87
|
+
each_segment in constants.ALL_ACTION_BOUNDARY_SEGMENTS
|
|
88
|
+
or each_segment.startswith(constants.ALL_ACTION_BOUNDARY_PREFIXES)
|
|
89
|
+
for each_segment in normalized_string.split("/")
|
|
90
|
+
if each_segment
|
|
91
|
+
)
|