Files
Nick 507417f06a feat: inspect complete local skill bundles over MCP (#1340)
MCP can now inspect the reference documents and scripts linked by a skill, using `list_skill_files` and `read_skill_file`. The catalog binds each file's path, size and digest; reads return bounded, inert UTF-8 text and reject traversal, links and changed bytes. All existing discovery, composition and evidence tools remain available.

Co-authored-by: sck_0 <188885273+sck000@users.noreply.github.com>
2026-09-05 02:11:47 +02:00

135 lines
6.3 KiB
JavaScript

"use strict";
const fs = require("node:fs");
const path = require("node:path");
const versions = require("./versions");
const { canonicalJson, sha256 } = require("./canonical-json");
const { compareStrings, sortedUnique, tokenize } = require("./normalize");
const { validateInstance } = require("./schema-validator");
const { validateSkillFilePath } = require("./skill-files");
const OFFLINE_MANIFEST = "data/aas-v1/catalog-manifest.v1.json";
const CONTENT_INDEX = "data/aas-v1/skill-content-index.v1.json";
const CATALOG_ASSET = "data/catalog.json";
const CATALOG_PACKAGE = "agentic-awesome-skills";
function readVerifiedAsset(root, asset) {
if (!asset || typeof asset.path !== "string" || !/^[a-zA-Z0-9._/-]+$/.test(asset.path)
|| asset.path.startsWith("/") || asset.path.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Offline catalog contains an unsafe asset path");
}
const filePath = path.resolve(root, ...asset.path.split("/"));
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) throw new Error("Offline catalog asset escaped package root");
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size !== asset.size) {
throw new Error(`Offline catalog asset is unsafe or changed: ${asset.path}`);
}
const bytes = fs.readFileSync(filePath);
if (sha256(bytes) !== asset.sha256) throw new Error(`Offline catalog asset digest mismatch: ${asset.path}`);
return bytes;
}
function loadOfflineIdentity(root) {
const manifestPath = path.join(root, ...OFFLINE_MANIFEST.split("/"));
const text = fs.readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(text);
validateInstance("catalog-manifest.schema.json", manifest, "AAS_CATALOG_MANIFEST_SCHEMA_INVALID");
if (`${canonicalJson(manifest)}\n` !== text || manifest.schemaVersion !== 1 || manifest.digestVersion !== 1
|| manifest.package !== CATALOG_PACKAGE || typeof manifest.packageVersion !== "string"
|| !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(manifest.packageVersion)
|| manifest.catalogSchemaVersion !== versions.catalogSchemaVersion || !Array.isArray(manifest.assets)) {
throw new Error("Offline catalog manifest is invalid or incompatible");
}
const verifiedAssets = new Map();
const assets = manifest.assets.map((asset) => {
const bytes = readVerifiedAsset(root, asset);
verifiedAssets.set(asset.path, bytes);
return { path: asset.path, size: bytes.length, sha256: sha256(bytes) };
}).sort((left, right) => compareStrings(left.path, right.path));
const digest = sha256(canonicalJson({ digestVersion: 1, assets }));
const indexAsset = manifest.assets.find((asset) => asset.path === CONTENT_INDEX);
if (!indexAsset) throw new Error("Offline catalog content index is missing");
const contentIndex = JSON.parse(verifiedAssets.get(indexAsset.path));
const indexedSkillCount = Object.keys(contentIndex?.entries || {}).length;
if (digest !== manifest.catalogDigest || !Number.isSafeInteger(manifest.skillCount)
|| manifest.skillCount <= 0 || manifest.skillCount !== indexedSkillCount) {
throw new Error("Offline catalog identity mismatch");
}
return { digest, manifest, contentIndex, verifiedAssets };
}
function loadBundledCatalog(options = {}) {
const root = path.resolve(options.root || path.resolve(__dirname, "../../.."));
const offline = loadOfflineIdentity(root);
if (options.catalogPath && path.resolve(options.catalogPath) !== path.join(root, ...CATALOG_ASSET.split("/"))) {
throw new Error("Custom catalog paths cannot satisfy the bundled catalog integrity contract");
}
const catalogBytes = offline.verifiedAssets.get(CATALOG_ASSET);
if (!catalogBytes) throw new Error("Offline catalog data asset is missing");
const raw = JSON.parse(catalogBytes);
const skills = (raw.skills || []).map((entry) => {
const canonicalId = entry.canonical_id || entry.id;
const contentRef = offline.contentIndex.entries?.[canonicalId];
if (!contentRef || !Number.isSafeInteger(contentRef.offset) || !Number.isSafeInteger(contentRef.length)
|| !/^sha256-[a-f0-9]{64}$/.test(contentRef.sha256)) {
throw new Error(`Offline skill content reference is invalid: ${canonicalId}`);
}
if (contentRef.files !== undefined) {
if (!Array.isArray(contentRef.files) || contentRef.files.length > 10000) throw new Error("Invalid skill file inventory");
let previous = "";
for (const file of contentRef.files) {
validateSkillFilePath(file.path);
if (file.path <= previous || !["file", "symlink"].includes(file.type)
|| (file.type === "file" && (!Number.isSafeInteger(file.size) || file.size < 0
|| !/^sha256-[a-f0-9]{64}$/.test(file.sha256)))) throw new Error("Invalid skill file record");
previous = file.path;
}
}
return {
id: canonicalId,
name: entry.name || canonicalId,
description: entry.description || "",
category: entry.category || "unknown",
tags: sortedUnique(entry.tags || []),
triggers: sortedUnique(entry.triggers || []),
searchTokens: sortedUnique(tokenize([
canonicalId,
entry.id,
entry.name,
entry.description,
entry.category,
...(entry.tags || []),
...(entry.triggers || []),
])),
untrustedContentPath: entry.path || null,
...(contentRef.files ? { untrustedFiles: contentRef.files } : {}),
untrustedContentRef: {
assetPath: "data/aas-v1/skill-content.v1.ndjson",
offset: contentRef.offset,
length: contentRef.length,
sha256: contentRef.sha256,
},
};
}).sort((left, right) => compareStrings(left.id, right.id));
return {
schemaVersion: 1,
package: offline.manifest.package,
version: offline.manifest.packageVersion,
digest: offline.digest,
skills,
};
}
function syntheticCatalog(skills, identity = {}) {
const normalized = [...skills].sort((left, right) => compareStrings(left.id, right.id));
return {
schemaVersion: 1,
package: identity.package || "synthetic-aas-catalog",
version: identity.version || "1.0.0",
digest: sha256(canonicalJson({ catalogSchemaVersion: versions.catalogSchemaVersion, skills: normalized })),
skills: normalized,
};
}
module.exports = { loadBundledCatalog, syntheticCatalog };