clckernel 1.2.18 → 2.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/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +244 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/cli.js +8 -0
- package/package.json +12 -2
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +27 -46
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +737 -309
- package/src/gitignore.js +144 -0
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -36
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
package/tools/audit.py
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Modular Audit Orchestrator — Python
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
The single place that decides which guards run (sdds/unified-discovery):
|
|
5
|
+
`clckernel audit` and agents run it, the pre-commit hook runs it with --hook.
|
|
6
|
+
Same discovery, same guards, each exactly once:
|
|
7
|
+
- top-level tools/ guards: check_*, scan_*, verify_* or *_guard, .py/.js;
|
|
8
|
+
- every tools/guards/*.py and *.js (project guards).
|
|
9
|
+
audit.py runs the .py guards; the .js ones belong to audit.js, unless
|
|
10
|
+
tools/audit.js is not installed: then audit.py runs them too, with node.
|
|
11
|
+
A top-level guard of this language that defines check() is called in
|
|
12
|
+
process; every other guard runs as a process (exit code decides, a JSON
|
|
13
|
+
result is optional). A guard listed in the managed registry but missing on
|
|
14
|
+
disk fails the run. Results are grouped into 4 lenses.
|
|
15
|
+
|
|
16
|
+
CLI (sdds/audit-cli): audit.py [targetDir] [--hook] [--change <name>] [--staged]
|
|
17
|
+
(none) print the 4-lens summary; write nothing
|
|
18
|
+
--change <name> also write sdds/<name>/05-audit-report.md and
|
|
19
|
+
audit-receipt.json (<name>: one safe path segment);
|
|
20
|
+
05-audit-report.py.md / audit-receipt.py.json when
|
|
21
|
+
tools/audit.js is installed too (mixed stack)
|
|
22
|
+
--hook one line per guard; write nothing (not with --change)
|
|
23
|
+
--staged the default snapshot, spelled out: the staged changes, or
|
|
24
|
+
the last commit when nothing is staged (_changes)
|
|
25
|
+
Unknown input exits 2. A failing guard whose level is 'warning' (its result's
|
|
26
|
+
`level`, overridden by `severities` in .clckernel.yaml) is reported but does
|
|
27
|
+
not fail the run; a guard that crashes always does. Self-contained: Python
|
|
28
|
+
stdlib plus the sibling _config.py (optional).
|
|
7
29
|
"""
|
|
8
30
|
from __future__ import annotations
|
|
9
31
|
|
|
@@ -11,13 +33,35 @@ import importlib.util
|
|
|
11
33
|
import json
|
|
12
34
|
import os
|
|
13
35
|
import pathlib
|
|
36
|
+
import re
|
|
14
37
|
import subprocess
|
|
15
38
|
import sys
|
|
16
39
|
import traceback
|
|
17
40
|
|
|
18
41
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
19
|
-
|
|
20
|
-
|
|
42
|
+
LENSES = ("correctness", "security", "architecture", "performance")
|
|
43
|
+
PROJECT_GUARD_RUNNERS = {".py": sys.executable or "python3", ".js": "node"}
|
|
44
|
+
OWN_EXT = ".py"
|
|
45
|
+
SIBLING_EXT, SIBLING_ORCHESTRATOR = ".js", "audit.js"
|
|
46
|
+
REGISTRY_FILE = ".clckernel-managed.json"
|
|
47
|
+
USAGE = "usage: audit.py [targetDir] [--hook] [--change <name>] [--staged]"
|
|
48
|
+
CHANGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
49
|
+
GUARD_STEM = re.compile(r"^(check|scan|verify)_")
|
|
50
|
+
|
|
51
|
+
# Runs a JS guard that exports a function (module contract) as a process:
|
|
52
|
+
# prints its result as JSON and exits with its exitCode.
|
|
53
|
+
JS_MODULE_RUNNER = (
|
|
54
|
+
"const p = require('path').resolve(process.argv[1]);"
|
|
55
|
+
"const m = require(p);"
|
|
56
|
+
"const fn = typeof m === 'function' ? m : Object.values(m || {}).find(v => typeof v === 'function');"
|
|
57
|
+
"if (!fn) { console.error('does not export a function'); process.exit(1); }"
|
|
58
|
+
"Promise.resolve(fn(process.cwd())).then(r => {"
|
|
59
|
+
" if (!r || typeof r !== 'object') { console.error('returned ' + (r === null ? 'null' : typeof r)); process.exitCode = 1; return; }"
|
|
60
|
+
" console.log(JSON.stringify(r)); process.exitCode = r.exitCode || 0; },"
|
|
61
|
+
" e => { console.error(e && e.message); process.exitCode = 1; });"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ── Static name → lens mapping (4 RDD lenses) ──────────────────────────────────────────
|
|
21
65
|
|
|
22
66
|
LENS_MAP = {
|
|
23
67
|
# Correctness
|
|
@@ -30,6 +74,10 @@ LENS_MAP = {
|
|
|
30
74
|
"tsc": "correctness",
|
|
31
75
|
"lint": "correctness",
|
|
32
76
|
"ruff": "correctness",
|
|
77
|
+
"check_skills": "correctness",
|
|
78
|
+
"check_links": "correctness",
|
|
79
|
+
"check_placeholders": "correctness",
|
|
80
|
+
"check_data_files": "correctness",
|
|
33
81
|
# Security
|
|
34
82
|
"scan_secrets": "security",
|
|
35
83
|
"docker_guard": "security",
|
|
@@ -42,7 +90,6 @@ LENS_MAP = {
|
|
|
42
90
|
"check_ui_reuse": "architecture",
|
|
43
91
|
"check_symlinks": "architecture",
|
|
44
92
|
"check_hitl": "architecture",
|
|
45
|
-
"check_domain_data": "architecture",
|
|
46
93
|
"check_migrations": "architecture",
|
|
47
94
|
"graphify": "architecture",
|
|
48
95
|
"gentle_ai_review": "architecture",
|
|
@@ -52,7 +99,6 @@ LENS_MAP = {
|
|
|
52
99
|
"check_seo": "performance",
|
|
53
100
|
"check_responsive": "performance",
|
|
54
101
|
"check_storybook": "performance",
|
|
55
|
-
"check_test_calendar_sync": "performance",
|
|
56
102
|
"check_observability": "performance",
|
|
57
103
|
}
|
|
58
104
|
|
|
@@ -62,39 +108,190 @@ def get_lens(guard_name: str) -> str:
|
|
|
62
108
|
|
|
63
109
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
64
110
|
|
|
65
|
-
def
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
if not
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
return
|
|
76
|
-
return
|
|
111
|
+
def is_guard_file(name: str) -> bool:
|
|
112
|
+
"""A top-level guard file name: check_*, scan_*, verify_* or *_guard, .py or .js."""
|
|
113
|
+
stem, ext = os.path.splitext(name)
|
|
114
|
+
if ext not in (".py", ".js"):
|
|
115
|
+
return False
|
|
116
|
+
return bool(GUARD_STEM.match(stem)) or stem.endswith("_guard")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _regular_files(directory: pathlib.Path) -> list[pathlib.Path]:
|
|
120
|
+
if not directory.is_dir():
|
|
121
|
+
return []
|
|
122
|
+
return sorted((f for f in directory.iterdir() if f.is_file()), key=lambda f: f.name)
|
|
77
123
|
|
|
78
124
|
|
|
79
125
|
def discover_guards(tools_dir: pathlib.Path) -> list[pathlib.Path]:
|
|
80
126
|
"""
|
|
81
|
-
|
|
82
|
-
|
|
127
|
+
Top-level guards of this orchestrator's language (.py) in tools/,
|
|
128
|
+
alphabetical: check_*, scan_*, verify_* and *_guard (technology guards
|
|
129
|
+
such as docker_guard.py). Helpers (_*) and audit.py never match.
|
|
130
|
+
"""
|
|
131
|
+
return [f for f in _regular_files(tools_dir) if is_guard_file(f.name) and f.suffix == OWN_EXT]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def discover_project_guards(tools_dir: pathlib.Path) -> list[pathlib.Path]:
|
|
135
|
+
"""
|
|
136
|
+
Project guards (sdds/domain-agnostic, decision 3): every *.py and *.js
|
|
137
|
+
file in tools/guards/, alphabetical.
|
|
138
|
+
"""
|
|
139
|
+
return [f for f in _regular_files(tools_dir / "guards") if f.suffix in PROJECT_GUARD_RUNNERS]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def registered_paths(tools_dir: pathlib.Path) -> list[str]:
|
|
143
|
+
"""Paths the managed registry lists, in either format (list, or {files: [{path}]})."""
|
|
144
|
+
try:
|
|
145
|
+
data = json.loads((tools_dir / REGISTRY_FILE).read_text(encoding="utf-8"))
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
return []
|
|
148
|
+
if isinstance(data, list):
|
|
149
|
+
entries = data
|
|
150
|
+
elif isinstance(data, dict) and isinstance(data.get("files"), list):
|
|
151
|
+
entries = [f.get("path") for f in data["files"] if isinstance(f, dict)]
|
|
152
|
+
else:
|
|
153
|
+
entries = []
|
|
154
|
+
return [p for p in entries if isinstance(p, str)]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _defines_check(path: pathlib.Path) -> bool:
|
|
158
|
+
try:
|
|
159
|
+
return re.search(r"^def check\s*\(", path.read_text(encoding="utf-8"), re.M) is not None
|
|
160
|
+
except (OSError, UnicodeDecodeError):
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _exports_module(path: pathlib.Path) -> bool:
|
|
165
|
+
try:
|
|
166
|
+
text = path.read_text(encoding="utf-8")
|
|
167
|
+
except (OSError, UnicodeDecodeError):
|
|
168
|
+
return False
|
|
169
|
+
return re.search(r"\bmodule\.exports\b|^\s*exports\.[\w$]+\s*=", text, re.M) is not None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def plan_guards(tools_dir: pathlib.Path) -> list[dict]:
|
|
173
|
+
"""
|
|
174
|
+
The guards this orchestrator runs (sdds/unified-discovery), in order:
|
|
175
|
+
top-level guards, then tools/guards/*. mode 'module' = a top-level .py
|
|
176
|
+
guard that defines check(): called in process; 'process' = any other
|
|
177
|
+
guard of a language this orchestrator owns (.py, plus .js when
|
|
178
|
+
tools/audit.js is not installed); 'missing' = registered, not on disk.
|
|
83
179
|
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
180
|
+
exts = {OWN_EXT}
|
|
181
|
+
if not (tools_dir / SIBLING_ORCHESTRATOR).exists():
|
|
182
|
+
exts.add(SIBLING_EXT)
|
|
183
|
+
plan: list[dict] = []
|
|
184
|
+
top = [f for f in _regular_files(tools_dir) if is_guard_file(f.name) and f.suffix in exts]
|
|
185
|
+
for f in top:
|
|
186
|
+
module = f.suffix == OWN_EXT and _defines_check(f)
|
|
187
|
+
plan.append({"file": f, "label": f.name, "mode": "module" if module else "process"})
|
|
188
|
+
for f in discover_project_guards(tools_dir):
|
|
189
|
+
if f.suffix in exts:
|
|
190
|
+
plan.append({"file": f, "label": f"guards/{f.name}", "mode": "process"})
|
|
191
|
+
present = {f.name for f in top}
|
|
192
|
+
for rel in registered_paths(tools_dir):
|
|
193
|
+
if not rel.startswith("tools/"):
|
|
194
|
+
continue
|
|
195
|
+
name = rel[len("tools/"):]
|
|
196
|
+
if "/" in name or not is_guard_file(name) or os.path.splitext(name)[1] not in exts or name in present:
|
|
87
197
|
continue
|
|
88
|
-
name
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
198
|
+
plan.append({"file": tools_dir / name, "label": name, "mode": "missing"})
|
|
199
|
+
return plan
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _missing_guard_result(entry: dict) -> dict:
|
|
203
|
+
name = os.path.splitext(entry["label"])[0]
|
|
204
|
+
return {
|
|
205
|
+
"exit_code": 1, "skipped": False, "crashed": True, "name": name, "lens": get_lens(name),
|
|
206
|
+
"data": {"message": (
|
|
207
|
+
f"Kernel guard missing: tools/{entry['label']} is listed in tools/{REGISTRY_FILE} "
|
|
208
|
+
"but not on disk; restore it with `clckernel update`"
|
|
209
|
+
)},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_guard_json(stdout: str):
|
|
214
|
+
"""The guard's JSON result when its stdout (or its last line) is a JSON object."""
|
|
215
|
+
text = (stdout or "").strip()
|
|
216
|
+
for candidate in (text, text.split("\n")[-1]):
|
|
217
|
+
try:
|
|
218
|
+
value = json.loads(candidate)
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
if isinstance(value, dict):
|
|
222
|
+
return value
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _last_line(text: str) -> str:
|
|
227
|
+
lines = [line.strip() for line in (text or "").split("\n") if line.strip()]
|
|
228
|
+
return lines[-1] if lines else ""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def run_project_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
|
|
232
|
+
"""
|
|
233
|
+
Run one guard as a process with cwd = target_dir. The exit code decides
|
|
234
|
+
(non-zero fails); a JSON result with the standard shape
|
|
235
|
+
({name, skipped, lens?, data: {message, violations}}) is used for the
|
|
236
|
+
report, plain output is reported by its last line. A .js guard that
|
|
237
|
+
exports a function is called through that contract; when it exits
|
|
238
|
+
non-zero without a JSON result it crashed. Lens: the JSON's, else the
|
|
239
|
+
guard name's, else architecture; level: the JSON's `level`.
|
|
240
|
+
"""
|
|
241
|
+
fallback_name = guard_path.stem
|
|
242
|
+
fallback_lens = get_lens(fallback_name)
|
|
243
|
+
runner = PROJECT_GUARD_RUNNERS[guard_path.suffix]
|
|
244
|
+
module_contract = guard_path.suffix == ".js" and _exports_module(guard_path)
|
|
245
|
+
if module_contract:
|
|
246
|
+
args = [runner, "-e", JS_MODULE_RUNNER, str(guard_path)]
|
|
247
|
+
else:
|
|
248
|
+
args = [runner, str(guard_path)]
|
|
249
|
+
try:
|
|
250
|
+
proc = subprocess.run(args, cwd=target_dir, capture_output=True, text=True)
|
|
251
|
+
except OSError as exc:
|
|
252
|
+
return {
|
|
253
|
+
"exit_code": 1, "skipped": False, "crashed": True, "name": fallback_name, "lens": fallback_lens,
|
|
254
|
+
"data": {"message": f"Guard crashed: {exc}"},
|
|
255
|
+
}
|
|
256
|
+
exit_code = proc.returncode
|
|
257
|
+
output = (proc.stdout or "") + (proc.stderr or "")
|
|
258
|
+
if exit_code < 0:
|
|
259
|
+
return {
|
|
260
|
+
"exit_code": 1, "skipped": False, "crashed": True, "name": fallback_name, "lens": fallback_lens,
|
|
261
|
+
"data": {"message": f"Guard crashed: killed by signal {-exit_code}"},
|
|
262
|
+
"output": output,
|
|
263
|
+
}
|
|
264
|
+
result = _parse_guard_json(proc.stdout)
|
|
265
|
+
if result is not None:
|
|
266
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else result
|
|
267
|
+
name = result.get("name")
|
|
268
|
+
return {
|
|
269
|
+
"exit_code": exit_code,
|
|
270
|
+
"skipped": exit_code == 0 and bool(result.get("skipped", False)),
|
|
271
|
+
"crashed": False,
|
|
272
|
+
"level": result.get("level"),
|
|
273
|
+
"name": name if isinstance(name, str) and name else fallback_name,
|
|
274
|
+
"lens": result.get("lens") if result.get("lens") in LENSES else fallback_lens,
|
|
275
|
+
"data": data,
|
|
276
|
+
"output": output,
|
|
277
|
+
}
|
|
278
|
+
line = _last_line(proc.stdout) if exit_code == 0 else (_last_line(proc.stderr) or _last_line(proc.stdout))
|
|
279
|
+
crashed = module_contract and exit_code != 0
|
|
280
|
+
if crashed:
|
|
281
|
+
message = f"Guard crashed: {line or f'exited with code {exit_code}'}"
|
|
282
|
+
else:
|
|
283
|
+
message = line or ("Passed" if exit_code == 0 else f"Exited with code {exit_code}")
|
|
284
|
+
return {
|
|
285
|
+
"exit_code": exit_code, "skipped": False, "crashed": crashed, "name": fallback_name, "lens": fallback_lens,
|
|
286
|
+
"data": {"message": message},
|
|
287
|
+
"output": output,
|
|
288
|
+
}
|
|
92
289
|
|
|
93
290
|
|
|
94
291
|
def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
|
|
95
292
|
"""
|
|
96
293
|
Run a single guard module by dynamic import and call its check() function.
|
|
97
|
-
Returns a normalized result dict on success, or a
|
|
294
|
+
Returns a normalized result dict on success, or a FAILED result if it cannot run.
|
|
98
295
|
"""
|
|
99
296
|
guard_name = guard_path.stem # e.g. "check_scope"
|
|
100
297
|
|
|
@@ -102,8 +299,9 @@ def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
|
|
|
102
299
|
spec = importlib.util.spec_from_file_location(guard_name, str(guard_path))
|
|
103
300
|
if spec is None or spec.loader is None:
|
|
104
301
|
return {
|
|
105
|
-
"exit_code":
|
|
106
|
-
"
|
|
302
|
+
"exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
|
|
303
|
+
"lens": get_lens(guard_name),
|
|
304
|
+
"data": {"message": f"Guard crashed: cannot load module spec for {guard_name}"},
|
|
107
305
|
}
|
|
108
306
|
|
|
109
307
|
mod = importlib.util.module_from_spec(spec)
|
|
@@ -112,8 +310,9 @@ def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
|
|
|
112
310
|
fn = getattr(mod, "check", None)
|
|
113
311
|
if fn is None or not callable(fn):
|
|
114
312
|
return {
|
|
115
|
-
"exit_code":
|
|
116
|
-
"
|
|
313
|
+
"exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
|
|
314
|
+
"lens": get_lens(guard_name),
|
|
315
|
+
"data": {"message": f"Guard crashed: {guard_name} does not export a check() function"},
|
|
117
316
|
}
|
|
118
317
|
|
|
119
318
|
result = fn(target_dir)
|
|
@@ -121,26 +320,85 @@ def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
|
|
|
121
320
|
# Normalize result shape
|
|
122
321
|
if not isinstance(result, dict):
|
|
123
322
|
return {
|
|
124
|
-
"exit_code":
|
|
125
|
-
"
|
|
323
|
+
"exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
|
|
324
|
+
"lens": get_lens(guard_name),
|
|
325
|
+
"data": {"message": f"Guard crashed: {guard_name} returned non-dict: {type(result).__name__}"},
|
|
126
326
|
}
|
|
127
327
|
|
|
128
328
|
return {
|
|
129
329
|
"exit_code": result.get("exit_code", 0),
|
|
130
330
|
"skipped": bool(result.get("skipped", False)),
|
|
331
|
+
"crashed": False,
|
|
332
|
+
"level": result.get("level"),
|
|
131
333
|
"name": result.get("name", guard_name),
|
|
132
334
|
"lens": get_lens(guard_name),
|
|
133
335
|
"data": result.get("data", result),
|
|
134
336
|
}
|
|
135
337
|
|
|
136
338
|
except Exception as exc:
|
|
339
|
+
# A guard that cannot run must never approve a commit (fail closed).
|
|
137
340
|
return {
|
|
138
|
-
"exit_code":
|
|
341
|
+
"exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
|
|
139
342
|
"lens": get_lens(guard_name),
|
|
140
|
-
"data": {"message": f"Guard
|
|
343
|
+
"data": {"message": f"Guard crashed: {exc}"},
|
|
141
344
|
}
|
|
142
345
|
|
|
143
346
|
|
|
347
|
+
# ── Result levels (sdds/audit-cli) ───────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
def _is_failed(r: dict) -> bool:
|
|
350
|
+
"""A guard that ran and did not pass (skipped guards never fail)."""
|
|
351
|
+
return not r.get("skipped") and r.get("exit_code", 0) != 0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _is_warning(r: dict) -> bool:
|
|
355
|
+
"""A failure reported but not blocking: effective level 'warning'."""
|
|
356
|
+
return _is_failed(r) and r.get("level") == "warning"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _is_blocking(r: dict) -> bool:
|
|
360
|
+
"""A failure that fails the run."""
|
|
361
|
+
return _is_failed(r) and r.get("level") != "warning"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def apply_levels(results: list[dict], severities: dict | None = None) -> list[dict]:
|
|
365
|
+
"""
|
|
366
|
+
Set each result's effective level: severities[name] (or [file stem]) from
|
|
367
|
+
.clckernel.yaml, else the guard's own `level` ('warning' only), else
|
|
368
|
+
'error'. A crash is always 'error' (fail closed).
|
|
369
|
+
"""
|
|
370
|
+
severities = severities or {}
|
|
371
|
+
for result in results:
|
|
372
|
+
level = "warning" if result.get("level") == "warning" else "error"
|
|
373
|
+
name, stem = result.get("name"), result.get("stem")
|
|
374
|
+
if isinstance(name, str) and name in severities:
|
|
375
|
+
level = severities[name]
|
|
376
|
+
elif isinstance(stem, str) and stem in severities:
|
|
377
|
+
level = severities[stem]
|
|
378
|
+
result["level"] = "error" if result.get("crashed") else level
|
|
379
|
+
return results
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _status_of(result: dict) -> tuple:
|
|
383
|
+
if result.get("skipped"):
|
|
384
|
+
return "⏭️", "SKIPPED"
|
|
385
|
+
if not _is_failed(result):
|
|
386
|
+
return "✅", "PASSED"
|
|
387
|
+
return ("⚠️", "WARNING") if _is_warning(result) else ("❌", "FAILED")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _verdict_of(results: list[dict]) -> str:
|
|
391
|
+
"""FAILED on a blocking failure, PASSED_WITH_WARNINGS on warnings only."""
|
|
392
|
+
if any(_is_blocking(r) for r in results):
|
|
393
|
+
return "FAILED"
|
|
394
|
+
return "PASSED_WITH_WARNINGS" if any(_is_warning(r) for r in results) else "PASSED"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _message(result: dict, default: str = "") -> str:
|
|
398
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
399
|
+
return data.get("message") or default
|
|
400
|
+
|
|
401
|
+
|
|
144
402
|
# ── Report Generation ────────────────────────────────────────────────────────
|
|
145
403
|
|
|
146
404
|
def _format_guard_section(name: str, result: dict) -> str:
|
|
@@ -148,17 +406,11 @@ def _format_guard_section(name: str, result: dict) -> str:
|
|
|
148
406
|
lines: list[str] = []
|
|
149
407
|
skipped = result.get("skipped", False)
|
|
150
408
|
exit_code = result.get("exit_code", 0)
|
|
151
|
-
data = result.get("data", {}
|
|
152
|
-
|
|
153
|
-
if skipped:
|
|
154
|
-
icon, status = "\u23ed\ufe0f", "SKIPPED"
|
|
155
|
-
elif exit_code == 0:
|
|
156
|
-
icon, status = "\u2705", "PASSED"
|
|
157
|
-
else:
|
|
158
|
-
icon, status = "\u274c", "FAILED"
|
|
409
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
410
|
+
icon, status = _status_of(result)
|
|
159
411
|
|
|
160
412
|
title = name.replace("_", " ").title()
|
|
161
|
-
lines.append(f"### {icon} {title}
|
|
413
|
+
lines.append(f"### {icon} {title} — {status}")
|
|
162
414
|
lines.append("")
|
|
163
415
|
|
|
164
416
|
if skipped:
|
|
@@ -169,23 +421,13 @@ def _format_guard_section(name: str, result: dict) -> str:
|
|
|
169
421
|
lines.append(f"> {data.get('message', 'Issues found')}")
|
|
170
422
|
lines.append("")
|
|
171
423
|
|
|
172
|
-
|
|
173
|
-
violations = data.get("violations") or data.get("findings") or {}
|
|
174
|
-
if isinstance(violations, dict):
|
|
175
|
-
for filepath, items in violations.items():
|
|
176
|
-
if not isinstance(items, list):
|
|
177
|
-
continue
|
|
178
|
-
for item in items:
|
|
179
|
-
loc = f" (line {item.get('lineNo', '')})" if item.get("lineNo") else ""
|
|
180
|
-
label = item.get("label") or item.get("rule") or item.get("module") or ""
|
|
181
|
-
msg = item.get("reason") or item.get("message") or ""
|
|
182
|
-
lines.append(f"- `{filepath}`{loc}: {f'[{label}] ' if label else ''}{msg}")
|
|
424
|
+
lines.extend(_violation_lines(data))
|
|
183
425
|
|
|
184
426
|
# Handle list-style data (e.g. scope unauthorized files)
|
|
185
427
|
for key in ("unrelated", "warnings", "authorized"):
|
|
186
428
|
items = data.get(key, [])
|
|
187
429
|
if isinstance(items, list) and items:
|
|
188
|
-
icon_map = {"unrelated": "
|
|
430
|
+
icon_map = {"unrelated": "❌", "warnings": "⚠️", "authorized": "✅"}
|
|
189
431
|
label_map = {"unrelated": "Not in SDD scope", "warnings": "Related warnings", "authorized": "Authorized"}
|
|
190
432
|
lines.append(f"\n{icon_map.get(key, '')} **{label_map.get(key, key)}:**")
|
|
191
433
|
for f in items:
|
|
@@ -198,7 +440,8 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
|
|
|
198
440
|
"""Generate the 05-audit-report.md from aggregated guard results grouped by 4 RDD lenses."""
|
|
199
441
|
task_name = sdd_dir.name
|
|
200
442
|
passed = sum(1 for r in results if not r.get("skipped") and r.get("exit_code") == 0)
|
|
201
|
-
failed = sum(1 for r in results if
|
|
443
|
+
failed = sum(1 for r in results if _is_blocking(r))
|
|
444
|
+
warnings = [r for r in results if _is_warning(r)]
|
|
202
445
|
skipped = sum(1 for r in results if r.get("skipped"))
|
|
203
446
|
total = len(results)
|
|
204
447
|
|
|
@@ -206,7 +449,9 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
|
|
|
206
449
|
sections.append(f"# Code Audit Report: {task_name}")
|
|
207
450
|
sections.append("")
|
|
208
451
|
sections.append("> **Review-Driven Development (RDD) Report:**")
|
|
209
|
-
sections.append(">
|
|
452
|
+
sections.append("> Results grouped into 4 lenses (Correctness, Security, Architecture, Performance).")
|
|
453
|
+
sections.append("")
|
|
454
|
+
sections.append(f"**Verdict:** {_verdict_of(results)}")
|
|
210
455
|
sections.append("")
|
|
211
456
|
sections.append("---")
|
|
212
457
|
sections.append("")
|
|
@@ -217,12 +462,23 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
|
|
|
217
462
|
sections.append(f"| Total guards | {total} |")
|
|
218
463
|
sections.append(f"| Passed | {passed} |")
|
|
219
464
|
sections.append(f"| Failed | {failed} |")
|
|
465
|
+
sections.append(f"| Warnings | {len(warnings)} |")
|
|
220
466
|
sections.append(f"| Skipped | {skipped} |")
|
|
221
467
|
sections.append("")
|
|
222
468
|
sections.append("---")
|
|
223
469
|
sections.append("")
|
|
224
470
|
|
|
225
|
-
|
|
471
|
+
if warnings:
|
|
472
|
+
sections.append("## ⚠️ Warnings")
|
|
473
|
+
sections.append("")
|
|
474
|
+
sections.append("_Failed, but not blocking (level `warning`)._")
|
|
475
|
+
sections.append("")
|
|
476
|
+
for r in warnings:
|
|
477
|
+
sections.append(f"- **{r.get('name', 'unknown')}** [{str(r.get('lens', 'architecture')).upper()}]: {_message(r, 'Issues found')}")
|
|
478
|
+
sections.append("")
|
|
479
|
+
sections.append("---")
|
|
480
|
+
sections.append("")
|
|
481
|
+
|
|
226
482
|
headers = {
|
|
227
483
|
"correctness": "## 🎯 Correctness Lens",
|
|
228
484
|
"security": "## 🛡️ Security Lens",
|
|
@@ -230,7 +486,7 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
|
|
|
230
486
|
"performance": "## ⚡ Performance Lens",
|
|
231
487
|
}
|
|
232
488
|
|
|
233
|
-
for lens in
|
|
489
|
+
for lens in LENSES:
|
|
234
490
|
sections.append(headers[lens])
|
|
235
491
|
sections.append("")
|
|
236
492
|
lens_results = [r for r in results if r.get("lens") == lens]
|
|
@@ -247,25 +503,22 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
|
|
|
247
503
|
|
|
248
504
|
|
|
249
505
|
def generate_receipt(results: list[dict], target_dir: str) -> dict:
|
|
250
|
-
"""
|
|
506
|
+
"""
|
|
507
|
+
Generate an immutable machine-readable audit receipt (audit-receipt.json).
|
|
508
|
+
verdict: FAILED | PASSED_WITH_WARNINGS | PASSED; `failed` counts blocking
|
|
509
|
+
failures, `warnings` the non-blocking ones (listed in `warnings`).
|
|
510
|
+
"""
|
|
251
511
|
import datetime
|
|
252
512
|
|
|
253
|
-
lenses = ["correctness", "security", "architecture", "performance"]
|
|
254
513
|
lens_summary = {}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
for lens in lenses:
|
|
514
|
+
for lens in LENSES:
|
|
258
515
|
lens_results = [r for r in results if r.get("lens") == lens]
|
|
259
|
-
passed = sum(1 for r in lens_results if not r.get("skipped") and r.get("exit_code") == 0)
|
|
260
|
-
failed = sum(1 for r in lens_results if not r.get("skipped") and r.get("exit_code") != 0)
|
|
261
|
-
skipped = sum(1 for r in lens_results if r.get("skipped"))
|
|
262
|
-
if failed > 0:
|
|
263
|
-
has_failure = True
|
|
264
516
|
lens_summary[lens] = {
|
|
265
|
-
"status":
|
|
266
|
-
"passed":
|
|
267
|
-
"failed":
|
|
268
|
-
"
|
|
517
|
+
"status": _verdict_of(lens_results),
|
|
518
|
+
"passed": sum(1 for r in lens_results if not r.get("skipped") and r.get("exit_code") == 0),
|
|
519
|
+
"failed": sum(1 for r in lens_results if _is_blocking(r)),
|
|
520
|
+
"warnings": sum(1 for r in lens_results if _is_warning(r)),
|
|
521
|
+
"skipped": sum(1 for r in lens_results if r.get("skipped")),
|
|
269
522
|
}
|
|
270
523
|
|
|
271
524
|
commit_hash = "UNKNOWN"
|
|
@@ -287,8 +540,12 @@ def generate_receipt(results: list[dict], target_dir: str) -> dict:
|
|
|
287
540
|
"contract": "clckernel.audit-receipt/v1",
|
|
288
541
|
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
289
542
|
"commitHash": commit_hash,
|
|
290
|
-
"verdict":
|
|
543
|
+
"verdict": _verdict_of(results),
|
|
291
544
|
"lenses": lens_summary,
|
|
545
|
+
"warnings": [
|
|
546
|
+
{"name": r.get("name", "unknown"), "lens": r.get("lens", "architecture"), "message": _message(r)}
|
|
547
|
+
for r in results if _is_warning(r)
|
|
548
|
+
],
|
|
292
549
|
"generator": "clckernel",
|
|
293
550
|
"gentle_ai": {
|
|
294
551
|
"compatible": True,
|
|
@@ -300,13 +557,16 @@ def generate_receipt(results: list[dict], target_dir: str) -> dict:
|
|
|
300
557
|
|
|
301
558
|
# ── Console Summary ──────────────────────────────────────────────────────────
|
|
302
559
|
|
|
303
|
-
def print_summary(results: list[dict], report_path: pathlib.Path
|
|
304
|
-
|
|
560
|
+
def print_summary(results: list[dict], report_path: pathlib.Path | None = None,
|
|
561
|
+
receipt_path: pathlib.Path | None = None) -> None:
|
|
562
|
+
"""
|
|
563
|
+
Print the 4-lens summary. report_path/receipt_path: where they were
|
|
564
|
+
written, or None when no --change was given (then a hint says how).
|
|
565
|
+
"""
|
|
305
566
|
print("\n" + "=" * 65)
|
|
306
567
|
print(" CLC KERNEL — 4-LENS RDD AUDIT SUMMARY")
|
|
307
568
|
print("=" * 65 + "\n")
|
|
308
569
|
|
|
309
|
-
lenses = ["correctness", "security", "architecture", "performance"]
|
|
310
570
|
labels = {
|
|
311
571
|
"correctness": "🎯 CORRECTNESS LENS",
|
|
312
572
|
"security": "🛡️ SECURITY LENS",
|
|
@@ -314,86 +574,204 @@ def print_summary(results: list[dict], report_path: pathlib.Path, receipt_path:
|
|
|
314
574
|
"performance": "⚡ PERFORMANCE LENS",
|
|
315
575
|
}
|
|
316
576
|
|
|
317
|
-
for lens in
|
|
577
|
+
for lens in LENSES:
|
|
318
578
|
lens_results = [r for r in results if r.get("lens") == lens]
|
|
319
579
|
print(f" {labels[lens]}:")
|
|
320
580
|
if not lens_results:
|
|
321
581
|
print(" — No guards active for this lens")
|
|
322
582
|
else:
|
|
323
583
|
for result in lens_results:
|
|
324
|
-
skipped = result.get("skipped", False)
|
|
325
584
|
exit_code = result.get("exit_code", 0)
|
|
326
585
|
name = result.get("name", "unknown").replace("_", " ")
|
|
327
|
-
msg = result
|
|
328
|
-
icon =
|
|
586
|
+
msg = _message(result, "Passed" if exit_code == 0 else "Failed")
|
|
587
|
+
icon, _ = _status_of(result)
|
|
329
588
|
print(f" {icon} {name}: {msg}")
|
|
330
589
|
print("")
|
|
331
590
|
|
|
332
|
-
print(f"
|
|
333
|
-
if
|
|
334
|
-
print(f"
|
|
591
|
+
print(f"Verdict: {_verdict_of(results)}")
|
|
592
|
+
if report_path:
|
|
593
|
+
print(f"📄 Full report: {report_path}")
|
|
594
|
+
if receipt_path:
|
|
595
|
+
print(f"🧾 Audit receipt: {receipt_path}")
|
|
596
|
+
else:
|
|
597
|
+
print("No report written: pass --change <name> to write sdds/<name>/05-audit-report.md and audit-receipt.json.")
|
|
335
598
|
print("=" * 65 + "\n")
|
|
336
599
|
|
|
337
600
|
|
|
338
601
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
339
602
|
|
|
340
|
-
def
|
|
341
|
-
"""
|
|
603
|
+
def _violation_lines(data: dict) -> list[str]:
|
|
604
|
+
"""One bullet per violation (or finding) of a failed guard."""
|
|
605
|
+
lines: list[str] = []
|
|
606
|
+
violations = data.get("violations") or data.get("findings") or {}
|
|
607
|
+
if isinstance(violations, list):
|
|
608
|
+
for item in violations:
|
|
609
|
+
if isinstance(item, dict):
|
|
610
|
+
where = f"`{item['file']}`: " if item.get("file") else ""
|
|
611
|
+
lines.append(f"- {where}{item.get('message') or item.get('reason') or json.dumps(item)}")
|
|
612
|
+
return lines
|
|
613
|
+
if isinstance(violations, dict):
|
|
614
|
+
for filepath, items in violations.items():
|
|
615
|
+
for item in items if isinstance(items, list) else []:
|
|
616
|
+
if not isinstance(item, dict):
|
|
617
|
+
continue
|
|
618
|
+
loc = f" (line {item.get('lineNo')})" if item.get("lineNo") else ""
|
|
619
|
+
label = item.get("label") or item.get("rule") or item.get("module") or ""
|
|
620
|
+
msg = item.get("reason") or item.get("message") or ""
|
|
621
|
+
lines.append(f"- `{filepath}`{loc}: {f'[{label}] ' if label else ''}{msg}")
|
|
622
|
+
return lines
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _run_plan(plan: list[dict], dir_path: str) -> list[dict]:
|
|
626
|
+
"""Run every planned guard once, in plan order; each result keeps its file stem."""
|
|
627
|
+
results: list[dict] = []
|
|
628
|
+
for entry in plan:
|
|
629
|
+
if entry["mode"] == "missing":
|
|
630
|
+
result = _missing_guard_result(entry)
|
|
631
|
+
elif entry["mode"] == "module":
|
|
632
|
+
result = run_guard(entry["file"], dir_path)
|
|
633
|
+
else:
|
|
634
|
+
result = run_project_guard(entry["file"], dir_path)
|
|
635
|
+
result["stem"] = pathlib.Path(entry["file"]).stem
|
|
636
|
+
results.append(result)
|
|
637
|
+
return results
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _print_hook_summary(results: list[dict]) -> None:
|
|
641
|
+
"""--hook output: one line per guard (ok, skip, WARN, FAIL); a failed one also prints its violations or raw output."""
|
|
642
|
+
for result in results:
|
|
643
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
644
|
+
if result.get("skipped"):
|
|
645
|
+
status = "skip"
|
|
646
|
+
elif not _is_failed(result):
|
|
647
|
+
status = "ok "
|
|
648
|
+
else:
|
|
649
|
+
status = "WARN" if _is_warning(result) else "FAIL"
|
|
650
|
+
print(f"clckernel: {status} {result.get('name', 'unknown')}: {data.get('message', '')}")
|
|
651
|
+
if not _is_failed(result):
|
|
652
|
+
continue
|
|
653
|
+
details = _violation_lines(data) or [l for l in (result.get("output") or "").rstrip().split("\n") if l]
|
|
654
|
+
for line in details:
|
|
655
|
+
print(f" {line}")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _load_severities(dir_path: str) -> dict:
|
|
659
|
+
"""
|
|
660
|
+
`severities` from the project's .clckernel.yaml, read with the sibling
|
|
661
|
+
_config.py; {} when that module is not installed. An invalid config raises.
|
|
662
|
+
"""
|
|
663
|
+
tools_dir = str(pathlib.Path(__file__).resolve().parent)
|
|
664
|
+
if not (pathlib.Path(tools_dir) / "_config.py").is_file():
|
|
665
|
+
return {}
|
|
666
|
+
if tools_dir not in sys.path:
|
|
667
|
+
sys.path.insert(0, tools_dir)
|
|
668
|
+
import _config # noqa: E402
|
|
669
|
+
return _config.load_config(dir_path)["config"].get("severities") or {}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def report_names(tools_dir: pathlib.Path) -> tuple[str, str]:
|
|
673
|
+
"""
|
|
674
|
+
Report and receipt file names. With both orchestrators installed side by
|
|
675
|
+
side (a mixed stack), each runs its own language's guards and writes its
|
|
676
|
+
own pair (05-audit-report.py.md, audit-receipt.py.json), so the second run
|
|
677
|
+
of `clckernel audit --change` does not overwrite the first. A single
|
|
678
|
+
orchestrator keeps the plain names.
|
|
679
|
+
"""
|
|
680
|
+
if (pathlib.Path(tools_dir) / "audit.js").is_file():
|
|
681
|
+
return "05-audit-report.py.md", "audit-receipt.py.json"
|
|
682
|
+
return "05-audit-report.md", "audit-receipt.json"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def main(target_dir: str | None = None, hook: bool = False, change: str | None = None) -> int:
|
|
686
|
+
"""
|
|
687
|
+
Run all discovered guards. hook: one line per guard; change: also write the
|
|
688
|
+
report and receipt to sdds/<change>/. Returns 1 when a guard failed with
|
|
689
|
+
level error, else 0.
|
|
690
|
+
"""
|
|
342
691
|
tools_dir = pathlib.Path(__file__).resolve().parent
|
|
343
|
-
dir_path = target_dir or os.getcwd()
|
|
344
|
-
|
|
345
|
-
sdd_dir = find_active_sdd()
|
|
346
|
-
if sdd_dir is None:
|
|
347
|
-
# Create a default SDD dir for the report
|
|
348
|
-
sdds_dir = REPO_ROOT / "sdds"
|
|
349
|
-
sdds_dir.mkdir(parents=True, exist_ok=True)
|
|
350
|
-
sdd_dir = sdds_dir / "audit-run"
|
|
351
|
-
sdd_dir.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
dir_path = os.path.abspath(target_dir or os.getcwd())
|
|
693
|
+
plan = plan_guards(tools_dir)
|
|
352
694
|
|
|
353
|
-
|
|
695
|
+
try:
|
|
696
|
+
severities = _load_severities(dir_path)
|
|
697
|
+
except ValueError as exc: # _config.ConfigError
|
|
698
|
+
print(f"clckernel: {exc}", file=sys.stderr)
|
|
699
|
+
return 1
|
|
354
700
|
|
|
355
|
-
|
|
701
|
+
if hook:
|
|
702
|
+
results = apply_levels(_run_plan(plan, dir_path), severities)
|
|
703
|
+
sys.stdout.flush()
|
|
704
|
+
_print_hook_summary(results)
|
|
705
|
+
return 1 if any(_is_blocking(r) for r in results) else 0
|
|
356
706
|
|
|
357
|
-
|
|
707
|
+
print("\nRunning CLC Kernel audit...\n")
|
|
708
|
+
|
|
709
|
+
if not plan:
|
|
358
710
|
print("No guard tools found in tools/. Nothing to audit.")
|
|
359
711
|
return 0
|
|
360
712
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
+ ", ".join(p.stem for p in guard_paths)
|
|
364
|
-
+ "\n"
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
results: list[dict] = []
|
|
368
|
-
for guard_path in guard_paths:
|
|
369
|
-
result = run_guard(guard_path, dir_path)
|
|
370
|
-
results.append(result)
|
|
713
|
+
names = [os.path.splitext(e["label"])[0] if e["label"].endswith(OWN_EXT) else e["label"] for e in plan]
|
|
714
|
+
print(f"Discovered {len(names)} guard(s): " + ", ".join(names) + "\n")
|
|
371
715
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
report_path = sdd_dir / "05-audit-report.md"
|
|
375
|
-
report_path.write_text(report_content, encoding="utf-8")
|
|
716
|
+
results = apply_levels(_run_plan(plan, dir_path), severities)
|
|
717
|
+
sys.stdout.flush()
|
|
376
718
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
719
|
+
report_path = receipt_path = None
|
|
720
|
+
if change:
|
|
721
|
+
sdd_dir = pathlib.Path(dir_path) / "sdds" / change
|
|
722
|
+
sdd_dir.mkdir(parents=True, exist_ok=True)
|
|
723
|
+
report_name, receipt_name = report_names(tools_dir)
|
|
724
|
+
report_path = sdd_dir / report_name
|
|
725
|
+
report_path.write_text(generate_report(results, sdd_dir), encoding="utf-8")
|
|
726
|
+
receipt_path = sdd_dir / receipt_name
|
|
727
|
+
receipt_path.write_text(json.dumps(generate_receipt(results, dir_path), indent=2) + "\n", encoding="utf-8")
|
|
381
728
|
|
|
382
|
-
# Print summary
|
|
383
729
|
print_summary(results, report_path, receipt_path)
|
|
384
730
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
731
|
+
return 1 if any(_is_blocking(r) for r in results) else 0
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def parse_args(argv: list[str]) -> dict:
|
|
735
|
+
"""
|
|
736
|
+
argv: [targetDir] [--hook] [--change <name>] [--staged], in any order.
|
|
737
|
+
Returns {target_dir, hook, change, staged}, or {error} on invalid input.
|
|
738
|
+
"""
|
|
739
|
+
out = {"target_dir": None, "hook": False, "change": None, "staged": False}
|
|
740
|
+
i = 0
|
|
741
|
+
while i < len(argv):
|
|
742
|
+
arg = argv[i]
|
|
743
|
+
if arg == "--hook":
|
|
744
|
+
out["hook"] = True
|
|
745
|
+
elif arg == "--staged":
|
|
746
|
+
out["staged"] = True
|
|
747
|
+
elif arg == "--change":
|
|
748
|
+
if i + 1 >= len(argv):
|
|
749
|
+
return {"error": "--change needs a name"}
|
|
750
|
+
i += 1
|
|
751
|
+
name = argv[i]
|
|
752
|
+
if not CHANGE_NAME.match(name):
|
|
753
|
+
return {"error": f"invalid --change name {json.dumps(name)}: use one path segment (letters, digits, ., _, -)"}
|
|
754
|
+
out["change"] = name
|
|
755
|
+
elif arg.startswith("-"):
|
|
756
|
+
return {"error": f"unknown option {arg}"}
|
|
757
|
+
elif out["target_dir"] is not None:
|
|
758
|
+
return {"error": f"unexpected argument {arg}"}
|
|
759
|
+
else:
|
|
760
|
+
out["target_dir"] = arg
|
|
761
|
+
i += 1
|
|
762
|
+
if out["hook"] and out["change"]:
|
|
763
|
+
return {"error": "--hook writes nothing; it cannot be combined with --change"}
|
|
764
|
+
return out
|
|
391
765
|
|
|
392
766
|
|
|
393
767
|
if __name__ == "__main__":
|
|
768
|
+
# Unknown input exits 2. Any crash fails closed.
|
|
769
|
+
parsed = parse_args(sys.argv[1:])
|
|
770
|
+
if "error" in parsed:
|
|
771
|
+
print(f"audit.py: {parsed['error']}\n{USAGE}", file=sys.stderr)
|
|
772
|
+
sys.exit(2)
|
|
394
773
|
try:
|
|
395
|
-
|
|
396
|
-
sys.exit(main(target))
|
|
774
|
+
sys.exit(main(parsed["target_dir"], hook=parsed["hook"], change=parsed["change"]))
|
|
397
775
|
except Exception:
|
|
398
776
|
traceback.print_exc()
|
|
399
777
|
sys.exit(1)
|