@softspark/ai-toolkit 2.7.3 → 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 CHANGED
@@ -7,6 +7,27 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v2.8.0 — Security Hardening & GHAS Integration (2026-04-18)
11
+
12
+ ### Added
13
+ - **`scripts/audit_skills.py --sarif`** — emits SARIF 2.1.0 JSON compatible with GitHub Advanced Security Code Scanning. Severity maps HIGH→error / WARN→warning / INFO→note. Enables the GitHub Security tab to ingest audit findings directly.
14
+ - **`scripts/audit_skills.py --permissions`** — per-skill tool-permission report (human + `--json` forms). Aggregates skills by tool usage (e.g. "50 skills use Bash"), flags broad Bash+Write+Edit access, prints full skill/invocable/tools table. Security review can now answer "show me every skill that can Bash" in one command.
15
+ - **Checksum pinning for URL-sourced rules and hooks** — `rule_sources.py` and `hook_sources.py` now persist `sha256` of the fetched payload in `sources.json`. Subsequent refreshes log `CHECKSUM CHANGED` when the upstream payload changes. Setting `AI_TOOLKIT_STRICT_PIN=1` turns the mismatch into a hard failure (exit 2) so CI can reject silent upstream tampering.
16
+ - **`SECRET_PLACEHOLDER_PREFIXES`** allowlist in `audit_skills.py` — WARN-level hardcoded-secret patterns now skip values starting with `REPLACE_`, `CHANGEME_`, `CHANGE_ME`, `YOUR_`, `EXAMPLE_`, `PLACEHOLDER_`, `${`, `{{`, `$ENV_`, `$(`, `<`, `xxx`, `XXX`. Fewer false positives on docs and `.env.example` fixtures.
17
+
18
+ ### Changed
19
+ - **npm publish workflow (`.github/workflows/publish.yml`)** now runs with `--provenance` and `id-token: write`. Published tarballs carry a cryptographic provenance attestation visible on npmjs.com and verifiable via `npm audit signatures`.
20
+ - **`scripts/config_resolver.py:_extract_tarball`** passes `filter="data"` to `tarfile.extract` on Python 3.12+. Defense in depth on top of existing path-traversal, symlink, and absolute-path rejection. Future-proofs against the 3.14 default-filter change.
21
+ - **`app/hooks/session-start.sh`** sanitises `VERSION_MSG` with `LC_ALL=C tr -d '"'"'"'\\`$'` before interpolating into `osascript` / `powershell.exe` notification commands. Closes a latent command-injection footgun.
22
+ - **`scripts/install_steps/ai_tools.py`** and **`hooks.py`** — generator and merge-hooks `subprocess.run` calls now have `timeout=120`. A stuck generator produces a clear error instead of hanging `ai-toolkit install` indefinitely.
23
+ - **`app/hooks/commit-quality.sh`** — extracted commit message via a small Python regex instead of fragile shell `grep -oE` chain. Handles commit messages containing mixed `"` and `'` correctly.
24
+ - **`app/hooks/quality-check.sh`** — normalised `|| true` handling across all languages (Python ruff was previously the only one propagating exit status). All language checks are now consistently advisory on the Stop hook.
25
+ - **`bin/ai-toolkit.js:handleAddRule`** — simplified HTTPS/HTTP detection (single `startsWith('https://')` after the `http://` reject).
26
+ - **`package.json` description** shortened from 400+ to 241 characters — stops mid-sentence truncation in npm search. Surfaces the new SARIF + provenance differentiators.
27
+ - **`package.json` `engines`** — removed non-standard `bats` entry (npm ignores unknown engines and warned on install). Bats requirement is documented in `CLAUDE.md` Commands section.
28
+
29
+ ---
30
+
10
31
  ## v2.7.3 — Regenerate llms after Medplum Merge (2026-04-17)
11
32
 
12
33
  ### Fixed
package/README.md CHANGED
@@ -10,9 +10,12 @@
10
10
 
11
11
  ---
12
12
 
13
- ## What's New in v2.7.3
13
+ ## What's New in v2.8.0
14
14
 
