204 lines
10 KiB
JavaScript
204 lines
10 KiB
JavaScript
"use strict";
|
|
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { execFileSync } = require("child_process");
|
|
const {
|
|
ALLOWED_BUNDLE_ROOTS,
|
|
MAX_BUNDLE_BYTES,
|
|
MAX_BUNDLE_FILE_BYTES,
|
|
MAX_BUNDLE_FILES,
|
|
MAX_SKILL_BYTES,
|
|
} = require("./constants");
|
|
const { safeTempRoot } = require("./safe-io");
|
|
const { secretIdentifierLike } = require("./secret");
|
|
|
|
function posix(value) { return value.split(path.sep).join("/"); }
|
|
|
|
function assertSafeRelative(value, label = "path") {
|
|
if (typeof value !== "string" || !value || value.includes("\0") || /[\x00-\x1f\x7f]/.test(value)) {
|
|
throw new Error(`${label} contains invalid characters`);
|
|
}
|
|
if (path.isAbsolute(value) || value.includes("\\")) throw new Error(`${label} must be a POSIX relative path`);
|
|
if (secretIdentifierLike(value)) throw new Error(`${label} contains secret-like material`);
|
|
const normalized = path.posix.normalize(value);
|
|
if (normalized !== value || normalized === ".." || normalized.startsWith("../")) {
|
|
throw new Error(`${label} escapes its root`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sha256(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); }
|
|
|
|
const REGULAR_GIT_MODES = new Set(["100644", "100755"]);
|
|
|
|
const TRACKED_CACHE = new Map();
|
|
|
|
function parseIndexRecords(output) {
|
|
const entries = new Map();
|
|
for (const record of output.toString("utf8").split("\0").filter(Boolean)) {
|
|
const full = record.match(/^(\d{6}) ([0-9a-f]{40,64}) (\d)\t([\s\S]+)$/);
|
|
if (!full) throw new Error("Unable to parse git index object");
|
|
assertSafeRelative(full[4], "Git index path");
|
|
if (full[3] !== "0") throw new Error(`Unmerged Git index entry rejected: ${full[4]}`);
|
|
if (entries.has(full[4])) throw new Error(`Duplicate Git index entry rejected: ${full[4]}`);
|
|
entries.set(full[4], full[1]);
|
|
if (!entries.objects) Object.defineProperty(entries, "objects", { value: new Map(), enumerable: false });
|
|
entries.objects.set(full[4], full[2]);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function trackedFiles(repoRoot) {
|
|
const output = execFileSync("git", ["ls-files", "--stage", "-z", "--", "skills"], {
|
|
cwd: repoRoot,
|
|
encoding: null,
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
const cacheId = `${fs.realpathSync(repoRoot)}\0${sha256(output)}`;
|
|
if (TRACKED_CACHE.has(cacheId)) return TRACKED_CACHE.get(cacheId);
|
|
const entries = parseIndexRecords(output);
|
|
const eligible = [...entries].filter(([filePath, mode]) => {
|
|
if (!REGULAR_GIT_MODES.has(mode) || !filePath.startsWith("skills/")) return false;
|
|
return filePath.endsWith("/SKILL.md") || /\/(?:references|scripts|assets)\//.test(filePath);
|
|
});
|
|
const oids = [...new Set(eligible.map(([filePath]) => entries.objects.get(filePath)))];
|
|
const input = `${oids.join("\n")}\n`;
|
|
const checked = execFileSync("git", ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], { cwd: repoRoot, input, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
let total = 0;
|
|
for (const line of checked.trim().split("\n").filter(Boolean)) {
|
|
const match = line.match(/^([0-9a-f]{40,64}) blob (\d+)$/);
|
|
if (!match) throw new Error("Unexpected frozen git object metadata");
|
|
total += Number(match[2]) + line.length + 2;
|
|
}
|
|
if (!Number.isSafeInteger(total) || total > 512 * 1024 * 1024) throw new Error("Frozen Git object corpus exceeds memory boundary");
|
|
const batch = execFileSync("git", ["cat-file", "--batch"], { cwd: repoRoot, input, encoding: null, maxBuffer: total + 1024 });
|
|
const blobs = new Map();
|
|
let offset = 0;
|
|
while (offset < batch.length) {
|
|
const newline = batch.indexOf(10, offset);
|
|
if (newline < 0) throw new Error("Truncated frozen Git object header");
|
|
const header = batch.subarray(offset, newline).toString("utf8");
|
|
const match = header.match(/^([0-9a-f]{40,64}) blob (\d+)$/);
|
|
if (!match) throw new Error("Unexpected frozen Git object header");
|
|
const size = Number(match[2]);
|
|
const start = newline + 1;
|
|
const end = start + size;
|
|
if (end >= batch.length || batch[end] !== 10) throw new Error("Truncated frozen Git object body");
|
|
blobs.set(match[1], Buffer.from(batch.subarray(start, end)));
|
|
offset = end + 1;
|
|
}
|
|
Object.defineProperty(entries, "blobs", { value: blobs, enumerable: false });
|
|
TRACKED_CACHE.set(cacheId, entries);
|
|
return entries;
|
|
}
|
|
|
|
function readTrackedFile(repoRoot, relPath, tracked, maxBytes, { requireUtf8 = true } = {}) {
|
|
assertSafeRelative(relPath);
|
|
const oid = tracked.objects?.get(relPath);
|
|
if (!oid || !/^[0-9a-f]{40,64}$/.test(oid)) throw new Error(`Missing frozen git object: ${relPath}`);
|
|
const bytes = tracked.blobs?.get(oid);
|
|
if (!bytes) throw new Error(`Frozen git object bytes unavailable: ${relPath}`);
|
|
if (bytes.length > maxBytes) throw new Error(`File exceeds byte limit: ${relPath}`);
|
|
const algorithm = oid.length === 40 ? "sha1" : "sha256";
|
|
const gitOid = crypto.createHash(algorithm).update(Buffer.from(`blob ${bytes.length}\0`)).update(bytes).digest("hex");
|
|
if (gitOid !== oid) throw new Error(`Frozen git object changed while reading: ${relPath}`);
|
|
let text = null;
|
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch (error) { if (requireUtf8) throw error; }
|
|
return { path: relPath, bytes, text, encoding: text === null ? "binary" : "utf-8", sha256: sha256(bytes), size: bytes.length };
|
|
}
|
|
|
|
function readRegularFile(repoRoot, relPath, maxBytes, { requireUtf8 = true } = {}) {
|
|
assertSafeRelative(relPath);
|
|
const absolute = path.join(repoRoot, ...relPath.split("/"));
|
|
const rootReal = fs.realpathSync(repoRoot);
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
const fd = fs.openSync(absolute, flags);
|
|
let bytes;
|
|
let stat;
|
|
try {
|
|
stat = fs.fstatSync(fd);
|
|
if (!stat.isFile()) throw new Error(`Not a regular file: ${relPath}`);
|
|
if (stat.size > maxBytes) throw new Error(`File exceeds byte limit: ${relPath}`);
|
|
const real = fs.realpathSync(absolute);
|
|
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) throw new Error(`File escapes repository: ${relPath}`);
|
|
bytes = fs.readFileSync(fd);
|
|
const after = fs.fstatSync(fd);
|
|
if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== bytes.length || after.mtimeMs !== stat.mtimeMs) throw new Error(`File changed while reading: ${relPath}`);
|
|
} finally { fs.closeSync(fd); }
|
|
let text = null;
|
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch (error) {
|
|
if (requireUtf8) throw error;
|
|
}
|
|
return { path: relPath, bytes, text, encoding: text === null ? "binary" : "utf-8", sha256: sha256(bytes), size: bytes.length };
|
|
}
|
|
|
|
function discoverBundle(repoRoot, skillId, tracked = trackedFiles(repoRoot)) {
|
|
assertSafeRelative(skillId, "skill id");
|
|
const base = `skills/${skillId}`;
|
|
const skillPath = `${base}/SKILL.md`;
|
|
if (!tracked.has(skillPath)) throw new Error(`Canonical skill is not tracked: ${skillPath}`);
|
|
if (!REGULAR_GIT_MODES.has(tracked.get(skillPath))) throw new Error(`Canonical skill has unsafe git mode: ${skillPath}`);
|
|
const primary = readTrackedFile(repoRoot, skillPath, tracked, MAX_SKILL_BYTES);
|
|
const candidates = [...tracked.keys()].filter((item) => item.startsWith(`${base}/`)).sort();
|
|
const files = [primary];
|
|
let bundleBytes = 0;
|
|
for (const relPath of candidates) {
|
|
if (relPath === skillPath) continue;
|
|
const suffix = relPath.slice(base.length + 1);
|
|
const root = suffix.split("/")[0];
|
|
if (!ALLOWED_BUNDLE_ROOTS.includes(root)) continue;
|
|
if (!REGULAR_GIT_MODES.has(tracked.get(relPath))) throw new Error(`Bundle file has unsafe git mode: ${relPath}`);
|
|
// Supplemental files participate byte-for-byte in bundle identity. Binary
|
|
// archives/assets are hashed but never parsed, rendered, or sent to Codex.
|
|
const file = readTrackedFile(repoRoot, relPath, tracked, MAX_BUNDLE_FILE_BYTES, { requireUtf8: false });
|
|
bundleBytes += file.size;
|
|
if (bundleBytes > MAX_BUNDLE_BYTES) throw new Error(`Bundle exceeds byte limit: ${skillId}`);
|
|
files.push(file);
|
|
if (files.length - 1 > MAX_BUNDLE_FILES) throw new Error(`Bundle exceeds file limit: ${skillId}`);
|
|
}
|
|
const identity = files.map((file) => `${file.path}\0${file.sha256}\0${file.size}`).join("\n");
|
|
return { skillId, skillPath, files, bundleHash: sha256(Buffer.from(identity)) };
|
|
}
|
|
|
|
function bundleMap(bundle) { return new Map(bundle.files.map((file) => [file.path, file.text])); }
|
|
|
|
function listCanonicalSkillIds(repoRoot, tracked = trackedFiles(repoRoot)) {
|
|
const result = [];
|
|
for (const [filePath, mode] of tracked.entries()) {
|
|
if (!filePath.startsWith("skills/") || !filePath.endsWith("/SKILL.md")) continue;
|
|
if (!REGULAR_GIT_MODES.has(mode)) throw new Error(`Canonical skill has unsafe git mode: ${filePath}`);
|
|
const skillId = filePath.slice("skills/".length, -"/SKILL.md".length);
|
|
result.push(skillId);
|
|
}
|
|
return result.sort();
|
|
}
|
|
|
|
function materializeBundle(bundle) {
|
|
const root = fs.mkdtempSync(path.join(safeTempRoot(), "aas-review-snapshot-"));
|
|
try {
|
|
for (const file of bundle.files) {
|
|
assertSafeRelative(file.path);
|
|
const target = path.join(root, ...file.path.split("/"));
|
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(target, file.bytes, { flag: "wx", mode: 0o600 });
|
|
}
|
|
const files = bundle.files.map((file, index) => readRegularFile(root, file.path, index === 0 ? MAX_SKILL_BYTES : MAX_BUNDLE_FILE_BYTES, { requireUtf8: index === 0 || file.encoding === "utf-8" }));
|
|
const identity = files.map((file) => `${file.path}\0${file.sha256}\0${file.size}`).join("\n");
|
|
const bundleHash = sha256(Buffer.from(identity));
|
|
if (bundleHash !== bundle.bundleHash) throw new Error("Isolated snapshot hash mismatch");
|
|
return { root, bundle: { ...bundle, files, bundleHash } };
|
|
} catch (error) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function disposeSnapshot(snapshot) {
|
|
if (!snapshot?.root || !path.basename(snapshot.root).startsWith("aas-review-snapshot-")) throw new Error("Invalid snapshot cleanup target");
|
|
fs.rmSync(snapshot.root, { recursive: true, force: true });
|
|
}
|
|
|
|
module.exports = { assertSafeRelative, bundleMap, discoverBundle, disposeSnapshot, listCanonicalSkillIds, materializeBundle, parseIndexRecords, readRegularFile, sha256, trackedFiles };
|