Skip to content

Commit b86d975

Browse files
AlexU-Aclaude
andcommitted
feat: add supply chain content integrity for external skill manifests
Refresh script now fetches skill content, shows unified diffs, and requires --approve to write updates. Verify script gains --verify-content flag to fetch and re-hash content against stored content_sha256. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0ca1edb commit b86d975

4 files changed

Lines changed: 152 additions & 11 deletions

File tree

.github/workflows/validate.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ jobs:
1515
- run: pip install pyyaml
1616
- run: python3 scripts/refresh_external_skills.py --check
1717
- run: python3 scripts/verify_no_copied_skills.py
18+
# Note: add --verify-content for full content integrity checks;
19+
# omitted by default to avoid CI dependency on GitHub raw content availability.

CLAUDE.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ A router agent (`js-critic-router`) dispatches to the correct critic based on fr
1515
## Commands
1616

1717
```bash
18-
python3 scripts/refresh_external_skills.py
19-
python3 scripts/refresh_external_skills.py --check
20-
python3 scripts/verify_no_copied_skills.py
18+
python3 scripts/refresh_external_skills.py # dry-run: show diffs
19+
python3 scripts/refresh_external_skills.py --approve # apply pin + hash updates
20+
python3 scripts/refresh_external_skills.py --check # CI: fail if updates needed
21+
python3 scripts/verify_no_copied_skills.py # validate manifest structure
22+
python3 scripts/verify_no_copied_skills.py --verify-content # fetch + hash verification
2123
python3 scripts/run_benchmark.py --all
2224
python3 scripts/aggregate_stability.py
2325
```
@@ -29,6 +31,15 @@ python3 scripts/aggregate_stability.py
2931
- Evidence required for CRITICAL/MAJOR findings.
3032
- Shared JS-core rubric + critic-specific rubrics.
3133

34+
## Supply Chain Security
35+
36+
External skills are loaded by reference (pinned commit SHA + content SHA-256 hash).
37+
38+
- `content_sha256` in manifests stores the SHA-256 of each skill's SKILL.md at the pinned commit.
39+
- `refresh_external_skills.py` shows content diffs and requires `--approve` to write updates.
40+
- `verify_no_copied_skills.py --verify-content` fetches and re-hashes to detect tampering.
41+
- CI validates manifest structure; full content verification available via `--verify-content`.
42+
3243
## Benchmark Infrastructure
3344

3445
- Fixtures: `research/benchmarks/fixtures/{react,next,react-native}/` (8 per critic)

scripts/refresh_external_skills.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
22
import argparse
3+
import difflib
4+
import hashlib
35
import subprocess
6+
import urllib.request
7+
import urllib.error
48
from datetime import datetime, timezone
59
from pathlib import Path
610

@@ -17,9 +21,40 @@ def get_head_sha(repo_url: str) -> str:
1721
return out.split()[0]
1822

1923

24+
def fetch_skill_content(repo_url: str, commit: str, skill_name: str) -> str | None:
25+
"""Fetch SKILL.md content from raw.githubusercontent.com. Returns None on failure."""
26+
owner_repo = repo_url.removeprefix('https://github.com/')
27+
raw_url = f'https://raw.githubusercontent.com/{owner_repo}/{commit}/{skill_name}/SKILL.md'
28+
try:
29+
with urllib.request.urlopen(raw_url, timeout=15) as resp:
30+
return resp.read().decode('utf-8')
31+
except urllib.error.HTTPError as e:
32+
print(f' WARNING: HTTP {e.code} fetching {raw_url}')
33+
return None
34+
except Exception as e:
35+
print(f' WARNING: Failed to fetch {raw_url}: {e}')
36+
return None
37+
38+
39+
def sha256_of(content: str) -> str:
40+
return hashlib.sha256(content.encode('utf-8')).hexdigest()
41+
42+
43+
def show_diff(skill_id: str, old_content: str | None, new_content: str | None) -> None:
44+
old_lines = (old_content or '(content unavailable)\n').splitlines(keepends=True)
45+
new_lines = (new_content or '(content unavailable)\n').splitlines(keepends=True)
46+
diff = list(difflib.unified_diff(old_lines, new_lines, fromfile=f'{skill_id} (old)', tofile=f'{skill_id} (new)'))
47+
if diff:
48+
print(f'\n--- Diff for {skill_id} ---')
49+
print(''.join(diff), end='')
50+
else:
51+
print(f' (no content diff for {skill_id})')
52+
53+
2054
def main() -> int:
2155
parser = argparse.ArgumentParser(description='Refresh pinned commits in all critic manifests.')
2256
parser.add_argument('--check', action='store_true', help='Fail if updates are needed without writing files.')
57+
parser.add_argument('--approve', action='store_true', help='Apply pin and content hash updates (writes manifests).')
2358
args = parser.parse_args()
2459

