// The Pi pin and the engine seal (#1507, CHAT-03 §3, lead decisions 31–32). // // Pin: package-lock.json and npm's installed record // (node_modules/.package-lock.json) must both name the pinned version with the // pinned integrity. That ties the install to the package through npm's record; // it is not a hash of the files on disk. `pi` runs dist/bundle/cli.js, the // package's bin, and the built-in llama.cpp extension ships inside it. // // Seal: the controller builds the launch argv. It always carries // --no-extensions, --no-prompt-templates, --no-themes and --no-approve, and // never an --extension argument (cli/args.js; usage.md 224 and 233–236). // --no-approve sets the project trust override to false, so a project's // .pi/settings.json, SYSTEM.md, APPEND_SYSTEM.md and skills don't load even // when trust.json under the agent dir trusts it (main.js 574–581; // usage.md 126; Filbert F2 on #1522). With --no-extensions Pi loads only // command-line extension paths (resource-loader.js 316–318), so no explicit // extension loads. Under the seal the Mosaic prompt in the slot is the only // thing that can start a run, which is the basis for attributing a run to it // by order. // // The seal is an allow-list. Pi's parser (cli/args.js) keeps the last --mode // and the last --session, reads a bare word as a prompt and an `@` word as a // file, so the argv must be exactly the controller's prefix followed by // ENGINE_OPTIONS pairs, each at most once with one plain value. import { readFileSync } from "node:fs"; import { isAbsolute, join } from "node:path"; import { createHash } from "node:crypto"; import { ControlRefusal } from "./safe-fs.mjs"; export const PI_PACKAGE = "@earendil-works/pi-coding-agent"; export const PI_VERSION = "0.85.1"; export const PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ=="; export const PI_BIN = join("node_modules", PI_PACKAGE, "dist", "bundle", "cli.js"); export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes", "--no-approve"]); export const ENGINE_OPTIONS = Object.freeze(["--model", "--provider", "--thinking"]); export const ENGINE_PIN_MISMATCH = "engine-pin-mismatch"; export const UNSEALED_ENGINE = "unsealed-engine"; function lockEntry(path) { let lock; try { lock = JSON.parse(readFileSync(path, "utf8")); } catch { return null; } const entry = lock?.packages?.[`node_modules/${PI_PACKAGE}`]; return entry && typeof entry === "object" ? entry : null; } // `root` holds package-lock.json and node_modules/.package-lock.json. export function checkEnginePin(root) { for (const path of [join(root, "package-lock.json"), join(root, "node_modules", ".package-lock.json")]) { const entry = lockEntry(path); if (!entry || entry.version !== PI_VERSION || entry.integrity !== PI_INTEGRITY) { throw new ControlRefusal(ENGINE_PIN_MISMATCH, `${path} does not pin ${PI_PACKAGE} ${PI_VERSION} with the pinned integrity`); } } return { version: PI_VERSION, pin: PI_INTEGRITY }; } export function buildPiArgs({ sessionFile, extraArgs = [] }) { return ["--mode", "rpc", ...SEAL_FLAGS, "--session", sessionFile, ...extraArgs]; } // Refuses any argv that is not `--mode rpc`, the four --no-* flags and // `--session `, in that order, followed by ENGINE_OPTIONS // pairs. That covers --extension in either spelling, a second --mode or // --session, session and output flags (--no-session, --fork, --export, ...) // and stray prompt words. export function checkSeal(args) { if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) throw new ControlRefusal(UNSEALED_ENGINE, "launch argv is not a list of strings"); const extension = args.find((a) => a === "-e" || a === "--extension" || a.startsWith("--extension=")); if (extension !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${extension}`); for (const flag of SEAL_FLAGS) { if (!args.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv lacks ${flag}`); } const prefix = ["--mode", "rpc", ...SEAL_FLAGS, "--session"]; if (prefix.some((a, i) => args[i] !== a)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv does not start with ${prefix.join(" ")}`); const file = args[prefix.length]; if (typeof file !== "string" || !isAbsolute(file)) throw new ControlRefusal(UNSEALED_ENGINE, "the --session value is not an absolute path"); const seen = new Set(); for (let i = prefix.length + 1; i < args.length; i += 2) { const flag = args[i], value = args[i + 1]; if (!ENGINE_OPTIONS.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${flag}, which is not one of ${ENGINE_OPTIONS.join(", ")}`); if (seen.has(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv repeats ${flag}`); if (typeof value !== "string" || !value || value.startsWith("-") || value.startsWith("@")) throw new ControlRefusal(UNSEALED_ENGINE, `${flag} needs one plain value`); seen.add(flag); } return true; } // The engine's environment (I3, Darkwing F3 on #1507): built from names, // never inherited whole. ENGINE_ENV names what Pi needs to run; a launch may // add provider credentials by name (`engine.envKeys`), and nothing else, so // NODE_OPTIONS, LD_PRELOAD and the PI_PACKAGE_DIR family can't load code // around the seal. Values come from the controller's own environment; an // unset name is left out, not set empty. export const ENGINE_ENV = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "TMPDIR", "PI_CODING_AGENT_DIR", "PI_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_TELEMETRY"]); export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]{0,62}_(API_KEY|TOKEN)$/; export function engineEnv(envKeys = [], source = process.env) { if (!Array.isArray(envKeys)) throw new ControlRefusal(UNSEALED_ENGINE, "engine.envKeys is not a list"); const bad = envKeys.find((k) => typeof k !== "string" || !CREDENTIAL_NAME.test(k)); if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.envKeys names ${String(bad).slice(0, 80)}, which is not a provider credential (*_API_KEY or *_TOKEN)`); const env = {}; for (const k of [...ENGINE_ENV, ...envKeys]) if (typeof source[k] === "string") env[k] = source[k]; return env; } export function argvDigest(command, args) { return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex"); }