Skip to content

Commit eba26fe

Browse files
AlexU-Aclaude
andcommitted
feat: add Tier 1 supply chain hardening
- Org allowlist: TRUSTED_OWNERS in skill_security.py, verify script rejects unknown owners - Compare URLs: clickable GitHub diff links in refresh report - Content scanning: 10 prompt injection patterns checked against fetched SKILL.md files - Scan gate: manifest update blocked if warnings found (--force to override) - GitHub settings recommendations documented in CLAUDE.md - Security Exploitability Gate added to Design Rules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b86d975 commit eba26fe

4 files changed

Lines changed: 106 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ python3 scripts/aggregate_stability.py
2929
- No-copy policy: external skills are referenced in manifests with pinned commits.
3030
- Max 3 external skills loaded per run.
3131
- Evidence required for CRITICAL/MAJOR findings.
32+
- Security Exploitability Gate: security findings must demonstrate a concrete exploit path reachable by non-privileged users. Unconfirmed findings tagged `[UNCONFIRMED]` and moved to Open Questions.
3233
- Shared JS-core rubric + critic-specific rubrics.
3334

3435
## Supply Chain Security
@@ -40,6 +41,16 @@ External skills are loaded by reference (pinned commit SHA + content SHA-256 has
4041
- `verify_no_copied_skills.py --verify-content` fetches and re-hashes to detect tampering.
4142
- CI validates manifest structure; full content verification available via `--verify-content`.
4243

44+
### GitHub Repository Settings (manual setup)
45+
46+
Upstream skill repos should be configured with:
47+
- **Signed commits**: require commit signature verification on default branch.
48+
- **Branch protection**: require PR reviews and status checks before merge to default branch.
49+
- **No force push**: disable force push to default branch to preserve commit history integrity.
50+
51+
These are manual configurations on each upstream repo — they cannot be enforced from this repo,
52+
but should be verified before adding a new owner to `TRUSTED_OWNERS`.
53+
4354
## Benchmark Infrastructure
4455

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

scripts/refresh_external_skills.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
import difflib
44
import hashlib
55
import subprocess
6+
import sys
67
import urllib.request
78
import urllib.error
89
from datetime import datetime, timezone
910
from pathlib import Path
1011

1112
import yaml
1213

14+
sys.path.insert(0, str(Path(__file__).resolve().parent))
15+
from skill_security import scan_content
16+
1317
ROOT = Path(__file__).resolve().parent.parent
1418
MANIFESTS = sorted(ROOT.glob('.claude/skills/*/references/external-skills-manifest.yaml'))
1519
REPORT = ROOT / 'research/javascript-skills/reports/external-skills-refresh-report.md'
@@ -51,14 +55,25 @@ def show_diff(skill_id: str, old_content: str | None, new_content: str | None) -
5155
print(f' (no content diff for {skill_id})')
5256

5357

58+
def make_compare_url(repo_url: str, old_sha: str, new_sha: str) -> str:
59+
owner_repo = repo_url.removeprefix('https://github.com/')
60+
if old_sha:
61+
return f'https://github.com/{owner_repo}/compare/{old_sha}...{new_sha}'
62+
return f'https://github.com/{owner_repo}/commit/{new_sha}'
63+
64+
5465
def main() -> int:
5566
parser = argparse.ArgumentParser(description='Refresh pinned commits in all critic manifests.')
5667
parser.add_argument('--check', action='store_true', help='Fail if updates are needed without writing files.')
5768
parser.add_argument('--approve', action='store_true', help='Apply pin and content hash updates (writes manifests).')
69+
parser.add_argument('--force', action='store_true', help='Allow --approve to proceed despite content scan warnings.')
5870
args = parser.parse_args()
5971

72+
# Each entry: (manifest, skill_id, old_sha, new_sha, new_hash_or_None, repo_url)
6073
changes = []
6174
checked = 0
75+
all_scan_warnings: list[str] = []
76+
pending_writes: list[tuple[Path, dict]] = []
6277

6378
for manifest in MANIFESTS:
6479
data = yaml.safe_load(manifest.read_text(encoding='utf-8')) or {}
@@ -84,12 +99,18 @@ def main() -> int:
8499
if new_content is not None:
85100
new_hash = sha256_of(new_content)
86101
print(f' content_sha256 (new): {new_hash}')
102+
scan_warnings = scan_content(new_content, s['id'])
103+
if scan_warnings:
104+
print(f' SCAN WARNINGS ({len(scan_warnings)}):')
105+
for w in scan_warnings:
106+
print(w)
107+
all_scan_warnings.extend(scan_warnings)
87108
else:
88109
new_hash = None
89110
print(f' WARNING: Could not fetch new content — content_sha256 will be unverified.')
90111

91112
local_changes.append((s['id'], current, latest, new_hash))
92-
changes.append((manifest, s['id'], current, latest, new_hash))
113+
changes.append((manifest, s['id'], current, latest, new_hash, s.get('repo_url', '')))
93114

94115
if args.approve:
95116
s['pinned_commit'] = latest
@@ -98,7 +119,17 @@ def main() -> int:
98119
else:
99120
s.pop('content_sha256', None)
100121

101-
if local_changes and args.approve:
122+
if local_changes:
123+
pending_writes.append((manifest, data))
124+
125+
# Scan gate: block all writes if warnings found without --force
126+
if all_scan_warnings and not args.force:
127+
print(f'\nContent scan found {len(all_scan_warnings)} warning(s). Review and use --approve --force to override.')
128+
if args.approve:
129+
return 2
130+
131+
if args.approve and (not all_scan_warnings or args.force):
132+
for manifest, data in pending_writes:
102133
data['generated_at'] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
103134
manifest.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
104135

@@ -112,11 +143,12 @@ def main() -> int:
112143
]
113144

