#!/usr/bin/env node // materialize.mjs — write an example's poisoned tree into a temp dir. // // v8.1.0 AV surface (S2, 2026-09-22): a Windows user cloning this repository // must not have Defender quarantine anything, so the demo trees no longer sit // on disk. Each one is stored as ONE encoded archive next to its example: // - text is rot13, so no payload sits in the file as a contiguous literal; // - every codepoint above U+007E (the Unicode Tag steganography, em dashes) // is stored as a NUMBER, never as a character. // Each file also carries the sha256 of the bytes that were on disk before the // move; tests/helpers/payload-trees.test.mjs asserts the materialized bytes // still match it, the same check the S1 trees get. // // Usage (prints the materialized directory; delete it when you are done): // node examples/malicious-skill-demo/materialize.mjs // node examples/malicious-skill-demo/materialize.mjs examples/poisoned-claude-md/fixture.archive.json import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); export const DEMO_ARCHIVE = join(HERE, 'evil-project-health.archive.json'); function rot13(s) { return s.replace(/[A-Za-z]/g, c => { const base = c <= 'Z' ? 65 : 97; return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base); }); } /** Parts are rot13 strings and codepoint numbers, in order. */ export function decodeContent(parts) { return parts.map(p => (typeof p === 'number' ? String.fromCodePoint(p) : rot13(p))).join(''); } /** * Read an archive. Returns `{ name, files: { rel: { sha256, content } } }` * with content decoded. * @param {string} archivePath */ export function readArchive(archivePath) { const archive = JSON.parse(readFileSync(archivePath, 'utf8')); const files = {}; for (const [rel, { sha256, parts }] of Object.entries(archive.files)) { files[rel] = { sha256, content: decodeContent(parts) }; } return { name: archive.name, files }; } /** * Write one archive into a fresh temp dir. The leaf directory keeps the old * tree's name (e.g. `evil-project-health`), so paths in findings look the same. * @param {string} [archivePath] * @returns {{ dir: string, cleanup: () => void }} */ export function materializeArchive(archivePath = DEMO_ARCHIVE) { const { name, files } = readArchive(archivePath); const root = mkdtempSync(join(tmpdir(), 'llm-sec-example-')); const dir = join(root, name); for (const [rel, { content }] of Object.entries(files)) { const target = join(dir, rel); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, content); } return { dir, cleanup: () => rmSync(root, { recursive: true, force: true }) }; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const { dir } = materializeArchive(process.argv[2] ? resolve(process.argv[2]) : DEMO_ARCHIVE); console.log(dir); }