2560
changes = []
@@ -30,15 +65,40 @@ def main() -> int:
3065
skills = data.get('skills', [])
3166
checked += len(skills)
3267
local_changes = []
68+
3369
for s in skills:
3470
latest = get_head_sha(s['repo_url'])
3571
current = s.get('pinned_commit', '')
36-
if latest != current:
72+
if latest == current:
73+
continue
74+
75+
skill_name = s['id'].split('/')[-1]
76+
print(f'\nSkill {s["id"]}: {current or "-"} -> {latest}')
77+
78+
old_content = fetch_skill_content(s['repo_url'], current, skill_name) if current else None
79+
new_content = fetch_skill_content(s['repo_url'], latest, skill_name)
80+
81+
if not args.check:
82+
show_diff(s['id'], old_content, new_content)
83+
84+
if new_content is not None:
85+
new_hash = sha256_of(new_content)
86+
print(f' content_sha256 (new): {new_hash}')
87+
else:
88+
new_hash = None
89+
print(f' WARNING: Could not fetch new content — content_sha256 will be unverified.')
90+
91+
local_changes.append((s['id'], current, latest, new_hash))
92+
changes.append((manifest, s['id'], current, latest, new_hash))
93+
94+
if args.approve:
3795
s['pinned_commit'] = latest
38-
local_changes.append((s['id'], current, latest))
39-
changes.append((manifest, s['id'], current, latest))
96+
if new_hash is not None:
97+
s['content_sha256'] = new_hash
98+
else:
99+
s.pop('content_sha256', None)
40100

41-
if local_changes and not args.check:
101+
if local_changes and args.approve:
42102
data['generated_at'] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
43103
manifest.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
44104

@@ -52,10 +112,11 @@ def main() -> int:
52112
]
53113

54114
if changes:
55-
lines.append('| Manifest | Skill | Old | New |')
56-
lines.append('|---|---|---|---|')
57-
for m, sid, old, new in changes:
58-
lines.append(f'| `{m.relative_to(ROOT)}` | `{sid}` | `{old or "-"}` | `{new}` |')
115+
lines.append('| Manifest | Skill | Old | New | Hash Verified |')
116+
lines.append('|---|---|---|---|---|')
117+
for m, sid, old, new, new_hash in changes:
118+
verified = 'yes' if new_hash else '# UNVERIFIED'
119+
lines.append(f'| `{m.relative_to(ROOT)}` | `{sid}` | `{old or "-"}` | `{new}` | {verified} |')
59120
else:
60121
lines.append('No pin changes detected.')
61122

@@ -66,6 +127,9 @@ def main() -> int:
66127
print(f'Manifest updates required for {len(changes)} skill entries.')
67128
return 1
68129

130+
if changes and not args.approve and not args.check:
131+
print('\nRun with --approve to apply these changes.')
132+
69133
print(f'Checked {checked} skill entries across {len(MANIFESTS)} manifests.')
70134
print(f'Wrote report: {REPORT}')
71135
return 0

scripts/verify_no_copied_skills.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
#!/usr/bin/env python3
2+
import argparse
3+
import hashlib
24
import re
35
import subprocess
46
import sys
7+
import urllib.request
8+
import urllib.error
59
from pathlib import Path
610

711
import yaml
@@ -11,18 +15,48 @@
1115