15
- - **`llms.txt` + `llms-full.txt` regenerated** after PR #7 (Medplum/FHIR rules) catalogs now list `medplum-docs-map.md` and the language-rules reference advertises the correct counts: `14 languages / 73 rule files` (was `13 / 68`).
15
+ - **SARIF + permissions audit** — `audit_skills.py --sarif` emits SARIF 2.1.0 for GitHub Advanced Security Code Scanning; `--permissions` prints per-skill tool usage (e.g. "50 skills use Bash") and flags broad Bash+Write+Edit access.
16
+ - **Signed npm provenance** — the publish workflow now runs with `--provenance`; published tarballs carry a cryptographic build-origin attestation verifiable via `npm audit signatures`.
17
+ - **Checksum-pinned URL sources** — `sources.json` now persists `sha256` of every URL-sourced rule/hook and warns on upstream content change. `AI_TOOLKIT_STRICT_PIN=1` turns mismatches into a hard CI failure.
18
+ - **Security hardening** — `tarfile.extract` uses `filter="data"` on Python 3.12+; `session-start.sh` sanitises `VERSION_MSG` before `osascript`/`powershell.exe`; install-time `subprocess.run` calls now time out at 120 s.
16
19
 
17
20
  See [CHANGELOG.md](CHANGELOG.md) for full history.
18
21
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-toolkit",
3
3
  "description": "Professional-grade Claude Code toolkit with persona presets, skill security auditor, expanded lifecycle hooks, experimental opt-in plugin packs, benchmark harvesting, and multi-tool support.",
4
- "version": "2.7.3",
4
+ "version": "2.8.0",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
@@ -14,14 +14,17 @@ fi
14
14
  # Only check commands that contain "git commit"
15
15
  printf '%s' "$COMMAND" | grep -q 'git commit' || exit 0
16
16
 
17
- # Extract commit message from -m flag
18
- # Handles: git commit -m "msg", git commit -m 'msg', git commit -am "msg"
19
- MSG=$(printf '%s' "$COMMAND" | grep -oE '\-m\s+["'"'"']([^"'"'"']*)["'"'"']' | head -1 | sed "s/^-m[[:space:]]*[\"']//" | sed "s/[\"']$//")
20
-
21
- # Also handle heredoc-style: -m "$(cat <<'EOF' ... EOF )"
22
- if [ -z "$MSG" ]; then
23
- MSG=$(printf '%s' "$COMMAND" | grep -oE '\-m\s+"[^"]*"' | head -1 | sed 's/^-m[[:space:]]*//' | tr -d '"')
24
- fi
17
+ # Extract commit message from -m flag. Delegated to Python for correct
18
+ # quote handling the previous shell-regex approach broke on commit messages
19
+ # containing both " and '.
20
+ MSG=$(printf '%s' "$COMMAND" | python3 -c '
21
+ import re, sys
22
+ cmd = sys.stdin.read()
23
+ # Match -m followed by a quoted string (either " or ").
24
+ m = re.search(r"""-m\s+("((?:[^"\\]|\\.)*)"|'"'"'((?:[^'"'"'\\]|\\.)*)'"'"')""", cmd)
25
+ if m:
26
+ print(m.group(2) if m.group(2) is not None else m.group(3))
27
+ ' 2>/dev/null)
25
28
 
26
29
  # No message found (might be --amend or interactive) — skip
27
30
  if [ -z "$MSG" ]; then
@@ -7,8 +7,12 @@
7
7
  # shellcheck source=_profile-check.sh
8
8
  source "$(dirname "$0")/_profile-check.sh"
9
9
 
10
+ # All lint/typecheck invocations are advisory. They run on the Stop hook and
11
+ # must not block Claude from returning a response, hence the trailing `|| true`.
12
+ # The first 15 lines of output are surfaced to the user; further lines are
13
+ # truncated to keep the context lean.
10
14
  if [ -f pyproject.toml ] || [ -f setup.py ]; then
11
- ruff check . 2>&1 | head -15
15
+ ruff check . 2>&1 | head -15 || true
12
16
  elif [ -f package.json ] && [ -f tsconfig.json ]; then
13
17
  npx tsc --noEmit 2>&1 | head -15 || true
14
18
  elif [ -f composer.json ] && [ -f vendor/bin/phpstan ]; then
@@ -14,13 +14,17 @@ TOOLKIT_DIR="$(npm root -g 2>/dev/null)/@softspark/ai-toolkit"
14
14
  VERSION_MSG=$(python3 "$TOOLKIT_DIR/scripts/version_check.py" 2>/dev/null)
15
15
  if [ -n "$VERSION_MSG" ]; then
16
16
  echo "$VERSION_MSG"
17
+ # Strip shell/AppleScript/PowerShell metacharacters before interpolating into
18
+ # notification commands. VERSION_MSG is version_check.py output which should
19
+ # be plain ASCII, but sanitize anyway as defense in depth.
20
+ VERSION_MSG_SAFE=$(printf '%s' "$VERSION_MSG" | LC_ALL=C tr -d '"'"'"'\\`$')
17
21
  # Desktop notification so user sees update before typing
18
22
  if command -v osascript >/dev/null 2>&1; then
19
- osascript -e "display notification \"$VERSION_MSG\" with title \"ai-toolkit\"" 2>/dev/null &
23
+ osascript -e "display notification \"$VERSION_MSG_SAFE\" with title \"ai-toolkit\"" 2>/dev/null &
20
24
  elif command -v notify-send >/dev/null 2>&1; then
21
- notify-send "ai-toolkit" "$VERSION_MSG" 2>/dev/null &
25
+ notify-send "ai-toolkit" "$VERSION_MSG_SAFE" 2>/dev/null &
22
26
  elif command -v powershell.exe >/dev/null 2>&1; then
23
- powershell.exe -Command "[void](New-Object -ComObject WScript.Shell).Popup('$VERSION_MSG',5,'ai-toolkit',64)" 2>/dev/null &
27
+ powershell.exe -Command "[void](New-Object -ComObject WScript.Shell).Popup('$VERSION_MSG_SAFE',5,'ai-toolkit',64)" 2>/dev/null &
24
28
  fi
25
29
  fi
26
30
 
package/bin/ai-toolkit.js CHANGED
@@ -368,11 +368,11 @@ function handleAddRule(args) {
368
368
  process.exit(1);
369
369
  }
370
370
  // Pass URLs through directly (don't resolve as filesystem path)
371
- const isUrl = ruleFile.startsWith('https://') || ruleFile.startsWith('http://');
372
371
  if (ruleFile.startsWith('http://')) {
373
372
  console.error('Error: only HTTPS URLs are supported. Use https:// for security.');
374
373
  process.exit(1);
375
374
  }
375
+ const isUrl = ruleFile.startsWith('https://');
376
376
  const absRuleFile = isUrl ? ruleFile : path.resolve(CWD, ruleFile);
377
377
  const ruleName = args[1];
378
378
  run(scriptPath('add_rule.py'), ruleName ? [absRuleFile, ruleName] : [absRuleFile]);
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.7.3",
2
+ "version": "2.8.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "2.7.3",
4
- "description": "Professional-grade AI coding toolkit: 99 skills, 44 agents, multi-platform support (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo Code, Aider, Augment, Google Antigravity, Codex CLI, opencode), machine-enforced safety constitution, persona presets, skill security auditor, expanded lifecycle hooks, 11 plugin packs, and benchmark tooling.",
3
+ "version": "2.8.0",
4
+ "description": "AI coding toolkit: 99 skills, 44 agents, 12-editor write-through (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity, Codex, opencode), machine-enforced safety constitution, SARIF audit, signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -77,7 +77,6 @@
77
77
  "llms-full.txt"
78
78
  ],
