@softspark/ai-toolkit 2.7.2 → 2.8.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/CHANGELOG.md +28 -0
- package/README.md +5 -4
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/commit-quality.sh +11 -8
- package/app/hooks/quality-check.sh +5 -1
- package/app/hooks/session-start.sh +7 -3
- package/app/rules/medplum/coding-style.md +56 -0
- package/app/rules/medplum/frameworks.md +51 -0
- package/app/rules/medplum/patterns.md +61 -0
- package/app/rules/medplum/security.md +58 -0
- package/app/rules/medplum/testing.md +56 -0
- package/bin/ai-toolkit.js +1 -1
- package/kb/reference/language-rules.md +8 -4
- package/kb/reference/medplum-docs-map.md +535 -0
- package/llms-full.txt +549 -4
- package/llms.txt +1 -0
- package/manifest.json +5 -1
- package/package.json +3 -4
- package/scripts/add_rule.py +1 -1
- package/scripts/audit_skills.py +178 -6
- package/scripts/config_resolver.py +8 -2
- package/scripts/hook_sources.py +30 -3
- package/scripts/inject_hook_cli.py +1 -1
- package/scripts/install_steps/ai_tools.py +10 -2
- package/scripts/install_steps/hooks.py +1 -1
- package/scripts/install_steps/markers.py +2 -2
- package/scripts/rule_sources.py +30 -3
package/scripts/audit_skills.py
CHANGED
|
@@ -7,9 +7,11 @@ Detects dangerous code patterns, hardcoded secrets, and permission issues.
|
|
|
7
7
|
Stdlib-only. JSON output to stdout. Non-zero exit on HIGH findings.
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
|
-
python3 scripts/audit_skills.py [toolkit-dir]
|
|
11
|
-
python3 scripts/audit_skills.py [toolkit-dir] --json
|
|
12
|
-
python3 scripts/audit_skills.py [toolkit-dir] --
|
|
10
|
+
python3 scripts/audit_skills.py [toolkit-dir] # scan all
|
|
11
|
+
python3 scripts/audit_skills.py [toolkit-dir] --json # JSON output
|
|
12
|
+
python3 scripts/audit_skills.py [toolkit-dir] --sarif # SARIF 2.1.0 output
|
|
13
|
+
python3 scripts/audit_skills.py [toolkit-dir] --permissions # per-skill tool permission report
|
|
14
|
+
python3 scripts/audit_skills.py [toolkit-dir] --ci # exit 1 on HIGH
|
|
13
15
|
|
|
14
16
|
Exit codes:
|
|
15
17
|
0 no HIGH findings
|
|
@@ -71,6 +73,13 @@ SECRET_PATTERNS = [
|
|
|
71
73
|
(r'api_key\s*=\s*["\'][^"\']{8,}["\']', "WARN", "Hardcoded API key"),
|
|
72
74
|
]
|
|
73
75
|
|
|
76
|
+
# Placeholder values that the WARN-severity secret patterns must ignore to cut
|
|
77
|
+
# false positives on docs, fixtures, and .env.example-style files.
|
|
78
|
+
SECRET_PLACEHOLDER_PREFIXES = (
|
|
79
|
+
"REPLACE_", "CHANGEME_", "CHANGE_ME", "YOUR_", "EXAMPLE_", "PLACEHOLDER_",
|
|
80
|
+
"${", "{{", "$ENV_", "$(", "<", "xxx", "XXX",
|
|
81
|
+
)
|
|
82
|
+
|
|
74
83
|
# ---------------------------------------------------------------------------
|
|
75
84
|
# Scanner
|
|
76
85
|
# ---------------------------------------------------------------------------
|
|
@@ -110,6 +119,16 @@ def scan_file_patterns(filepath: Path, patterns: list[tuple],
|
|
|
110
119
|
findings.append(Finding(severity, rel, lineno, regex, desc))
|
|
111
120
|
|
|
112
121
|
|
|
122
|
+
def _is_placeholder_value(match: re.Match) -> bool:
|
|
123
|
+
"""Return True if the matched value looks like a docs placeholder."""
|
|
124
|
+
# Extract content between quotes (group 0 is the full match)
|
|
125
|
+
inner = re.search(r'["\']([^"\']+)["\']', match.group(0))
|
|
126
|
+
if not inner:
|
|
127
|
+
return False
|
|
128
|
+
value = inner.group(1)
|
|
129
|
+
return value.startswith(SECRET_PLACEHOLDER_PREFIXES)
|
|
130
|
+
|
|
131
|
+
|
|
113
132
|
def scan_secrets(filepath: Path, findings: list[Finding]) -> None:
|
|
114
133
|
"""Scan a file for hardcoded secrets."""
|
|
115
134
|
try:
|
|
@@ -119,8 +138,15 @@ def scan_secrets(filepath: Path, findings: list[Finding]) -> None:
|
|
|
119
138
|
rel = str(filepath)
|
|
120
139
|
for lineno, line in enumerate(text.splitlines(), 1):
|
|
121
140
|
for regex, severity, desc in SECRET_PATTERNS:
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
m = re.search(regex, line)
|
|
142
|
+
if not m:
|
|
143
|
+
continue
|
|
144
|
+
# Skip placeholder values for WARN-level hardcoded-* patterns.
|
|
145
|
+
# HIGH-level patterns (real AWS/GitHub/etc. keys) are structural —
|
|
146
|
+
# no need to allowlist.
|
|
147
|
+
if severity == "WARN" and _is_placeholder_value(m):
|
|
148
|
+
continue
|
|
149
|
+
findings.append(Finding(severity, rel, lineno, regex, desc))
|
|
124
150
|
|
|
125
151
|
|
|
126
152
|
def check_frontmatter(skill_dir: Path, findings: list[Finding]) -> None:
|
|
@@ -176,6 +202,78 @@ def check_agent(agent_md: Path, findings: list[Finding]) -> None:
|
|
|
176
202
|
))
|
|
177
203
|
|
|
178
204
|
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Per-skill permission report
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def collect_permissions(toolkit_root: Path) -> list[dict]:
|
|
210
|
+
"""Read each SKILL.md frontmatter and return permission metadata per skill."""
|
|
211
|
+
skills = toolkit_root / "app" / "skills"
|
|
212
|
+
rows: list[dict] = []
|
|
213
|
+
if not skills.is_dir():
|
|
214
|
+
return rows
|
|
215
|
+
for skill_dir in sorted(skills.iterdir()):
|
|
216
|
+
if not skill_dir.is_dir() or skill_dir.name.startswith("_"):
|
|
217
|
+
continue
|
|
218
|
+
skill_md = skill_dir / "SKILL.md"
|
|
219
|
+
if not skill_md.is_file():
|
|
220
|
+
continue
|
|
221
|
+
allowed_raw = frontmatter_field(skill_md, "allowed-tools") or ""
|
|
222
|
+
tools = [t.strip() for t in allowed_raw.split(",") if t.strip()]
|
|
223
|
+
rows.append({
|
|
224
|
+
"name": skill_dir.name,
|
|
225
|
+
"tools": tools,
|
|
226
|
+
"user_invocable": frontmatter_field(skill_md, "user-invocable") or "",
|
|
227
|
+
"disable_model_invocation": frontmatter_field(
|
|
228
|
+
skill_md, "disable-model-invocation"
|
|
229
|
+
) or "",
|
|
230
|
+
})
|
|
231
|
+
return rows
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def print_permissions(rows: list[dict], json_mode: bool = False) -> None:
|
|
235
|
+
"""Emit the per-skill permission report."""
|
|
236
|
+
# Aggregate tool usage counts
|
|
237
|
+
by_tool: dict[str, list[str]] = {}
|
|
238
|
+
for row in rows:
|
|
239
|
+
for tool in row["tools"]:
|
|
240
|
+
by_tool.setdefault(tool, []).append(row["name"])
|
|
241
|
+
broad = [
|
|
242
|
+
row["name"] for row in rows
|
|
243
|
+
if {"Bash", "Write", "Edit"}.issubset(set(row["tools"]))
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
if json_mode:
|
|
247
|
+
report = {
|
|
248
|
+
"total": len(rows),
|
|
249
|
+
"by_tool": {k: sorted(v) for k, v in by_tool.items()},
|
|
250
|
+
"broad_access": sorted(broad),
|
|
251
|
+
"skills": rows,
|
|
252
|
+
}
|
|
253
|
+
print(json.dumps(report, indent=2))
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
print("Skill Permissions Report")
|
|
257
|
+
print("=" * 40)
|
|
258
|
+
print(f"Total skills: {len(rows)}")
|
|
259
|
+
print()
|
|
260
|
+
print("By tool (skill count):")
|
|
261
|
+
for tool in sorted(by_tool, key=lambda t: (-len(by_tool[t]), t)):
|
|
262
|
+
print(f" {tool:<12} {len(by_tool[tool])}")
|
|
263
|
+
print()
|
|
264
|
+
if broad:
|
|
265
|
+
print(f"Skills with Bash + Write + Edit ({len(broad)}):")
|
|
266
|
+
for name in sorted(broad):
|
|
267
|
+
print(f" - {name}")
|
|
268
|
+
print()
|
|
269
|
+
print("Full table:")
|
|
270
|
+
print(f" {'skill':<32} {'invocable':<10} {'tools'}")
|
|
271
|
+
for row in rows:
|
|
272
|
+
inv = row["user_invocable"] or "-"
|
|
273
|
+
tools = ",".join(row["tools"]) or "(none declared)"
|
|
274
|
+
print(f" {row['name']:<32} {inv:<10} {tools}")
|
|
275
|
+
|
|
276
|
+
|
|
179
277
|
# ---------------------------------------------------------------------------
|
|
180
278
|
# Main
|
|
181
279
|
# ---------------------------------------------------------------------------
|
|
@@ -260,23 +358,97 @@ def print_json(findings: list[Finding]) -> None:
|
|
|
260
358
|
print(json.dumps(report, indent=2))
|
|
261
359
|
|
|
262
360
|
|
|
361
|
+
# SARIF severity maps to GitHub Advanced Security Code Scanning levels.
|
|
362
|
+
_SARIF_LEVEL = {"HIGH": "error", "WARN": "warning", "INFO": "note"}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _sarif_rules(findings: list[Finding]) -> list[dict]:
|
|
366
|
+
"""Build the tool.driver.rules array from unique (severity, description) pairs."""
|
|
367
|
+
seen: dict[str, dict] = {}
|
|
368
|
+
for f in findings:
|
|
369
|
+
rule_id = f"{f.severity}-{hash(f.description) & 0xFFFFFFFF:08x}"
|
|
370
|
+
if rule_id in seen:
|
|
371
|
+
continue
|
|
372
|
+
seen[rule_id] = {
|
|
373
|
+
"id": rule_id,
|
|
374
|
+
"name": f.description.split(" — ")[0].replace(" ", "-").lower()[:64],
|
|
375
|
+
"shortDescription": {"text": f.description},
|
|
376
|
+
"defaultConfiguration": {"level": _SARIF_LEVEL.get(f.severity, "note")},
|
|
377
|
+
}
|
|
378
|
+
return list(seen.values())
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def print_sarif(findings: list[Finding], toolkit_root: Path) -> None:
|
|
382
|
+
"""Print SARIF 2.1.0 report for GitHub Code Scanning ingestion."""
|
|
383
|
+
rules = _sarif_rules(findings)
|
|
384
|
+
rule_index = {r["shortDescription"]["text"]: i for i, r in enumerate(rules)}
|
|
385
|
+
results = []
|
|
386
|
+
for f in findings:
|
|
387
|
+
idx = rule_index.get(f.description, 0)
|
|
388
|
+
rule_id = rules[idx]["id"] if rules else "unknown"
|
|
389
|
+
try:
|
|
390
|
+
rel = str(Path(f.file).resolve().relative_to(toolkit_root.resolve()))
|
|
391
|
+
except ValueError:
|
|
392
|
+
rel = f.file
|
|
393
|
+
results.append({
|
|
394
|
+
"ruleId": rule_id,
|
|
395
|
+
"ruleIndex": idx,
|
|
396
|
+
"level": _SARIF_LEVEL.get(f.severity, "note"),
|
|
397
|
+
"message": {"text": f.description},
|
|
398
|
+
"locations": [{
|
|
399
|
+
"physicalLocation": {
|
|
400
|
+
"artifactLocation": {"uri": rel},
|
|
401
|
+
"region": {"startLine": max(1, f.line)},
|
|
402
|
+
}
|
|
403
|
+
}],
|
|
404
|
+
})
|
|
405
|
+
sarif = {
|
|
406
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
407
|
+
"version": "2.1.0",
|
|
408
|
+
"runs": [{
|
|
409
|
+
"tool": {
|
|
410
|
+
"driver": {
|
|
411
|
+
"name": "ai-toolkit-audit-skills",
|
|
412
|
+
"informationUri": "https://github.com/softspark/ai-toolkit",
|
|
413
|
+
"rules": rules,
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
"results": results,
|
|
417
|
+
}],
|
|
418
|
+
}
|
|
419
|
+
print(json.dumps(sarif, indent=2))
|
|
420
|
+
|
|
421
|
+
|
|
263
422
|
def main() -> None:
|
|
264
423
|
args = sys.argv[1:]
|
|
265
424
|
toolkit_root = default_toolkit_dir
|
|
266
425
|
json_mode = False
|
|
426
|
+
sarif_mode = False
|
|
427
|
+
permissions_mode = False
|
|
267
428
|
ci_mode = False
|
|
268
429
|
|
|
269
430
|
for arg in args:
|
|
270
431
|
if arg == "--json":
|
|
271
432
|
json_mode = True
|
|
433
|
+
elif arg == "--sarif":
|
|
434
|
+
sarif_mode = True
|
|
435
|
+
elif arg == "--permissions":
|
|
436
|
+
permissions_mode = True
|
|
272
437
|
elif arg == "--ci":
|
|
273
438
|
ci_mode = True
|
|
274
439
|
elif not arg.startswith("-"):
|
|
275
440
|
toolkit_root = Path(arg)
|
|
276
441
|
|
|
442
|
+
if permissions_mode:
|
|
443
|
+
rows = collect_permissions(toolkit_root)
|
|
444
|
+
print_permissions(rows, json_mode=json_mode)
|
|
445
|
+
return
|
|
446
|
+
|
|
277
447
|
findings = audit(toolkit_root)
|
|
278
448
|
|
|
279
|
-
if
|
|
449
|
+
if sarif_mode:
|
|
450
|
+
print_sarif(findings, toolkit_root)
|
|
451
|
+
elif json_mode:
|
|
280
452
|
print_json(findings)
|
|
281
453
|
else:
|
|
282
454
|
print_text(findings)
|
|
@@ -302,9 +302,12 @@ def _extract_tarball(tarball: Path, dest: Path) -> None:
|
|
|
302
302
|
"""Extract npm tarball (which has a package/ prefix) to dest.
|
|
303
303
|
|
|
304
304
|
Validates that extracted paths stay within dest to prevent path traversal.
|
|
305
|
-
Rejects symlinks and absolute paths.
|
|
305
|
+
Rejects symlinks and absolute paths. Uses tarfile filter="data" on 3.12+
|
|
306
|
+
as defense in depth (Python 3.14 will require it).
|
|
306
307
|
"""
|
|
307
308
|
dest_resolved = dest.resolve()
|
|
309
|
+
# filter="data" landed in 3.12 and becomes the default in 3.14
|
|
310
|
+
supports_filter = sys.version_info >= (3, 12)
|
|
308
311
|
with tarfile.open(tarball, "r:gz") as tf:
|
|
309
312
|
for member in tf.getmembers():
|
|
310
313
|
# npm tarballs have a "package/" prefix
|
|
@@ -320,7 +323,10 @@ def _extract_tarball(tarball: Path, dest: Path) -> None:
|
|
|
320
323
|
target = (dest / member.name).resolve()
|
|
321
324
|
if not str(target).startswith(str(dest_resolved)):
|
|
322
325
|
continue
|
|
323
|
-
|
|
326
|
+
if supports_filter:
|
|
327
|
+
tf.extract(member, dest, filter="data")
|
|
328
|
+
else:
|
|
329
|
+
tf.extract(member, dest)
|
|
324
330
|
|
|
325
331
|
|
|
326
332
|
def _extract_version_from_tarball(filename: str, package_name: str) -> str:
|
package/scripts/hook_sources.py
CHANGED
|
@@ -10,6 +10,7 @@ Stdlib-only — no external dependencies.
|
|
|
10
10
|
"""
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import hashlib
|
|
13
14
|
import json
|
|
14
15
|
import os
|
|
15
16
|
import sys
|
|
@@ -78,17 +79,43 @@ def save_sources(hooks_dir: Path | None = None,
|
|
|
78
79
|
# CRUD
|
|
79
80
|
# ---------------------------------------------------------------------------
|
|
80
81
|
|
|
81
|
-
def register_url_source(
|
|
82
|
-
|
|
82
|
+
def register_url_source(
|
|
83
|
+
hooks_dir: Path | None,
|
|
84
|
+
hook_name: str,
|
|
85
|
+
url: str,
|
|
86
|
+
content: bytes | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Add or update a URL source entry.
|
|
89
|
+
|
|
90
|
+
When ``content`` is supplied, its sha256 is persisted. If a previous
|
|
91
|
+
sha256 exists and differs from the new one, a warning is printed
|
|
92
|
+
(and the process fails with exit 2 when ``AI_TOOLKIT_STRICT_PIN=1``).
|
|
93
|
+
"""
|
|
83
94
|
import re
|
|
84
95
|
if not hook_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", hook_name):
|
|
85
96
|
raise ValueError(f"Invalid hook name: {hook_name!r}")
|
|
86
97
|
hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
|
|
87
98
|
sources = load_sources(hooks_dir)
|
|
88
|
-
|
|
99
|
+
entry: dict[str, Any] = {
|
|
89
100
|
"url": url,
|
|
90
101
|
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
91
102
|
}
|
|
103
|
+
if content is not None:
|
|
104
|
+
new_hash = hashlib.sha256(content).hexdigest()
|
|
105
|
+
prev = sources.get(hook_name) or {}
|
|
106
|
+
prev_hash = prev.get("sha256")
|
|
107
|
+
if prev_hash and prev_hash != new_hash:
|
|
108
|
+
msg = (
|
|
109
|
+
f" CHECKSUM CHANGED: hook '{hook_name}' sha256 "
|
|
110
|
+
f"{prev_hash[:12]}... -> {new_hash[:12]}..."
|
|
111
|
+
)
|
|
112
|
+
print(msg)
|
|
113
|
+
if os.environ.get("AI_TOOLKIT_STRICT_PIN") == "1":
|
|
114
|
+
raise SystemExit(
|
|
115
|
+
f"Refusing to update '{hook_name}' under AI_TOOLKIT_STRICT_PIN=1."
|
|
116
|
+
)
|
|
117
|
+
entry["sha256"] = new_hash
|
|
118
|
+
sources[hook_name] = entry
|
|
92
119
|
save_sources(hooks_dir, sources)
|
|
93
120
|
|
|
94
121
|
|
|
@@ -284,7 +284,7 @@ def _fetch_and_cache(url: str, source: str) -> str:
|
|
|
284
284
|
|
|
285
285
|
cached_path = EXTERNAL_HOOKS_DIR / f"{source}.json"
|
|
286
286
|
cached_path.write_bytes(data)
|
|
287
|
-
register_url_source(None, source, url)
|
|
287
|
+
register_url_source(None, source, url, content=data)
|
|
288
288
|
|
|
289
289
|
return str(cached_path)
|
|
290
290
|
|
|
@@ -194,7 +194,11 @@ def inject_with_rules(
|
|
|
194
194
|
else:
|
|
195
195
|
cmd = ["bash", str(scripts_dir / generator_script)]
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
try:
|
|
198
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
print(f" ERROR: {generator_script} timed out after 120s")
|
|
201
|
+
return
|
|
198
202
|
if result.returncode != 0:
|
|
199
203
|
print(f" ERROR: {generator_script} failed: {result.stderr.strip()}")
|
|
200
204
|
return
|
|
@@ -240,7 +244,11 @@ def run_script(script_name: str, *args: str, capture: bool = False) -> str:
|
|
|
240
244
|
cmd = ["python3", str(scripts_dir / py_name), *args]
|
|
241
245
|
else:
|
|
242
246
|
cmd = ["bash", str(scripts_dir / script_name), *args]
|
|
243
|
-
|
|
247
|
+
try:
|
|
248
|
+
result = subprocess.run(cmd, capture_output=capture, text=True, timeout=120)
|
|
249
|
+
except subprocess.TimeoutExpired:
|
|
250
|
+
print(f" ERROR: {script_name} timed out after 120s")
|
|
251
|
+
return ""
|
|
244
252
|
return result.stdout if capture else ""
|
|
245
253
|
|
|
246
254
|
|
|
@@ -64,7 +64,7 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
64
64
|
|
|
65
65
|
def _run_merge_hooks(action: str, *args: str) -> None:
|
|
66
66
|
cmd = ["python3", str(toolkit_dir / "scripts" / "merge-hooks.py"), action, *args]
|
|
67
|
-
subprocess.run(cmd, check=True)
|
|
67
|
+
subprocess.run(cmd, check=True, timeout=120)
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
def _install_output_styles(claude_dir: Path) -> None:
|
|
@@ -88,7 +88,7 @@ def _refresh_url_rules(rules_dir: Path) -> None:
|
|
|
88
88
|
try:
|
|
89
89
|
data = fetch_url(url)
|
|
90
90
|
rule_file.write_bytes(data)
|
|
91
|
-
register_url_source(rules_dir, rule_name, url)
|
|
91
|
+
register_url_source(rules_dir, rule_name, url, content=data)
|
|
92
92
|
print(f" Refreshed: {rule_name} (from {url})")
|
|
93
93
|
except Exception as exc:
|
|
94
94
|
if rule_file.is_file():
|
|
@@ -124,7 +124,7 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
|
|
|
124
124
|
# Validate JSON before caching
|
|
125
125
|
json.loads(data)
|
|
126
126
|
cached_file.write_bytes(data)
|
|
127
|
-
register_url_source(None, hook_name, url)
|
|
127
|
+
register_url_source(None, hook_name, url, content=data)
|
|
128
128
|
print(f" Refreshed: {hook_name} (from {url})")
|
|
129
129
|
except Exception as exc:
|
|
130
130
|
if cached_file.is_file():
|
package/scripts/rule_sources.py
CHANGED
|
@@ -10,6 +10,7 @@ Stdlib-only — no external dependencies.
|
|
|
10
10
|
"""
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import hashlib
|
|
13
14
|
import json
|
|
14
15
|
import os
|
|
15
16
|
import sys
|
|
@@ -79,17 +80,43 @@ def save_sources(rules_dir: Path | None = None,
|
|
|
79
80
|
# CRUD
|
|
80
81
|
# ---------------------------------------------------------------------------
|
|
81
82
|
|
|
82
|
-
def register_url_source(
|
|
83
|
-
|
|
83
|
+
def register_url_source(
|
|
84
|
+
rules_dir: Path | None,
|
|
85
|
+
rule_name: str,
|
|
86
|
+
url: str,
|
|
87
|
+
content: bytes | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Add or update a URL source entry.
|
|
90
|
+
|
|
91
|
+
When ``content`` is supplied, its sha256 is persisted. If a previous
|
|
92
|
+
sha256 exists and differs from the new one, a warning is printed
|
|
93
|
+
(and the process fails with exit 2 when ``AI_TOOLKIT_STRICT_PIN=1``).
|
|
94
|
+
"""
|
|
84
95
|
import re
|
|
85
96
|
if not rule_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", rule_name):
|
|
86
97
|
raise ValueError(f"Invalid rule name: {rule_name!r}")
|
|
87
98
|
rules_dir = rules_dir or RULES_DIR
|
|
88
99
|
sources = load_sources(rules_dir)
|
|
89
|
-
|
|
100
|
+
entry: dict[str, Any] = {
|
|
90
101
|
"url": url,
|
|
91
102
|
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
92
103
|
}
|
|
104
|
+
if content is not None:
|
|
105
|
+
new_hash = hashlib.sha256(content).hexdigest()
|
|
106
|
+
prev = sources.get(rule_name) or {}
|
|
107
|
+
prev_hash = prev.get("sha256")
|
|
108
|
+
if prev_hash and prev_hash != new_hash:
|
|
109
|
+
msg = (
|
|
110
|
+
f" CHECKSUM CHANGED: rule '{rule_name}' sha256 "
|
|
111
|
+
f"{prev_hash[:12]}... -> {new_hash[:12]}..."
|
|
112
|
+
)
|
|
113
|
+
print(msg)
|
|
114
|
+
if os.environ.get("AI_TOOLKIT_STRICT_PIN") == "1":
|
|
115
|
+
raise SystemExit(
|
|
116
|
+
f"Refusing to update '{rule_name}' under AI_TOOLKIT_STRICT_PIN=1."
|
|
117
|
+
)
|
|
118
|
+
entry["sha256"] = new_hash
|
|
119
|
+
sources[rule_name] = entry
|
|
93
120
|
save_sources(rules_dir, sources)
|
|
94
121
|
|
|
95
122
|
|