fix(install): reconcile OpenCode hook consent with current main

Preserve the contributor change and current installer contracts. Make automatically discovered plugin entrypoints inert without explicit runtime consent, verify managed ownership before deactivation, and retain prior consent on incomplete migration.

Source-PR: https://github.com/affaan-m/ECC/pull/3008
Source-Head: f8c0c2c182
This commit is contained in:
affaan-m
2026-09-18 18:35:19 -04:00
367 changed files with 51853 additions and 4846 deletions
+17 -11
View File
@@ -270,6 +270,21 @@ function agentPriority(agent) {
return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 };
}
/**
* Right-of-way between two agents: more progress wins; tie goes to the earlier
* start (greater age); final deterministic tiebreak on agentId so the maneuver
* is coordinated. Returns { hold, steer } as agentIds.
*/
function rightOfWay(a, b) {
const pa = agentPriority(a);
const pb = agentPriority(b);
let aHasPriority;
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
else aHasPriority = String(a.agentId) < String(b.agentId);
return { hold: aHasPriority ? a.agentId : b.agentId, steer: aHasPriority ? b.agentId : a.agentId };
}
/**
* TCAS-style advisory between two agents given their collision risk.
* Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }.
@@ -284,17 +299,7 @@ function advise(a, b, graph = {}, options = {}) {
return { level: 'clear', risk, distance, channels, transmit: false, steer: null, hold: null };
}
const pa = agentPriority(a);
const pb = agentPriority(b);
// Right-of-way: more progress wins; tie → earlier start (greater age) wins;
// final deterministic tiebreak on agentId so the maneuver is coordinated.
let aHasPriority;
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
else aHasPriority = String(a.agentId) < String(b.agentId);
const hold = aHasPriority ? a.agentId : b.agentId;
const steer = aHasPriority ? b.agentId : a.agentId;
const { hold, steer } = rightOfWay(a, b);
if (risk < thresholds.ra) {
// Traffic advisory: exchange intent, no one has to move yet.
@@ -324,6 +329,7 @@ module.exports = {
treeRisk,
collisionRisk,
agentPriority,
rightOfWay,
advise,
closureRate,
_internal: { normalizePath, segments, jaccard }
+3 -1
View File
@@ -25,9 +25,11 @@ function toRepoRel(repoRoot, absPath) {
// Match relative specifiers only (./ or ../). Bare specifiers are node_modules
// and never the target of an in-repo collision.
// Consume import whitespace once; a word boundary before `from` avoids
// overlapping whitespace quantifiers on incomplete import statements.
const SPEC_PATTERNS = [
/require\(\s*['"](\.[^'"]+)['"]\s*\)/g,
/import\s+(?:[^'"]*?\s+from\s+)?['"](\.[^'"]+)['"]/g,
/import\s+(?!\s)(?:[^'"]*?\bfrom\s+)?['"](\.[^'"]+)['"]/g,
/import\(\s*['"](\.[^'"]+)['"]\s*\)/g,
/export\s+(?:\*|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"]/g
];
+2 -1
View File
@@ -135,7 +135,8 @@ function scanAirspace(agents, graph = {}, options = {}) {
b: b.agentId,
risk: verdict.risk,
distance: verdict.distance,
level: verdict.level
level: verdict.level,
channels: verdict.channels
});
if (verdict.level !== 'clear') {
advisories.push({ a: a.agentId, b: b.agentId, ...verdict });
+305
View File
@@ -0,0 +1,305 @@
'use strict';
/**
* 2D projection of the pairwise proximity channels for the control-plane view.
*
* Input: one row per agent pair, the shipped channel vector
* x = [x_tree, x_overlap, x_dep] each in [0, 1]
* (distance.js: treeRisk, overlapRisk, dependencyRisk).
*
* Pipeline (COMPETITION-AND-VISION section 4, "Normalization and projection"):
* 1. z-score each channel against a rolling window of pair samples,
* 2. clip the tails at the 2.5th and 97.5th percentile of that window,
* 3. map back to [0, 1],
* 4. apply the static channel weights (same omega as the noisy-OR),
* 5. PCA over the weighted matrix, keep the first two components.
*
* Agent positions are the risk-weighted centroid of the projected points of
* the pairs the agent belongs to. Nothing here changes the risk or the
* advisory: the projection is a display, not a decision.
*
* No runtime dependencies. The eigen-decomposition is a Jacobi sweep over the
* 3x3 covariance matrix, which is exact enough for a display.
*/
const CHANNEL_ORDER = ['tree', 'overlap', 'dependency'];
const CHANNEL_LABELS = { tree: 'x_tree', overlap: 'x_overlap', dependency: 'x_dep' };
const PROJECTION_DEFAULTS = {
windowSize: 512,
minWindowForZscore: 8,
clipPercentiles: [2.5, 97.5],
components: 2
};
function finite(x) {
return Number.isFinite(x) ? x : 0;
}
function mean(values) {
if (values.length === 0) return 0;
let s = 0;
for (const v of values) s += v;
return s / values.length;
}
function stddev(values, mu) {
if (values.length < 2) return 0;
let s = 0;
for (const v of values) s += (v - mu) * (v - mu);
return Math.sqrt(s / (values.length - 1));
}
/**
* Linear-interpolated percentile (p in [0, 100]) of a numeric array.
*/
function percentile(values, p) {
const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1);
const lo = Math.floor(rank);
const hi = Math.ceil(rank);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
}
/**
* Rolling window of pair channel samples. Each push records one sample vector;
* the window keeps the newest `size` samples. `stats()` returns, per channel,
* the mean, standard deviation and clip bounds (in z units) used to normalize.
*/
function createProjectionWindow(options = {}) {
const size = Number.isFinite(options.windowSize) && options.windowSize > 0 ? Math.floor(options.windowSize) : PROJECTION_DEFAULTS.windowSize;
const [pLo, pHi] = Array.isArray(options.clipPercentiles) && options.clipPercentiles.length === 2 ? options.clipPercentiles : PROJECTION_DEFAULTS.clipPercentiles;
const samples = [];
return {
size,
push(vector) {
const row = CHANNEL_ORDER.map((_, i) => finite(vector[i]));
samples.push(row);
if (samples.length > size) samples.splice(0, samples.length - size);
return samples.length;
},
get length() {
return samples.length;
},
stats() {
const per = CHANNEL_ORDER.map((channel, i) => {
const column = samples.map(row => row[i]);
const mu = mean(column);
const sigma = stddev(column, mu);
const z = sigma > 0 ? column.map(v => (v - mu) / sigma) : column.map(() => 0);
return {
channel,
mean: mu,
stddev: sigma,
clipLow: percentile(z, pLo),
clipHigh: percentile(z, pHi)
};
});
return { samples: samples.length, percentiles: [pLo, pHi], channels: per };
},
reset() {
samples.length = 0;
}
};
}
/**
* z-score one sample against the window stats, clip to the percentile bounds,
* map back to [0, 1]. A channel with zero variance maps to 0.5.
*/
function normalizeSample(vector, stats) {
return CHANNEL_ORDER.map((_, i) => {
const s = stats.channels[i];
const v = finite(vector[i]);
if (!(s.stddev > 0)) return 0.5;
const z = (v - s.mean) / s.stddev;
const lo = s.clipLow;
const hi = s.clipHigh;
if (!(hi > lo)) return 0.5;
const clipped = Math.min(hi, Math.max(lo, z));
return (clipped - lo) / (hi - lo);
});
}
/**
* Jacobi eigen-decomposition of a small symmetric matrix. Returns eigenvalues
* (descending) and the matching unit eigenvectors (as columns).
*/
function symmetricEigen(matrix) {
const n = matrix.length;
const a = matrix.map(row => row.slice());
const v = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
for (let sweep = 0; sweep < 64; sweep += 1) {
let off = 0;
for (let p = 0; p < n; p += 1) for (let q = p + 1; q < n; q += 1) off += a[p][q] * a[p][q];
if (off < 1e-18) break;
for (let p = 0; p < n; p += 1) {
for (let q = p + 1; q < n; q += 1) {
if (Math.abs(a[p][q]) < 1e-14) continue;
const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
const c = 1 / Math.sqrt(t * t + 1);
const s = t * c;
for (let k = 0; k < n; k += 1) {
const akp = a[k][p];
const akq = a[k][q];
a[k][p] = c * akp - s * akq;
a[k][q] = s * akp + c * akq;
}
for (let k = 0; k < n; k += 1) {
const apk = a[p][k];
const aqk = a[q][k];
a[p][k] = c * apk - s * aqk;
a[q][k] = s * apk + c * aqk;
}
for (let k = 0; k < n; k += 1) {
const vkp = v[k][p];
const vkq = v[k][q];
v[k][p] = c * vkp - s * vkq;
v[k][q] = s * vkp + c * vkq;
}
}
}
}
const order = Array.from({ length: n }, (_, i) => i).sort((i, j) => a[j][j] - a[i][i]);
return {
values: order.map(i => a[i][i]),
vectors: order.map(i => v.map(row => row[i]))
};
}
/**
* PCA over a row matrix. Returns the scores for the first `components`
* components, the loadings (unit eigenvectors) and the explained variance.
* Fewer than two rows, or zero total variance, yields all-zero scores.
*/
function pca(rows, components = PROJECTION_DEFAULTS.components) {
const n = rows.length;
const dims = n > 0 ? rows[0].length : CHANNEL_ORDER.length;
const k = Math.max(1, Math.min(components, dims));
const centre = Array.from({ length: dims }, (_, d) => mean(rows.map(r => r[d])));
const zeroScores = rows.map(() => new Array(k).fill(0));
if (n < 2) {
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
}
const cov = Array.from({ length: dims }, () => new Array(dims).fill(0));
for (const row of rows) {
for (let i = 0; i < dims; i += 1) {
for (let j = i; j < dims; j += 1) {
cov[i][j] += (row[i] - centre[i]) * (row[j] - centre[j]);
}
}
}
for (let i = 0; i < dims; i += 1) for (let j = i; j < dims; j += 1) {
cov[i][j] /= n - 1;
cov[j][i] = cov[i][j];
}
const total = cov.reduce((s, row, i) => s + row[i], 0);
if (!(total > 1e-12)) {
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
}
const eig = symmetricEigen(cov);
const loadings = eig.vectors.slice(0, k);
const scores = rows.map(row => loadings.map(vec => vec.reduce((s, w, d) => s + w * (row[d] - centre[d]), 0)));
const explainedVariance = eig.values.slice(0, k).map(val => Math.max(0, val) / total);
return { scores, loadings, explainedVariance, centre };
}
function channelVector(channels) {
return CHANNEL_ORDER.map(key => finite(channels && channels[key]));
}
/**
* Project a set of pair links ({ a, b, risk, channels }) to 2D.
*
* The window is optional; when given, each link's channel vector is pushed
* into it and the normalization uses the window stats (rolling z-score plus
* tail clip). Without a window, or while the window holds fewer than
* `minWindowForZscore` samples, the raw [0, 1] channel values are used and the
* result says so (`normalization: 'raw'`).
*
* @returns {{ pairs, agents, normalization, window, pca }}
*/
function projectPairs(links, options = {}) {
const list = Array.isArray(links) ? links.filter(l => l && l.a !== undefined && l.b !== undefined) : [];
const weights = { tree: 0.25, overlap: 1.0, dependency: 0.9, ...(options.channelWeights || {}) };
const window = options.window || null;
const minWindow = Number.isFinite(options.minWindowForZscore) ? options.minWindowForZscore : PROJECTION_DEFAULTS.minWindowForZscore;
const raw = list.map(l => channelVector(l.channels));
if (window && options.sample !== false) for (const vec of raw) window.push(vec);
let stats = null;
let normalization = 'raw';
let normalized = raw;
if (window && window.length >= minWindow) {
stats = window.stats();
normalized = raw.map(vec => normalizeSample(vec, stats));
normalization = 'zscore-clipped';
}
const weighted = normalized.map(vec => vec.map((v, i) => v * finite(weights[CHANNEL_ORDER[i]])));
const result = pca(weighted, options.components || PROJECTION_DEFAULTS.components);
const pairs = list.map((l, i) => ({
a: l.a,
b: l.b,
risk: finite(l.risk),
level: l.level || null,
channels: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], raw[i][d]])),
normalized: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], normalized[i][d]])),
point: result.scores[i]
}));
// Agent position: risk-weighted centroid of its pair points. A floor keeps
// a clear pair from vanishing, so every agent with a pair gets a position.
const byAgent = new Map();
for (const pair of pairs) {
const w = 0.05 + pair.risk;
for (const id of [pair.a, pair.b]) {
const acc = byAgent.get(id) || { sum: pair.point.map(() => 0), w: 0, pairs: 0, maxRisk: 0 };
pair.point.forEach((x, d) => {
acc.sum[d] += x * w;
});
acc.w += w;
acc.pairs += 1;
acc.maxRisk = Math.max(acc.maxRisk, pair.risk);
byAgent.set(id, acc);
}
}
const agents = [...byAgent.entries()].map(([agentId, acc]) => ({
agentId,
point: acc.sum.map(x => (acc.w > 0 ? x / acc.w : 0)),
pairs: acc.pairs,
maxRisk: acc.maxRisk
}));
return {
method: 'pca',
channels: CHANNEL_ORDER.map(key => CHANNEL_LABELS[key]),
weights: Object.fromEntries(CHANNEL_ORDER.map(key => [CHANNEL_LABELS[key], finite(weights[key])])),
normalization,
window: stats ? { samples: stats.samples, percentiles: stats.percentiles, channels: stats.channels.map(c => ({ ...c, channel: CHANNEL_LABELS[c.channel] })) } : { samples: window ? window.length : 0, percentiles: PROJECTION_DEFAULTS.clipPercentiles, channels: [] },
pca: {
loadings: result.loadings.map(vec => Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], vec[d]]))),
explainedVariance: result.explainedVariance
},
pairs,
agents
};
}
module.exports = {
PROJECTION_DEFAULTS,
CHANNEL_ORDER,
CHANNEL_LABELS,
percentile,
createProjectionWindow,
normalizeSample,
pca,
projectPairs,
_internal: { symmetricEigen, mean, stddev }
};
+14 -1
View File
@@ -13,21 +13,34 @@ function writeFileAtomic(filePath, content, options = {}) {
);
const mode = options.mode || 0o600;
if (options.validateParent) options.validateParent();
fs.mkdirSync(parentDir, { recursive: true });
let descriptor;
try {
if (options.validateParent) options.validateParent();
descriptor = fs.openSync(tempPath, 'wx', mode);
if (options.validateParent) options.validateParent();
fs.writeFileSync(descriptor, content, { encoding: options.encoding || 'utf8' });
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
if (options.validateParent) options.validateParent();
if (options.beforeRename) options.beforeRename();
fs.renameSync(tempPath, resolvedPath);
} catch (error) {
if (descriptor !== undefined) {
fs.closeSync(descriptor);
}
fs.rmSync(tempPath, { force: true });
// If the parent was replaced, this pathname may now name somebody else's
// file. Leave the private staging file in its original directory.
let parentUnchanged = true;
try {
if (options.validateParent) options.validateParent();
} catch (_error) {
parentUnchanged = false;
}
if (parentUnchanged) fs.rmSync(tempPath, { force: true });
throw error;
}
@@ -0,0 +1,243 @@
'use strict';
/**
* Self-contained control-plane live view page, served at /control-plane.
*
* Draws the 2D PCA projection of the agent pairs (projection.js) on a canvas,
* the lanes and tasks beside it, and the advisory event feed. Polls
* /api/control-plane. No external scripts, no framework: it has to work on a
* loopback server with a strict CSP and offline.
*/
function renderControlPlaneViewHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ECC Control Plane</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 -apple-system, system-ui, sans-serif; background: #0b0e14; color: #e6edf3; }
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
header h1 { font-size: 15px; margin: 0; }
header .sub { color: #8b949e; font-size: 12px; }
header nav { margin-left: auto; font-size: 12px; }
header nav a { color: #8b949e; margin-left: 12px; text-decoration: none; }
header nav a:hover { color: #e6edf3; }
#wrap { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 49px); }
#stage { position: relative; border-right: 1px solid #1f2630; }
canvas { width: 100%; height: 100%; display: block; }
#side { padding: 12px 14px; overflow-y: auto; }
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 14px 0 8px; }
#side h2:first-child { margin-top: 0; }
.ev { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
.ev.resolution { border-color: #b3402f; }
.ev.traffic { border-color: #9a6700; }
.ev.conflict { border-color: #58a6ff; }
.ev .lv { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.ev.resolution .lv { color: #ff7b72; }
.ev.traffic .lv { color: #e3b341; }
.ev.conflict .lv { color: #58a6ff; }
.ev .msg { color: #c9d1d9; font-size: 12px; margin-top: 3px; }
.lane { margin-bottom: 10px; }
.lane .name { color: #c9d1d9; font-weight: 600; font-size: 12px; }
.task { display: flex; gap: 8px; align-items: baseline; font-size: 12px; padding: 2px 0 2px 8px; color: #8b949e; }
.task .id { color: #c9d1d9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.task .risk { margin-left: auto; }
.empty { color: #6e7681; font-size: 12px; }
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
#meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
</style>
</head>
<body>
<header>
<h1>ECC Control Plane</h1>
<span class="sub" id="status">connecting...</span>
<nav><a href="/">board</a><a href="/proximity">3D airspace</a><a href="/api/control-plane">json</a></nav>
</header>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<div id="meta"></div>
<div id="legend">
<div><span class="dot" style="background:#3fb950"></span>clear</div>
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
<div><span class="dot" style="background:#ff7b72"></span>resolution advisory (steer)</div>
</div>
</div>
<div id="side">
<h2>Events</h2>
<div id="events"><div class="empty">No events.</div></div>
<h2>Lanes</h2>
<div id="lanes"><div class="empty">No tasks.</div></div>
</div>
</div>
<script>
(function () {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } };
function resize() {
var r = canvas.parentElement.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(r.width * dpr));
canvas.height = Math.max(1, Math.floor(r.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
window.addEventListener('resize', resize);
function riskColor(risk) {
if (risk >= view.thresholds.ra) return '#ff7b72';
if (risk >= view.thresholds.ta) return '#e3b341';
return '#3fb950';
}
// Fit the projected points into the canvas with a margin. The PCA scores
// are centred already, so we only need a scale.
function fit(points, w, h) {
var maxAbs = 1e-6;
points.forEach(function (p) {
maxAbs = Math.max(maxAbs, Math.abs(p[0] || 0), Math.abs(p[1] || 0));
});
var scale = (Math.min(w, h) * 0.4) / maxAbs;
return function (p) { return [w / 2 + (p[0] || 0) * scale, h / 2 - (p[1] || 0) * scale]; };
}
function draw() {
var w = canvas.clientWidth, h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
var agents = view.projection.agents || [];
var toScreen = fit(agents.map(function (a) { return a.point; }), w, h);
var pos = {};
agents.forEach(function (a) { pos[a.agentId] = toScreen(a.point); });
// axes
ctx.strokeStyle = '#1f2630';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke();
// pair links under the points
(view.pairs || []).forEach(function (pair) {
if (pair.risk < 0.2) return;
var pa = pos[pair.a], pb = pos[pair.b];
if (!pa || !pb) return;
ctx.strokeStyle = riskColor(pair.risk);
ctx.globalAlpha = Math.min(1, 0.25 + pair.risk * 0.7);
ctx.lineWidth = 1 + pair.risk * 3;
ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1]); ctx.stroke();
});
ctx.globalAlpha = 1;
// agents
var taskById = {};
view.tasks.forEach(function (t) { taskById[t.id] = t; });
agents.forEach(function (a) {
var p = pos[a.agentId];
var t = taskById[a.agentId] || {};
var files = (t.workingSet && t.workingSet.fileCount) || 1;
var radius = 6 + Math.sqrt(files) * 3;
ctx.fillStyle = riskColor(a.maxRisk || 0);
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#c9d1d9';
ctx.font = '11px -apple-system, system-ui, sans-serif';
ctx.fillText(String(a.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
});
// singleton tasks (no pair yet) sit at the origin, listed in the side panel
var meta = document.getElementById('meta');
var pj = view.projection || {};
var ev = (pj.pca && pj.pca.explainedVariance) || [];
meta.textContent = 'pca over x_tree, x_overlap, x_dep | ' + (pj.normalization || 'raw') +
(pj.window && pj.window.samples ? ' | window ' + pj.window.samples : '') +
(ev.length ? ' | var ' + ev.map(function (x) { return Math.round(x * 100) + '%'; }).join(' / ') : '');
}
function renderEvents() {
var box = document.getElementById('events');
box.textContent = '';
if (!view.events.length) {
var e = document.createElement('div'); e.className = 'empty';
e.textContent = 'No events. Airspace clear, no declared lease conflicts.'; box.appendChild(e); return;
}
view.events.forEach(function (ev) {
var el = document.createElement('div');
el.className = 'ev ' + ev.level;
var lv = document.createElement('div'); lv.className = 'lv';
lv.textContent = (ev.risk !== undefined ? Math.round(ev.risk * 100) + '% ' : '') + ev.kind + ' / ' + ev.level;
el.appendChild(lv);
var msg = document.createElement('div'); msg.className = 'msg';
msg.textContent = ev.message; el.appendChild(msg);
box.appendChild(el);
});
}
function renderLanes() {
var box = document.getElementById('lanes');
box.textContent = '';
if (!view.tasks.length) {
var e = document.createElement('div'); e.className = 'empty';
e.textContent = 'No tasks.'; box.appendChild(e); return;
}
var taskById = {};
view.tasks.forEach(function (t) { taskById[t.id] = t; });
view.lanes.forEach(function (lane) {
var el = document.createElement('div'); el.className = 'lane';
var name = document.createElement('div'); name.className = 'name';
name.textContent = lane.label + ' (' + lane.kind + ', ' + lane.taskIds.length + ')';
el.appendChild(name);
lane.taskIds.forEach(function (id) {
var t = taskById[id]; if (!t) return;
var row = document.createElement('div'); row.className = 'task';
var idEl = document.createElement('span'); idEl.className = 'id'; idEl.textContent = t.id.slice(0, 20);
var st = document.createElement('span'); st.textContent = t.harness + ' / ' + t.state + ' / ' + (t.workingSet.fileCount || 0) + ' files';
var risk = document.createElement('span'); risk.className = 'risk';
risk.style.color = riskColor(t.projection.maxRisk || 0);
risk.textContent = t.projection.point ? Math.round((t.projection.maxRisk || 0) * 100) + '%' : 'no pair';
row.appendChild(idEl); row.appendChild(st); row.appendChild(risk);
el.appendChild(row);
});
box.appendChild(el);
});
}
function apply(data) {
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
!data.projection || !Array.isArray(data.projection.agents) || !data.thresholds ||
!Number.isFinite(data.thresholds.ta) || !Number.isFinite(data.thresholds.ra)) {
throw new Error('Invalid control-plane view');
}
view = Object.assign({}, data);
renderEvents(); renderLanes(); draw();
var c = view.counts || {};
document.getElementById('status').textContent =
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' +
(c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' +
(view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : '');
}
function poll() {
fetch('/api/control-plane').then(function (r) {
if (!r.ok) throw new Error('Control-plane request failed');
return r.json();
}).then(apply).catch(function () {
document.getElementById('status').textContent = 'offline';
});
}
resize();
poll();
setInterval(poll, 5000);
})();
</script>
</body>
</html>`;
}
module.exports = { renderControlPlaneViewHtml };
@@ -0,0 +1,358 @@
'use strict';
/**
* ECC control-plane live view.
*
* One JSON document, `ecc.control-plane.view.v1`, that joins three things the
* repo already computes separately:
*
* 1. the control-pane session snapshot (state.js): who is running where,
* 2. the agent-proximity airspace scan (agent-proximity + proximity.js):
* pairwise collision risk over the shipped channels x_tree, x_overlap,
* x_dep, with the 2D PCA projection from agent-proximity/projection.js,
* 3. the coordination inventory (coordination-inventory.js, PR #3028):
* declared tasks and sessions, heartbeat freshness, lease conflicts.
*
* The output is shaped as tasks, lanes and events so another control plane
* (the Ito ops board) can consume it without knowing ECC internals:
*
* task = one agent session (id, lane, harness, state, worktree, working
* set size, projected point, inventory observation)
* lane = a grouping of tasks (task group, project, or harness)
* event = something an operator or a hook may act on. Today: a proximity
* advisory at a static threshold, or a lease conflict.
*
* Everything here is read-only and advisory. The view does not acquire
* leases, does not steer agents and does not claim a conflict-reduction
* number. See docs/control-plane/VIEW-CONTRACT.md.
*/
const { DEFAULTS, rightOfWay } = require('../agent-proximity/distance');
const { projectPairs, createProjectionWindow } = require('../agent-proximity/projection');
const VIEW_SCHEMA_VERSION = 'ecc.control-plane.view.v1';
const EVENT_KINDS = {
advisory: 'proximity.advisory',
leaseConflict: 'inventory.lease-conflict'
};
const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/;
const OPEN_STATES = new Set(['running', 'pending', 'idle']);
const CLOSED_STATES = new Set(['completed', 'failed', 'stopped']);
function isoOrNull(value) {
if (!value) return null;
const ms = Date.parse(value);
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
}
/**
* Map a session id to an identifier the inventory accepts. Replaces anything
* outside the allowed alphabet, strips a leading non-alphanumeric run, and
* falls back to a positional id. Callers get the mapping back so a consumer
* can join inventory rows to tasks.
*/
function inventoryIdFor(id, index, taken) {
let candidate = String(id || '')
.replace(/[^a-zA-Z0-9_.:-]/g, '-')
.replace(/^[^a-zA-Z0-9]+/, '')
.slice(0, 200);
if (!candidate || ['__proto__', 'constructor', 'prototype'].includes(candidate)) candidate = `task-${index + 1}`;
let unique = candidate;
let n = 2;
while (taken.has(unique)) {
unique = `${candidate.slice(0, 190)}-${n}`;
n += 1;
}
taken.add(unique);
return IDENTIFIER.test(unique) ? unique : `task-${index + 1}`;
}
function laneFor(session) {
if (session.taskGroup) return { id: `group:${session.taskGroup}`, label: session.taskGroup, kind: 'task-group' };
if (session.project) return { id: `project:${session.project}`, label: session.project, kind: 'project' };
const harness = session.harness || 'unknown';
return { id: `harness:${harness}`, label: harness, kind: 'harness' };
}
function sessionDeclarationStatus(state) {
if (OPEN_STATES.has(state)) return 'open';
if (CLOSED_STATES.has(state)) return 'closed';
return 'unknown';
}
/**
* Build the #3028 manifest from live sessions plus the working sets the
* proximity scan already extracted. Declared-only by construction: the
* inventory library labels every row `declared-only` and this view keeps
* that label.
*/
function buildInventoryManifest(sessions, agentsById, options = {}) {
const taken = new Set();
const idMap = new Map();
const tasks = [];
const declaredSessions = [];
const limited = (sessions || []).slice(0, 64);
limited.forEach((session, index) => {
const invId = inventoryIdFor(session.id, index, taken);
idMap.set(session.id, invId);
const agent = agentsById.get(session.id);
const paths = (agent ? agent.files : []).filter(p => typeof p === 'string' && !p.startsWith('/') && !/^[A-Za-z]:/.test(p) && !p.split('/').some(x => !x || x === '.' || x === '..')).slice(0, 128);
tasks.push({
id: invId,
repoId: null,
paths,
pid: Number.isSafeInteger(session.pid) && session.pid > 0 ? session.pid : null,
status: String(session.state || 'unknown').slice(0, 200) || 'unknown',
heartbeatAt: isoOrNull(session.lastHeartbeatAt),
statusFileModifiedAt: isoOrNull(session.updatedAt)
});
declaredSessions.push({
id: invId,
taskId: invId,
goalId: null,
status: sessionDeclarationStatus(session.state),
updatedAt: isoOrNull(session.lastHeartbeatAt || session.updatedAt)
});
});
const extra = options.manifest && typeof options.manifest === 'object' ? options.manifest : {};
return {
manifest: {
version: 1,
repositories: Array.isArray(extra.repositories) ? extra.repositories : [],
tasks: [...tasks, ...(Array.isArray(extra.tasks) ? extra.tasks : [])],
sessions: [...declaredSessions, ...(Array.isArray(extra.sessions) ? extra.sessions : [])],
goals: Array.isArray(extra.goals) ? extra.goals : [],
leases: Array.isArray(extra.leases) ? extra.leases : []
},
idMap,
truncated: (sessions || []).length > limited.length
};
}
function runInventory(sessions, agentsById, options = {}) {
const built = buildInventoryManifest(sessions, agentsById, options);
try {
const { buildInventory } = options.inventoryModule || require('../coordination-inventory');
const report = buildInventory(built.manifest, { now: options.now, resources: options.resources });
return { status: 'ok', idMap: built.idMap, truncated: built.truncated, report };
} catch (error) {
return { status: 'unavailable', idMap: built.idMap, truncated: built.truncated, reason: error.message, report: null };
}
}
/**
* Agent shape the right-of-way rule needs, rebuilt from the proximity
* snapshot's agent summaries (progress = recency-weighted file count).
*/
function priorityAgent(summary, agentId) {
if (!summary) return { agentId, files: [], startedAt: null };
const progress = Number.isFinite(summary.progress) ? summary.progress : summary.fileCount || 0;
return { agentId, startedAt: summary.startedAt || null, files: [{ path: '', weight: progress }] };
}
/**
* Static-threshold advisory events, derived from every pair link against the
* view's own thresholds so an override changes the events, not only labels.
* The risk itself comes from the scan (noisy-OR, unchanged).
*/
function advisoryEvents(links, agentsById, thresholds, at) {
const events = [];
for (const link of links || []) {
if (!link || !Number.isFinite(link.risk) || link.risk < thresholds.ta) continue;
const resolution = link.risk >= thresholds.ra;
const level = resolution ? 'resolution' : 'traffic';
const a = agentsById.get(link.a);
const b = agentsById.get(link.b);
const aLabel = (a && a.label) || link.a;
const bLabel = (b && b.label) || link.b;
const way = resolution ? rightOfWay(priorityAgent(a, link.a), priorityAgent(b, link.b)) : { steer: null, hold: null };
const channels = link.channels || {};
events.push({
id: `${EVENT_KINDS.advisory}:${link.a}|${link.b}:${level}`,
kind: EVENT_KINDS.advisory,
level,
severity: resolution ? 'critical' : 'warning',
at,
subject: { a: link.a, b: link.b, aLabel, bLabel },
risk: link.risk,
distance: Number.isFinite(link.distance) ? link.distance : 1 - link.risk,
channels: {
x_tree: Number.isFinite(channels.tree) ? channels.tree : null,
x_overlap: Number.isFinite(channels.overlap) ? channels.overlap : null,
x_dep: Number.isFinite(channels.dependency) ? channels.dependency : null
},
threshold: { ta: thresholds.ta, ra: thresholds.ra, crossed: resolution ? 'ra' : 'ta', source: 'static' },
action: resolution ? { type: 'steer', steer: way.steer, hold: way.hold } : { type: 'transmit', steer: null, hold: null },
message: resolution
? `Resolution advisory: ${way.steer} steers, ${way.hold} holds (risk ${Math.round(link.risk * 100)}%, static threshold ${thresholds.ra}).`
: `Traffic advisory: ${link.a} and ${link.b} transmit intent (risk ${Math.round(link.risk * 100)}%, static threshold ${thresholds.ta}).`
});
}
events.sort((x, y) => y.risk - x.risk);
return events;
}
function leaseConflictEvents(report, at) {
if (!report || !Array.isArray(report.leaseConflicts)) return [];
return report.leaseConflicts.map(conflict => ({
id: `${EVENT_KINDS.leaseConflict}:${conflict.resource}`,
kind: EVENT_KINDS.leaseConflict,
level: 'conflict',
severity: 'warning',
at,
subject: { resource: conflict.resource, owners: conflict.owners },
action: { type: 'review', steer: null, hold: null },
message: `Declared lease conflict on ${conflict.resource}: ${conflict.owners.join(', ')}. Declared-only, not a lock.`
}));
}
/**
* Build the live view from a control-pane snapshot that already carries a
* `proximity` field (buildControlPaneSnapshot with includeProximity: true).
*
* @param {object} snapshot control-pane snapshot
* @param {object} [options] { window, thresholds, now, manifest, resources, channelWeights }
*/
function buildControlPlaneView(snapshot, options = {}) {
const at = options.now || new Date().toISOString();
const thresholds = { ...DEFAULTS.thresholds, ...(options.thresholds || {}) };
const sessions = Array.isArray(snapshot && snapshot.sessions) ? snapshot.sessions : [];
const prox = (snapshot && snapshot.proximity) || {};
const agents = Array.isArray(prox.agents) ? prox.agents : [];
const agentsById = new Map(agents.map(a => [a.agentId, a]));
const projection = projectPairs(prox.links || [], {
window: options.window,
channelWeights: options.channelWeights,
sample: options.sample,
minWindowForZscore: options.minWindowForZscore
});
const pointByAgent = new Map(projection.agents.map(a => [a.agentId, a]));
const inventory = runInventory(sessions, agentsById, {
now: at,
manifest: options.manifest,
resources: options.resources,
inventoryModule: options.inventoryModule
});
const inventoryTaskById = new Map();
if (inventory.report) for (const task of inventory.report.tasks || []) inventoryTaskById.set(task.id, task);
const lanes = new Map();
const tasks = sessions.map(session => {
const lane = laneFor(session);
if (!lanes.has(lane.id)) lanes.set(lane.id, { ...lane, taskIds: [] });
lanes.get(lane.id).taskIds.push(session.id);
const agent = agentsById.get(session.id);
const projected = pointByAgent.get(session.id);
const invId = inventory.idMap.get(session.id) || null;
const invTask = invId ? inventoryTaskById.get(invId) : null;
return {
id: session.id,
lane: lane.id,
label: session.task || session.id,
harness: session.harness || 'unknown',
agentType: session.agentType || '',
state: session.state || 'unknown',
pid: session.pid === undefined ? null : session.pid,
worktree: session.worktree || null,
heartbeatAt: isoOrNull(session.lastHeartbeatAt),
updatedAt: isoOrNull(session.updatedAt),
workingSet: { fileCount: agent ? agent.fileCount : 0, files: agent ? agent.files : [] },
projection: projected ? { point: projected.point, pairs: projected.pairs, maxRisk: projected.maxRisk } : { point: null, pairs: 0, maxRisk: 0 },
inventory: invTask ? { id: invId, heartbeat: invTask.heartbeat, process: invTask.process, authority: 'declared-only' } : { id: invId, heartbeat: null, process: null, authority: 'declared-only' }
};
});
const events = [...advisoryEvents(prox.links, agentsById, thresholds, at), ...leaseConflictEvents(inventory.report, at)];
const { pairs, agents: projectedAgents, ...projectionMeta } = projection;
return {
schemaVersion: VIEW_SCHEMA_VERSION,
generatedAt: at,
source: {
snapshotSchema: snapshot ? snapshot.schemaVersion || null : null,
repoRoot: snapshot ? snapshot.repoRoot || null : null,
dbPath: snapshot ? snapshot.dbPath || null : null
},
thresholds: { ta: thresholds.ta, ra: thresholds.ra, source: 'static' },
lanes: [...lanes.values()],
tasks,
pairs,
events,
projection: { ...projectionMeta, agents: projectedAgents },
inventory: inventory.report
? {
status: 'ok',
truncated: inventory.truncated,
observedAt: inventory.report.observedAt,
mode: inventory.report.mode,
activity: inventory.report.activity,
leaseConflicts: inventory.report.leaseConflicts,
warnings: inventory.report.warnings,
coverage: inventory.report.coverage,
limits: inventory.report.limits
}
: { status: inventory.status, truncated: inventory.truncated, reason: inventory.reason || null },
counts: {
lanes: lanes.size,
tasks: tasks.length,
agents: agents.length,
pairs: pairs.length,
events: events.length,
advisories: events.filter(e => e.kind === EVENT_KINDS.advisory).length,
resolutions: events.filter(e => e.kind === EVENT_KINDS.advisory && e.level === 'resolution').length
},
limits: [
'Advisories use static thresholds; no learned threshold and no conflict-reduction claim.',
'Projection is a display over the shipped channels x_tree, x_overlap, x_dep; it does not change risk.',
'Inventory rows are declared-only observations; leases are not locks.',
'The view does not steer, pause or lock any agent.'
]
};
}
/**
* Stateful view builder for a long-lived server: keeps one projection window
* so z-scores roll over ticks. `buildSnapshot()` is injected (it is the
* control-pane snapshot with includeProximity: true).
*/
function createControlPlaneViewSource(deps = {}) {
const window = deps.window || createProjectionWindow(deps.projection || {});
const clock = deps.clock || Date.now;
const interval = deps.sampleIntervalMs === undefined ? 5000 : deps.sampleIntervalMs;
if (!Number.isFinite(interval) || interval <= 0) throw new Error('sampleIntervalMs must be positive and finite');
let cached = null;
let pending = null;
let expiresAt = 0;
async function refresh() {
const snapshot = await deps.buildSnapshot();
const view = buildControlPlaneView(snapshot, { ...deps.viewOptions, window });
cached = { snapshot, view };
expiresAt = clock() + interval;
return cached;
}
return {
window,
async build(extra = {}) {
if (!cached || clock() >= expiresAt) {
if (!pending) pending = refresh().finally(() => { pending = null; });
await pending;
}
if (Object.keys(extra).length === 0) return cached.view;
return buildControlPlaneView(cached.snapshot, {
...deps.viewOptions, ...extra, now: extra.now || cached.view.generatedAt, window, sample: false
});
}
};
}
module.exports = {
VIEW_SCHEMA_VERSION,
EVENT_KINDS,
buildControlPlaneView,
createControlPlaneViewSource,
buildInventoryManifest,
_internal: { inventoryIdFor, laneFor, sessionDeclarationStatus, advisoryEvents, leaseConflictEvents }
};
@@ -49,6 +49,7 @@ function renderProximityVizHtml() {
<header>
<h1>ECC - Agent Airspace</h1>
<span class="sub" id="status">connecting...</span>
<a class="sub" href="/control-plane" style="margin-left:auto;text-decoration:none">2D control plane</a>
</header>
<div id="wrap">
<div id="stage">
+12
View File
@@ -122,10 +122,21 @@ function buildProximitySnapshot(sessions, options = {}) {
const agents = sessionsToAgents(sessions, options);
// Need at least two participating agents for a collision to be possible.
const agentSummaries = agents.map(a => ({
agentId: a.agentId,
label: a.label,
startedAt: a.startedAt,
fileCount: a.files.length,
progress: a.files.reduce((s, f) => s + (f.weight ?? 1), 0),
files: a.files.map(f => f.path)
}));
if (agents.length < 2) {
return {
enabled: true,
advisories: [],
triggers: [],
agents: agentSummaries,
positions: agents.map(a => ({ agentId: a.agentId, position: [0, 0, 0], fileCount: a.files.length })),
links: [],
counts: { agents: agents.length, advisories: 0, resolutions: 0 }
@@ -151,6 +162,7 @@ function buildProximitySnapshot(sessions, options = {}) {
enabled: true,
advisories,
triggers: buildProximityTriggers(scan.advisories),
agents: agentSummaries,
positions: scan.positions,
links: scan.links,
counts: scan.counts
+43
View File
@@ -9,6 +9,8 @@ const { buildControlPaneAction } = require('./actions');
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
const { renderControlPaneHtml } = require('./ui');
const { renderProximityVizHtml } = require('./proximity-viz');
const { renderControlPlaneViewHtml } = require('./control-plane-view-ui');
const { createControlPlaneViewSource } = require('./control-plane-view');
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
// Run a single write against the local work-item store, then close it. Kept
@@ -185,6 +187,24 @@ function createControlPaneServer(options = {}) {
const baseQuery = options.query || '';
const allowedHostnames = buildAllowedHostnames(host);
// Live control-plane view: sessions + proximity scan + coordination
// inventory, joined as tasks/lanes/events with a 2D projection. The view
// source owns the rolling projection window so z-scores span ticks.
const viewSource = createControlPlaneViewSource({
projection: options.projection || {},
viewOptions: options.viewOptions || {},
buildSnapshot: () =>
buildControlPaneSnapshot({
repoRoot,
dbPath: resolvedConfig.dbPath,
stateDbPath: resolvedConfig.stateDbPath,
config: resolvedConfig,
allowActions,
includeProximity: true,
proximityOptions: options.proximityOptions
})
});
const server = http.createServer(async (req, res) => {
try {
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
@@ -257,6 +277,29 @@ function createControlPaneServer(options = {}) {
return;
}
// Control-plane live view: 2D projection + advisory events + inventory.
if (req.method === 'GET' && requestUrl.pathname === '/control-plane') {
sendText(res, 200, renderControlPlaneViewHtml(), 'text/html; charset=utf-8');
return;
}
if (req.method === 'GET' && requestUrl.pathname === '/api/control-plane') {
sendJson(res, 200, await viewSource.build());
return;
}
if (req.method === 'GET' && requestUrl.pathname === '/api/control-plane/events') {
const view = await viewSource.build();
sendJson(res, 200, {
schemaVersion: view.schemaVersion,
generatedAt: view.generatedAt,
thresholds: view.thresholds,
events: view.events,
counts: { events: view.counts.events, advisories: view.counts.advisories, resolutions: view.counts.resolutions }
});
return;
}
const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/);
if (req.method === 'POST' && actionMatch) {
if (!allowActions) {
+8 -3
View File
@@ -4,9 +4,6 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const initSqlJs = require('sql.js');
const toml = require('@iarna/toml');
const { buildControlPaneActions } = require('./actions');
const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1';
@@ -85,6 +82,10 @@ function normalizeConfig(rawConfig = {}, options = {}) {
}
function readTomlConfig(configPath) {
// @iarna/toml is required lazily so commands that never resolve a config
// file (e.g. `--help`, or a first run before any ecc2.toml exists) don't
// need it on the require path.
const toml = require('@iarna/toml');
const raw = fs.readFileSync(configPath, 'utf8');
return toml.parse(raw);
}
@@ -113,6 +114,10 @@ function resolveControlPaneConfig(options = {}) {
async function openSqlDatabase(dbPath) {
if (!dbPath || !fs.existsSync(dbPath)) return null;
// sql.js is required lazily so commands that never open an existing
// ecc2.db (e.g. `--help`, or a first run before any db exists) don't need
// it on the require path.
const initSqlJs = require('sql.js');
const SQL = await initSqlJs();
const buffer = fs.readFileSync(dbPath);
return new SQL.Database(buffer);
+261
View File
@@ -0,0 +1,261 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { collisionRisk } = require('./agent-proximity/distance');
const { buildDependencyGraphFromSources } = require('./agent-proximity/graph');
const { parseWorkerStatus } = require('./orchestration-session');
const MAX_BYTES = 1024 * 1024;
const STALE_MS = 5 * 60 * 1000;
function invalid() { throw new Error('Invalid coordination input.'); }
function record(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
return value;
}
function list(value, max = 64) {
if (!Array.isArray(value) || value.length > max) invalid();
return value;
}
function text(value, max = 200) {
if (typeof value !== 'string' || !value.length || value.length > max || [...value].some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) invalid();
return value;
}
function missing(value) { return value === null || value === undefined; }
function identifier(value) {
text(value);
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) || ['__proto__', 'constructor', 'prototype'].includes(value)) invalid();
return value;
}
function timestamp(value) {
text(value);
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value) || !Number.isFinite(Date.parse(value))) invalid();
const canonical = value.replace(/(?:\.(\d{1,3}))?Z$/, (_, fraction) => `.${(fraction || '').padEnd(3, '0')}Z`);
if (new Date(value).toISOString() !== canonical) invalid();
return value;
}
function relativePath(value) {
const p = text(value, 1024).replace(/\\/g, '/').replace(/^\.\//, '');
const parts = p.split('/');
if (p.startsWith('/') || /^[A-Za-z]:/.test(p) || parts.some(x => !x || ['.', '..', '__proto__', 'constructor', 'prototype'].includes(x))) invalid();
return p;
}
function unique(items, key) {
if (new Set(items.map(x => x[key])).size !== items.length) invalid();
return items;
}
function normalizeTask(value) {
const t = record(value);
if (!missing(t.pid) && (!Number.isSafeInteger(t.pid) || t.pid <= 0)) invalid();
const id = identifier(t.id);
const parentId = t.parentId ? identifier(t.parentId) : null;
if (parentId === id) invalid();
return {
id, parentId, repoId: missing(t.repoId) ? null : identifier(t.repoId),
paths: [...new Set(list(t.paths || [], 128).map(relativePath))].sort(),
pid: t.pid ?? null, status: missing(t.status) ? 'unknown' : text(t.status),
heartbeatAt: missing(t.heartbeatAt) ? null : timestamp(t.heartbeatAt),
statusFileModifiedAt: missing(t.statusFileModifiedAt) ? null : timestamp(t.statusFileModifiedAt)
};
}
function declarationStatus(value, allowed) {
if (value === undefined) return 'unknown';
if (!allowed.includes(value)) invalid();
return value;
}
function normalizeDeclarations(manifest, tasks) {
const taskIds = new Set(tasks.map(t => t.id));
const link = (value, ids) => {
if (missing(value)) return null;
const id = identifier(value);
if (!ids.has(id)) invalid();
return id;
};
const common = value => ({ id: identifier(value.id), taskId: link(value.taskId, taskIds),
updatedAt: missing(value.updatedAt) ? null : timestamp(value.updatedAt) });
const goals = unique(list(manifest.goals === undefined ? [] : manifest.goals).map(value => {
const g = record(value);
return { ...common(g), kind: declarationStatus(g.kind, ['native', 'unknown']),
status: declarationStatus(g.status, ['active', 'complete', 'blocked', 'unknown']) };
}), 'id');
const goalIds = new Set(goals.map(g => g.id));
const sessions = unique(list(manifest.sessions === undefined ? [] : manifest.sessions).map(value => {
const s = record(value);
return { ...common(s), goalId: link(s.goalId, goalIds),
status: declarationStatus(s.status, ['open', 'closed', 'unknown']) };
}), 'id');
return { goals, sessions, declarationCoverage: {
goals: manifest.goals === undefined ? 'missing' : 'declared-only',
sessions: manifest.sessions === undefined ? 'missing' : 'declared-only'
} };
}
function normalizeManifest(value) {
const m = record(value);
if (m.version !== 1 || Buffer.byteLength(JSON.stringify(m)) > MAX_BYTES) invalid();
let sourceBytes = 0;
const repositories = unique(list(m.repositories ?? []).map(value => {
const r = record(value); const sourceEntries = Object.entries(record(r.sources ?? {}));
if (sourceEntries.length > 128) invalid();
const entries = sourceEntries.map(([p, source]) => {
// Existing regex extractor is for snippets, not arbitrary full source files.
if (typeof source !== 'string' || Buffer.byteLength(source) > 1024) invalid();
sourceBytes += Buffer.byteLength(source);
if (sourceBytes > 32768) invalid();
return [relativePath(p), source];
});
if (new Set(entries.map(([p]) => p)).size !== entries.length) invalid();
return { id: identifier(r.id), sources: Object.fromEntries(entries) };
}), 'id');
const tasks = unique(list(m.tasks).map(normalizeTask), 'id');
const ids = new Set(repositories.map(r => r.id));
if (tasks.some(t => t.repoId !== null && !ids.has(t.repoId))) invalid();
const leases = list(m.leases ?? [], 128).map(value => {
const l = record(value);
return { resource: identifier(l.resource), owner: identifier(l.owner), expiresAt: timestamp(l.expiresAt) };
});
return { version: 1, repositories, tasks, leases, ...normalizeDeclarations(m, tasks) };
}
function heartbeat(value, nowMs) {
if (!value) return { state: 'unknown', ageMs: null };
const ageMs = nowMs - Date.parse(value);
return { state: ageMs < 0 ? 'clock-skew' : ageMs > STALE_MS ? 'stale' : 'fresh', ageMs };
}
function declarationInventory(manifest, nowMs) {
const observe = item => ({ ...item, authority: 'declared-only', freshness: heartbeat(item.updatedAt, nowMs) });
const goals = manifest.goals.map(observe);
const sessions = manifest.sessions.map(observe);
const counts = (items, statuses) => Object.fromEntries(statuses.map(status =>
[status, items.filter(item => item.status === status).length]));
const statuses = ['active', 'complete', 'blocked', 'unknown'];
const native = goals.filter(g => g.kind === 'native');
return { goals, sessions, activity: {
declaredGoalsByStatus: counts(goals, statuses),
declaredNativeGoalsByStatus: counts(native, statuses),
declaredSessionsByStatus: counts(sessions, ['open', 'closed', 'unknown']),
openSessionsWithoutGoalDeclaration: sessions.filter(s => s.status === 'open' && s.goalId === null).length,
freshActiveNativeGoalDeclarations: native.filter(g => g.status === 'active' && g.freshness.state === 'fresh').length
} };
}
function proximityWarnings(manifest) {
const warnings = [];
let workBudget = 200000;
for (const repo of manifest.repositories) {
const tasks = manifest.tasks.filter(t => t.repoId === repo.id && t.paths.length > 0).sort((a,b) => a.id < b.id ? -1 : 1);
if (tasks.length < 2) continue;
const parsed = buildDependencyGraphFromSources(repo.sources);
const graph = { ...parsed, adjacency: Object.assign(Object.create(null), parsed.adjacency) };
const graphCost = 1 + graph.files.length + Object.values(graph.adjacency).reduce((sum, edges) => sum + edges.length, 0);
const pathPairs = tasks.reduce((sum, task, i) => sum + task.paths.length * tasks.slice(i + 1).reduce((n, other) => n + other.paths.length, 0), 0);
workBudget -= pathPairs * graphCost;
if (workBudget < 0) throw new Error('Inventory comparison budget exceeded; split the manifest.');
for (let i = 0; i < tasks.length; i += 1) {
for (let j = i + 1; j < tasks.length; j += 1) {
const a = tasks[i]; const b = tasks[j];
const score = collisionRisk({ files: a.paths.map(p => ({ path: p })) }, { files: b.paths.map(p => ({ path: p })) }, graph);
if (score.risk < 0.35) continue;
const reasons = [];
if (score.channels.overlap) reasons.push('path_overlap');
if (score.channels.dependency) reasons.push('import_dependency');
warnings.push({ repoId: repo.id, tasks: [a.id, b.id], reasons, score: score.risk, channels: score.channels, action: 'review-declared-work' });
}
}
}
return warnings;
}
function buildInventory(input, options = {}) {
const m = normalizeManifest(input);
const now = timestamp(options.now || new Date().toISOString());
const nowMs = Date.parse(now);
const resources = options.resources || { memory: null, processStatus: 'not-requested', processes: [] };
const processes = new Map(resources.processes.map(p => [p.pid, p]));
const tasks = m.tasks.map(t => ({ ...t, heartbeat: heartbeat(t.heartbeatAt, nowMs),
process: processes.has(t.pid) ? { ...processes.get(t.pid), state: 'observed' }
: { state: t.pid && resources.processStatus === 'ok' ? 'not-observed' : 'unknown' }
}));
const leases = m.leases.map(l => ({ ...l, state: Date.parse(l.expiresAt) > nowMs ? 'unexpired' : 'expired', authority: 'declared-only' }));
const active = new Map();
for (const l of leases.filter(l => l.state === 'unexpired')) {
active.set(l.resource, new Set([...(active.get(l.resource) || []), l.owner]));
}
const leaseConflicts = [...active].filter(([,owners]) => owners.size > 1)
.map(([resource,owners]) => ({ resource, owners: [...owners].sort() })).sort((a,b) => a.resource < b.resource ? -1 : 1);
return {
version: 1, mode: 'read-only', observedAt: now, tasks, leases, leaseConflicts,
...declarationInventory(m, nowMs),
resources, warnings: proximityWarnings(m),
coverage: { tasks: 'declared-or-status-files-only', workingSets: 'declared-paths-only', imports: 'provided-source-map-relative-js-ts-only', leases: 'declared-only', processes: 'declared-pids-only', ...m.declarationCoverage },
limits: ['Score is a heuristic, not a calibrated probability.', 'No warning does not establish collision-free work.',
'Goal/session states and native kind are caller declarations, not verified execution or authority.',
'Open sessions, task status and observed PIDs do not establish an active native goal.',
'Missing declarations and empty lists do not establish global absence; fresh declarations do not prove current execution.',
'Stale heartbeat is not proof of a stuck process; PID reuse is not resolved.',
'Import regex may match comments and misses aliases, nonliteral and non-JS imports.',
'No semantic/PCA proximity or conflict-reduction claim is validated.',
'Leases are observations, not locks or permission grants.']
};
}
function collectResources(tasks, deps = {}) {
const memory = { totalBytes: (deps.totalmem || os.totalmem)(), freeBytes: (deps.freemem || os.freemem)(),
source: 'os', note: 'OS free memory is not application headroom or macOS memory pressure.' };
const pids = [...new Set(tasks.map(t => t.pid).filter(pid => Number.isSafeInteger(pid) && pid > 0))];
if (!pids.length) return { memory, processStatus: 'not-requested', processes: [] };
if (!['darwin', 'linux'].includes(deps.platform || process.platform)) return { memory, processStatus: 'unsupported', processes: [] };
try {
const result = (deps.execFileSync || execFileSync)('ps', ['-p', pids.join(','), '-o', 'pid=,ppid=,rss=,etime=,stat='],
{ encoding: 'utf8', timeout: 2000, maxBuffer: 65536, shell: false, stdio: ['ignore','pipe','pipe'] });
const processes = String(result).split('\n').filter(l => l.trim()).map(line => {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d:-]+)\s+([A-Za-z+<>NsElLW]+)$/);
if (!match) throw new Error('Invalid process metadata.');
const values = match.slice(1,4).map(Number);
if (values.some(v => !Number.isSafeInteger(v)) || !pids.includes(values[0])) throw new Error('Invalid process metadata.');
return { pid: values[0], parentPid: values[1], rssBytes: values[2] * 1024, elapsed: match[4], flags: match[5] };
});
return { memory, processStatus: 'ok', processes };
} catch { return { memory, processStatus: 'unavailable', processes: [] }; }
}
function readBounded(file, limit = MAX_BYTES) {
// Refuse symlink final components, devices and files beyond the byte budget.
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile() || stat.size > limit) throw new Error('Input exceeds file limit.');
const buffer = Buffer.alloc(limit + 1);
let size = 0; let count;
do { count = fs.readSync(fd, buffer, size, buffer.length - size, null); size += count; } while (count && size < buffer.length);
if (size > limit) throw new Error('Input exceeds file limit.');
return { content: buffer.subarray(0,size).toString('utf8'), modifiedAt: stat.mtime.toISOString() };
} finally { fs.closeSync(fd); }
}
function readJson(file) {
try { return JSON.parse(readBounded(file).content); }
catch (error) { throw new Error(error.message === 'Input exceeds file limit.' ? error.message : 'Cannot read coordination JSON.'); }
}
function collectTaskFiles(directory) {
try {
const entries = fs.readdirSync(directory, { withFileTypes: true }).filter(e => e.isDirectory() && !e.name.startsWith('.')).sort((a,b) => a.name < b.name ? -1 : 1);
if (entries.length > 64) throw new Error('Too many task directories.');
const tasks = []; const unreadable = [];
for (const entry of entries) {
let loaded = false;
for (const name of ['STATUS.md', 'status.md']) {
try {
const data = readBounded(path.join(directory, entry.name, name), 65536);
const parsed = parseWorkerStatus(data.content);
let heartbeatAt = null;
try { if (parsed.updated) heartbeatAt = timestamp(parsed.updated); } catch { /* Unknown timestamp, not a heartbeat. */ }
tasks.push(normalizeTask({ id: entry.name, paths: [], status: parsed.state || 'unknown', heartbeatAt, statusFileModifiedAt: data.modifiedAt }));
loaded = true; break;
} catch { /* Try legacy lowercase status filename; report unreadable below. */ }
}
if (!loaded) unreadable.push(entry.name);
}
return { status: unreadable.length ? 'partial' : 'ok', tasks, unreadable };
} catch { return { status: 'unavailable', tasks: [], unreadable: [] }; }
}
module.exports = { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson };
+52
View File
@@ -0,0 +1,52 @@
'use strict';
/**
* Canonical JSON and hashing helpers shared by the eval-harness frameworks.
*
* Every hash in the capsule journal, the gate receipts, and the offline
* receipts is computed over canonical JSON: object keys sorted recursively,
* no whitespace, UTF-8. Two writers that agree on content therefore agree on
* bytes, which is what makes projections and receipts reproducible.
*/
const crypto = require('crypto');
function canonicalize(value) {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(canonicalize);
}
const out = {};
for (const key of Object.keys(value).sort()) {
const item = value[key];
if (item === undefined) {
continue;
}
// Generic JSON keys are data, including __proto__; never invoke a setter.
Object.defineProperty(out, key, {
value: canonicalize(item), enumerable: true, writable: true, configurable: true,
});
}
return out;
}
function canonicalJson(value) {
return JSON.stringify(canonicalize(value));
}
function sha256Hex(input) {
return crypto.createHash('sha256').update(input).digest('hex');
}
function hashValue(value) {
return sha256Hex(canonicalJson(value));
}
module.exports = {
canonicalize,
canonicalJson,
sha256Hex,
hashValue,
};
+410
View File
@@ -0,0 +1,410 @@
'use strict';
/**
* Local execution capsule: an append-only, hash-linked NDJSON journal with
* five typed lineages and a deterministic projection.
*
* Framework 2 of the eval-harness set. Properties the tests pin down:
* - every entry links to its predecessor by sha256 (parent_hash);
* - verify() fails closed at the exact entry for tamper, truncation, and
* reordering, and reports a partial trailing write as truncation;
* - project() rebuilds the same bytes from the same journal every time;
* - exportBundle() copies the journal and projection only, never the
* workspace the run touched.
*
* What this does not claim: a hash chain does not stop an operator who
* replaces the whole log. Witnessing is a later, opt-in layer.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { canonicalJson, hashValue, sha256Hex } = require('./canonical');
const envelope = require('./envelope');
const JOURNAL_FILE = 'journal.ndjson';
const PROJECTION_FILE = 'projection.json';
const META_FILE = 'capsule.json';
const APPEND_LOCK_FILE = '.append.lock';
class CapsuleError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'CapsuleError';
this.code = code;
Object.assign(this, details);
}
}
function newId(prefix) {
return `${prefix}-${crypto.randomBytes(8).toString('hex')}`;
}
function nowIso(clock) {
return (clock ? clock() : new Date()).toISOString();
}
/** Validate metadata before persistence, and bind identity to every journal entry. */
function metadataFailure(meta, entries = []) {
const invalid = reason => ({ ok: false, code: 'capsule.metadata_invalid', reason, failed_at: null });
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || meta.schema !== envelope.SCHEMA_VERSION) {
return invalid('capsule metadata has an invalid schema');
}
for (const field of ['run_id', 'capsule_id']) {
if (typeof meta[field] !== 'string' || !envelope.ID_PATTERN.test(meta[field])) return invalid(`invalid metadata ${field}`);
}
for (const field of ['harness_version', 'task_family']) {
if (typeof meta[field] !== 'string' || !meta[field].trim()) return invalid(`invalid metadata ${field}`);
}
const date = typeof meta.created_at === 'string' ? new Date(meta.created_at) : new Date(NaN);
if (!Number.isFinite(date.getTime()) || date.toISOString() !== meta.created_at) return invalid('metadata created_at must be a canonical ISO timestamp');
const fields = ['schema', 'run_id', 'capsule_id', 'harness_version', 'task_family'];
for (const [index, entry] of entries.entries()) {
if (fields.some(field => entry[field] !== meta[field])) {
return { ok: false, code: 'capsule.metadata_mismatch', reason: `metadata identity differs from journal entry ${index}`, failed_at: index };
}
}
return null;
}
function releaseOwnedLock(lockPath, fd, identity) {
let inspectionDenied;
try {
// Keep the original descriptor open while checking ownership so its inode
// cannot be reused. Preserve a replacement detected before release; this
// check is not atomic against noncooperating filesystem mutation.
if (identity) {
let current;
try { current = fs.lstatSync(lockPath); } catch (error) {
if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release');
if (error.code !== 'EPERM') throw error;
inspectionDenied = error;
}
if (!inspectionDenied) {
if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino) {
throw new CapsuleError('capsule.lock_lost', 'append lock ownership changed before release');
}
fs.unlinkSync(lockPath);
}
}
} finally {
fs.closeSync(fd);
}
if (inspectionDenied) {
// Windows may deny stat while a removed file awaits its last handle close.
// Only confirmed absence changes the error. Never unlink after closing:
// the pathname could now belong to another owner, even with a reused inode.
try { fs.lstatSync(lockPath); } catch (error) {
if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release');
}
throw inspectionDenied;
}
}
/** Exclusive cooperative append lock. Never waits or infers stale ownership. */
function withAppendLock(dir, operation) {
const lockPath = path.join(dir, APPEND_LOCK_FILE);
let fd;
try {
fd = fs.openSync(lockPath, 'wx', 0o600);
} catch (error) {
if (error.code === 'EEXIST') throw new CapsuleError('capsule.busy', 'capsule append lock is already held');
throw error;
}
let identity;
try {
identity = fs.fstatSync(fd);
return operation();
} finally {
releaseOwnedLock(lockPath, fd, identity);
}
}
class Capsule {
/**
* @param {string} dir capsule root (created if missing)
* @param {object} meta { run_id, capsule_id, harness_version, task_family }
*/
constructor(dir, meta, options = {}) {
this.dir = path.resolve(dir);
this.meta = meta;
this.clock = options.clock || null;
this.journalPath = path.join(this.dir, JOURNAL_FILE);
this.lastHash = envelope.GENESIS_HASH;
this.nextSeq = 0;
}
static create(dir, options = {}) {
const resolved = path.resolve(dir);
if (fs.existsSync(path.join(resolved, META_FILE))) {
throw new CapsuleError('capsule.exists', `capsule already exists at ${resolved}`);
}
const meta = {
schema: envelope.SCHEMA_VERSION,
run_id: options.run_id === undefined ? newId('run') : options.run_id,
capsule_id: options.capsule_id === undefined ? newId('capsule') : options.capsule_id,
harness_version: options.harness_version === undefined ? 'unknown' : options.harness_version,
task_family: options.task_family === undefined ? 'unspecified' : options.task_family,
created_at: nowIso(options.clock),
};
const failure = metadataFailure(meta);
if (failure) throw new CapsuleError(failure.code, failure.reason);
fs.mkdirSync(resolved, { recursive: true });
fs.writeFileSync(path.join(resolved, META_FILE), canonicalJson(meta) + '\n', 'utf8');
fs.writeFileSync(path.join(resolved, JOURNAL_FILE), '', 'utf8');
return new Capsule(resolved, meta, options);
}
static open(dir, options = {}) {
const resolved = path.resolve(dir);
const state = readCapsule(resolved);
if (!state.ok) {
throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
}
const capsule = new Capsule(resolved, state.meta, options);
if (state.entries.length > 0) {
const last = state.entries[state.entries.length - 1];
capsule.lastHash = last.entry_hash;
capsule.nextSeq = last.seq + 1;
}
return capsule;
}
/**
* Serialize cooperating appenders and validate current disk state under lock.
* A partial I/O failure is preserved for diagnosis, never silently rolled back.
*/
append(lineage, kind, payload = {}, options = {}) {
return withAppendLock(this.dir, () => {
const state = readCapsule(this.dir);
if (!state.ok) throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
if (!envelope.LINEAGES.includes(lineage)) {
throw new CapsuleError('capsule.bad_lineage', `unknown lineage ${lineage}`);
}
const effectClass = options.effect_class || 'SE0';
const { payload: clean, dropped, findings, errors: payloadErrors } = envelope.redactPayload(payload, options);
if (payloadErrors.length > 0) {
throw new CapsuleError('capsule.payload_invalid', payloadErrors.join('; '));
}
if (findings.length > 0) {
throw new CapsuleError('capsule.secret_canary', `payload tripped secret canary ${findings[0].canary} at ${findings[0].path}`, { findings });
}
if (dropped.length > 0 && options.strict !== false) {
throw new CapsuleError('capsule.payload_denied', `payload keys not allowlisted: ${dropped.join(', ')}`, { dropped });
}
const body = {
schema: envelope.SCHEMA_VERSION,
run_id: state.meta.run_id,
capsule_id: state.meta.capsule_id,
seq: state.entries.length,
ts: nowIso(this.clock),
lineage,
kind,
effect_class: effectClass,
harness_version: state.meta.harness_version,
task_family: state.meta.task_family,
parent_hash: state.root_hash,
payload: clean,
};
const entry = { ...body, entry_hash: envelope.computeEntryHash(body) };
const errors = envelope.validateEnvelope(entry);
if (errors.length > 0) throw new CapsuleError('capsule.invalid_entry', errors.join('; '));
const bytes = Buffer.from(canonicalJson(entry) + '\n', 'utf8');
const fd = fs.openSync(this.journalPath, 'a');
try {
let offset = 0;
while (offset < bytes.length) {
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, null);
if (written <= 0) throw new CapsuleError('capsule.write_failed', 'journal write made no progress');
offset += written;
}
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
// These fields remain observable for compatibility, but are never used as
// authoritative append state. A preopened handle always reloads above.
this.meta = state.meta;
this.lastHash = entry.entry_hash;
this.nextSeq = entry.seq + 1;
return entry;
});
}
entries() {
const state = readJournal(this.journalPath);
if (!state.ok) {
throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
}
return state.entries;
}
}
/**
* Read and verify a journal file. Never throws for content problems; the
* result names the first failing entry index and a stable reason code.
*/
function readJournal(journalPath) {
if (!fs.existsSync(journalPath)) {
return { ok: false, code: 'capsule.missing_journal', reason: 'journal file missing', failed_at: null, entries: [] };
}
let bytes;
try { bytes = fs.readFileSync(journalPath); } catch {
return { ok: false, code: 'capsule.unreadable_journal', reason: 'journal file could not be read', failed_at: null, entries: [] };
}
const raw = bytes.toString('utf8');
if (!bytes.equals(Buffer.from(raw, 'utf8'))) {
return { ok: false, code: 'capsule.non_canonical', reason: 'journal is not valid UTF-8', failed_at: null, entries: [] };
}
const journalDigest = sha256Hex(bytes);
const entries = [];
if (raw.length === 0) {
return { ok: true, entries, root_hash: envelope.GENESIS_HASH, journal_sha256: journalDigest };
}
if (!raw.endsWith('\n')) {
const index = raw.split('\n').length - 1;
return { ok: false, code: 'capsule.truncated_tail', reason: 'last entry is incomplete (no terminating newline)', failed_at: index, entries };
}
const lines = raw.slice(0, -1).split('\n');
let expectedParent = envelope.GENESIS_HASH;
for (let index = 0; index < lines.length; index += 1) {
let entry;
try {
entry = JSON.parse(lines[index]);
} catch (_error) {
return { ok: false, code: 'capsule.corrupt_entry', reason: `entry ${index} is not valid JSON`, failed_at: index, entries };
}
const errors = envelope.validateEnvelope(entry);
if (errors.length > 0) {
return { ok: false, code: 'capsule.invalid_entry', reason: `entry ${index}: ${errors[0]}`, failed_at: index, entries };
}
if (entry.seq !== index) {
return { ok: false, code: 'capsule.reordered', reason: `entry ${index} carries seq ${entry.seq}`, failed_at: index, entries };
}
if (entry.parent_hash !== expectedParent) {
return { ok: false, code: 'capsule.broken_link', reason: `entry ${index} parent_hash does not match predecessor`, failed_at: index, entries };
}
if (canonicalJson(entry) !== lines[index]) {
return { ok: false, code: 'capsule.non_canonical', reason: `entry ${index} is not canonical JSON`, failed_at: index, entries };
}
expectedParent = entry.entry_hash;
entries.push(entry);
}
return { ok: true, entries, root_hash: expectedParent, journal_sha256: journalDigest };
}
/** Read one journal snapshot and validate its capsule metadata. Never writes. */
function readCapsule(dir) {
const resolved = path.resolve(dir);
const state = readJournal(path.join(resolved, JOURNAL_FILE));
if (!state.ok) return state;
let meta;
try {
const bytes = fs.readFileSync(path.join(resolved, META_FILE));
const raw = bytes.toString('utf8');
if (!bytes.equals(Buffer.from(raw, 'utf8'))) throw new Error('invalid UTF-8 metadata');
meta = JSON.parse(raw);
} catch {
return { ...state, ok: false, code: 'capsule.metadata_invalid', reason: 'capsule metadata is missing, unreadable or corrupt', failed_at: null };
}
const failure = metadataFailure(meta, state.entries);
if (failure) return { ...state, ...failure };
return { ...state, meta, projection: projectState(meta, state) };
}
function verify(dir) {
const state = readCapsule(dir);
return {
ok: state.ok,
code: state.ok ? 'ok' : state.code,
reason: state.ok ? 'journal verified' : state.reason,
failed_at: state.ok ? null : state.failed_at,
entry_count: state.entries.length,
root_hash: state.ok ? state.root_hash : null,
};
}
/**
* Deterministic projection: the same journal always yields the same bytes.
* Includes per-lineage counts, last seq, root hash, and the journal digest.
*/
function project(dir) {
const state = readCapsule(dir);
if (!state.ok) throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
return state.projection;
}
/** Derive the projection only from the metadata and journal snapshot just verified. */
function projectState(meta, state) {
const byLineage = {};
for (const lineage of envelope.LINEAGES) {
byLineage[lineage] = 0;
}
const byEffect = {};
for (const effectClass of envelope.EFFECT_CLASSES) {
byEffect[effectClass] = 0;
}
for (const entry of state.entries) {
byLineage[entry.lineage] += 1;
byEffect[entry.effect_class] += 1;
}
const projection = {
schema: envelope.SCHEMA_VERSION,
run_id: meta.run_id,
capsule_id: meta.capsule_id,
harness_version: meta.harness_version,
task_family: meta.task_family,
entry_count: state.entries.length,
last_seq: state.entries.length === 0 ? null : state.entries.length - 1,
root_hash: state.root_hash,
journal_sha256: state.journal_sha256,
by_lineage: byLineage,
by_effect_class: byEffect,
max_effect_class: maxEffectClass(state.entries),
};
return { ...projection, projection_hash: hashValue(projection) };
}
function maxEffectClass(entries) {
let rank = 0;
for (const entry of entries) {
rank = Math.max(rank, envelope.effectRank(entry.effect_class));
}
return envelope.EFFECT_CLASSES[rank];
}
function writeProjection(dir) {
const projection = project(dir);
fs.writeFileSync(path.join(path.resolve(dir), PROJECTION_FILE), canonicalJson(projection) + '\n', 'utf8');
return projection;
}
/**
* Export a minimal bundle: capsule.json, journal.ndjson, projection.json.
* Workspace contents are never copied.
*/
function exportBundle(dir, outDir) {
const resolved = path.resolve(dir);
const target = path.resolve(outDir);
fs.mkdirSync(target, { recursive: true });
writeProjection(resolved);
for (const name of [META_FILE, JOURNAL_FILE, PROJECTION_FILE]) {
fs.copyFileSync(path.join(resolved, name), path.join(target, name));
}
return { dir: target, files: [META_FILE, JOURNAL_FILE, PROJECTION_FILE] };
}
module.exports = {
Capsule,
CapsuleError,
JOURNAL_FILE,
PROJECTION_FILE,
META_FILE,
readJournal,
readCapsule,
verify,
project,
writeProjection,
exportBundle,
};
+5
View File
@@ -0,0 +1,5 @@
'use strict';
// Retired execution entrypoint. No JS interception or trust flag provides
// OS containment; refuse before reading requests or loading candidate code.
require('./gate').requireSupportedIsolation();
+251
View File
@@ -0,0 +1,251 @@
'use strict';
/**
* capsule-envelope/v1: the portable record contract for one journal entry.
*
* Framework 1 of the eval-harness set (telemetry and capsule contract).
* The envelope is deliberately small. It carries identity, lineage, effect
* class, a hash link to its predecessor, and an allowlisted payload. Raw
* secrets, credentials, and unrestricted reasoning text never enter the
* default envelope: the payload passes through a default-deny property
* allowlist and a secret canary scan before it is written.
*/
const { hashValue } = require('./canonical');
const SCHEMA_VERSION = 'capsule-envelope/v1';
/** The five append-only lineages a capsule records. */
const LINEAGES = Object.freeze(['plan', 'attempt', 'interaction', 'environment', 'strategy']);
/**
* Side-effect classes, ordered from pure to irreversible.
* SE0 read-only evaluation. SE1 reversible local writes inside a capsule root.
* SE2 sandboxed process or filesystem mutation, no live network writes.
* SE3 append-only remote evidence publication. SE4 economic or external effects.
*/
const EFFECT_CLASSES = Object.freeze(['SE0', 'SE1', 'SE2', 'SE3', 'SE4']);
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const HASH_PATTERN = /^[0-9a-f]{64}$/;
const GENESIS_HASH = '0'.repeat(64);
/** Scalar types mirror schemas/capsule-envelope.schema.json. */
const PAYLOAD_TYPES = Object.freeze({
task_id: 'string',
task_family: 'string',
tool: 'string',
tool_call_id: 'string',
args_hash: 'string',
response_hash: 'string',
status: 'string',
exit_code: 'integer|null',
duration_ms: 'number',
tokens_in: 'integer',
tokens_out: 'integer',
cost_usd: 'number',
model: 'string',
message: 'string',
note: 'string',
decision: 'string',
reason: 'string',
score: 'number',
passed: 'integer',
failed: 'integer',
total: 'integer',
variant: 'string',
digest: 'string',
path: 'string',
fixture_key: 'string',
stage: 'string',
verdict: 'string',
hits: 'integer',
branch_id: 'string',
parent_branch_id: 'string',
summary: 'string',
});
const DEFAULT_PAYLOAD_ALLOWLIST = Object.freeze(Object.keys(PAYLOAD_TYPES));
const ENVELOPE_FIELDS = new Set([
'schema', 'run_id', 'capsule_id', 'seq', 'ts', 'lineage', 'kind',
'effect_class', 'harness_version', 'task_family', 'parent_hash', 'entry_hash', 'payload',
]);
/**
* Secret and credential canaries. A match anywhere in a payload string is
* a hard refusal: the entry is not written and the caller sees which
* canary fired. Patterns are intentionally broad and cheap.
*/
const SECRET_CANARIES = Object.freeze([
{ name: 'private_key_block', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
{ name: 'aws_access_key', pattern: /\bAKIA[0-9A-Z]{16}\b/ },
{ name: 'openai_style_key', pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
{ name: 'github_token', pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
{ name: 'slack_token', pattern: /\bxox[abpr]-[A-Za-z0-9-]{10,}\b/ },
{ name: 'stripe_key', pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
{ name: 'bearer_header', pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/ },
{ name: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
{ name: 'env_assignment', pattern: /\b(?:API_KEY|SECRET|TOKEN|PASSWORD|PASSWD)\s*=\s*\S{8,}/i },
]);
function scanForCanaries(value, findings = [], trail = '$') {
if (typeof value === 'string') {
for (const canary of SECRET_CANARIES) {
if (canary.pattern.test(value)) {
findings.push({ canary: canary.name, path: trail });
}
}
return findings;
}
if (Array.isArray(value)) {
value.forEach((item, index) => scanForCanaries(item, findings, `${trail}[${index}]`));
return findings;
}
if (value && typeof value === 'object') {
for (const key of Object.keys(value)) {
scanForCanaries(value[key], findings, `${trail}.${key}`);
}
}
return findings;
}
function isPlainObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
/** Inspect descriptors before reading values; this is not a boundary for proxies. */
function dataObjectErrors(value, label) {
if (!isPlainObject(value)) return [`${label} must be a plain data object`];
const errors = [];
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (typeof key !== 'string' || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
errors.push(`${label} must contain only enumerable string data properties`);
}
}
return errors;
}
function matchesPayloadType(value, type) {
if (type === 'string') return typeof value === 'string';
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
if (type === 'integer|null' && value === null) return true;
return typeof value === 'number' && Number.isInteger(value);
}
/**
* Return { payload, dropped, findings, errors } without coercing retained fields.
* Custom allowlists only narrow v1. Invalid data is never scanned or hashed.
*/
function redactPayload(payload, options = {}) {
const errors = dataObjectErrors(payload, 'payload');
if (errors.length) return { payload: {}, dropped: [], findings: [], errors };
const allowlist = new Set(options.allowlist || DEFAULT_PAYLOAD_ALLOWLIST);
const kept = {};
const dropped = [];
for (const key of Object.keys(payload)) {
if (!Object.hasOwn(PAYLOAD_TYPES, key) || !allowlist.has(key)) {
dropped.push(key);
} else if (!matchesPayloadType(payload[key], PAYLOAD_TYPES[key])) {
errors.push(`payload field ${key} must have type ${PAYLOAD_TYPES[key]}`);
} else {
kept[key] = payload[key];
}
}
const findings = errors.length ? [] : scanForCanaries(kept);
return { payload: kept, dropped: dropped.sort(), findings, errors };
}
/**
* Validate one envelope. Returns an array of error strings; empty means valid.
* The check is structural and independent of the journal it came from.
* Hash-link correctness is verified by the capsule reader, not here.
*/
function validateEnvelope(entry) {
const errors = dataObjectErrors(entry, 'envelope');
if (errors.length) return errors;
if (Object.keys(entry).some(key => !ENVELOPE_FIELDS.has(key))) {
errors.push('envelope has unknown top-level fields');
}
if ([...ENVELOPE_FIELDS].some(key => !Object.hasOwn(entry, key))) {
errors.push('envelope is missing required own fields');
}
if (errors.length) return errors;
if (entry.schema !== SCHEMA_VERSION) {
errors.push(`schema must be ${SCHEMA_VERSION}`);
}
for (const field of ['run_id', 'capsule_id']) {
if (typeof entry[field] !== 'string' || !ID_PATTERN.test(entry[field])) {
errors.push(`${field} must match ${ID_PATTERN}`);
}
}
if (!Number.isInteger(entry.seq) || entry.seq < 0) {
errors.push('seq must be a non-negative integer');
}
if (typeof entry.ts !== 'string' || Number.isNaN(Date.parse(entry.ts))) {
errors.push('ts must be an ISO-8601 timestamp');
}
if (!LINEAGES.includes(entry.lineage)) {
errors.push(`lineage must be one of ${LINEAGES.join(', ')}`);
}
if (typeof entry.kind !== 'string' || !/^[a-z][a-z0-9_.-]{0,63}$/.test(entry.kind)) {
errors.push('kind must be a short lowercase identifier');
}
if (!EFFECT_CLASSES.includes(entry.effect_class)) {
errors.push(`effect_class must be one of ${EFFECT_CLASSES.join(', ')}`);
}
if (typeof entry.harness_version !== 'string' || entry.harness_version.length === 0) {
errors.push('harness_version must be a non-empty string');
}
if (typeof entry.task_family !== 'string' || entry.task_family.length === 0) {
errors.push('task_family must be a non-empty string');
}
if (typeof entry.parent_hash !== 'string' || !HASH_PATTERN.test(entry.parent_hash)) {
errors.push('parent_hash must be a 64-char hex sha256');
}
if (typeof entry.entry_hash !== 'string' || !HASH_PATTERN.test(entry.entry_hash)) {
errors.push('entry_hash must be a 64-char hex sha256');
}
if (!isPlainObject(entry.payload)) {
errors.push('payload must be an object');
} else {
const { dropped, findings, errors: payloadErrors } = redactPayload(entry.payload);
errors.push(...payloadErrors);
if (dropped.length > 0) {
errors.push(`payload has non-allowlisted keys: ${dropped.join(', ')}`);
}
for (const finding of findings) {
errors.push(`payload tripped secret canary ${finding.canary} at ${finding.path}`);
}
}
if (errors.length === 0) {
const expected = computeEntryHash(entry);
if (expected !== entry.entry_hash) {
errors.push('entry_hash does not match entry content');
}
}
return errors;
}
/** The hash covers every field except entry_hash itself. */
function computeEntryHash(entry) {
const { entry_hash: _ignored, ...rest } = entry;
return hashValue(rest);
}
module.exports = {
SCHEMA_VERSION,
LINEAGES,
EFFECT_CLASSES,
GENESIS_HASH,
DEFAULT_PAYLOAD_ALLOWLIST,
SECRET_CANARIES,
ID_PATTERN,
HASH_PATTERN,
redactPayload,
scanForCanaries,
validateEnvelope,
computeEntryHash,
effectRank: (effectClass) => EFFECT_CLASSES.indexOf(effectClass),
};
+5
View File
@@ -0,0 +1,5 @@
'use strict';
// Retired execution entrypoint. No JS interception or trust flag provides
// OS containment; refuse before reading requests or loading candidate code.
require('./gate').requireSupportedIsolation();
+258
View File
@@ -0,0 +1,258 @@
'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,
};
+24
View File
@@ -0,0 +1,24 @@
'use strict';
/**
* ECC eval-harness frameworks.
*
* envelope capsule-envelope/v1 contract, redaction, secret canaries
* capsule append-only hash-linked journal with five lineages
* retrospective offline report-only grouping of selected capsule snapshots
* gate static inspection and disabled execution gate, syntactic warnings
* replay declared tool effects, fixtures, fail-closed replay, retired effect preload
* receipt offline-verifiable capsule receipts
*
* See docs/architecture/eval-harness-frameworks.md and examples/eval-harness.
*/
module.exports = {
canonical: require('./canonical'),
envelope: require('./envelope'),
capsule: require('./capsule'),
retrospective: require('./retrospective'),
gate: require('./gate'),
replay: require('./replay'),
receipt: require('./receipt'),
};
+180
View File
@@ -0,0 +1,180 @@
'use strict';
/**
* Offline-verifiable capsule receipts.
*
* Framework 5 of the eval-harness set (verifiable receipts, local only).
* A receipt names the capsule root hash, entry count, schema version, the
* artifact digest under evaluation, and the gate receipt digest. It can be
* verified on a machine that never sees the source store as long as it has
* the exported bundle. The signature field is a detached interface: callers
* pass a signer/verifier pair; nothing here generates or stores keys.
*
* Signatures prove who vouched for the bytes, not that the run was correct.
*/
const fs = require('fs');
const path = require('path');
const { isDeepStrictEqual } = require('util');
const { canonicalJson, hashValue, sha256Hex } = require('./canonical');
const capsule = require('./capsule');
const envelope = require('./envelope');
const RECEIPT_SCHEMA = 'capsule-receipt/v1';
function digestFile(filePath) {
return sha256Hex(fs.readFileSync(filePath));
}
/**
* Build a receipt and persist its verified projection in the capsule directory.
* options: { artifact_path | artifact_digest, gate_receipt (object), signer(fn) }
*/
function buildReceipt(capsuleDir, options = {}) {
if (options.artifact_digest !== undefined && options.artifact_digest !== null
&& (typeof options.artifact_digest !== 'string' || !envelope.HASH_PATTERN.test(options.artifact_digest))) {
throw new capsule.CapsuleError('receipt.schema_invalid', 'artifact_digest must be a SHA-256 digest or null');
}
const artifactDigest = options.artifact_digest
|| (options.artifact_path ? digestFile(options.artifact_path) : null);
const projection = capsule.writeProjection(capsuleDir);
const receipt = {
schema: RECEIPT_SCHEMA,
envelope_schema: envelope.SCHEMA_VERSION,
capsule_id: projection.capsule_id,
run_id: projection.run_id,
capsule_root: projection.root_hash,
entry_count: projection.entry_count,
journal_sha256: projection.journal_sha256,
projection_hash: projection.projection_hash,
artifact_digest: artifactDigest,
gate_receipt_digest: options.gate_receipt ? hashValue(options.gate_receipt) : null,
gate_verdict: options.gate_receipt ? options.gate_receipt.verdict || null : null,
created_at: (options.clock ? options.clock() : new Date()).toISOString(),
signature: null,
};
const receiptHash = hashValue(receipt);
return {
...receipt,
receipt_hash: receiptHash,
signature: typeof options.signer === 'function' ? options.signer(receiptHash) : null,
};
}
function validReceiptSchema(receipt) {
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
|| receipt.schema !== RECEIPT_SCHEMA || receipt.envelope_schema !== envelope.SCHEMA_VERSION
|| !Number.isSafeInteger(receipt.entry_count) || receipt.entry_count < 0) return false;
for (const field of ['run_id', 'capsule_id']) {
if (typeof receipt[field] !== 'string' || !envelope.ID_PATTERN.test(receipt[field])) return false;
}
for (const field of ['capsule_root', 'journal_sha256', 'projection_hash', 'receipt_hash']) {
if (typeof receipt[field] !== 'string' || !envelope.HASH_PATTERN.test(receipt[field])) return false;
}
for (const field of ['artifact_digest', 'gate_receipt_digest']) {
if (receipt[field] !== null && (typeof receipt[field] !== 'string' || !envelope.HASH_PATTERN.test(receipt[field]))) return false;
}
return true;
}
/** Read and compare the supplied projection without writing or regenerating it. */
function projectionMatches(dir, expected, receipt) {
try {
const bytes = fs.readFileSync(path.join(path.resolve(dir), capsule.PROJECTION_FILE));
const raw = bytes.toString('utf8');
if (!bytes.equals(Buffer.from(raw, 'utf8'))) return false;
const stored = JSON.parse(raw);
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return false;
const { projection_hash: claimed, ...body } = stored;
return hashValue(body) === claimed && claimed === receipt.projection_hash
&& isDeepStrictEqual(stored, expected);
} catch {
return false;
}
}
/**
* Verify a receipt against a capsule directory (or exported bundle).
* Returns { ok, check, reason }. `check` names the first failing check:
* schema, receipt_hash, signature, journal_present, journal_integrity,
* truncation, stale_checkpoint, capsule_root, metadata, projection, artifact, gate_receipt.
*/
function verifyReceipt(receipt, capsuleDir, options = {}) {
const fail = (check, reason) => ({ ok: false, check, reason });
if (!validReceiptSchema(receipt)) {
return fail('schema', 'receipt schema, count, identity or digest fields are invalid');
}
const { receipt_hash: claimedHash, signature, ...unsigned } = receipt;
const recomputed = hashValue({ ...unsigned, signature: null });
if (recomputed !== claimedHash) {
return fail('receipt_hash', 'receipt content does not match receipt_hash');
}
if (typeof options.verifier === 'function') {
if (!signature) {
return fail('signature', 'receipt is unsigned but a verifier was supplied');
}
if (!options.verifier(claimedHash, signature)) {
return fail('signature', 'signature does not verify for this receipt_hash');
}
}
const journalPath = path.join(path.resolve(capsuleDir), capsule.JOURNAL_FILE);
if (!fs.existsSync(journalPath)) {
return fail('journal_present', 'journal.ndjson missing from capsule directory');
}
const state = capsule.readCapsule(capsuleDir);
if (!state.ok) {
const check = state.code.startsWith('capsule.metadata_') ? 'metadata' : 'journal_integrity';
return fail(check, `${state.reason} (entry ${state.failed_at})`);
}
if (state.entries.length < receipt.entry_count) {
return fail('truncation', `journal has ${state.entries.length} entries, receipt names ${receipt.entry_count}`);
}
const rootAtReceipt = receipt.entry_count === 0
? envelope.GENESIS_HASH
: state.entries[receipt.entry_count - 1].entry_hash;
if (rootAtReceipt !== receipt.capsule_root) {
return fail('capsule_root', 'journal prefix does not reproduce the receipt capsule_root');
}
if (state.entries.length > receipt.entry_count) {
return fail('stale_checkpoint', `journal advanced to ${state.entries.length} entries after the receipt (prefix verified)`);
}
if (receipt.journal_sha256 !== state.journal_sha256) {
return fail('journal_integrity', 'journal bytes differ from receipt journal_sha256');
}
if (receipt.run_id !== state.meta.run_id || receipt.capsule_id !== state.meta.capsule_id) {
return fail('metadata', 'receipt identity differs from the verified capsule');
}
if (!projectionMatches(capsuleDir, state.projection, receipt)) {
return fail('projection', 'projection is missing, unreadable, corrupt or differs from the verified capsule and receipt');
}
if (options.artifact_path) {
let digest;
try { digest = digestFile(options.artifact_path); } catch {
return fail('artifact', 'artifact could not be read');
}
if (digest !== receipt.artifact_digest) {
return fail('artifact', 'artifact digest does not match receipt');
}
} else if (options.artifact_digest && options.artifact_digest !== receipt.artifact_digest) {
return fail('artifact', 'artifact digest does not match receipt');
}
if (options.gate_receipt && hashValue(options.gate_receipt) !== receipt.gate_receipt_digest) {
return fail('gate_receipt', 'gate receipt digest does not match receipt');
}
return { ok: true, check: null, reason: 'receipt verified' };
}
function writeReceipt(receipt, filePath) {
fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
fs.writeFileSync(filePath, canonicalJson(receipt) + '\n', 'utf8');
return path.resolve(filePath);
}
module.exports = {
RECEIPT_SCHEMA,
buildReceipt,
verifyReceipt,
writeReceipt,
digestFile,
};
+152
View File
@@ -0,0 +1,152 @@
'use strict';
/**
* Replay-safe tool calls: declared determinism and effect class per tool,
* content-addressed fixtures, and fail-closed replay.
*
* Framework 4 of the eval-harness set (replay-safe branch and diff, first
* slices). Modes:
* record call the live implementation, store the response under the
* canonical hash of (tool, args);
* replay never call the live implementation; return the stored response
* or fail with tool.fixture_missing. Tools declared SE3 or above
* fail with tool.effect_forbidden regardless of fixtures.
*
* Money-touching or counterparty-facing tools never get permissive replay.
*/
const fs = require('fs');
const path = require('path');
const { canonicalJson, hashValue } = require('./canonical');
const envelope = require('./envelope');
class ReplayError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'ReplayError';
this.code = code;
Object.assign(this, details);
}
}
class FixtureStore {
constructor(dir) {
this.dir = path.resolve(dir);
fs.mkdirSync(this.dir, { recursive: true });
}
key(tool, args) {
return hashValue({ tool, args });
}
pathFor(key) {
return path.join(this.dir, `${key}.json`);
}
has(tool, args) {
return fs.existsSync(this.pathFor(this.key(tool, args)));
}
put(tool, args, response) {
const key = this.key(tool, args);
const record = {
key,
tool,
args_hash: hashValue(args),
response_hash: hashValue(response),
response,
};
fs.writeFileSync(this.pathFor(key), canonicalJson(record) + '\n', 'utf8');
return record;
}
get(tool, args) {
const key = this.key(tool, args);
const filePath = this.pathFor(key);
if (!fs.existsSync(filePath)) {
throw new ReplayError('tool.fixture_missing', `no fixture for ${tool} (${key.slice(0, 16)})`, { tool, key });
}
let record;
try {
record = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (_error) {
throw new ReplayError('tool.fixture_corrupt', `fixture ${key.slice(0, 16)} is not valid JSON`, { tool, key });
}
if (record.tool !== tool || record.args_hash !== hashValue(args)) {
throw new ReplayError('tool.fixture_mismatch', `fixture ${key.slice(0, 16)} was recorded for different arguments`, { tool, key });
}
if (record.response_hash !== hashValue(record.response)) {
throw new ReplayError('tool.fixture_mismatch', `fixture ${key.slice(0, 16)} response hash does not match its content`, { tool, key });
}
return record;
}
}
/**
* tools: { name: { effect_class, determinism: 'deterministic'|'nondeterministic', impl(args) } }
* options: { mode: 'record'|'replay', store: FixtureStore, maxEffectClass: 'SE2', onCall(entry) }
*/
function createReplayer(tools, options = {}) {
const mode = options.mode || 'replay';
const store = options.store;
const maxRank = envelope.effectRank(options.maxEffectClass || 'SE2');
if (!['record', 'replay'].includes(mode)) {
throw new ReplayError('replay.bad_mode', `mode must be record or replay, got ${mode}`);
}
if (!store) {
throw new ReplayError('replay.no_store', 'a FixtureStore is required');
}
for (const [name, tool] of Object.entries(tools)) {
if (!envelope.EFFECT_CLASSES.includes(tool.effect_class)) {
throw new ReplayError('replay.bad_declaration', `tool ${name} must declare an effect_class`);
}
if (!['deterministic', 'nondeterministic'].includes(tool.determinism)) {
throw new ReplayError('replay.bad_declaration', `tool ${name} must declare determinism`);
}
}
const calls = [];
const emit = (entry) => {
calls.push(entry);
if (typeof options.onCall === 'function') {
options.onCall(entry);
}
};
return {
mode,
calls,
call(name, args = {}) {
const tool = tools[name];
if (!tool) {
throw new ReplayError('tool.unknown', `tool ${name} is not declared`);
}
const rank = envelope.effectRank(tool.effect_class);
if (rank > maxRank) {
emit({ tool: name, mode, status: 'refused', code: 'tool.effect_forbidden' });
throw new ReplayError('tool.effect_forbidden', `tool ${name} is ${tool.effect_class}, above the allowed ${options.maxEffectClass || 'SE2'}`, { tool: name });
}
if (mode === 'replay') {
if (rank >= envelope.effectRank('SE3')) {
emit({ tool: name, mode, status: 'refused', code: 'tool.effect_forbidden' });
throw new ReplayError('tool.effect_forbidden', `tool ${name} (${tool.effect_class}) can never be replayed`, { tool: name });
}
const record = store.get(name, args);
emit({ tool: name, mode, status: 'replayed', fixture_key: record.key, args_hash: record.args_hash, response_hash: record.response_hash });
return record.response;
}
const response = tool.impl(args);
const record = store.put(name, args, response);
emit({ tool: name, mode, status: 'recorded', fixture_key: record.key, args_hash: record.args_hash, response_hash: record.response_hash });
return response;
},
};
}
module.exports = {
ReplayError,
FixtureStore,
createReplayer,
EFFECT_FENCE_PRELOAD: path.join(__dirname, 'effect-fence.js'),
};
+101
View File
@@ -0,0 +1,101 @@
'use strict';
/** Read-only retrospective preparation over explicit, quiescent local capsules.
* Counts are declarations in the records, never scores or promotion evidence.
* Inherits capsule.project's local-reader limits; not hostile-filesystem isolation.
*/
const capsule = require('./capsule');
const { LINEAGES, EFFECT_CLASSES } = require('./envelope');
const { hashValue } = require('./canonical');
const MAX_INPUTS = 100;
const SOURCE_FIELDS = ['entry_count', 'root_hash', 'journal_sha256', 'projection_hash'];
class RetrospectiveError extends Error {
constructor(code, message, inputIndex) {
super(message);
this.name = 'RetrospectiveError';
this.code = code;
if (inputIndex !== undefined) this.input_index = inputIndex;
}
}
function readProjection(dir, index) {
try {
return capsule.project(dir);
} catch {
// Reader errors can contain private paths or journal content. No partial
// report or unchecked diagnostic content crosses the report boundary.
throw new RetrospectiveError('retrospective.invalid_capsule', `capsule at input index ${index} failed verification`, index);
}
}
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
const identity = projection => JSON.stringify([projection.run_id, projection.capsule_id]);
function summarizeGroup(harnessVersion, projections) {
const total = keys => Object.fromEntries(keys.map(key => [key, 0]));
const byLineage = total(LINEAGES);
const byEffect = total(EFFECT_CLASSES);
for (const projection of projections) {
for (const key of LINEAGES) byLineage[key] += projection.by_lineage[key];
for (const key of EFFECT_CLASSES) byEffect[key] += projection.by_effect_class[key];
}
return {
harness_version: harnessVersion,
capsule_count: projections.length,
entry_count: projections.reduce((sum, item) => sum + item.entry_count, 0),
by_lineage: byLineage,
by_effect_class: byEffect,
sources: [...projections].sort((a, b) => compare(identity(a), identity(b)))
.map(item => ({
identity_hash: hashValue([item.run_id, item.capsule_id]),
...Object.fromEntries(SOURCE_FIELDS.map(key => [key, item[key]])),
})),
};
}
/**
* Group up to 100 selected snapshots of one task family by harness_version.
* Duplicate identities count once only if the verified projections match.
* Different checkpoints of the same identity are ambiguous and refused.
* Does not read saved projections, copy payloads, write files or invoke tools.
*/
function groupCapsules(dirs) {
if (!Array.isArray(dirs) || dirs.length < 1 || dirs.length > MAX_INPUTS
|| !Array.from(dirs).every(dir => typeof dir === 'string' && dir.trim() && !dir.includes('\0'))) {
throw new RetrospectiveError('retrospective.invalid_inputs', `supply 1 to ${MAX_INPUTS} capsule directory paths`);
}
const projections = dirs.map(readProjection);
const family = projections[0].task_family;
const unique = new Map();
for (const [index, projection] of projections.entries()) {
if (projection.task_family !== family) {
throw new RetrospectiveError('retrospective.mixed_task_families', 'all capsules must have the same task family', index);
}
const key = identity(projection);
const previous = unique.get(key);
if (previous && previous.projection_hash !== projection.projection_hash) {
throw new RetrospectiveError('retrospective.conflicting_identity', 'conflicting snapshots share a capsule identity', index);
}
unique.set(key, projection);
}
const byHarness = new Map();
for (const projection of unique.values()) {
const version = projection.harness_version;
byHarness.set(version, [...(byHarness.get(version) || []), projection]);
}
const report = {
schema: 'capsule-retrospective/v1',
report_only: true,
task_family: family,
input_count: dirs.length,
capsule_count: unique.size,
duplicate_count: dirs.length - unique.size,
groups: [...byHarness].sort(([a], [b]) => compare(a, b))
.map(([version, items]) => summarizeGroup(version, items)),
};
return { ...report, report_hash: hashValue(report) };
}
module.exports = { groupCapsules, MAX_INPUTS, RetrospectiveError };
+18 -1
View File
@@ -84,7 +84,24 @@ function applySync(repo, options = {}, context = {}) {
assertValidRepo(repo);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 });
const labelIssues = listIssues(repo, {
...options,
state: options.state || 'all',
limit: options.limit || 100,
label: policy.labels && policy.labels.epic,
});
const marker = policy.sectionMarker || 'ecc-coordination';
const coordinatedIssues = listIssues(repo, {
...options,
state: options.state || 'all',
limit: options.limit || 100,
search: `in:body "${marker}:start"`,
});
const issues = Array.from(
new Map(
[...labelIssues, ...coordinatedIssues].map(issue => [String(issue.number), issue])
).values()
);
const syncedAt = new Date().toISOString();
const results = [];
+5 -2
View File
@@ -102,7 +102,7 @@ function listIssues(repo, options = {}) {
const { owner, name } = normalizeRepo(repo);
const limit = Number.isFinite(options.limit) ? options.limit : 100;
const state = options.state || 'all';
return runGhJson([
const args = [
'issue',
'list',
'--repo',
@@ -111,9 +111,12 @@ function listIssues(repo, options = {}) {
state,
'--limit',
String(limit),
...(options.label ? ['--label', options.label] : []),
...(options.search ? ['--search', options.search] : []),
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options) || [];
];
return runGhJson(args, options) || [];
}
function editIssue(repo, issueNumber, options = {}) {
+5 -1
View File
@@ -74,10 +74,14 @@ function loadPolicy(rootDir = process.cwd(), configPath = null) {
const branchModel = typeof parsed.branchModel === 'object' && parsed.branchModel !== null && !Array.isArray(parsed.branchModel) ? parsed.branchModel : {};
const project = typeof parsed.project === 'object' && parsed.project !== null && !Array.isArray(parsed.project) ? parsed.project : {};
const fieldNames = typeof project.fieldNames === 'object' && project.fieldNames !== null && !Array.isArray(project.fieldNames) ? project.fieldNames : {};
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
if (typeof mergedLabels.epic !== 'string' || !mergedLabels.epic.trim()) {
throw new Error(`Policy file ${resolvedPath} must define labels.epic as a non-empty string`);
}
return {
...DEFAULT_POLICY,
...parsed,
labels: { ...DEFAULT_LABELS, ...labels },
labels: mergedLabels,
review: { ...DEFAULT_POLICY.review, ...review },
validation: { ...DEFAULT_POLICY.validation, ...validation },
branchModel: { ...DEFAULT_POLICY.branchModel, ...branchModel },
+318
View File
@@ -0,0 +1,318 @@
'use strict';
/**
* Read hooks/hooks.json together with its sibling hooks/hooks.metadata.json.
*
* Claude Code validates a plugin's hooks.json against its own schema and warns
* about every key it does not recognise, so ECC's stable matcher ids and
* human-readable descriptions cannot live in that file. They are kept in a
* sidecar keyed by event name and aligned with hooks.json entry order, and
* merged back here so the rest of ECC keeps seeing one object with `id` and
* `description` on each matcher entry.
*
* Index alignment alone cannot tell a reordered hooks.json from a correct one,
* so every sidecar entry also carries a fingerprint of the matcher entry it
* describes. A mismatch means the two files drifted apart.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const HOOKS_FILENAME = 'hooks.json';
const METADATA_FILENAME = 'hooks.metadata.json';
const FINGERPRINT_LENGTH = 12;
const FINGERPRINT_PATTERN = /^[0-9a-f]{12}$/;
function readJsonObject(filePath, label) {
let raw;
try {
raw = fs.readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(`Unable to read ${label} at ${filePath}: ${error.message}`);
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid JSON in ${label} at ${filePath}: ${error.message}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
}
return parsed;
}
function metadataPathFor(hooksPath) {
return path.join(path.dirname(hooksPath), METADATA_FILENAME);
}
/**
* JSON.stringify with object keys sorted, so a fingerprint does not change when
* someone reorders the keys inside a hook object.
*/
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map(
key => `${JSON.stringify(key)}:${stableStringify(value[key])}`
).join(',')}}`;
}
return JSON.stringify(value);
}
/**
* Fingerprint the parts of a hooks.json matcher entry that identify it: its
* matcher and its hook commands. Ids and descriptions are excluded so the
* fingerprint is the same whether or not metadata has been merged in.
*
* @param {object} entry - A matcher entry from hooks.json.
* @returns {string} short hex digest
*/
function fingerprintHookEntry(entry) {
const subject = {
matcher: entry && 'matcher' in entry ? entry.matcher : null,
hooks: entry && Array.isArray(entry.hooks) ? entry.hooks : [],
};
return crypto.createHash('sha256')
.update(stableStringify(subject))
.digest('hex')
.slice(0, FINGERPRINT_LENGTH);
}
function eventsOf(hooksConfig) {
return hooksConfig && typeof hooksConfig.hooks === 'object' && hooksConfig.hooks
&& !Array.isArray(hooksConfig.hooks)
? hooksConfig.hooks
: null;
}
function metadataEntriesOf(metadata) {
return metadata && typeof metadata.entries === 'object' && metadata.entries
&& !Array.isArray(metadata.entries)
? metadata.entries
: null;
}
/**
* Merge sidecar metadata into a parsed hooks.json object.
*
* Neither argument is mutated; the returned config shares untouched matcher
* entries with the input and copies the ones that receive metadata.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object|null} metadata - Parsed hooks.metadata.json, or null when absent.
* @returns {object} a new hooks configuration with id/description restored.
*/
function applyHooksMetadata(hooksConfig, metadata) {
const events = eventsOf(hooksConfig);
const entriesByEvent = metadataEntriesOf(metadata);
if (!events || !entriesByEvent) {
return hooksConfig;
}
const mergedEvents = {};
for (const [event, entries] of Object.entries(events)) {
const eventMetadata = entriesByEvent[event];
if (!Array.isArray(entries) || !Array.isArray(eventMetadata)) {
mergedEvents[event] = entries;
continue;
}
mergedEvents[event] = entries.map((entry, index) => {
const entryMetadata = eventMetadata[index];
if (!entry || typeof entry !== 'object') return entry;
if (!entryMetadata || typeof entryMetadata !== 'object') return entry;
const merged = { ...entry };
if (typeof entryMetadata.id === 'string' && !('id' in entry)) {
merged.id = entryMetadata.id;
}
if (typeof entryMetadata.description === 'string' && !('description' in entry)) {
merged.description = entryMetadata.description;
}
return merged;
});
}
return { ...hooksConfig, hooks: mergedEvents };
}
/**
* Report entries whose metadata is missing, misaligned, or bound to a
* different matcher entry than the one at the same index.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object|null} metadata - Parsed hooks.metadata.json.
* @returns {string[]} human-readable problems; empty when the sidecar lines up.
*/
function findMetadataMismatches(hooksConfig, metadata) {
const problems = [];
const idLocations = new Map();
const events = eventsOf(hooksConfig) || {};
const entriesByEvent = metadataEntriesOf(metadata) || {};
for (const [event, entries] of Object.entries(events)) {
if (!Array.isArray(entries)) continue;
const eventMetadata = entriesByEvent[event];
if (!Array.isArray(eventMetadata)) {
problems.push(`${METADATA_FILENAME} is missing entries for event "${event}"`);
continue;
}
if (eventMetadata.length !== entries.length) {
problems.push(
`${METADATA_FILENAME} lists ${eventMetadata.length} entr(ies) for event "${event}" `
+ `but ${HOOKS_FILENAME} has ${entries.length}`
);
continue;
}
eventMetadata.forEach((entry, index) => {
const label = `${METADATA_FILENAME} ${event}[${index}]`;
if (!entry || typeof entry !== 'object') {
problems.push(`${label} is not an object`);
return;
}
if (typeof entry.id !== 'string' || entry.id.trim() === '') {
problems.push(`${label} is missing a non-empty "id"`);
} else if (idLocations.has(entry.id)) {
problems.push(`${label} has duplicate id "${entry.id}" already used by ${idLocations.get(entry.id)}`);
} else {
idLocations.set(entry.id, label);
}
if ('description' in entry && typeof entry.description !== 'string') {
problems.push(`${label} has a non-string "description"`);
}
if (typeof entry.fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(entry.fingerprint)) {
problems.push(`${label} is missing a valid "fingerprint"`);
return;
}
const expected = fingerprintHookEntry(entries[index]);
if (entry.fingerprint !== expected) {
problems.push(
`${label} (id "${entry.id}") fingerprint ${entry.fingerprint} does not match `
+ `${HOOKS_FILENAME} ${event}[${index}] (${expected}); the entries were reordered `
+ 'or the hook command changed - regenerate with '
+ 'node scripts/ci/validate-hooks.js --update-fingerprints'
);
}
});
}
for (const event of Object.keys(entriesByEvent)) {
if (!Array.isArray(events[event])) {
problems.push(`${METADATA_FILENAME} describes event "${event}" which ${HOOKS_FILENAME} does not define`);
}
}
return problems;
}
/**
* Return a copy of the sidecar with every fingerprint recomputed from the
* matcher entry at the same index. Used to refresh the sidecar after hook
* commands change.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object} metadata - Parsed hooks.metadata.json.
* @returns {object} a new metadata object
*/
function withRefreshedFingerprints(hooksConfig, metadata) {
const events = eventsOf(hooksConfig) || {};
const entriesByEvent = metadataEntriesOf(metadata) || {};
const refreshed = {};
// A known fingerprint at another position signals a reorder, not a command
// edit. Require the author to move its metadata before refreshing anything.
const positions = new Map();
for (const [event, entries] of Object.entries(events)) {
if (!Array.isArray(entries)) continue;
entries.forEach((entry, index) => {
const fingerprint = fingerprintHookEntry(entry);
const locations = positions.get(fingerprint) || [];
positions.set(fingerprint, [...locations, `${event}[${index}]`]);
});
}
for (const [event, entries] of Object.entries(entriesByEvent)) {
if (!Array.isArray(entries)) continue;
entries.forEach((entry, index) => {
const locations = positions.get(entry?.fingerprint);
const location = `${event}[${index}]`;
if (locations && !locations.includes(location)) {
throw new Error(`Metadata reorder detected at ${location}; move the matching sidecar entry before refreshing fingerprints`);
}
});
}
for (const [event, eventMetadata] of Object.entries(entriesByEvent)) {
const entries = Array.isArray(events[event]) ? events[event] : [];
refreshed[event] = Array.isArray(eventMetadata)
? eventMetadata.map((entry, index) => (
entry && typeof entry === 'object' && index < entries.length
? { ...entry, fingerprint: fingerprintHookEntry(entries[index]) }
: entry
))
: eventMetadata;
}
return { ...metadata, entries: refreshed };
}
function assertMetadataAligned(hooksConfig, metadata, hooksPath) {
const mismatches = findMetadataMismatches(hooksConfig, metadata);
if (mismatches.length > 0) {
throw new Error(
`${METADATA_FILENAME} does not line up with ${hooksPath}:\n ${mismatches.join('\n ')}`
);
}
}
/**
* Merge a sidecar into a hooks config, rejecting a sidecar that does not line
* up. Shared by the readers below so a truncated or reordered sidecar fails
* loudly instead of producing entries with the wrong or missing ids.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object} metadata - Parsed hooks.metadata.json.
* @param {string} hooksPath - Used in the error message.
* @returns {object} a new merged hooks configuration
*/
function mergeHooksMetadata(hooksConfig, metadata, hooksPath = HOOKS_FILENAME) {
assertMetadataAligned(hooksConfig, metadata, hooksPath);
return applyHooksMetadata(hooksConfig, metadata);
}
/**
* Read hooks.json and return it with sidecar metadata merged in.
*
* @param {string} hooksPath - Path to hooks/hooks.json.
* @param {string} [label] - Label used in error messages.
* @returns {object} the merged hooks configuration.
*/
function readHooksConfig(hooksPath, label = HOOKS_FILENAME) {
const hooksConfig = readJsonObject(hooksPath, label);
const metadataPath = metadataPathFor(hooksPath);
if (!fs.existsSync(metadataPath)) {
return hooksConfig;
}
return mergeHooksMetadata(hooksConfig, readJsonObject(metadataPath, METADATA_FILENAME), hooksPath);
}
module.exports = {
HOOKS_FILENAME,
METADATA_FILENAME,
applyHooksMetadata,
findMetadataMismatches,
fingerprintHookEntry,
mergeHooksMetadata,
metadataPathFor,
readHooksConfig,
readJsonObject,
withRefreshedFingerprints,
};
+333 -14
View File
@@ -3,6 +3,7 @@ const fs = require('fs');
const { execFileSync } = require('child_process');
const os = require('os');
const path = require('path');
const { isDeepStrictEqual } = require('util');
const { loadInstallManifests } = require('./install-manifests');
const { readInstallState, validateInstallState } = require('./install-state');
@@ -10,7 +11,9 @@ const { assertWithinTrustedRoot } = require('./path-safety');
const { createInstallPlanFromRequest } = require('./install/runtime');
const {
disableOpenCodeHookPluginRegistration,
getDisabledOpenCodePluginContent,
getRecordedHookConsent,
withHookConsent,
} = require('./install/hook-consent');
const {
prepareClaudeSkillMigration,
@@ -23,10 +26,22 @@ const {
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
} = require('./install/opencode-legacy-migration');
const {
acquireSettingsLock,
assertClaudeSettingsPath,
getClaudeSettingsPath,
inspectManagedHooks,
materializeManagedHooks,
repairManagedHooks,
uninstallManagedHooks,
updateSettingsAtomic,
validateManagedHooks,
} = require('./install/claude-settings');
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const { mergeHooksMetadata, metadataPathFor } = require('./hooks-config');
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built';
@@ -223,6 +238,9 @@ function transformCopyFileContent(operation, content) {
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
return getDisabledOpenCodePluginContent();
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
@@ -529,6 +547,32 @@ function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
}
/**
* Read hooks.json and merge in hooks/hooks.metadata.json without following
* symlinks. The sidecar holds the stable matcher ids that hooks.json cannot
* carry, because Claude Code reports unknown keys when the plugin loads. A
* sidecar that does not line up with hooks.json is rejected before repair can
* reconcile matchers under the wrong ids.
*
* @param {string} hooksPath - Path to the source hooks.json.
* @returns {object} the hooks configuration with ids and descriptions restored.
*/
function readHooksConfigNoFollow(hooksPath) {
const hooksConfig = readJsonNoFollow(hooksPath);
const metadataPath = metadataPathFor(hooksPath);
if (!fs.existsSync(metadataPath)) {
return hooksConfig;
}
return mergeHooksMetadata(hooksConfig, readJsonNoFollow(metadataPath), hooksPath);
}
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
if (target && target !== 'claude' && target !== 'claude-project') {
throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
}
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
}
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
const finalDestination = getManagedDestination(
@@ -693,8 +737,26 @@ function deepRemoveJsonSubset(currentValue, managedValue) {
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;
}
function hydrateRecordedOperations(repoRoot, operations) {
function hydrateRecordedOperations(repoRoot, operations, trustedRoot) {
return operations.map(operation => {
if (operation.kind === 'update-claude-settings') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) {
throw new Error(
`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`
);
}
return {
...operation,
sourcePath,
previousManagedHooks: operation.managedHooks,
managedHooks: materializeManagedHooks(
readHooksConfigNoFollow(sourcePath),
trustedRoot
),
};
}
if (operation.kind !== 'copy-file') {
return { ...operation };
}
@@ -723,7 +785,14 @@ function shouldRepairFromRecordedOperations(state) {
return getManagedOperations(state).some(operation => operation.kind !== 'copy-file');
}
function executeRepairOperation(repoRoot, operation, trustedRoot, linkIndex = null) {
function executeRepairOperation(
repoRoot,
operation,
trustedRoot,
linkIndex = null,
target = null,
settingsLockHeld = false
) {
// Install-state is attacker-controllable; never write/delete outside the
// adapter-derived trusted root, regardless of what the state file claims
// (GHSA-hfpv-w6mp-5g95).
@@ -776,6 +845,35 @@ function executeRepairOperation(repoRoot, operation, trustedRoot, linkIndex = nu
return operation.destinationPath;
}
if (operation.kind === 'update-claude-settings') {
assertClaudeSettingsDestination(operation, trustedRoot, target);
const managedHooks = validateManagedHooks(operation.managedHooks);
const previousManagedHooks = operation.previousManagedHooks
? validateManagedHooks(operation.previousManagedHooks, 'previous managed hooks')
: null;
const existingDestination = getContainedExistingPath(
operation.destinationPath,
trustedRoot,
'repair'
);
const settingsPath = existingDestination
? getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath
: prepareContainedWriteDestination(operation.destinationPath, trustedRoot, 'repair');
updateSettingsAtomic(
settingsPath,
currentSettings => repairManagedHooks(currentSettings, managedHooks, {
previousManagedHooks,
}),
{
lockHeld: settingsLockHeld,
beforeCommit() {
getManagedDestination(settingsPath, trustedRoot, 'repair');
},
}
);
return operation.destinationPath;
}
if (operation.kind === 'remove') {
const removedPath = removeContainedPath(
operation.destinationPath,
@@ -944,6 +1042,45 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) {
};
}
if (operation.kind === 'update-claude-settings') {
assertClaudeSettingsDestination(operation, trustedRoot, options.target);
const existingDestination = getContainedExistingPath(
operation.destinationPath,
trustedRoot,
'uninstall'
);
if (!existingDestination) {
return {
removedPaths: [],
cleanupTargets: []
};
}
const settingsPath = getManagedDestination(
existingDestination,
trustedRoot,
'uninstall'
).managedPath;
const uninstalled = updateSettingsAtomic(
settingsPath,
currentSettings => uninstallManagedHooks(currentSettings, operation.managedHooks),
{
lockHeld: Boolean(options.settingsLockHeld),
beforeCommit() {
getManagedDestination(settingsPath, trustedRoot, 'uninstall');
},
}
);
return {
removedPaths: [],
cleanupTargets: [],
retainedPaths: uninstalled.retained.length > 0
? [operation.destinationPath]
: []
};
}
if (operation.kind === 'remove') {
const previousContent = getOperationPreviousContent(operation);
if (previousContent !== null) {
@@ -972,7 +1109,7 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) {
throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`);
}
function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null) {
function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null, target = null) {
const destinationPath = operation.destinationPath;
if (!destinationPath) {
return {
@@ -1153,6 +1290,49 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n
};
}
if (operation.kind === 'update-claude-settings') {
try {
assertClaudeSettingsDestination(operation, trustedRoot, target);
} catch (_error) {
return {
status: 'unsafe-destination',
operation,
destinationPath,
reason: 'non-canonical-claude-settings'
};
}
let managedHooks;
try {
managedHooks = validateManagedHooks(operation.managedHooks);
} catch (_error) {
return {
status: 'unverified',
operation,
destinationPath
};
}
try {
const inspection = inspectManagedHooks(
readJsonNoFollow(inspectedPath),
managedHooks
);
return {
status: inspection.status,
operation,
destinationPath,
managedHookInspection: inspection
};
} catch (error) {
return {
status: 'invalid-settings',
operation,
destinationPath,
error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}`
};
}
}
return {
status: 'unverified',
operation,
@@ -1160,11 +1340,17 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n
};
}
function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, target = null) {
const linkIndex = buildLinkIndexForOperations(operations, trustedRoot);
return operations.reduce(
(summary, operation) => {
const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex);
const inspection = inspectManagedOperation(
repoRoot,
trustedRoot,
operation,
linkIndex,
target
);
if (inspection.status === 'missing') {
summary.missing.push(inspection);
} else if (inspection.status === 'drifted') {
@@ -1175,6 +1361,8 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
summary.unsafeSource.push(inspection);
} else if (inspection.status === 'unsafe-destination') {
summary.unsafeDestination.push(inspection);
} else if (inspection.status === 'invalid-settings') {
summary.invalidSettings.push(inspection);
} else if (inspection.status === 'unverified' || inspection.status === 'invalid-destination') {
summary.unverified.push(inspection);
}
@@ -1186,6 +1374,7 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
missingSource: [],
unsafeSource: [],
unsafeDestination: [],
invalidSettings: [],
unverified: []
}
);
@@ -1206,7 +1395,9 @@ function getUnsafeOperationResult(record, operationHealth) {
? getUnsafeManagedDestinationError(operationHealth)
: operationHealth.unsafeSource.length > 0
? createUnsafeRepairSourceError().message
: null;
: operationHealth.invalidSettings.length > 0
? operationHealth.invalidSettings[0].error
: null;
if (!error) {
return null;
}
@@ -1447,6 +1638,14 @@ function analyzeRecord(record, context) {
};
}
if (record.adapter.target === 'opencode') {
try {
preflightOpenCodeHookDeactivation(record, context, { requireInactive: true });
} catch (error) {
issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message));
}
}
if (!fs.existsSync(state.target.root)) {
issues.push(buildIssue('error', 'missing-target-root', `Target root does not exist: ${state.target.root}`));
}
@@ -1473,7 +1672,8 @@ function analyzeRecord(record, context) {
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
managedOperations
managedOperations,
record.adapter.target
);
const missingManagedOperations = operationHealth.missing;
@@ -1497,6 +1697,17 @@ function analyzeRecord(record, context) {
);
}
if (operationHealth.invalidSettings.length > 0) {
issues.push(
buildIssue(
'error',
'invalid-claude-settings',
operationHealth.invalidSettings[0].error,
{ paths: operationHealth.invalidSettings.map(entry => entry.destinationPath) }
)
);
}
if (missingManagedOperations.length > 0) {
issues.push(
buildIssue('error', 'missing-managed-files', `${missingManagedOperations.length} managed file(s) are missing`, {
@@ -1620,10 +1831,14 @@ function createRepairPlanFromRecord(record, context, options = {}) {
record.legacyLayout !== 'opencode'
&& (state.request.legacyMode || shouldRepairFromRecordedOperations(state))
) {
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
const operations = hydrateRecordedOperations(
context.repoRoot,
getManagedOperations(state),
record.targetRoot
);
const statePreview = buildRecordedStatePreview(state, context, operations);
return {
const recordedPlan = {
mode: state.request.legacyMode ? 'legacy' : 'recorded',
target: record.adapter.target,
adapter: record.adapter,
@@ -1632,9 +1847,13 @@ function createRepairPlanFromRecord(record, context, options = {}) {
installStatePath: state.target.installStatePath,
warnings: [],
languages: Array.isArray(state.request.legacyLanguages) ? [...state.request.legacyLanguages] : [],
selectedModuleIds: [...(state.resolution.selectedModules || [])],
operations,
statePreview
};
return record.adapter.target === 'opencode'
? withHookConsent(recordedPlan, getRecordedHookConsent(state))
: recordedPlan;
}
const desiredPlan = resolveRecordedManifestPlan(record, context, options);
@@ -1712,7 +1931,10 @@ function prepareRepairMigration(plan, record) {
installStatePath: record.installStatePath,
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
};
const migration = prepareClaudeSkillMigration(trustedPlan);
const initialMigration = prepareClaudeSkillMigration(trustedPlan);
const migration = trustedPlan.target === 'opencode'
? require('./install/apply').prepareHookConsentMigration(trustedPlan, initialMigration)
: initialMigration;
return {
migration,
plan: {
@@ -1727,6 +1949,48 @@ function prepareRepairMigration(plan, record) {
};
}
function assertOpenCodeRepairHookDeactivation(plan, options = {}) {
if (plan.target !== 'opencode') {
return new Map();
}
// The installer imports lifecycle helpers. Resolve this shared read-only
// guard lazily so both paths enforce the same discovery/ownership boundary.
const { assertOpenCodeHookDeactivationReady } = require('./install/apply');
return assertOpenCodeHookDeactivationReady(plan, options);
}
function preflightOpenCodeHookDeactivation(record, context, options = {}) {
if (record.adapter.target !== 'opencode') {
return;
}
const rawPlan = createRepairPlanFromRecord(record, context, {
// Planning must reject unsafe existing activations before a repair build
// writes compiled files. Missing payload is still validated/built later.
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
});
if (record.legacyLayout === 'opencode') {
const state = record.state;
const legacyPlan = withHookConsent({
target: 'opencode',
adapter: record.adapter,
targetRoot: record.targetRoot,
installRoot: record.targetRoot,
installStatePath: record.installStatePath,
selectedModuleIds: [...(state.resolution.selectedModules || [])],
operations: getManagedOperations(state),
statePreview: buildAdapterDerivedStatePreview(state, record),
}, getRecordedHookConsent(state));
// Migration does not replay operations into the old root. Inspect existing
// activations there, without treating historical merge-json records as new
// writes; the canonical destination is validated separately below.
assertOpenCodeRepairHookDeactivation({ ...legacyPlan, operations: [] }, { requireInactive: true });
assertOpenCodeRepairHookDeactivation(rawPlan, options);
return;
}
const { plan } = prepareRepairMigration(rawPlan, record);
assertOpenCodeRepairHookDeactivation(plan, options);
}
function repairInstalledStates(options = {}) {
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
const manifests = loadInstallManifests({ repoRoot });
@@ -1763,7 +2027,18 @@ function repairInstalledStates(options = {}) {
};
}
let releaseSettingsLock = null;
try {
preflightOpenCodeHookDeactivation(record, context);
const settingsPathToLock = !options.dryRun
&& getManagedOperations(record.state || {}).some(
operation => operation.kind === 'update-claude-settings'
)
? getClaudeSettingsPath(record.targetRoot)
: null;
if (settingsPathToLock) {
releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
}
const needsOpencodeBuild = record.adapter.target === 'opencode'
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
@@ -1789,6 +2064,7 @@ function repairInstalledStates(options = {}) {
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
: [],
});
assertOpenCodeRepairHookDeactivation(canonicalPlan);
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
@@ -1835,7 +2111,8 @@ function repairInstalledStates(options = {}) {
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
desiredPlan.operations
desiredPlan.operations,
record.adapter.target
);
const unsafeOperationResult = getUnsafeOperationResult(
record,
@@ -1879,10 +2156,12 @@ function repairInstalledStates(options = {}) {
migration,
plan: desiredPlan,
} = prepareRepairMigration(rawPlan, record);
const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan);
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
desiredPlan.operations
desiredPlan.operations,
record.adapter.target
);
const unsafeOperationResult = getUnsafeOperationResult(
@@ -1905,7 +2184,20 @@ function repairInstalledStates(options = {}) {
};
}
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
const repairOperations = [
...operationHealth.missing.map(entry => ({ ...entry.operation })),
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
...desiredPlan.operations
.filter(operation => (
operation.kind === 'update-claude-settings'
&& operation.previousManagedHooks
&& !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
))
.map(operation => ({ ...operation })),
].filter((operation, index, items) => items.findIndex(candidate => (
candidate.kind === operation.kind
&& candidate.destinationPath === operation.destinationPath
)) === index);
const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
operation => operation.destinationPath
@@ -1931,16 +2223,30 @@ function repairInstalledStates(options = {}) {
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
if (desiredPlan.target === 'opencode') {
const { assertOpenCodeActivationUnchanged } = require('./install/apply');
// Health inspection must not let a changed activation become owned by
// a bridge checkpoint before the per-write check can reject it.
for (const destinationPath of activationSnapshot.keys()) {
assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot);
}
}
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
writeRefreshedInstallState(record, migration.bridgeState);
}
for (const operation of repairOperations) {
if (desiredPlan.target === 'opencode') {
const { assertOpenCodeActivationUnchanged } = require('./install/apply');
assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot);
}
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
record.targetRoot,
repairLinkIndex
repairLinkIndex,
record.adapter.target,
Boolean(releaseSettingsLock)
);
if (repairedPath) {
repairedPaths.push(repairedPath);
@@ -1969,6 +2275,7 @@ function repairInstalledStates(options = {}) {
installedAt: record.state.installedAt,
source: { ...record.state.source },
};
assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true });
writeRefreshedInstallState(record, statePreviewToWrite);
return {
@@ -1992,6 +2299,8 @@ function repairInstalledStates(options = {}) {
plannedRepairs: [],
error: error.message
};
} finally {
if (releaseSettingsLock) releaseSettingsLock();
}
});
@@ -2106,15 +2415,23 @@ function uninstallInstalledStates(options = {}) {
};
}
let releaseSettingsLock = null;
try {
const removedPaths = [];
const cleanupTargets = [];
const retainedPaths = [];
const operations = getManagedOperations(state);
if (operations.some(operation => operation.kind === 'update-claude-settings')) {
releaseSettingsLock = acquireSettingsLock(
getClaudeSettingsPath(record.targetRoot)
);
}
for (const operation of operations) {
const outcome = executeUninstallOperation(operation, record.targetRoot, {
preserveDriftedCopies: true,
target: record.adapter.target,
settingsLockHeld: Boolean(releaseSettingsLock),
});
removedPaths.push(...outcome.removedPaths);
cleanupTargets.push(...outcome.cleanupTargets);
@@ -2159,6 +2476,8 @@ function uninstallInstalledStates(options = {}) {
plannedRemovals,
error: error.message
};
} finally {
if (releaseSettingsLock) releaseSettingsLock();
}
});
+37
View File
@@ -1,5 +1,10 @@
const fs = require('fs');
const path = require('path');
const {
CLAUDE_HOOKS_CONFIG_PATH,
getClaudeSettingsPath,
validateRecordedManagedHooks,
} = require('./install/claude-settings');
// Dependency-free, self-contained validation. The installer closure must not
// require any non-builtin package (enterprise supply-chain vetting: the vetted
@@ -209,6 +214,38 @@ function createFallbackValidator() {
) {
pushError(`${instancePath}/contentSha256`, 'must be a SHA-256 hex digest');
}
if (operation.kind === 'update-claude-settings') {
if (!['claude', 'claude-project'].includes(state.target && state.target.target)) {
pushError(`${instancePath}/kind`, 'is only valid for Claude targets');
}
if (operation.moduleId !== 'hooks-runtime') {
pushError(`${instancePath}/moduleId`, 'must equal hooks-runtime');
}
if (String(operation.sourceRelativePath).replace(/\\/g, '/') !== CLAUDE_HOOKS_CONFIG_PATH) {
pushError(`${instancePath}/sourceRelativePath`, 'must equal hooks/hooks.json');
}
if (
isNonEmptyString(state.target && state.target.root)
&& isNonEmptyString(operation.destinationPath)
) {
const expectedDestination = path.resolve(getClaudeSettingsPath(state.target.root));
const actualDestination = path.resolve(operation.destinationPath);
const pathsMatch = process.platform === 'win32'
? expectedDestination.toLowerCase() === actualDestination.toLowerCase()
: expectedDestination === actualDestination;
if (!pathsMatch) {
pushError(
`${instancePath}/destinationPath`,
'must equal the canonical Claude settings path'
);
}
}
try {
validateRecordedManagedHooks(operation.managedHooks);
} catch (error) {
pushError(`${instancePath}/managedHooks`, error.message);
}
}
}
}
+12 -4
View File
@@ -5,6 +5,7 @@ const {
createRemappedOperation,
isForeignPlatformPath,
normalizeRelativePath,
planClaudeHooksOperations,
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
@@ -66,7 +67,14 @@ module.exports = createInstallTargetAdapter({
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, adapter.target))
.map(sourceRelativePath => {
.flatMap(sourceRelativePath => {
if (
module.id === 'hooks-runtime'
&& normalizeRelativePath(sourceRelativePath) === 'hooks'
) {
return planClaudeHooksOperations(adapter, module, planningInput);
}
const managedDestinationPath = getClaudeManagedDestinationPath(
adapter,
sourceRelativePath,
@@ -74,16 +82,16 @@ module.exports = createInstallTargetAdapter({
);
if (managedDestinationPath) {
return createRemappedOperation(
return [createRemappedOperation(
adapter,
module.id,
sourceRelativePath,
managedDestinationPath,
{ strategy: 'preserve-relative-path' }
);
)];
}
return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput);
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
+12 -4
View File
@@ -5,6 +5,7 @@ const {
createRemappedOperation,
isForeignPlatformPath,
normalizeRelativePath,
planClaudeHooksOperations,
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
@@ -66,7 +67,14 @@ module.exports = createInstallTargetAdapter({
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, 'claude'))
.map(sourceRelativePath => {
.flatMap(sourceRelativePath => {
if (
module.id === 'hooks-runtime'
&& normalizeRelativePath(sourceRelativePath) === 'hooks'
) {
return planClaudeHooksOperations(adapter, module, planningInput);
}
const managedDestinationPath = getClaudeManagedDestinationPath(
adapter,
sourceRelativePath,
@@ -74,16 +82,16 @@ module.exports = createInstallTargetAdapter({
);
if (managedDestinationPath) {
return createRemappedOperation(
return [createRemappedOperation(
adapter,
module.id,
sourceRelativePath,
managedDestinationPath,
{ strategy: 'preserve-relative-path' }
);
)];
}
return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput);
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
+44
View File
@@ -1,6 +1,11 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
CLAUDE_HOOKS_CONFIG_PATH,
getClaudeSettingsPath,
} = require('../install/claude-settings');
const { METADATA_FILENAME } = require('../hooks-config');
const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.claude-plugin': 'claude',
@@ -146,6 +151,44 @@ function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinat
});
}
function planClaudeHooksOperations(adapter, module, input) {
const operations = [
createRemappedOperation(
adapter,
module.id,
CLAUDE_HOOKS_CONFIG_PATH,
getClaudeSettingsPath(adapter.resolveRoot(input)),
{
kind: 'update-claude-settings',
strategy: 'merge-hook-ids',
}
),
];
if (!input.repoRoot) {
return operations;
}
const sourceHooksRoot = path.join(input.repoRoot, 'hooks');
if (!fs.existsSync(sourceHooksRoot)) {
return operations;
}
return [
...operations,
...fs.readdirSync(sourceHooksRoot, { withFileTypes: true })
// hooks.json is merged into settings.json above, and its metadata sidecar
// is consumed with it, so neither is scaffolded into the target hooks dir.
.filter(entry => entry.name !== 'hooks.json' && entry.name !== METADATA_FILENAME)
.sort((left, right) => left.name.localeCompare(right.name))
.map(entry => adapter.createScaffoldOperation(
module.id,
path.join('hooks', entry.name),
input
)),
];
}
function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);
@@ -373,4 +416,5 @@ module.exports = {
createRemappedOperation,
isForeignPlatformPath,
normalizeRelativePath,
planClaudeHooksOperations,
};
+432 -147
View File
@@ -8,12 +8,27 @@ const {
hasExplicitCommitAttributionPreference,
withCommitAttributionDisabled,
} = require('../claude-commit-attribution');
const { writeInstallState } = require('../install-state');
const { readInstallState, writeInstallState } = require('../install-state');
const {
assertHookConsentReady,
disableOpenCodeHookPluginRegistration,
getDisabledOpenCodePluginContent,
getRecordedHookConsent,
isOpenCodeHookActivationOperation,
isOpenCodePluginEntrypoint,
planMaterializesHookRuntime,
shouldDisableOpenCodeHooks,
} = require('./hook-consent');
const {
getClaudeSettingsPath,
mergeManagedHooks,
readSettings,
runWithSettingsLock,
uninstallManagedHooks,
updateSettingsAtomic,
validateManagedHooks,
validateRecordedManagedHooks,
} = require('./claude-settings');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
@@ -22,6 +37,11 @@ const {
removeLegacyClaudeSkillFiles,
} = require('./claude-skill-migration');
const { cleanupLegacyAntigravityInstall } = require('./antigravity-legacy-migration');
const {
assertNoNewUserOwnedFile,
prepareUserOwnedFileGuard,
preserveUnwrittenFiles,
} = require('./ownership-guard');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
@@ -40,6 +60,9 @@ function transformInstallContent(operation, content) {
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
return getDisabledOpenCodePluginContent();
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
@@ -207,22 +230,12 @@ function shouldSetClaudeCommitAttributionPreference(plan) {
});
}
function writeClaudeCommitAttributionPreference(settingsPath) {
// Read once rather than probing with existsSync first. Checking for the file and
// then writing it is a file system race (CodeQL js/file-system-race), and a
// missing file is simply the fresh-install case.
function writeClaudeCommitAttributionPreference(settingsPath, options = {}) {
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch (error) {
if (error.code !== 'ENOENT') {
// Unreadable or malformed settings belong to the user; leave them untouched.
return false;
}
settings = {};
}
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
settings = readSettings(settingsPath);
} catch (_error) {
// Unreadable or malformed settings belong to the user; leave them untouched.
return false;
}
@@ -230,46 +243,15 @@ function writeClaudeCommitAttributionPreference(settingsPath) {
return false;
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
formatJson(withCommitAttributionDisabled(settings)),
'utf8'
);
return true;
}
function replacePluginRootPlaceholders(value, pluginRoot) {
if (!pluginRoot) {
return value;
}
if (typeof value === 'string') {
return value.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRoot);
}
if (Array.isArray(value)) {
return value.map(item => replacePluginRootPlaceholders(item, pluginRoot));
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
replacePluginRootPlaceholders(nestedValue, pluginRoot),
])
);
}
return value;
}
function findHooksOperation(plan, hooksDestinationPath) {
return plan.operations.find(item => (
item.destinationPath === hooksDestinationPath
&& item.moduleId === 'hooks-runtime'
&& typeof item.sourcePath === 'string'
));
let changed = false;
updateSettingsAtomic(settingsPath, latestSettings => {
if (hasExplicitCommitAttributionPreference(latestSettings)) {
return { settings: latestSettings };
}
changed = true;
return { settings: withCommitAttributionDisabled(latestSettings) };
}, options);
return changed;
}
function isMcpConfigPath(filePath) {
@@ -309,40 +291,268 @@ function assertSafeInstallOperation(plan, operation) {
}
}
function buildResolvedClaudeHooks(plan) {
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
function readPreviousInstallState(plan) {
if (!fs.existsSync(plan.installStatePath)) {
return null;
}
return readInstallState(plan.installStatePath);
}
function comparablePath(filePath) {
const resolved = path.resolve(filePath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function openCodeActivationKind(plan, operation) {
const relative = operation.destinationPath
? path.relative(plan.targetRoot, operation.destinationPath).split(path.sep).join('/').toLowerCase()
: '';
if (/^plugins\/(?:[^/]+\.(?:[cm]?js|ts)|[^/]+\/(?:index\.(?:[cm]?js|ts)|package\.json))$/.test(relative)) {
return 'plugin';
}
if (relative === 'opencode.json') return 'config';
if (isOpenCodePluginEntrypoint(operation)) return 'plugin';
return isOpenCodeHookActivationOperation(operation) ? 'config' : null;
}
function openCodeActivationCandidates(plan, previousOperations) {
const candidates = new Map();
for (const operation of [...previousOperations, ...plan.operations]) {
if (openCodeActivationKind(plan, operation) && operation.destinationPath) {
candidates.set(comparablePath(operation.destinationPath), operation);
}
}
// Old installs can leave aliases that are absent from the new source tree.
// Inspect only ECC's known entrypoint names, never unrelated user plugins.
for (const name of ['ecc-hooks', 'index']) {
for (const extension of ['ts', 'js', 'mjs', 'cjs']) {
const destinationPath = path.join(plan.targetRoot, 'plugins', `${name}.${extension}`);
const key = comparablePath(destinationPath);
if (!candidates.has(key)) {
candidates.set(key, {
sourceRelativePath: `.opencode/plugins/${name}.${extension}`,
destinationPath,
});
}
}
}
return candidates;
}
function activationIsInactive(kind, operation, content) {
if (kind === 'plugin') {
return content.toString('utf8') === getDisabledOpenCodePluginContent();
}
const config = JSON.parse(content.toString('utf8'));
// Validate the shape through the same transformer used by installation.
disableOpenCodeHookPluginRegistration(content.toString('utf8'), operation.sourceRelativePath);
return !Array.isArray(config.plugin) || !config.plugin.includes('./plugins');
}
function assertOpenCodeHookDeactivationReady(plan, options = {}) {
if (!shouldDisableOpenCodeHooks(plan)) {
return new Map();
}
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
const previousState = readPreviousInstallState(plan);
if (previousState && (
previousState.target.id !== plan.adapter.id
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
)) {
throw new Error('Refusing OpenCode hook deactivation: install-state target mismatch.');
}
const previous = new Map(((previousState && previousState.operations) || [])
.filter(operation => operation.ownership === 'managed' && operation.destinationPath)
.map(operation => [comparablePath(operation.destinationPath), operation]));
const desired = new Map(plan.operations.filter(operation => openCodeActivationKind(plan, operation))
.map(operation => [comparablePath(operation.destinationPath), operation]));
const snapshot = new Map();
for (const [key, operation] of openCodeActivationCandidates(plan, [...previous.values()])) {
const kind = openCodeActivationKind(plan, operation);
const expectedTransform = kind === 'plugin'
? 'opencode-disable-plugin-entrypoint' : 'opencode-disable-ecc-hooks';
const replacement = desired.get(key);
// Validate planned activation even when its destination does not yet exist.
// Recorded operations may name an unrelated source or use render-template.
if (replacement && (replacement.kind !== 'copy-file'
|| replacement.contentTransform !== expectedTransform)) {
throw new Error(`Refusing OpenCode hook deactivation: unsupported activation operation at ${operation.destinationPath}`);
}
const content = readInstalledFileNoFollow(plan, operation);
if (content === null && fs.existsSync(operation.destinationPath)) {
throw new Error(`Refusing OpenCode hook deactivation: non-file activation at ${operation.destinationPath}`);
}
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
snapshot.set(key, digest);
if (content === null) continue;
const inactive = activationIsInactive(kind, operation, content);
if (options.requireInactive) {
if (!inactive) {
throw new Error(`OpenCode hook activation remains active at ${operation.destinationPath}`);
}
continue;
}
if (kind === 'plugin' && inactive) continue;
const recorded = previous.get(key);
if (!replacement || replacement.kind !== 'copy-file'
|| replacement.contentTransform !== expectedTransform
|| !recorded || recorded.contentSha256 !== digest) {
throw new Error(`Refusing OpenCode hook deactivation: user-owned, modified, unverifiable or stale activation at ${operation.destinationPath}`);
}
}
return snapshot;
}
function assertOpenCodeActivationUnchanged(plan, operation, snapshot) {
const key = comparablePath(operation.destinationPath);
if (!snapshot.has(key)) return;
const content = readInstalledFileNoFollow(plan, operation);
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
if (digest !== snapshot.get(key)) {
throw new Error(`Refusing OpenCode hook deactivation: activation changed after preflight at ${operation.destinationPath}`);
}
}
function findPreviousManagedHooks(previousState, plan, operation) {
if (
!previousState
|| previousState.target.id !== plan.adapter.id
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
) {
return null;
}
const pluginRoot = plan.targetRoot;
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
const hooksOperation = findHooksOperation(plan, hooksDestinationPath);
if (!hooksOperation) {
return null;
}
const hooksSourcePath = hooksOperation.sourcePath;
if (!fs.existsSync(hooksSourcePath)) {
const previousOperation = (previousState.operations || []).find(candidate => (
candidate.kind === operation.kind
&& comparablePath(candidate.destinationPath) === comparablePath(operation.destinationPath)
));
if (!previousOperation || !previousOperation.managedHooks) {
return null;
}
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
return validateRecordedManagedHooks(
previousOperation.managedHooks,
'previous managed hooks'
);
}
function preflightClaudeSettingsOperations(plan) {
const settingsOperations = plan.operations.filter(operation => (
operation.kind === 'update-claude-settings'
|| operation.kind === 'remove-claude-settings-hooks'
));
if (settingsOperations.length === 0) {
return new Map();
}
const previousState = readPreviousInstallState(plan);
return new Map(settingsOperations.map(operation => {
assertSafeInstallOperation(plan, operation);
const managedHooks = validateManagedHooks(operation.managedHooks);
const settings = readSettings(operation.destinationPath);
const previousManagedHooks = findPreviousManagedHooks(previousState, plan, operation);
if (operation.kind === 'remove-claude-settings-hooks') {
const removal = uninstallManagedHooks(settings, managedHooks);
if (removal.retained.length > 0) {
throw new Error(
`Refusing to disable modified Claude hooks in ${operation.destinationPath}; `
+ 'run the ECC uninstaller to review retained entries.'
);
}
} else {
mergeManagedHooks(settings, managedHooks, { previousManagedHooks });
}
return [operation, { managedHooks, previousManagedHooks }];
}));
}
function prepareHookConsentMigration(plan, migration) {
if (shouldDisableOpenCodeHooks(plan) && migration.requiresBridgeState) {
const previousState = readPreviousInstallState(plan);
if (previousState) {
const previousConsent = getRecordedHookConsent(previousState);
return {
...migration,
// A checkpoint is not a completed consent transition. On failure,
// retain the previous decision until every activation is inactive.
bridgeState: {
...migration.bridgeState,
request: { ...migration.bridgeState.request, hookConsent: previousConsent },
resolution: {
...migration.bridgeState.resolution,
selectedModules: previousConsent === 'enabled'
? [...new Set([...migration.bridgeState.resolution.selectedModules, 'hooks-runtime'])]
: migration.bridgeState.resolution.selectedModules,
},
},
};
}
}
if (plan.hookConsent !== 'declined') {
return migration;
}
const previousState = readPreviousInstallState(plan);
if (!previousState) {
return migration;
}
const removals = (previousState.operations || [])
.filter(operation => operation.kind === 'update-claude-settings')
.map(operation => ({
...operation,
kind: 'remove-claude-settings-hooks',
strategy: 'remove-hook-ids',
scaffoldOnly: false,
}));
if (removals.length === 0) {
return migration;
}
const removalDestinations = new Set(removals.map(operation => comparablePath(
operation.destinationPath
)));
return {
hooksOperation,
hooksDestinationPath,
resolvedHooksConfig: {
...hooksConfig,
hooks: resolvedHooks,
...migration,
// Disable hooks only after every ordinary install operation succeeds so a
// partial reinstall cannot silently revoke working hooks before failing.
appliedOperations: [...migration.appliedOperations, ...removals],
finalState: {
...migration.finalState,
operations: migration.finalState.operations.filter(operation => !(
operation.kind === 'update-claude-settings'
&& removalDestinations.has(comparablePath(operation.destinationPath))
)),
},
bridgeState: {
...migration.bridgeState,
request: {
...migration.bridgeState.request,
hookConsent: 'enabled',
},
resolution: {
...migration.bridgeState.resolution,
selectedModules: [...new Set([
...migration.bridgeState.resolution.selectedModules,
'hooks-runtime',
])],
},
},
requiresBridgeState: true,
};
}
function previewInstallPlan(plan) {
const migration = prepareClaudeSkillMigration(plan);
assertOpenCodeHookDeactivationReady(plan);
const migration = prepareHookConsentMigration(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
);
const appliedPlan = {
...plan,
operations: migration.appliedOperations,
};
preflightClaudeSettingsOperations(appliedPlan);
const hookConsentWarnings = planMaterializesHookRuntime(plan) && plan.hookConsent !== 'enabled'
? ['Applying this plan requires an explicit hook decision: --enable-hooks or --no-hooks.']
: [];
@@ -363,6 +573,23 @@ function previewInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
assertHookConsentReady(plan);
const isClaudeManualTarget = plan.adapter
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
const settingsPathToLock = isClaudeManualTarget
? getClaudeSettingsPath(plan.targetRoot)
: null;
if (settingsPathToLock) {
assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock });
}
return settingsPathToLock
? runWithSettingsLock(
settingsPathToLock,
() => applyInstallPlanLocked(plan, dependencies, true)
)
: applyInstallPlanLocked(plan, dependencies, false);
}
function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeInstallStateRead = dependencies.beforeInstallStateRead;
const beforeOperationWrite = dependencies.beforeOperationWrite;
@@ -370,30 +597,38 @@ function applyInstallPlan(plan, dependencies = {}) {
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
const migration = prepareClaudeSkillMigration(plan);
const activationSnapshot = assertOpenCodeHookDeactivationReady(plan);
const migration = prepareHookConsentMigration(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
);
const appliedPlan = {
...plan,
operations: migration.appliedOperations,
};
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(appliedPlan);
const preparedClaudeSettings = preflightClaudeSettingsOperations(appliedPlan);
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
const linkIndex = buildLinkIndexForPlan(appliedPlan);
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
if (migration.requiresBridgeState) {
// Own every operation that may be written during a flat-skill migration
// before the first copy. A later failure is retryable and uninstall can
// clean the entire partial install, including non-skill files. During
// legacy migration the bridge also retains the prior managed operations.
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
const hookRemovalCount = appliedPlan.operations.filter(operation => (
operation.kind === 'remove-claude-settings-hooks'
)).length;
let completedHookRemovalCount = 0;
const writtenDestinations = new Set();
if (migration.requiresBridgeState) {
// Own every operation that may be written during a flat-skill migration
// before the first copy. A later failure is retryable and uninstall can
// clean the entire partial install, including non-skill files. During
// legacy migration the bridge also retains the prior managed operations.
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
}
persistInstallState(plan.installStatePath, migration.bridgeState);
}
persistInstallState(plan.installStatePath, migration.bridgeState);
}
let finalState;
try {
for (const operation of appliedPlan.operations) {
let finalState;
try {
for (const operation of appliedPlan.operations) {
assertSafeInstallOperation(appliedPlan, operation);
assertSafeClaudeSkillOperation(appliedPlan, operation);
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
@@ -405,6 +640,45 @@ function applyInstallPlan(plan, dependencies = {}) {
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation });
}
assertNoNewUserOwnedFile(migration, operation);
assertOpenCodeActivationUnchanged(appliedPlan, operation, activationSnapshot);
if (
operation.kind === 'update-claude-settings'
|| operation.kind === 'remove-claude-settings-hooks'
) {
// Re-read at the write boundary so unrelated settings added after
// planning are preserved. A same-ID change still fails closed.
const prepared = preparedClaudeSettings.get(operation);
assertSafeInstallOperation(appliedPlan, operation);
updateSettingsAtomic(operation.destinationPath, latestSettings => {
const merged = operation.kind === 'remove-claude-settings-hooks'
? uninstallManagedHooks(latestSettings, prepared.managedHooks)
: mergeManagedHooks(latestSettings, prepared.managedHooks, {
previousManagedHooks: prepared.previousManagedHooks,
});
if (
operation.kind === 'remove-claude-settings-hooks'
&& merged.retained.length > 0
) {
throw new Error(
`Refusing to disable modified Claude hooks in ${operation.destinationPath}; `
+ 'run the ECC uninstaller to review retained entries.'
);
}
return merged;
}, {
lockHeld: settingsLockHeld,
beforeCommit() {
assertSafeInstallOperation(appliedPlan, operation);
},
});
writtenDestinations.add(operation.destinationPath);
if (operation.kind === 'remove-claude-settings-hooks') {
completedHookRemovalCount += 1;
}
continue;
}
if (operation.kind === 'merge-json') {
const payload = cloneJsonValue(operation.mergePayload);
@@ -424,6 +698,7 @@ function applyInstallPlan(plan, dependencies = {}) {
);
const mergedValue = deepMergeJson(currentValue, filteredPayload);
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8');
writtenDestinations.add(operation.destinationPath);
continue;
}
@@ -431,6 +706,7 @@ function applyInstallPlan(plan, dependencies = {}) {
const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config');
const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config;
fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8');
writtenDestinations.add(operation.destinationPath);
continue;
}
@@ -453,59 +729,65 @@ function applyInstallPlan(plan, dependencies = {}) {
})
: transformed;
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
writtenDestinations.add(operation.destinationPath);
continue;
}
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
}
if (resolvedClaudeHooksPlan) {
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation });
writtenDestinations.add(operation.destinationPath);
}
fs.writeFileSync(
resolvedClaudeHooksPlan.hooksDestinationPath,
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
'utf8'
);
}
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json'));
}
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
}
persistInstallState(plan.installStatePath, finalState);
} catch (error) {
if (migration.requiresBridgeState) {
try {
// The bridge was committed before any writes. Refresh it with hashes of
// files that now exist so uninstall can remove only bytes this attempt
// actually installed while preserving user changes.
persistInstallState(
plan.installStatePath,
stateWithContentDigests(migration.bridgeState, appliedPlan)
);
} catch (checkpointError) {
throw new Error(
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
{ cause: error }
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
writeClaudeCommitAttributionPreference(
getClaudeSettingsPath(plan.targetRoot),
{ lockHeld: settingsLockHeld }
);
}
assertOpenCodeHookDeactivationReady(appliedPlan, { requireInactive: true });
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
}
persistInstallState(plan.installStatePath, finalState);
} catch (error) {
if (migration.requiresBridgeState) {
try {
// The bridge was committed before any writes. Refresh it with hashes of
// files that now exist so uninstall can remove only bytes this attempt
// actually installed while preserving user changes.
persistInstallState(
plan.installStatePath,
stateWithContentDigests(
preserveUnwrittenFiles(
hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
? migration.finalState
: migration.bridgeState,
migration,
writtenDestinations
),
{
...appliedPlan,
operations: appliedPlan.operations.filter(operation => (
writtenDestinations.has(operation.destinationPath)
)),
}
)
);
} catch (checkpointError) {
throw new Error(
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
{ cause: error }
);
}
}
throw error;
}
throw error;
}
let antigravityMigrationWarnings = [];
let antigravityMigrationWarnings = [];
try {
const antigravityMigration = cleanupLegacyAntigravityInstall(appliedPlan);
if (antigravityMigration.detected && !antigravityMigration.complete) {
@@ -535,24 +817,27 @@ function applyInstallPlan(plan, dependencies = {}) {
];
}
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
],
applied: true,
};
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
],
applied: true,
};
}
module.exports = {
applyInstallPlan,
assertOpenCodeActivationUnchanged,
assertOpenCodeHookDeactivationReady,
assertSafeInstallOperation,
prepareHookConsentMigration,
previewInstallPlan,
};
+180
View File
@@ -0,0 +1,180 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
function sameFileIdentity(left, right) {
if (left.ino !== right.ino) {
return false;
}
// Node's path-based stats can omit the Windows volume serial (`dev = 0`)
// while fstat() on the same file handle reports it. Preserve strict device
// checks everywhere else, including when both Windows stats report a device.
if (process.platform === 'win32' && (!left.dev || !right.dev)) {
return true;
}
return left.dev === right.dev;
}
function createSettingsLock(lockPath) {
const tempPath = `${lockPath}.create-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
let descriptor;
let ownedStats;
try {
descriptor = fs.openSync(tempPath, 'wx', 0o600);
fs.writeFileSync(descriptor, `${JSON.stringify({
pid: process.pid,
startedAt: new Date().toISOString(),
token: crypto.randomBytes(16).toString('hex'),
})}\n`);
fs.fsyncSync(descriptor);
ownedStats = fs.fstatSync(descriptor, { bigint: true });
fs.closeSync(descriptor);
descriptor = undefined;
fs.linkSync(tempPath, lockPath);
} catch (error) {
if (descriptor !== undefined) fs.closeSync(descriptor);
fs.rmSync(tempPath, { force: true });
throw error;
}
fs.rmSync(tempPath, { force: true });
let released = false;
return () => {
if (released) return;
const quarantinePath = `${lockPath}.release-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
fs.renameSync(lockPath, quarantinePath);
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
if (!sameFileIdentity(quarantinedStats, ownedStats)) {
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
throw new Error(`Refusing to release a changed Claude settings lock: ${lockPath}`);
}
released = true;
fs.rmSync(quarantinePath, { force: true });
};
}
function inspectSettingsLock(lockPath) {
const descriptor = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
try {
const stats = fs.fstatSync(descriptor, { bigint: true });
const pathStats = fs.lstatSync(lockPath, { bigint: true });
if (
!stats.isFile()
|| pathStats.isSymbolicLink()
|| !pathStats.isFile()
|| !sameFileIdentity(stats, pathStats)
) {
return { metadata: null, stats };
}
let metadata = null;
try {
metadata = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
} catch (_error) {
// Invalid locks may be recovered only after the bounded lease below.
}
return { metadata, stats };
} finally {
fs.closeSync(descriptor);
}
}
function processIsAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== 'ESRCH';
}
}
function recoverSettingsLock(lockPath) {
const recoveryPath = `${lockPath}.recover`;
try {
fs.mkdirSync(recoveryPath, { mode: 0o700 });
} catch (error) {
if (error && error.code === 'EEXIST') return null;
throw error;
}
const quarantinePath = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
try {
let inspected;
try {
inspected = inspectSettingsLock(lockPath);
} catch (error) {
if (error && error.code === 'ENOENT') return createSettingsLock(lockPath);
throw error;
}
const validOwner = Number.isSafeInteger(inspected.metadata && inspected.metadata.pid)
&& inspected.metadata.pid > 0;
const stale = validOwner
? !processIsAlive(inspected.metadata.pid)
: Date.now() - Number(inspected.stats.mtimeMs) >= INVALID_LOCK_STALE_MS;
if (!stale) return null;
fs.renameSync(lockPath, quarantinePath);
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
if (!sameFileIdentity(quarantinedStats, inspected.stats)) {
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
return null;
}
fs.rmSync(quarantinePath, { force: true });
return createSettingsLock(lockPath);
} finally {
fs.rmSync(recoveryPath, { recursive: true, force: true });
fs.rmSync(quarantinePath, { force: true });
}
}
function acquireSettingsLock(settingsPath) {
const lockPath = `${settingsPath}.ecc.lock`;
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
try {
return createSettingsLock(lockPath);
} catch (error) {
if (!error || error.code !== 'EEXIST') {
throw error;
}
}
const recovered = recoverSettingsLock(lockPath);
if (recovered) return recovered;
throw new Error(
`Another ECC process is updating Claude settings: ${settingsPath}. `
+ `If no ECC process is active, inspect and remove ${lockPath}.`
);
}
function runWithSettingsLock(settingsPath, callback) {
const releaseLock = acquireSettingsLock(settingsPath);
let primaryError = null;
let result;
try {
result = callback();
} catch (error) {
primaryError = error;
}
let releaseError = null;
try {
releaseLock();
} catch (error) {
releaseError = error;
}
if (primaryError) {
if (releaseError) primaryError.releaseError = releaseError;
throw primaryError;
}
if (releaseError) throw releaseError;
return result;
}
module.exports = {
acquireSettingsLock,
runWithSettingsLock,
sameFileIdentity,
};
+702
View File
@@ -0,0 +1,702 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { isDeepStrictEqual } = require('util');
const { writeFileAtomic } = require('../atomic-write');
const {
acquireSettingsLock,
runWithSettingsLock,
sameFileIdentity,
} = require('./claude-settings-lock');
const CLAUDE_SETTINGS_FILENAME = 'settings.json';
const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
const PLUGIN_ROOT_PLACEHOLDER = '${CLAUDE_PLUGIN_ROOT}';
const PLUGIN_ROOT_ENV_PROLOGUE = 'var e=process.env.CLAUDE_PLUGIN_ROOT;';
const PLUGIN_ROOT_ENV_READ = /\bprocess\.env\.CLAUDE_PLUGIN_ROOT\b(?!\s*=)/;
const VALID_EVENTS = new Set([
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest',
'PostToolUse', 'PostToolUseFailure', 'Notification', 'SubagentStart',
'Stop', 'SubagentStop', 'PreCompact', 'InstructionsLoaded',
'TeammateIdle', 'TaskCompleted', 'ConfigChange', 'WorktreeCreate',
'WorktreeRemove', 'SessionEnd',
]);
const EVENTS_WITHOUT_MATCHER = new Set([
'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop',
]);
const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
function isJsonObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function cloneValue(value) {
if (Array.isArray(value)) {
return value.map(cloneValue);
}
if (isJsonObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, cloneValue(nestedValue)])
);
}
return value;
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim() !== '';
}
function getClaudeSettingsPath(targetRoot) {
return path.join(targetRoot, CLAUDE_SETTINGS_FILENAME);
}
function assertClaudeSettingsPath(destinationPath, trustedRoot) {
const resolvedDestination = path.resolve(destinationPath);
const resolvedExpected = path.resolve(getClaudeSettingsPath(trustedRoot));
const pathsMatch = process.platform === 'win32'
? resolvedDestination.toLowerCase() === resolvedExpected.toLowerCase()
: resolvedDestination === resolvedExpected;
if (!pathsMatch) {
throw new Error(
`Refusing to manage Claude hooks outside the canonical settings file: ${destinationPath}`
);
}
}
function validateHookHandler(hook, label) {
if (!isJsonObject(hook)) {
throw new Error(`Invalid managed hook handler at ${label}: expected a JSON object`);
}
if (!VALID_HOOK_TYPES.has(hook.type)) {
throw new Error(`Invalid managed hook handler at ${label}: unsupported type`);
}
if (hook.timeout !== undefined && (typeof hook.timeout !== 'number' || hook.timeout < 0)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid timeout`);
}
if (hook.type === 'command') {
const validCommand = isNonEmptyString(hook.command)
|| (Array.isArray(hook.command)
&& hook.command.length > 0
&& hook.command.every(isNonEmptyString));
if (!validCommand) {
throw new Error(`Invalid managed hook handler at ${label}: invalid command`);
}
if (hook.async !== undefined && typeof hook.async !== 'boolean') {
throw new Error(`Invalid managed hook handler at ${label}: invalid async flag`);
}
return;
}
if (hook.async !== undefined) {
throw new Error(`Invalid managed hook handler at ${label}: async requires command type`);
}
if (hook.type === 'http') {
if (!isNonEmptyString(hook.url)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid url`);
}
if (
hook.headers !== undefined
&& (!isJsonObject(hook.headers)
|| !Object.values(hook.headers).every(value => typeof value === 'string'))
) {
throw new Error(`Invalid managed hook handler at ${label}: invalid headers`);
}
if (
hook.allowedEnvVars !== undefined
&& (!Array.isArray(hook.allowedEnvVars)
|| !hook.allowedEnvVars.every(isNonEmptyString))
) {
throw new Error(`Invalid managed hook handler at ${label}: invalid allowedEnvVars`);
}
return;
}
if (!isNonEmptyString(hook.prompt)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid prompt`);
}
if (hook.model !== undefined && !isNonEmptyString(hook.model)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid model`);
}
}
function validateManagedHooks(managedHooks, label = 'managed hooks') {
if (!isJsonObject(managedHooks)) {
throw new Error(`Invalid ${label}: expected a JSON object`);
}
if (Object.keys(managedHooks).length === 0) {
throw new Error(`Invalid ${label}: expected at least one hook event`);
}
const seenIds = new Set();
for (const [event, entries] of Object.entries(managedHooks)) {
if (!VALID_EVENTS.has(event)) {
throw new Error(`Invalid ${label}: unsupported hook event "${event}"`);
}
if (!Array.isArray(entries)) {
throw new Error(`Invalid ${label}.${event}: expected an array`);
}
if (entries.length === 0) {
throw new Error(`Invalid ${label}.${event}: expected at least one hook entry`);
}
entries.forEach((entry, index) => {
if (!isJsonObject(entry)) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: expected a JSON object`
);
}
if (typeof entry.id !== 'string' || entry.id.trim() === '') {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: `
+ 'expected a non-empty unique id'
);
}
if (seenIds.has(entry.id)) {
throw new Error(`Invalid ${label}: expected globally unique id "${entry.id}"`);
}
seenIds.add(entry.id);
if (
!Object.prototype.hasOwnProperty.call(entry, 'matcher')
&& !EVENTS_WITHOUT_MATCHER.has(event)
) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: missing matcher`
);
}
if (
Object.prototype.hasOwnProperty.call(entry, 'matcher')
&& typeof entry.matcher !== 'string'
&& !isJsonObject(entry.matcher)
) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: invalid matcher`
);
}
if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: expected hooks`
);
}
entry.hooks.forEach((hook, hookIndex) => {
validateHookHandler(hook, `${label}.${event}[${index}].hooks[${hookIndex}]`);
});
});
}
return cloneValue(managedHooks);
}
function validateRecordedManagedHooks(managedHooks, label = 'recorded managed hooks') {
if (!isJsonObject(managedHooks) || Object.keys(managedHooks).length === 0) {
throw new Error(`Invalid ${label}: expected a non-empty JSON object`);
}
for (const [event, entries] of Object.entries(managedHooks)) {
if (!isNonEmptyString(event) || !Array.isArray(entries) || entries.length === 0) {
throw new Error(`Invalid ${label}.${event}: expected a non-empty hook array`);
}
const seenIds = new Set();
entries.forEach((entry, index) => {
if (!isJsonObject(entry) || !isNonEmptyString(entry.id) || !Array.isArray(entry.hooks)) {
throw new Error(`Invalid hook entry at ${label}.${event}[${index}]`);
}
if (seenIds.has(entry.id)) {
throw new Error(
`Invalid ${label}: expected unique id "${entry.id}" within event "${event}"`
);
}
seenIds.add(entry.id);
});
}
return cloneValue(managedHooks);
}
function validateSettings(settings, label = 'Claude settings') {
if (!isJsonObject(settings)) {
throw new Error(`Invalid ${label}: expected a JSON object`);
}
if (Object.prototype.hasOwnProperty.call(settings, 'hooks')) {
if (!isJsonObject(settings.hooks)) {
throw new Error(`Invalid ${label}: expected "hooks" to be a JSON object`);
}
for (const [event, entries] of Object.entries(settings.hooks)) {
if (!Array.isArray(entries)) {
throw new Error(`Invalid ${label}: expected hooks.${event} to be an array`);
}
}
}
return cloneValue(settings);
}
function replacePluginRootPlaceholders(value, pluginRoot) {
if (typeof pluginRoot !== 'string') {
throw new Error('Invalid Claude plugin root: expected a string');
}
if (typeof value === 'string') {
return value.split(PLUGIN_ROOT_PLACEHOLDER).join(pluginRoot);
}
if (Array.isArray(value)) {
return value.map(item => replacePluginRootPlaceholders(item, pluginRoot));
}
if (isJsonObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
replacePluginRootPlaceholders(nestedValue, pluginRoot),
])
);
}
return value;
}
function resolveManagedHookCommands(managedHooks, targetRoot) {
const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64');
const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`;
const resolveCommand = command => {
// Leave invalid entries intact so managed-hook validation reports them.
if (typeof command !== 'string') {
return command;
}
const resolved = command
.split(PLUGIN_ROOT_ENV_PROLOGUE)
.join(`var e=${rootExpression};`);
if (PLUGIN_ROOT_ENV_READ.test(resolved)) {
throw new Error(
'Unable to resolve CLAUDE_PLUGIN_ROOT in a managed hook command; '
+ 'the hooks.json command prologue no longer matches the expected form'
);
}
return resolved;
};
return Object.fromEntries(
Object.entries(managedHooks).map(([event, entries]) => [
event,
entries.map(entry => ({
...entry,
hooks: entry.hooks.map(hook => ({
...hook,
...(typeof hook.command === 'string' || Array.isArray(hook.command)
? {
command: Array.isArray(hook.command)
? hook.command.map(resolveCommand)
: resolveCommand(hook.command),
}
: {}),
})),
})),
])
);
}
function materializeManagedHooks(hooksConfig, targetRoot) {
if (!isJsonObject(hooksConfig) || !isJsonObject(hooksConfig.hooks)) {
throw new Error('Invalid hooks config: expected a JSON object with a hooks object');
}
if (!isNonEmptyString(targetRoot)) {
throw new Error('Invalid Claude target root: expected a non-empty string');
}
return validateManagedHooks(resolveManagedHookCommands(
replacePluginRootPlaceholders(hooksConfig.hooks, targetRoot),
targetRoot
));
}
function parseSettings(rawSettings, label = 'Claude settings') {
let settings;
try {
settings = JSON.parse(rawSettings);
} catch (error) {
throw new Error(`Failed to parse ${label}: ${error.message}`, { cause: error });
}
return validateSettings(settings, label);
}
function readSettings(settingsPath, fileSystem = fs) {
const reader = fileSystem && fileSystem.fs ? fileSystem.fs : fileSystem;
let rawSettings;
try {
rawSettings = reader.readFileSync(settingsPath, 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') {
return {};
}
throw error;
}
return parseSettings(rawSettings, `Claude settings at ${settingsPath}`);
}
function readSettingsSnapshot(settingsPath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
let descriptor;
try {
descriptor = fs.openSync(settingsPath, flags);
} catch (error) {
if (error && error.code === 'ENOENT') {
return { exists: false, raw: null, settings: {}, mode: 0o600 };
}
throw error;
}
try {
const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
let pathStat;
try {
pathStat = fs.lstatSync(settingsPath, { bigint: true });
} catch (error) {
if (error && error.code === 'ENOENT') {
error.code = 'ECC_SETTINGS_CHANGED';
}
throw error;
}
if (
!descriptorStat.isFile()
|| !pathStat.isFile()
|| pathStat.isSymbolicLink()
|| !sameFileIdentity(descriptorStat, pathStat)
) {
const error = new Error(`Refusing to read changed Claude settings at ${settingsPath}`);
error.code = 'ECC_SETTINGS_CHANGED';
throw error;
}
const raw = fs.readFileSync(descriptor, 'utf8');
return {
exists: true,
raw,
settings: parseSettings(raw, `Claude settings at ${settingsPath}`),
mode: Number(descriptorStat.mode & 0o777n),
dev: descriptorStat.dev,
ino: descriptorStat.ino,
};
} finally {
fs.closeSync(descriptor);
}
}
function assertSettingsSnapshotUnchanged(settingsPath, snapshot) {
let current;
try {
current = readSettingsSnapshot(settingsPath);
} catch (error) {
error.code = error.code || 'ECC_SETTINGS_CHANGED';
throw error;
}
const unchanged = current.exists === snapshot.exists
&& current.raw === snapshot.raw
&& (!current.exists || (current.dev === snapshot.dev && current.ino === snapshot.ino));
if (!unchanged) {
const error = new Error(`Claude settings changed during update: ${settingsPath}`);
error.code = 'ECC_SETTINGS_CHANGED';
throw error;
}
}
function updateSettingsAtomic(settingsPath, transform, options = {}) {
const update = () => {
const parentPath = path.dirname(path.resolve(settingsPath));
const parentStats = fs.lstatSync(parentPath, { bigint: true });
const validateParent = () => {
const current = fs.lstatSync(parentPath, { bigint: true });
if (
!current.isDirectory() || current.isSymbolicLink()
|| current.dev !== parentStats.dev || current.ino !== parentStats.ino
) {
const error = new Error(`Claude settings parent directory changed: ${parentPath}`);
error.code = 'ECC_SETTINGS_PARENT_CHANGED';
throw error;
}
};
const maxAttempts = options.maxAttempts || 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
validateParent();
const snapshot = readSettingsSnapshot(settingsPath);
const result = transform(snapshot.settings);
if (typeof options.beforeCommit === 'function') options.beforeCommit();
assertSettingsSnapshotUnchanged(settingsPath, snapshot);
writeFileAtomic(
settingsPath,
`${JSON.stringify(result.settings, null, 2)}\n`,
{
encoding: 'utf8',
mode: snapshot.mode,
validateParent,
beforeRename() {
assertSettingsSnapshotUnchanged(settingsPath, snapshot);
},
}
);
return result;
} catch (error) {
if (error.code !== 'ECC_SETTINGS_CHANGED' || attempt === maxAttempts) {
throw error;
}
}
}
throw new Error(`Unable to update Claude settings at ${settingsPath}`);
};
if (options.lockHeld) {
return update();
}
return runWithSettingsLock(settingsPath, update);
}
function reference(event, id) {
return { event, id };
}
function entriesMatchingId(entries, id) {
return entries
.map((entry, index) => ({ entry, index }))
.filter(candidate => isJsonObject(candidate.entry) && candidate.entry.id === id);
}
function assertUnambiguousMatch(entries, event, id) {
const matches = entriesMatchingId(entries, id);
if (matches.length > 1) {
throw new Error(
`Claude settings contains multiple hooks for event "${event}" and id "${id}"`
);
}
return matches[0] || null;
}
function managedEntryFor(managedHooks, event, id) {
const entries = managedHooks && managedHooks[event];
if (!Array.isArray(entries)) {
return null;
}
return entries.find(entry => entry.id === id) || null;
}
function mergeManagedHooks(settings, managedHooks, options = {}) {
const validatedSettings = validateSettings(settings);
const desiredHooks = validateManagedHooks(managedHooks);
const previousHooks = options.previousManagedHooks === undefined
|| options.previousManagedHooks === null
? null
: validateRecordedManagedHooks(options.previousManagedHooks, 'previous managed hooks');
const repair = options.mode === 'repair' || options.repair === true;
if (options.mode !== undefined && options.mode !== 'merge' && options.mode !== 'repair') {
throw new Error(`Unknown Claude settings merge mode: ${options.mode}`);
}
let nextHooks = validatedSettings.hooks
? cloneValue(validatedSettings.hooks)
: {};
const added = [];
const updated = [];
const unchanged = [];
const removed = [];
if (previousHooks) {
for (const [event, previousEntries] of Object.entries(previousHooks)) {
let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : [];
for (const previousEntry of previousEntries) {
if (managedEntryFor(desiredHooks, event, previousEntry.id)) continue;
const match = assertUnambiguousMatch(eventEntries, event, previousEntry.id);
if (!match) continue;
if (!isDeepStrictEqual(match.entry, previousEntry)) {
throw new Error(
`Refusing to remove Claude hook for event "${event}" and id `
+ `"${previousEntry.id}" because the previous managed entry has drifted`
);
}
eventEntries = eventEntries.filter((_entry, index) => index !== match.index);
removed.push(reference(event, previousEntry.id));
}
nextHooks = eventEntries.length > 0
? { ...nextHooks, [event]: eventEntries }
: withoutProperty(nextHooks, event);
}
}
for (const [event, desiredEntries] of Object.entries(desiredHooks)) {
let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : [];
for (const desiredEntry of desiredEntries) {
const match = assertUnambiguousMatch(eventEntries, event, desiredEntry.id);
if (!match) {
eventEntries = [...eventEntries, cloneValue(desiredEntry)];
added.push(reference(event, desiredEntry.id));
continue;
}
if (isDeepStrictEqual(match.entry, desiredEntry)) {
unchanged.push(reference(event, desiredEntry.id));
continue;
}
const previousEntry = managedEntryFor(previousHooks, event, desiredEntry.id);
if (!repair && (!previousEntry || !isDeepStrictEqual(match.entry, previousEntry))) {
const driftReason = previousEntry ? ' because the previous managed entry has drifted' : '';
throw new Error(
`Refusing to overwrite Claude hook for event "${event}" and id `
+ `"${desiredEntry.id}"${driftReason}`
);
}
eventEntries = eventEntries.map((entry, index) => (
index === match.index ? cloneValue(desiredEntry) : entry
));
updated.push(reference(event, desiredEntry.id));
}
if (desiredEntries.length > 0) {
nextHooks = { ...nextHooks, [event]: eventEntries };
}
}
const nextSettings = Object.keys(nextHooks).length > 0
? { ...validatedSettings, hooks: nextHooks }
: validatedSettings;
return {
settings: nextSettings,
managedHooks: cloneValue(desiredHooks),
added,
updated,
unchanged,
removed,
};
}
function repairManagedHooks(settings, managedHooks, options = {}) {
return mergeManagedHooks(settings, managedHooks, {
...options,
mode: 'repair',
});
}
function inspectManagedHooks(settings, managedHooks) {
const validatedSettings = validateSettings(settings);
const expectedHooks = validateManagedHooks(managedHooks);
const settingsHooks = validatedSettings.hooks || {};
const managedSubset = {};
const matched = [];
const missing = [];
const drifted = [];
for (const [event, expectedEntries] of Object.entries(expectedHooks)) {
const actualEntries = settingsHooks[event] || [];
const foundEntries = [];
for (const expectedEntry of expectedEntries) {
const match = assertUnambiguousMatch(actualEntries, event, expectedEntry.id);
if (!match) {
missing.push(reference(event, expectedEntry.id));
continue;
}
foundEntries.push(cloneValue(match.entry));
if (isDeepStrictEqual(match.entry, expectedEntry)) {
matched.push(reference(event, expectedEntry.id));
} else {
drifted.push({
...reference(event, expectedEntry.id),
expected: cloneValue(expectedEntry),
actual: cloneValue(match.entry),
});
}
}
if (foundEntries.length > 0) {
managedSubset[event] = foundEntries;
}
}
const ok = missing.length === 0 && drifted.length === 0;
return {
status: ok ? 'ok' : (missing.length > 0 ? 'missing' : 'drifted'),
ok,
managedHooks: managedSubset,
matched,
missing,
drifted,
};
}
function withoutProperty(object, omittedKey) {
return Object.fromEntries(
Object.entries(object).filter(([key]) => key !== omittedKey)
);
}
function uninstallManagedHooks(settings, recordedManagedHooks) {
const validatedSettings = validateSettings(settings);
const recordedHooks = validateRecordedManagedHooks(recordedManagedHooks);
const currentHooks = validatedSettings.hooks || {};
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
const eventEntries = currentHooks[event] || [];
for (const recordedEntry of recordedEntries) {
assertUnambiguousMatch(eventEntries, event, recordedEntry.id);
}
}
const removed = [];
const retained = [];
const missing = [];
let nextHooks = cloneValue(currentHooks);
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
let eventEntries = nextHooks[event] || [];
for (const recordedEntry of recordedEntries) {
const match = assertUnambiguousMatch(eventEntries, event, recordedEntry.id);
if (!match) {
missing.push(reference(event, recordedEntry.id));
continue;
}
if (!isDeepStrictEqual(match.entry, recordedEntry)) {
retained.push({
...reference(event, recordedEntry.id),
expected: cloneValue(recordedEntry),
actual: cloneValue(match.entry),
reason: 'modified',
});
continue;
}
eventEntries = eventEntries.filter((_entry, index) => index !== match.index);
removed.push(reference(event, recordedEntry.id));
}
nextHooks = eventEntries.length > 0
? { ...nextHooks, [event]: eventEntries }
: withoutProperty(nextHooks, event);
}
nextHooks = Object.fromEntries(
Object.entries(nextHooks).filter(([, entries]) => entries.length > 0)
);
const settingsWithoutHooks = withoutProperty(validatedSettings, 'hooks');
const nextSettings = Object.keys(nextHooks).length > 0
? { ...settingsWithoutHooks, hooks: nextHooks }
: settingsWithoutHooks;
return {
settings: nextSettings,
removed,
retained,
missing,
};
}
module.exports = {
CLAUDE_HOOKS_CONFIG_PATH,
CLAUDE_SETTINGS_FILENAME,
acquireSettingsLock,
assertClaudeSettingsPath,
getClaudeSettingsPath,
inspectManagedHooks,
materializeManagedHooks,
mergeManagedHooks,
parseSettings,
readSettings,
repairManagedHooks,
replacePluginRootPlaceholders,
runWithSettingsLock,
updateSettingsAtomic,
uninstallManagedHooks,
validateManagedHooks,
validateRecordedManagedHooks,
validateSettings,
};
+3 -1
View File
@@ -2,7 +2,6 @@
const fs = require('fs');
const path = require('path');
const Ajv = require('ajv');
const DEFAULT_INSTALL_CONFIG = 'ecc-install.json';
const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'ecc-install-config.schema.json');
@@ -22,6 +21,9 @@ function getValidator() {
return cachedValidator;
}
// ajv is required lazily so scripts that never load an install config (the
// common case, e.g. `--list-profiles`) don't need it on the require path.
const Ajv = require('ajv');
const schema = readJson(CONFIG_SCHEMA_PATH, 'ecc-install-config.schema.json');
const ajv = new Ajv({ allErrors: true });
cachedValidator = ajv.compile(schema);
+39 -5
View File
@@ -39,6 +39,7 @@ const HOOK_CAPABILITY_GROUPS = Object.freeze([
const HOOK_CONSENT_DECISIONS = Object.freeze(['enabled', 'declined']);
const HOOK_RUNTIME_MODULE_ID = 'hooks-runtime';
const OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM = 'opencode-disable-ecc-hooks';
const OPENCODE_DISABLE_PLUGIN_TRANSFORM = 'opencode-disable-plugin-entrypoint';
function normalizeOperationPath(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
@@ -68,19 +69,37 @@ function disableOpenCodeHookPluginRegistration(content, sourceRelativePath) {
}, null, 2)}\n`;
}
function isOpenCodePluginEntrypoint(operation = {}) {
return /^\.opencode\/(?:dist\/)?plugins\/[^/]+\.(?:[cm]?js|ts)$/.test(
normalizeOperationPath(operation.sourceRelativePath)
);
}
function getDisabledOpenCodePluginContent() {
// OpenCode discovers plugins independently of opencode.json registration.
// Do not import the original module: even module initialization has effects.
return 'export default async () => ({});\n';
}
function isOpenCodeHookActivationOperation(operation = {}) {
return normalizeOperationPath(operation.sourceRelativePath) === '.opencode/opencode.json';
return normalizeOperationPath(operation.sourceRelativePath) === '.opencode/opencode.json'
|| isOpenCodePluginEntrypoint(operation);
}
function isHookRuntimeOperation(operation = {}) {
if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) {
if (
operation.kind === 'update-claude-settings'
|| operation.moduleId === HOOK_RUNTIME_MODULE_ID
) {
return true;
}
if (isOpenCodeHookActivationOperation(operation)) {
return !(
operation.kind === 'copy-file'
&& operation.contentTransform === OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM
&& operation.contentTransform === (isOpenCodePluginEntrypoint(operation)
? OPENCODE_DISABLE_PLUGIN_TRANSFORM
: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM)
);
}
@@ -135,7 +154,9 @@ function withoutOpenCodeHookActivation(operation) {
}
return {
...operation,
contentTransform: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM,
contentTransform: isOpenCodePluginEntrypoint(operation)
? OPENCODE_DISABLE_PLUGIN_TRANSFORM
: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM,
};
}
@@ -170,6 +191,11 @@ function disableUnselectedOpenCodeHooks(plan) {
};
}
function shouldDisableOpenCodeHooks(plan = {}) {
return plan.target === 'opencode'
&& (plan.hookConsent === 'declined' || !planSelectsHookRuntime(plan));
}
function setStatePreviewHookConsent(statePreview, hookConsent) {
if (!statePreview || !statePreview.request) {
return statePreview;
@@ -200,7 +226,11 @@ function getRecordedHookConsent(state = {}) {
return 'enabled';
}
if (planMaterializesHookRuntime(state)) {
// Older OpenCode installs copied activation files without asking for consent.
// Their presence cannot establish that the user opted in to automatic hooks.
if ((Array.isArray(state.operations) ? state.operations : []).some(operation => (
!isOpenCodeHookActivationOperation(operation) && isHookRuntimeOperation(operation)
))) {
return 'enabled';
}
@@ -279,10 +309,14 @@ module.exports = {
assertHookConsentReady,
disableUnselectedOpenCodeHooks,
disableOpenCodeHookPluginRegistration,
getDisabledOpenCodePluginContent,
formatHookCapabilityDisclosure,
getRecordedHookConsent,
isHookRuntimeOperation,
isOpenCodeHookActivationOperation,
isOpenCodePluginEntrypoint,
planMaterializesHookRuntime,
resolveHookConsentFlags,
shouldDisableOpenCodeHooks,
withHookConsent,
};
+159
View File
@@ -0,0 +1,159 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
/**
* #2964: the shared copy path used to write every copy-file operation
* unconditionally and record the destination as `ownership: 'managed'` even
* when the file already existed and was authored by the user. The visible
* symptom is a lost edit; the dangerous one is the install-state record,
* which makes a later uninstall delete the user's file.
*
* This guard generalises the Claude flat-skill migration conflict pattern to
* every adapter copy operation: when a destination exists and is NOT recorded
* as an ECC-managed operation in the previous install-state, the operation is
* skipped with a warning instead of overwriting and claiming ownership.
*
* All managed targets share this ownership boundary (#2964).
*/
function prepareUserOwnedFileGuard(plan, migration) {
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
if (previousState && (
previousState.target.id !== plan.adapter.id
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
)) {
throw new Error(`Refusing install: install-state target does not match the current plan at ${plan.installStatePath}.`);
}
// Recorded files remain updateable by reinstall/repair. Preserve their prior
// digests if an attempt fails before writing them so uninstall detects drift.
const previousManagedOperations = new Map(
((previousState && previousState.operations) || [])
.filter(operation => (
operation
&& operation.ownership === 'managed'
&& operation.destinationPath
))
.map(operation => [comparablePath(operation.destinationPath), operation])
);
const managedDestinations = new Set(previousManagedOperations.keys());
const appliedOperations = [];
const skippedOperations = [];
const warnings = [];
for (const operation of (migration && migration.appliedOperations) || []) {
if (
operation
&& operation.kind === 'copy-file'
&& operation.destinationPath
&& pathExists(operation.destinationPath)
&& !managedDestinations.has(comparablePath(operation.destinationPath))
) {
skippedOperations.push(operation);
warnings.push(
`Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`
);
continue;
}
appliedOperations.push(operation);
}
if (skippedOperations.length === 0) {
return { ...migration, managedDestinations, previousManagedOperations };
}
const skippedDestinations = new Set(
skippedOperations.map(operation => comparablePath(operation.destinationPath))
);
const filterStateOperations = operations => (operations || [])
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)));
// Never leave a skipped destination inside the install-state: recording it
// would claim ownership of a file ECC did not create and make uninstall
// delete it (#2964).
const bridgeState = migration.bridgeState
? {
...migration.bridgeState,
operations: filterStateOperations(migration.bridgeState.operations),
}
: migration.bridgeState;
const finalState = migration.finalState
? {
...migration.finalState,
operations: filterStateOperations(migration.finalState.operations),
}
: migration.finalState;
return {
...migration,
managedDestinations,
previousManagedOperations,
appliedOperations,
skippedOperations: [
...((migration && migration.skippedOperations) || []),
...skippedOperations,
],
warnings: [...((migration && migration.warnings) || []), ...warnings],
bridgeState,
finalState,
// Only keep bridge persistence when operations actually remain; a fully
// skipped plan installs nothing and must not claim anything.
requiresBridgeState: Boolean(migration.requiresBridgeState)
&& appliedOperations.length > 0,
};
}
function assertNoNewUserOwnedFile(migration, operation) {
if (operation.kind !== 'copy-file'
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|| !pathExists(operation.destinationPath)) {
return;
}
throw new Error(`Refusing install: a user-owned file appeared at ${operation.destinationPath} after planning. Rerun the installer to preserve it.`);
}
function preserveUnwrittenFiles(state, migration, writtenDestinations) {
const writtenPaths = new Set([...writtenDestinations].map(comparablePath));
return {
...state,
operations: state.operations.filter(operation => (
operation.kind !== 'copy-file'
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|| writtenPaths.has(comparablePath(operation.destinationPath))
|| !pathExists(operation.destinationPath)
)).map(operation => {
const destination = comparablePath(operation.destinationPath);
return operation.kind === 'copy-file' && !writtenPaths.has(destination)
? migration.previousManagedOperations.get(destination) || operation
: operation;
}),
};
}
module.exports = {
assertNoNewUserOwnedFile,
prepareUserOwnedFileGuard,
preserveUnwrittenFiles,
};
+30
View File
@@ -8,6 +8,10 @@ const { resolveInstallPlan } = require('../install-manifests');
const { getInstallTargetAdapter } = require('../install-targets/registry');
const { resolveInvocationEnvironment } = require('../invocation-environment');
const { disableUnselectedOpenCodeHooks } = require('./hook-consent');
const { readHooksConfig } = require('../hooks-config');
const {
materializeManagedHooks,
} = require('./claude-settings');
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
const IGNORED_DIRECTORY_NAMES = new Set([
@@ -128,7 +132,33 @@ function readJsonObject(filePath, label) {
return parsed;
}
function materializeClaudeSettingsOperation(sourceRoot, operation) {
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
if (!fs.existsSync(sourcePath)) {
return [];
}
// Stable ids and descriptions live in hooks/hooks.metadata.json; readHooksConfig
// merges them back so managed settings entries keep their ids.
const hooksConfig = readHooksConfig(sourcePath, operation.sourceRelativePath);
const managedHooks = materializeManagedHooks(
hooksConfig,
path.dirname(operation.destinationPath)
);
return [{
...operation,
sourcePath,
scaffoldOnly: false,
managedHooks,
}];
}
function materializeScaffoldOperation(sourceRoot, operation) {
if (operation.kind === 'update-claude-settings') {
return materializeClaudeSettingsOperation(sourceRoot, operation);
}
if (operation.kind === 'merge-json') {
return [
{
+16 -1
View File
@@ -443,6 +443,12 @@ function publicMemoryFileError(error) {
};
}
function incompleteMemoryLookupError() {
const error = new Error('Memory lookup is incomplete. Inspect the authorized vault before retrying.');
error.code = 'ECC_MEMORY_INCOMPLETE';
return error;
}
function readMemoryFiles(options = {}) {
const roots = options.roots || resolveVaultRoots(options);
const scopes = normalizeScopes(options.scopes || DEFAULT_RECALL_SCOPES);
@@ -461,7 +467,13 @@ function readMemoryFiles(options = {}) {
break;
}
const root = assertMemoryRootSafe(roots, scope);
const walked = walkMemoryRoot(root, MAX_FILES - visitedCount);
let walked;
try {
walked = walkMemoryRoot(root, MAX_FILES - visitedCount);
} catch {
// Directory open/read/close failures cannot establish a complete lookup.
throw incompleteMemoryLookupError();
}
visitedCount += walked.visitedCount;
truncated = truncated || walked.truncated;
skippedSymlinkCount += walked.skippedSymlinkCount;
@@ -659,6 +671,9 @@ function readMemoryById(id, options = {}) {
? validateSlug(options.targetHarness, 'target harness')
: null;
const loaded = readMemoryFiles(options);
if (loaded.truncated || loaded.invalidFileCount > 0) {
throw incompleteMemoryLookupError();
}
const matches = loaded.entries
.filter(entry => entry.memory.id === memoryId)
.filter(entry => (
+65
View File
@@ -0,0 +1,65 @@
'use strict';
const fs = require('fs');
const path = require('path');
// Runtime dependencies that can be missing when ECC is installed via the
// Claude Code plugin marketplace (a plain git clone, so `npm install` never
// runs), even though the code that needs them is fine. Versions are read
// straight from package.json's "dependencies" field instead of a second
// hardcoded copy, so this can't silently drift out of sync with what's
// actually declared there.
const TRACKED_DEPENDENCIES = ['ajv', 'sql.js', 'js-yaml', '@iarna/toml'];
function loadRuntimeDependencyVersions() {
try {
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
const declared = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).dependencies || {};
const versions = {};
for (const name of TRACKED_DEPENDENCIES) {
const declaredVersion = declared[name];
if (declaredVersion) {
versions[name] = declaredVersion.replace(/^[\^~]/, '');
}
}
return versions;
} catch {
// package.json isn't reachable from here for some reason. Fall back to
// an empty map rather than crash — describeMissingDependencyError()
// just won't be able to suggest a pinned version in that case.
return {};
}
}
const RUNTIME_DEPENDENCY_VERSIONS = loadRuntimeDependencyVersions();
function describeMissingDependencyError(error) {
if (!error || error.code !== 'MODULE_NOT_FOUND') {
return null;
}
const match = /Cannot find module '([^']+)'/.exec(error.message || '');
const moduleName = match && match[1];
if (!moduleName || !TRACKED_DEPENDENCIES.includes(moduleName)) {
return null;
}
const pinnedVersion = RUNTIME_DEPENDENCY_VERSIONS[moduleName];
const installCommand = pinnedVersion
? `npm install --no-save ${moduleName}@${pinnedVersion}`
: `npm install --no-save ${moduleName}`;
return (
`Missing dependency '${moduleName}'. ECC's production dependencies aren't installed ` +
'(this happens when ECC was installed via the Claude Code plugin marketplace, which ' +
'clones the repo but never runs npm install). Run "npm install" from the ECC repo ' +
`root, or install just this package with "${installCommand}".`
);
}
module.exports = {
RUNTIME_DEPENDENCY_VERSIONS,
describeMissingDependencyError,
};
+36 -16
View File
@@ -373,7 +373,12 @@ async function applyPreflightedManagedPlan(entry) {
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let operationIndex = 0;
// Several ordered JSON merges may share one destination. Preserve each
// operation's preview instead of collapsing that sequence to one path entry.
const expectedOperations = new Map(preview.plan.operations.map((operation, index) => [
operation, preview.operations[index],
]));
const writtenDestinations = new Set();
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
@@ -381,26 +386,41 @@ async function applyPreflightedManagedPlan(entry) {
assertStateUnchanged();
expectedStateFingerprint = fingerprintInstallStateValue(state);
};
const assertOperationUnchanged = operation => {
const destination = canonicalPath(operation.destinationPath);
const expected = expectedOperations.get(operation);
const currentClassification = classifyManagedOperation(operation, ownedDestinations);
const expectedClassification = operation.kind === 'merge-json'
&& writtenDestinations.has(destination)
? 'managed-json-update'
: expected && expected.classification;
if (
!expected
|| expected.kind !== operation.kind
|| canonicalPath(expected.destinationPath) !== destination
|| expectedClassification !== currentClassification
) {
throw new Error(
`Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.`
);
}
return destination;
};
const result = require('./install-executor').applyInstallPlan(preview.plan, {
beforeInstallStateRead: assertStateUnchanged,
beforeInstallStateRead() {
assertStateUnchanged();
// Check the original preview before ownership filtering can skip a late
// collision. Guided setup must report the changed plan as a failure.
for (const operation of preview.plan.operations) {
assertOperationUnchanged(operation);
}
},
beforeOperationWrite({ operation }) {
assertStateUnchanged();
const expected = preview.operations[operationIndex];
const currentClassification = classifyManagedOperation(operation, ownedDestinations);
const destination = canonicalPath(operation.destinationPath);
if (
!expected
|| expected.kind !== operation.kind
|| canonicalPath(expected.destinationPath) !== destination
|| expected.classification !== currentClassification
) {
throw new Error(
`Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.`
);
}
const destination = assertOperationUnchanged(operation);
ownedDestinations.add(destination);
operationIndex += 1;
writtenDestinations.add(destination);
},
beforeInstallStateWrite: prepareInstallStateWrite,
});
File diff suppressed because it is too large Load Diff