114145
if changes:
115-
lines.append('| Manifest | Skill | Old | New | Hash Verified |')
116-
lines.append('|---|---|---|---|---|')
117-
for m, sid, old, new, new_hash in changes:
146+
lines.append('| Manifest | Skill | Old | New | Hash Verified | Compare |')
147+
lines.append('|---|---|---|---|---|---|')
148+
for m, sid, old, new, new_hash, repo_url in changes:
118149
verified = 'yes' if new_hash else '# UNVERIFIED'
119-
lines.append(f'| `{m.relative_to(ROOT)}` | `{sid}` | `{old or "-"}` | `{new}` | {verified} |')
150+
compare_url = make_compare_url(repo_url, old, new)
151+
lines.append(f'| `{m.relative_to(ROOT)}` | `{sid}` | `{old or "-"}` | `{new}` | {verified} | [compare]({compare_url}) |')
120152
else:
121153
lines.append('No pin changes detected.')
122154

scripts/skill_security.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Shared supply-chain security utilities for skill manifest tooling."""
2+
import re
3+
4+
TRUSTED_OWNERS = frozenset({
5+
'auth0',
6+
'callstack',
7+
'callstackincubator',
8+
'clerk',
9+
'dotneet',
10+
'expo',
11+
'getsentry',
12+
'github',
13+
'millionco',
14+
'mindrally',
15+
'react-native-community',
16+
'sickn33',
17+
'vercel-labs',
18+
'wshobson',
19+
'wsimmonds',
20+
})
21+
22+
INJECTION_PATTERNS = [
23+
(re.compile(r'ignore\s+(?:all\s+|any\s+|previous\s+|prior\s+|above\s+)?(?:instructions|rules|guidelines)', re.IGNORECASE), 'instruction override'),
24+
(re.compile(r'you\s+are\s+now\b', re.IGNORECASE), 'identity override'),
25+
(re.compile(r'from\s+now\s+on\b.*?\b(?:ignore|forget|disregard)', re.IGNORECASE), 'behavioral override'),
26+
(re.compile(r'system\s+(?:prompt|message|instruction)', re.IGNORECASE), 'system prompt reference'),
27+
(re.compile(r'disregard\s+(?:all\s+|any\s+|previous\s+|prior\s+)?(?:instructions|rules|guidelines|constraints)', re.IGNORECASE), 'instruction disregard'),
28+
(re.compile(r'<script[\s>]', re.IGNORECASE), 'script injection'),
29+
(re.compile(r'javascript\s*:', re.IGNORECASE), 'javascript URI'),
30+
(re.compile(r'data:\s*\w+/\w+;base64,', re.IGNORECASE), 'base64 data embed'),
31+
(re.compile(r'<\s*(?:invoke|tool_use|function_call)', re.IGNORECASE), 'tool invocation injection'),
32+
(re.compile(r'\bIMPORTANT\s*:\s*(?:ignore|override|disregard|forget)', re.IGNORECASE), 'priority override'),
33+
]
34+
35+
36+
def scan_content(content: str, skill_id: str) -> list[str]:
37+
"""Scan skill content for prompt injection patterns. Returns list of warning strings."""
38+
warnings = []
39+
for pattern, description in INJECTION_PATTERNS:
40+
for match in pattern.finditer(content):
41+
line_num = content[:match.start()].count('\n') + 1
42+
warnings.append(f' {skill_id} line {line_num}: {description} — matched "{match.group()}"')
43+
return warnings

scripts/verify_no_copied_skills.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
import yaml
1212

13+
sys.path.insert(0, str(Path(__file__).resolve().parent))
14+
from skill_security import TRUSTED_OWNERS, scan_content
15+
1316
ROOT = Path(__file__).resolve().parent.parent
1417
MANIFESTS = sorted(ROOT.glob('.claude/skills/*/references/external-skills-manifest.yaml'))
1518

@@ -86,6 +89,9 @@ def main() -> int:
8689
fail(f'Invalid skills_url for {sid}')
8790
if not str(s.get('repo_url', '')).startswith('https://github.com/'):
8891
fail(f'Invalid repo_url for {sid}')
92+
owner = sid.split('/')[0]
93+
if owner not in TRUSTED_OWNERS:
94+
fail(f'Untrusted owner "{owner}" for {sid}. Add to TRUSTED_OWNERS in scripts/skill_security.py if approved.')
8995
if not SHA_RE.match(str(s.get('pinned_commit', ''))):
9096
fail(f'Invalid pinned_commit for {sid}')
9197
if s.get('status') not in {'active', 'deprecated'}:
@@ -117,6 +123,14 @@ def main() -> int:
117123
else:
118124
print(f' OK: {sid} content hash verified.')
119125

126+
if content is not None:
127+
scan_warnings = scan_content(content, sid)
128+
if scan_warnings:
129+
print(f' SCAN WARNINGS ({len(scan_warnings)}) for {sid}:')
130+
for w in scan_warnings:
131+
print(w)
132+
content_errors += len(scan_warnings)
133+
120134
git_ls = subprocess.check_output(['git', '-C', str(ROOT), 'ls-files'], text=True).splitlines()
121135
forbidden_prefixes = [
122136
'research/javascript-skills/upstream/',

0 commit comments

Comments
 (0)