1216
ID_RE = re.compile(r'^[^/]+/[^/]+/[^/]+$')
1317
SHA_RE = re.compile(r'^[0-9a-f]{40}$')
18+
SHA256_RE = re.compile(r'^[0-9a-f]{64}$')
1419

1520

1621
def fail(msg: str) -> None:
1722
print(f'ERROR: {msg}')
1823
raise SystemExit(1)
1924

2025

26+
def fetch_skill_content(repo_url: str, commit: str, skill_name: str) -> str | None:
27+
"""Fetch SKILL.md content from raw.githubusercontent.com. Returns None on failure."""
28+
owner_repo = repo_url.removeprefix('https://github.com/')
29+
raw_url = f'https://raw.githubusercontent.com/{owner_repo}/{commit}/{skill_name}/SKILL.md'
30+
try:
31+
with urllib.request.urlopen(raw_url, timeout=15) as resp:
32+
return resp.read().decode('utf-8')
33+
except urllib.error.HTTPError as e:
34+
print(f' WARNING: HTTP {e.code} fetching {raw_url}')
35+
return None
36+
except Exception as e:
37+
print(f' WARNING: Failed to fetch {raw_url}: {e}')
38+
return None
39+
40+
41+
def sha256_of(content: str) -> str:
42+
return hashlib.sha256(content.encode('utf-8')).hexdigest()
43+
44+
2145
def main() -> int:
46+
parser = argparse.ArgumentParser(description='Verify no copied skills in manifests.')
47+
parser.add_argument(
48+
'--verify-content',
49+
action='store_true',
50+
help='Fetch skill content from raw.githubusercontent.com and verify content_sha256 hashes.',
51+
)
52+
args = parser.parse_args()
53+
2254
if not MANIFESTS:
2355
fail('No external-skills-manifest.yaml files found.')
2456

2557
seen = {}
58+
content_errors = 0
59+
2660
for manifest in MANIFESTS:
2761
data = yaml.safe_load(manifest.read_text(encoding='utf-8')) or {}
2862
skills = data.get('skills', [])
@@ -57,6 +91,32 @@ def main() -> int:
5791
if s.get('status') not in {'active', 'deprecated'}:
5892
fail(f'Invalid status for {sid}: {s.get("status")}')
5993

94+
# Validate content_sha256 format if present
95+
stored_hash = s.get('content_sha256')
96+
if stored_hash is not None:
97+
if not SHA256_RE.match(str(stored_hash)):
98+
fail(f'Invalid content_sha256 format for {sid} (must be 64-char hex): {stored_hash}')
99+
100+
# Optionally verify content hash against live content
101+
if args.verify_content:
102+
skill_name = sid.split('/')[-1]
103+
commit = s.get('pinned_commit', '')
104+
repo_url = s.get('repo_url', '')
105+
content = fetch_skill_content(repo_url, commit, skill_name)
106+
if content is None:
107+
print(f' WARNING: Could not fetch content for {sid} — skipping hash verification.')
108+
elif stored_hash is None:
109+
print(f' WARNING: No content_sha256 stored for {sid} — run refresh_external_skills.py --approve to populate.')
110+
else:
111+
actual_hash = sha256_of(content)
112+
if actual_hash != stored_hash:
113+
print(f'ERROR: content_sha256 mismatch for {sid}')
114+
print(f' expected: {stored_hash}')
115+
print(f' actual: {actual_hash}')
116+
content_errors += 1
117+
else:
118+
print(f' OK: {sid} content hash verified.')
119+
60120
git_ls = subprocess.check_output(['git', '-C', str(ROOT), 'ls-files'], text=True).splitlines()
61121
forbidden_prefixes = [
62122
'research/javascript-skills/upstream/',
@@ -67,6 +127,10 @@ def main() -> int:
67127
if f.startswith(prefix):
68128
fail(f'Forbidden tracked file path: {f}')
69129

130+
if content_errors:
131+
print(f'\n{content_errors} content hash mismatch(es) detected.')
132+
raise SystemExit(1)
133+
70134
print(f'Validation passed for {len(MANIFESTS)} manifests and {len(seen)} unique skills.')
71135
return 0
72136

0 commit comments

Comments
 (0)