diff --git a/.agents/plans/skill-evals-and-packages.plan.md b/.agents/plans/skill-evals-and-packages.plan.md index c5c15771..376a4c17 100644 --- a/.agents/plans/skill-evals-and-packages.plan.md +++ b/.agents/plans/skill-evals-and-packages.plan.md @@ -7,16 +7,16 @@ todos: status: in_progress - id: p1-tree-hash-spec content: "Write docs/SKILL_TREE_HASH.md (the exact tree-hash algorithm, path rules, sort order) with shared test vectors. Done when web computeTreeHash and the new CLI implementation pass the same vectors." - status: in_progress + status: completed - id: p1-hash-sweep content: "Add a read-only script that recomputes tree_hash from served bytes for every public skill version and lists mismatches. Done when production shows zero mismatches, or each mismatch has a recorded fix." status: in_progress - id: p1-cli-verify content: "Make agentvouch skill install and update recompute the tree hash after download and stop on mismatch. Also reject trees where a path is both a file and a folder (at upload in normalizeSkillTreeFiles, and at install). Done when tampered-download tests fail the install, clean installs pass, and the CLI passes the shared tree-hash vectors." - status: pending + status: completed - id: p1-version-pinned-reads content: "Add a read-only, version-pinned archive download (same entitlement rules as today for paid skills). Done when a runner can fetch version N of a free skill and the bytes match the stored tree_hash." - status: pending + status: in_progress - id: p1-static-checks content: "Add deterministic static checks over the full tree (Agent Skills frontmatter rules, hidden Unicode, remote-install instructions, URL inventory, secret patterns), cached by (tree_hash, checks_version) and shown as a display-only signal. Done when each rule has tests and mirrored skills show results." status: pending @@ -635,3 +635,11 @@ Each phase is one or more small PRs off current `main`. P3 and later need their - 2026-10-25: Owner answers. The owner runs the production sweep on their Mac. The owner reviews and merges every PR. No evaluation model spending yet (D13): P2 code is built and tested with mock runs only, so the "one real run" part of `p2-eval-runner` waits for a budget. Recommended defaults approved for D1-D11 and D14. D12 (auto-uninstall) stays off. Still open: Q2 (v1 default is AgentVouch-only results), Q3, Q4, Q8. - 2026-10-24: New finding (not yet seen in data): `ingestTarArchive` enforces the upload limits (200 files, 5 MB), but mirror sync stores trees up to 512 files and 8 MB. `/raw?path=` and `/zip` read stored trees through it, so they would fail for a mirrored tree above the upload limits; `/archive` would not. The sweep reports such versions as `files_unavailable` with the limit message. If the production sweep finds any, fix the reader to accept the limits the tree was stored with. - 2026-11-04: Refreshed PR #262 against current `main` for the owner's authorized PR review and merge sweep. Resolved the `AGENTS.md` conflict by preserving the short working instructions and adding one task-specific pointer to the tree-hash spec; package integrity details live in `docs/AGENT_REFERENCE.md`. The root is 1,135 words versus 5,295 in this PR's former manual (79% less). Audited all 35 numbered safeguards: 33 are verbatim in the companion and two only replace old section-number references. Both approval-boundary sections are preserved verbatim. Routine tasks do not require reading all reference material. Python passed all 10 vectors and 9 invalid paths; the three focused Vitest suites passed 91 tests. The production sweep remains unrun, and CLI download enforcement remains future work. +- 2026-11-04 (`p1-cli-verify`, `p1-version-pinned-reads`, branch `feat/cli-tree-hash-verify`, on `main` after #262 was squash-merged as `a5a9798`): Phase 1, second PR. + - CLI: new `packages/agentvouch-cli/src/lib/integrity.ts`. `skill install` and `skills update` check every download against the published hashes before they write: a tree download against `tree_hash`, and a single `SKILL.md` against its manifest entry and the manifest against `tree_hash`. On a mismatch the CLI re-reads the skill once (a new version can be published during the download), then stops and writes nothing. The install metadata records `tree_hash_algorithm` and `tree_hash_verified_at`. The CLI passes the shared vectors, so `p1-tree-hash-spec` is done. + - Divergence: the CLI also stops before any download or payment when the skill has no published `tree_hash` (or, for one file, no manifest entry). Without this, a paid install could take the payment and then refuse to write. + - Server: upload rejects a file/folder path conflict with a 400, and `putSkillTree` rejects it too. Mirror sync skips such a skill with a `path conflict` detail, like a size-limit violation, so one bad upstream skill does not make the daily mirror cron return 500. The new route `/api/skills/{id}/versions/{version}/archive` serves one stored version with the same access rules as `/archive`. + - Divergence from the 2026-10-24 note: stored trees are now read with the mirror limits (512 files, 8 MB) without waiting for the sweep, because the fix is small and the CLI download check needs the same limits. The CLI reads downloads with the same limits. + - Found and fixed: `downloadRaw` decoded `/raw` with `response.text()`, which drops a leading byte order mark, so a written `SKILL.md` could differ from the published bytes. The CLI now hashes and writes the exact bytes. + - Merge gate: merge only after the production sweep (`p1-hash-sweep`) shows zero mismatches, or each mismatch has a recorded fix. `p1-version-pinned-reads` stays in progress until a live fetch of version N of a free skill matches its stored `tree_hash`. + - Verified: `format:check`, lint, web and CLI typecheck, web Vitest (166 files, 1,613 tests), CLI Vitest (18 files, 173 tests), and the Python vectors. `next build --webpack` compiled and ran TypeScript, then stopped at the `/sitemap.xml` prerender without `DATABASE_URL`. Not verified: the production sweep, and a live fetch from the new version route. diff --git a/docs/SKILL_TREE_HASH.md b/docs/SKILL_TREE_HASH.md index a11a2045..dfaa346a 100644 --- a/docs/SKILL_TREE_HASH.md +++ b/docs/SKILL_TREE_HASH.md @@ -38,7 +38,7 @@ A stored path obeys all of these rules. Upload enforces them (`normalizeSkillPat AgentVouch stores and hashes path text exactly as it was received, as UTF-8. It applies no Unicode normalization. Two names that look the same can be different paths: `café.md` written with `é` (U+00E9, NFC) and `café.md` written with `e` plus a combining accent (U+0065 U+0301, NFD). They give different tree hashes. Publishers should use NFC names. -**Client rule:** a client must also reject a tree in which one path is a folder of another path, for example `docs` and `docs/a.md`. Such a tree cannot be written to a file system. Upload does not enforce this yet (see [Known gaps](#known-gaps-2026-10-24)). +**No file/folder conflicts:** a tree must not contain a path that is also a folder of another path, for example `docs` and `docs/a.md`. Such a tree cannot be written to a file system. Upload rejects it with a 400, and mirror sync skips the skill. A tree stored before these checks can still have a conflict; the sweep reports it as `path_conflict`. A client must reject such a tree. ## Algorithm @@ -113,7 +113,14 @@ The manifest digests are the output of step 1, so the tree hash can be computed - Compute the digest of the downloaded bytes. Compare it with the manifest entry for that path. - Compute the tree hash from the manifest digests. Compare it with `tree_hash`. - Stop if either check fails. -4. Record the checked tree hash with the install. The CLI writes it to `.agentvouch.json`. +4. Record the checked tree hash with the install. The CLI writes it to `.agentvouch.json` with `tree_hash_algorithm` and `tree_hash_verified_at`. +5. **An older version:** download `/api/skills/{id}/versions/{version}/archive`, and compare with the `tree_hash` of that entry in the `versions` list of `GET /api/skills/{id}`. The response also carries `X-AgentVouch-Tree-Hash` and `X-AgentVouch-Skill-Version` headers. Do not use those headers as the expected value, because they arrive with the bytes they describe. + +The `agentvouch` CLI runs steps 1 to 4 on every install and update of a skill that AgentVouch stores: + +- It stops before it downloads or pays if the skill has no published `tree_hash`, or (for one file) no manifest entry for that file. +- If a check fails after the download, it re-reads the skill once, because a new version may have been published during the download. Then it stops without writing anything. +- Chain-only skills (ids that start with `chain-`) are not stored by AgentVouch and have no tree hash, so the CLI cannot check them. Note: `/raw` serves `SKILL.md` from `skill_versions.content` for every storage type. The stored tree must therefore hold the same `SKILL.md` bytes. The sweep below checks this. @@ -180,7 +187,7 @@ Finding codes: | `missing_manifest` | The version has no stored manifest | | `invalid_manifest` | The manifest is not an array of valid entries | | `manifest_tree_hash_mismatch` | The manifest digests give a different tree hash | -| `files_unavailable` | The stored bytes could not be read: for example a missing Blob archive, or a tree above the upload limits (see Known gaps) | +| `files_unavailable` | The stored bytes could not be read (for example, a missing Blob archive) | | `bytes_tree_hash_mismatch` | The stored bytes give a different tree hash | | `file_digest_mismatch` | A file's bytes do not match its manifest digest | | `file_size_mismatch` | A file's length does not match its manifest size | @@ -196,9 +203,13 @@ Finding codes: A change to any step is a new algorithm with a new name, for example `agentvouch-tree-v2`. Stored hashes keep the version that made them. Never change `agentvouch-tree-v1` in place. -## Known gaps (2026-10-24) +## Known gaps (updated 2026-11-04) -- Upload does not reject a tree where one path is also a folder of another path. The sweep reports such trees as `path_conflict`. The fix is planned with the CLI download check (Phase 1, second PR). -- The CLI records `tree_hash` but does not check downloads against it yet (Phase 1, second PR). - The `/zip` builder sorts entries with `localeCompare`, which depends on the server's ICU data. Zip bytes can differ between servers, so do not pin zip bytes. The tree hash does not depend on archive bytes. -- The stored-tar reader (`ingestTarArchive`) enforces the upload limits (200 files, 5 MB), but mirror sync stores trees up to 512 files and 8 MB. `/raw?path=` and `/zip` use that reader, so they cannot serve a mirrored tree above the upload limits. The sweep reports such a version as `files_unavailable` with the limit message. `/archive` is not affected, because it serves the stored tar bytes directly. + +Fixed in Phase 1, second PR (2026-11-04): + +- Upload now rejects a tree where one path is also a folder of another path, and mirror sync skips such a skill. +- The CLI now checks every download against the published hashes before it writes. +- Stored trees are now read with the mirror limits (512 files, 8 MB) instead of the upload limits (200 files, 5 MB). Before this, `/raw?path=`, `/zip`, and CLI tree installs failed for a mirrored tree above the upload limits. The CLI also reads downloads with the mirror limits. +- The CLI now hashes and writes the exact bytes of `/raw` responses. Before, it decoded them as text, which drops a leading byte order mark, so a written `SKILL.md` could differ from the published bytes. diff --git a/packages/agentvouch-cli/README.md b/packages/agentvouch-cli/README.md index f4626d1e..4af5e82a 100644 --- a/packages/agentvouch-cli/README.md +++ b/packages/agentvouch-cli/README.md @@ -103,6 +103,27 @@ agentvouch check "$SKILL_DIR" --fail-on avoid || { Text files only: the CLI sends each file as UTF-8, so the `tree_hash` of a skill that has binary assets can differ from the published hash. +### Download checks + +`skill install` and `skills update` check each download before they write +anything: + +- For a skill folder, the CLI recomputes the tree hash (`agentvouch-tree-v1`, + see [docs/SKILL_TREE_HASH.md](https://github.com/dirtybits/agentvouch/blob/main/docs/SKILL_TREE_HASH.md)) + and compares it with the published `tree_hash`. +- For a single `SKILL.md`, it compares the file's SHA-256 with the published + file list, and checks that list against `tree_hash`. +- If the skill has no published tree hash, the CLI stops before it downloads or + pays. +- If a check fails after the download, the CLI writes nothing and exits with an + error. For a paid skill your access is kept, so you can run the command again. +- On success, `.agentvouch.json` records `tree_hash`, + `tree_hash_algorithm`, and `tree_hash_verified_at`. + +Chain-only skills (ids that start with `chain-`) have no tree hash, so the CLI +cannot check them. A matching hash proves that the bytes did not change. It +does not prove that the skill is safe. + ### Agents ```bash diff --git a/packages/agentvouch-cli/src/cli.ts b/packages/agentvouch-cli/src/cli.ts index 21990c0a..a500f786 100644 --- a/packages/agentvouch-cli/src/cli.ts +++ b/packages/agentvouch-cli/src/cli.ts @@ -422,6 +422,9 @@ export function buildProgram(): Command { ...(typeof result.filesWritten === "number" ? [`files_written: ${result.filesWritten}`] : []), + ...(result.treeHash + ? [`tree_hash_verified: ${result.treeHash}`] + : []), ...(result.dryRun ? ["dry_run: true"] : []), ] ); diff --git a/packages/agentvouch-cli/src/lib/archive.ts b/packages/agentvouch-cli/src/lib/archive.ts index c32b335a..c8dc9642 100644 --- a/packages/agentvouch-cli/src/lib/archive.ts +++ b/packages/agentvouch-cli/src/lib/archive.ts @@ -7,10 +7,31 @@ const MAX_SKILL_CONTENT_BYTES = 256 * 1024; const MAX_SKILL_FILE_BYTES = 1024 * 1024; const MAX_SKILL_TREE_BYTES = 5 * 1024 * 1024; const MAX_SKILL_TREE_FILES = 200; +// AgentVouch stores mirrored trees up to these limits +// (web/lib/skillDraft.ts MIRROR_MAX_SKILL_TREE_*), so downloads may be this large. +const MAX_DOWNLOADED_TREE_BYTES = 8 * 1024 * 1024; +const MAX_DOWNLOADED_TREE_FILES = 512; const TAR_BLOCK_SIZE = 512; const SKIP_DIRECTORY_NAMES = new Set([".git", "node_modules"]); const SKIP_FILE_NAMES = new Set([".DS_Store"]); +export interface TreeCaps { + maxFiles: number; + maxBytes: number; +} + +/** Limits for trees the CLI publishes. */ +export const UPLOAD_TREE_CAPS: TreeCaps = { + maxFiles: MAX_SKILL_TREE_FILES, + maxBytes: MAX_SKILL_TREE_BYTES, +}; + +/** Limits for trees the CLI downloads. */ +export const DOWNLOAD_TREE_CAPS: TreeCaps = { + maxFiles: MAX_DOWNLOADED_TREE_FILES, + maxBytes: MAX_DOWNLOADED_TREE_BYTES, +}; + export interface SkillTreeInputFile { path: string; content: Buffer; @@ -42,14 +63,15 @@ function normalizeSkillPath(filePath: string): string { } function toNormalizedFiles( - files: SkillTreeInputFile[] + files: SkillTreeInputFile[], + caps: TreeCaps = UPLOAD_TREE_CAPS ): Array { if (files.length === 0) { throw new CliError("Skill tree must include SKILL.md."); } - if (files.length > MAX_SKILL_TREE_FILES) { + if (files.length > caps.maxFiles) { throw new CliError( - `Skill tree has ${files.length} files, exceeds cap of ${MAX_SKILL_TREE_FILES}.` + `Skill tree has ${files.length} files, exceeds cap of ${caps.maxFiles}.` ); } @@ -71,9 +93,9 @@ function toNormalizedFiles( ); } totalBytes += bytes.byteLength; - if (totalBytes > MAX_SKILL_TREE_BYTES) { + if (totalBytes > caps.maxBytes) { throw new CliError( - `Skill tree is ${totalBytes} bytes, exceeds cap of ${MAX_SKILL_TREE_BYTES} bytes.` + `Skill tree is ${totalBytes} bytes, exceeds cap of ${caps.maxBytes} bytes.` ); } @@ -196,7 +218,10 @@ function isZeroBlock(block: Buffer): boolean { return block.every((byte) => byte === 0); } -export function ingestTarArchive(bytes: Buffer): SkillTreeInputFile[] { +export function ingestTarArchive( + bytes: Buffer, + caps: TreeCaps = UPLOAD_TREE_CAPS +): SkillTreeInputFile[] { const files: SkillTreeInputFile[] = []; let offset = 0; let totalBytes = 0; @@ -216,14 +241,12 @@ export function ingestTarArchive(bytes: Buffer): SkillTreeInputFile[] { const filePath = normalizeSkillPath(prefix ? `${prefix}/${name}` : name); const size = readOctal(header, 124, 12); totalBytes += size; - if (files.length + 1 > MAX_SKILL_TREE_FILES) { - throw new CliError( - `Skill tree exceeds cap of ${MAX_SKILL_TREE_FILES} files.` - ); + if (files.length + 1 > caps.maxFiles) { + throw new CliError(`Skill tree exceeds cap of ${caps.maxFiles} files.`); } - if (totalBytes > MAX_SKILL_TREE_BYTES) { + if (totalBytes > caps.maxBytes) { throw new CliError( - `Skill tree is ${totalBytes} bytes, exceeds cap of ${MAX_SKILL_TREE_BYTES} bytes.` + `Skill tree is ${totalBytes} bytes, exceeds cap of ${caps.maxBytes} bytes.` ); } if (offset + size > bytes.byteLength) { @@ -238,7 +261,7 @@ export function ingestTarArchive(bytes: Buffer): SkillTreeInputFile[] { offset += (TAR_BLOCK_SIZE - (size % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE; } - return toNormalizedFiles(files); + return toNormalizedFiles(files, caps); } /** @@ -290,12 +313,11 @@ export async function prepareSkillUploadFromPath( }; } -export async function writeTarArchiveToDirectory( +/** Write files that were already parsed (and checked) to a folder. */ +export async function writeSkillFilesToDirectory( outputDir: string, - archive: Buffer + files: SkillTreeInputFile[] ): Promise { - const files = ingestTarArchive(archive); - for (const file of files) { const targetPath = path.join(outputDir, ...file.path.split("/")); await mkdir(path.dirname(targetPath), { recursive: true }); @@ -305,6 +327,16 @@ export async function writeTarArchiveToDirectory( return files.length; } +export async function writeTarArchiveToDirectory( + outputDir: string, + archive: Buffer +): Promise { + return writeSkillFilesToDirectory( + outputDir, + ingestTarArchive(archive, DOWNLOAD_TREE_CAPS) + ); +} + export function isDirectoryLikeOutput(outputPath: string): boolean { return outputPath.endsWith(path.sep) || path.extname(outputPath) === ""; } diff --git a/packages/agentvouch-cli/src/lib/fs.ts b/packages/agentvouch-cli/src/lib/fs.ts index 7e8d02a0..583bc97e 100644 --- a/packages/agentvouch-cli/src/lib/fs.ts +++ b/packages/agentvouch-cli/src/lib/fs.ts @@ -34,3 +34,12 @@ export async function writeUtf8File( await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, contents, "utf8"); } + +/** Write bytes exactly as given (no text decoding or encoding). */ +export async function writeBytesFile( + filePath: string, + contents: Uint8Array +): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents); +} diff --git a/packages/agentvouch-cli/src/lib/http.ts b/packages/agentvouch-cli/src/lib/http.ts index 03dc25b5..90d26f7d 100644 --- a/packages/agentvouch-cli/src/lib/http.ts +++ b/packages/agentvouch-cli/src/lib/http.ts @@ -193,6 +193,8 @@ export interface DownloadResponse { ok: boolean; status: number; content?: string; + /** Exact response bytes for /raw. Hash these, not the decoded text. */ + bytes?: Buffer; archive?: Buffer; error?: string; requirement?: PaymentRequirement; @@ -511,10 +513,14 @@ export class AgentVouchApiClient { : undefined; if (response.ok) { + // Read bytes, not response.text(): text() drops a leading byte order + // mark, which would change the file hash. + const bytes = Buffer.from(await response.arrayBuffer()); return { ok: true, status: response.status, - content: await response.text(), + content: bytes.toString("utf8"), + bytes, paymentResponse, }; } diff --git a/packages/agentvouch-cli/src/lib/install.ts b/packages/agentvouch-cli/src/lib/install.ts index 61c5b49d..25869e47 100644 --- a/packages/agentvouch-cli/src/lib/install.ts +++ b/packages/agentvouch-cli/src/lib/install.ts @@ -4,7 +4,11 @@ import { wrapFetchWithPayment, x402Client, x402HTTPClient } from "@x402/fetch"; import { ExactSvmScheme, toClientSvmSigner } from "@x402/svm"; import type { AuthPayload } from "@agentvouch/protocol"; import { CliError } from "./errors.js"; -import { assertWritableOutputPath, writeUtf8File } from "./fs.js"; +import { + assertWritableOutputPath, + writeBytesFile, + writeUtf8File, +} from "./fs.js"; import { type SkillRecord, AgentVouchApiClient } from "./http.js"; import { buildInstalledSkillMetadata, @@ -14,9 +18,17 @@ import { import { createDownloadAuthPayload, loadKeypair } from "./signer.js"; import { AgentVouchSolanaClient } from "./solana.js"; import { + DOWNLOAD_TREE_CAPS, + ingestTarArchive, isDirectoryLikeOutput, - writeTarArchiveToDirectory, + type SkillTreeInputFile, + writeSkillFilesToDirectory, } from "./archive.js"; +import { + TreeHashMismatchError, + verifySingleFile, + verifyTreeFiles, +} from "./integrity.js"; export interface InstallSkillInput { id: string; @@ -69,23 +81,138 @@ function hasDownloadPayload( : download.content !== undefined; } -async function writeDownloadOutput( +type DownloadResult = Awaited>; + +type VerifiedDownload = + | { kind: "archive"; files: SkillTreeInputFile[]; treeHash: string } + | { kind: "raw"; bytes: Buffer; treeHash: string }; + +/** + * Stop before any download or payment when the published record has nothing + * to check the download against. Otherwise a paid install could take the + * payment and then refuse to write the files. + */ +function assertDownloadCanBeVerified( kind: InstallKind, - outputPath: string, - download: Awaited> -): Promise { + skill: SkillRecord, + id: string +) { + if (!skill.tree_hash) { + throw new CliError( + `Skill ${id} has no published tree_hash, so the CLI cannot check the download. Nothing was downloaded or paid.` + ); + } + if ( + kind === "raw" && + !skill.files?.some((file) => file.path === "SKILL.md") + ) { + throw new CliError( + `The published file list for skill ${id} has no entry for SKILL.md, so the CLI cannot check the download. Nothing was downloaded or paid.` + ); + } +} + +/** Check downloaded bytes against the published hashes (docs/SKILL_TREE_HASH.md). */ +function verifyDownloadAgainst( + kind: InstallKind, + download: DownloadResult, + skill: SkillRecord, + label: string +): VerifiedDownload { if (kind === "archive") { if (!download.archive) { throw new CliError("Archive download did not include an archive body."); } - return writeTarArchiveToDirectory(outputPath, download.archive); + const files = ingestTarArchive(download.archive, DOWNLOAD_TREE_CAPS); + const treeHash = verifyTreeFiles( + files.map((file) => ({ path: file.path, bytes: file.content })), + { treeHash: skill.tree_hash, label } + ); + return { kind, files, treeHash }; } - if (download.content === undefined) { + const bytes = + download.bytes ?? + (download.content !== undefined + ? Buffer.from(download.content, "utf8") + : undefined); + if (!bytes) { throw new CliError("Raw download did not include skill content."); } - await writeUtf8File(outputPath, download.content); - return null; + const treeHash = verifySingleFile( + { path: "SKILL.md", bytes }, + { treeHash: skill.tree_hash, manifest: skill.files, label } + ); + return { kind, bytes, treeHash }; +} + +/** + * Verify a download. If it does not match, re-read the skill once: a new version + * may have been published between the detail read and the download. + */ +async function verifyDownload( + api: AgentVouchApiClient, + id: string, + kind: InstallKind, + download: DownloadResult, + skill: SkillRecord +): Promise<{ verified: VerifiedDownload; skill: SkillRecord }> { + try { + return { + verified: verifyDownloadAgainst(kind, download, skill, id), + skill, + }; + } catch (error) { + if (!(error instanceof TreeHashMismatchError)) throw error; + const latest = await api.getSkill(id); + if (!latest.tree_hash || latest.tree_hash === skill.tree_hash) throw error; + return { + verified: verifyDownloadAgainst(kind, download, latest, id), + skill: latest, + }; + } +} + +/** Verify, then write the files and the install metadata (not in a dry run). */ +async function completeDownload( + context: { + api: AgentVouchApiClient; + id: string; + kind: InstallKind; + outputPath: string; + dryRun: boolean; + skill: SkillRecord; + }, + download: DownloadResult +): Promise<{ filesWritten: number | null; treeHash: string }> { + const { verified, skill } = await verifyDownload( + context.api, + context.id, + context.kind, + download, + context.skill + ); + if (context.dryRun) { + return { filesWritten: null, treeHash: verified.treeHash }; + } + + let filesWritten: number | null = null; + if (verified.kind === "archive") { + filesWritten = await writeSkillFilesToDirectory( + context.outputPath, + verified.files + ); + } else { + await writeBytesFile(context.outputPath, verified.bytes); + } + await writeInstalledSkillMetadata( + context.outputPath, + buildInstalledSkillMetadata(context.id, skill, { + installedFormat: context.kind === "archive" ? "tree" : "file", + verifiedTreeHash: verified.treeHash, + }) + ); + return { filesWritten, treeHash: verified.treeHash }; } async function resolveChainSkillContent( @@ -183,6 +310,8 @@ export async function installSkill(input: InstallSkillInput) { }; } + assertDownloadCanBeVerified(installKind, skill, input.id); + const signedInitialAuth = input.keypairPath && BigInt(skill.price_usdc_micros ?? "0") > 0n ? createDownloadAuthPayload( @@ -197,18 +326,19 @@ export async function installSkill(input: InstallSkillInput) { input.id, signedInitialAuth ? { auth: signedInitialAuth } : undefined ); + const downloadContext = { + api, + id: input.id, + kind: installKind, + outputPath, + skill, + }; + if (initialDownload.ok && hasDownloadPayload(installKind, initialDownload)) { - const filesWritten = input.dryRun - ? null - : await writeDownloadOutput(installKind, outputPath, initialDownload); - if (!input.dryRun) { - await writeInstalledSkillMetadata( - outputPath, - buildInstalledSkillMetadata(input.id, skill, { - installedFormat: installKind === "archive" ? "tree" : "file", - }) - ); - } + const { filesWritten, treeHash } = await completeDownload( + { ...downloadContext, dryRun: !!input.dryRun }, + initialDownload + ); return { ok: true, mode: installKind === "archive" ? "free-archive" : "free-raw", @@ -217,6 +347,7 @@ export async function installSkill(input: InstallSkillInput) { metadataPath, priceUsdcMicros: skill.price_usdc_micros ?? null, filesWritten, + treeHash, dryRun: !!input.dryRun, }; } @@ -289,17 +420,10 @@ export async function installSkill(input: InstallSkillInput) { }); if (signedDownload.ok && hasDownloadPayload(installKind, signedDownload)) { - const filesWritten = await writeDownloadOutput( - installKind, - outputPath, + const { filesWritten, treeHash } = await completeDownload( + { ...downloadContext, dryRun: false }, signedDownload ); - await writeInstalledSkillMetadata( - outputPath, - buildInstalledSkillMetadata(input.id, skill, { - installedFormat: installKind === "archive" ? "tree" : "file", - }) - ); return { ok: true, @@ -317,6 +441,7 @@ export async function installSkill(input: InstallSkillInput) { listingAddress: null, alreadyPurchased: true, filesWritten, + treeHash, dryRun: false, }; } @@ -358,17 +483,10 @@ export async function installSkill(input: InstallSkillInput) { ); } - const filesWritten = await writeDownloadOutput( - installKind, - outputPath, + const { filesWritten, treeHash } = await completeDownload( + { ...downloadContext, dryRun: false }, paidDownload ); - await writeInstalledSkillMetadata( - outputPath, - buildInstalledSkillMetadata(input.id, skill, { - installedFormat: installKind === "archive" ? "tree" : "file", - }) - ); return { ok: true, @@ -386,6 +504,7 @@ export async function installSkill(input: InstallSkillInput) { purchaseTx: paidDownload.paymentResponse?.transaction ?? null, alreadyPurchased: !paidDownload.paymentResponse, filesWritten, + treeHash, dryRun: false, }; } @@ -439,17 +558,10 @@ export async function installSkill(input: InstallSkillInput) { ); } - const filesWritten = await writeDownloadOutput( - installKind, - outputPath, + const { filesWritten, treeHash } = await completeDownload( + { ...downloadContext, dryRun: false }, signedDownload ); - await writeInstalledSkillMetadata( - outputPath, - buildInstalledSkillMetadata(input.id, skill, { - installedFormat: installKind === "archive" ? "tree" : "file", - }) - ); return { ok: true, @@ -466,6 +578,7 @@ export async function installSkill(input: InstallSkillInput) { purchaseTx: purchase.tx, alreadyPurchased: purchase.alreadyPurchased, filesWritten, + treeHash, dryRun: false, }; } diff --git a/packages/agentvouch-cli/src/lib/integrity.ts b/packages/agentvouch-cli/src/lib/integrity.ts new file mode 100644 index 00000000..61fdfcae --- /dev/null +++ b/packages/agentvouch-cli/src/lib/integrity.ts @@ -0,0 +1,138 @@ +import { createHash } from "node:crypto"; +import { CliError } from "./errors.js"; + +// Skill tree hash, algorithm agentvouch-tree-v1 (docs/SKILL_TREE_HASH.md). +// The CLI checks every download against the published tree hash before it +// writes any file. The shared vectors in +// packages/agentvouch-protocol/test-vectors/tree-hash.json pin this code to the +// web implementation and to an independent Python reference. + +export const TREE_HASH_ALGORITHM = "agentvouch-tree-v1"; + +export interface TreeDigestEntry { + path: string; + sha256: string; +} + +export interface TreeFileBytes { + path: string; + bytes: Uint8Array; +} + +export interface ExpectedTree { + /** tree_hash from GET /api/skills/{id} */ + treeHash: string | null | undefined; + /** files (the manifest) from GET /api/skills/{id} */ + manifest?: ReadonlyArray | null; + /** Skill id for error messages. */ + label: string; +} + +/** The downloaded bytes do not match the published hashes. */ +export class TreeHashMismatchError extends CliError {} + +const NOTHING_WRITTEN = + "Nothing was written. If this was a paid install, your access is kept; run the command again to retry the download."; + +export function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Steps 2-6 of the algorithm: entries, UTF-16 code unit sort, LF join, SHA-256. */ +export function treeHashFromDigests( + entries: ReadonlyArray +): string { + const lines = entries.map((entry) => `${entry.path}\0${entry.sha256}`).sort(); + return createHash("sha256").update(lines.join("\n")).digest("hex"); +} + +export function treeHashFromFiles(files: ReadonlyArray): string { + return treeHashFromDigests( + files.map((file) => ({ path: file.path, sha256: sha256Hex(file.bytes) })) + ); +} + +/** Pairs where one path is also a folder of another ("docs" and "docs/a.md"). */ +export function findPathConflicts( + paths: readonly string[] +): Array<{ path: string; folderOf: string }> { + const seen = new Set(paths); + const conflicts: Array<{ path: string; folderOf: string }> = []; + for (const filePath of seen) { + const parts = filePath.split("/"); + for (let index = 1; index < parts.length; index += 1) { + const prefix = parts.slice(0, index).join("/"); + if (seen.has(prefix)) { + conflicts.push({ path: prefix, folderOf: filePath }); + } + } + } + return conflicts; +} + +function requireTreeHash(expected: ExpectedTree): string { + if (!expected.treeHash) { + throw new CliError( + `Skill ${expected.label} has no published tree_hash, so the CLI cannot check the download. Nothing was written.` + ); + } + return expected.treeHash; +} + +/** Check a full tree download. Returns the verified tree hash. */ +export function verifyTreeFiles( + files: ReadonlyArray, + expected: ExpectedTree +): string { + const treeHash = requireTreeHash(expected); + const [conflict] = findPathConflicts(files.map((file) => file.path)); + if (conflict) { + throw new CliError( + `Skill ${expected.label} contains "${conflict.path}" as both a file and a folder (${conflict.folderOf}), so it cannot be written to disk. Nothing was written.` + ); + } + const actual = treeHashFromFiles(files); + if (actual !== treeHash) { + throw new TreeHashMismatchError( + `Downloaded files for skill ${expected.label} do not match the published tree hash.\n expected ${treeHash}\n received ${actual}\n${NOTHING_WRITTEN}` + ); + } + return treeHash; +} + +/** + * Check one downloaded file: its digest must match the manifest entry, and the + * manifest must produce the published tree hash. Returns the verified tree hash. + */ +export function verifySingleFile( + file: TreeFileBytes, + expected: ExpectedTree +): string { + const treeHash = requireTreeHash(expected); + if (!expected.manifest || expected.manifest.length === 0) { + throw new CliError( + `Skill ${expected.label} has no published file list, so the CLI cannot check ${file.path}. Nothing was written.` + ); + } + const entry = expected.manifest.find( + (candidate) => candidate.path === file.path + ); + if (!entry) { + throw new CliError( + `The published file list for skill ${expected.label} has no entry for ${file.path}. Nothing was written.` + ); + } + const digest = sha256Hex(file.bytes); + if (digest !== entry.sha256) { + throw new TreeHashMismatchError( + `Downloaded ${file.path} for skill ${expected.label} does not match the published file hash.\n expected ${entry.sha256}\n received ${digest}\n${NOTHING_WRITTEN}` + ); + } + const manifestTreeHash = treeHashFromDigests(expected.manifest); + if (manifestTreeHash !== treeHash) { + throw new TreeHashMismatchError( + `The published file list for skill ${expected.label} does not match its published tree hash.\n expected ${treeHash}\n file list gives ${manifestTreeHash}\n${NOTHING_WRITTEN}` + ); + } + return treeHash; +} diff --git a/packages/agentvouch-cli/src/lib/metadata.ts b/packages/agentvouch-cli/src/lib/metadata.ts index 640cbb86..87132125 100644 --- a/packages/agentvouch-cli/src/lib/metadata.ts +++ b/packages/agentvouch-cli/src/lib/metadata.ts @@ -1,6 +1,7 @@ import { CliError } from "./errors.js"; import { readUtf8File, writeUtf8File } from "./fs.js"; import type { SkillRecord } from "./http.js"; +import { TREE_HASH_ALGORITHM } from "./integrity.js"; export const INSTALL_METADATA_SCHEMA_VERSION = 1; @@ -18,6 +19,9 @@ export interface InstalledSkillMetadata { author_handle?: string | null; publisher_tier?: string | null; tree_hash?: string | null; + /** Set when the CLI checked the downloaded bytes against tree_hash. */ + tree_hash_algorithm?: string; + tree_hash_verified_at?: string; historical_sol_price_base_units?: number; /** * Legacy alias for historical_sol_price_base_units. New installs write the @@ -37,8 +41,9 @@ export function getInstallMetadataPath(skillFilePath: string): string { export function buildInstalledSkillMetadata( installedSkillId: string, skill: SkillRecord, - options: { installedFormat?: "file" | "tree" } = {} + options: { installedFormat?: "file" | "tree"; verifiedTreeHash?: string } = {} ): InstalledSkillMetadata { + const installedAt = new Date().toISOString(); return { schema_version: INSTALL_METADATA_SCHEMA_VERSION, installed_with: "agentvouch-cli", @@ -52,11 +57,17 @@ export function buildInstalledSkillMetadata( author_kind: skill.author_kind ?? null, author_handle: skill.author_handle ?? null, publisher_tier: skill.publisher_tier ?? null, - tree_hash: skill.tree_hash ?? null, + tree_hash: options.verifiedTreeHash ?? skill.tree_hash ?? null, + ...(options.verifiedTreeHash + ? { + tree_hash_algorithm: TREE_HASH_ALGORITHM, + tree_hash_verified_at: installedAt, + } + : {}), historical_sol_price_base_units: skill.price_lamports ?? 0, price_usdc_micros: skill.price_usdc_micros ?? null, currency_mint: skill.currency_mint ?? null, - installed_at: new Date().toISOString(), + installed_at: installedAt, }; } @@ -92,6 +103,10 @@ function isInstalledSkillMetadata( (typeof candidate.tree_hash === "string" || candidate.tree_hash === null || candidate.tree_hash === undefined) && + (typeof candidate.tree_hash_algorithm === "string" || + candidate.tree_hash_algorithm === undefined) && + (typeof candidate.tree_hash_verified_at === "string" || + candidate.tree_hash_verified_at === undefined) && (typeof candidate.historical_sol_price_base_units === "number" || typeof (candidate as { price_lamports?: unknown }).price_lamports === "number" || diff --git a/packages/agentvouch-cli/test/archive.test.ts b/packages/agentvouch-cli/test/archive.test.ts index f4093cbf..53f43236 100644 --- a/packages/agentvouch-cli/test/archive.test.ts +++ b/packages/agentvouch-cli/test/archive.test.ts @@ -4,8 +4,10 @@ import os from "node:os"; import path from "node:path"; import { buildTarArchive, + DOWNLOAD_TREE_CAPS, ingestTarArchive, isDirectoryLikeOutput, + writeSkillFilesToDirectory, writeTarArchiveToDirectory, } from "../src/lib/archive.js"; import { CliError } from "../src/lib/errors.js"; @@ -193,3 +195,77 @@ describe("isDirectoryLikeOutput", () => { expect(isDirectoryLikeOutput("skills.tar")).toBe(false); }); }); + +describe("download limits", () => { + function tree(count: number): SkillTreeInputFile[] { + const files = [{ path: "SKILL.md", content: Buffer.from("# big\n") }]; + for (let index = 1; index < count; index += 1) { + files.push({ + path: `references/part-${String(index).padStart(3, "0")}.md`, + content: Buffer.from(`part ${index}\n`), + }); + } + return files; + } + + it("keeps the 200-file upload limit for publishing", () => { + expect(() => buildTarArchive(tree(201))).toThrow("exceeds cap of 200"); + }); + + // Minimal ustar writer for this test only: it has no file-count limit, so it + // can model a stored mirror tree that is larger than the upload limit. + function tarOf(files: SkillTreeInputFile[]): Buffer { + const blocks: Buffer[] = []; + for (const entry of files) { + const header = Buffer.alloc(512); + header.write(entry.path, 0, 100, "utf8"); + header.write("0000644\0", 100, 8, "ascii"); + header.write("0000000\0", 108, 8, "ascii"); + header.write("0000000\0", 116, 8, "ascii"); + header.write( + entry.content.byteLength.toString(8).padStart(11, "0") + "\0", + 124, + 12, + "ascii" + ); + header.write("00000000000\0", 136, 12, "ascii"); + header.fill(" ", 148, 156); + header.write("0", 156, 1, "ascii"); + header.write("ustar", 257, 6, "ascii"); + header.write("00", 263, 2, "ascii"); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(checksum.toString(8).padStart(6, "0"), 148, 6, "ascii"); + header[154] = 0; + header[155] = 0x20; + const padding = (512 - (entry.content.byteLength % 512)) % 512; + blocks.push(header, entry.content, Buffer.alloc(padding)); + } + blocks.push(Buffer.alloc(1024)); + return Buffer.concat(blocks); + } + + it("reads downloaded trees up to the 512-file mirror limit", () => { + const archive = tarOf(tree(300)); + + expect(() => ingestTarArchive(archive)).toThrow("exceeds cap of 200"); + expect(ingestTarArchive(archive, DOWNLOAD_TREE_CAPS)).toHaveLength(300); + expect(DOWNLOAD_TREE_CAPS).toEqual({ + maxFiles: 512, + maxBytes: 8 * 1024 * 1024, + }); + }); + + it("writes already-checked files with writeSkillFilesToDirectory", async () => { + const outputDir = await mkdtemp( + path.join(os.tmpdir(), "agentvouch-write-") + ); + const count = await writeSkillFilesToDirectory(outputDir, [ + { path: "SKILL.md", content: Buffer.from("# skill\n") }, + { path: "scripts/run.sh", content: Buffer.from("echo hi\n") }, + ]); + expect(count).toBe(2); + expect( + await readFile(path.join(outputDir, "scripts", "run.sh"), "utf8") + ).toBe("echo hi\n"); + }); +}); diff --git a/packages/agentvouch-cli/test/install.test.ts b/packages/agentvouch-cli/test/install.test.ts index fb3926dc..599ba124 100644 --- a/packages/agentvouch-cli/test/install.test.ts +++ b/packages/agentvouch-cli/test/install.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Keypair } from "@solana/web3.js"; @@ -7,6 +7,30 @@ import { AgentVouchApiClient } from "../src/lib/http.js"; import { installSkill } from "../src/lib/install.js"; import { AgentVouchSolanaClient } from "../src/lib/solana.js"; import { buildTarArchive } from "../src/lib/archive.js"; +import { sha256Hex, treeHashFromFiles } from "../src/lib/integrity.js"; + +/** The manifest and tree hash the API publishes for these files. */ +function published(files: Record) { + const entries = Object.entries(files).map(([filePath, text]) => ({ + path: filePath, + bytes: Buffer.from(text, "utf8"), + })); + return { + files: entries.map((entry) => ({ + path: entry.path, + size: entry.bytes.byteLength, + sha256: sha256Hex(entry.bytes), + })), + tree_hash: treeHashFromFiles(entries), + }; +} + +async function exists(filePath: string): Promise { + return access(filePath).then( + () => true, + () => false + ); +} async function createKeypairFile() { const tempDir = await mkdtemp(path.join(os.tmpdir(), "agentvouch-cli-")); @@ -33,6 +57,7 @@ describe("installSkill", () => { description: "Free skill", on_chain_address: null, total_installs: 0, + ...published({ "SKILL.md": "# free skill\n" }), }); vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw").mockResolvedValue({ ok: true, @@ -59,7 +84,13 @@ describe("installSkill", () => { skill_id: "595f5534-07ae-4839-a45a-b6858ab731fe", source: "repo", installed_version: 1, + tree_hash: published({ "SKILL.md": "# free skill\n" }).tree_hash, + tree_hash_algorithm: "agentvouch-tree-v1", }); + expect(typeof metadata.tree_hash_verified_at).toBe("string"); + expect(result.treeHash).toBe( + published({ "SKILL.md": "# free skill\n" }).tree_hash + ); }); it("installs multi-file repo skills as a directory archive", async () => { @@ -78,19 +109,10 @@ describe("installSkill", () => { description: "Tree skill", on_chain_address: null, total_installs: 0, - files: [ - { - path: "SKILL.md", - size: 13, - sha256: "sha-skill", - }, - { - path: "scripts/run.mjs", - size: 18, - sha256: "sha-script", - }, - ], - tree_hash: "tree-hash", + ...published({ + "SKILL.md": "# tree skill\n", + "scripts/run.mjs": "export default 1;\n", + }), }); const rawSpy = vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw"); vi.spyOn( @@ -122,7 +144,11 @@ describe("installSkill", () => { const metadata = JSON.parse(await readFile(result.metadataPath, "utf8")); expect(metadata).toMatchObject({ installed_format: "tree", - tree_hash: "tree-hash", + tree_hash: published({ + "SKILL.md": "# tree skill\n", + "scripts/run.mjs": "export default 1;\n", + }).tree_hash, + tree_hash_algorithm: "agentvouch-tree-v1", }); }); @@ -164,6 +190,7 @@ describe("installSkill", () => { total_installs: 0, price_usdc_micros: "1000000", currency_mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + ...published({ "SKILL.md": "# paid skill\n" }), }); const purchaseSpy = vi .spyOn(AgentVouchSolanaClient.prototype, "purchaseSkill") @@ -211,6 +238,7 @@ describe("installSkill", () => { price_usdc_micros: "1000000", currency_mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", payment_flow: "listing-required", + ...published({ "SKILL.md": "# paid skill\n" }), }); vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw") .mockResolvedValueOnce({ @@ -260,6 +288,7 @@ describe("installSkill", () => { price_usdc_micros: "1000000", currency_mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", payment_flow: "listing-required", + ...published({ "SKILL.md": "# historical paid skill\n" }), }); vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw") .mockResolvedValueOnce({ @@ -345,6 +374,7 @@ describe("installSkill", () => { price_usdc_micros: "10000", currency_mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", payment_flow: "x402-bridge-purchase-skill", + ...published({ "SKILL.md": "# bridge paid skill\n" }), }); const result = await installSkill({ @@ -369,4 +399,220 @@ describe("installSkill", () => { fetchImpl: expect.any(Function), }); }); + describe("tree hash verification", () => { + const SKILL_ID = "595f5534-07ae-4839-a45a-b6858ab731fe"; + + function freeSkill(extra: Record = {}) { + return { + id: SKILL_ID, + skill_id: "calendar-agent", + author_pubkey: Keypair.generate().publicKey.toBase58(), + name: "Calendar Agent", + description: "Free skill", + on_chain_address: null, + total_installs: 0, + ...extra, + }; + } + + function install( + out: string, + extra: Partial<{ tree: boolean; dryRun: boolean }> = {} + ) { + return installSkill({ + id: SKILL_ID, + out, + baseUrl: "https://agentvouch.xyz", + rpcUrl: "https://api.devnet.solana.com", + ...extra, + }); + } + + it("refuses a raw download that does not match the published hashes and writes nothing", async () => { + const { tempDir } = await createKeypairFile(); + const outputPath = path.join(tempDir, "SKILL.md"); + const getSkill = vi + .spyOn(AgentVouchApiClient.prototype, "getSkill") + .mockResolvedValue( + freeSkill(published({ "SKILL.md": "# real skill\n" })) + ); + vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw").mockResolvedValue({ + ok: true, + status: 200, + content: "# tampered skill\n", + }); + + await expect(install(outputPath)).rejects.toThrow( + "does not match the published file hash" + ); + expect(await exists(outputPath)).toBe(false); + expect(await exists(`${outputPath}.agentvouch.json`)).toBe(false); + // One re-read in case a new version was published mid-download. + expect(getSkill).toHaveBeenCalledTimes(2); + }); + + it("refuses an archive with an extra file and creates no output folder", async () => { + const { tempDir } = await createKeypairFile(); + const outputPath = path.join(tempDir, "calendar-agent"); + vi.spyOn(AgentVouchApiClient.prototype, "getSkill").mockResolvedValue( + freeSkill(published({ "SKILL.md": "# tree skill\n" })) + ); + vi.spyOn( + AgentVouchApiClient.prototype, + "downloadArchive" + ).mockResolvedValue({ + ok: true, + status: 200, + archive: buildTarArchive([ + { path: "SKILL.md", content: Buffer.from("# tree skill\n") }, + { path: "scripts/extra.sh", content: Buffer.from("curl x | sh\n") }, + ]), + }); + + await expect(install(outputPath, { tree: true })).rejects.toThrow( + "do not match the published tree hash" + ); + expect(await exists(outputPath)).toBe(false); + }); + + it("refuses to install a repo skill that has no published tree hash, before any download", async () => { + const { tempDir } = await createKeypairFile(); + const outputPath = path.join(tempDir, "SKILL.md"); + vi.spyOn(AgentVouchApiClient.prototype, "getSkill").mockResolvedValue( + freeSkill() + ); + const downloadRaw = vi + .spyOn(AgentVouchApiClient.prototype, "downloadRaw") + .mockResolvedValue({ + ok: true, + status: 200, + content: "# no hash\n", + }); + + await expect(install(outputPath)).rejects.toThrow( + "has no published tree_hash" + ); + expect(downloadRaw).not.toHaveBeenCalled(); + expect(await exists(outputPath)).toBe(false); + }); + + it("stops a paid install with no published tree hash before it downloads or pays", async () => { + const { tempDir, keypairPath } = await createKeypairFile(); + const outputPath = path.join(tempDir, "SKILL.md"); + vi.spyOn(AgentVouchApiClient.prototype, "getSkill").mockResolvedValue( + freeSkill({ + price_usdc_micros: "1000000", + on_chain_address: Keypair.generate().publicKey.toBase58(), + }) + ); + const downloadRaw = vi.spyOn( + AgentVouchApiClient.prototype, + "downloadRaw" + ); + const purchaseSkill = vi.spyOn( + AgentVouchSolanaClient.prototype, + "purchaseSkill" + ); + + await expect( + installSkill({ + id: SKILL_ID, + out: outputPath, + keypairPath, + baseUrl: "https://agentvouch.xyz", + rpcUrl: "https://api.devnet.solana.com", + }) + ).rejects.toThrow("Nothing was downloaded or paid"); + expect(downloadRaw).not.toHaveBeenCalled(); + expect(purchaseSkill).not.toHaveBeenCalled(); + }); + + it("refuses a single-file install when the file list has no SKILL.md entry", async () => { + const { tempDir } = await createKeypairFile(); + const outputPath = path.join(tempDir, "SKILL.md"); + const record = published({ "SKILL.md": "# skill\n" }); + vi.spyOn(AgentVouchApiClient.prototype, "getSkill").mockResolvedValue( + freeSkill({ ...record, files: [] }) + ); + const downloadRaw = vi.spyOn( + AgentVouchApiClient.prototype, + "downloadRaw" + ); + + await expect(install(outputPath)).rejects.toThrow( + "has no entry for SKILL.md" + ); + expect(downloadRaw).not.toHaveBeenCalled(); + }); + + it("accepts a version published between the detail read and the download", async () => { + const { tempDir } = await createKeypairFile(); + const outputPath = path.join(tempDir, "SKILL.md"); + const v2 = published({ "SKILL.md": "# version two\n" }); + vi.spyOn(AgentVouchApiClient.prototype, "getSkill") + .mockResolvedValueOnce( + freeSkill({ + current_version: 1, + ...published({ "SKILL.md": "# version one\n" }), + }) + ) + .mockResolvedValueOnce(freeSkill({ current_version: 2, ...v2 })); + vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw").mockResolvedValue({ + ok: true, + status: 200, + content: "# version two\n", + }); + + const result = await install(outputPath); + + expect(result.treeHash).toBe(v2.tree_hash); + const metadata = JSON.parse(await readFile(result.metadataPath, "utf8")); + expect(metadata).toMatchObject({ + installed_version: 2, + tree_hash: v2.tree_hash, + }); + }); + + it("hashes and writes the exact bytes, including a leading byte order mark", async () => { + const { tempDir } = await createKeypairFile(); + const outputPath = path.join(tempDir, "SKILL.md"); + const text = "\ufeff# bom skill\n"; + vi.spyOn(AgentVouchApiClient.prototype, "getSkill").mockResolvedValue( + freeSkill(published({ "SKILL.md": text })) + ); + vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw").mockResolvedValue({ + ok: true, + status: 200, + // response.text() drops the BOM; the exact bytes keep it. + content: "# bom skill\n", + bytes: Buffer.from(text, "utf8"), + }); + + await install(outputPath); + + const written = await readFile(outputPath); + expect(written.subarray(0, 3)).toEqual(Buffer.from([0xef, 0xbb, 0xbf])); + expect(written).toEqual(Buffer.from(text, "utf8")); + }); + + it("verifies in a dry run without writing anything", async () => { + const { tempDir } = await createKeypairFile(); + const outputPath = path.join(tempDir, "SKILL.md"); + const record = published({ "SKILL.md": "# dry run\n" }); + vi.spyOn(AgentVouchApiClient.prototype, "getSkill").mockResolvedValue( + freeSkill(record) + ); + vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw").mockResolvedValue({ + ok: true, + status: 200, + content: "# dry run\n", + }); + + const result = await install(outputPath, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.treeHash).toBe(record.tree_hash); + expect(await exists(outputPath)).toBe(false); + }); + }); }); diff --git a/packages/agentvouch-cli/test/integrity.test.ts b/packages/agentvouch-cli/test/integrity.test.ts new file mode 100644 index 00000000..1d2f683c --- /dev/null +++ b/packages/agentvouch-cli/test/integrity.test.ts @@ -0,0 +1,198 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { CliError } from "../src/lib/errors.js"; +import { + findPathConflicts, + sha256Hex, + TREE_HASH_ALGORITHM, + TreeHashMismatchError, + treeHashFromDigests, + treeHashFromFiles, + verifySingleFile, + verifyTreeFiles, +} from "../src/lib/integrity.js"; + +// Shared vectors for agentvouch-tree-v1 (docs/SKILL_TREE_HASH.md). The web +// implementation and an independent Python reference pass the same file. + +type VectorFile = { + path: string; + content_utf8?: string; + content_base64?: string; +}; + +type Vector = { + id: string; + files: VectorFile[]; + expected: { + sorted_paths: string[]; + file_sha256: Record; + tree_hash: string; + }; +}; + +const vectorsPath = fileURLToPath( + new URL( + "../../agentvouch-protocol/test-vectors/tree-hash.json", + import.meta.url + ) +); +const document = JSON.parse(readFileSync(vectorsPath, "utf8")) as { + algorithm: string; + vectors: Vector[]; +}; + +function filesOf(vector: Vector) { + return vector.files.map((file) => ({ + path: file.path, + bytes: + file.content_base64 !== undefined + ? Buffer.from(file.content_base64, "base64") + : Buffer.from(file.content_utf8 ?? "", "utf8"), + })); +} + +function manifestOf(files: Array<{ path: string; bytes: Buffer }>) { + return files.map((file) => ({ + path: file.path, + size: file.bytes.byteLength, + sha256: sha256Hex(file.bytes), + })); +} + +const TREE = [ + { path: "SKILL.md", bytes: Buffer.from("# Skill\n") }, + { path: "scripts/run.sh", bytes: Buffer.from("#!/bin/sh\necho hi\n") }, +]; +const TREE_HASH = treeHashFromFiles(TREE); + +describe("tree hash shared vectors (CLI)", () => { + it("uses the same algorithm id as the vectors", () => { + expect(document.algorithm).toBe(TREE_HASH_ALGORITHM); + }); + + it.each(document.vectors.map((vector) => [vector.id, vector] as const))( + "%s: file digests, tree hash from bytes, and tree hash from digests match", + (_id, vector) => { + const files = filesOf(vector); + expect( + Object.fromEntries( + files.map((file) => [file.path, sha256Hex(file.bytes)]) + ) + ).toEqual(vector.expected.file_sha256); + expect(treeHashFromFiles(files)).toBe(vector.expected.tree_hash); + expect(treeHashFromDigests(manifestOf(files))).toBe( + vector.expected.tree_hash + ); + } + ); +}); + +describe("verifyTreeFiles", () => { + it("returns the tree hash for an exact match", () => { + expect(verifyTreeFiles(TREE, { treeHash: TREE_HASH, label: "s1" })).toBe( + TREE_HASH + ); + }); + + it.each([ + [ + "a changed byte", + [TREE[0], { ...TREE[1], bytes: Buffer.from("#!/bin/sh\necho HI\n") }], + ], + [ + "an added file", + [...TREE, { path: "extra.md", bytes: Buffer.from("extra") }], + ], + ["a renamed file", [TREE[0], { ...TREE[1], path: "scripts/other.sh" }]], + ["a missing file", [TREE[0]]], + ])("rejects %s with a mismatch error", (_case, files) => { + expect(() => + verifyTreeFiles(files, { treeHash: TREE_HASH, label: "s1" }) + ).toThrow(TreeHashMismatchError); + }); + + it("refuses to verify without a published tree hash", () => { + const error = (() => { + try { + verifyTreeFiles(TREE, { treeHash: null, label: "s1" }); + } catch (caught) { + return caught; + } + })(); + expect(error).toBeInstanceOf(CliError); + expect(error).not.toBeInstanceOf(TreeHashMismatchError); + expect((error as Error).message).toContain("has no published tree_hash"); + }); + + it("refuses a tree where a path is both a file and a folder", () => { + const files = [ + { path: "SKILL.md", bytes: Buffer.from("# Skill\n") }, + { path: "docs", bytes: Buffer.from("file") }, + { path: "docs/a.md", bytes: Buffer.from("nested") }, + ]; + expect(() => + verifyTreeFiles(files, { + treeHash: treeHashFromFiles(files), + label: "s1", + }) + ).toThrow('contains "docs" as both a file and a folder'); + }); +}); + +describe("verifySingleFile", () => { + const manifest = manifestOf(TREE); + + it("checks one file against the manifest and the manifest against the tree hash", () => { + expect( + verifySingleFile(TREE[0], { treeHash: TREE_HASH, manifest, label: "s1" }) + ).toBe(TREE_HASH); + }); + + it("rejects a file whose bytes do not match its manifest entry", () => { + expect(() => + verifySingleFile( + { path: "SKILL.md", bytes: Buffer.from("# Changed\n") }, + { treeHash: TREE_HASH, manifest, label: "s1" } + ) + ).toThrow("does not match the published file hash"); + }); + + it("rejects a manifest that does not match the tree hash", () => { + expect(() => + verifySingleFile(TREE[0], { + treeHash: "0".repeat(64), + manifest, + label: "s1", + }) + ).toThrow("does not match its published tree hash"); + }); + + it("refuses to verify without a manifest or a manifest entry", () => { + expect(() => + verifySingleFile(TREE[0], { + treeHash: TREE_HASH, + manifest: null, + label: "s1", + }) + ).toThrow("has no published file list"); + expect(() => + verifySingleFile( + { path: "README.md", bytes: Buffer.from("x") }, + { treeHash: TREE_HASH, manifest, label: "s1" } + ) + ).toThrow("has no entry for README.md"); + }); +}); + +describe("findPathConflicts", () => { + it("matches the web rule", () => { + expect(findPathConflicts(["SKILL.md", "a", "a/b", "a/b/c"])).toEqual([ + { path: "a", folderOf: "a/b" }, + { path: "a", folderOf: "a/b/c" }, + { path: "a/b", folderOf: "a/b/c" }, + ]); + expect(findPathConflicts(["SKILL.md", "a.md", "a/b.md"])).toEqual([]); + }); +}); diff --git a/packages/agentvouch-cli/test/metadata.test.ts b/packages/agentvouch-cli/test/metadata.test.ts index 2cddb873..f7693ddd 100644 --- a/packages/agentvouch-cli/test/metadata.test.ts +++ b/packages/agentvouch-cli/test/metadata.test.ts @@ -273,3 +273,28 @@ describe("readInstalledSkillMetadata", () => { ); }); }); + +describe("tree hash verification fields", () => { + it("records the verified hash, algorithm, and time only when verification ran", () => { + const verified = buildInstalledSkillMetadata("skill-1", baseSkill(), { + verifiedTreeHash: "a".repeat(64), + }); + expect(verified.tree_hash).toBe("a".repeat(64)); + expect(verified.tree_hash_algorithm).toBe("agentvouch-tree-v1"); + expect(verified.tree_hash_verified_at).toBe(verified.installed_at); + + const unverified = buildInstalledSkillMetadata("skill-1", baseSkill()); + expect(unverified.tree_hash_algorithm).toBeUndefined(); + expect(unverified.tree_hash_verified_at).toBeUndefined(); + }); + + it("reads back metadata that includes the verification fields", async () => { + await setTempDir(); + const skillPath = path.join(tempDir, "SKILL.md"); + const metadata = buildInstalledSkillMetadata("skill-1", baseSkill(), { + verifiedTreeHash: "b".repeat(64), + }); + await writeInstalledSkillMetadata(skillPath, metadata); + expect(await readInstalledSkillMetadata(skillPath)).toEqual(metadata); + }); +}); diff --git a/packages/agentvouch-cli/test/update.test.ts b/packages/agentvouch-cli/test/update.test.ts index 917d5f6e..43ac14a0 100644 --- a/packages/agentvouch-cli/test/update.test.ts +++ b/packages/agentvouch-cli/test/update.test.ts @@ -7,6 +7,18 @@ import { AgentVouchApiClient } from "../src/lib/http.js"; import { writeInstalledSkillMetadata } from "../src/lib/metadata.js"; import { AgentVouchSolanaClient } from "../src/lib/solana.js"; import { updateSkill } from "../src/lib/update.js"; +import { sha256Hex, treeHashFromFiles } from "../src/lib/integrity.js"; + +/** The manifest and tree hash the API publishes for a one-file skill. */ +function publishedSkillMd(text: string) { + const bytes = Buffer.from(text, "utf8"); + return { + files: [ + { path: "SKILL.md", size: bytes.byteLength, sha256: sha256Hex(bytes) }, + ], + tree_hash: treeHashFromFiles([{ path: "SKILL.md", bytes }]), + }; +} async function createKeypairFile() { const tempDir = await mkdtemp(path.join(os.tmpdir(), "agentvouch-cli-")); @@ -115,6 +127,7 @@ describe("updateSkill", () => { on_chain_address: null, total_installs: 0, price_lamports: 0, + ...publishedSkillMd("# updated\n"), }); vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw").mockResolvedValue({ ok: true, @@ -180,6 +193,7 @@ describe("updateSkill", () => { on_chain_address: "ListingAddr1", total_installs: 0, price_lamports: 1_000_000, + ...publishedSkillMd("# paid updated\n"), }); vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw") .mockResolvedValueOnce({ @@ -268,6 +282,7 @@ describe("updateSkill", () => { on_chain_address: "ListingAddr1", total_installs: 0, price_lamports: 1_000_000, + ...publishedSkillMd("# paid updated\n"), }); vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw") .mockResolvedValueOnce({ @@ -347,6 +362,7 @@ describe("updateSkill", () => { on_chain_address: null, total_installs: 0, price_lamports: 0, + ...publishedSkillMd("# adopted\n"), }); vi.spyOn(AgentVouchApiClient.prototype, "downloadRaw").mockResolvedValue({ ok: true, diff --git a/web/__tests__/api/skills-version-archive.test.ts b/web/__tests__/api/skills-version-archive.test.ts new file mode 100644 index 00000000..765c6a81 --- /dev/null +++ b/web/__tests__/api/skills-version-archive.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/db", () => ({ + sql: vi.fn(), +})); + +vi.mock("@/lib/skillRawAccess", () => ({ + getOptionalDownloadAuthPubkey: vi.fn().mockReturnValue(null), + recordInstallAndDownloadEvent: vi.fn().mockResolvedValue(undefined), + resolveSkillAccess: vi.fn(), +})); + +vi.mock("@/lib/skillStorage", () => ({ + buildArchiveForVersion: vi.fn().mockResolvedValue(Buffer.from("tar-v2")), +})); + +import { GET } from "@/app/api/skills/[id]/versions/[version]/archive/route"; +import { sql } from "@/lib/db"; +import { + recordInstallAndDownloadEvent, + resolveSkillAccess, +} from "@/lib/skillRawAccess"; +import { buildArchiveForVersion } from "@/lib/skillStorage"; + +const mockSql = sql as unknown as ReturnType; +const mockResolveSkillAccess = resolveSkillAccess as unknown as ReturnType< + typeof vi.fn +>; +const mockRecordEvent = recordInstallAndDownloadEvent as unknown as ReturnType< + typeof vi.fn +>; +const mockBuildArchive = buildArchiveForVersion as unknown as ReturnType< + typeof vi.fn +>; + +const SKILL_ID = "8f6c3a52-4c1e-4b1f-9d2a-0f3e5b7c9a11"; +const LATEST = { + id: SKILL_ID, + version_id: "version-3", + version: 3, + content: "# Latest", + files: null, + tree_hash: "c".repeat(64), + storage_backend: "inline", + evm_listing_id: null, + on_chain_address: null, +}; +const PINNED = { + version_id: "version-2", + version: 2, + content: "# Version two", + files: null, + tree_hash: "b".repeat(64), + storage_backend: "inline", +}; + +function request(version: string) { + return new NextRequest( + `http://localhost/api/skills/${SKILL_ID}/versions/${version}/archive`, + { method: "GET" } + ); +} + +function params(version: string) { + return { params: Promise.resolve({ id: SKILL_ID, version }) }; +} + +function mockVersionRows(rows: unknown[]) { + const query = vi.fn().mockResolvedValue(rows); + mockSql.mockReturnValue(query); + return query; +} + +describe("GET /api/skills/[id]/versions/[version]/archive", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBuildArchive.mockResolvedValue(Buffer.from("tar-v2")); + }); + + it.each(["0", "-1", "1.5", "v2", "01", "1234567890"])( + "rejects version %j before any access check", + async (version) => { + const res = await GET(request(version), params(version)); + expect(res.status).toBe(400); + expect(mockResolveSkillAccess).not.toHaveBeenCalled(); + expect(mockSql).not.toHaveBeenCalled(); + } + ); + + it("returns the access decision unchanged when access is denied", async () => { + mockResolveSkillAccess.mockResolvedValue({ + ok: false, + response: NextResponse.json( + { error: "Payment required" }, + { status: 402 } + ), + }); + + const res = await GET(request("2"), params("2")); + + expect(res.status).toBe(402); + expect(mockSql).not.toHaveBeenCalled(); + expect(mockBuildArchive).not.toHaveBeenCalled(); + expect(mockRecordEvent).not.toHaveBeenCalled(); + }); + + it("serves the requested version, not the latest, with its tree hash", async () => { + mockResolveSkillAccess.mockResolvedValue({ ok: true, skill: LATEST }); + const query = mockVersionRows([PINNED]); + + const res = await GET(request("2"), params("2")); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/x-tar"); + expect(res.headers.get("x-agentvouch-tree-hash")).toBe(PINNED.tree_hash); + expect(res.headers.get("x-agentvouch-skill-version")).toBe("2"); + expect(await res.text()).toBe("tar-v2"); + expect(mockBuildArchive).toHaveBeenCalledWith(PINNED); + const values = query.mock.calls[0].slice(1); + expect(values).toEqual([SKILL_ID, 2]); + expect(mockRecordEvent).toHaveBeenCalledWith( + SKILL_ID, + expect.objectContaining({ + kind: "archive", + skillVersionId: "version-2", + skillVersion: 2, + }) + ); + }); + + it("returns 404 when the version does not exist", async () => { + mockResolveSkillAccess.mockResolvedValue({ ok: true, skill: LATEST }); + mockVersionRows([]); + + const res = await GET(request("9"), params("9")); + + expect(res.status).toBe(404); + expect(mockBuildArchive).not.toHaveBeenCalled(); + expect(mockRecordEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/web/__tests__/lib/mirror-sync.test.ts b/web/__tests__/lib/mirror-sync.test.ts index 13cf7d75..f16f65d7 100644 --- a/web/__tests__/lib/mirror-sync.test.ts +++ b/web/__tests__/lib/mirror-sync.test.ts @@ -210,6 +210,24 @@ describe("syncMirrorSkills — change detection", () => { expect(mockPutBlob).not.toHaveBeenCalled(); }); + it("skips a skill whose tree has a file/folder path conflict, without failing the run", async () => { + mockFetchSkillFiles.mockResolvedValue([ + ...makeSkillFiles(), + { path: "docs", content: "a file named docs" }, + { path: "docs/a.md", content: "a file inside docs/" }, + ]); + + const result = await syncMirrorSkills({ apply: true, skipReview: true }); + + expect(result.counts.skip).toBe(1); + expect(result.counts.error).toBe(0); + expect(result.outcomes[0]?.detail).toBe( + 'path conflict: Skill tree path "docs" is both a file and a folder (docs/a.md)' + ); + expect(mockSqlFn).not.toHaveBeenCalled(); + expect(mockPutBlob).not.toHaveBeenCalled(); + }); + it("dry-run (apply:false) reports create without writing to DB or blob", async () => { mockSqlFn.mockResolvedValueOnce([]); // findExistingListing diff --git a/web/__tests__/lib/skillStorage-tree-rules.test.ts b/web/__tests__/lib/skillStorage-tree-rules.test.ts new file mode 100644 index 00000000..5f35c815 --- /dev/null +++ b/web/__tests__/lib/skillStorage-tree-rules.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Blob reads are mocked so getFilesForVersion can read a stored tree offline. +vi.mock("@vercel/blob", () => ({ + get: vi.fn(), + put: vi.fn(), +})); + +import { get, put } from "@vercel/blob"; +import { + assertNoTreePathConflicts, + buildTarArchive, + findTreePathConflicts, + getFileForVersion, + getFilesForVersion, + ingestTarArchive, + prepareSkillTreeForMirror, + putSkillTree, + STORED_TREE_READ_CAPS, +} from "@/lib/skillStorage"; +import { + MIRROR_MAX_SKILL_TREE_BYTES, + MIRROR_MAX_SKILL_TREE_FILES, +} from "@/lib/skillDraft"; + +const mockGet = get as unknown as ReturnType; +const mockPut = put as unknown as ReturnType; + +function manyFiles(count: number) { + const files = [{ path: "SKILL.md", bytes: Buffer.from("# Big skill\n") }]; + for (let index = 1; index < count; index += 1) { + files.push({ + path: `references/part-${String(index).padStart(3, "0")}.md`, + bytes: Buffer.from(`Part ${index}\n`), + }); + } + return files; +} + +function streamOf(bytes: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(bytes)); + controller.close(); + }, + }); +} + +describe("tree path conflicts", () => { + beforeEach(() => vi.clearAllMocks()); + + it("finds a path that is also a folder of another path", () => { + expect(findTreePathConflicts(["SKILL.md", "docs", "docs/a.md"])).toEqual([ + { path: "docs", folderOf: "docs/a.md" }, + ]); + expect(findTreePathConflicts(["SKILL.md", "docs.md", "docs/a.md"])).toEqual( + [] + ); + }); + + it("names the conflicting path in the error", () => { + expect(() => + assertNoTreePathConflicts(["SKILL.md", "a/b", "a/b/c.md"]) + ).toThrow('Skill tree path "a/b" is both a file and a folder (a/b/c.md)'); + }); + + it("rejects a conflicting tree on the mirror write path", () => { + expect(() => + prepareSkillTreeForMirror([ + { path: "SKILL.md", content: "# Skill" }, + { path: "docs", content: "file" }, + { path: "docs/a.md", content: "nested" }, + ]) + ).toThrow("is both a file and a folder"); + }); + + it("rejects a conflicting tree before it writes to Blob storage", async () => { + await expect( + putSkillTree([ + { path: "SKILL.md", content: "# Skill" }, + { path: "docs", content: "file" }, + { path: "docs/a.md", content: "nested" }, + ]) + ).rejects.toThrow("is both a file and a folder"); + expect(mockPut).not.toHaveBeenCalled(); + }); +}); + +describe("stored tree read limits", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses the mirror limits for stored trees", () => { + expect(STORED_TREE_READ_CAPS).toEqual({ + maxFiles: MIRROR_MAX_SKILL_TREE_FILES, + maxBytes: MIRROR_MAX_SKILL_TREE_BYTES, + }); + }); + + it("keeps the upload limit by default, and accepts larger stored trees with read caps", () => { + const archive = buildTarArchive(manyFiles(250)); + expect(() => ingestTarArchive(archive)).toThrow( + "Skill tree exceeds cap of 200 files" + ); + expect(ingestTarArchive(archive, STORED_TREE_READ_CAPS)).toHaveLength(250); + }); + + it("serves files and per-file reads from a mirrored tree above the upload limits", async () => { + const archive = buildTarArchive(manyFiles(250)); + mockGet.mockImplementation(async () => ({ + statusCode: 200, + stream: streamOf(archive), + })); + const version = { + content: "# Big skill\n", + files: null, + tree_hash: "a".repeat(64), + storage_backend: "blob", + }; + + const files = await getFilesForVersion(version); + expect(files).toHaveLength(250); + + const part = await getFileForVersion(version, "references/part-249.md"); + expect(part.bytes.toString("utf8")).toBe("Part 249\n"); + }); +}); diff --git a/web/__tests__/lib/skillUpload-path-conflicts.test.ts b/web/__tests__/lib/skillUpload-path-conflicts.test.ts new file mode 100644 index 00000000..0fcb582c --- /dev/null +++ b/web/__tests__/lib/skillUpload-path-conflicts.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillUploadRequest, SkillUploadError } from "@/lib/skillUpload"; + +function jsonUpload(files: Array<{ path: string; content: string }>) { + return new Request("http://localhost/api/skills", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ skill_id: "demo", files }), + }); +} + +function multipartUpload(files: Array<{ path: string; content: string }>) { + const form = new FormData(); + form.set("skill_id", "demo"); + for (const file of files) { + form.append("files", new File([file.content], file.path)); + } + return new Request("http://localhost/api/skills", { + method: "POST", + body: form, + }); +} + +const CONFLICTING = [ + { path: "SKILL.md", content: "# Skill" }, + { path: "docs", content: "a file named docs" }, + { path: "docs/a.md", content: "a file inside docs/" }, +]; + +describe("skill upload path conflicts", () => { + it.each([ + ["JSON", jsonUpload], + ["multipart", multipartUpload], + ])( + "rejects a %s upload where a path is also a folder, with a 400", + async (_kind, build) => { + const error = await parseSkillUploadRequest(build(CONFLICTING)).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(SkillUploadError); + expect((error as SkillUploadError).status).toBe(400); + expect((error as Error).message).toBe( + 'Skill tree path "docs" is both a file and a folder (docs/a.md)' + ); + } + ); + + it("still accepts a normal multi-file upload", async () => { + const parsed = await parseSkillUploadRequest( + jsonUpload([ + { path: "SKILL.md", content: "# Skill" }, + { path: "docs.md", content: "docs" }, + { path: "docs/a.md", content: "nested" }, + ]) + ); + expect(parsed.files.map((file) => file.path).sort()).toEqual([ + "SKILL.md", + "docs.md", + "docs/a.md", + ]); + }); +}); diff --git a/web/app/api/skills/[id]/versions/[version]/archive/route.ts b/web/app/api/skills/[id]/versions/[version]/archive/route.ts new file mode 100644 index 00000000..61667897 --- /dev/null +++ b/web/app/api/skills/[id]/versions/[version]/archive/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@/lib/db"; +import { + getOptionalDownloadAuthPubkey, + recordInstallAndDownloadEvent, + resolveSkillAccess, +} from "@/lib/skillRawAccess"; +import { + buildArchiveForVersion, + type SkillFileManifestEntry, +} from "@/lib/skillStorage"; +import { getErrorMessage } from "@/lib/errors"; + +// Version-pinned tree download: GET /api/skills/{id}/versions/{version}/archive. +// Access rules are the same as /api/skills/{id}/archive (free, entitled, or a +// payment flow), decided by resolveSkillAccess. After access is granted, the +// requested version is served instead of the latest. Clients check the bytes +// against that version's tree_hash (docs/SKILL_TREE_HASH.md). + +const VERSION_PATTERN = /^[1-9][0-9]{0,8}$/; + +type PinnedVersionRow = { + version_id: string; + version: number; + content: string; + files: SkillFileManifestEntry[] | null; + tree_hash: string | null; + storage_backend: string | null; +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; version: string }> } +) { + try { + const { id, version } = await params; + if (!VERSION_PATTERN.test(version)) { + return NextResponse.json( + { error: "Version must be a positive integer" }, + { status: 400 } + ); + } + + const access = await resolveSkillAccess(request, id); + if (!access.ok) { + return access.response; + } + + const requestedVersion = Number(version); + const rows = await sql()` + SELECT + id AS version_id, + version, + content, + files, + tree_hash, + storage_backend + FROM skill_versions + WHERE skill_id = ${access.skill.id}::uuid + AND version = ${requestedVersion} + LIMIT 1 + `; + const pinned = rows[0]; + if (!pinned) { + return NextResponse.json( + { error: `Skill version ${requestedVersion} not found` }, + { status: 404 } + ); + } + + const archive = await buildArchiveForVersion(pinned); + await recordInstallAndDownloadEvent(access.skill.id, { + kind: "archive", + request, + walletPubkey: await getOptionalDownloadAuthPubkey( + request, + id, + access.skill.evm_listing_id ?? access.skill.on_chain_address + ), + authPresent: Boolean(request.headers.get("x-agentvouch-auth")), + skillVersionId: pinned.version_id, + skillVersion: pinned.version, + }); + + const treeHash = pinned.tree_hash ?? "skill"; + return new NextResponse(new Uint8Array(archive), { + headers: { + "Content-Type": "application/x-tar", + "Content-Disposition": `attachment; filename="${id}-v${pinned.version}-${treeHash}.tar"`, + "X-AgentVouch-Tree-Hash": treeHash, + "X-AgentVouch-Skill-Version": String(pinned.version), + }, + }); + } catch (error: unknown) { + console.error( + "GET /api/skills/[id]/versions/[version]/archive error:", + error + ); + return NextResponse.json( + { error: getErrorMessage(error, "Internal server error") }, + { status: 500 } + ); + } +} diff --git a/web/lib/mirror/sync.ts b/web/lib/mirror/sync.ts index 77219872..b3f2ec22 100644 --- a/web/lib/mirror/sync.ts +++ b/web/lib/mirror/sync.ts @@ -624,10 +624,14 @@ async function reconcileSkill( return outcome; } catch (error) { const message = (error as Error).message; - // Tree cap violations are deterministic, expected exclusions — not failures. + // Tree cap violations and file/folder path conflicts are deterministic, + // expected exclusions — not failures. if (/exceeds cap of/i.test(message)) { outcome.action = "skip"; outcome.detail = `exceeds marketplace cap: ${message}`; + } else if (/is both a file and a folder/i.test(message)) { + outcome.action = "skip"; + outcome.detail = `path conflict: ${message}`; } else { outcome.action = "error"; outcome.detail = message; diff --git a/web/lib/skillStorage.ts b/web/lib/skillStorage.ts index b7b737bf..b3dea4e4 100644 --- a/web/lib/skillStorage.ts +++ b/web/lib/skillStorage.ts @@ -10,7 +10,13 @@ import { MIRROR_MAX_SKILL_TREE_FILES, } from "@/lib/skillDraft"; -type TreeCaps = { maxFiles: number; maxBytes: number }; +export type TreeCaps = { maxFiles: number; maxBytes: number }; + +/** Limits for new uploads (author publish and new versions). */ +const UPLOAD_CAPS: TreeCaps = { + maxFiles: MAX_SKILL_TREE_FILES, + maxBytes: MAX_SKILL_TREE_BYTES, +}; export type SkillStorageBackend = "blob" | "inline"; @@ -201,10 +207,7 @@ function isExecutablePath(filePath: string, content: Buffer): boolean { export function normalizeSkillTreeFiles( files: SkillTreeInputFile[], - caps: TreeCaps = { - maxFiles: MAX_SKILL_TREE_FILES, - maxBytes: MAX_SKILL_TREE_BYTES, - } + caps: TreeCaps = UPLOAD_CAPS ): Array { if (files.length === 0) { throw new Error("Skill tree must include SKILL.md"); @@ -259,6 +262,37 @@ export function buildSkillManifest( })); } +/** + * Pairs where one path is also a folder of another path, for example "docs" + * and "docs/a.md". Such a tree cannot be written to a file system + * (docs/SKILL_TREE_HASH.md, path rules). + */ +export function findTreePathConflicts( + paths: readonly string[] +): Array<{ path: string; folderOf: string }> { + const seen = new Set(paths); + const conflicts: Array<{ path: string; folderOf: string }> = []; + for (const filePath of seen) { + const parts = filePath.split("/"); + for (let index = 1; index < parts.length; index += 1) { + const prefix = parts.slice(0, index).join("/"); + if (seen.has(prefix)) { + conflicts.push({ path: prefix, folderOf: filePath }); + } + } + } + return conflicts; +} + +export function assertNoTreePathConflicts(paths: readonly string[]): void { + const [conflict] = findTreePathConflicts(paths); + if (conflict) { + throw new Error( + `Skill tree path "${conflict.path}" is both a file and a folder (${conflict.folderOf})` + ); + } +} + export function computeTreeHash( filesOrManifest: Array<{ path: string; bytes?: Buffer; sha256?: string }> ): string { @@ -433,7 +467,10 @@ function isZeroBlock(block: Buffer): boolean { return block.every((byte) => byte === 0); } -export function ingestTarArchive(bytes: Buffer): SkillTreeInputFile[] { +export function ingestTarArchive( + bytes: Buffer, + caps: TreeCaps = UPLOAD_CAPS +): SkillTreeInputFile[] { const files: SkillTreeInputFile[] = []; let offset = 0; let totalBytes = 0; @@ -463,14 +500,12 @@ export function ingestTarArchive(bytes: Buffer): SkillTreeInputFile[] { ); } totalBytes += size; - if (files.length + 1 > MAX_SKILL_TREE_FILES) { - throw new Error( - `Skill tree exceeds cap of ${MAX_SKILL_TREE_FILES} files` - ); + if (files.length + 1 > caps.maxFiles) { + throw new Error(`Skill tree exceeds cap of ${caps.maxFiles} files`); } - if (totalBytes > MAX_SKILL_TREE_BYTES) { + if (totalBytes > caps.maxBytes) { throw new Error( - `Skill tree is ${totalBytes} bytes, exceeds cap of ${MAX_SKILL_TREE_BYTES} bytes` + `Skill tree is ${totalBytes} bytes, exceeds cap of ${caps.maxBytes} bytes` ); } if (offset + size > bytes.byteLength) { @@ -520,6 +555,7 @@ export async function putSkillTree( caps?: TreeCaps ): Promise { const tree = prepareSkillTree(files, caps); + assertNoTreePathConflicts(tree.manifest.map((file) => file.path)); await put(getBlobArchivePath(tree.treeHash), tree.archiveBytes, { access: "private", allowOverwrite: true, @@ -534,10 +570,19 @@ const MIRROR_CAPS: TreeCaps = { maxBytes: MIRROR_MAX_SKILL_TREE_BYTES, }; +/** + * Stored trees were checked when they were written, against the upload limits + * or the larger mirror limits. Read them with the larger limits, so a mirrored + * tree above the upload limits stays readable by /raw?path= and /zip. + */ +export const STORED_TREE_READ_CAPS: TreeCaps = MIRROR_CAPS; + export function prepareSkillTreeForMirror( files: SkillTreeInputFile[] ): SkillTreeStoreResult { - return prepareSkillTree(files, MIRROR_CAPS); + const tree = prepareSkillTree(files, MIRROR_CAPS); + assertNoTreePathConflicts(tree.manifest.map((file) => file.path)); + return tree; } export async function putSkillTreeForMirror( @@ -602,7 +647,10 @@ export async function getFilesForVersion( } const archive = await buildArchiveForVersion(version); - const files = normalizeSkillTreeFiles(ingestTarArchive(archive)); + const files = normalizeSkillTreeFiles( + ingestTarArchive(archive, STORED_TREE_READ_CAPS), + STORED_TREE_READ_CAPS + ); return files.map((file) => ({ path: file.path, bytes: file.bytes, @@ -632,7 +680,10 @@ export async function getFileForVersion( } const archive = await buildArchiveForVersion(version); - const files = normalizeSkillTreeFiles(ingestTarArchive(archive)); + const files = normalizeSkillTreeFiles( + ingestTarArchive(archive, STORED_TREE_READ_CAPS), + STORED_TREE_READ_CAPS + ); const match = files.find((file) => file.path === filePath); if (!match) throw new Error(`Skill file not found: ${filePath}`); return { diff --git a/web/lib/skillUpload.ts b/web/lib/skillUpload.ts index dee8b166..a7ab0545 100644 --- a/web/lib/skillUpload.ts +++ b/web/lib/skillUpload.ts @@ -1,5 +1,6 @@ import { Buffer } from "buffer"; import { + findTreePathConflicts, ingestTarArchive, normalizeSkillTreeFiles, type SkillTreeInputFile, @@ -55,6 +56,17 @@ function getStringField(form: FormData, name: string): string | undefined { return typeof value === "string" ? value : undefined; } +// A path that is also a folder of another path ("docs" and "docs/a.md") +// cannot be written to disk, so reject the upload with a clear 400. +function assertUploadHasNoPathConflicts(files: Array<{ path: string }>) { + const [conflict] = findTreePathConflicts(files.map((file) => file.path)); + if (conflict) { + throw new SkillUploadError( + `Skill tree path "${conflict.path}" is both a file and a folder (${conflict.folderOf})` + ); + } +} + function decodeBase64(value: string): Buffer { return Buffer.from(value, "base64"); } @@ -133,6 +145,7 @@ async function parseMultipart(request: Request): Promise { } const normalized = normalizeSkillTreeFiles(files); + assertUploadHasNoPathConflicts(normalized); const skillFile = normalized.find((file) => file.path === "SKILL.md"); if (!skillFile) throw new Error("Skill tree must include SKILL.md"); return { @@ -173,6 +186,7 @@ async function parseJson(request: Request): Promise { } const normalized = normalizeSkillTreeFiles(files); + assertUploadHasNoPathConflicts(normalized); const skillFile = normalized.find((file) => file.path === "SKILL.md"); if (!skillFile) throw new Error("Skill tree must include SKILL.md"); return { diff --git a/web/lib/treeHashAudit.ts b/web/lib/treeHashAudit.ts index d8996ffd..f67bd73f 100644 --- a/web/lib/treeHashAudit.ts +++ b/web/lib/treeHashAudit.ts @@ -7,7 +7,11 @@ // returns the same SKILL.md bytes as the tree. Spec: docs/SKILL_TREE_HASH.md. import { createHash } from "crypto"; -import { computeTreeHash, normalizeSkillPath } from "@/lib/skillStorage"; +import { + computeTreeHash, + findTreePathConflicts, + normalizeSkillPath, +} from "@/lib/skillStorage"; export const TREE_HASH_ALGORITHM = "agentvouch-tree-v1"; @@ -167,20 +171,14 @@ export function findTreePathProblems( }); } - for (const path of seen) { - const parts = path.split("/"); - for (let index = 1; index < parts.length; index += 1) { - const prefix = parts.slice(0, index).join("/"); - if (seen.has(prefix)) { - findings.push({ - code: "path_conflict", - path: prefix, - detail: `${JSON.stringify( - prefix - )} is a file and also a folder of ${JSON.stringify(path)}`, - }); - } - } + for (const conflict of findTreePathConflicts([...seen])) { + findings.push({ + code: "path_conflict", + path: conflict.path, + detail: `${JSON.stringify( + conflict.path + )} is a file and also a folder of ${JSON.stringify(conflict.folderOf)}`, + }); } return findings; diff --git a/web/public/skill.md b/web/public/skill.md index 5b579965..5a2ad228 100644 --- a/web/public/skill.md +++ b/web/public/skill.md @@ -1,6 +1,6 @@ --- name: agentvouch -version: 2.2.1 +version: 2.3.0 description: USDC-native on-chain agent reputation system and reputation oracle for trusted AI agent skills across Solana devnet and Base Sepolia. Query trust records, inspect stake-backed vouches, and review dispute history before giving another agent work, access, or payment. homepage: https://agentvouch.xyz repository: https://github.com/dirtybits/agentvouch @@ -159,8 +159,13 @@ curl -sL https://agentvouch.xyz/api/skills/{id}/zip -o skill.zip # Or fetch an individual file from the tree curl -sL 'https://agentvouch.xyz/api/skills/{id}/raw?path=scripts/run.sh' -o scripts/run.sh + +# Or download one specific (older) version as an archive +curl -sL https://agentvouch.xyz/api/skills/{id}/versions/{version}/archive -o skill-v2.tar ``` +**Check what you downloaded.** `GET /api/skills/{id}` returns `tree_hash` and `files` (path, size, and SHA-256 of every file) for the latest version, and the same fields for each entry in `versions`. Recompute the tree hash from the downloaded bytes and stop if it differs. The algorithm (`agentvouch-tree-v1`) and test vectors are in [docs/SKILL_TREE_HASH.md](https://github.com/dirtybits/agentvouch/blob/main/docs/SKILL_TREE_HASH.md). The `agentvouch` CLI does this on every install and update of a stored skill, and writes nothing when the check fails. Chain-only skills (ids that start with `chain-`) have no tree hash. A matching hash proves the bytes are unchanged; it does not prove the skill is safe. + Single-file skills remain valid. Multi-file skills use a canonical tree (`SKILL.md` plus optional `scripts/`, `references/`, and `assets/`) and expose a deterministic `tree_hash` so agents can cache and verify the folder across storage backends. Free listings use `0` USDC and download directly. Paid marketplace listings must preserve protocol economics: - **Free repo-backed skills** — use `0` USDC, download directly, and can be published by the CLI without creating an on-chain `SkillListing`. @@ -576,6 +581,7 @@ The repo must be public — content is fetched over `raw.githubusercontent.com`. | Check for repo updates | `GET` | `/api/skills/{id}/update?installed_version=` | None | | Download SKILL.md/file | `GET` | `/api/skills/{id}/raw?path=` | `X-AgentVouch-Auth` for paid entitlements and bridge requirements, `listing-required` for unlinked paid repo skills, direct download for free skills; successful repo downloads increment aggregate counts and write a download event | | Download skill archive | `GET` | `/api/skills/{id}/archive` | Same entitlement checks as `/raw`; returns the canonical tree tar and records the successful archive download | +| Download a version | `GET` | `/api/skills/{id}/versions/{version}/archive` | Same entitlement checks as `/archive`; returns that version's tree tar with `X-AgentVouch-Tree-Hash` and `X-AgentVouch-Skill-Version` headers and records the download | | Download skill zip | `GET` | `/api/skills/{id}/zip` | Same entitlement checks as `/raw`; returns a browser-friendly zip generated from the canonical tree and records the successful archive download | | Record install | `POST` | `/api/skills/{id}/install` | Wallet signature; records an attributed install event | | Publish skill | `POST` | `/api/skills` | GitHub/session auth for free unverified listings; wallet signature for paid protocol listings | diff --git a/web/scripts/tree-hash-sweep.ts b/web/scripts/tree-hash-sweep.ts index 4ce1838b..80fef9e9 100644 --- a/web/scripts/tree-hash-sweep.ts +++ b/web/scripts/tree-hash-sweep.ts @@ -36,7 +36,11 @@ */ import { writeFileSync } from "fs"; import { neon } from "@neondatabase/serverless"; -import { buildArchiveForVersion, ingestTarArchive } from "@/lib/skillStorage"; +import { + buildArchiveForVersion, + ingestTarArchive, + STORED_TREE_READ_CAPS, +} from "@/lib/skillStorage"; import { buildSweepVersionQuery, sweepRowFromVersionRecord, @@ -133,7 +137,7 @@ async function loadStoredFiles(row: TreeHashSweepRow): Promise { tree_hash: row.storedTreeHash, storage_backend: row.storageBackend, }); - return ingestTarArchive(archive).map((file) => ({ + return ingestTarArchive(archive, STORED_TREE_READ_CAPS).map((file) => ({ path: file.path, bytes: Buffer.isBuffer(file.content) ? file.content