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,244 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* foundation-inspect — offline synthetic permission inspector (charter candidate 3).
|
||||
*
|
||||
* node scripts/foundation-inspect.mjs [--json] <bundle.json>
|
||||
*
|
||||
* SYNTHETIC PREVIEW — NO LIVE EFFECTS. Reads exactly one bounded regular file,
|
||||
* evaluates it with the pure core in scripts/foundation/ and prints a closed result.
|
||||
* It never spawns processes, reads environment/config, touches the network, or
|
||||
* consults any state outside the named file. Exit: 0 permitted preview, 2 malformed
|
||||
* or unsupported input, 3 simulated refusal or unresolved admission, 4 I/O failure.
|
||||
*/
|
||||
|
||||
import { openSync, fstatSync, readSync, closeSync, constants as fsConstants } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { parseStrict, StrictJsonError, MAX_INPUT_BYTES } from "./foundation/strict-json.mjs";
|
||||
import { evaluate, buildResult, exitFor } from "./foundation/resolve.mjs";
|
||||
|
||||
const REQUIRED_FLAGS = ["O_RDONLY", "O_NOFOLLOW", "O_NONBLOCK"];
|
||||
|
||||
class IoFailure extends Error {
|
||||
constructor(exit, reason, rule, diagnostic) {
|
||||
super(rule);
|
||||
this.exit = exit;
|
||||
this.reason = reason;
|
||||
this.rule = rule;
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
const RISKY_RE = /^[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]$/u;
|
||||
|
||||
/** \uXXXX (or a surrogate-pair escape for astral code points) for one code point. */
|
||||
function escapeCodePoint(cp) {
|
||||
if (cp > 0xffff) {
|
||||
const v = cp - 0x10000;
|
||||
return `\\u${(0xd800 + (v >> 10)).toString(16)}\\u${(0xdc00 + (v & 0x3ff)).toString(16)}`;
|
||||
}
|
||||
return `\\u${cp.toString(16).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape C0/C1 controls, format, lone surrogate and line/paragraph separators
|
||||
* (and backslash) as \uXXXX, iterating by code point so well-formed astral
|
||||
* characters pass through unchanged.
|
||||
*/
|
||||
export function escapeText(value) {
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
const cp = ch.codePointAt(0);
|
||||
out += (cp === 0x5c || RISKY_RE.test(ch)) ? escapeCodePoint(cp) : ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function refText(ref) {
|
||||
if (ref === null) return "null";
|
||||
const scope = ref.scope.kind === "system" ? "system"
|
||||
: ref.scope.kind === "project" ? `project:${escapeText(ref.scope.projectId)}`
|
||||
: `workspace:${escapeText(ref.scope.projectId)}/${escapeText(ref.scope.workspaceId)}`;
|
||||
return `${escapeText(ref.kind)} ${escapeText(ref.id)} @${scope} rev ${ref.revision}`;
|
||||
}
|
||||
|
||||
/** Text rendering derived from the same closed result object as the JSON form. */
|
||||
export function renderText(r) {
|
||||
const lines = [];
|
||||
lines.push(r.disclaimer);
|
||||
lines.push(r.preview);
|
||||
lines.push(`authentication: ${r.authentication}; declarations: ${r.declarations}`);
|
||||
lines.push(`result: ${r.result}`);
|
||||
lines.push(`reason: ${r.reason}`);
|
||||
lines.push(`rule: ${r.rule === null ? "null" : r.rule}`);
|
||||
if (r.selection === null) {
|
||||
lines.push("selection: null");
|
||||
} else {
|
||||
const s = r.selection;
|
||||
lines.push(`selection: agent ${escapeText(s.agentId)}, project ${escapeText(s.projectId)}, workspace ${escapeText(s.workspaceId)}, assignment ${refText(s.assignmentRef)}`);
|
||||
}
|
||||
if (r.operation === null) {
|
||||
lines.push("operation: null");
|
||||
} else {
|
||||
const target = r.operation.target === null ? "null" : `workspace:${escapeText(r.operation.target.path)}`;
|
||||
lines.push(`operation: ${escapeText(r.operation.name)} target ${target}`);
|
||||
}
|
||||
if (r.proposal === null) {
|
||||
lines.push("proposal: null");
|
||||
} else {
|
||||
const p = r.proposal;
|
||||
lines.push(`proposal: ${p.result} (${p.reason}; ${p.rule}) selected assignment ${refText(p.selectedAssignmentRef)}`);
|
||||
}
|
||||
if (r.diagnostic === null) {
|
||||
lines.push("diagnostic: null");
|
||||
} else {
|
||||
const d = r.diagnostic;
|
||||
lines.push(`diagnostic: byteOffset ${d.byteOffset === null ? "null" : d.byteOffset}; inputPath ${d.inputPath === null ? "null" : escapeText(d.inputPath)}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid JSON whose decoded value equals the result. Inside string tokens, risky
|
||||
* code points (controls, format, lone surrogates, line/paragraph separators) become
|
||||
* \\uXXXX escapes; structural whitespace outside strings is left alone.
|
||||
*/
|
||||
export function renderJson(r) {
|
||||
const raw = JSON.stringify(r, null, 2);
|
||||
let out = "";
|
||||
let inString = false;
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
if (inString) {
|
||||
if (ch === "\\") {
|
||||
out += ch + raw[i + 1];
|
||||
i += 1;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
out += ch;
|
||||
} else {
|
||||
const cp = raw.codePointAt(i);
|
||||
const whole = String.fromCodePoint(cp);
|
||||
out += RISKY_RE.test(whole) ? escapeCodePoint(cp) : whole;
|
||||
i += whole.length - 1;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"') inString = true;
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return `${out}\n`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let json = false;
|
||||
let input = null;
|
||||
for (const a of argv) {
|
||||
if (a === "--json") {
|
||||
if (json) return { error: true, json };
|
||||
json = true;
|
||||
} else if (a.startsWith("-") && a !== "-") {
|
||||
return { error: true, json };
|
||||
} else if (input === null) {
|
||||
input = a;
|
||||
} else {
|
||||
return { error: true, json };
|
||||
}
|
||||
}
|
||||
if (input === null) return { error: true, json };
|
||||
return { error: false, json, input };
|
||||
}
|
||||
|
||||
/** Bounded, non-following, regular-file read. Returns a Uint8Array or throws IoFailure. */
|
||||
export function readBundleBytes(inputPath) {
|
||||
for (const name of REQUIRED_FLAGS) {
|
||||
if (typeof fsConstants[name] !== "number") {
|
||||
throw new IoFailure(4, "io-failure", "open-flags-unavailable", { byteOffset: null, inputPath });
|
||||
}
|
||||
}
|
||||
const flags = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK;
|
||||
let fd = -1;
|
||||
try {
|
||||
try {
|
||||
fd = openSync(inputPath, flags);
|
||||
} catch (e) {
|
||||
throw new IoFailure(4, "io-failure", "input-open-failed", { byteOffset: null, inputPath });
|
||||
}
|
||||
let st;
|
||||
try {
|
||||
st = fstatSync(fd);
|
||||
} catch (e) {
|
||||
throw new IoFailure(4, "io-failure", "input-open-failed", { byteOffset: null, inputPath });
|
||||
}
|
||||
if (!st.isFile()) throw new IoFailure(4, "io-failure", "input-not-regular", { byteOffset: null, inputPath });
|
||||
// Size gate is a bound refusal (exit 2), not an I/O failure: no path echo, no offset.
|
||||
if (st.size > MAX_INPUT_BYTES) throw new IoFailure(2, "invalid-request", "input-too-large", null);
|
||||
const size = Number(st.size);
|
||||
const buf = new Uint8Array(size + 1);
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
let n;
|
||||
try {
|
||||
n = readSync(fd, buf, total, buf.length - total, null);
|
||||
} catch (e) {
|
||||
throw new IoFailure(4, "io-failure", "input-open-failed", { byteOffset: null, inputPath });
|
||||
}
|
||||
if (n === 0) break;
|
||||
total += n;
|
||||
if (total >= buf.length) break;
|
||||
}
|
||||
if (total !== size) throw new IoFailure(4, "io-failure", "input-size-changed", { byteOffset: null, inputPath });
|
||||
return buf.subarray(0, size);
|
||||
} finally {
|
||||
if (fd >= 0) {
|
||||
try { closeSync(fd); } catch (e) { /* nothing further to release */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closed(fields) {
|
||||
return buildResult({ selection: null, operation: null, proposal: null, diagnostic: null, ...fields });
|
||||
}
|
||||
|
||||
function withExit(json, result) {
|
||||
return { json, result, exit: exitFor(result) };
|
||||
}
|
||||
|
||||
/** Returns {json, result, exit}: exit is process metadata derived from the closed result. */
|
||||
export function run(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.error) {
|
||||
return withExit(args.json, closed({ exit: 2, reason: "invalid-request", rule: "usage-invalid" }));
|
||||
}
|
||||
let bytes;
|
||||
try {
|
||||
bytes = readBundleBytes(args.input);
|
||||
} catch (e) {
|
||||
if (e instanceof IoFailure) {
|
||||
return withExit(args.json, closed({ exit: e.exit, reason: e.reason, rule: e.rule, diagnostic: e.diagnostic }));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = parseStrict(bytes);
|
||||
} catch (e) {
|
||||
if (e instanceof StrictJsonError) {
|
||||
const rule = e.code === "input-too-large" ? "input-too-large" : "input-parse-failed";
|
||||
return withExit(args.json, closed({ exit: 2, reason: "invalid-request", rule, diagnostic: { byteOffset: e.byteOffset, inputPath: null } }));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const evaluated = evaluate(value);
|
||||
return { json: args.json, result: evaluated.result, exit: evaluated.exit };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { json, result, exit } = run(process.argv.slice(2));
|
||||
process.stdout.write(json ? renderJson(result) : renderText(result));
|
||||
process.exitCode = exit;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolvePath(process.argv[1])).href) {
|
||||
main();
|
||||
}
|
||||
Reference in New Issue
Block a user