79
79
  "engines": {
80
- "node": ">=18.0.0",
81
- "bats": ">=1.8.0 — install via: brew install bats-core (macOS) or apt install bats (Ubuntu)"
80
+ "node": ">=18.0.0"
82
81
  }
83
82
  }
@@ -68,7 +68,7 @@ def main() -> None:
68
68
 
69
69
  dest = rules_dir / f"{rule_name}.md"
70
70
  dest.write_bytes(data)
71
- register_url_source(rules_dir, rule_name, source)
71
+ register_url_source(rules_dir, rule_name, source, content=data)
72
72
 
73
73
  print(f"Registered: '{rule_name}' -> {dest}")
74
74
  print(f"Source URL: {source} (auto-refreshed on update)")
@@ -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] # scan all
11
- python3 scripts/audit_skills.py [toolkit-dir] --json # JSON output
12
- python3 scripts/audit_skills.py [toolkit-dir] --ci # exit 1 on HIGH
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
- if re.search(regex, line):
123
- findings.append(Finding(severity, rel, lineno, regex, desc))
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 json_mode:
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
- tf.extract(member, dest)
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:
@@ -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(hooks_dir: Path | None, hook_name: str, url: str) -> None:
82
- """Add or update a URL source entry."""
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
- sources[hook_name] = {
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
- result = subprocess.run(cmd, capture_output=True, text=True)
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
- result = subprocess.run(cmd, capture_output=capture, text=True)
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():
@@ -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(rules_dir: Path | None, rule_name: str, url: str) -> None:
83
- """Add or update a URL source entry."""
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
- sources[rule_name] = {
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