feat(foundation): offline synthetic scope/permission inspector (FI-FILBERT-8 APPROVED r6)
Rocko-authored, Filbert-reviewed inspector (r6 manifest a4a44930...) with full review/build/verdict evidence under docs/plans/reviews. 43/0 selftests, oracle zero-disagreement, foundation checker PASS. Owner A9 acceptance recorded separately.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* inspector-content-digest/1 — canonical form and digest for MOCK registry
|
||||
* content only (charter candidate 3 §6; feasibility r2 §6.1).
|
||||
*
|
||||
* Domain: objects, arrays, ASCII strings, booleans, null and safe integers.
|
||||
* Object keys are sorted by UTF-16 code unit; separators are compact; array
|
||||
* order is preserved; the digest is "sha256:" + hex(SHA-256(UTF-8 bytes)).
|
||||
* Anything outside the domain refuses with CanonicalError (reason
|
||||
* unsupported-capability): non-ASCII strings, non-integral or unsafe numbers,
|
||||
* negative zero, undefined, functions, symbols, bigints.
|
||||
*
|
||||
* This is NOT the candidate README launch-fingerprint projection and makes no
|
||||
* JCS (RFC 8785) claim. Pure module: imports only node:crypto.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const DIGEST_ALGORITHM = "inspector-content-digest/1";
|
||||
|
||||
export class CanonicalError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.name = "CanonicalError";
|
||||
this.code = code;
|
||||
this.reason = "unsupported-capability";
|
||||
}
|
||||
}
|
||||
|
||||
function isAscii(text) {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text.charCodeAt(i) > 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function encodeString(text) {
|
||||
if (!isAscii(text)) throw new CanonicalError("string-not-ascii");
|
||||
// JSON.stringify on an ASCII string escapes exactly ", \ and U+0000..U+001F
|
||||
// (short forms \b \f \n \r \t, otherwise \u00XX lowercase), matching Python
|
||||
// json.dumps for the ASCII domain.
|
||||
return JSON.stringify(text);
|
||||
}
|
||||
|
||||
function encodeValue(value, depth) {
|
||||
if (depth > 64) throw new CanonicalError("depth-exceeded");
|
||||
if (value === null) return "null";
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
return value ? "true" : "false";
|
||||
case "number":
|
||||
if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
|
||||
throw new CanonicalError("number-not-safe-integer");
|
||||
}
|
||||
return String(value);
|
||||
case "string":
|
||||
return encodeString(value);
|
||||
case "object":
|
||||
break;
|
||||
default:
|
||||
throw new CanonicalError("value-not-json");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = [];
|
||||
for (const item of value) parts.push(encodeValue(item, depth + 1));
|
||||
return `[${parts.join(",")}]`;
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
keys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
const parts = [];
|
||||
for (const key of keys) {
|
||||
const item = value[key];
|
||||
if (item === undefined) throw new CanonicalError("value-not-json");
|
||||
parts.push(`${encodeString(key)}:${encodeValue(item, depth + 1)}`);
|
||||
}
|
||||
return `{${parts.join(",")}}`;
|
||||
}
|
||||
|
||||
/** Canonical compact encoding, or throws CanonicalError. */
|
||||
export function canonicalize(value) {
|
||||
return encodeValue(value, 0);
|
||||
}
|
||||
|
||||
/** "sha256:<hex>" of the canonical encoding, or throws CanonicalError. */
|
||||
export function digestOf(value) {
|
||||
const canonical = canonicalize(value);
|
||||
return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Unit tests for inspector-content-digest/1 (charter §6, feasibility r2 §6.1 vectors).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { canonicalize, digestOf, CanonicalError, DIGEST_ALGORITHM } from "./canonical.mjs";
|
||||
|
||||
const V1 = {
|
||||
restrictions: {
|
||||
operations: ["work.read", "file.read"],
|
||||
readPaths: [{ root: "workspace", path: null }],
|
||||
writePaths: [],
|
||||
network: "none",
|
||||
endpointRefs: [],
|
||||
},
|
||||
};
|
||||
|
||||
function refuses(value, code) {
|
||||
assert.throws(() => digestOf(value), (e) => e instanceof CanonicalError && e.code === code && e.reason === "unsupported-capability");
|
||||
}
|
||||
|
||||
test("algorithm identifier is fixed", () => {
|
||||
assert.equal(DIGEST_ALGORITHM, "inspector-content-digest/1");
|
||||
});
|
||||
|
||||
test("V1: pinned digest of the reader restrictions", () => {
|
||||
assert.equal(
|
||||
canonicalize(V1),
|
||||
'{"restrictions":{"endpointRefs":[],"network":"none","operations":["work.read","file.read"],"readPaths":[{"path":null,"root":"workspace"}],"writePaths":[]}}',
|
||||
);
|
||||
assert.equal(digestOf(V1), "sha256:0bc44e14fd8354a8a85be879306a54881da7a0cb4d3dcf1f88bc4bc08a40dc9c");
|
||||
});
|
||||
|
||||
test("V2: array order is significant", () => {
|
||||
const v2 = structuredClone(V1);
|
||||
v2.restrictions.operations = ["file.read", "work.read"];
|
||||
assert.equal(digestOf(v2), "sha256:63817bffc57803ca6ac5df971aa0a160d4be73646394b3bc536578bd66c82660");
|
||||
assert.notEqual(digestOf(v2), digestOf(V1));
|
||||
});
|
||||
|
||||
test("V3: writer restrictions", () => {
|
||||
const v3 = structuredClone(V1);
|
||||
v3.restrictions.operations = ["work.read", "file.read", "file.change"];
|
||||
v3.restrictions.writePaths = [{ root: "workspace", path: "docs" }];
|
||||
assert.equal(digestOf(v3), "sha256:d538c865808cfe7665956615fb48f69819baa0833626d1ef6e1cb3614acbc7da");
|
||||
});
|
||||
|
||||
test("object key order is not significant", () => {
|
||||
const reordered = {
|
||||
restrictions: {
|
||||
writePaths: [], endpointRefs: [], network: "none",
|
||||
readPaths: [{ path: null, root: "workspace" }],
|
||||
operations: ["work.read", "file.read"],
|
||||
},
|
||||
};
|
||||
assert.equal(digestOf(reordered), digestOf(V1));
|
||||
const nullProto = Object.create(null);
|
||||
nullProto.restrictions = reordered.restrictions;
|
||||
assert.equal(digestOf(nullProto), digestOf(V1));
|
||||
});
|
||||
|
||||
test("V4: non-integral numbers refuse", () => {
|
||||
const v4 = structuredClone(V1);
|
||||
v4.restrictions.weight = 1.5;
|
||||
refuses(v4, "number-not-safe-integer");
|
||||
refuses({ a: -0 }, "number-not-safe-integer");
|
||||
refuses({ a: 9007199254740992 }, "number-not-safe-integer");
|
||||
refuses({ a: Number.NaN }, "number-not-safe-integer");
|
||||
refuses({ a: Number.POSITIVE_INFINITY }, "number-not-safe-integer");
|
||||
assert.equal(canonicalize({ a: 9007199254740991, b: -5, c: 0 }), '{"a":9007199254740991,"b":-5,"c":0}');
|
||||
});
|
||||
|
||||
test("V5: non-ASCII strings and keys refuse", () => {
|
||||
const v5 = structuredClone(V1);
|
||||
v5.restrictions.note = "café";
|
||||
refuses(v5, "string-not-ascii");
|
||||
refuses({ "kéy": 1 }, "string-not-ascii");
|
||||
refuses({ a: "\u{1F600}" }, "string-not-ascii");
|
||||
});
|
||||
|
||||
test("ASCII control characters are escaped like Python json.dumps", () => {
|
||||
assert.equal(canonicalize({ a: "x\ty\n\"\\" }), '{"a":"x\\ty\\n\\u0001\\"\\\\"}');
|
||||
assert.equal(canonicalize({ a: "" }), '{"a":""}');
|
||||
});
|
||||
|
||||
test("keys sort by UTF-16 code unit, not locale", () => {
|
||||
assert.equal(canonicalize({ b: 1, B: 2, a: 3, A: 4, _: 5, "10": 6, "2": 7 }), '{"10":6,"2":7,"A":4,"B":2,"_":5,"a":3,"b":1}');
|
||||
});
|
||||
|
||||
test("undefined, functions, symbols, bigints refuse; depth is bounded", () => {
|
||||
refuses({ a: undefined }, "value-not-json");
|
||||
refuses({ a: () => 1 }, "value-not-json");
|
||||
refuses({ a: Symbol("s") }, "value-not-json");
|
||||
refuses({ a: 1n }, "value-not-json");
|
||||
refuses(undefined, "value-not-json");
|
||||
let deep = 1;
|
||||
for (let i = 0; i < 64; i += 1) deep = [deep];
|
||||
assert.ok(canonicalize(deep));
|
||||
deep = [deep];
|
||||
refuses(deep, "depth-exceeded");
|
||||
});
|
||||
|
||||
test("booleans, null and nesting encode compactly and deterministically", () => {
|
||||
const value = { z: [true, false, null, { y: [], x: {} }] };
|
||||
assert.equal(canonicalize(value), '{"z":[true,false,null,{"x":{},"y":[]}]}');
|
||||
assert.equal(digestOf(value), digestOf(structuredClone(value)));
|
||||
assert.match(digestOf(value), /^sha256:[0-9a-f]{64}$/);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
// Isolated CLI tests: usage, I/O failures, bounds, output escaping, privacy and
|
||||
// non-effect inventory (charter §7, §8, §10.3). Every run uses a disposable synthetic
|
||||
// sandbox (home, cwd, fixtures, data-root) with canary files; nothing here touches
|
||||
// the operator's real HOME, ~/.mosaic or any credential.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdtempSync, mkdirSync, writeFileSync, symlinkSync, readdirSync, lstatSync, readFileSync, rmSync, chmodSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { renderText, renderJson, escapeText, run } from "../foundation-inspect.mjs";
|
||||
import { exitFor } from "./resolve.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = join(HERE, "..", "foundation-inspect.mjs");
|
||||
const DEMO = join(HERE, "fixtures", "demo");
|
||||
const CANARY = "CANARY-DO-NOT-PRINT";
|
||||
|
||||
let root;
|
||||
let env;
|
||||
let cwd;
|
||||
|
||||
function sha256(buf) {
|
||||
return createHash("sha256").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
/** path/type/size/mode/uid/gid/ino/mtime/content-hash inventory of a tree, sorted by path (charter §8 ownership included). */
|
||||
function inventory(dir, out = []) {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const p = join(dir, name);
|
||||
const st = lstatSync(p);
|
||||
const type = st.isDirectory() ? "dir" : st.isSymbolicLink() ? "link" : st.isFIFO() ? "fifo" : st.isFile() ? "file" : "other";
|
||||
const content = st.isFile() ? sha256(readFileSync(p)) : null;
|
||||
out.push([p.slice(root.length), type, st.size, st.mode, st.uid, st.gid, st.ino, st.mtimeMs, content].join("|"));
|
||||
if (st.isDirectory()) inventory(p, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inspect(args, opts = {}) {
|
||||
const r = spawnSync(process.execPath, [CLI, ...args], {
|
||||
cwd: opts.cwd || cwd, env: opts.env || env, encoding: "buffer", timeout: 30000,
|
||||
});
|
||||
assert.equal(r.error, undefined, "spawn failed");
|
||||
return { status: r.status, stdout: r.stdout.toString("utf8"), stderr: r.stderr.toString("utf8"), raw: r.stdout };
|
||||
}
|
||||
|
||||
function jsonRun(args, opts) {
|
||||
const r = inspect(["--json", ...args], opts);
|
||||
assert.equal(r.stderr, "", "stderr must be empty");
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.ok(!("exit" in parsed), "exit is process metadata, never a serialized field");
|
||||
assert.equal(r.status, exitFor(parsed), "process exit code is derived from the closed result");
|
||||
return { ...r, parsed };
|
||||
}
|
||||
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "foundation-cli-"));
|
||||
for (const d of ["home", "home/.mosaic", "home/.config", "home/.config/mosaic", "cwd", "fixtures", "data-root", "data-root/runs"]) {
|
||||
mkdirSync(join(root, d), { recursive: true });
|
||||
}
|
||||
writeFileSync(join(root, "home", ".mosaic", "credentials.json"), `{"token":"${CANARY}-1"}\n`);
|
||||
writeFileSync(join(root, "home", ".config", "mosaic", "config.json"), `{"secret":"${CANARY}-2"}\n`);
|
||||
writeFileSync(join(root, "cwd", "bundle.json"), `{"canary":"${CANARY}-3"}\n`);
|
||||
writeFileSync(join(root, "data-root", "runs", "result.json"), `{"canary":"${CANARY}-4"}\n`);
|
||||
writeFileSync(join(root, "cwd", "package.json"), `{"name":"${CANARY}-5"}\n`);
|
||||
for (const name of readdirSync(DEMO)) {
|
||||
if (name.endsWith(".json") && !name.includes(".expected.")) writeFileSync(join(root, "fixtures", name), readFileSync(join(DEMO, name)));
|
||||
}
|
||||
cwd = join(root, "cwd");
|
||||
env = {
|
||||
HOME: join(root, "home"),
|
||||
TMPDIR: join(root, "data-root"),
|
||||
PATH: "/nonexistent",
|
||||
MOSAIC_CONFIG: join(root, "home", ".config", "mosaic", "config.json"),
|
||||
XDG_CONFIG_HOME: join(root, "home", ".config"),
|
||||
MOSAIC_DATA_ROOT: join(root, "data-root"),
|
||||
LANG: "C",
|
||||
};
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("usage errors exit 2 usage-invalid without touching any file", () => {
|
||||
const before = inventory(root);
|
||||
for (const args of [[], ["--json"], ["a.json", "b.json"], ["--nope", "a.json"], ["--json", "--json", "a.json"], ["-x"]]) {
|
||||
const r = jsonRun(args);
|
||||
assert.equal(r.status, 2, JSON.stringify(args));
|
||||
assert.equal(r.parsed.rule, "usage-invalid");
|
||||
assert.equal(r.parsed.reason, "invalid-request");
|
||||
assert.equal(r.parsed.result, "invalid");
|
||||
assert.equal(r.parsed.diagnostic, null);
|
||||
}
|
||||
const t = inspect([]);
|
||||
assert.equal(t.status, 2);
|
||||
assert.match(t.stdout, /^SYNTHETIC PREVIEW/);
|
||||
assert.deepEqual(inventory(root), before);
|
||||
});
|
||||
|
||||
test("missing file: exit 4 input-open-failed with the input path echoed in the diagnostic only", () => {
|
||||
const r = jsonRun(["does-not-exist.json"]);
|
||||
assert.equal(r.status, 4);
|
||||
assert.equal(r.parsed.rule, "input-open-failed");
|
||||
assert.equal(r.parsed.reason, "io-failure");
|
||||
assert.deepEqual(r.parsed.diagnostic, { byteOffset: null, inputPath: "does-not-exist.json" });
|
||||
assert.equal(r.parsed.selection, null);
|
||||
});
|
||||
|
||||
test("symlink to a valid bundle is refused (O_NOFOLLOW) with exit 4", () => {
|
||||
symlinkSync(join(root, "fixtures", "demo-read-w1.json"), join(root, "cwd", "link.json"));
|
||||
const r = jsonRun(["link.json"]);
|
||||
assert.equal(r.status, 4);
|
||||
assert.equal(r.parsed.rule, "input-open-failed");
|
||||
assert.equal(r.parsed.diagnostic.inputPath, "link.json");
|
||||
});
|
||||
|
||||
test("directory and FIFO are not regular files: exit 4 input-not-regular", () => {
|
||||
const d = jsonRun([join(root, "fixtures")]);
|
||||
assert.equal(d.status, 4);
|
||||
assert.equal(d.parsed.rule, "input-not-regular");
|
||||
const fifo = join(root, "cwd", "pipe.json");
|
||||
const mk = spawnSync("mkfifo", [fifo], { env: { ...env, PATH: "/usr/bin:/bin" } });
|
||||
if (mk.status !== 0) {
|
||||
assert.fail("mkfifo unavailable; FIFO case cannot be exercised");
|
||||
}
|
||||
const f = jsonRun(["pipe.json"]);
|
||||
assert.equal(f.status, 4, "FIFO must be refused after O_NONBLOCK open, not read");
|
||||
assert.equal(f.parsed.rule, "input-not-regular");
|
||||
});
|
||||
|
||||
test("unreadable regular file: exit 4 input-open-failed", (t) => {
|
||||
if (typeof process.getuid === "function" && process.getuid() === 0) {
|
||||
t.skip("root ignores mode bits");
|
||||
return;
|
||||
}
|
||||
const p = join(root, "cwd", "unreadable.json");
|
||||
writeFileSync(p, "{}\n");
|
||||
chmodSync(p, 0o000);
|
||||
const r = jsonRun(["unreadable.json"]);
|
||||
assert.equal(r.status, 4);
|
||||
assert.equal(r.parsed.rule, "input-open-failed");
|
||||
chmodSync(p, 0o600);
|
||||
});
|
||||
|
||||
test("oversize file: exit 2 input-too-large with a null diagnostic (no path echo)", () => {
|
||||
const p = join(root, "cwd", "big.json");
|
||||
const buf = Buffer.alloc(1024 * 1024 + 1, 0x20);
|
||||
buf[0] = 0x7b; buf[buf.length - 1] = 0x7d;
|
||||
writeFileSync(p, buf);
|
||||
const r = jsonRun(["big.json"]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.equal(r.parsed.rule, "input-too-large");
|
||||
assert.equal(r.parsed.reason, "invalid-request");
|
||||
assert.equal(r.parsed.diagnostic, null);
|
||||
rmSync(p);
|
||||
// exactly 1 MiB is read and parsed (here: an object, so a shape refusal, not a bound)
|
||||
const exact = Buffer.alloc(1024 * 1024, 0x20);
|
||||
exact[0] = 0x7b; exact[exact.length - 1] = 0x7d;
|
||||
writeFileSync(p, exact);
|
||||
const e = jsonRun(["big.json"]);
|
||||
assert.equal(e.status, 2);
|
||||
assert.equal(e.parsed.rule, "shape-missing-field");
|
||||
rmSync(p);
|
||||
});
|
||||
|
||||
test("parse failure carries byteOffset and never the path", () => {
|
||||
writeFileSync(join(root, "cwd", "dup.json"), '{"a": 1, "a": 2}');
|
||||
const r = jsonRun(["dup.json"]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.equal(r.parsed.rule, "input-parse-failed");
|
||||
assert.deepEqual(r.parsed.diagnostic, { byteOffset: 9, inputPath: null });
|
||||
});
|
||||
|
||||
test("text output is derived from the same result as JSON output", () => {
|
||||
for (const name of ["demo-read-w1", "demo-change-w1", "adm-registration-revoked"]) {
|
||||
const j = jsonRun([join(root, "fixtures", `${name}.json`)]);
|
||||
const t = inspect([join(root, "fixtures", `${name}.json`)]);
|
||||
assert.equal(t.stderr, "");
|
||||
assert.equal(t.status, j.status);
|
||||
assert.equal(t.stdout, renderText(j.parsed), name);
|
||||
assert.equal(j.stdout, renderJson(j.parsed), name);
|
||||
assert.equal(j.stdout, renderJson(JSON.parse(j.stdout)), `${name}: JSON output is a pure round-trip`);
|
||||
}
|
||||
});
|
||||
|
||||
test("owner demo: permitted preview exits 0 and prints the disclaimer first", () => {
|
||||
const t = inspect([join(root, "fixtures", "demo-read-w1.json")]);
|
||||
assert.equal(t.status, 0);
|
||||
const lines = t.stdout.split("\n");
|
||||
assert.equal(lines[0], "SYNTHETIC PREVIEW — NO LIVE EFFECTS");
|
||||
assert.equal(lines[1], "preview: no live registrations or permission grants");
|
||||
assert.ok(lines.includes("result: allowed"));
|
||||
assert.ok(!lines.some((l) => l.startsWith("exit")), "no exit line in text output");
|
||||
});
|
||||
|
||||
test("risky code points in echoed fields are escaped in both renderings", () => {
|
||||
const bundle = JSON.parse(readFileSync(join(root, "fixtures", "demo-read-w1.json"), "utf8"));
|
||||
bundle.operation = { name: "file.read", target: { root: "workspace", path: "src/a\u2028b" } };
|
||||
writeFileSync(join(root, "cwd", "sep.json"), `${JSON.stringify(bundle)}\n`);
|
||||
const j = jsonRun(["sep.json"]);
|
||||
assert.equal(j.status, 0, "U+2028 is Zl, accepted by the pinned checker");
|
||||
assert.ok(!j.stdout.includes("\u2028"), "JSON output escapes U+2028");
|
||||
assert.ok(j.stdout.includes("\\u2028"));
|
||||
const t = inspect(["sep.json"]);
|
||||
assert.ok(!t.stdout.includes("\u2028"));
|
||||
assert.ok(t.stdout.includes("operation: file.read target workspace:src/a\\u2028b"));
|
||||
// ESC in a selection id is a shape refusal; the id must still not reach the terminal raw
|
||||
bundle.operation = { name: "work.read", target: null };
|
||||
bundle.selection.agentId = "agent-a\u001b[31m";
|
||||
writeFileSync(join(root, "cwd", "esc.json"), `${JSON.stringify(bundle)}\n`);
|
||||
const e = inspect(["esc.json"]);
|
||||
assert.equal(e.status, 2);
|
||||
assert.ok(!e.stdout.includes("\u001b"));
|
||||
assert.ok(e.stdout.includes("selection: null"));
|
||||
assert.equal(escapeText("a\u001b\u2028\\\u{1F600}b"), "a\\u001b\\u2028\\u005c\u{1F600}b");
|
||||
});
|
||||
|
||||
test("canaries never reach stdout: bundle strings, HOME, config, cwd, data-root", () => {
|
||||
const bundle = JSON.parse(readFileSync(join(root, "fixtures", "demo-read-w1.json"), "utf8"));
|
||||
for (const r of bundle.records) if (r.kind === "project") r.payload.displayName = `${CANARY}-6`;
|
||||
writeFileSync(join(root, "cwd", "canary.json"), `${JSON.stringify(bundle)}\n`);
|
||||
const outputs = [];
|
||||
for (const args of [["canary.json"], ["--json", "canary.json"], ["missing.json"], ["--json", "missing.json"], []]) {
|
||||
const r = inspect(args);
|
||||
outputs.push(r.stdout, r.stderr);
|
||||
}
|
||||
const joined = outputs.join("\n");
|
||||
assert.ok(!joined.includes(CANARY), "canary content leaked to output");
|
||||
assert.ok(!joined.includes(root), "sandbox root path leaked to output");
|
||||
});
|
||||
|
||||
test("HOME/config independence: identical bytes across different synthetic environments", () => {
|
||||
const args = ["--json", join(root, "fixtures", "demo-change-w1.json")];
|
||||
const a = inspect(args);
|
||||
const altHome = join(root, "home2");
|
||||
mkdirSync(altHome, { recursive: true });
|
||||
const b = inspect(args, { env: { HOME: altHome, PATH: "/nonexistent", MOSAIC_CONFIG: "/nonexistent/config.json", NODE_OPTIONS: "" }, cwd: root });
|
||||
const c = inspect(args);
|
||||
assert.equal(a.stdout, b.stdout);
|
||||
assert.equal(a.stdout, c.stdout);
|
||||
assert.equal(a.status, 3);
|
||||
assert.equal(JSON.parse(a.stdout).result, "unresolved");
|
||||
});
|
||||
|
||||
test("non-effect: before/after inventory of the sandbox is unchanged by every kind of run", () => {
|
||||
rmSync(join(root, "cwd", "pipe.json"), { force: true });
|
||||
const before = inventory(root);
|
||||
const runs = [
|
||||
["demo-read-w1.json"], ["--json", "demo-change-w1.json"], ["adm-registration-revoked.json"],
|
||||
["prop-message-is-not-authority.json"], ["demo-file-change-src.json"],
|
||||
];
|
||||
for (const args of runs) {
|
||||
const r = inspect(args.map((a) => (a.endsWith(".json") ? join(root, "fixtures", a) : a)));
|
||||
assert.equal(r.stderr, "");
|
||||
assert.ok([0, 3].includes(r.status), args.join(" "));
|
||||
}
|
||||
inspect(["../home/.mosaic/credentials.json"]);
|
||||
inspect([join(root, "home", ".config", "mosaic", "config.json")]);
|
||||
inspect(["missing.json"]);
|
||||
inspect([]);
|
||||
const after = inventory(root);
|
||||
assert.deepEqual(after, before);
|
||||
assert.deepEqual(readdirSync(join(root, "data-root")), ["runs"]);
|
||||
assert.deepEqual(readdirSync(join(root, "data-root", "runs")), ["result.json"]);
|
||||
});
|
||||
|
||||
test("in-process run() agrees with the spawned CLI", () => {
|
||||
const p = join(root, "fixtures", "demo-read-w1.json");
|
||||
const inproc = run(["--json", p]);
|
||||
const spawned = jsonRun([p]);
|
||||
assert.deepEqual(inproc.result, spawned.parsed);
|
||||
assert.equal(inproc.json, true);
|
||||
assert.equal(run([]).result.rule, "usage-invalid");
|
||||
assert.equal(run(["--json"]).json, true);
|
||||
});
|
||||
|
||||
test("profile negative control: one final LF in a typed field is refused pre-admission through the CLI and never echoed", () => {
|
||||
const bundle = JSON.parse(readFileSync(join(root, "fixtures", "demo-read-w1.json"), "utf8"));
|
||||
bundle.selection.agentId = `${bundle.selection.agentId}\n`;
|
||||
const p = join(root, "cwd", "profile-lf.json");
|
||||
writeFileSync(p, `${JSON.stringify(bundle)}\n`);
|
||||
const r = jsonRun(["profile-lf.json"]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.equal(r.parsed.result, "invalid");
|
||||
assert.equal(r.parsed.reason, "invalid-request");
|
||||
assert.equal(r.parsed.rule, "profile-pattern-mismatch");
|
||||
assert.equal(r.parsed.selection, null);
|
||||
assert.equal(r.parsed.operation, null);
|
||||
assert.equal(r.parsed.proposal, null);
|
||||
assert.equal(r.parsed.diagnostic, null);
|
||||
assert.ok(!r.stdout.includes("agent-a"), "typed value never echoed");
|
||||
const t = inspect(["profile-lf.json"]);
|
||||
assert.equal(t.status, 2);
|
||||
assert.equal(t.stderr, "");
|
||||
assert.ok(t.stdout.includes("\nrule: profile-pattern-mismatch\n"));
|
||||
assert.ok(t.stdout.includes("\nselection: null\n"));
|
||||
assert.ok(!t.stdout.includes("agent-a"));
|
||||
// Two final LFs fail the schema pattern itself (both implementations), not the profile.
|
||||
bundle.selection.agentId = `${bundle.selection.agentId}\n`;
|
||||
writeFileSync(p, `${JSON.stringify(bundle)}\n`);
|
||||
const two = jsonRun(["profile-lf.json"]);
|
||||
assert.equal(two.status, 2);
|
||||
assert.equal(two.parsed.rule, "shape-pattern-mismatch");
|
||||
// Positive control: free-form text with escaped newlines is not blanket-rejected.
|
||||
const ok = JSON.parse(readFileSync(join(root, "fixtures", "demo-read-w1.json"), "utf8"));
|
||||
ok.records.find((x) => x.kind === "mission" && x.id === "m-w1").payload.objective = "line one\nline two\n";
|
||||
writeFileSync(join(root, "cwd", "text-lf.json"), `${JSON.stringify(ok)}\n`);
|
||||
const allowed = jsonRun(["text-lf.json"]);
|
||||
assert.equal(allowed.status, 0);
|
||||
assert.equal(allowed.parsed.result, "allowed");
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// Every fixture case runs through the real CLI in a fresh subprocess; the observed
|
||||
// exit/result/reason/rule/proposalRule/byteOffset must equal index.json. The owner
|
||||
// demo goldens are compared byte-for-byte.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { renderText, run } from "../foundation-inspect.mjs";
|
||||
import { exitFor } from "./resolve.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = join(HERE, "fixtures");
|
||||
const CLI = join(HERE, "..", "foundation-inspect.mjs");
|
||||
const INDEX = JSON.parse(readFileSync(join(FIXTURES, "index.json"), "utf8"));
|
||||
const ENV = { PATH: "/nonexistent", HOME: "/nonexistent", LANG: "C" };
|
||||
const RAW_RISKY_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u2028\u2029\ufeff\u200b]/;
|
||||
|
||||
function spawnJson(file) {
|
||||
const r = spawnSync(process.execPath, [CLI, "--json", file], { cwd: FIXTURES, env: ENV, encoding: "utf8", timeout: 30000 });
|
||||
assert.equal(r.error, undefined);
|
||||
assert.equal(r.stderr, "", `${file}: stderr must be empty`);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.ok(!("exit" in parsed), `${file}: exit is not a serialized field`);
|
||||
assert.equal(r.status, exitFor(parsed), `${file}: exit code derived from the closed result`);
|
||||
return { parsed, stdout: r.stdout, status: r.status };
|
||||
}
|
||||
|
||||
test("index is complete and every listed file exists", () => {
|
||||
assert.equal(INDEX.count, INDEX.cases.length);
|
||||
assert.equal(INDEX.generator, "build-fixtures.mjs");
|
||||
const names = INDEX.cases.map((c) => c.name);
|
||||
assert.deepEqual(names, [...names].sort(), "index sorted by name");
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
const listed = new Set(INDEX.cases.map((c) => c.file));
|
||||
for (const dir of ["bundles", "raw"]) {
|
||||
for (const f of readdirSync(join(FIXTURES, dir))) assert.ok(listed.has(`${dir}/${f}`), `${dir}/${f} not in index`);
|
||||
}
|
||||
for (const c of INDEX.cases) assert.ok(existsSync(join(FIXTURES, c.file)), c.file);
|
||||
});
|
||||
|
||||
test("every fixture case matches its expected verdict through the CLI", () => {
|
||||
const failures = [];
|
||||
for (const c of INDEX.cases) {
|
||||
const { parsed, status } = spawnJson(c.file);
|
||||
const got = {
|
||||
exit: status, result: parsed.result, reason: parsed.reason, rule: parsed.rule,
|
||||
proposalRule: parsed.proposal === null ? null : parsed.proposal.rule,
|
||||
};
|
||||
const want = { exit: c.expect.exit, result: c.expect.result, reason: c.expect.reason, rule: c.expect.rule, proposalRule: c.expect.proposalRule };
|
||||
if ("byteOffset" in c.expect) {
|
||||
got.byteOffset = parsed.diagnostic === null ? null : parsed.diagnostic.byteOffset;
|
||||
want.byteOffset = c.expect.byteOffset;
|
||||
if (parsed.diagnostic !== null) assert.equal(parsed.diagnostic.inputPath, null, `${c.name}: path never echoed on parse failure`);
|
||||
} else {
|
||||
assert.equal(parsed.diagnostic, null, `${c.name}: diagnostic only on I/O or parse failure`);
|
||||
}
|
||||
if (JSON.stringify(got) !== JSON.stringify(want)) failures.push(`${c.name}: got ${JSON.stringify(got)} want ${JSON.stringify(want)}`);
|
||||
if (status === 0) assert.equal(parsed.rule, null, c.name);
|
||||
if (parsed.proposal !== null) assert.equal(parsed.result, parsed.proposal.result, c.name);
|
||||
}
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test("in-process text rendering agrees with the JSON result for every case", () => {
|
||||
for (const c of INDEX.cases) {
|
||||
const file = join(FIXTURES, c.file);
|
||||
const text = run([file]);
|
||||
const json = run(["--json", file]);
|
||||
assert.equal(text.json, false);
|
||||
assert.deepEqual(text.result, json.result, c.name);
|
||||
assert.equal(text.exit, c.expect.exit, c.name);
|
||||
assert.equal(json.exit, c.expect.exit, c.name);
|
||||
const rendered = renderText(text.result);
|
||||
assert.ok(rendered.startsWith("SYNTHETIC PREVIEW — NO LIVE EFFECTS\n"), c.name);
|
||||
assert.ok(/\ndiagnostic: [^\n]*\n$/.test(rendered), `${c.name}: text ends with the diagnostic line, no exit line`);
|
||||
assert.ok(!/^exit/m.test(rendered), c.name);
|
||||
assert.ok(!RAW_RISKY_RE.test(rendered), `${c.name}: raw control in text output`);
|
||||
}
|
||||
});
|
||||
|
||||
test("owner demo goldens match byte-for-byte", () => {
|
||||
const names = readdirSync(join(FIXTURES, "demo")).filter((f) => f.endsWith(".json") && !f.includes(".expected."));
|
||||
assert.equal(names.length, 5);
|
||||
for (const name of names) {
|
||||
const base = name.slice(0, -5);
|
||||
const file = join(FIXTURES, "demo", name);
|
||||
const text = spawnSync(process.execPath, [CLI, file], { cwd: FIXTURES, env: ENV, encoding: "utf8" });
|
||||
const json = spawnSync(process.execPath, [CLI, "--json", file], { cwd: FIXTURES, env: ENV, encoding: "utf8" });
|
||||
assert.equal(text.stdout, readFileSync(join(FIXTURES, "demo", `${base}.expected.txt`), "utf8"), `${base}.expected.txt`);
|
||||
assert.equal(json.stdout, readFileSync(join(FIXTURES, "demo", `${base}.expected.json`), "utf8"), `${base}.expected.json`);
|
||||
assert.equal(text.status, json.status);
|
||||
assert.equal(String(text.status), readFileSync(join(FIXTURES, "demo", `${base}.expected.exit`), "utf8").trim());
|
||||
// the demo copy equals the indexed bundle it was copied from
|
||||
assert.equal(readFileSync(file, "utf8"), readFileSync(join(FIXTURES, "bundles", name), "utf8"), `${base}: demo copy drifted`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
# Foundation inspector fixtures
|
||||
|
||||
Synthetic, deterministic inputs for `scripts/foundation-inspect.mjs` and its tests.
|
||||
Nothing here is live: identities, digests, times and paths are invented by
|
||||
`build-fixtures.mjs` (SHA-256 of `synthetic:<label>` for every fake digest).
|
||||
|
||||
| Path | Content |
|
||||
| --- | --- |
|
||||
| `build-fixtures.mjs` | Generator. `node scripts/foundation/fixtures/build-fixtures.mjs <out-dir>` |
|
||||
| `bundles/<case>.json` | One complete bundle per evaluation case (pretty JSON, trailing newline) |
|
||||
| `raw/<case>.json` | Byte-exact lexical cases (duplicate keys, BOM, invalid UTF-8, oversize, ...) |
|
||||
| `index.json` | Expected `exit/result/reason/rule/proposalRule[/byteOffset]` per case, sorted by name |
|
||||
| `demo/<case>.json` | Owner-demo copies of five bundles, byte-identical to `bundles/` |
|
||||
| `demo/<case>.expected.{txt,json,exit}` | Goldens: text output, `--json` output and exit code |
|
||||
|
||||
## Regeneration
|
||||
|
||||
```sh
|
||||
node scripts/foundation/fixtures/build-fixtures.mjs /tmp/fx
|
||||
diff -r /tmp/fx/bundles scripts/foundation/fixtures/bundles
|
||||
diff -r /tmp/fx/raw scripts/foundation/fixtures/raw
|
||||
diff /tmp/fx/index.json scripts/foundation/fixtures/index.json
|
||||
```
|
||||
|
||||
`scripts/test-foundation.sh` performs exactly this comparison; a checked-in
|
||||
fixture that differs from a fresh generation fails the suite. Goldens are
|
||||
produced by running the inspector on the demo copies:
|
||||
|
||||
```sh
|
||||
cd scripts/foundation/fixtures/demo
|
||||
for f in *.json; do case "$f" in *.expected.*) continue;; esac
|
||||
b="${f%.json}"
|
||||
node ../../../foundation-inspect.mjs "$f" > "$b.expected.txt"; echo $? > "$b.expected.exit"
|
||||
node ../../../foundation-inspect.mjs --json "$f" > "$b.expected.json"
|
||||
done
|
||||
```
|
||||
|
||||
## Case groups (see `index.json`)
|
||||
|
||||
positive, shape, profile, identity, structure, cycle, continuity, ownership,
|
||||
operation, admission, registration-delegation, proposal, lexical (raw). Every
|
||||
rule in the closed vocabulary except the CLI/I-O rules (covered by
|
||||
`../cli.test.mjs` with a disposable sandbox) is exercised by at least one case;
|
||||
`../resolve.test.mjs` enforces that.
|
||||
|
||||
The `profile-*` cases (addendum FI-C2-1) cover the strict typed-string profile:
|
||||
one final LF on an `id`/`runtimeId`/`digest` value is schema-valid under the pinned
|
||||
checker's `$` semantics and refused by the inspector as `profile-pattern-mismatch`
|
||||
before any identity lookup (group `profile`); two final LFs, CRLF, CR, interior LF,
|
||||
U+2028 and U+2029 fail the schema pattern itself (group `shape`); free-form text
|
||||
with escaped newlines stays allowed (group `positive`).
|
||||
|
||||
The FI-FILBERT-6 correction cases (verdict findings F1–F5) are the review's witness
|
||||
recipes rebuilt from the frozen demo bundles, each with positive controls:
|
||||
`adm-registry-declaration-*` and `adm-*-authorization-undeclared` (F1: every consulted
|
||||
registry reference resolves by exact registry/id/revision/digest, rule
|
||||
`registry-declaration-missing`; consulted dependency/mission/subject-task
|
||||
authorizations must be declared), `adm-assigned-task-without-work-read*` and
|
||||
`prop-task-only-*` (F2: a bound task is consulted work), `prop-issuer-*` (F3: the
|
||||
issuer ceiling narrows both requester calculations and work access is rechecked),
|
||||
`cycle-*versioned*` / `cycle-*-cross-revision` / `cycle-mission-parent-*revision*`
|
||||
(F4: revision-exact cycle graphs), `shape-order-*` and `prop-message-*` /
|
||||
`prop-delegation-missing-after-requester-admission` (F5: first failure independent of
|
||||
record input order; message-is-not-authority after structural validation, before
|
||||
requester admission). `../resolve.test.mjs` asserts each recipe's first failure and
|
||||
permutation independence directly.
|
||||
|
||||
The FI-ROCKO-7 ordering cases extend F5 to the declared inventories (charter §10.4
|
||||
applies beyond records): `shape-order-registries-*`, `shape-order-artifacts-*`,
|
||||
`shape-order-authorizations-*`, `shape-order-delegation-inputs-*` (two different shape
|
||||
errors per pair, forward and reversed, plus a "swapped" pair where the errors change
|
||||
places and a malformed entry without a readable typed key, which sorts last),
|
||||
`shape-order-family-precedence-*` (records, registries, artifacts, authorizations,
|
||||
delegationInputs stay in that stage order), `profile-order-*` (strict-profile
|
||||
violations follow the same key; any shape failure precedes them),
|
||||
`shape-order-registry-digest-*` / `shape-order-profile-precedes-digest-registries` /
|
||||
`shape-order-two-digest-mismatches-reversed` (content-digest traversal in
|
||||
registry/id/revision/digest order after the profile stage), `dup-order-*` (identity
|
||||
stage) and `struct-order-delegation-inputs-*` (structural stage). Typed keys:
|
||||
registries `registry/id/revision/digest`, artifacts `runId/artifactId/digest`,
|
||||
authorizations the runtime id, delegationInputs `decisionRef` kind/id/scope/revision;
|
||||
equal keys tie-break on the entry's total ordering form (below). Semantic sequence arrays (instruction,
|
||||
skill, endpoint, dependency, subject and evidence lists, paths, canonical content) are
|
||||
never reordered. `../resolve.test.mjs` asserts the located first failure over every
|
||||
permutation (all permutations for inventories of up to four entries, a fixed
|
||||
deterministic family beyond that).
|
||||
|
||||
The FI-ROCKO-8 cases (FI-FILBERT-7 finding R5-1) fix the tie-break itself: r5 broke
|
||||
ties on the ASCII-only mock-digest canonicalizer and mapped every non-ASCII entry to one
|
||||
empty form, so two distinct Unicode entries with equal (or no) typed keys fell back to
|
||||
input order. The inspector now orders ties by a total ordering form defined over the
|
||||
whole strict-JSON input domain (sorted keys, array order kept, every string as a JSON
|
||||
literal, no normalization), which is injective on distinct values and never a digest or
|
||||
a validity judgement. `shape-order-unkeyed-unicode-<family>-{forward,reversed}` (two
|
||||
unreadable non-ASCII entries per family: a string and an object, `shape-type-mismatch`
|
||||
in both orders), `shape-order-equal-key-unicode-{records,registries}-{forward,reversed}`
|
||||
(two copies with identical typed keys, a legitimate non-ASCII value and one different
|
||||
shape error each: `shape-unknown-field` in both orders) and
|
||||
`positive-unicode-display-name-allowed` (control). `../resolve.test.mjs` asserts the
|
||||
form's totality and injectivity against `canonicalize()` refusals and the permutation
|
||||
stability of every pair.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1813
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1798
File diff suppressed because it is too large
Load Diff
+1838
File diff suppressed because it is too large
Load Diff
+1837
File diff suppressed because it is too large
Load Diff
+1823
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1816
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2118
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1865
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user