@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 +21 -0
- package/README.md +5 -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/bin/ai-toolkit.js +1 -1
- package/manifest.json +1 -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/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.
|
|
13
|
+
## What's New in v2.8.0
|
|
14
14
|
|
|
15
|
-
-
|
|
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.
|
|
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
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 \"$
|
|
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" "$
|
|
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('$
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
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
|
}
|
package/scripts/add_rule.py
CHANGED
|
@@ -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)")
|
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
|
|