cyber-aced 0.3.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/.claude-plugin/plugin.json +18 -0
- package/.plugin/pins.json +1 -0
- package/.plugin/plugin.json +18 -0
- package/agents/aced-case-judge.md +165 -0
- package/agents/aced-impl-judge.md +55 -0
- package/agents/aced-scenario-writer.md +76 -0
- package/agents/aced-spec-validator.md +66 -0
- package/governances/README.md +52 -0
- package/governances/agent-tool-output.md +86 -0
- package/governances/cli-resolution.md +102 -0
- package/governances/skill-design.md +258 -0
- package/governances/skill-repo-structure.md +173 -0
- package/package.json +44 -0
- package/readme.md +124 -0
- package/skills/aced-architect-impl/README.md +9 -0
- package/skills/aced-architect-impl/SKILL.md +73 -0
- package/skills/aced-builder-impl/README.md +14 -0
- package/skills/aced-builder-impl/SKILL.md +46 -0
- package/skills/aced-builder-spec/README.md +15 -0
- package/skills/aced-builder-spec/SKILL.md +130 -0
- package/skills/aced-fit/README.md +21 -0
- package/skills/aced-fit/SKILL.md +105 -0
- package/skills/aced-impl-producer/SKILL.md +75 -0
- package/skills/add-scenario/README.md +25 -0
- package/skills/add-scenario/SKILL.md +75 -0
- package/skills/bench/README.md +64 -0
- package/skills/bench/SKILL.md +199 -0
- package/skills/bench/scripts/bench-claude-code.mts +187 -0
- package/skills/bench/scripts/bench-compare.mts +369 -0
- package/skills/bench/scripts/bench-record.mts +133 -0
- package/skills/bench/scripts/bench.mts +930 -0
- package/skills/check-freshness/README.md +57 -0
- package/skills/check-freshness/SKILL.md +76 -0
- package/skills/check-freshness/scripts/check-freshness.mts +399 -0
- package/skills/compare/README.md +25 -0
- package/skills/compare/SKILL.md +87 -0
- package/skills/contribute-skill/README.md +25 -0
- package/skills/contribute-skill/SKILL.md +241 -0
- package/skills/contribute-skill/scripts/source.mts +188 -0
- package/skills/define-agent/README.md +40 -0
- package/skills/define-agent/SKILL.md +279 -0
- package/skills/define-command/README.md +36 -0
- package/skills/define-command/SKILL.md +174 -0
- package/skills/define-governance/SKILL.md +151 -0
- package/skills/define-skill/README.md +37 -0
- package/skills/define-skill/SKILL.md +200 -0
- package/skills/extract-situation/README.md +32 -0
- package/skills/extract-situation/SKILL.md +54 -0
- package/skills/extract-situation/scripts/extract-situation.mts +287 -0
- package/skills/improve/README.md +25 -0
- package/skills/improve/SKILL.md +100 -0
- package/skills/improve-skill/README.md +38 -0
- package/skills/improve-skill/SKILL.md +169 -0
- package/skills/improve-skill/references/check-definitions.md +215 -0
- package/skills/improve-skill/scripts/validate.mts +1033 -0
- package/skills/init-aced/README.md +5 -0
- package/skills/init-aced/SKILL.md +76 -0
- package/skills/init-aced/scripts/ensure-results-ignored.mts +117 -0
- package/skills/list-skills/README.md +19 -0
- package/skills/list-skills/SKILL.md +55 -0
- package/skills/list-skills/scripts/list-skills.mts +210 -0
- package/skills/manage/README.md +35 -0
- package/skills/manage/SKILL.md +65 -0
- package/skills/manage-model-runners/README.md +25 -0
- package/skills/manage-model-runners/SKILL.md +113 -0
- package/skills/manage-skill-dirs/README.md +17 -0
- package/skills/manage-skill-dirs/SKILL.md +54 -0
- package/skills/manage-skill-dirs/scripts/manage-skill-dirs.mts +328 -0
- package/skills/repair-private-skills/README.md +17 -0
- package/skills/repair-private-skills/SKILL.md +59 -0
- package/skills/repair-private-skills/scripts/repair-private-skills.mts +279 -0
- package/skills/report/README.md +25 -0
- package/skills/report/SKILL.md +74 -0
- package/skills/run/README.md +25 -0
- package/skills/run/SKILL.md +220 -0
- package/skills/skillify/README.md +25 -0
- package/skills/skillify/SKILL.md +141 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// validate — the deterministic mechanical-check engine for the ACED improve-skill node. Ported
|
|
3
|
+
// from the cyberplace CLI's `audit validate` (packages/cyberplace/src/audit/{validate,cli}.ts +
|
|
4
|
+
// skill/manifest.ts, inlined here for self-containment).
|
|
5
|
+
//
|
|
6
|
+
// Runs ONLY the mechanical check subset: S1-S9, Q2-Q5, Q10-Q11, Q17, Q18, E1-E2, E6, E9. Everything
|
|
7
|
+
// else (Q1, Q6-Q9, Q12-Q16, E3-E5, E7-E8, P1-P3) is agent-only quality review and is NOT run here —
|
|
8
|
+
// that is judged separately by the improve-skill agent skill / the ACED impl-judge.
|
|
9
|
+
//
|
|
10
|
+
// CLI:
|
|
11
|
+
// --path <path> validate a single skill directory or SKILL.md file (default: whole-project scan)
|
|
12
|
+
// --dir <glob> add a one-off scan location for this run (repeatable; ignored when --path is set)
|
|
13
|
+
// --root <path> repo root to scan from (default: cwd)
|
|
14
|
+
// --format <fmt> text (default, human-readable) or json (machine-readable findings)
|
|
15
|
+
//
|
|
16
|
+
// Whole-project scans (no --path) union the built-in defaults (skills/, .agents/skills/) with any
|
|
17
|
+
// extra locations declared in .agents/aced/skill-dirs.toml's `anchors` array (ADR-0019-style, opt-in)
|
|
18
|
+
// plus --dir globs. * globs one path segment; ** globs zero or more segments at any depth.
|
|
19
|
+
//
|
|
20
|
+
// Exit codes: 0 = no CRITICAL findings (including "no SKILL.md files found"); 1 = at least one
|
|
21
|
+
// CRITICAL finding, OR --path named a target with no SKILL.md.
|
|
22
|
+
//
|
|
23
|
+
// Pure functions are exported for node:test; running the file directly drives the CLI. No deps
|
|
24
|
+
// (the repo's node-≥23.6 convention) — imports ONLY node:* builtins.
|
|
25
|
+
|
|
26
|
+
import * as fs from 'node:fs'
|
|
27
|
+
import * as path from 'node:path'
|
|
28
|
+
import { pathToFileURL } from 'node:url'
|
|
29
|
+
|
|
30
|
+
// ── types ──
|
|
31
|
+
|
|
32
|
+
type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
|
33
|
+
|
|
34
|
+
export interface Finding {
|
|
35
|
+
severity: Severity
|
|
36
|
+
checkId: string
|
|
37
|
+
name: string
|
|
38
|
+
evidence: string
|
|
39
|
+
fix: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CheckResult {
|
|
43
|
+
criticals: Finding[]
|
|
44
|
+
warnings: Finding[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── skill.json manifest (inlined from cyberplace src/skill/manifest.ts) ──
|
|
48
|
+
|
|
49
|
+
interface SkillManifest {
|
|
50
|
+
distribution?: {
|
|
51
|
+
install_via: string
|
|
52
|
+
package?: { name: string; bin?: string }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readSkillManifest(skillDir: string): SkillManifest | null {
|
|
57
|
+
const filePath = path.join(skillDir, 'skill.json')
|
|
58
|
+
if (!fs.existsSync(filePath)) return null
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as SkillManifest
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── scan scope ──
|
|
67
|
+
|
|
68
|
+
export const SKILL_DIRS = ['skills', '.agents/skills']
|
|
69
|
+
|
|
70
|
+
const SKILL_DIRS_CONFIG = '.agents/aced/skill-dirs.toml'
|
|
71
|
+
const SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
|
|
72
|
+
|
|
73
|
+
// Minimal `anchors = [ … ]` TOML string-array parser (mirrors manage-spec-anchors.mts'
|
|
74
|
+
// parseAnchorsToml — no toml dependency). Malformed/absent config => no extra locations.
|
|
75
|
+
export function parseSkillDirsToml(text: string): string[] {
|
|
76
|
+
const m = /(^|\n)\s*anchors\s*=\s*\[([\s\S]*?)\]/.exec(text)
|
|
77
|
+
if (!m) return []
|
|
78
|
+
const out: string[] = []
|
|
79
|
+
for (const q of m[2]!.matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2] ?? '').trim())
|
|
80
|
+
return out.filter((s) => s !== '')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Read .agents/aced/skill-dirs.toml's `anchors` array. Absent/unreadable/malformed => [] so the
|
|
84
|
+
// scan falls back to the built-in defaults unchanged; never throws.
|
|
85
|
+
export function readSkillDirsConfig(cwd: string): string[] {
|
|
86
|
+
const file = path.join(cwd, SKILL_DIRS_CONFIG)
|
|
87
|
+
if (!fs.existsSync(file)) return []
|
|
88
|
+
try {
|
|
89
|
+
return parseSkillDirsToml(fs.readFileSync(file, 'utf8'))
|
|
90
|
+
} catch {
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Every dir reachable from `startDir` by descending zero or more levels (startDir itself
|
|
96
|
+
// included), skipping SCAN_SKIP_DIRS. Backs the `**` segment (any-depth glob) below.
|
|
97
|
+
function collectDescendants(cwd: string, startDir: string): string[] {
|
|
98
|
+
const out = [startDir]
|
|
99
|
+
let entries: fs.Dirent[]
|
|
100
|
+
try {
|
|
101
|
+
entries = fs.readdirSync(path.join(cwd, startDir), { withFileTypes: true })
|
|
102
|
+
} catch {
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (!entry.isDirectory() || SCAN_SKIP_DIRS.has(entry.name)) continue
|
|
107
|
+
out.push(...collectDescendants(cwd, startDir ? `${startDir}/${entry.name}` : entry.name))
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Expand one skill-dir pattern against the filesystem under cwd. `*` globs exactly one path
|
|
113
|
+
// segment; `**` globs zero or more segments at any depth; every other segment is literal. Returns
|
|
114
|
+
// concrete repo-relative directories (no <project> capture token here — only * and **).
|
|
115
|
+
export function expandSkillDirPattern(cwd: string, pattern: string): string[] {
|
|
116
|
+
const segs = pattern
|
|
117
|
+
.trim()
|
|
118
|
+
.replace(/\\/g, '/')
|
|
119
|
+
.replace(/^\/+|\/+$/g, '')
|
|
120
|
+
.split('/')
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
|
|
123
|
+
let frontier: string[] = ['']
|
|
124
|
+
for (const seg of segs) {
|
|
125
|
+
const next: string[] = []
|
|
126
|
+
if (seg === '**') {
|
|
127
|
+
for (const dir of frontier) next.push(...collectDescendants(cwd, dir))
|
|
128
|
+
} else if (seg === '*') {
|
|
129
|
+
for (const dir of frontier) {
|
|
130
|
+
let entries: fs.Dirent[]
|
|
131
|
+
try {
|
|
132
|
+
entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true })
|
|
133
|
+
} catch {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
if (!entry.isDirectory() || SCAN_SKIP_DIRS.has(entry.name)) continue
|
|
138
|
+
next.push(dir ? `${dir}/${entry.name}` : entry.name)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
for (const dir of frontier) next.push(dir ? `${dir}/${seg}` : seg)
|
|
143
|
+
}
|
|
144
|
+
frontier = next
|
|
145
|
+
}
|
|
146
|
+
return frontier
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The scan locations for a whole-project run: the two built-in defaults, unioned with the
|
|
150
|
+
// configured .agents/aced/skill-dirs.toml anchors and any --dir globs, each expanded against the
|
|
151
|
+
// filesystem. A glob matching nothing contributes no locations and never errors.
|
|
152
|
+
export function resolveScanDirs(cwd: string, dirGlobs: string[] = []): string[] {
|
|
153
|
+
const dirs = new Set<string>(SKILL_DIRS)
|
|
154
|
+
for (const pattern of [...readSkillDirsConfig(cwd), ...dirGlobs]) {
|
|
155
|
+
for (const dir of expandSkillDirPattern(cwd, pattern)) dirs.add(dir)
|
|
156
|
+
}
|
|
157
|
+
return [...dirs]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The recognized scan roots as absolute realpaths — a directory a correctly-nested skill sits one
|
|
161
|
+
// named level below. S1 keys on membership here (in addition to the universal `skills` convention),
|
|
162
|
+
// so a skill under a configured non-`skills` scan location is still recognized as correctly placed.
|
|
163
|
+
export function recognizedScanRoots(cwd: string, dirGlobs: string[] = []): Set<string> {
|
|
164
|
+
const roots = new Set<string>()
|
|
165
|
+
for (const dir of resolveScanDirs(cwd, dirGlobs)) {
|
|
166
|
+
try {
|
|
167
|
+
roots.add(fs.realpathSync(path.join(cwd, dir)))
|
|
168
|
+
} catch {
|
|
169
|
+
// a configured location that does not exist on disk contributes no root
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return roots
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function findSkillFiles(dirs: string[], cwd: string): string[] {
|
|
176
|
+
const seen = new Set<string>()
|
|
177
|
+
const results: string[] = []
|
|
178
|
+
|
|
179
|
+
for (const dir of dirs) {
|
|
180
|
+
const base = path.join(cwd, dir)
|
|
181
|
+
if (!fs.existsSync(base)) continue
|
|
182
|
+
|
|
183
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
184
|
+
const skillFile = path.join(base, entry.name, 'SKILL.md')
|
|
185
|
+
if (!fs.existsSync(skillFile)) continue
|
|
186
|
+
try {
|
|
187
|
+
const real = fs.realpathSync(skillFile)
|
|
188
|
+
if (!seen.has(real)) {
|
|
189
|
+
seen.add(real)
|
|
190
|
+
results.push(real)
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// skip unresolvable symlinks
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return results.sort()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── check constants ──
|
|
202
|
+
|
|
203
|
+
const GENERIC_PHRASES = [
|
|
204
|
+
'helps with',
|
|
205
|
+
'general purpose',
|
|
206
|
+
'handles tasks',
|
|
207
|
+
'use this skill when the user asks anything',
|
|
208
|
+
'does things',
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
// Catastrophic command shapes: always CRITICAL regardless of an `rm` flag/target analysis. `rm`
|
|
212
|
+
// itself is graded separately below by recursion flag and target shape (classifyRmSeverity).
|
|
213
|
+
const E1_CRITICAL_PATTERNS: RegExp[] = [
|
|
214
|
+
/\bsudo\s+rm\b/,
|
|
215
|
+
/(curl|wget)[^|\n]*\|\s*(ba|z)?sh\b/,
|
|
216
|
+
/\bdd\s+if=/,
|
|
217
|
+
/\b(mkfs|fdisk|parted)\b/,
|
|
218
|
+
/kill\s+-9\s+1\b/,
|
|
219
|
+
/:\(\)\{\s*:\|:&\s*\}/,
|
|
220
|
+
/chmod\s+-R\s+777\s+/,
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
// Grade an `rm` invocation by blast radius. Not flagged at all (no -r/-R, no -f) => null (E1
|
|
224
|
+
// doesn't fire). Any recursion flag => CRITICAL. A forced (-f), non-recursive remove is CRITICAL
|
|
225
|
+
// when any target token escapes a single named relative file (glob, absolute, or home/var path),
|
|
226
|
+
// else HIGH (scoped forced delete of named relative file(s) — surfaced, not blocking).
|
|
227
|
+
export function classifyRmSeverity(line: string): 'CRITICAL' | 'HIGH' | null {
|
|
228
|
+
const m = /\brm\s+(.+)/.exec(line)
|
|
229
|
+
if (!m) return null
|
|
230
|
+
const tokens = m[1]!.trim().split(/\s+/)
|
|
231
|
+
const flagTokens = tokens.filter((t) => /^-\w+$/.test(t))
|
|
232
|
+
const targetTokens = tokens.filter((t) => !t.startsWith('-'))
|
|
233
|
+
const flags = flagTokens.join('')
|
|
234
|
+
const hasRecursion = /[rR]/.test(flags)
|
|
235
|
+
const hasForce = /f/.test(flags)
|
|
236
|
+
|
|
237
|
+
if (!hasRecursion && !hasForce) return null
|
|
238
|
+
if (hasRecursion) return 'CRITICAL'
|
|
239
|
+
|
|
240
|
+
const escapesScope =
|
|
241
|
+
targetTokens.length === 0 ||
|
|
242
|
+
targetTokens.some((t) => /[*?[]/.test(t) || t.startsWith('/') || t.startsWith('~') || t.includes('$'))
|
|
243
|
+
return escapesScope ? 'CRITICAL' : 'HIGH'
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Classify one line for E1. Checks the unconditional-CRITICAL patterns first, then falls back to
|
|
247
|
+
// the rm-specific flag/target grading.
|
|
248
|
+
function classifyE1Line(line: string): 'CRITICAL' | 'HIGH' | null {
|
|
249
|
+
for (const pat of E1_CRITICAL_PATTERNS) {
|
|
250
|
+
if (pat.test(line)) return 'CRITICAL'
|
|
251
|
+
}
|
|
252
|
+
return classifyRmSeverity(line)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const E2_PATTERNS: RegExp[] = [
|
|
256
|
+
/[Ii]gnore (previous|all|prior) instructions/,
|
|
257
|
+
/[Yy]ou are now [A-Z][a-zA-Z]/,
|
|
258
|
+
/[Ff]rom now on you are /,
|
|
259
|
+
/[Dd]isregard your (guidelines|rules)/,
|
|
260
|
+
/[Ff]orget your (guidelines|training)/,
|
|
261
|
+
/[Yy]our new instructions are/,
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
const INVISIBLE_UNICODE = new Map<number, string>([
|
|
265
|
+
[0x00ad, 'SOFT HYPHEN'],
|
|
266
|
+
[0x034f, 'COMBINING GRAPHEME JOINER'],
|
|
267
|
+
[0x061c, 'ARABIC LETTER MARK'],
|
|
268
|
+
[0x180e, 'MONGOLIAN VOWEL SEPARATOR'],
|
|
269
|
+
[0x200b, 'ZERO WIDTH SPACE'],
|
|
270
|
+
[0x200c, 'ZERO WIDTH NON-JOINER'],
|
|
271
|
+
[0x200d, 'ZERO WIDTH JOINER'],
|
|
272
|
+
[0x200e, 'LEFT-TO-RIGHT MARK'],
|
|
273
|
+
[0x200f, 'RIGHT-TO-LEFT MARK'],
|
|
274
|
+
[0x202a, 'LEFT-TO-RIGHT EMBEDDING'],
|
|
275
|
+
[0x202b, 'RIGHT-TO-LEFT EMBEDDING'],
|
|
276
|
+
[0x202c, 'POP DIRECTIONAL FORMATTING'],
|
|
277
|
+
[0x202d, 'LEFT-TO-RIGHT OVERRIDE'],
|
|
278
|
+
[0x202e, 'RIGHT-TO-LEFT OVERRIDE'],
|
|
279
|
+
[0x2060, 'WORD JOINER'],
|
|
280
|
+
[0x2061, 'FUNCTION APPLICATION'],
|
|
281
|
+
[0x2062, 'INVISIBLE TIMES'],
|
|
282
|
+
[0x2063, 'INVISIBLE SEPARATOR'],
|
|
283
|
+
[0x2064, 'INVISIBLE PLUS'],
|
|
284
|
+
[0x2066, 'LEFT-TO-RIGHT ISOLATE'],
|
|
285
|
+
[0x2067, 'RIGHT-TO-LEFT ISOLATE'],
|
|
286
|
+
[0x2068, 'FIRST STRONG ISOLATE'],
|
|
287
|
+
[0x2069, 'POP DIRECTIONAL ISOLATE'],
|
|
288
|
+
[0xfeff, 'ZERO WIDTH NO-BREAK SPACE / BOM'],
|
|
289
|
+
])
|
|
290
|
+
|
|
291
|
+
// Q17 — objective operational-detail markers in a partial-skill description.
|
|
292
|
+
// A named/pathed artifact file (stem required so bare domain-noun ".feature" is not flagged):
|
|
293
|
+
const OP_DETAIL_FILEREF = /[\w-]+\.(?:mts|mjs|feature|jsonl|toml|sddignore)\b|[\w.-]+\/[\w./-]+\.(?:md|js|ts|json|sh)\b/
|
|
294
|
+
// A known operational directory reference:
|
|
295
|
+
const OP_DETAIL_DIR = /\.agents\/|\.github\/|(?<![\w.])scripts\//
|
|
296
|
+
// A validate-engine check-ID (S1, Q5, E9, P1-P3, S1-S6):
|
|
297
|
+
const OP_DETAIL_CHECKID = /\b[SQEP]\d{1,2}(?:[-–]\d{1,2})?\b/
|
|
298
|
+
|
|
299
|
+
const STDOUT_AS_DATA_PATTERNS: RegExp[] = [
|
|
300
|
+
/\bparse (?:the )?(?:script )?output\b/i,
|
|
301
|
+
/\bread (?:the )?(?:summary )?table\b/i,
|
|
302
|
+
/\bshow (?:the )?(?:script )?output\b/i,
|
|
303
|
+
/\bfrom (?:the )?script output\b/i,
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
function hasStdoutAsDataMitigation(content: string): boolean {
|
|
307
|
+
return (
|
|
308
|
+
/(--json|--format json|--format agent)/.test(content) ||
|
|
309
|
+
/parse stdout json/i.test(content) ||
|
|
310
|
+
/read `<[^`]+>`/i.test(content) ||
|
|
311
|
+
/read the (?:artifact|file|report)/i.test(content)
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── frontmatter / body parsing ──
|
|
316
|
+
|
|
317
|
+
function parseFrontmatter(content: string): { name: string; description: string; internal: boolean } {
|
|
318
|
+
const lines = content.split('\n')
|
|
319
|
+
let fmCount = 0
|
|
320
|
+
let name = ''
|
|
321
|
+
let description = ''
|
|
322
|
+
let internal = false
|
|
323
|
+
|
|
324
|
+
for (const line of lines) {
|
|
325
|
+
if (line.trim() === '---') {
|
|
326
|
+
fmCount++
|
|
327
|
+
if (fmCount === 2) break
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
if (fmCount !== 1) continue
|
|
331
|
+
|
|
332
|
+
const nameMatch = line.match(/^name:\s*(.+)/)
|
|
333
|
+
if (nameMatch) name = nameMatch[1]!.trim().replace(/^["']|["']$/g, '')
|
|
334
|
+
|
|
335
|
+
const descMatch = line.match(/^description:\s*(.+)/)
|
|
336
|
+
if (descMatch) description = descMatch[1]!.trim().replace(/^["']|["']$/g, '')
|
|
337
|
+
|
|
338
|
+
if (/^user-invocable:\s*false\s*$/i.test(line)) {
|
|
339
|
+
internal = true
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { name, description, internal }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function extractBody(content: string): string {
|
|
347
|
+
const lines = content.split('\n')
|
|
348
|
+
let fmCount = 0
|
|
349
|
+
const bodyLines: string[] = []
|
|
350
|
+
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
if (line.trim() === '---') {
|
|
353
|
+
fmCount++
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
if (fmCount >= 2) bodyLines.push(line)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return bodyLines.join('\n')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function extractCodeBlocks(content: string): string {
|
|
363
|
+
const lines = content.split('\n')
|
|
364
|
+
let inBlock = false
|
|
365
|
+
const out: string[] = []
|
|
366
|
+
|
|
367
|
+
for (let i = 0; i < lines.length; i++) {
|
|
368
|
+
if (lines[i]!.startsWith('```')) {
|
|
369
|
+
inBlock = !inBlock
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
if (inBlock) out.push(`${i + 1}: ${lines[i]}`)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return out.join('\n')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function stripExamples(content: string): string {
|
|
379
|
+
return content.replace(/`[^`]*`/g, '').replace(/"[^"]*"/g, '')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isShellExpandedReference(source: string, matchIndex: number): boolean {
|
|
383
|
+
const previousChar = source[matchIndex - 1]
|
|
384
|
+
const previousTwoChars = source.slice(Math.max(0, matchIndex - 2), matchIndex)
|
|
385
|
+
return previousChar === '$' || previousTwoChars === ')/' || previousTwoChars === '}/'
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function findInvisibleUnicode(
|
|
389
|
+
source: string,
|
|
390
|
+
): { line: number; column: number; codePoint: number; name: string } | null {
|
|
391
|
+
let line = 1
|
|
392
|
+
let column = 1
|
|
393
|
+
|
|
394
|
+
for (const char of source) {
|
|
395
|
+
if (char === '\n') {
|
|
396
|
+
line++
|
|
397
|
+
column = 1
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const codePoint = char.codePointAt(0)
|
|
402
|
+
if (codePoint !== undefined) {
|
|
403
|
+
const name = INVISIBLE_UNICODE.get(codePoint)
|
|
404
|
+
if (name) return { line, column, codePoint, name }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
column++
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const ROOT_LOCAL_PATH_PREFIXES = ['docs/', 'governances/', 'skills/', 'src/', 'apps/', 'packages/']
|
|
414
|
+
|
|
415
|
+
// S7–S9: agentskills.io directory structure checks
|
|
416
|
+
const ALLOWED_SKILL_SUBDIRS = new Set(['scripts', 'references', 'assets'])
|
|
417
|
+
const STATIC_RESOURCE_EXTENSIONS = new Set([
|
|
418
|
+
'.png',
|
|
419
|
+
'.jpg',
|
|
420
|
+
'.jpeg',
|
|
421
|
+
'.gif',
|
|
422
|
+
'.svg',
|
|
423
|
+
'.ico',
|
|
424
|
+
'.webp', // images
|
|
425
|
+
'.json',
|
|
426
|
+
'.yaml',
|
|
427
|
+
'.yml',
|
|
428
|
+
'.toml',
|
|
429
|
+
'.csv',
|
|
430
|
+
'.xml', // data files (json excluded for skill.json below)
|
|
431
|
+
])
|
|
432
|
+
const TEMPLATE_PATTERNS = [/\.template\./i, /\.tmpl$/i, /^template\./i]
|
|
433
|
+
|
|
434
|
+
function normalizeLinkTarget(target: string): string {
|
|
435
|
+
const trimmed = target.trim().replace(/^<|>$/g, '')
|
|
436
|
+
const withoutTitle = trimmed.split(/\s+"/)[0] ?? trimmed
|
|
437
|
+
return withoutTitle
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function findPublicSkillExternalRefs(
|
|
441
|
+
content: string,
|
|
442
|
+
skillDir: string,
|
|
443
|
+
repoRoot: string,
|
|
444
|
+
): Array<{ ref: string; reason: string }> {
|
|
445
|
+
const findings = new Map<string, string>()
|
|
446
|
+
const add = (ref: string, reason: string) => {
|
|
447
|
+
if (!findings.has(ref)) findings.set(ref, reason)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g
|
|
451
|
+
let match: RegExpExecArray | null
|
|
452
|
+
while ((match = markdownLinkPattern.exec(content)) !== null) {
|
|
453
|
+
const target = normalizeLinkTarget(match[1] ?? '')
|
|
454
|
+
if (!target || /^(https?:|mailto:|#)/i.test(target)) continue
|
|
455
|
+
if (target.startsWith('../')) {
|
|
456
|
+
add(target, 'parent-directory traversal escapes the skill folder')
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
if (ROOT_LOCAL_PATH_PREFIXES.some((prefix) => target.startsWith(prefix) && target.length > prefix.length)) {
|
|
460
|
+
add(target, 'repo-local link points outside the skill folder')
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
if (path.isAbsolute(target) && target.startsWith(repoRoot)) {
|
|
464
|
+
add(target, 'absolute path points to a repo file outside the skill folder')
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const prosePathPattern =
|
|
469
|
+
/(^|[\s(<'"`])(\.\.\/[^\s)"'`<>]+|(?:docs|governances|skills|src|apps|packages)\/[^\s)"'`<>]+)(?![A-Za-z0-9_-])/gm
|
|
470
|
+
|
|
471
|
+
while ((match = prosePathPattern.exec(content)) !== null) {
|
|
472
|
+
const ref = match[2] ?? ''
|
|
473
|
+
if (!ref) continue
|
|
474
|
+
if (ref.startsWith('../')) {
|
|
475
|
+
add(ref, 'parent-directory traversal escapes the skill folder')
|
|
476
|
+
} else {
|
|
477
|
+
const resolved = path.resolve(repoRoot, ref)
|
|
478
|
+
if (resolved.startsWith(skillDir)) continue
|
|
479
|
+
if (fs.existsSync(resolved)) add(ref, 'repo-local path points outside the skill folder')
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return Array.from(findings, ([ref, reason]) => ({ ref, reason }))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── the mechanical check engine: S1-S6, Q1-Q5, Q10-Q11, Q17, Q18, E1-E2, E6, E9 ──
|
|
487
|
+
|
|
488
|
+
export function runChecks(filePath: string, scanRoots?: Set<string>): CheckResult {
|
|
489
|
+
const criticals: Finding[] = []
|
|
490
|
+
const warnings: Finding[] = []
|
|
491
|
+
|
|
492
|
+
const crit = (checkId: string, name: string, evidence: string, fix: string) =>
|
|
493
|
+
criticals.push({ severity: 'CRITICAL', checkId, name, evidence, fix })
|
|
494
|
+
|
|
495
|
+
const warn = (severity: Severity, checkId: string, name: string, evidence: string, fix: string) =>
|
|
496
|
+
warnings.push({ severity, checkId, name, evidence, fix })
|
|
497
|
+
|
|
498
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
499
|
+
const skillDir = path.dirname(filePath)
|
|
500
|
+
const skillBaseDir = path.dirname(skillDir)
|
|
501
|
+
const skillBaseParent = path.basename(path.dirname(skillBaseDir))
|
|
502
|
+
const repoRoot = skillBaseParent === '.agents' ? path.dirname(path.dirname(skillBaseDir)) : path.dirname(skillBaseDir)
|
|
503
|
+
const dirName = path.basename(skillDir)
|
|
504
|
+
const parent = path.basename(skillBaseDir)
|
|
505
|
+
|
|
506
|
+
const { name: fmName, description: fmDesc, internal: fmInternal } = parseFrontmatter(content)
|
|
507
|
+
const body = extractBody(content)
|
|
508
|
+
const codeBlocks = extractCodeBlocks(content)
|
|
509
|
+
const stripped = stripExamples(content)
|
|
510
|
+
const invisibleInSkill = findInvisibleUnicode(content)
|
|
511
|
+
const isPartialSkill = fmInternal
|
|
512
|
+
const isPublicShippedSkill = parent === 'skills' && skillBaseParent !== '.agents' && !fmInternal
|
|
513
|
+
|
|
514
|
+
// S1: the SKILL.md must sit in its own named subdirectory directly under a recognized scan root.
|
|
515
|
+
// "Recognized" is the universal `skills` convention (holds anywhere, incl. an external/temp skill
|
|
516
|
+
// audited via --path) OR any configured scan root passed in — never a literal-`skills`-dirname
|
|
517
|
+
// requirement, which would false-positive a skill under a configured non-`skills` location (#149).
|
|
518
|
+
let containerReal = skillBaseDir
|
|
519
|
+
try {
|
|
520
|
+
containerReal = fs.realpathSync(skillBaseDir)
|
|
521
|
+
} catch {
|
|
522
|
+
// unresolvable container (e.g. broken symlink) — fall back to the raw path
|
|
523
|
+
}
|
|
524
|
+
const nestedUnderScanRoot = parent === 'skills' || (scanRoots?.has(containerReal) ?? false)
|
|
525
|
+
if (!nestedUnderScanRoot) {
|
|
526
|
+
crit(
|
|
527
|
+
'S1',
|
|
528
|
+
'SKILL.md in own directory',
|
|
529
|
+
`path: ${filePath}`,
|
|
530
|
+
'Move SKILL.md into its own named subdirectory directly under a recognized skill-scan root',
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!fmName) {
|
|
535
|
+
crit(
|
|
536
|
+
'S2',
|
|
537
|
+
'Required frontmatter: name',
|
|
538
|
+
'name: field missing or empty',
|
|
539
|
+
`Add 'name: ${dirName}' to the YAML frontmatter block`,
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
if (!fmDesc) {
|
|
543
|
+
crit(
|
|
544
|
+
'S2',
|
|
545
|
+
'Required frontmatter: description',
|
|
546
|
+
'description: field missing or empty',
|
|
547
|
+
'Add a description: field to the YAML frontmatter block',
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (fmName && fmName !== dirName) {
|
|
552
|
+
warn(
|
|
553
|
+
'HIGH',
|
|
554
|
+
'S3',
|
|
555
|
+
'name matches directory',
|
|
556
|
+
`name: '${fmName}' but directory is '${dirName}'`,
|
|
557
|
+
`Set name: to '${dirName}'`,
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const s4RefPattern = /([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_./-]+\.[a-zA-Z]+)/g
|
|
562
|
+
const s4Skip = /[<>*]|^(https?:\/\/|~|\/|skills\/|\.agents\/|\.github\/|\.cursor\/|\.vscode\/)/
|
|
563
|
+
const s4Ext = /\.(md|sh|mts|mjs|js|ts|py|json|yaml|yml)$/
|
|
564
|
+
const s4Refs = new Set<string>()
|
|
565
|
+
let m: RegExpExecArray | null
|
|
566
|
+
while ((m = s4RefPattern.exec(codeBlocks)) !== null) {
|
|
567
|
+
const ref = m[1]!
|
|
568
|
+
if (isShellExpandedReference(codeBlocks, m.index)) continue
|
|
569
|
+
if (m.index > 0 && codeBlocks[m.index - 1] === '/') continue
|
|
570
|
+
if (!s4Skip.test(ref) && s4Ext.test(ref)) s4Refs.add(ref)
|
|
571
|
+
}
|
|
572
|
+
for (const ref of s4Refs) {
|
|
573
|
+
if (!fs.existsSync(path.join(skillDir, ref))) {
|
|
574
|
+
warn(
|
|
575
|
+
'HIGH',
|
|
576
|
+
'S4',
|
|
577
|
+
'Referenced file does not exist in skill directory',
|
|
578
|
+
`${ref} (looked for ${path.join(skillDir, ref)})`,
|
|
579
|
+
'Create the file inside the skill directory or remove the reference',
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (isPublicShippedSkill) {
|
|
585
|
+
for (const finding of findPublicSkillExternalRefs(content, skillDir, repoRoot)) {
|
|
586
|
+
warn(
|
|
587
|
+
'HIGH',
|
|
588
|
+
'S4',
|
|
589
|
+
'Public skill references files outside its skill directory',
|
|
590
|
+
`${finding.ref} (${finding.reason})`,
|
|
591
|
+
'Keep shipped skill references inside the skill folder or replace with external URLs / generic prose',
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const anchorPattern = /\[([^\]]+)\]\(#([^)]+)\)/g
|
|
597
|
+
while ((m = anchorPattern.exec(stripped)) !== null) {
|
|
598
|
+
const anchor = m[2]!
|
|
599
|
+
const headingPat = anchor.replace(/-/g, '[- ]')
|
|
600
|
+
if (!new RegExp(`^#{1,6}.*${headingPat}`, 'im').test(content)) {
|
|
601
|
+
warn(
|
|
602
|
+
'MEDIUM',
|
|
603
|
+
'S5',
|
|
604
|
+
'Internal anchor link does not resolve',
|
|
605
|
+
`#${anchor}`,
|
|
606
|
+
`Add a heading matching '${anchor.replace(/-/g, ' ')}' or fix the link target`,
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (fmDesc) {
|
|
612
|
+
if (!isPartialSkill) {
|
|
613
|
+
const wordCount = fmDesc.split(/\s+/).filter(Boolean).length
|
|
614
|
+
if (wordCount < 12) {
|
|
615
|
+
warn(
|
|
616
|
+
'HIGH',
|
|
617
|
+
'Q2',
|
|
618
|
+
'Description too short (specificity)',
|
|
619
|
+
`${wordCount} words: ${fmDesc}`,
|
|
620
|
+
'Expand the description to at least 12 words with specific trigger conditions',
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const genericHit = GENERIC_PHRASES.find((p) => new RegExp(p, 'i').test(fmDesc))
|
|
625
|
+
if (genericHit) {
|
|
626
|
+
warn(
|
|
627
|
+
'HIGH',
|
|
628
|
+
'Q2',
|
|
629
|
+
'Description uses vague generic phrase',
|
|
630
|
+
`matched '${genericHit}' in: ${fmDesc}`,
|
|
631
|
+
'Replace with specific trigger conditions and outcomes',
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (
|
|
637
|
+
isPartialSkill &&
|
|
638
|
+
fmDesc &&
|
|
639
|
+
(OP_DETAIL_FILEREF.test(fmDesc) || OP_DETAIL_DIR.test(fmDesc) || OP_DETAIL_CHECKID.test(fmDesc))
|
|
640
|
+
) {
|
|
641
|
+
warn(
|
|
642
|
+
'MEDIUM',
|
|
643
|
+
'Q17',
|
|
644
|
+
'Partial skill description carries operational detail',
|
|
645
|
+
`description: ${fmDesc}`,
|
|
646
|
+
'Trim to identity + named caller only; move paths, directories, check-IDs, and artifact filenames to the body or README',
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (isPartialSkill && fmDesc && !/^Partial Skill:/i.test(fmDesc)) {
|
|
651
|
+
warn(
|
|
652
|
+
'MEDIUM',
|
|
653
|
+
'Q3',
|
|
654
|
+
'Partial skill description missing the "Partial Skill:" prefix',
|
|
655
|
+
`description: ${fmDesc}`,
|
|
656
|
+
'Lead the description with "Partial Skill: invoke by name only — <identity>. <caller>."',
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (isPartialSkill && fmDesc && /use this skill when|when to use/i.test(fmDesc)) {
|
|
661
|
+
warn(
|
|
662
|
+
'MEDIUM',
|
|
663
|
+
'Q18',
|
|
664
|
+
'Trigger language on a partial (by-name) skill',
|
|
665
|
+
`description: ${fmDesc}`,
|
|
666
|
+
'Remove "Use this skill when"/"when to use" phrasing — a partial skill is invoked by name, and trigger-shaped text invites spurious harness auto-matching',
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!body.trim()) {
|
|
671
|
+
warn(
|
|
672
|
+
'MEDIUM',
|
|
673
|
+
'Q4',
|
|
674
|
+
'No actionable instruction body',
|
|
675
|
+
'body is empty after frontmatter',
|
|
676
|
+
'Add numbered steps, decision logic, or ## section headers',
|
|
677
|
+
)
|
|
678
|
+
} else if (!/^\d+\.|^#{1,4} /m.test(body)) {
|
|
679
|
+
warn(
|
|
680
|
+
'MEDIUM',
|
|
681
|
+
'Q4',
|
|
682
|
+
'No actionable instruction body',
|
|
683
|
+
'body has no numbered steps or section headers',
|
|
684
|
+
'Add numbered steps, decision logic, or ## section headers',
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (fmDesc && fmDesc.length > 1024) {
|
|
689
|
+
warn(
|
|
690
|
+
'HIGH',
|
|
691
|
+
'Q5',
|
|
692
|
+
'Description exceeds 1024 characters (spec hard limit)',
|
|
693
|
+
`description: ${fmDesc.slice(0, 80)}… (${fmDesc.length} chars)`,
|
|
694
|
+
'Trim to ≤1024 chars; move example phrases to the skill body',
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const scriptsDir = path.join(skillDir, 'scripts')
|
|
699
|
+
const hasScripts = fs.existsSync(scriptsDir)
|
|
700
|
+
|
|
701
|
+
for (const pat of STDOUT_AS_DATA_PATTERNS) {
|
|
702
|
+
if (pat.test(body) && !hasStdoutAsDataMitigation(content)) {
|
|
703
|
+
warn(
|
|
704
|
+
'HIGH',
|
|
705
|
+
'Q10',
|
|
706
|
+
'SKILL.md instructs parsing stdout prose as data',
|
|
707
|
+
body.match(pat)?.[0] ?? 'stdout-as-data pattern',
|
|
708
|
+
'Prefer artifact file paths, read stdout with --format agent, or parse with --format json for non-LLM consumers',
|
|
709
|
+
)
|
|
710
|
+
break
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (hasScripts) {
|
|
715
|
+
const scriptFiles = fs.readdirSync(scriptsDir).filter((f) => !f.startsWith('.'))
|
|
716
|
+
const hasInteractive = scriptFiles.some((f) => {
|
|
717
|
+
const src = fs.readFileSync(path.join(scriptsDir, f), 'utf8')
|
|
718
|
+
return /readline|createInterface|\.question\(/.test(src)
|
|
719
|
+
})
|
|
720
|
+
if (hasInteractive && !/--yes|-y/.test(content)) {
|
|
721
|
+
warn(
|
|
722
|
+
'HIGH',
|
|
723
|
+
'Q11',
|
|
724
|
+
'Interactive script missing non-interactive agent path',
|
|
725
|
+
'scripts/ uses readline/prompt but SKILL.md does not document --yes or -y',
|
|
726
|
+
'Document --yes (or equivalent) in SKILL.md for autonomous agent runs',
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
for (const line of codeBlocks.split('\n')) {
|
|
732
|
+
const severity = classifyE1Line(line)
|
|
733
|
+
if (!severity) continue
|
|
734
|
+
if (severity === 'CRITICAL') {
|
|
735
|
+
crit(
|
|
736
|
+
'E1',
|
|
737
|
+
'Dangerous shell command (in code block)',
|
|
738
|
+
line.trim(),
|
|
739
|
+
'Remove or rewrite; never embed destructive commands in a skill',
|
|
740
|
+
)
|
|
741
|
+
} else {
|
|
742
|
+
warn(
|
|
743
|
+
'HIGH',
|
|
744
|
+
'E1',
|
|
745
|
+
'Dangerous shell command (in code block)',
|
|
746
|
+
line.trim(),
|
|
747
|
+
'Remove or rewrite; never embed destructive commands in a skill',
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
break
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
for (const pat of E2_PATTERNS) {
|
|
754
|
+
const hit = stripped.split('\n').find((l) => pat.test(l))
|
|
755
|
+
if (hit) {
|
|
756
|
+
crit(
|
|
757
|
+
'E2',
|
|
758
|
+
'Prompt injection pattern',
|
|
759
|
+
hit.trim(),
|
|
760
|
+
'Remove prompt-injection content; treat skill body as untrusted data',
|
|
761
|
+
)
|
|
762
|
+
break
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (invisibleInSkill) {
|
|
767
|
+
crit(
|
|
768
|
+
'E9',
|
|
769
|
+
'Invisible Unicode control character',
|
|
770
|
+
`SKILL.md:${invisibleInSkill.line}:${invisibleInSkill.column} U+${invisibleInSkill.codePoint.toString(16).toUpperCase().padStart(4, '0')} ${invisibleInSkill.name}`,
|
|
771
|
+
'Remove the hidden character or replace it with visible ASCII text',
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const e6Hit = stripped.split('\n').find((l) => /git push.*(--force|-f )/.test(l) && /(main|master)/.test(l))
|
|
776
|
+
if (e6Hit) {
|
|
777
|
+
warn(
|
|
778
|
+
'HIGH',
|
|
779
|
+
'E6',
|
|
780
|
+
'Silent permission escalation: force-push to main/master',
|
|
781
|
+
e6Hit.trim(),
|
|
782
|
+
'Add an explicit user-confirmation step before the force-push',
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (hasScripts) {
|
|
787
|
+
for (const f of fs.readdirSync(scriptsDir).filter((name) => !name.startsWith('.'))) {
|
|
788
|
+
const src = fs.readFileSync(path.join(scriptsDir, f), 'utf8')
|
|
789
|
+
const invisibleInScript = findInvisibleUnicode(src)
|
|
790
|
+
if (invisibleInScript) {
|
|
791
|
+
crit(
|
|
792
|
+
'E9',
|
|
793
|
+
'Invisible Unicode control character',
|
|
794
|
+
`scripts/${f}:${invisibleInScript.line}:${invisibleInScript.column} U+${invisibleInScript.codePoint.toString(16).toUpperCase().padStart(4, '0')} ${invisibleInScript.name}`,
|
|
795
|
+
'Remove the hidden character or replace it with visible ASCII text',
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const manifest = readSkillManifest(skillDir)
|
|
802
|
+
if (manifest !== null) {
|
|
803
|
+
const dist = manifest.distribution
|
|
804
|
+
if (dist !== undefined) {
|
|
805
|
+
const knownInstallVia = ['package_manager']
|
|
806
|
+
if (!dist.install_via || !knownInstallVia.includes(dist.install_via)) {
|
|
807
|
+
crit(
|
|
808
|
+
'S6',
|
|
809
|
+
'skill.json: invalid distribution.install_via',
|
|
810
|
+
`install_via: ${dist.install_via ?? '(missing)'}`,
|
|
811
|
+
`Set distribution.install_via to one of: ${knownInstallVia.join(', ')}`,
|
|
812
|
+
)
|
|
813
|
+
} else if (dist.install_via === 'package_manager' && !dist.package?.name) {
|
|
814
|
+
crit(
|
|
815
|
+
'S6',
|
|
816
|
+
'skill.json: distribution.package.name required when install_via is package_manager',
|
|
817
|
+
'distribution.package.name is missing or empty',
|
|
818
|
+
'Add distribution.package.name with the npm package name that provides the skill binary',
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// S7: Non-standard directories
|
|
825
|
+
try {
|
|
826
|
+
const entries = fs.readdirSync(skillDir, { withFileTypes: true })
|
|
827
|
+
const nonStandardDirs = entries
|
|
828
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !ALLOWED_SKILL_SUBDIRS.has(e.name))
|
|
829
|
+
.map((e) => e.name)
|
|
830
|
+
if (nonStandardDirs.length > 0) {
|
|
831
|
+
warn(
|
|
832
|
+
'MEDIUM',
|
|
833
|
+
'S7',
|
|
834
|
+
'Non-standard directory in skill folder',
|
|
835
|
+
`found: ${nonStandardDirs.join(', ')}`,
|
|
836
|
+
'Move contents to scripts/, references/, or assets/ per agentskills.io spec',
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// S8: Static resources in root
|
|
841
|
+
const staticInRoot = entries
|
|
842
|
+
.filter((e) => {
|
|
843
|
+
if (!e.isFile()) return false
|
|
844
|
+
const name = e.name
|
|
845
|
+
const ext = path.extname(name).toLowerCase()
|
|
846
|
+
// Exclude skill.json from data file check
|
|
847
|
+
if (name === 'skill.json') return false
|
|
848
|
+
// Check for template patterns
|
|
849
|
+
if (TEMPLATE_PATTERNS.some((pat) => pat.test(name))) return true
|
|
850
|
+
// Check for static resource extensions
|
|
851
|
+
return STATIC_RESOURCE_EXTENSIONS.has(ext)
|
|
852
|
+
})
|
|
853
|
+
.map((e) => e.name)
|
|
854
|
+
if (staticInRoot.length > 0) {
|
|
855
|
+
warn(
|
|
856
|
+
'LOW',
|
|
857
|
+
'S8',
|
|
858
|
+
'Static resources in skill root instead of assets/',
|
|
859
|
+
`found: ${staticInRoot.join(', ')}`,
|
|
860
|
+
'Move to assets/ per agentskills.io spec',
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// S9: Extra documentation in root
|
|
865
|
+
const extraDocs = entries
|
|
866
|
+
.filter((e) => {
|
|
867
|
+
if (!e.isFile()) return false
|
|
868
|
+
const name = e.name.toLowerCase()
|
|
869
|
+
if (!name.endsWith('.md')) return false
|
|
870
|
+
// SKILL.md and README.md are allowed in root
|
|
871
|
+
if (name === 'skill.md' || name === 'readme.md') return false
|
|
872
|
+
// Template files belong in assets/ (S8), not references/
|
|
873
|
+
if (TEMPLATE_PATTERNS.some((pat) => pat.test(name))) return false
|
|
874
|
+
return true
|
|
875
|
+
})
|
|
876
|
+
.map((e) => e.name)
|
|
877
|
+
if (extraDocs.length > 0) {
|
|
878
|
+
warn(
|
|
879
|
+
'LOW',
|
|
880
|
+
'S9',
|
|
881
|
+
'Extra documentation files in skill root instead of references/',
|
|
882
|
+
`found: ${extraDocs.join(', ')}`,
|
|
883
|
+
'Move to references/ with explicit load conditions per agentskills.io spec',
|
|
884
|
+
)
|
|
885
|
+
}
|
|
886
|
+
} catch {
|
|
887
|
+
// If we can't read the skill directory, skip S7-S9 checks
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return { criticals, warnings }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ── CLI: scan resolution, report, exit code ──
|
|
894
|
+
|
|
895
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
896
|
+
const i = argv.indexOf(name)
|
|
897
|
+
return i === -1 ? undefined : argv[i + 1]
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Every value passed for a repeatable flag (e.g. --dir <glob> --dir <glob>).
|
|
901
|
+
function flagAll(argv: string[], name: string): string[] {
|
|
902
|
+
const out: string[] = []
|
|
903
|
+
for (let i = 0; i < argv.length; i++) {
|
|
904
|
+
if (argv[i] === name && argv[i + 1] !== undefined) out.push(argv[i + 1]!)
|
|
905
|
+
}
|
|
906
|
+
return out
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export interface ScanOutcome {
|
|
910
|
+
ok: boolean
|
|
911
|
+
exitCode: number
|
|
912
|
+
message?: string
|
|
913
|
+
results: Array<{ filePath: string; dirName: string; criticals: Finding[]; warnings: Finding[] }>
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Resolve --path (single skill dir or SKILL.md file) or the whole-project scan (SKILL_DIRS),
|
|
917
|
+
// run the mechanical check subset over each resolved SKILL.md, and roll up the exit code:
|
|
918
|
+
// a --path target with no SKILL.md errors non-zero; a project scan with zero SKILL.md files
|
|
919
|
+
// exits zero; any CRITICAL finding anywhere exits non-zero; otherwise exits zero.
|
|
920
|
+
export function scan(cwd: string, pathArg?: string, dirGlobs: string[] = []): ScanOutcome {
|
|
921
|
+
let skillFiles: string[]
|
|
922
|
+
|
|
923
|
+
if (pathArg) {
|
|
924
|
+
const resolved = path.resolve(cwd, pathArg)
|
|
925
|
+
const skillMd = resolved.endsWith('SKILL.md') ? resolved : path.join(resolved, 'SKILL.md')
|
|
926
|
+
if (!fs.existsSync(skillMd)) {
|
|
927
|
+
return { ok: false, exitCode: 1, message: `No SKILL.md found at ${skillMd}`, results: [] }
|
|
928
|
+
}
|
|
929
|
+
skillFiles = [skillMd]
|
|
930
|
+
} else {
|
|
931
|
+
skillFiles = findSkillFiles(resolveScanDirs(cwd, dirGlobs), cwd)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (skillFiles.length === 0) {
|
|
935
|
+
return { ok: true, exitCode: 0, message: 'No SKILL.md files found.', results: [] }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const scanRoots = recognizedScanRoots(cwd, dirGlobs)
|
|
939
|
+
const results = skillFiles.map((filePath) => ({
|
|
940
|
+
filePath,
|
|
941
|
+
dirName: path.basename(path.dirname(filePath)),
|
|
942
|
+
...runChecks(filePath, scanRoots),
|
|
943
|
+
}))
|
|
944
|
+
|
|
945
|
+
const totalCriticals = results.reduce((n, r) => n + r.criticals.length, 0)
|
|
946
|
+
return { ok: totalCriticals === 0, exitCode: totalCriticals === 0 ? 0 : 1, results }
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function printFinding(w: (s: string) => void, f: Finding): void {
|
|
950
|
+
const icon = f.severity === 'CRITICAL' ? '❌' : '⚠️ '
|
|
951
|
+
w(` ${icon} [${f.severity}] ${f.checkId} — ${f.name}`)
|
|
952
|
+
w(` Evidence: ${f.evidence}`)
|
|
953
|
+
w(` Fix: ${f.fix}`)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function printReport(w: (s: string) => void, outcome: ScanOutcome): void {
|
|
957
|
+
if (outcome.results.length === 0) {
|
|
958
|
+
w(outcome.message ?? '')
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
w(`Validating ${outcome.results.length} skill(s)…`)
|
|
963
|
+
|
|
964
|
+
let totalCriticals = 0
|
|
965
|
+
let totalWarnings = 0
|
|
966
|
+
|
|
967
|
+
for (const { dirName, criticals, warnings } of outcome.results) {
|
|
968
|
+
w(`\n── ${dirName} ─────────────────────────`)
|
|
969
|
+
totalCriticals += criticals.length
|
|
970
|
+
totalWarnings += warnings.length
|
|
971
|
+
|
|
972
|
+
for (const f of criticals) printFinding(w, f)
|
|
973
|
+
for (const f of warnings) printFinding(w, f)
|
|
974
|
+
|
|
975
|
+
if (criticals.length === 0) {
|
|
976
|
+
w(' ✅ no CRITICAL findings')
|
|
977
|
+
} else {
|
|
978
|
+
w(' 🚨 DO NOT commit or install until all CRITICAL findings are resolved.')
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
w('\n══════════════════════════════════════')
|
|
983
|
+
w(`Results: ${totalCriticals} critical failure(s), ${totalWarnings} warning(s)`)
|
|
984
|
+
|
|
985
|
+
if (totalCriticals > 0) {
|
|
986
|
+
w('❌ Fix all CRITICAL findings before merging.')
|
|
987
|
+
} else {
|
|
988
|
+
w('✅ All checks passed (S1–S9, Q2–Q5, Q10–Q11, Q17, Q18, E1–E2, E6, E9).')
|
|
989
|
+
w(' Run the improve-skill agent skill for full quality review (Q1, Q6–Q16, E3–E5, E7–E8, P1–P3).')
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const HELP = `usage: validate.mts [--path <path>] [--dir <glob>]... [--root <path>] [--format text|json]
|
|
994
|
+
|
|
995
|
+
Validate skills against the mechanical check subset (S1-S9, Q2-Q5, Q10-Q11, Q17, Q18, E1-E2, E6, E9).
|
|
996
|
+
|
|
997
|
+
--path <path> validate a single skill directory or SKILL.md file (default: whole-project scan)
|
|
998
|
+
--dir <glob> add a one-off scan location for this run (repeatable; ignored when --path is set)
|
|
999
|
+
--root <path> repo root to scan from (default: cwd)
|
|
1000
|
+
--format <fmt> text (default) or json
|
|
1001
|
+
--help show this message
|
|
1002
|
+
`
|
|
1003
|
+
|
|
1004
|
+
export function main(argv: string[]): number {
|
|
1005
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
1006
|
+
process.stdout.write(HELP)
|
|
1007
|
+
return 0
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const cwd = path.resolve(flag(argv, '--root') ?? '.')
|
|
1011
|
+
const pathArg = flag(argv, '--path')
|
|
1012
|
+
const dirGlobs = flagAll(argv, '--dir')
|
|
1013
|
+
const format = flag(argv, '--format') ?? (argv.includes('--json') ? 'json' : 'text')
|
|
1014
|
+
|
|
1015
|
+
const outcome = scan(cwd, pathArg, dirGlobs)
|
|
1016
|
+
|
|
1017
|
+
if (format === 'json') {
|
|
1018
|
+
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`)
|
|
1019
|
+
return outcome.exitCode
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (!outcome.ok && outcome.results.length === 0) {
|
|
1023
|
+
process.stderr.write(`${outcome.message}\n`)
|
|
1024
|
+
return outcome.exitCode
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
printReport((s) => process.stdout.write(`${s}\n`), outcome)
|
|
1028
|
+
return outcome.exitCode
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) {
|
|
1032
|
+
process.exit(main(process.argv.slice(2)))
|
|
1033
|
+
}
|