mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
* feat: consolidate offline eval and operator workflows
Compose the retained framework, operator skill, roadmap and cleanup ranges on current main. Preserve current release dependencies and keep candidate execution disabled pending OS containment. Repair draft/DOCX behavior, obligation uniqueness, trusted send and audience guidance, runner provenance and eval diagnostics.
Source-PR: 2930 0abe3727d2b500c6e4830bdeb47ed67cae3f4785
Source-PR: 2931 992b49c44ed872def49675b791168b8fcd091df6
Source-PR: 2932 4a193dd13041cb7a6bebf4d2e910a0cd32bcc797
Source-PR: 2933 59cdfe500a91949ba1415f1edd7279620f21e804
Source-Base: ca185ef5f7
* fix: repair foundation CI and update js-yaml
* fix: reconcile pending-delete capsule locks after close
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
259 lines
11 KiB
JavaScript
259 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Static variant inspection and disabled execution gate.
|
|
*
|
|
* Framework 3 of the eval-harness set. Candidate execution is disabled
|
|
* because no verified
|
|
* OS containment backend exists. Neither a copied directory, JavaScript
|
|
* interception nor a caller-supplied trusted-local flag is a security boundary.
|
|
*
|
|
* Tripwires are cheap and syntactic on purpose: sandbox weakening, marker
|
|
* tampering, hidden network enablement, and effect-class expansion are
|
|
* reported by static inspection. Absence of detected tripwires does not
|
|
* establish containment or prevent reward hacking.
|
|
*
|
|
* Non-goals: no automatic merge or release, no online self-editing, and no
|
|
* claim that a small taskset measures small score deltas precisely.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { sha256Hex } = require('./canonical');
|
|
const envelope = require('./envelope');
|
|
|
|
const GATE_SCHEMA = 'gate-receipt/v1';
|
|
|
|
const DEFAULT_THRESHOLDS = Object.freeze({
|
|
smoke_tasks: 3,
|
|
min_pass_rate: 0.9,
|
|
max_regressions: 0,
|
|
timeout_ms: 20000,
|
|
});
|
|
|
|
/** Default syntactic tripwires. Each hit names the rule, file, and line. */
|
|
const DEFAULT_TRIPWIRES = Object.freeze([
|
|
{ rule: 'hidden_network', pattern: /require\(\s*['"](?:node:)?(?:http|https|net|tls|dgram|dns|http2)['"]\s*\)/ },
|
|
{ rule: 'hidden_network', pattern: /\bfetch\s*\(/ },
|
|
{ rule: 'process_spawn', pattern: /require\(\s*['"](?:node:)?child_process['"]\s*\)/ },
|
|
{ rule: 'sandbox_weakening', pattern: /Module\._load|--no-sandbox|NODE_OPTIONS|effect-fence|ECC_EFFECT_FENCE/ },
|
|
{ rule: 'checker_probe', pattern: /taskset|expected_output|\.gate-marker|gate-receipt|ECC_GATE_/ },
|
|
{ rule: 'parent_escape', pattern: /(?:^|[^.\w])\.\.(?:[\\/]|['"`])/ },
|
|
]);
|
|
|
|
class GateError extends Error {
|
|
constructor(code, message, details = {}) {
|
|
super(message);
|
|
this.name = 'GateError';
|
|
this.code = code;
|
|
Object.assign(this, details);
|
|
}
|
|
}
|
|
|
|
function listFiles(dir, base = dir, acc = []) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
if (entry.name === 'node_modules' || entry.name === '.git') {
|
|
continue;
|
|
}
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
|
throw new GateError('gate.variant_invalid', 'variant trees must contain only regular files and directories');
|
|
}
|
|
if (entry.isDirectory()) {
|
|
listFiles(full, base, acc);
|
|
} else if (entry.isFile()) {
|
|
acc.push(path.relative(base, full).split(path.sep).join('/'));
|
|
}
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
/** Read the opened regular file, never reopen a previously checked pathname.
|
|
* No-follow/nonblocking flags reduce symlink and special-file hazards where
|
|
* supported. Descriptor/path identity also rejects symlinks on other hosts.
|
|
* This is static inspection of a caller-controlled tree, not OS containment.
|
|
*/
|
|
function readRegularFile(filePath, encoding) {
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
|
|
let fd;
|
|
try {
|
|
fd = fs.openSync(filePath, flags);
|
|
const opened = fs.fstatSync(fd);
|
|
const current = fs.lstatSync(filePath);
|
|
if (!opened.isFile() || !current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) {
|
|
throw new GateError('gate.variant_invalid', 'inspection requires the same regular file');
|
|
}
|
|
return fs.readFileSync(fd, encoding);
|
|
} catch (error) {
|
|
if (error.code === 'ELOOP') throw new GateError('gate.variant_invalid', 'inspection refuses symbolic links');
|
|
throw error;
|
|
} finally {
|
|
if (fd !== undefined) fs.closeSync(fd);
|
|
}
|
|
}
|
|
|
|
/** Content digest of a directory tree: sorted relative paths and bytes. */
|
|
function digestDir(dir) {
|
|
const hash = crypto.createHash('sha256');
|
|
for (const relative of listFiles(dir)) {
|
|
hash.update(relative);
|
|
hash.update('\0');
|
|
hash.update(readRegularFile(path.join(dir, relative)));
|
|
hash.update('\0');
|
|
}
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function loadVariant(dir) {
|
|
const resolved = fs.realpathSync(path.resolve(dir));
|
|
const manifestPath = path.join(resolved, 'variant.json');
|
|
let manifestBytes;
|
|
try {
|
|
manifestBytes = readRegularFile(manifestPath, 'utf8');
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') throw new GateError('gate.variant_missing', `variant.json missing in ${resolved}`);
|
|
throw error;
|
|
}
|
|
const manifest = JSON.parse(manifestBytes);
|
|
if (typeof manifest.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(manifest.name) || !envelope.EFFECT_CLASSES.includes(manifest.effect_class)) {
|
|
throw new GateError('gate.variant_invalid', `variant.json in ${resolved} needs name and a valid effect_class`);
|
|
}
|
|
const entry = manifest.entry === undefined ? 'run.js' : manifest.entry;
|
|
if (typeof entry !== 'string' || !entry || path.isAbsolute(entry) || path.win32.isAbsolute(entry) || entry.includes('\\') || entry.split('/').includes('..')) {
|
|
throw new GateError('gate.variant_invalid', 'entry must be a relative regular file within the variant');
|
|
}
|
|
const entryPath = path.resolve(resolved, entry);
|
|
const relative = path.relative(resolved, entryPath);
|
|
if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) || !listFiles(resolved).includes(relative.split(path.sep).join('/')) || !fs.lstatSync(entryPath).isFile()) {
|
|
throw new GateError('gate.variant_invalid', 'entry must be covered by the variant digest');
|
|
}
|
|
return { dir: resolved, name: manifest.name, effect_class: manifest.effect_class, entry: relative, digest: digestDir(resolved) };
|
|
}
|
|
|
|
function loadTaskset(tasksetPath) {
|
|
const resolved = path.resolve(tasksetPath);
|
|
const taskset = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
if (!taskset || typeof taskset !== 'object' || !taskset.version || !taskset.family || !Array.isArray(taskset.tasks) || taskset.tasks.length === 0) {
|
|
throw new GateError('gate.taskset_invalid', 'taskset needs version, family, and a non-empty tasks array');
|
|
}
|
|
if (new Set(taskset.tasks.map(task => task && task.id)).size !== taskset.tasks.length) throw new GateError('gate.taskset_invalid', 'task ids must be unique');
|
|
for (const task of taskset.tasks) {
|
|
if (!task || typeof task !== 'object' || typeof task.id !== 'string' || !task.id || !('input' in task) || !('expected' in task)) {
|
|
throw new GateError('gate.taskset_invalid', 'every task needs id, input, and expected');
|
|
}
|
|
}
|
|
return { ...taskset, path: resolved, digest: sha256Hex(fs.readFileSync(resolved)) };
|
|
}
|
|
|
|
/** Scan variant sources for tripwire patterns and effect-class expansion. */
|
|
function scanTripwires(variant, options = {}) {
|
|
const rules = options.tripwires || DEFAULT_TRIPWIRES;
|
|
const maxRank = envelope.effectRank(options.max_effect_class || 'SE1');
|
|
const hits = [];
|
|
if (envelope.effectRank(variant.effect_class) > maxRank) {
|
|
hits.push({ variant: variant.name, rule: 'effect_class_expansion', file: 'variant.json', line: 1, detail: `${variant.effect_class} exceeds ${options.max_effect_class || 'SE1'}` });
|
|
}
|
|
for (const relative of listFiles(variant.dir)) {
|
|
if (!/\.(?:js|cjs|mjs|json|sh)$/.test(relative)) {
|
|
continue;
|
|
}
|
|
const lines = readRegularFile(path.join(variant.dir, relative), 'utf8').split(/\r?\n/);
|
|
lines.forEach((text, index) => {
|
|
for (const rule of rules) {
|
|
if (rule.pattern.test(text)) {
|
|
hits.push({ variant: variant.name, rule: rule.rule, file: relative, line: index + 1 });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
/** No verified OS backend is implemented; caller-supplied flags cannot bypass this. */
|
|
function requireSupportedIsolation() {
|
|
throw new GateError('gate.isolation_required', 'Candidate execution is disabled: no verified OS containment backend is implemented.');
|
|
}
|
|
|
|
/** Reject every legacy direct-runner invocation before copying or executing code. */
|
|
function runVariant() {
|
|
requireSupportedIsolation();
|
|
}
|
|
|
|
/** Validate bounded child protocol data. This does not attest to isolation. */
|
|
function parseChildResult(child, tasks) {
|
|
const outputs = new Map();
|
|
let fatal = null;
|
|
if (!child || typeof child !== 'object') return { outputs, fatal: 'missing child result' };
|
|
if (child.error) return { outputs, fatal: child.error.code === 'ETIMEDOUT' ? 'timeout' : 'child process error' };
|
|
if (child.status !== 0 || child.signal) return { outputs, fatal: 'child exited unsuccessfully' };
|
|
try {
|
|
const raw = String(child.stdout || '');
|
|
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error('oversized child output');
|
|
const lastLine = raw.trim().split('\n').filter(Boolean).pop() || '';
|
|
const parsed = JSON.parse(lastLine);
|
|
const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid child envelope');
|
|
if (owns(parsed, 'fatal')) {
|
|
if (typeof parsed.fatal !== 'string' || !parsed.fatal || owns(parsed, 'results')) throw new Error('invalid fatal');
|
|
fatal = 'child reported fatal failure';
|
|
} else {
|
|
const expectedIds = new Set(tasks.map(task => task.id));
|
|
if (!Array.isArray(parsed.results) || parsed.results.length !== tasks.length || expectedIds.size !== tasks.length) throw new Error('incomplete results');
|
|
for (const result of parsed.results) {
|
|
if (!result || typeof result !== 'object' || Array.isArray(result) || !expectedIds.delete(result.id) || owns(result, 'output') === owns(result, 'error')) throw new Error('invalid result');
|
|
outputs.set(result.id, result);
|
|
}
|
|
if (expectedIds.size) throw new Error('missing result');
|
|
}
|
|
} catch {
|
|
fatal = 'invalid child result protocol';
|
|
}
|
|
// Never expose partial rows from an invalid response as successful baseline results.
|
|
return { outputs: fatal ? new Map() : outputs, fatal };
|
|
}
|
|
|
|
/** Require a complete, error-free baseline before any future candidate scoring. */
|
|
function baselineFailure(run, tasks) {
|
|
const invalidTasks = !Array.isArray(tasks) || !tasks.length
|
|
|| tasks.some(task => !task || typeof task.id !== 'string' || !task.id)
|
|
|| new Set(tasks.map(task => task.id)).size !== tasks.length;
|
|
if (invalidTasks || !run || run.fatal || run.exit_code !== 0
|
|
|| run.marker_intact !== true || !Array.isArray(run.fence_events)
|
|
|| run.fence_events.length || !(run.outputs instanceof Map)
|
|
|| run.outputs.size !== tasks.length) {
|
|
return 'baseline process, protocol or integrity failure';
|
|
}
|
|
for (const task of tasks) {
|
|
const result = run.outputs.get(task.id);
|
|
if (!result || result.id !== task.id
|
|
|| !Object.prototype.hasOwnProperty.call(result, 'output')
|
|
|| Object.prototype.hasOwnProperty.call(result, 'error')) {
|
|
return 'baseline result missing or failed';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Reject before inspecting config, reading files, or emitting any gate receipt. */
|
|
function runGate() {
|
|
requireSupportedIsolation();
|
|
}
|
|
|
|
module.exports = {
|
|
GATE_SCHEMA,
|
|
DEFAULT_THRESHOLDS,
|
|
DEFAULT_TRIPWIRES,
|
|
requireSupportedIsolation,
|
|
parseChildResult,
|
|
baselineFailure,
|
|
GateError,
|
|
digestDir,
|
|
loadVariant,
|
|
loadTaskset,
|
|
scanTripwires,
|
|
runVariant,
|
|
runGate,
|
|
};
|