@softspark/ai-toolkit 2.7.3 → 2.9.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.md +30 -30
- package/CHANGELOG.md +30 -0
- package/README.md +4 -2
- 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/skills/api-patterns/SKILL.md +1 -1
- package/app/skills/app-builder/SKILL.md +1 -1
- package/app/skills/architecture-decision/SKILL.md +1 -1
- package/app/skills/ci-cd-patterns/SKILL.md +1 -1
- package/app/skills/clean-code/SKILL.md +1 -1
- package/app/skills/csharp-patterns/SKILL.md +1 -1
- package/app/skills/database-patterns/SKILL.md +1 -1
- package/app/skills/debugging-tactics/SKILL.md +1 -1
- package/app/skills/design-engineering/SKILL.md +1 -1
- package/app/skills/docker-devops/SKILL.md +1 -1
- package/app/skills/documentation-standards/SKILL.md +1 -1
- package/app/skills/ecommerce-patterns/SKILL.md +1 -1
- package/app/skills/flutter-patterns/SKILL.md +1 -1
- package/app/skills/git-mastery/SKILL.md +1 -1
- package/app/skills/hive-mind/SKILL.md +1 -1
- package/app/skills/java-patterns/SKILL.md +1 -1
- package/app/skills/kotlin-patterns/SKILL.md +1 -1
- package/app/skills/mcp-patterns/SKILL.md +1 -1
- package/app/skills/migration-patterns/SKILL.md +1 -1
- package/app/skills/observability-patterns/SKILL.md +1 -1
- package/app/skills/performance-profiling/SKILL.md +1 -1
- package/app/skills/plan-writing/SKILL.md +1 -1
- package/app/skills/rag-patterns/SKILL.md +1 -1
- package/app/skills/research-mastery/SKILL.md +1 -1
- package/app/skills/ruby-patterns/SKILL.md +1 -1
- package/app/skills/rust-patterns/SKILL.md +1 -1
- package/app/skills/security-patterns/SKILL.md +1 -1
- package/app/skills/swift-patterns/SKILL.md +1 -1
- package/app/skills/testing-patterns/SKILL.md +1 -1
- package/app/skills/typescript-patterns/SKILL.md +1 -1
- package/bin/ai-toolkit.js +1 -1
- package/kb/procedures/release-preparation-sop.md +72 -15
- package/kb/procedures/release-verification-sop.md +82 -7
- package/llms-full.txt +184 -52
- package/manifest.json +1 -1
- package/package.json +3 -4
- package/scripts/add_rule.py +1 -1
- package/scripts/audit_skills.py +246 -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,80 @@ 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))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Description quality — per Anthropic docs (code.claude.com/docs/en/skills.md):
|
|
153
|
+
# description + when_to_use combined ≤ 1536 chars; first sentence carries
|
|
154
|
+
# the trigger keywords that let the LLM route to this skill.
|
|
155
|
+
DESCRIPTION_MAX_CHARS = 1536
|
|
156
|
+
DESCRIPTION_MIN_CHARS = 80
|
|
157
|
+
|
|
158
|
+
# Weak patterns — fail-fast on the historical "Loaded when user asks about X"
|
|
159
|
+
# shape that carries no action verb and no concrete keywords.
|
|
160
|
+
WEAK_DESCRIPTION_PATTERNS = [
|
|
161
|
+
(r'^Loaded when user asks about ', "starts with 'Loaded when user asks about' — no action verb, no trigger keywords"),
|
|
162
|
+
(r'^Loaded when user asks to ', "starts with 'Loaded when user asks to' — no action verb, no trigger keywords"),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def check_description(skill_md: Path, findings: list[Finding]) -> None:
|
|
167
|
+
"""Check that SKILL.md description is specific enough for auto-loading.
|
|
168
|
+
|
|
169
|
+
Only enforces for knowledge skills (user-invocable: false). Task skills
|
|
170
|
+
invoked via `/name` do not rely on description for routing.
|
|
171
|
+
"""
|
|
172
|
+
user_invocable = frontmatter_field(skill_md, "user-invocable")
|
|
173
|
+
disable_model = frontmatter_field(skill_md, "disable-model-invocation")
|
|
174
|
+
|
|
175
|
+
# Skip strictly-task skills — description is a menu label, not a trigger.
|
|
176
|
+
if disable_model == "true" and user_invocable != "false":
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
desc = frontmatter_field(skill_md, "description")
|
|
180
|
+
when = frontmatter_field(skill_md, "when_to_use")
|
|
181
|
+
combined_len = len(desc) + len(when)
|
|
182
|
+
rel = str(skill_md)
|
|
183
|
+
|
|
184
|
+
if not desc:
|
|
185
|
+
findings.append(Finding(
|
|
186
|
+
"WARN", rel, 0, "missing-description",
|
|
187
|
+
"Skill has no description — auto-loaded skills need one to be routable",
|
|
188
|
+
))
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
if combined_len > DESCRIPTION_MAX_CHARS:
|
|
192
|
+
findings.append(Finding(
|
|
193
|
+
"WARN", rel, 0, "description-too-long",
|
|
194
|
+
f"description + when_to_use is {combined_len} chars (limit {DESCRIPTION_MAX_CHARS}). "
|
|
195
|
+
"Anthropic truncates past this — trigger keywords may be lost.",
|
|
196
|
+
))
|
|
197
|
+
|
|
198
|
+
# Knowledge skills must have routable descriptions.
|
|
199
|
+
if user_invocable == "false":
|
|
200
|
+
if len(desc) < DESCRIPTION_MIN_CHARS:
|
|
201
|
+
findings.append(Finding(
|
|
202
|
+
"WARN", rel, 0, "description-too-short",
|
|
203
|
+
f"Knowledge skill description is {len(desc)} chars — needs "
|
|
204
|
+
f"≥{DESCRIPTION_MIN_CHARS} with concrete trigger keywords",
|
|
205
|
+
))
|
|
206
|
+
|
|
207
|
+
for regex, reason in WEAK_DESCRIPTION_PATTERNS:
|
|
208
|
+
if re.match(regex, desc):
|
|
209
|
+
findings.append(Finding(
|
|
210
|
+
"WARN", rel, 0, "description-weak-pattern",
|
|
211
|
+
f"Weak description: {reason}. "
|
|
212
|
+
"Use the shape '[capability]. Triggers: [keywords]. Load when [...].'",
|
|
213
|
+
))
|
|
214
|
+
break
|
|
124
215
|
|
|
125
216
|
|
|
126
217
|
def check_frontmatter(skill_dir: Path, findings: list[Finding]) -> None:
|
|
@@ -130,6 +221,9 @@ def check_frontmatter(skill_dir: Path, findings: list[Finding]) -> None:
|
|
|
130
221
|
return
|
|
131
222
|
rel = str(skill_md)
|
|
132
223
|
|
|
224
|
+
# Description quality (routability)
|
|
225
|
+
check_description(skill_md, findings)
|
|
226
|
+
|
|
133
227
|
allowed = frontmatter_field(skill_md, "allowed-tools")
|
|
134
228
|
user_invocable = frontmatter_field(skill_md, "user-invocable")
|
|
135
229
|
disable_model = frontmatter_field(skill_md, "disable-model-invocation")
|
|
@@ -176,6 +270,78 @@ def check_agent(agent_md: Path, findings: list[Finding]) -> None:
|
|
|
176
270
|
))
|
|
177
271
|
|
|
178
272
|
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Per-skill permission report
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def collect_permissions(toolkit_root: Path) -> list[dict]:
|
|
278
|
+
"""Read each SKILL.md frontmatter and return permission metadata per skill."""
|
|
279
|
+
skills = toolkit_root / "app" / "skills"
|
|
280
|
+
rows: list[dict] = []
|
|
281
|
+
if not skills.is_dir():
|
|
282
|
+
return rows
|
|
283
|
+
for skill_dir in sorted(skills.iterdir()):
|
|
284
|
+
if not skill_dir.is_dir() or skill_dir.name.startswith("_"):
|
|
285
|
+
continue
|
|
286
|
+
skill_md = skill_dir / "SKILL.md"
|
|
287
|
+
if not skill_md.is_file():
|
|
288
|
+
continue
|
|
289
|
+
allowed_raw = frontmatter_field(skill_md, "allowed-tools") or ""
|
|
290
|
+
tools = [t.strip() for t in allowed_raw.split(",") if t.strip()]
|
|
291
|
+
rows.append({
|
|
292
|
+
"name": skill_dir.name,
|
|
293
|
+
"tools": tools,
|
|
294
|
+
"user_invocable": frontmatter_field(skill_md, "user-invocable") or "",
|
|
295
|
+
"disable_model_invocation": frontmatter_field(
|
|
296
|
+
skill_md, "disable-model-invocation"
|
|
297
|
+
) or "",
|
|
298
|
+
})
|
|
299
|
+
return rows
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def print_permissions(rows: list[dict], json_mode: bool = False) -> None:
|
|
303
|
+
"""Emit the per-skill permission report."""
|
|
304
|
+
# Aggregate tool usage counts
|
|
305
|
+
by_tool: dict[str, list[str]] = {}
|
|
306
|
+
for row in rows:
|
|
307
|
+
for tool in row["tools"]:
|
|
308
|
+
by_tool.setdefault(tool, []).append(row["name"])
|
|
309
|
+
broad = [
|
|
310
|
+
row["name"] for row in rows
|
|
311
|
+
if {"Bash", "Write", "Edit"}.issubset(set(row["tools"]))
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
if json_mode:
|
|
315
|
+
report = {
|
|
316
|
+
"total": len(rows),
|
|
317
|
+
"by_tool": {k: sorted(v) for k, v in by_tool.items()},
|
|
318
|
+
"broad_access": sorted(broad),
|
|
319
|
+
"skills": rows,
|
|
320
|
+
}
|
|
321
|
+
print(json.dumps(report, indent=2))
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
print("Skill Permissions Report")
|
|
325
|
+
print("=" * 40)
|
|
326
|
+
print(f"Total skills: {len(rows)}")
|
|
327
|
+
print()
|
|
328
|
+
print("By tool (skill count):")
|
|
329
|
+
for tool in sorted(by_tool, key=lambda t: (-len(by_tool[t]), t)):
|
|
330
|
+
print(f" {tool:<12} {len(by_tool[tool])}")
|
|
331
|
+
print()
|
|
332
|
+
if broad:
|
|
333
|
+
print(f"Skills with Bash + Write + Edit ({len(broad)}):")
|
|
334
|
+
for name in sorted(broad):
|
|
335
|
+
print(f" - {name}")
|
|
336
|
+
print()
|
|
337
|
+
print("Full table:")
|
|
338
|
+
print(f" {'skill':<32} {'invocable':<10} {'tools'}")
|
|
339
|
+
for row in rows:
|
|
340
|
+
inv = row["user_invocable"] or "-"
|
|
341
|
+
tools = ",".join(row["tools"]) or "(none declared)"
|
|
342
|
+
print(f" {row['name']:<32} {inv:<10} {tools}")
|
|
343
|
+
|
|
344
|
+
|
|
179
345
|
# ---------------------------------------------------------------------------
|
|
180
346
|
# Main
|
|
181
347
|
# ---------------------------------------------------------------------------
|
|
@@ -260,23 +426,97 @@ def print_json(findings: list[Finding]) -> None:
|
|
|
260
426
|
print(json.dumps(report, indent=2))
|
|
261
427
|
|
|
262
428
|
|
|
429
|
+
# SARIF severity maps to GitHub Advanced Security Code Scanning levels.
|
|
430
|
+
_SARIF_LEVEL = {"HIGH": "error", "WARN": "warning", "INFO": "note"}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _sarif_rules(findings: list[Finding]) -> list[dict]:
|
|
434
|
+
"""Build the tool.driver.rules array from unique (severity, description) pairs."""
|
|
435
|
+
seen: dict[str, dict] = {}
|
|
436
|
+
for f in findings:
|
|
437
|
+
rule_id = f"{f.severity}-{hash(f.description) & 0xFFFFFFFF:08x}"
|
|
438
|
+
if rule_id in seen:
|
|
439
|
+
continue
|
|
440
|
+
seen[rule_id] = {
|
|
441
|
+
"id": rule_id,
|
|
442
|
+
"name": f.description.split(" — ")[0].replace(" ", "-").lower()[:64],
|
|
443
|
+
"shortDescription": {"text": f.description},
|
|
444
|
+
"defaultConfiguration": {"level": _SARIF_LEVEL.get(f.severity, "note")},
|
|
445
|
+
}
|
|
446
|
+
return list(seen.values())
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def print_sarif(findings: list[Finding], toolkit_root: Path) -> None:
|
|
450
|
+
"""Print SARIF 2.1.0 report for GitHub Code Scanning ingestion."""
|
|
451
|
+
rules = _sarif_rules(findings)
|
|
452
|
+
rule_index = {r["shortDescription"]["text"]: i for i, r in enumerate(rules)}
|
|
453
|
+
results = []
|
|
454
|
+
for f in findings:
|
|
455
|
+
idx = rule_index.get(f.description, 0)
|
|
456
|
+
rule_id = rules[idx]["id"] if rules else "unknown"
|
|
457
|
+
try:
|
|
458
|
+
rel = str(Path(f.file).resolve().relative_to(toolkit_root.resolve()))
|
|
459
|
+
except ValueError:
|
|
460
|
+
rel = f.file
|
|
461
|
+
results.append({
|
|
462
|
+
"ruleId": rule_id,
|
|
463
|
+
"ruleIndex": idx,
|
|
464
|
+
"level": _SARIF_LEVEL.get(f.severity, "note"),
|
|
465
|
+
"message": {"text": f.description},
|
|
466
|
+
"locations": [{
|
|
467
|
+
"physicalLocation": {
|
|
468
|
+
"artifactLocation": {"uri": rel},
|
|
469
|
+
"region": {"startLine": max(1, f.line)},
|
|
470
|
+
}
|
|
471
|
+
}],
|
|
472
|
+
})
|
|
473
|
+
sarif = {
|
|
474
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
475
|
+
"version": "2.1.0",
|
|
476
|
+
"runs": [{
|
|
477
|
+
"tool": {
|
|
478
|
+
"driver": {
|
|
479
|
+
"name": "ai-toolkit-audit-skills",
|
|
480
|
+
"informationUri": "https://github.com/softspark/ai-toolkit",
|
|
481
|
+
"rules": rules,
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
"results": results,
|
|
485
|
+
}],
|
|
486
|
+
}
|
|
487
|
+
print(json.dumps(sarif, indent=2))
|
|
488
|
+
|
|
489
|
+
|
|
263
490
|
def main() -> None:
|
|
264
491
|
args = sys.argv[1:]
|
|
265
492
|
toolkit_root = default_toolkit_dir
|
|
266
493
|
json_mode = False
|
|
494
|
+
sarif_mode = False
|
|
495
|
+
permissions_mode = False
|
|
267
496
|
ci_mode = False
|
|
268
497
|
|
|
269
498
|
for arg in args:
|
|
270
499
|
if arg == "--json":
|
|
271
500
|
json_mode = True
|
|
501
|
+
elif arg == "--sarif":
|
|
502
|
+
sarif_mode = True
|
|
503
|
+
elif arg == "--permissions":
|
|
504
|
+
permissions_mode = True
|
|
272
505
|
elif arg == "--ci":
|
|
273
506
|
ci_mode = True
|
|
274
507
|
elif not arg.startswith("-"):
|
|
275
508
|
toolkit_root = Path(arg)
|
|
276
509
|
|
|
510
|
+
if permissions_mode:
|
|
511
|
+
rows = collect_permissions(toolkit_root)
|
|
512
|
+
print_permissions(rows, json_mode=json_mode)
|
|
513
|
+
return
|
|
514
|
+
|
|
277
515
|
findings = audit(toolkit_root)
|
|
278
516
|
|
|
279
|
-
if
|
|
517
|
+
if sarif_mode:
|
|
518
|
+
print_sarif(findings, toolkit_root)
|
|
519
|
+
elif json_mode:
|
|
280
520
|
print_json(findings)
|
|
281
521
|
else:
|
|
282
522
|
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
|
|