diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3a5bdcb0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to SkillKit are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions use [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added +- **Skill integrity verification (`skillkit verify`).** New command computes a deterministic SHA-256 over every file in a skill (excluding metadata, VCS, and editor artifacts) and emits the result as a Subresource Integrity string (`sha256-`). Supports: + - `--expected ` to verify against a known publisher digest + - `--against-lock` to verify against the local `~/.skillkit/lock.json` entry + - `--files` to dump per-file digests + - `--json` for CI pipelines +- **`computeSkillIntegrity` / `verifySkillIntegrity`** exported from `@skillkit/core` for programmatic use. +- **`integrity` field** in `WellKnownSkill` index. `skillkit publish` now records a full SRI digest for each published skill alongside the file list. +- **Lockfile records full integrity.** `skillkit install` and `skillkit update` now persist the full SRI digest to `~/.skillkit/lock.json` (with graceful fallback to the legacy short checksum when integrity computation fails). +- Phase 1 of supply-chain integrity (issue #90). Phase 2 (ed25519 signing + publisher key registry) tracked separately. + +### Tests +- 11 unit tests for the integrity module (`packages/core/src/integrity/__tests__/integrity.test.ts`). +- 5 CLI end-to-end tests for `skillkit verify` (`packages/cli/src/__tests__/verify.test.ts`). + +## [1.24.0] - 2026-04-XX + +See [GitHub releases](https://github.com/rohitg00/skillkit/releases) for prior history. diff --git a/README.md b/README.md index 11d2767c..16525c77 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,9 @@ skillkit marketplace # browse skillkit tree # taxonomy skillkit find # quick search skillkit scan # security scan +skillkit verify # SHA-256 integrity (SRI) +skillkit verify --expected sha256-... # verify against published digest +skillkit verify --against-lock # verify against ~/.skillkit/lock.json ``` diff --git a/apps/skillkit/src/cli.ts b/apps/skillkit/src/cli.ts index 8eba159b..8078a451 100644 --- a/apps/skillkit/src/cli.ts +++ b/apps/skillkit/src/cli.ts @@ -112,6 +112,7 @@ import { SkillMdCheckCommand, ServeCommand, ScanCommand, + VerifyCommand, EvalCommand, DoctorCommand, SaveCommand, @@ -265,6 +266,7 @@ cli.register(SkillMdCheckCommand); cli.register(ServeCommand); cli.register(ScanCommand); +cli.register(VerifyCommand); cli.register(EvalCommand); cli.register(DoctorCommand); cli.register(SaveCommand); diff --git a/docs/fumadocs/content/docs/commands.mdx b/docs/fumadocs/content/docs/commands.mdx index e17914c4..dba1d0d6 100644 --- a/docs/fumadocs/content/docs/commands.mdx +++ b/docs/fumadocs/content/docs/commands.mdx @@ -573,6 +573,23 @@ Skills are automatically scanned during `skillkit install`. Use `--no-scan` to s - `table` - tabular format - `sarif` - SARIF v2.1 for GitHub Code Scanning / IDE integration +## Integrity Verification + +```bash +skillkit verify # Print SRI digest (sha256-) +skillkit verify --files # Show per-file digests +skillkit verify --format hex # Print raw 64-char hex digest +skillkit verify --json # Machine-readable JSON +skillkit verify --expected sha256-... # Verify against a known publisher digest +skillkit verify --against-lock # Verify against ~/.skillkit/lock.json +``` + +`skillkit verify` computes a deterministic SHA-256 over every file in the skill (excluding `.skillkit.json` metadata, VCS artifacts, and editor noise) and emits a [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) string. The digest matches the `integrity` field that `skillkit publish` writes into `.well-known/skills/index.json`, so a downstream consumer can detect tampering between publish and install. + +`skillkit install` and `skillkit update` automatically record the full SRI digest into `~/.skillkit/lock.json`, enabling `--against-lock` audits in CI pipelines and supply-chain workflows. + +**Exit codes:** `0` on match, `1` on mismatch, missing path, or malformed `--expected` value. + ## Interactive TUI ```bash diff --git a/docs/fumadocs/content/docs/index.mdx b/docs/fumadocs/content/docs/index.mdx index dc0c2791..a1a9546b 100644 --- a/docs/fumadocs/content/docs/index.mdx +++ b/docs/fumadocs/content/docs/index.mdx @@ -101,17 +101,20 @@ SkillKit sits between your skills and your agents. It handles format translation [Full agent details](/docs/agents) -### Security scanning +### Security & integrity -Every skill is scanned for threats before installation — prompt injection, hardcoded secrets, command injection, and more: +Every skill is scanned for threats before installation — prompt injection, hardcoded secrets, command injection, and more — and content integrity is verifiable against the publisher digest: ```bash skillkit scan ./my-skill # 46+ detection rules skillkit scan ./my-skill --format sarif # GitHub Code Scanning skillkit scan ./my-skill --fail-on high # CI gate + +skillkit verify ./my-skill # SHA-256 SRI digest +skillkit verify ./my-skill --against-lock # Detect supply-chain tampering ``` -[Security Scanner](/docs/security) +[Security & Integrity](/docs/security) ## Beyond Basic Skills @@ -169,7 +172,7 @@ Team members run `skillkit manifest install` and they're in sync. - [Installation](/docs/installation) — Install and configure - [Quick Start](/docs/quickstart) — First project setup - [Methodology Packs](/docs/methodology) — Debugging, testing, planning skills -- [Security Scanner](/docs/security) — Threat detection for skills +- [Security & Integrity](/docs/security) — Threat detection plus SHA-256 content verification - [Commands](/docs/commands) — Full CLI reference - [REST API](/docs/rest-api) — Runtime discovery server - [MCP Server](/docs/mcp-server) — Agent-native discovery diff --git a/docs/fumadocs/content/docs/security.mdx b/docs/fumadocs/content/docs/security.mdx index 372824f6..819a2e52 100644 --- a/docs/fumadocs/content/docs/security.mdx +++ b/docs/fumadocs/content/docs/security.mdx @@ -1,11 +1,16 @@ --- -title: Security Scanner -description: Detect malicious patterns, secrets, and vulnerabilities in AI agent skills +title: Security & Integrity +description: Scan skills for malicious patterns, verify content integrity, and lock against supply-chain tampering --- -# Security Scanner +# Security & Integrity -SkillKit includes a built-in security scanner that analyzes skills for prompt injection, command injection, data exfiltration, hardcoded secrets, and other threats before installation. +SkillKit ships two complementary supply-chain defenses: + +1. **`skillkit scan`** — static security scanner detecting prompt injection, command injection, data exfiltration, hardcoded secrets, tool abuse, and unicode steganography (46+ rules). +2. **`skillkit verify`** — SHA-256 content-integrity check that compares the locally installed skill against the publisher's recorded digest (full SRI, recorded automatically in `~/.skillkit/lock.json`). + +Run `scan` to ask _"is this skill dangerous?"_. Run `verify` to ask _"is this skill what the publisher actually shipped?"_. ## Quick Start @@ -279,3 +284,42 @@ To skip specific rules: ```bash skillkit scan . --skip-rules UC001,UC002,MF004 ``` + +## Integrity Verification (`skillkit verify`) + +`skillkit verify` is the supply-chain counterpart to `skillkit scan`. The scanner answers _"is this skill dangerous?"_; `verify` answers _"is this skill what the publisher actually shipped?"_. + +```bash +skillkit verify ./my-skill # Print SRI digest +skillkit verify ./my-skill --expected sha256-iQo/... # Compare against publisher digest +skillkit verify ./my-skill --against-lock # Compare against ~/.skillkit/lock.json +skillkit verify ./my-skill --json # JSON for CI pipelines +``` + +### How the digest is computed + +`verify` walks every file in the skill in deterministic alphabetical order, computes SHA-256 per file, and rolls those file hashes (plus their POSIX-normalized relative paths) into a single SHA-256 digest, emitted as a [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) string of the form `sha256-`. Skipped: `.skillkit.json`, `.DS_Store`, `.git`, `node_modules`, `dist`, `.turbo`, and any `*.skillkit.json` sidecar. + +Because file _paths_ feed the hash alongside _content_, renaming, adding, or removing a file all change the digest — not just edits to file bodies. + +### Publish, install, and lock integration + +- `skillkit publish` writes the digest into the `integrity` field of `.well-known/skills/index.json` for every published skill. +- `skillkit install` records the full SRI digest into `~/.skillkit/lock.json` (falling back to the legacy short checksum only if integrity computation fails). +- `skillkit update` refreshes the lock entry on successful update. + +### CI usage + +```yaml +- name: Verify all installed skills + run: | + for skill in .claude/skills/*/; do + npx skillkit verify "$skill" --against-lock --json + done +``` + +Exit codes: `0` on match, `1` on mismatch, missing path, or malformed `--expected` value. Reason codes in JSON output: `mismatch`, `invalid-expected-format`, `missing-expected`, `no-lock-entry`. + +### Roadmap + +Cryptographic skill signing (ed25519 publisher keys + verified-publisher registry) is tracked separately as Phase 2 of [#90](https://github.com/rohitg00/skillkit/issues/90). Phase 1 (content integrity) is shipping now in response to the ClawHavoc supply-chain campaign and OWASP Agentic Skills Top 10 guidance. diff --git a/docs/skillkit/components/Commands.tsx b/docs/skillkit/components/Commands.tsx index dd499cb3..1bd1517e 100644 --- a/docs/skillkit/components/Commands.tsx +++ b/docs/skillkit/components/Commands.tsx @@ -99,6 +99,9 @@ const COMMAND_GROUPS: CommandGroup[] = [ { cmd: 'scan --format sarif', desc: 'SARIF output' }, { cmd: 'scan --fail-on high', desc: 'CI gate' }, { cmd: 'install --no-scan', desc: 'Skip scan' }, + { cmd: 'verify ', desc: 'SHA-256 integrity (SRI)' }, + { cmd: 'verify --expected sha256-...', desc: 'Match publisher digest' }, + { cmd: 'verify --against-lock', desc: 'Verify vs lockfile' }, { cmd: 'audit log', desc: 'View audit logs' }, { cmd: 'validate', desc: 'Validate format' }, ], diff --git a/docs/skillkit/components/Features.tsx b/docs/skillkit/components/Features.tsx index 3bc183f9..aa37ca21 100644 --- a/docs/skillkit/components/Features.tsx +++ b/docs/skillkit/components/Features.tsx @@ -79,6 +79,15 @@ const FEATURES: Feature[] = [ ) }, + { + title: 'Content Integrity', + description: 'SHA-256 SRI digests on publish, install, and lock. Detect supply-chain tampering with skillkit verify.', + icon: ( + + + + ) + }, { title: 'Testing', description: 'Built-in test framework with assertions.', @@ -114,6 +123,7 @@ const COMPARISONS = [ ['Translation', 'None or limited', 'All 45 formats'], ['Memory', 'None', 'Persistent learning'], ['Security', 'None', '46-rule scanner'], + ['Integrity', 'None', 'SHA-256 SRI + lockfile'], ['Team Sync', 'None', '.skills manifest'], ['Telemetry', 'Varies', 'Zero. Ever.'], ['API Access', 'None', 'REST + MCP + Python'], diff --git a/packages/cli/src/__tests__/commands.test.ts b/packages/cli/src/__tests__/commands.test.ts index adeb93e9..f33186f2 100644 --- a/packages/cli/src/__tests__/commands.test.ts +++ b/packages/cli/src/__tests__/commands.test.ts @@ -30,6 +30,8 @@ describe('CLI Commands', () => { expect(commands.SessionSnapshotListCommand).toBeDefined(); expect(commands.SessionSnapshotDeleteCommand).toBeDefined(); expect(commands.SessionExplainCommand).toBeDefined(); + expect(commands.VerifyCommand).toBeDefined(); + expect(commands.ScanCommand).toBeDefined(); }); }); }); diff --git a/packages/cli/src/__tests__/verify.test.ts b/packages/cli/src/__tests__/verify.test.ts new file mode 100644 index 00000000..166db205 --- /dev/null +++ b/packages/cli/src/__tests__/verify.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const CLI = join(__dirname, '../../../../apps/skillkit/dist/cli.js'); + +function run(args: string[]): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync(process.execPath, [CLI, ...args], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout, stderr: '' }; + } catch (err: any) { + return { + code: err.status ?? 1, + stdout: err.stdout?.toString() ?? '', + stderr: err.stderr?.toString() ?? '', + }; + } +} + +describe('skillkit verify', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'skillkit-verify-cli-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('prints an SRI digest by default', () => { + writeFileSync(join(dir, 'SKILL.md'), '# test\n'); + const r = run(['verify', dir]); + expect(r.code).toBe(0); + expect(r.stdout.trim()).toMatch(/^sha256-[A-Za-z0-9+/]+=*$/); + }); + + it('emits JSON with --json', () => { + writeFileSync(join(dir, 'SKILL.md'), '# test\n'); + mkdirSync(join(dir, 'scripts')); + writeFileSync(join(dir, 'scripts', 'a.sh'), 'echo'); + const r = run(['verify', dir, '--json']); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout); + expect(payload.ok).toBe(true); + expect(payload.algorithm).toBe('sha256'); + expect(payload.sri).toMatch(/^sha256-/); + expect(typeof payload.digest).toBe('string'); + expect(payload.totalBytes).toBeGreaterThan(0); + }); + + it('returns exit 1 on integrity mismatch', () => { + writeFileSync(join(dir, 'SKILL.md'), 'hello'); + const bogus = 'sha256-' + Buffer.alloc(32, 7).toString('base64'); + const r = run(['verify', dir, '--expected', bogus, '--json']); + expect(r.code).toBe(1); + const payload = JSON.parse(r.stdout); + expect(payload.ok).toBe(false); + expect(payload.reason).toBe('mismatch'); + }); + + it('returns exit 0 when --expected matches', () => { + writeFileSync(join(dir, 'SKILL.md'), 'matched'); + const computed = run(['verify', dir]); + const sri = computed.stdout.trim(); + const r = run(['verify', dir, '--expected', sri, '--json']); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout); + expect(payload.ok).toBe(true); + }); + + it('rejects garbage --expected', () => { + writeFileSync(join(dir, 'SKILL.md'), 'x'); + const r = run(['verify', dir, '--expected', 'garbage']); + expect(r.code).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 8baa283b..e307665d 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -125,6 +125,7 @@ export { SkillMdValidateCommand, SkillMdInitCommand, SkillMdCheckCommand } from // API server export { ServeCommand } from './serve.js'; export { ScanCommand } from './scan.js'; +export { VerifyCommand } from './verify.js'; export { EvalCommand } from './eval.js'; export { IssuePlanCommand, IssueListCommand } from './issue.js'; export { DoctorCommand } from './doctor.js'; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 4dc70c49..0fac03d0 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -29,6 +29,7 @@ import { TrustScorer, readSkillContent, computeSkillChecksum, + computeSkillIntegrity, addSkillToLock, } from "@skillkit/core"; import type { SkillsShStats } from "@skillkit/core"; @@ -617,11 +618,17 @@ export class InstallCommand extends Command { const actualPath = isStandalone ? join(firstAgentInstallDir, skillName.endsWith(".md") ? skillName : `${skillName}.md`) : join(firstAgentInstallDir, skillName); + let integritySri: string | undefined; + try { + integritySri = computeSkillIntegrity(actualPath).sri; + } catch { + integritySri = undefined; + } addSkillToLock(skillName, { source: this.source, sourceType: providerAdapter!.type, installedAt: new Date().toISOString(), - checksum: computeSkillChecksum(actualPath), + checksum: integritySri ?? computeSkillChecksum(actualPath), agents: installedAgents, path: actualPath, }); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index b00fd1bc..587f5021 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -25,6 +25,7 @@ import { formatSummary, Severity, evaluateSkillDirectory, + computeSkillIntegrity, } from "@skillkit/core"; import { formatCount, timeAgo, fetchGitHubActivity } from "../helpers.js"; @@ -194,10 +195,21 @@ export class PublishCommand extends Command { description: skill.description, path: skill.path, }); + let integritySri: string | undefined; + try { + integritySri = computeSkillIntegrity(skill.path).sri; + } catch { + integritySri = undefined; + } + if (integritySri) { + console.log(colors.muted(` Integrity: ${integritySri}`)); + } + wellKnownSkills.push({ name: safeName, description: skill.description, files, + integrity: integritySri, }); } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index b91ba65b..85cf3d6a 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,7 +1,15 @@ import { existsSync, rmSync, cpSync } from 'node:fs'; import { join } from 'node:path'; import { Command, Option } from 'clipanion'; -import { findAllSkills, findSkill, detectProvider, isLocalPath, computeSkillChecksum, addSkillToLock } from '@skillkit/core'; +import { findAllSkills, findSkill, detectProvider, isLocalPath, computeSkillChecksum, computeSkillIntegrity, addSkillToLock } from '@skillkit/core'; + +function safeIntegrity(path: string): string | undefined { + try { + return computeSkillIntegrity(path).sri; + } catch { + return undefined; + } +} import { getSearchDirs, loadSkillMetadata, saveSkillMetadata, fetchGitHubActivity } from '../helpers.js'; import { colors, spinner, warn, step } from '../onboarding/index.js'; @@ -102,7 +110,7 @@ export class UpdateCommand extends Command { sourceType: metadata.sourceType, installedAt: metadata.installedAt, updatedAt: metadata.updatedAt, - checksum: metadata.checksum, + checksum: safeIntegrity(skill.path) ?? metadata.checksum, agents: [], path: skill.path, }); @@ -170,7 +178,7 @@ export class UpdateCommand extends Command { sourceType: metadata.sourceType, installedAt: metadata.installedAt, updatedAt: metadata.updatedAt, - checksum: metadata.checksum, + checksum: safeIntegrity(skill.path) ?? metadata.checksum, agents: [], path: skill.path, }); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts new file mode 100644 index 00000000..3b0ce266 --- /dev/null +++ b/packages/cli/src/commands/verify.ts @@ -0,0 +1,167 @@ +import { Command, Option } from 'clipanion'; +import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { + computeSkillIntegrity, + verifySkillIntegrity, + loadLockFile, + parseIntegrity, +} from '@skillkit/core'; +import { colors, success, error, warn, step } from '../onboarding/index.js'; + +export class VerifyCommand extends Command { + static override paths = [['verify']]; + + static override usage = Command.Usage({ + description: 'Compute or verify the SHA-256 integrity of a skill', + details: ` + Computes a deterministic SHA-256 digest over every file in the skill + (excluding .skillkit.json metadata and version-control artifacts). The + digest is emitted as a Subresource Integrity (SRI) string by default + (sha256-) and is compatible with the published well-known/skills.json + index. + + Pass --expected to verify against a known value, or --against-lock to + cross-check against the locally recorded checksum in ~/.skillkit/lock.json. + `, + examples: [ + ['Print integrity of current directory', '$0 verify .'], + ['Print full per-file table', '$0 verify ./my-skill --files'], + ['Verify against an expected digest', '$0 verify ./my-skill --expected=sha256-abc...'], + ['Verify against the local lockfile', '$0 verify ./my-skill --against-lock'], + ['Emit JSON for CI pipelines', '$0 verify ./my-skill --json'], + ], + }); + + skillPath = Option.String({ required: true, name: 'path' }); + + expected = Option.String('--expected', { description: 'Expected integrity (SRI or hex sha256)' }); + + againstLock = Option.Boolean('--against-lock', false, { + description: 'Verify against the checksum recorded in ~/.skillkit/lock.json', + }); + + showFiles = Option.Boolean('--files', false, { description: 'Print per-file digests' }); + + json = Option.Boolean('--json', false, { description: 'Emit machine-readable JSON' }); + + format = Option.String('--format', 'sri', { + description: 'Output digest format: sri (default) or hex', + }); + + async execute(): Promise { + const target = resolve(this.skillPath); + if (!existsSync(target)) { + error(`Path not found: ${target}`); + return 1; + } + + if (this.format !== 'sri' && this.format !== 'hex') { + error(`Invalid --format value: "${this.format}" (must be 'sri' or 'hex')`); + return 1; + } + + let computed; + try { + computed = computeSkillIntegrity(target); + } catch (err) { + error((err as Error).message); + return 1; + } + + const expectedFromArgs = this.expected?.trim(); + const expectedFromLock = this.againstLock ? findLockChecksum(target) : null; + const expected = expectedFromArgs ?? expectedFromLock ?? null; + + if (this.againstLock && !expectedFromLock) { + if (this.json) { + this.context.stdout.write( + JSON.stringify({ ok: false, reason: 'no-lock-entry', target }, null, 2) + '\n', + ); + } else { + warn(`No lock entry found for ${target}`); + } + return 1; + } + + if (expected) { + const parsed = parseIntegrity(expected); + if (!parsed) { + error(`Invalid --expected value (must be sha256- or 64-char hex)`); + return 1; + } + const result = verifySkillIntegrity(target, expected); + if (this.json) { + this.context.stdout.write( + JSON.stringify( + { + ok: result.valid, + algorithm: result.algorithm, + expected: parsed.digest, + computed: result.computed, + sri: computed.sri, + files: computed.files.length, + totalBytes: computed.totalBytes, + reason: result.reason, + }, + null, + 2, + ) + '\n', + ); + return result.valid ? 0 : 1; + } + if (result.valid) { + success(`Integrity verified (${computed.files.length} files, ${computed.totalBytes} bytes)`); + this.context.stdout.write(` ${colors.dim('expected')} ${parsed.digest}\n`); + this.context.stdout.write(` ${colors.dim('computed')} ${result.computed}\n`); + return 0; + } + error('Integrity mismatch — skill content has changed since signing'); + this.context.stdout.write(` ${colors.dim('expected')} ${parsed.digest}\n`); + this.context.stdout.write(` ${colors.dim('computed')} ${result.computed}\n`); + return 1; + } + + if (this.json) { + this.context.stdout.write( + JSON.stringify( + { + ok: true, + algorithm: computed.algorithm, + sri: computed.sri, + digest: computed.digest, + files: this.showFiles ? computed.files : computed.files.length, + totalBytes: computed.totalBytes, + }, + null, + 2, + ) + '\n', + ); + return 0; + } + + const out = this.format === 'hex' ? computed.digest : computed.sri; + this.context.stdout.write(out + '\n'); + if (this.showFiles) { + step(`${computed.files.length} files, ${computed.totalBytes} bytes`); + for (const f of computed.files) { + this.context.stdout.write(` ${colors.dim(f.sha256.slice(0, 12))} ${f.path}\n`); + } + } + return 0; + } +} + +function findLockChecksum(target: string): string | null { + try { + const lock = loadLockFile(); + for (const entry of Object.values(lock.skills)) { + if (entry.path === target && entry.checksum) { + return entry.checksum; + } + } + } catch { + return null; + } + return null; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0353d3b9..be5eed07 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,6 +124,9 @@ export * from './runtime/index.js'; // Security Scanner export * from './scanner/index.js'; +// Skill Integrity (SHA-256 content hashing + verification) +export * from './integrity/index.js'; + // Spec Validation export * from './validation/index.js'; diff --git a/packages/core/src/integrity/__tests__/integrity.test.ts b/packages/core/src/integrity/__tests__/integrity.test.ts new file mode 100644 index 00000000..87f49371 --- /dev/null +++ b/packages/core/src/integrity/__tests__/integrity.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + computeSkillIntegrity, + verifySkillIntegrity, + formatIntegrity, + parseIntegrity, +} from '../integrity.js'; + +describe('integrity', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'skillkit-integrity-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('computes a stable digest for a single-file skill', () => { + writeFileSync(join(dir, 'SKILL.md'), '# Hello\n\nbody\n'); + const a = computeSkillIntegrity(dir); + const b = computeSkillIntegrity(dir); + expect(a.digest).toBe(b.digest); + expect(a.digest).toMatch(/^[a-f0-9]{64}$/); + expect(a.sri.startsWith('sha256-')).toBe(true); + expect(a.files).toHaveLength(1); + expect(a.files[0].path).toBe('SKILL.md'); + }); + + it('hashes file contents recursively and ignores metadata files', () => { + writeFileSync(join(dir, 'SKILL.md'), 'a'); + mkdirSync(join(dir, 'scripts')); + writeFileSync(join(dir, 'scripts', 'do.sh'), 'echo hi'); + writeFileSync(join(dir, '.skillkit.json'), '{}'); + writeFileSync(join(dir, '.DS_Store'), 'noise'); + + const result = computeSkillIntegrity(dir); + const paths = result.files.map((f) => f.path).sort(); + expect(paths).toEqual(['SKILL.md', 'scripts/do.sh']); + }); + + it('detects content drift', () => { + writeFileSync(join(dir, 'SKILL.md'), 'original'); + const before = computeSkillIntegrity(dir).digest; + writeFileSync(join(dir, 'SKILL.md'), 'tampered'); + const after = computeSkillIntegrity(dir).digest; + expect(before).not.toBe(after); + }); + + it('detects new file additions (path matters, not just contents)', () => { + writeFileSync(join(dir, 'SKILL.md'), 'x'); + const before = computeSkillIntegrity(dir).digest; + writeFileSync(join(dir, 'extra.txt'), 'x'); + const after = computeSkillIntegrity(dir).digest; + expect(before).not.toBe(after); + }); + + it('verifies matching SRI strings', () => { + writeFileSync(join(dir, 'SKILL.md'), '# test'); + const result = computeSkillIntegrity(dir); + expect(verifySkillIntegrity(dir, result.sri).valid).toBe(true); + expect(verifySkillIntegrity(dir, result.digest).valid).toBe(true); + }); + + it('rejects mismatched integrity strings', () => { + writeFileSync(join(dir, 'SKILL.md'), '# test'); + const result = verifySkillIntegrity(dir, 'sha256-' + Buffer.alloc(32).toString('base64')); + expect(result.valid).toBe(false); + expect(result.reason).toBe('mismatch'); + }); + + it('rejects malformed integrity strings', () => { + writeFileSync(join(dir, 'SKILL.md'), 'x'); + const r = verifySkillIntegrity(dir, 'not-a-hash'); + expect(r.valid).toBe(false); + expect(r.reason).toBe('invalid-expected-format'); + }); + + it('parses both SRI and bare hex', () => { + const hex = 'a'.repeat(64); + expect(parseIntegrity(hex)?.digest).toBe(hex); + const sri = 'sha256-' + Buffer.alloc(32, 1).toString('base64'); + const parsed = parseIntegrity(sri); + expect(parsed?.digest).toMatch(/^[a-f0-9]{64}$/); + expect(parseIntegrity('garbage')).toBeNull(); + }); + + it('formats integrity in hex or SRI', () => { + writeFileSync(join(dir, 'SKILL.md'), 'fmt'); + const r = computeSkillIntegrity(dir); + expect(formatIntegrity(r, 'sri')).toBe(r.sri); + expect(formatIntegrity(r, 'hex')).toBe(r.digest); + }); + + it('throws on missing path', () => { + expect(() => computeSkillIntegrity(join(dir, 'nope'))).toThrow(); + }); + + it('enforces maxBytes limit', () => { + const buf = Buffer.alloc(1024).fill('x'); + writeFileSync(join(dir, 'SKILL.md'), buf); + expect(() => computeSkillIntegrity(dir, { maxBytes: 100 })).toThrow(/max integrity size/); + }); +}); diff --git a/packages/core/src/integrity/index.ts b/packages/core/src/integrity/index.ts new file mode 100644 index 00000000..6b8b7c38 --- /dev/null +++ b/packages/core/src/integrity/index.ts @@ -0,0 +1,9 @@ +export { + computeSkillIntegrity, + verifySkillIntegrity, + formatIntegrity, + parseIntegrity, + type IntegrityResult, + type VerifyResult, + type IntegrityOptions, +} from './integrity.js'; diff --git a/packages/core/src/integrity/integrity.ts b/packages/core/src/integrity/integrity.ts new file mode 100644 index 00000000..6d93ad58 --- /dev/null +++ b/packages/core/src/integrity/integrity.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, statSync, readdirSync, existsSync } from 'node:fs'; +import { join, relative, sep, posix } from 'node:path'; + +export interface IntegrityOptions { + exclude?: string[]; + maxBytes?: number; +} + +export interface IntegrityResult { + algorithm: 'sha256'; + digest: string; + sri: string; + files: Array<{ path: string; sha256: string; bytes: number }>; + totalBytes: number; +} + +export interface VerifyResult { + valid: boolean; + expected: string; + computed: string; + algorithm: 'sha256'; + reason?: string; +} + +const DEFAULT_EXCLUDES = new Set([ + '.skillkit.json', + '.DS_Store', + '.git', + 'node_modules', + 'dist', + '.turbo', +]); + +const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; + +function shouldExclude(name: string, extra: Set): boolean { + if (DEFAULT_EXCLUDES.has(name)) return true; + if (extra.has(name)) return true; + if (name.endsWith('.skillkit.json')) return true; + return false; +} + +function walk(root: string, dir: string, extra: Set, out: string[]): void { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (shouldExclude(entry.name, extra)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(root, full, extra, out); + } else if (entry.isFile()) { + out.push(full); + } + } +} + +function toPosix(p: string): string { + return p.split(sep).join(posix.sep); +} + +export function computeSkillIntegrity(skillPath: string, options: IntegrityOptions = {}): IntegrityResult { + if (!existsSync(skillPath)) { + throw new Error(`Skill path not found: ${skillPath}`); + } + + const stats = statSync(skillPath); + const extra = new Set(options.exclude ?? []); + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + let root: string; + const files: string[] = []; + + if (stats.isFile()) { + if (!skillPath.toLowerCase().endsWith('.md')) { + throw new Error(`Single-file skill must be a .md file: ${skillPath}`); + } + root = skillPath; + files.push(skillPath); + } else if (stats.isDirectory()) { + root = skillPath; + walk(root, root, extra, files); + } else { + throw new Error(`Unsupported skill path type: ${skillPath}`); + } + + const fileEntries: IntegrityResult['files'] = []; + const rollup = createHash('sha256'); + let totalBytes = 0; + + for (const file of files) { + const data = readFileSync(file); + totalBytes += data.byteLength; + if (totalBytes > maxBytes) { + throw new Error(`Skill exceeds max integrity size (${maxBytes} bytes)`); + } + + const fileHash = createHash('sha256').update(data).digest('hex'); + const rel = stats.isFile() ? toPosix(file.split(sep).pop()!) : toPosix(relative(root, file)); + + fileEntries.push({ path: rel, sha256: fileHash, bytes: data.byteLength }); + + rollup.update(rel); + rollup.update('\0'); + rollup.update(fileHash); + rollup.update('\n'); + } + + const digest = rollup.digest('hex'); + const sri = `sha256-${Buffer.from(digest, 'hex').toString('base64')}`; + + return { + algorithm: 'sha256', + digest, + sri, + files: fileEntries, + totalBytes, + }; +} + +export function verifySkillIntegrity(skillPath: string, expected: string, options: IntegrityOptions = {}): VerifyResult { + if (!expected || typeof expected !== 'string') { + return { + valid: false, + expected: expected ?? '', + computed: '', + algorithm: 'sha256', + reason: 'missing-expected', + }; + } + + const result = computeSkillIntegrity(skillPath, options); + const parsed = parseIntegrity(expected); + + if (!parsed) { + return { + valid: false, + expected, + computed: result.digest, + algorithm: 'sha256', + reason: 'invalid-expected-format', + }; + } + + const valid = parsed.digest === result.digest; + return { + valid, + expected: parsed.digest, + computed: result.digest, + algorithm: 'sha256', + reason: valid ? undefined : 'mismatch', + }; +} + +export function formatIntegrity(result: IntegrityResult, format: 'sri' | 'hex' = 'sri'): string { + return format === 'sri' ? result.sri : result.digest; +} + +export function parseIntegrity(input: string): { algorithm: 'sha256'; digest: string } | null { + if (!input) return null; + const trimmed = input.trim(); + + if (trimmed.startsWith('sha256-')) { + const b64 = trimmed.slice('sha256-'.length); + try { + const buf = Buffer.from(b64, 'base64'); + if (buf.length !== 32) return null; + return { algorithm: 'sha256', digest: buf.toString('hex') }; + } catch { + return null; + } + } + + if (/^[a-f0-9]{64}$/i.test(trimmed)) { + return { algorithm: 'sha256', digest: trimmed.toLowerCase() }; + } + + return null; +} diff --git a/packages/core/src/providers/wellknown.ts b/packages/core/src/providers/wellknown.ts index d9197c3a..5aa05d7c 100644 --- a/packages/core/src/providers/wellknown.ts +++ b/packages/core/src/providers/wellknown.ts @@ -34,6 +34,7 @@ export interface WellKnownSkill { name: string; description?: string; files: string[]; + integrity?: string; } export interface WellKnownIndex { @@ -280,14 +281,18 @@ export function calculateBaseSkillsUrl(foundUrl: string): string { : foundUrl.replace('/skills.json', '/skills'); } -export function generateWellKnownIndex(skills: Array<{ name: string; description?: string; files: string[] }>): WellKnownIndex { +export function generateWellKnownIndex(skills: Array<{ name: string; description?: string; files: string[]; integrity?: string }>): WellKnownIndex { return { version: '1.0', - skills: skills.map(s => ({ - name: s.name, - description: s.description, - files: s.files, - })), + skills: skills.map(s => { + const entry: WellKnownSkill = { + name: s.name, + description: s.description, + files: s.files, + }; + if (s.integrity) entry.integrity = s.integrity; + return entry; + }), }; }