feat(skill): add security review builtin skill with code audit support
- Add generate-skills script to download and bundle skills from external repositories. - Register code-review-security skill for vulnerability detection and integrate into bundled skill initialization.
This commit is contained in:
parent
3e0eed1462
commit
df65350c35
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ coverage
|
|||
*.lock
|
||||
*.exe
|
||||
src/utils/vendor/
|
||||
.tmp/
|
||||
|
||||
# AI tool runtime directories
|
||||
.agents/
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist", "!!**/packages/@ant"]
|
||||
"includes": ["**", "!!**/dist", "!!**/packages/@ant", "!!**/src/costrict/skill/builtin.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"scripts/postinstall.cjs"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "bun run scripts/generate-skills.ts",
|
||||
"build": "bun run build.ts",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||
|
|
@ -50,7 +51,8 @@
|
|||
"check:unused": "knip-bun",
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
"postinstall": "node scripts/postinstall.cjs",
|
||||
"docs:dev": "npx mintlify dev"
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"generate:skills": "bun run scripts/generate-skills.ts"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
299
scripts/generate-skills.ts
Normal file
299
scripts/generate-skills.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Downloads builtin skills from their source repositories and generates
|
||||
* src/costrict/skill/builtin.ts with all skill files embedded as string constants.
|
||||
*
|
||||
* Usage: bun run scripts/generate-skills.ts
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const bundledSkillsDir = path.resolve(__dirname, '../.tmp/skills')
|
||||
const builtinTsFile = path.resolve(__dirname, '../src/costrict/skill/builtin.ts')
|
||||
|
||||
const BUILTIN_SKILLS = {
|
||||
'security-review': {
|
||||
repo: 'zgsm-ai/security-review-skill',
|
||||
branch: 'main',
|
||||
subdir: 'security-review',
|
||||
},
|
||||
} as const
|
||||
|
||||
type Index = {
|
||||
skills: Array<{
|
||||
name: string
|
||||
description: string
|
||||
files: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
async function fetchCommitSha(repo: string, branch: string): Promise<string | null> {
|
||||
const apiUrl = `https://api.github.com/repos/${repo}/commits/${branch}`
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'csc-build',
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.warn(` ⚠ Could not fetch commit SHA from GitHub API: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
const data = (await response.json()) as { sha?: string }
|
||||
return data.sha ?? null
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Failed to fetch commit SHA: ${err}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIndex(repo: string, branch: string): Promise<Index | null> {
|
||||
const indexUrl = `https://raw.githubusercontent.com/${repo}/${branch}/index.json`
|
||||
const response = await fetch(indexUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch index: ${indexUrl} (${response.status})`)
|
||||
}
|
||||
return response.json() as Promise<Index>
|
||||
}
|
||||
|
||||
async function fetchFile(url: string): Promise<string> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${url} (${response.status})`)
|
||||
}
|
||||
return response.text()
|
||||
}
|
||||
|
||||
async function downloadSkill(
|
||||
name: string,
|
||||
config: { repo: string; branch: string; subdir: string },
|
||||
): Promise<{ name: string; commitSha: string | null } | null> {
|
||||
const { repo, branch, subdir } = config
|
||||
console.log(`\n📦 Downloading skill: ${name}`)
|
||||
console.log(` From: https://github.com/${repo}`)
|
||||
console.log(` Branch: ${branch}`)
|
||||
|
||||
// Try HTTP first
|
||||
try {
|
||||
return await downloadSkillHttp(name, config)
|
||||
} catch (httpErr) {
|
||||
console.warn(` ⚠ HTTP download failed (${httpErr}), trying git clone...`)
|
||||
return await downloadSkillGit(name, config)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSkillHttp(
|
||||
name: string,
|
||||
config: { repo: string; branch: string; subdir: string },
|
||||
): Promise<{ name: string; commitSha: string | null } | null> {
|
||||
const { repo, branch, subdir } = config
|
||||
|
||||
const commitSha = await fetchCommitSha(repo, branch)
|
||||
if (commitSha) {
|
||||
console.log(` Commit: ${commitSha.slice(0, 7)}`)
|
||||
}
|
||||
|
||||
const index = await fetchIndex(repo, branch)
|
||||
if (!index?.skills?.length) {
|
||||
throw new Error(`Invalid index for skill: ${name}`)
|
||||
}
|
||||
|
||||
const skill = index.skills.find(s => s.name === name)
|
||||
if (!skill) {
|
||||
throw new Error(`Skill "${name}" not found in index`)
|
||||
}
|
||||
|
||||
console.log(` Found ${skill.files.length} files to download`)
|
||||
|
||||
const skillOutputDir = path.join(bundledSkillsDir, name)
|
||||
await fs.mkdir(skillOutputDir, { recursive: true })
|
||||
|
||||
const pathPrefix = subdir ? `${subdir}/` : ''
|
||||
|
||||
for (const file of skill.files) {
|
||||
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${pathPrefix}${file}`
|
||||
const targetPath = path.join(skillOutputDir, file)
|
||||
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true })
|
||||
|
||||
try {
|
||||
const content = await fetchFile(url)
|
||||
await fs.writeFile(targetPath, content, 'utf-8')
|
||||
console.log(` ✓ ${file}`)
|
||||
} catch (err) {
|
||||
console.warn(` ✗ Failed to download ${file}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
const skillMdPath = path.join(skillOutputDir, 'SKILL.md')
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
} catch {
|
||||
throw new Error(`Skill "${name}" missing SKILL.md`)
|
||||
}
|
||||
|
||||
console.log(` ✓ Skill ${name} downloaded successfully`)
|
||||
return { name, commitSha }
|
||||
}
|
||||
|
||||
function git(...args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
||||
const result = spawnSync('git', args, { encoding: 'utf-8' })
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
stdout: result.stdout?.trim() ?? '',
|
||||
stderr: result.stderr?.trim() ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSkillGit(
|
||||
name: string,
|
||||
config: { repo: string; branch: string; subdir: string },
|
||||
): Promise<{ name: string; commitSha: string | null } | null> {
|
||||
const { repo, branch, subdir } = config
|
||||
const cloneUrl = `https://github.com/${repo}.git`
|
||||
const cloneDir = path.join(bundledSkillsDir, `.clone-${name}`)
|
||||
const skillOutputDir = path.join(bundledSkillsDir, name)
|
||||
|
||||
console.log(` git clone ${cloneUrl}`)
|
||||
|
||||
await fs.rm(cloneDir, { recursive: true, force: true })
|
||||
|
||||
const cloneResult = git('clone', '--depth', '1', '--branch', branch, cloneUrl, cloneDir)
|
||||
if (!cloneResult.ok) {
|
||||
throw new Error(`git clone failed: ${cloneResult.stderr}`)
|
||||
}
|
||||
|
||||
const shaResult = git('-C', cloneDir, 'rev-parse', 'HEAD')
|
||||
const commitSha = shaResult.ok ? shaResult.stdout : null
|
||||
if (commitSha) {
|
||||
console.log(` Commit: ${commitSha.slice(0, 7)}`)
|
||||
}
|
||||
|
||||
const srcDir = subdir ? path.join(cloneDir, subdir) : cloneDir
|
||||
await fs.rm(skillOutputDir, { recursive: true, force: true })
|
||||
await fs.cp(srcDir, skillOutputDir, { recursive: true })
|
||||
|
||||
await fs.rm(cloneDir, { recursive: true, force: true })
|
||||
|
||||
const skillMdPath = path.join(skillOutputDir, 'SKILL.md')
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
} catch {
|
||||
throw new Error(`Skill "${name}" missing SKILL.md`)
|
||||
}
|
||||
|
||||
const fileCount = (await walk(skillOutputDir)).length
|
||||
console.log(` ✓ ${fileCount} files copied via git clone`)
|
||||
return { name, commitSha }
|
||||
}
|
||||
|
||||
async function walk(dir: string, base = ''): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
const relativePath = base ? path.join(base, entry.name) : entry.name
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath, relativePath)))
|
||||
} else {
|
||||
files.push(relativePath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBuiltinTs(
|
||||
skills: Array<{ name: string; commitSha: string | null }>,
|
||||
): Promise<void> {
|
||||
const outLines: string[] = [
|
||||
'// This file is auto-generated by scripts/generate-skills.ts',
|
||||
'// Do not edit manually',
|
||||
'',
|
||||
]
|
||||
|
||||
const skillEntries: string[] = []
|
||||
let fileIdx = 0
|
||||
|
||||
for (const { name, commitSha } of skills) {
|
||||
const skillDir = path.join(bundledSkillsDir, name)
|
||||
const files = await walk(skillDir)
|
||||
const fileEntries: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const varName = `SKILL_FILE_${fileIdx++}`
|
||||
const content = await fs.readFile(path.join(skillDir, file), 'utf-8')
|
||||
const normalizedPath = file.replaceAll('\\', '/')
|
||||
outLines.push(`const ${varName} = ${JSON.stringify(content)}`)
|
||||
fileEntries.push(` ${JSON.stringify(normalizedPath)}: ${varName}`)
|
||||
}
|
||||
|
||||
skillEntries.push(` ${JSON.stringify(name)}: {\n${fileEntries.join(',\n')}\n }`)
|
||||
}
|
||||
|
||||
outLines.push('')
|
||||
outLines.push('export const BUNDLED_SKILLS: Record<string, Record<string, string>> = {')
|
||||
outLines.push(skillEntries.join(',\n'))
|
||||
outLines.push('}')
|
||||
outLines.push('')
|
||||
|
||||
const versions = skills
|
||||
.filter(s => s.commitSha)
|
||||
.map(s => ` ${JSON.stringify(s.name)}: ${JSON.stringify(s.commitSha)}`)
|
||||
|
||||
outLines.push('export const SKILL_VERSIONS: Record<string, string> = {')
|
||||
outLines.push(versions.join(',\n'))
|
||||
outLines.push('}')
|
||||
outLines.push('')
|
||||
|
||||
await fs.writeFile(builtinTsFile, outLines.join('\n') + '\n')
|
||||
console.log(`\n✓ Generated ${builtinTsFile}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nCSC — Downloading Builtin Skills\n')
|
||||
|
||||
await fs.mkdir(bundledSkillsDir, { recursive: true })
|
||||
|
||||
const results: Array<{ name: string; commitSha: string | null }> = []
|
||||
|
||||
for (const [name, config] of Object.entries(BUILTIN_SKILLS)) {
|
||||
try {
|
||||
const result = await downloadSkill(name, config)
|
||||
if (result) results.push(result)
|
||||
} catch (err) {
|
||||
// Fall back to cached files in .tmp/skills/ if download fails
|
||||
const skillDir = path.join(bundledSkillsDir, name)
|
||||
const cached = await walk(skillDir)
|
||||
if (cached.length > 0) {
|
||||
console.warn(` ⚠ Download failed, using cached files for "${name}": ${err}`)
|
||||
results.push({ name, commitSha: null })
|
||||
} else {
|
||||
console.error(` ✗ Download failed and no cache for "${name}": ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
await generateBuiltinTs(results)
|
||||
} else {
|
||||
console.warn('\n⚠ No skills downloaded. builtin.ts not updated.')
|
||||
}
|
||||
|
||||
console.log('\n💡 Run `bun run build` to rebuild the CLI.\n')
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
187
src/costrict/skill/builtin.ts
Normal file
187
src/costrict/skill/builtin.ts
Normal file
File diff suppressed because one or more lines are too long
19
src/costrict/skill/codeReviewSecurity.ts
Normal file
19
src/costrict/skill/codeReviewSecurity.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { registerBundledSkill } from '../../skills/bundledSkills.js'
|
||||
import { BUNDLED_SKILLS } from './builtin.js'
|
||||
|
||||
export function registerCodeReviewSecuritySkill(): void {
|
||||
const files = BUNDLED_SKILLS['security-review'] ?? {}
|
||||
const skillMd = files['SKILL.md'] ?? ''
|
||||
|
||||
registerBundledSkill({
|
||||
name: 'code-review-security',
|
||||
description: 'Systematic code security audit — identifies vulnerabilities like SQL injection, XSS, command injection, SSRF, etc.',
|
||||
whenToUse:
|
||||
'Use when the user requests a code audit, security audit, or vulnerability scan; or mentions /code-review-security, /audit, or wants a security check before deployment',
|
||||
userInvocable: true,
|
||||
files,
|
||||
async getPromptForCommand() {
|
||||
return [{ type: 'text', text: skillMd }]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { feature } from 'bun:bundle'
|
||||
import { shouldAutoEnableClaudeInChrome } from 'src/utils/claudeInChrome/setup.js'
|
||||
import { registerCodeReviewSecuritySkill } from 'src/costrict/skill/codeReviewSecurity.js'
|
||||
import { registerBatchSkill } from './batch.js'
|
||||
import { registerClaudeInChromeSkill } from './claudeInChrome.js'
|
||||
import { registerDebugSkill } from './debug.js'
|
||||
|
|
@ -25,6 +26,7 @@ import { registerVerifySkill } from './verify.js'
|
|||
* 3. Import and call that function here
|
||||
*/
|
||||
export function initBundledSkills(): void {
|
||||
registerCodeReviewSecuritySkill()
|
||||
registerUpdateConfigSkill()
|
||||
registerKeybindingsSkill()
|
||||
registerVerifySkill()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user