@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.
@@ -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