diff --git a/.github/authoring-public-tools.json b/.github/authoring-public-tools.json new file mode 100644 index 00000000..df963298 --- /dev/null +++ b/.github/authoring-public-tools.json @@ -0,0 +1,266 @@ +{ + "schema": "authoring-public-tools/v1", + "controllers": { + "linux-amd64": { + "node": null, + "python": null, + "git": null, + "gh": null, + "tar": null + }, + "linux-arm64": { + "node": null, + "python": null, + "git": null, + "gh": null, + "tar": null + }, + "darwin-amd64": { + "node": null, + "python": null, + "git": null, + "gh": null, + "tar": null + }, + "darwin-arm64": { + "node": null, + "python": null, + "git": null, + "gh": null, + "tar": null + }, + "windows-amd64": { + "node": null, + "python": null, + "git": null, + "gh": null, + "tar": null + }, + "windows-arm64": { + "node": null, + "python": null, + "git": null, + "gh": null, + "tar": null + } + }, + "cells": { + "linux-amd64/kit-node18": { + "runner": null, + "image": null, + "controller": "linux-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "linux-amd64/pair-node22": { + "runner": null, + "image": null, + "controller": "linux-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "linux-amd64/pair-node24": { + "runner": null, + "image": null, + "controller": "linux-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "linux-arm64/kit-node18": { + "runner": null, + "image": null, + "controller": "linux-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "linux-arm64/pair-node22": { + "runner": null, + "image": null, + "controller": "linux-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "linux-arm64/pair-node24": { + "runner": null, + "image": null, + "controller": "linux-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "darwin-amd64/kit-node18": { + "runner": null, + "image": null, + "controller": "darwin-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "darwin-amd64/pair-node22": { + "runner": null, + "image": null, + "controller": "darwin-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "darwin-amd64/pair-node24": { + "runner": null, + "image": null, + "controller": "darwin-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "darwin-arm64/kit-node18": { + "runner": null, + "image": null, + "controller": "darwin-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "darwin-arm64/pair-node22": { + "runner": null, + "image": null, + "controller": "darwin-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "darwin-arm64/pair-node24": { + "runner": null, + "image": null, + "controller": "darwin-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "windows-amd64/kit-node18": { + "runner": null, + "image": null, + "controller": "windows-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "windows-amd64/pair-node22": { + "runner": null, + "image": null, + "controller": "windows-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "windows-amd64/pair-node24": { + "runner": null, + "image": null, + "controller": "windows-amd64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "windows-arm64/kit-node18": { + "runner": null, + "image": null, + "controller": "windows-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "windows-arm64/pair-node22": { + "runner": null, + "image": null, + "controller": "windows-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + }, + "windows-arm64/pair-node24": { + "runner": null, + "image": null, + "controller": "windows-arm64", + "npm_node": null, + "shim_node": null, + "npm": null, + "go": null, + "mod_cache": null, + "observer": null, + "installer_policy": null + } + }, + "reader": "linux-amd64" +} diff --git a/npm/agentplugins/scripts/packed-installer-bridge.md b/npm/agentplugins/scripts/packed-installer-bridge.md index 8a1b7bd5..38e2d0e3 100644 --- a/npm/agentplugins/scripts/packed-installer-bridge.md +++ b/npm/agentplugins/scripts/packed-installer-bridge.md @@ -362,3 +362,47 @@ E aggregate/authenticated reader and P adapter together. Its npm/process, workflow and P test names remain outstanding. Native/N2, genuine E, P/Q/B, anonymous acquisition/readbacks/channels, stable release and phases 0–11 remain open; independent review and required remote CI are not waived. + +### C3b first invariant: source-frozen public tools + +The controller now reads only `.github/authoring-public-tools.json` beside the +executing trusted checkout. `expectedCommit`, receipt pins, PATH and environment +cannot select that root or an interpreter. `options.node` is comparison only. +`authoring-public-tools/v1` fixes six controller keys, eighteen cell keys and the +Linux-amd64 reader. The closed, ordered JSON encoding is two-space indentation +plus one LF, using the existing encoding/checked-file interface in JavaScript +and minimal standard-library checks in Python. Limits are 1 MiB and depth eight; +unknown, missing, duplicate, reordered and alternate-encoded fields fail. + +All fields remain present. `null` means approved provision is unavailable; +there are no approved pins in this checkout. Controller rows contain +`node,python,git,gh,tar`, each eventually `{path,version,sha256}`. Cell +`runner,image,observer,installer_policy` are immutable `{id,sha256}` bindings +supplied by approved provisioning, not guessed runner labels. `npm_node` and +`shim_node` must match the cell major. npm additionally requires +`closure:{root,files:[{path,sha256}]}`: the sorted exhaustive regular-file closure, +including its CLI and dependencies. `mod_cache` uses that same closure shape. +Closures allow at most 4096 files, 8192 entries and 256 MiB; links and path aliases +fail. Go and module cache are required only for Linux-amd64/pair-node22; +installer policy is required for pair cells. Other null capabilities fail with +`PUBLIC_PROVISIONING_REQUIRED::` before execution. +Provisioned versions are frozen metadata bound to bytes, not inferred by running +a tool. Provisioning owners must supply authentic supply-chain instructions and +immutable host/image identities before any real cell can run. + +`readProvisioning()` takes no root argument. `requireController(key)` returns a +verified fixed Node path; `requireCellTools(key)` checks that cell's provision. +Python `require_authenticated_controller()` accepts no caller input. Its initial +TCB is trusted checkout plus trusted Python/workflow OS. Prepared subprocess paths +recheck tools and trusted source before and after invocation, using an environment +that excludes interpreter injection settings. The external provision contract +must keep paths immutable throughout execution; hashes do not prevent hostile +same-UID replacement between checks. + +A verified controller does **not** open authenticated summary success. A separate +explicit `C3b execution incomplete` gate remains before receipt reads/effects: +full result/installer/observer validation and independent invocation authority +are still missing. Synthetic unit fixtures mock that later capability and child +execution only; harmless tool bytes are never executed and establish no authentic +acceptance. Full producer/validators/facades, workflow, J/E/P, genuine matrix and +remaining release gates remain mandatory next-lane work. diff --git a/npm/agentplugins/scripts/public-authoring-tools.js b/npm/agentplugins/scripts/public-authoring-tools.js new file mode 100644 index 00000000..dc3eb084 --- /dev/null +++ b/npm/agentplugins/scripts/public-authoring-tools.js @@ -0,0 +1,148 @@ +"use strict"; +// Source checkout + independently immutable provision are authority, never receipts. +const fs = require("node:fs"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const c = require("./dual-authoring-candidate"); +const { fields: checkedFields, hash } = require("../lib/public-authoring-contract").checks; +const TARGETS = ["linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64", "windows-arm64"]; +const CELLS = TARGETS.flatMap(t => ["kit-node18", "pair-node22", "pair-node24"].map(l => `${t}/${l}`)); +const TOOLS = ["node", "python", "git", "gh", "tar"]; +const CELL_FIELDS = ["runner", "image", "controller", "npm_node", "shim_node", "npm", "go", "mod_cache", "observer", "installer_policy"]; +const ROOT = path.resolve(__dirname, "../../.."); +const MANIFEST = path.join(ROOT, ".github/authoring-public-tools.json"); +const LIMIT = 1024 * 1024; +function fields(v, names, label) { + if (v && typeof v === "object" && !Array.isArray(v)) for (const name of names) { + if (!Object.hasOwn(v, name)) absent(["six controllers", "eighteen cells"].includes(label) ? `${name}:entry` : `${label}:${name}`); + } + checkedFields(v, names, label); assert.deepEqual(Object.keys(v), names, "ordered provision fields"); +} +function absent(label) { throw new Error(`PUBLIC_PROVISIONING_REQUIRED:${label}`); } +function text(v) { assert.ok(typeof v === "string" && /^[\x21-\x7e]{1,256}$/.test(v), "bounded provision identity"); } +// Absolute path limit: 4096 Unicode code points in both decoders. +function absolute(v, target) { + assert.ok(typeof v === "string" && Array.from(v).length <= 4096 && !/[\x00-\x1f\x7f]/.test(v), "provision path"); + const p = target.startsWith("windows-") ? path.win32 : path.posix; + assert.ok(p.isAbsolute(v) && p.normalize(v) === v && v !== p.parse(v).root && + !v.startsWith("\\\\") && !v.startsWith("//") && !v.endsWith(p.sep), "canonical provision path"); +} +function identity(v) { fields(v, ["id", "sha256"], "provision identity"); text(v.id); hash(v.sha256, "identity"); } +function closure(v, target) { + fields(v, ["root", "files"], "complete provision closure"); absolute(v.root, target); + assert.ok(Array.isArray(v.files) && v.files.length > 0 && v.files.length <= 4096, "bounded provision closure"); + let last = ""; + for (const row of v.files) { + fields(row, ["path", "sha256"], "closure file"); hash(row.sha256, "closure file"); + assert.ok(typeof row.path === "string" && /^[A-Za-z0-9_.@+-]+(?:\/[A-Za-z0-9_.@+-]+)*$/.test(row.path) && + row.path.length <= 4096 && !row.path.split("/").some(x => x === "." || x === "..") && + row.path > last, "ordered unique relative closure files"); last = row.path; + } +} +function tool(v, target, npm = false) { + fields(v, npm ? ["path", "version", "sha256", "closure"] : ["path", "version", "sha256"], "provision tool"); + absolute(v.path, target); text(v.version); hash(v.sha256, "tool"); + if (npm) { + closure(v.closure, target); + const p = target.startsWith("windows-") ? path.win32 : path.posix; + assert.ok(v.closure.files.some(f => p.join(v.closure.root, ...f.path.split("/")) === v.path && f.sha256 === v.sha256), "npm CLI in complete closure"); + } +} +function decode(body) { + assert.ok(body.length > 0 && body.length <= LIMIT, "bounded provision manifest"); + const s = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body); + let depth = 0, quoted = false, escaped = false; + for (const ch of s) { + if (quoted) { if (escaped) escaped = false; else if (ch === "\\") escaped = true; else if (ch === '"') quoted = false; } + else if (ch === '"') quoted = true; + else if (ch === "{" || ch === "[") assert.ok(++depth <= 8, "provision depth"); + else if (ch === "}" || ch === "]") depth--; + } + const v = JSON.parse(s); + assert.deepEqual(body, c.encode(v), "canonical provision JSON: duplicates/alternate encoding rejected"); + fields(v, ["schema", "controllers", "cells", "reader"], "provision manifest"); + assert.equal(v.schema, "authoring-public-tools/v1"); assert.equal(v.reader, "linux-amd64"); + fields(v.controllers, TARGETS, "six controllers"); fields(v.cells, CELLS, "eighteen cells"); + for (const target of TARGETS) { + fields(v.controllers[target], TOOLS, target); + for (const t of TOOLS) if (v.controllers[target][t] !== null) tool(v.controllers[target][t], target); + } + for (const key of CELLS) { + const row = v.cells[key], target = key.split("/")[0], major = key.match(/node(\d+)$/)[1]; + fields(row, CELL_FIELDS, key); assert.equal(row.controller, target); + for (const t of ["runner", "image", "observer", "installer_policy"]) if (row[t] !== null) identity(row[t]); + for (const t of ["npm_node", "shim_node", "npm", "go"]) if (row[t] !== null) { + tool(row[t], target, t === "npm"); + if (t.endsWith("_node")) assert.match(row[t].version, new RegExp(`^v${major}\\.[0-9]+\\.[0-9]+$`)); + } + if (row.mod_cache !== null) closure(row.mod_cache, target); + } + return v; +} +function freeze(v) { if (v && typeof v === "object") { Object.values(v).forEach(freeze); Object.freeze(v); } return v; } +function readProvisioning() { + assert.equal(arguments.length, 0, "provision root is trusted source only"); + try { return freeze(decode(c.readFile(MANIFEST, LIMIT))); } + catch (e) { if (e.code === "ENOENT") absent("linux-amd64:manifest"); throw e; } +} +function checkTool(t, label) { + if (t === null) absent(label); + let bytes; + try { bytes = c.readFile(t.path, 256 * LIMIT); } + catch (e) { if (e.code === "ENOENT") absent(label); throw e; } + assert.equal(c.digest(bytes), t.sha256, `source-frozen provision pin mismatch:${label}`); +} +// Closure members alone may be empty; manifests and tools retain c.readFile. +function readClosureFile(file, maximum) { + c.safeDirectory(path.dirname(file)); + const before = fs.lstatSync(file, { bigint: true }); + assert.ok(before.isFile() && before.nlink === 1n && before.size >= 0n && before.size <= BigInt(maximum), + "closure file must be regular, bounded and unaliased"); + const same = st => ["dev", "ino", "mode", "nlink", "uid", "gid", "size", "mtimeNs", "ctimeNs"].every(k => st[k] === before[k]); + const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + assert.ok(same(fs.fstatSync(fd, { bigint: true })), "closure file changed"); + const body = Buffer.alloc(Number(before.size) + 1); + let length = 0, count; + while (length < body.length && (count = fs.readSync(fd, body, length, body.length - length, null)) > 0) length += count; + assert.ok(BigInt(length) === before.size && same(fs.fstatSync(fd, { bigint: true })) && + same(fs.lstatSync(file, { bigint: true })), "closure file changed"); + c.safeDirectory(path.dirname(file)); + return body.subarray(0, length); + } finally { fs.closeSync(fd); } +} +function checkClosure(value, label) { + if (value === null) absent(label); + const found = []; let entries = 0, total = 0; + function walk(dir, relative) { + c.safeDirectory(dir); + for (const name of fs.readdirSync(dir).sort()) { + assert.ok(++entries <= 8192, "closure entry bound"); + const file = path.join(dir, name), rel = relative ? `${relative}/${name}` : name, st = fs.lstatSync(file); + total += st.isFile() ? st.size : 0; assert.ok(total <= 256 * LIMIT, "closure byte bound"); + if (st.isDirectory()) walk(file, rel); + else { assert.ok(found.length < 4096, "closure bound"); found.push({ path: rel, sha256: c.digest(readClosureFile(file, 256 * LIMIT)) }); } + } + } + try { walk(value.root, ""); } catch (e) { if (e.code === "ENOENT") absent(label); throw e; } + found.sort((a, b) => a.path < b.path ? -1 : 1); + assert.deepEqual(found, value.files, `complete source-frozen closure mismatch:${label}`); +} +function requireController(key = "linux-amd64") { + assert.ok(arguments.length <= 1 && TARGETS.includes(key), "fixed controller key"); + const provision = readProvisioning(); + for (const t of TOOLS) checkTool(provision.controllers[key][t], `${key}:${t}`); + return provision.controllers[key].node.path; +} +function requireCellTools(key) { + assert.ok(arguments.length === 1 && CELLS.includes(key), "fixed cell key"); + const provision = readProvisioning(), row = provision.cells[key]; + // Availability checks precede any tool access or future cell scheduling. + const required = CELL_FIELDS.filter(n => !(["go", "mod_cache"].includes(n) && key !== "linux-amd64/pair-node22") && !(n === "installer_policy" && key.endsWith("kit-node18"))); + for (const name of required) if (row[name] === null) absent(`${key}:${name}`); + requireController(row.controller); + for (const name of ["npm_node", "shim_node", "npm", "go"]) if (required.includes(name)) checkTool(row[name], `${key}:${name}`); + checkClosure(row.npm.closure, `${key}:npm`); if (required.includes("mod_cache")) checkClosure(row.mod_cache, `${key}:mod_cache`); + return row; +} +module.exports = { readProvisioning, requireController, requireCellTools }; diff --git a/npm/agentplugins/test/public-authoring-tools.test.js b/npm/agentplugins/test/public-authoring-tools.test.js new file mode 100644 index 00000000..57ce5d19 --- /dev/null +++ b/npm/agentplugins/test/public-authoring-tools.test.js @@ -0,0 +1,153 @@ +"use strict"; +// SYNTHETIC provision only. Harmless bytes are hashed, never executed. +const test = require("node:test"), assert = require("node:assert/strict"); +const fs = require("node:fs"), path = require("node:path"), os = require("node:os"), vm = require("node:vm"); +const { createRequire } = require("node:module"); +const source = path.resolve(__dirname, "../scripts/public-authoring-tools.js"), local = createRequire(source); +const c = local("./dual-authoring-candidate"), shipped = local("./public-authoring-tools"); +const base = shipped.readProvisioning(), manifestPath = path.resolve(__dirname, "../../../.github/authoring-public-tools.json"); +function fixture(body = c.encode(base), read = c.readFile, filesystem = fs) { + const context = { module: { exports: {} }, Buffer, TextDecoder, __dirname: path.dirname(source), + require: id => id === "./dual-authoring-candidate" ? { ...c, readFile: (f, cap) => f === manifestPath ? body : read(f, cap) } : id === "node:fs" ? filesystem : local(id) }; + vm.runInThisContext("(function(require,module,__dirname){" + fs.readFileSync(source, "utf8") + "\n})", { filename: source })(context.require, context.module, context.__dirname); return context.module.exports; +} +const fresh = () => JSON.parse(c.encode(base)); +function pin(file) { return { path: file, version: "v22.21.1", sha256: c.digest(fs.readFileSync(file)) }; } +function prepared() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "C3b-tools-SYNTHETIC-")); + const file = path.join(root, "node"); fs.writeFileSync(file, "SYNTHETIC TOOL\n"); + const value = fresh(); for (const key of Object.keys(value.controllers["linux-amd64"])) value.controllers["linux-amd64"][key] = pin(file); + return { root, file, value }; +} +test("C3b tools frozen six controllers eighteen cells and unavailable provision", () => { + assert.equal(Object.keys(base.controllers).length, 6); assert.equal(Object.keys(base.cells).length, 18); + assert.equal(base.reader, "linux-amd64"); assert.ok(Object.isFrozen(base.cells)); + assert.throws(() => shipped.requireController(), /PUBLIC_PROVISIONING_REQUIRED:linux-amd64:node/); + for (const key of Object.keys(base.cells)) assert.throws(() => shipped.requireCellTools(key), new RegExp(`PUBLIC_PROVISIONING_REQUIRED:${key}:runner`)); + for (const fn of [() => shipped.readProvisioning("/receipt"), () => shipped.requireController("unknown"), + () => shipped.requireController("linux-amd64", base), () => shipped.requireCellTools("unknown")]) assert.throws(fn); +}); +test("C3b tools literal canonical fixture and closed malformed table", () => { + const literal = '{\n "path": "/synthetic/node",\n "version": "v22.21.1",\n "sha256": "' + c.digest(Buffer.from("SYNTHETIC TOOL\n")) + '"\n}'; + const value = fresh(); value.controllers["linux-amd64"].node = JSON.parse(literal); + assert.equal(JSON.stringify(fixture(c.encode(value)).readProvisioning().controllers["linux-amd64"].node, null, 2), literal); + const missing = fresh(); delete missing.cells["linux-arm64/kit-node18"]; + assert.throws(() => fixture(c.encode(missing)).readProvisioning(), /PUBLIC_PROVISIONING_REQUIRED:linux-arm64\/kit-node18:entry/); + const mutations = [v => delete v.cells["linux-arm64/kit-node18"], v => v.controllers.extra = {}, + v => delete v.controllers["linux-amd64"].tar, v => v.cells["linux-amd64/kit-node18"].extra = null, + v => v.reader = "windows-amd64", v => v.cells["linux-amd64/kit-node18"].controller = "linux-arm64", + v => v.controllers["linux-amd64"].node.sha256 = "0".repeat(64), v => v.controllers["linux-amd64"].node.path = "/synthetic/../node", + v => v.controllers["linux-amd64"].node.version = "x".repeat(257), + v => v.controllers["linux-amd64"].node.extra = true]; + for (const mutate of mutations) { const bad = JSON.parse(c.encode(value)); mutate(bad); assert.throws(() => fixture(c.encode(bad)).readProvisioning()); } + for (const body of [Buffer.from(JSON.stringify(value)), Buffer.from(c.encode(value).toString().replace(' "schema":', ' "reader": "linux-amd64",\n "schema":')), + Buffer.from('[['.repeat(10)), Buffer.alloc(1024 * 1024 + 1, 32), Buffer.from([255]), Buffer.from('null\n')]) { + assert.throws(() => fixture(body).readProvisioning()); + } +}); +test("C3b tools controller pins missing tools links and late replacement", () => { + const { file, value, root } = prepared(); const api = fixture(c.encode(value)); + assert.equal(api.requireController(), file); + fs.writeFileSync(file, "CHANGED TOOL\n"); assert.throws(() => api.requireController(), /pin mismatch/); + fs.writeFileSync(file, "SYNTHETIC TOOL\n"); + const link = path.join(root, "link"); fs.symlinkSync(file, link); + value.controllers["linux-amd64"].node.path = link; assert.throws(() => fixture(c.encode(value)).requireController()); + value.controllers["linux-amd64"].node.path = path.join(root, "missing"); + assert.throws(() => fixture(c.encode(value)).requireController(), /PUBLIC_PROVISIONING_REQUIRED:linux-amd64:node/); + value.controllers["linux-amd64"].node = pin(file); value.controllers["linux-amd64"].gh = null; + assert.throws(() => fixture(c.encode(value)).requireController(), /PUBLIC_PROVISIONING_REQUIRED:linux-amd64:gh/); + fs.linkSync(file, path.join(root, "hardlink")); assert.throws(() => api.requireController()); +}); +test("C3b tools complete npm closure and cell capabilities", () => { + const { file, value, root } = prepared(), key = "linux-amd64/pair-node22", row = value.cells[key]; + const npmRoot = path.join(root, "npm"); fs.mkdirSync(npmRoot); const cli = path.join(npmRoot, "npm-cli.js"); fs.writeFileSync(cli, "SYNTHETIC NPM\n"); + const files = [{ path: "npm-cli.js", sha256: pin(cli).sha256 }]; + for (const name of ["runner", "image", "observer", "installer_policy"]) row[name] = { id: "synthetic-only", sha256: pin(file).sha256 }; + for (const name of ["npm_node", "shim_node", "go"]) row[name] = pin(file); + row.npm = { ...pin(cli), closure: { root: npmRoot, files } }; row.mod_cache = { root: npmRoot, files }; + assert.equal(fixture(c.encode(value)).requireCellTools(key).npm.path, cli); + for (const name of ["image", "npm_node", "shim_node", "npm", "go", "mod_cache", "observer", "installer_policy"]) { + const bad = JSON.parse(c.encode(value)); bad.cells[key][name] = null; + assert.throws(() => fixture(c.encode(bad)).requireCellTools(key), new RegExp(`PUBLIC_PROVISIONING_REQUIRED:${key}:${name}`)); + } + const empty = path.join(npmRoot, "empty.js"); fs.writeFileSync(empty, ""); + assert.throws(() => fixture(c.encode(value)).requireCellTools(key), /closure mismatch/); + files.unshift({ path: "empty.js", sha256: pin(empty).sha256 }); + assert.equal(fixture(c.encode(value)).requireCellTools(key).mod_cache.files[0].sha256, c.digest(Buffer.alloc(0))); + // Empty members remain exhaustive and cannot be aliased or change mid-read. + const hard = path.join(root, "empty-hardlink"); fs.linkSync(empty, hard); + assert.throws(() => fixture(c.encode(value)).requireCellTools(key), /unaliased/); + // Retain fixtures without cleanup: use a fresh empty member after the alias case. + const nextRoot = path.join(root, "next-npm"); fs.mkdirSync(nextRoot); + fs.writeFileSync(path.join(nextRoot, "empty.js"), ""); fs.writeFileSync(path.join(nextRoot, "npm-cli.js"), "SYNTHETIC NPM\n"); + row.npm.path = path.join(nextRoot, "npm-cli.js"); row.npm.closure.root = nextRoot; row.mod_cache.root = nextRoot; + const target = path.join(nextRoot, "empty.js"); + let changed = false; + const filesystem = { ...fs, readSync(fd, ...args) { + if (!changed) { changed = true; fs.writeFileSync(target, "x"); } + return fs.readSync(fd, ...args); + } }; + assert.throws(() => fixture(c.encode(value), c.readFile, filesystem).requireCellTools(key), /closure file changed/); + fs.writeFileSync(target, ""); + const aliasRoot = path.join(root, "linked-npm"); fs.symlinkSync(nextRoot, aliasRoot); + const linked = JSON.parse(c.encode(value)); linked.cells[key].mod_cache.root = aliasRoot; + assert.throws(() => fixture(c.encode(linked)).requireCellTools(key), /symlink/); + const replacedDescriptor = { ...fs, fstatSync(fd, options) { + const st = fs.fstatSync(fd, options); return { ...st, ino: st.ino + 1n }; + } }; + assert.throws(() => fixture(c.encode(value), c.readFile, replacedDescriptor).requireCellTools(key), /closure file changed/); + const emptyTool = JSON.parse(c.encode(value)); emptyTool.controllers["linux-amd64"].node = pin(target); + assert.throws(() => fixture(c.encode(emptyTool)).requireController(), /nonempty/); + assert.throws(() => fixture(Buffer.alloc(0)).readProvisioning()); + const bad = JSON.parse(c.encode(value)); bad.cells[key].npm.closure.files.push(files[0]); + assert.throws(() => fixture(c.encode(bad)).readProvisioning(), /ordered unique/); + fs.writeFileSync(path.join(row.npm.closure.root, "unlisted.js"), "UNLISTED\n"); + assert.throws(() => fixture(c.encode(value)).requireCellTools(key), /complete source-frozen closure mismatch/); +}); +test("C3b tools manifest absence and source root cannot be substituted", () => { + const error = Object.assign(new Error("missing"), { code: "ENOENT" }); + const context = { module: { exports: {} }, Buffer, TextDecoder, __dirname: path.dirname(source), + require: id => id === "./dual-authoring-candidate" ? { ...c, readFile: f => { assert.equal(f, manifestPath); throw error; } } : local(id) }; + vm.runInThisContext("(function(require,module,__dirname){" + fs.readFileSync(source, "utf8") + "\n})")(context.require, context.module, context.__dirname); + assert.throws(() => context.module.exports.readProvisioning(), /PUBLIC_PROVISIONING_REQUIRED:linux-amd64:manifest/); + assert.throws(() => context.module.exports.readProvisioning(base), /trusted source only/); +}); +test("C3b tools JS Python agree on identical canonical fixture bytes", () => { + const { spawnSync } = require("node:child_process"); + const { root, value } = prepared(), bodies = [c.encode(base), c.encode(value)]; + for (const mutate of [v => v.reader = "darwin-amd64", v => delete v.cells["windows-arm64/pair-node24"], + v => v.controllers["linux-amd64"].node.extra = true, v => v.controllers["linux-amd64"].node.sha256 = "0".repeat(64), + v => v.controllers["linux-amd64"].node.path = "/synthetic/../node", v => v.controllers["linux-amd64"].node.path = "/synthetic/node/", + v => v.controllers["linux-amd64"].node = { version: "v22.21.1", path: "/synthetic/node", sha256: v.controllers["linux-amd64"].node.sha256 }]) { + const bad = JSON.parse(c.encode(value)); mutate(bad); bodies.push(c.encode(bad)); + } + bodies.push(Buffer.from(JSON.stringify(value)), Buffer.from(c.encode(value).toString().replace(' "schema":', ' "reader": "linux-amd64",\n "schema":')), + Buffer.from('['.repeat(9)), Buffer.alloc(1024 * 1024 + 1, 32), Buffer.from([255])); + const expected = bodies.map(body => { try { fixture(body).readProvisioning(); return true; } catch { return false; } }); + assert.deepEqual(expected, [true, true, ...Array(bodies.length - 2).fill(false)]); + // Identical canonical bytes at 4095/4096/4097 code points, including supplementary Unicode. + for (const [suffix, accepted] of [["", true], ["a", true], ["ab", false]]) { + const unicode = JSON.parse(c.encode(value)); + unicode.controllers["linux-amd64"].node.path = "/" + "😀".repeat(4094) + suffix; + const body = c.encode(unicode); bodies.push(body); expected.push(accepted); + if (accepted) assert.doesNotThrow(() => fixture(body).readProvisioning()); + else assert.throws(() => fixture(body).readProvisioning(), /provision path/); + } + bodies.forEach((body, i) => fs.writeFileSync(path.join(root, `${i}.json`), body)); + const code = `import importlib.util,json,pathlib,sys +spec=importlib.util.spec_from_file_location('provision',sys.argv[1]); p=importlib.util.module_from_spec(spec); spec.loader.exec_module(p) +root=pathlib.Path(sys.argv[2]); (root/'.github').mkdir(); p.__file__=str(root/'scripts/check-packed-ci.py'); results=[] +for i in range(int(sys.argv[3])): + (root/'.github/authoring-public-tools.json').write_bytes((root/(str(i)+'.json')).read_bytes()) + try: p.read_provisioning(); results.append(True) + except (ValueError,TypeError): results.append(False) +print(json.dumps(results)) +`; + const argv = ["-B", "-c", code, path.resolve(__dirname, "../../../scripts/check-packed-ci.py"), root, String(bodies.length)]; + const env = Object.fromEntries(["HOME", "TMPDIR", "TMP", "TEMP", "XDG_CACHE_HOME"].map(k => [k, process.env[k]])); + Object.assign(env, { PATH: "/usr/local/bin:/usr/bin:/bin", LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }); + const result = spawnSync("/usr/bin/python3", argv, { env, cwd: root, encoding: "utf8" }); + fs.writeFileSync(path.join(root, "agreement-receipt.json"), JSON.stringify({ argv: ["/usr/bin/python3", ...argv], env, cwd: root, + exit: result.status, stdout: result.stdout, stderr: result.stderr, fixtures: bodies.map(c.digest), expected }, null, 2) + "\n"); + assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ""); assert.deepEqual(JSON.parse(result.stdout), expected); +}); diff --git a/scripts/check-packed-ci.py b/scripts/check-packed-ci.py index a8aca890..4d4a356f 100644 --- a/scripts/check-packed-ci.py +++ b/scripts/check-packed-ci.py @@ -379,25 +379,158 @@ def authentic_read(path): return value +PROVISION_TARGETS = ('linux-amd64', 'linux-arm64', 'darwin-amd64', 'darwin-arm64', 'windows-amd64', 'windows-arm64') +PROVISION_CELLS = tuple(t + '/' + lane for t in PROVISION_TARGETS for lane in ('kit-node18', 'pair-node22', 'pair-node24')) +PROVISION_TOOLS = ('node', 'python', 'git', 'gh', 'tar') +PROVISION_FIELDS = ('runner', 'image', 'controller', 'npm_node', 'shim_node', 'npm', 'go', 'mod_cache', 'observer', 'installer_policy') + + +def provision_fields(value, names, label='manifest'): + if type(value) is dict: + for name in names: + missing = name + ':entry' if label in ('controllers', 'cells') else label + ':' + name + require(name in value, 'PUBLIC_PROVISIONING_REQUIRED:' + missing) + require(type(value) is dict and list(value) == list(names), 'closed ordered provision fields') + + +def provision_bytes(file, maximum=1024 * 1024): + import os + file = Path(file) + require(str(file) == os.path.abspath(file) and file.resolve() == file, 'canonical provision path') + before = file.lstat() + require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1 and 0 < before.st_size <= maximum, + 'bounded regular unaliased provision file') + with open(file, 'rb') as stream: + opened = os.fstat(stream.fileno()) + require((opened.st_dev, opened.st_ino) == (before.st_dev, before.st_ino), 'provision file changed') + body = stream.read(maximum + 1) + after = os.fstat(stream.fileno()) + # Reading may update atime (e.g. relatime on a fresh checkout). Compare + # identity and mutation metadata explicitly, retaining nanosecond precision. + def identity(st): + return (st.st_dev, st.st_ino, st.st_mode, st.st_nlink, st.st_uid, st.st_gid, + st.st_size, st.st_mtime_ns, st.st_ctime_ns) + require(identity(before) == identity(opened) == identity(after) == identity(file.lstat()) and + len(body) == before.st_size, 'provision file changed') + return body + + +def read_provisioning(): + # No caller root, expectedCommit, receipt, PATH or environment selection. + file = Path(__file__).absolute().parent.parent / '.github/authoring-public-tools.json' + try: body = provision_bytes(file) + except FileNotFoundError: raise ValueError('PUBLIC_PROVISIONING_REQUIRED:linux-amd64:manifest') from None + text = body.decode('utf-8'); depth = 0; quoted = escaped = False + for ch in text: + if quoted: + if escaped: escaped = False + elif ch == '\\': escaped = True + elif ch == '"': quoted = False + elif ch == '"': quoted = True + elif ch in '{[': + depth += 1; require(depth <= 8, 'provision depth') + elif ch in '}]': depth -= 1 + value = json.loads(text) + require(body == (json.dumps(value, indent=2, ensure_ascii=False) + '\n').encode(), 'canonical provision JSON') + def pin(v): require(type(v) is str and re.fullmatch('[0-9a-f]{64}', v) and v != '0' * 64, 'provision pin') + def label(v): require(type(v) is str and re.fullmatch('[!-~]{1,256}', v), 'bounded provision identity') + # Absolute path limit: 4096 Unicode code points, shared with JS. + def absolute(v, target): + import ntpath, posixpath + p = ntpath if target.startswith('windows-') else posixpath + require(type(v) is str and 0 < len(v) <= 4096 and not re.search('[\x00-\x1f\x7f]', v) and + p.isabs(v) and p.normpath(v) == v and v not in ('/', p.splitdrive(v)[0] + '\\') and + not v.startswith(('\\\\', '//')), 'canonical provision path') + def closure(v, target): + provision_fields(v, ('root', 'files')); absolute(v['root'], target) + require(type(v['files']) is list and 0 < len(v['files']) <= 4096, 'bounded provision closure') + last = '' + for row in v['files']: + provision_fields(row, ('path', 'sha256')); pin(row['sha256']); name = row['path'] + require(type(name) is str and len(name) <= 4096 and re.fullmatch(r'[A-Za-z0-9_.@+-]+(?:/[A-Za-z0-9_.@+-]+)*', name) and + not set(name.split('/')) & {'.', '..'} and name > last, 'ordered unique relative closure files') + last = name + def tool(v, target, npm=False): + provision_fields(v, ('path', 'version', 'sha256', 'closure') if npm else ('path', 'version', 'sha256')) + absolute(v['path'], target); label(v['version']); pin(v['sha256']) + if npm: + import ntpath, posixpath + closure(v['closure'], target); p = ntpath if target.startswith('windows-') else posixpath + require(any(p.join(v['closure']['root'], *f['path'].split('/')) == v['path'] and f['sha256'] == v['sha256'] + for f in v['closure']['files']), 'npm CLI in complete closure') + provision_fields(value, ('schema', 'controllers', 'cells', 'reader')) + require(value['schema'] == 'authoring-public-tools/v1' and value['reader'] == 'linux-amd64', 'fixed provision reader/schema') + provision_fields(value['controllers'], PROVISION_TARGETS, 'controllers'); provision_fields(value['cells'], PROVISION_CELLS, 'cells') + for target, row in value['controllers'].items(): + provision_fields(row, PROVISION_TOOLS, target) + for v in row.values(): + if v is not None: tool(v, target) + for key, row in value['cells'].items(): + target = key.split('/')[0]; provision_fields(row, PROVISION_FIELDS, key) + require(row['controller'] == target, 'fixed cell controller') + for name in ('runner', 'image', 'observer', 'installer_policy'): + if row[name] is not None: + provision_fields(row[name], ('id', 'sha256')); label(row[name]['id']); pin(row[name]['sha256']) + for name in ('npm_node', 'shim_node', 'npm', 'go'): + if row[name] is not None: + tool(row[name], target, name == 'npm') + if name.endswith('_node'): + require(re.fullmatch('v' + key.split('node')[1] + r'\.[0-9]+\.[0-9]+', row[name]['version']), 'selected Node major') + if row['mod_cache'] is not None: closure(row['mod_cache'], target) + return value + + def require_authenticated_controller(): - # Receipt-selected executables and their self-supplied hashes are not authority. - raise ValueError('missing independently provisioned trusted controller; C3b capability required') + value = read_provisioning() + for name, tool in value['controllers']['linux-amd64'].items(): + message = 'PUBLIC_PROVISIONING_REQUIRED:linux-amd64:' + name + require(tool is not None, message) + try: body = provision_bytes(tool['path'], 256 * 1024 * 1024) + except FileNotFoundError: raise ValueError(message) from None + require(hashlib.sha256(body).hexdigest() == tool['sha256'], 'source-frozen provision pin mismatch:' + name) + return value['controllers']['linux-amd64']['node']['path'] + + +def require_authenticated_execution(): + raise ValueError('C3b execution incomplete: result/installer/observer validators and independent invocation authority required') + + +def authenticated_source(): + # Snapshot trusted source, never receipt-selected source. External provisioning + # keeps this namespace immutable; before/after hashing is not same-UID isolation. + repo = Path(__file__).absolute().parent.parent + files = [] + for directory in ('.github', 'scripts', 'npm/agentplugins/scripts', 'npm/agentplugins/lib', 'npm/plugin-kit-ai/lib'): + def walk(folder): + require(folder.resolve() == folder and stat.S_ISDIR(folder.lstat().st_mode), 'trusted source directory') + for file in sorted(folder.iterdir()): + require(not file.is_symlink(), 'trusted source link') + if file.is_dir(): walk(file) + else: files.append(file) + require(len(files) <= 4096, 'trusted source closure bound') + walk(repo / directory) + return {str(f): hashlib.sha256(provision_bytes(f, 16 * 1024 * 1024)).hexdigest() for f in files} def authenticated_verify(node, argv): - require_authenticated_controller() - # Prepared reader path; C3b must independently bind its controller before use. + controller = require_authenticated_controller() + require(str(node) == controller, 'source-frozen controller comparison mismatch') + require_authenticated_execution() import subprocess - repo = Path(__file__).resolve().parent.parent + repo = Path(__file__).absolute().parent.parent bridge = repo / 'npm/agentplugins/scripts/packed-installer-bridge.js' - result = subprocess.run([str(node), str(bridge), *map(str, argv)], cwd=repo, - env={'PATH': '/usr/local/bin:/usr/bin:/bin', 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}, - capture_output=True, timeout=1200) + source = authenticated_source() + require(require_authenticated_controller() == controller and authenticated_source() == source, 'trusted source/controller changed') + try: + result = subprocess.run([controller, str(bridge), *map(str, argv)], cwd=repo, + env={'PATH': '/usr/local/bin:/usr/bin:/bin', 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}, + capture_output=True, timeout=1200) + finally: + require(require_authenticated_controller() == controller and authenticated_source() == source, 'trusted source/controller changed') require(result.returncode == 0 and result.stderr == b'', 'authenticated reader failed: ' + result.stderr.decode(errors='replace')[:4096]) require(len(result.stdout) <= 32 * 1024 * 1024, 'bounded authenticated reader output') return json.loads(result.stdout) - def authenticated_plans(root, sha, inputs, sealed_pin, fixture_root): """Complementary injected plans only; does not authenticate J or remote E.""" result = read(root / 'results/completion.json'); false_claims(result) @@ -414,11 +547,13 @@ def authenticated_plans(root, sha, inputs, sealed_pin, fixture_root): def check_authenticated(root, sha, require_summary=True, require_completed_e=False): require(not require_completed_e, 'completed E cannot use local J or fixture success; C3b E reader required') - require_authenticated_controller() + controller = require_authenticated_controller() + require_authenticated_execution() run = authentic_read(root / 'authenticated-run.json'); false_claims(run) require(set(run) == {'schema', 'head', 'options', 'tools', *CLAIMS} and run['schema'] == 'public-authenticated-packed-run/v1' and run['head'] == sha, 'authentic run schema') - options = run['options']; request = authenticated_options(options, sha) + options = run['options']; require(options['node'] == controller, 'source-frozen controller comparison mismatch') + request = authenticated_options(options, sha) sealed_path = root / 'bridge-config/sealed.json'; sealed = read(sealed_path); false_claims(sealed) require(set(sealed) == {'schema', 'request', 'verifier_sha256', 'helper_sha256', 'reader_sha256', 'inputs', *CLAIMS} and sealed['schema'] == AUTHENTIC_SEAL and sealed['request'] == request == read(root / 'bridge-config/request.json'), 'authentic seal schema') diff --git a/scripts/run-packed-ci.py b/scripts/run-packed-ci.py index 751ddefc..d6d0fdc2 100644 --- a/scripts/run-packed-ci.py +++ b/scripts/run-packed-ci.py @@ -205,9 +205,11 @@ def run(name, argv, extra=None): def authenticated_main(root, sha, options_path): """Same invocation local J intake. Never generate, copy or replay projects.""" - proof.require_authenticated_controller() + controller = proof.require_authenticated_controller() + proof.require_authenticated_execution() repo = Path(__file__).resolve().parent.parent options = proof.authentic_read(options_path) + proof.require(options.get('node') == controller, 'source-frozen controller comparison mismatch') request = proof.authenticated_options(options, sha) proof.require(root.is_absolute() and root.resolve() == root and not root.exists(), 'new canonical authentic output') proof.require(platform.system() == 'Linux' and platform.machine() == 'x86_64', 'native Linux amd64 required') @@ -217,7 +219,7 @@ def authenticated_main(root, sha, options_path): *[Path(admission[k]) for k in ('repo', 'work_parent', 'stage_root', 'input_root', 'journey_root', 'fixture_root')]] for other in protected: proof.require(other.is_absolute() and other.resolve() == other and not root.is_relative_to(other) and not other.is_relative_to(root), 'authentic output overlaps input') - # Prepared admission remains behind the unconditional Python controller gate. + # Full execution remains separately closed after independent controller binding. inputs = proof.authenticated_verify(options['node'], ['authenticated-options', options_path]) proof.require(inputs['repo'] == str(repo), 'authenticated source checkout') for key, name in [('node', 'orchestrator_node'), ('go', 'go')]: @@ -231,10 +233,13 @@ def authenticated_main(root, sha, options_path): def run(name, argv, extra=None): argv = list(map(str, argv)); record = dict(argv=argv, cwd=str(repo), env=dict(env, **(extra or {})), exit=None) started = time.monotonic() + source = proof.authenticated_source() + proof.require(proof.require_authenticated_controller() == controller and proof.authenticated_source() == source, 'trusted source/controller changed') try: with (root / 'logs' / (name + '.stdout')).open('x') as out, (root / 'logs' / (name + '.stderr')).open('x') as err: record['exit'] = subprocess.run(argv, cwd=repo, env=record['env'], stdout=out, stderr=err, timeout=1200).returncode finally: + proof.require(proof.require_authenticated_controller() == controller and proof.authenticated_source() == source, 'trusted source/controller changed') record['seconds'] = round(time.monotonic() - started, 3); write(root / 'logs' / (name + '.json'), record) proof.require(record['exit'] == 0, 'authenticated phase failed: ' + name) return (root / 'logs' / (name + '.stdout')).read_text() diff --git a/scripts/test_packed_ci.py b/scripts/test_packed_ci.py index 1f66d55a..912ced80 100644 --- a/scripts/test_packed_ci.py +++ b/scripts/test_packed_ci.py @@ -392,11 +392,13 @@ def test_dependencies_must_all_succeed(self): class C3AuthenticatedControls(unittest.TestCase): # SYNTHETIC gate mock exercises retained prepared intake validations only. - @patch.object(r.proof, 'require_authenticated_controller', return_value=None) - def test_closed_intake_before_output(self, synthetic_gate): + @patch.object(r.proof, 'require_authenticated_execution', return_value=None) + @patch.object(r.proof, 'require_authenticated_controller') + def test_closed_intake_before_output(self, synthetic_gate, synthetic_execution): from unittest.mock import patch root = Path(tempfile.mkdtemp(prefix='C3-runner-SYNTHETIC-')) options = root / 'options.json'; output = root / 'must-not-exist' + synthetic_gate.return_value = '/unused' for value in ({}, dict(request={'intake': 'public-fixture/v2'}, go='/unused', node='/unused', modCache='/unused')): options.write_text(json.dumps(value, indent=2) + '\n') with self.assertRaises(ValueError): r.authenticated_main(output, 'a' * 40, options) @@ -412,6 +414,7 @@ def test_closed_intake_before_output(self, synthetic_gate): admission.write_text(json.dumps(dirs, indent=2) + '\n') request = dict(intake=p.AUTHENTIC, expectedCommit='a' * 40, journey=str(journey), journeySha256=p.digest(journey), admission=str(admission), admissionSha256=p.digest(admission), fixtureRoot=dirs['fixture_root']) + synthetic_gate.return_value = str(tool) value = dict(request=request, go=str(tool), node=str(tool), modCache=str(modules)) options.write_text(json.dumps(value, indent=2) + '\n') with patch.object(r.proof, 'authenticated_verify', side_effect=ValueError('missing reviewed installer/observer')) as reader, \ @@ -420,8 +423,9 @@ def test_closed_intake_before_output(self, synthetic_gate): reader.assert_called_once(); planner.assert_not_called(); self.assertFalse(output.exists()) # SYNTHETIC gate mock exercises retained prepared terminal validations only. - @patch.object(p, 'require_authenticated_controller', return_value=None) - def test_exact_thirty_plans_and_post_seal(self, synthetic_gate): + @patch.object(p, 'require_authenticated_execution', return_value=None) + @patch.object(p, 'require_authenticated_controller') + def test_exact_thirty_plans_and_post_seal(self, synthetic_gate, synthetic_execution): root = Path(tempfile.mkdtemp(prefix='C3-plans-SYNTHETIC-')); (root / 'results').mkdir(); (root / 'logs').mkdir() fixture = root / 'original-projects'; fixture.mkdir() entries = [dict(path='.', mode=fixture.stat().st_mode & 0o777, kind='directory')] @@ -437,6 +441,7 @@ def test_exact_thirty_plans_and_post_seal(self, synthetic_gate): # readback, then corrupt real retained logs. No subprocess is launched. from unittest.mock import patch tool = root / 'tool'; tool.write_text('SYNTHETIC TOOL') + synthetic_gate.return_value = str(tool) modules = root / 'modules'; modules.mkdir() journey = root / 'J.json'; admission = root / 'admission.json' put(journey, {}); put(admission, {}) @@ -530,7 +535,7 @@ def test_substituted_node_rejected_before_authenticated_effects(self): ('direct-reader', lambda: p.authenticated_verify(options['node'], ['authenticated-options', options_path])), ): with self.subTest(entrypoint=name), self.assertRaisesRegex(ValueError, - 'missing independently provisioned trusted controller; C3b capability required'): + 'PUBLIC_PROVISIONING_REQUIRED:linux-amd64:node'): call() child.assert_not_called(); planner.assert_not_called(); write.assert_not_called() self.assertFalse(output.exists()) @@ -542,8 +547,152 @@ def test_completed_e_cannot_use_fixture_success(self): (root / 'summary.json').write_text(json.dumps(dict(status='passed', intake=schema, plans=30, projects=10))) with self.subTest(intake=schema), self.assertRaisesRegex(ValueError, 'completed E cannot use'): p.check_authenticated(root, 'a' * 40, require_completed_e=True) - with self.assertRaisesRegex(ValueError, 'missing independently provisioned trusted controller'): + with self.assertRaisesRegex(ValueError, 'PUBLIC_PROVISIONING_REQUIRED:linux-amd64:node'): p.check_authenticated(root, 'a' * 40) +class C3bProvisionControls(unittest.TestCase): + def fixture(self): + root = Path(tempfile.mkdtemp(prefix='C3b-provision-SYNTHETIC-')) + (root / '.github').mkdir(); (root / 'scripts').mkdir() + source = root / 'scripts/check-packed-ci.py'; source.write_text('SYNTHETIC SOURCE\n') + (root / 'scripts/run-packed-ci.py').write_text('SYNTHETIC RUNNER\n') + for folder in ('npm/agentplugins/scripts', 'npm/agentplugins/lib', 'npm/plugin-kit-ai/lib'): + (root / folder).mkdir(parents=True); (root / folder / 'source.js').write_text('SYNTHETIC SOURCE\n') + value = p.read_provisioning(); file = root / '.github/authoring-public-tools.json' + tool = root / 'node'; tool.write_text('SYNTHETIC TOOL\n') + for name in p.PROVISION_TOOLS: + value['controllers']['linux-amd64'][name] = dict(path=str(tool), version='v22.21.1', sha256=p.digest(tool)) + self.put(file, value) + return root, source, file, tool, value + + def put(self, file, value): file.write_text(json.dumps(value, indent=2, ensure_ascii=False) + '\n') + + def test_literal_canonical_and_closed_manifest_table(self): + root, source, file, tool, value = self.fixture() + literal = '{\n "path": "/synthetic/node",\n "version": "v22.21.1",\n "sha256": "' + p.hashlib.sha256(b'SYNTHETIC TOOL\n').hexdigest() + '"\n}' + value['controllers']['linux-amd64']['node'] = json.loads(literal); self.put(file, value) + with patch.object(p, '__file__', str(source)): + self.assertEqual(json.dumps(p.read_provisioning()['controllers']['linux-amd64']['node'], indent=2), literal) + missing = copy.deepcopy(value); del missing['cells']['linux-arm64/kit-node18']; self.put(file, missing) + with self.assertRaisesRegex(ValueError, 'PUBLIC_PROVISIONING_REQUIRED:linux-arm64/kit-node18:entry'): p.read_provisioning() + mutations = [lambda v: v['cells'].pop('linux-arm64/kit-node18'), lambda v: v['controllers'].update(extra={}), + lambda v: v['controllers']['linux-amd64'].pop('tar'), lambda v: v['cells']['linux-amd64/kit-node18'].update(extra=None), + lambda v: v.update(reader='windows-amd64'), lambda v: v['cells']['linux-amd64/kit-node18'].update(controller='linux-arm64'), + lambda v: v['controllers']['linux-amd64']['node'].update(sha256='0' * 64), + lambda v: v['controllers']['linux-amd64']['node'].update(path='/synthetic/../node'), + lambda v: v['controllers']['linux-amd64']['node'].update(version='x' * 257), + lambda v: v['controllers']['linux-amd64']['node'].update(extra=True)] + for index, mutate in enumerate(mutations): + bad = copy.deepcopy(value); mutate(bad); self.put(file, bad) + with self.subTest(case=index), self.assertRaises(ValueError): p.read_provisioning() + body = (json.dumps(value, indent=2) + '\n').encode() + for bad in (json.dumps(value).encode(), body.replace(b' "schema":', b' "reader": "linux-amd64",\n "schema":'), + b'[[' * 10, b' ' * (1024 * 1024 + 1), b'\xff', b'null\n'): + file.write_bytes(bad) + with self.subTest(bytes=len(bad)), self.assertRaises(ValueError): p.read_provisioning() + + def test_source_binding_tools_missing_pins_links_and_aliases(self): + root, source, file, tool, value = self.fixture() + with patch.object(p, '__file__', str(source)): + self.assertEqual(p.require_authenticated_controller(), str(tool)) + tool.write_text('CHANGED TOOL\n') + with self.assertRaisesRegex(ValueError, 'pin mismatch'): p.require_authenticated_controller() + tool.write_text('SYNTHETIC TOOL\n') + value['controllers']['linux-amd64']['gh'] = None; self.put(file, value) + with self.assertRaisesRegex(ValueError, 'PUBLIC_PROVISIONING_REQUIRED:linux-amd64:gh'): p.require_authenticated_controller() + value['controllers']['linux-amd64']['node']['path'] = str(root / 'missing'); self.put(file, value) + with self.assertRaisesRegex(ValueError, 'PUBLIC_PROVISIONING_REQUIRED:linux-amd64:node'): p.require_authenticated_controller() + link = root / 'link'; link.symlink_to(tool); value['controllers']['linux-amd64']['node']['path'] = str(link); self.put(file, value) + with self.assertRaisesRegex(ValueError, 'canonical provision path'): p.require_authenticated_controller() + with self.assertRaises(TypeError): p.require_authenticated_controller(root) + # A receipt with its own complete matching tool manifest cannot select root. + with self.assertRaisesRegex(ValueError, 'PUBLIC_PROVISIONING_REQUIRED:linux-amd64:node'): p.require_authenticated_controller() + with patch.object(p, '__file__', str(root / 'absent/scripts/check-packed-ci.py')): + with self.assertRaisesRegex(ValueError, 'PUBLIC_PROVISIONING_REQUIRED:linux-amd64:manifest'): p.require_authenticated_controller() + + def test_prepared_controller_rechecks_and_minimal_environment(self): + import subprocess + from types import SimpleNamespace + root, source, file, tool, value = self.fixture() + # Real tool-byte binding; only later unfinished capability is mocked. + # All subprocesses are mocked; SYNTHETIC TOOL is never executed. + with patch.object(p, '__file__', str(source)), patch.object(p, 'require_authenticated_execution'), \ + patch.object(subprocess, 'run', return_value=SimpleNamespace(returncode=0, stderr=b'', stdout=b'{}')) as child: + self.assertEqual(p.authenticated_verify(str(tool), ['authenticated-options', '/synthetic/options']), {}) + argv = child.call_args.args[0]; env = child.call_args.kwargs['env'] + self.assertEqual(argv, [str(tool), str(root / 'npm/agentplugins/scripts/packed-installer-bridge.js'), 'authenticated-options', '/synthetic/options']) + self.assertEqual(set(env), {'PATH', 'LANG', 'LC_ALL'}) + for name in ('NODE_OPTIONS', 'NODE_PATH', 'PYTHONPATH', 'PYTHONHOME', 'LD_PRELOAD', 'LD_LIBRARY_PATH'): + self.assertNotIn(name, env) + child.reset_mock() + with self.assertRaisesRegex(ValueError, 'comparison mismatch'): p.authenticated_verify('/receipt/node', []) + child.assert_not_called() + def changed(*args, **kwargs): + tool.write_text('LATE CHANGE\n'); return SimpleNamespace(returncode=0, stderr=b'', stdout=b'{}') + child.side_effect = changed + with self.assertRaisesRegex(ValueError, 'pin mismatch'): p.authenticated_verify(str(tool), []) + tool.write_text('SYNTHETIC TOOL\n'); child.side_effect = None + def changed_source(*args, **kwargs): + source.write_text('LATE SOURCE CHANGE\n'); return SimpleNamespace(returncode=0, stderr=b'', stdout=b'{}') + child.side_effect = changed_source + with self.assertRaisesRegex(ValueError, 'trusted source/controller changed'): p.authenticated_verify(str(tool), []) + child.reset_mock(); child.side_effect = None + controller = p.require_authenticated_controller; calls = [] + def before_launch(): + calls.append(True) + if len(calls) == 2: source.write_text('CHANGED BEFORE LAUNCH\n') + return controller() + with patch.object(p, 'require_authenticated_controller', side_effect=before_launch): + with self.assertRaisesRegex(ValueError, 'trusted source/controller changed'): p.authenticated_verify(str(tool), []) + child.assert_not_called() + + def test_supplementary_unicode_path_code_point_boundary(self): + root, source, file, tool, value = self.fixture() + for suffix, accepted in (('', True), ('a', True), ('ab', False)): + value['controllers']['linux-amd64']['node']['path'] = '/' + '😀' * 4094 + suffix + self.put(file, value) + with self.subTest(code_points=4095 + len(suffix)), patch.object(p, '__file__', str(source)): + if accepted: self.assertEqual(p.read_provisioning(), value) + else: + with self.assertRaisesRegex(ValueError, 'canonical provision path'): p.read_provisioning() + + def test_provision_read_atime_and_mutation_metadata(self): + import os + from types import SimpleNamespace + root = Path(tempfile.mkdtemp(prefix='C3b-stat-SYNTHETIC-')) + file = root / 'bytes'; file.write_bytes(b'harmless bytes') + original = os.fstat + def observed(**changes): + def result(fd): + st = original(fd) + fields = {name: getattr(st, name) for name in dir(st) if name.startswith('st_')} + fields.update(changes) + return SimpleNamespace(**fields) + return result + st = file.stat() + # Deterministic read-side atime transition; no filesystem mount assumptions. + with patch.object(os, 'fstat', side_effect=observed(st_atime=st.st_atime + 1, st_atime_ns=st.st_atime_ns + 1000000000)): + self.assertEqual(p.provision_bytes(file), b'harmless bytes') + for field in ('st_dev', 'st_ino', 'st_mode', 'st_nlink', 'st_uid', 'st_gid', 'st_size', 'st_mtime_ns', 'st_ctime_ns'): + changed = observed(**{field: getattr(st, field) + 1}) + calls = iter((False, True)) + with self.subTest(field=field), patch.object(os, 'fstat', side_effect=lambda fd: changed(fd) if next(calls) else original(fd)): + with self.assertRaisesRegex(ValueError, 'provision file changed'): p.provision_bytes(file) + file.write_bytes(b'') + with self.assertRaisesRegex(ValueError, 'bounded regular'): p.provision_bytes(file) + + def test_verified_controller_still_cannot_open_execution(self): + import subprocess + root, source, file, tool, value = self.fixture(); output = root / 'must-not-exist' + with patch.object(p, '__file__', str(source)), patch.object(r.proof, '__file__', str(source)), \ + patch.object(subprocess, 'run') as child, patch.object(r, 'write') as write, patch.object(r, 'planner') as planner: + for call in (lambda: p.authenticated_verify(str(tool), []), lambda: p.check_authenticated(root, 'a' * 40), + lambda: p.check_authenticated(root, 'a' * 40, require_summary=False), + lambda: r.authenticated_main(output, 'a' * 40, root / 'unread-receipt')): + with self.assertRaisesRegex(ValueError, 'C3b execution incomplete'): call() + child.assert_not_called(); write.assert_not_called(); planner.assert_not_called() + self.assertFalse(output.exists()) + + if __name__ == '__main__': unittest.main()