replume-cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -19
- package/THIRD-PARTY-NOTICES.md +28 -0
- package/dist/cli.js +1226 -454
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Command, Option } from "commander";
|
|
|
6
6
|
// package.json
|
|
7
7
|
var package_default = {
|
|
8
8
|
name: "replume-cli",
|
|
9
|
-
version: "0.1.
|
|
9
|
+
version: "0.1.2",
|
|
10
10
|
type: "module",
|
|
11
11
|
bin: {
|
|
12
12
|
replume: "dist/cli.js"
|
|
@@ -48,9 +48,9 @@ var package_default = {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
// src/cli.ts
|
|
51
|
-
import { execFileSync as
|
|
52
|
-
import { existsSync as
|
|
53
|
-
import { join as
|
|
51
|
+
import { execFileSync as execFileSync11 } from "child_process";
|
|
52
|
+
import { existsSync as existsSync20, lstatSync as lstatSync3, readFileSync as readFileSync19 } from "fs";
|
|
53
|
+
import { join as join25, resolve as resolve8 } from "path";
|
|
54
54
|
|
|
55
55
|
// src/hooks/session-start.ts
|
|
56
56
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
@@ -342,20 +342,21 @@ function markPathsOutsideRepo(text, repoRoot2) {
|
|
|
342
342
|
const path = (value) => {
|
|
343
343
|
const rel = relative(repoRoot2, value);
|
|
344
344
|
if (!rel) return "[REPO_ROOT]";
|
|
345
|
+
if (/^\/dev\/[\w./-]+$/.test(value)) return value;
|
|
345
346
|
return rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel) ? rel.replace(/\\/g, "/") : "[OUTSIDE_REPO]";
|
|
346
347
|
};
|
|
347
|
-
return text.replace(/(^|[\s"'=(,:])(\/(?!\/)[^\s"'`,;)]+)/g, (_m, prefix, absolute) => `${prefix}${path(absolute)}`);
|
|
348
|
+
return text.replace(/(^|[\s"'=(,:])(\/(?!\/)[^\s"'`,;)(]+)(?![^\s"'`,;)])/g, (_m, prefix, absolute) => `${prefix}${path(absolute)}`);
|
|
348
349
|
}
|
|
349
350
|
function redactLedgerText(text, repoRoot2) {
|
|
350
351
|
const out = redact(text).replace(SECRET_ENV_ANYCASE, `$1${REDACTED}`).replace(URL_USER, `$1${REDACTED}:${REDACTED}@`);
|
|
351
352
|
return markPathsOutsideRepo(out, repoRoot2);
|
|
352
353
|
}
|
|
353
354
|
function redactForLedger(value, repoRoot2) {
|
|
354
|
-
const
|
|
355
|
+
const str4 = (s) => redactLedgerText(s, repoRoot2);
|
|
355
356
|
const deep = (v) => {
|
|
356
|
-
if (typeof v === "string") return
|
|
357
|
+
if (typeof v === "string") return str4(v);
|
|
357
358
|
if (Array.isArray(v)) return v.map(deep);
|
|
358
|
-
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, c]) => [
|
|
359
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, c]) => [str4(k), deep(c)]));
|
|
359
360
|
return v;
|
|
360
361
|
};
|
|
361
362
|
return deep(value);
|
|
@@ -601,7 +602,7 @@ async function runVerify(opts) {
|
|
|
601
602
|
// src/agents/claude/handler.ts
|
|
602
603
|
import { execFileSync as execFileSync5 } from "child_process";
|
|
603
604
|
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync6 } from "fs";
|
|
604
|
-
import { join as
|
|
605
|
+
import { join as join10 } from "path";
|
|
605
606
|
import { homedir as homedir2 } from "os";
|
|
606
607
|
|
|
607
608
|
// src/core/usage-store.ts
|
|
@@ -886,7 +887,9 @@ function validateReport(x) {
|
|
|
886
887
|
const valid = isObj(a) && isObj(t) && isObj(tokens) && [t.wall, t.active, t.humanWait, tokens.input, tokens.output, tokens.cacheRead, tokens.cacheWrite, a.apiEquivalentUsd].every(metric) && typeof tokens.partial === "boolean" && typeof a.accepted === "boolean" && typeof a.priceVersion === "string" && (a.apiEquivalentReason === void 0 || typeof a.apiEquivalentReason === "string" && a.apiEquivalentReason.length > 0 && a.apiEquivalentReason.length <= 200);
|
|
887
888
|
if (!valid) p.push("metrics.accounting has invalid evidence or value");
|
|
888
889
|
}
|
|
889
|
-
|
|
890
|
+
if (x.observedOnly !== void 0 && (!Array.isArray(x.observedOnly) || !x.observedOnly.every((e) => isObj(e) && typeof e.path === "string" && typeof e.reason === "string")))
|
|
891
|
+
p.push("observedOnly must be an array of {path, reason}");
|
|
892
|
+
const levelled = ["done", "failed", "extra", "tampering", "notChecked", "observedOnly", "checks"].flatMap((k) => Array.isArray(x[k]) ? x[k] : []);
|
|
890
893
|
if (levelled.some((e) => isObj(e) && e.evidence_level !== void 0 && !EVIDENCE_LEVELS.includes(e.evidence_level)))
|
|
891
894
|
p.push(`evidence_level must be one of ${EVIDENCE_LEVELS.join(", ")}`);
|
|
892
895
|
return p;
|
|
@@ -1039,7 +1042,7 @@ var Parser = class {
|
|
|
1039
1042
|
return ITEM.test(first.body) ? this.sequence(indent) : this.mapping(indent);
|
|
1040
1043
|
}
|
|
1041
1044
|
mapping(indent, seed) {
|
|
1042
|
-
const
|
|
1045
|
+
const obj3 = seed?.obj ?? {};
|
|
1043
1046
|
while (this.peek()) {
|
|
1044
1047
|
const line = this.peek();
|
|
1045
1048
|
if (line.indent < indent) break;
|
|
@@ -1048,20 +1051,20 @@ var Parser = class {
|
|
|
1048
1051
|
const m = KEY.exec(line.body);
|
|
1049
1052
|
if (!m) throw new Error(`line ${line.n}: expected "key: value"`);
|
|
1050
1053
|
this.i++;
|
|
1051
|
-
this.entry(
|
|
1054
|
+
this.entry(obj3, m[1], m[2], line, indent);
|
|
1052
1055
|
}
|
|
1053
|
-
return
|
|
1056
|
+
return obj3;
|
|
1054
1057
|
}
|
|
1055
|
-
entry(
|
|
1056
|
-
if (key in
|
|
1058
|
+
entry(obj3, key, rest, line, indent) {
|
|
1059
|
+
if (key in obj3) throw new Error(`line ${line.n}: duplicate key ${key}`);
|
|
1057
1060
|
if (rest !== void 0 && rest.trim() !== "") {
|
|
1058
|
-
|
|
1061
|
+
obj3[key] = parseInline(rest, `line ${line.n}`);
|
|
1059
1062
|
return;
|
|
1060
1063
|
}
|
|
1061
1064
|
const next = this.peek();
|
|
1062
|
-
if (next && next.indent > indent)
|
|
1063
|
-
else if (next && next.indent === indent && ITEM.test(next.body))
|
|
1064
|
-
else
|
|
1065
|
+
if (next && next.indent > indent) obj3[key] = this.block(next.indent);
|
|
1066
|
+
else if (next && next.indent === indent && ITEM.test(next.body)) obj3[key] = this.sequence(indent);
|
|
1067
|
+
else obj3[key] = null;
|
|
1065
1068
|
}
|
|
1066
1069
|
sequence(indent) {
|
|
1067
1070
|
const items = [];
|
|
@@ -1080,11 +1083,11 @@ var Parser = class {
|
|
|
1080
1083
|
continue;
|
|
1081
1084
|
}
|
|
1082
1085
|
const column = indent + (line.body.length - body.length);
|
|
1083
|
-
const
|
|
1084
|
-
this.entry(
|
|
1086
|
+
const obj3 = {};
|
|
1087
|
+
this.entry(obj3, km[1], km[2], line, column);
|
|
1085
1088
|
const next = this.peek();
|
|
1086
|
-
if (next && next.indent === column && !ITEM.test(next.body)) this.mapping(column, { obj:
|
|
1087
|
-
items.push(
|
|
1089
|
+
if (next && next.indent === column && !ITEM.test(next.body)) this.mapping(column, { obj: obj3 });
|
|
1090
|
+
items.push(obj3);
|
|
1088
1091
|
}
|
|
1089
1092
|
return items;
|
|
1090
1093
|
}
|
|
@@ -1111,9 +1114,9 @@ function emitScalar(v) {
|
|
|
1111
1114
|
if (typeof v === "string") return JSON.stringify(v);
|
|
1112
1115
|
return String(v);
|
|
1113
1116
|
}
|
|
1114
|
-
function stringifyYaml(
|
|
1117
|
+
function stringifyYaml(obj3, comments = {}) {
|
|
1115
1118
|
const lines = [];
|
|
1116
|
-
for (const [k, v] of Object.entries(
|
|
1119
|
+
for (const [k, v] of Object.entries(obj3)) {
|
|
1117
1120
|
if (v === void 0) continue;
|
|
1118
1121
|
const c = comments[k] ? ` # ${comments[k]}` : "";
|
|
1119
1122
|
if (Array.isArray(v)) {
|
|
@@ -1258,7 +1261,7 @@ function buildContract(input) {
|
|
|
1258
1261
|
|
|
1259
1262
|
// src/core/rules.ts
|
|
1260
1263
|
import { createHash } from "crypto";
|
|
1261
|
-
import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep } from "path";
|
|
1264
|
+
import { isAbsolute as isAbsolute3, join as join7, relative as relative3, resolve as resolve2, sep } from "path";
|
|
1262
1265
|
import { homedir, tmpdir } from "os";
|
|
1263
1266
|
|
|
1264
1267
|
// src/core/glob.ts
|
|
@@ -1294,16 +1297,160 @@ function matchesAny(path, patterns) {
|
|
|
1294
1297
|
return null;
|
|
1295
1298
|
}
|
|
1296
1299
|
|
|
1300
|
+
// src/core/capture.ts
|
|
1301
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
|
|
1302
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
1303
|
+
var EXPECTED_TYPES = ["prompt", "tool_pre", "command", "stop"];
|
|
1304
|
+
var SHOWN_PATHS = 5;
|
|
1305
|
+
var count2 = (m, k) => {
|
|
1306
|
+
m[k] = (m[k] ?? 0) + 1;
|
|
1307
|
+
};
|
|
1308
|
+
var HOOK_CRASH = "hook-errors.log exists: at least one hook crashed, so some steps may not have been recorded";
|
|
1309
|
+
function captureCoverage(eventsFile, changedPaths, hooksConfigured = false) {
|
|
1310
|
+
const absence = hooksConfigured ? "Replume project hooks are configured, but no hook events were recorded; agent steps and scope were not observed" : "no Replume project hooks were found; agent steps and scope were not observed";
|
|
1311
|
+
const empty = (ledger, notes) => ({
|
|
1312
|
+
ledger,
|
|
1313
|
+
hooksConfigured,
|
|
1314
|
+
events: 0,
|
|
1315
|
+
sessions: 0,
|
|
1316
|
+
agents: [],
|
|
1317
|
+
byType: {},
|
|
1318
|
+
tools: {},
|
|
1319
|
+
firstTs: null,
|
|
1320
|
+
lastTs: null,
|
|
1321
|
+
badLines: 0,
|
|
1322
|
+
hookErrorsLogged: hookErrors(eventsFile),
|
|
1323
|
+
changedFiles: changedPaths.length,
|
|
1324
|
+
changedFilesSeen: 0,
|
|
1325
|
+
changedFilesLanded: 0,
|
|
1326
|
+
changedFilesAttemptedOnly: [],
|
|
1327
|
+
changedFilesNotSeen: changedPaths.slice(0, SHOWN_PATHS),
|
|
1328
|
+
unobservableCommands: 0,
|
|
1329
|
+
// a hook that crashed on every call leaves no ledger: say so here too, not only in the JSON flag
|
|
1330
|
+
notSeen: hookErrors(eventsFile) ? [...notes, HOOK_CRASH] : notes
|
|
1331
|
+
});
|
|
1332
|
+
if (!existsSync6(eventsFile)) {
|
|
1333
|
+
return empty("absent", [`no ledger file: ${absence}`]);
|
|
1334
|
+
}
|
|
1335
|
+
const text = readFileSync7(eventsFile, "utf-8");
|
|
1336
|
+
const { events, badLines } = parseEvents(text);
|
|
1337
|
+
if (!events.length) {
|
|
1338
|
+
return empty("empty", [
|
|
1339
|
+
badLines.length ? `the ledger has ${badLines.length} line(s) but none is a valid event` : `the ledger is empty: ${absence}`
|
|
1340
|
+
]);
|
|
1341
|
+
}
|
|
1342
|
+
const byType = {};
|
|
1343
|
+
const tools = {};
|
|
1344
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
1345
|
+
const landedPaths = /* @__PURE__ */ new Set();
|
|
1346
|
+
const blockedPaths = /* @__PURE__ */ new Set();
|
|
1347
|
+
for (const e of events) {
|
|
1348
|
+
count2(byType, e.type);
|
|
1349
|
+
if (e.type === "tool_pre" && e.tool) count2(tools, e.tool);
|
|
1350
|
+
const blocked = e.action === "block" && e.type !== "file_edit" && e.type !== "command";
|
|
1351
|
+
const landed = e.type === "file_edit" || e.type === "command" && e.raw?.ok !== false;
|
|
1352
|
+
for (const p of e.paths) {
|
|
1353
|
+
if (blocked) blockedPaths.add(p);
|
|
1354
|
+
else seenPaths.add(p);
|
|
1355
|
+
const writes = e.type === "command" ? e.raw?.writes : void 0;
|
|
1356
|
+
if (landed && (!Array.isArray(writes) || writes.includes(p))) landedPaths.add(p);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const ts = events.map((e) => e.ts).sort();
|
|
1360
|
+
const notSeen = [];
|
|
1361
|
+
const missing = EXPECTED_TYPES.filter((t) => !byType[t]);
|
|
1362
|
+
if (missing.length) notSeen.push(`no ${missing.join(", ")} events`);
|
|
1363
|
+
const truncated = badLines.length > 0 || !text.endsWith("\n");
|
|
1364
|
+
if (truncated) notSeen.push(`the ledger may be truncated or damaged (${badLines.length} unreadable line(s)${text.endsWith("\n") ? "" : ", last line has no line break"}); events after that point may be missing`);
|
|
1365
|
+
const attemptedOnly = changedPaths.filter((p) => !seenPaths.has(p) && blockedPaths.has(p));
|
|
1366
|
+
if (attemptedOnly.length)
|
|
1367
|
+
notSeen.push(
|
|
1368
|
+
`${attemptedOnly.length} changed file(s) appear in the ledger only as blocked attempts (${attemptedOnly.slice(0, SHOWN_PATHS).join(", ")}${attemptedOnly.length > SHOWN_PATHS ? ", \u2026" : ""}): the change that landed was not observed by any hook`
|
|
1369
|
+
);
|
|
1370
|
+
const unseen = changedPaths.filter((p) => !seenPaths.has(p) && !blockedPaths.has(p));
|
|
1371
|
+
if (unseen.length) {
|
|
1372
|
+
notSeen.push(
|
|
1373
|
+
`${unseen.length} of ${changedPaths.length} changed file(s) are named by no ledger event (${unseen.slice(0, SHOWN_PATHS).join(", ")}${unseen.length > SHOWN_PATHS ? ", \u2026" : ""}); a file changed through a shell command is seen only if the command text names it`
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
const opaque = [...new Set(events.filter((e) => (e.type === "command" || e.type === "tool_pre") && e.command && unobservable(e.command)).map((e) => e.command))];
|
|
1377
|
+
const unparsed = [...new Set(events.filter((e) => (e.type === "command" || e.type === "tool_pre") && e.command && !unobservable(e.command) && e.raw?.writeNote === "write target not parsed").map((e) => e.command))];
|
|
1378
|
+
if (opaque.length)
|
|
1379
|
+
notSeen.push(
|
|
1380
|
+
`${opaque.length} shell command${opaque.length === 1 ? "" : "s"} ran a script file or wrote to a path built at run time (e.g. \`${opaque[0].slice(0, 80)}\`); what ${opaque.length === 1 ? "it" : "they"} wrote is not observable by Replume's shell rules`
|
|
1381
|
+
);
|
|
1382
|
+
if (unparsed.length)
|
|
1383
|
+
notSeen.push(
|
|
1384
|
+
`${unparsed.length} shell command${unparsed.length === 1 ? "" : "s"} wrote to a target that was not parsed (variable, glob, inline code, patch or a git command that rewrites files; e.g. \`${unparsed[0].slice(0, 80)}\`): write target not parsed, so ${unparsed.length === 1 ? "its" : "their"} writes are not attributed to any file`
|
|
1385
|
+
);
|
|
1386
|
+
const hookErrorsLogged = hookErrors(eventsFile);
|
|
1387
|
+
if (hookErrorsLogged) notSeen.push(HOOK_CRASH);
|
|
1388
|
+
return {
|
|
1389
|
+
ledger: "present",
|
|
1390
|
+
events: events.length,
|
|
1391
|
+
sessions: new Set(events.map((e) => e.sessionId)).size,
|
|
1392
|
+
agents: [...new Set(events.map((e) => e.agent))].sort(),
|
|
1393
|
+
byType,
|
|
1394
|
+
tools,
|
|
1395
|
+
firstTs: ts[0],
|
|
1396
|
+
lastTs: ts[ts.length - 1],
|
|
1397
|
+
badLines: badLines.length,
|
|
1398
|
+
hookErrorsLogged,
|
|
1399
|
+
changedFiles: changedPaths.length,
|
|
1400
|
+
changedFilesSeen: changedPaths.filter((p) => seenPaths.has(p)).length,
|
|
1401
|
+
changedFilesLanded: changedPaths.filter((p) => landedPaths.has(p)).length,
|
|
1402
|
+
changedFilesAttemptedOnly: attemptedOnly.slice(0, SHOWN_PATHS),
|
|
1403
|
+
changedFilesNotSeen: unseen.slice(0, SHOWN_PATHS),
|
|
1404
|
+
unobservableCommands: opaque.length + unparsed.length,
|
|
1405
|
+
notSeen
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
var SCRIPT_RUN = /(?:^|[;&|(]\s*|\s(?:sudo|exec|env|nohup|time)\s+)(?:(?:node|nodejs|deno|bun|python[0-9.]*|ruby|perl|php|bash|sh|zsh|pwsh|tsx|ts-node)\s+(?:-[^-ce\s][^\s]*\s+)*[\w./~-]+\.(?:js|mjs|cjs|ts|py|rb|pl|php|sh|bash|zsh|ps1)\b|\.{1,2}\/[\w./-]+)/;
|
|
1409
|
+
var RUNTIME_TARGET = /(?:>{1,2}|>\||\btee(?:\s+-\w+)*)\s*["']?[^\s"';|&]*(?:\$|`)/;
|
|
1410
|
+
var unobservable = (command) => SCRIPT_RUN.test(command) || RUNTIME_TARGET.test(command);
|
|
1411
|
+
function hookErrors(eventsFile) {
|
|
1412
|
+
return existsSync6(join6(dirname4(eventsFile), "hook-errors.log"));
|
|
1413
|
+
}
|
|
1414
|
+
var list = (m) => Object.entries(m).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => `${k} ${n}`).join(", ");
|
|
1415
|
+
function renderCapture(c) {
|
|
1416
|
+
const out = ["CAPTURE COVERAGE"];
|
|
1417
|
+
if (c.ledger !== "present") {
|
|
1418
|
+
out.push(` seen nothing (ledger ${c.ledger === "absent" ? "file not found" : "empty"})`);
|
|
1419
|
+
} else {
|
|
1420
|
+
out.push(` seen ${c.events} events from ${c.agents.join(", ")} in ${c.sessions} session${c.sessions === 1 ? "" : "s"} (${c.firstTs} to ${c.lastTs}): ${list(c.byType)}`);
|
|
1421
|
+
if (Object.keys(c.tools).length) out.push(` tools ${list(c.tools)}`);
|
|
1422
|
+
if (c.changedFilesLanded !== void 0) {
|
|
1423
|
+
const a = c.changedFilesAttemptedOnly ?? [];
|
|
1424
|
+
out.push(
|
|
1425
|
+
` files ${c.changedFilesSeen} of ${c.changedFiles} changed file(s) named by an allowed step; ${c.changedFilesLanded} landed and observed (file_edit or successful command event); ${a.length} only attempted (blocked)${a.length ? `: ${a.join(", ")}` : ""}`
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
out.push(` not seen ${c.notSeen.length ? c.notSeen.join("; ") : "nothing flagged by these checks (they cannot see steps taken outside the hooks)"}`);
|
|
1430
|
+
out.push(" limits only agents with a Replume adapter and installed hooks write events; the ledger is a local file that can be edited or deleted. This is what was captured, not proof of what the agent did.");
|
|
1431
|
+
return out;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1297
1434
|
// src/core/rules.ts
|
|
1298
1435
|
var PREFIX = "Replume:";
|
|
1299
1436
|
function fingerprint(tool, input) {
|
|
1300
1437
|
return createHash("sha1").update(tool).update("\0").update(JSON.stringify(input ?? null)).digest("hex").slice(0, 16);
|
|
1301
1438
|
}
|
|
1439
|
+
function expandHome(p) {
|
|
1440
|
+
if (p === "~") return homedir();
|
|
1441
|
+
if (p.startsWith("~/")) return join7(homedir(), p.slice(2));
|
|
1442
|
+
return p;
|
|
1443
|
+
}
|
|
1302
1444
|
function splitPaths(repoRoot2, paths) {
|
|
1303
1445
|
const inside = [];
|
|
1304
1446
|
const outside = [];
|
|
1305
1447
|
const root = resolve2(repoRoot2);
|
|
1306
|
-
for (const
|
|
1448
|
+
for (const raw3 of paths) {
|
|
1449
|
+
const p = expandHome(raw3);
|
|
1450
|
+
if (p.startsWith("~")) {
|
|
1451
|
+
outside.push(p);
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1307
1454
|
const abs = isAbsolute3(p) ? resolve2(p) : resolve2(root, p);
|
|
1308
1455
|
const rel = relative3(root, abs);
|
|
1309
1456
|
if (rel === "" || rel.startsWith("..") || isAbsolute3(rel)) outside.push(abs);
|
|
@@ -1332,11 +1479,32 @@ function shellTokens(src) {
|
|
|
1332
1479
|
const toks = [];
|
|
1333
1480
|
let cur = "";
|
|
1334
1481
|
let has = false;
|
|
1482
|
+
const heredocs = [];
|
|
1335
1483
|
const flush = () => {
|
|
1336
|
-
if (has)
|
|
1484
|
+
if (has) {
|
|
1485
|
+
const prev = toks[toks.length - 1];
|
|
1486
|
+
if (prev && "op" in prev && prev.op === "<<") {
|
|
1487
|
+
const strip = cur.startsWith("-");
|
|
1488
|
+
heredocs.push({ delim: strip ? cur.slice(1) : cur, strip });
|
|
1489
|
+
}
|
|
1490
|
+
toks.push({ word: cur });
|
|
1491
|
+
}
|
|
1337
1492
|
cur = "";
|
|
1338
1493
|
has = false;
|
|
1339
1494
|
};
|
|
1495
|
+
const skipBodies = (from) => {
|
|
1496
|
+
let at = from;
|
|
1497
|
+
for (const h of heredocs.splice(0)) {
|
|
1498
|
+
for (; ; ) {
|
|
1499
|
+
if (at >= src.length) return src.length;
|
|
1500
|
+
const nl = src.indexOf("\n", at);
|
|
1501
|
+
const line = src.slice(at, nl < 0 ? src.length : nl);
|
|
1502
|
+
at = nl < 0 ? src.length : nl + 1;
|
|
1503
|
+
if ((h.strip ? line.replace(/^\t+/, "") : line) === h.delim) break;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return at;
|
|
1507
|
+
};
|
|
1340
1508
|
for (let i = 0; i < src.length; ) {
|
|
1341
1509
|
const c = src[i];
|
|
1342
1510
|
if (c === "'") {
|
|
@@ -1363,15 +1531,23 @@ function shellTokens(src) {
|
|
|
1363
1531
|
i = j < 0 ? src.length : j;
|
|
1364
1532
|
} else if (/\s/.test(c)) {
|
|
1365
1533
|
flush();
|
|
1366
|
-
if (c === "\n")
|
|
1534
|
+
if (c === "\n") {
|
|
1535
|
+
toks.push({ op: ";" });
|
|
1536
|
+
if (heredocs.length) {
|
|
1537
|
+
i = skipBodies(i + 1);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1367
1541
|
i++;
|
|
1368
1542
|
} else if (">|<&;()".includes(c)) {
|
|
1369
1543
|
const op = /^(?:&>>|>>|>\||>&|&>|&&|\|\||\|&|<<<|<<|<&|[>|<&;()])/.exec(src.slice(i))[0];
|
|
1544
|
+
let fd;
|
|
1370
1545
|
if ((op[0] === ">" || op[0] === "<") && has && /^\d+$/.test(cur)) {
|
|
1546
|
+
fd = Number(cur);
|
|
1371
1547
|
cur = "";
|
|
1372
1548
|
has = false;
|
|
1373
1549
|
} else flush();
|
|
1374
|
-
toks.push({ op });
|
|
1550
|
+
toks.push(fd === void 0 ? { op } : { op, fd });
|
|
1375
1551
|
i += op.length;
|
|
1376
1552
|
} else {
|
|
1377
1553
|
cur += c;
|
|
@@ -1383,8 +1559,122 @@ function shellTokens(src) {
|
|
|
1383
1559
|
return toks;
|
|
1384
1560
|
}
|
|
1385
1561
|
var SHELLS = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
1386
|
-
var WRAPPERS = /* @__PURE__ */ new Set(["sudo", "command", "exec", "time", "xargs", "env", "nohup"]);
|
|
1387
1562
|
var WRITE_OPS = /* @__PURE__ */ new Set([">", ">>", ">|", "&>", "&>>"]);
|
|
1563
|
+
var MAX_NEST = 8;
|
|
1564
|
+
var SHELL_KEYWORDS = /* @__PURE__ */ new Set(["if", "then", "else", "elif", "fi", "while", "until", "do", "done", "esac", "{", "}", "!"]);
|
|
1565
|
+
var HEADER_KEYWORDS = /* @__PURE__ */ new Set(["for", "select", "case"]);
|
|
1566
|
+
var WRAPPER_SPECS = {
|
|
1567
|
+
sudo: { withArg: ["-u", "-g", "-h", "-p", "-C", "-T", "-r", "-t", "-U", "-D", "-R", "--user", "--group", "--host", "--prompt"] },
|
|
1568
|
+
doas: { withArg: ["-u", "-C"] },
|
|
1569
|
+
env: { withArg: ["-u", "-C", "--unset", "--chdir"] },
|
|
1570
|
+
command: { withArg: [] },
|
|
1571
|
+
exec: { withArg: ["-a"] },
|
|
1572
|
+
time: { withArg: ["-f", "-o", "--format", "--output"] },
|
|
1573
|
+
nohup: { withArg: [] },
|
|
1574
|
+
nice: { withArg: ["-n", "--adjustment"] },
|
|
1575
|
+
ionice: { withArg: ["-c", "-n", "-p", "-P", "-u", "--class", "--classdata"] },
|
|
1576
|
+
stdbuf: { withArg: ["-i", "-o", "-e"] },
|
|
1577
|
+
timeout: { withArg: ["-s", "-k", "--signal", "--kill-after"], positional: 1 },
|
|
1578
|
+
xargs: { withArg: ["-I", "-n", "-P", "-L", "-s", "-d", "-E", "-a"] }
|
|
1579
|
+
};
|
|
1580
|
+
function unwrapCommand(input) {
|
|
1581
|
+
let words = input;
|
|
1582
|
+
for (let guard = 0; guard < 16 && words.length; guard++) {
|
|
1583
|
+
const w = words[0];
|
|
1584
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || SHELL_KEYWORDS.has(w)) {
|
|
1585
|
+
words = words.slice(1);
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
if (HEADER_KEYWORDS.has(w)) return [];
|
|
1589
|
+
if (w === "function") {
|
|
1590
|
+
words = words.slice(2);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
const spec = WRAPPER_SPECS[w.split("/").pop()];
|
|
1594
|
+
if (!spec) break;
|
|
1595
|
+
let i = 1;
|
|
1596
|
+
while (i < words.length) {
|
|
1597
|
+
const a = words[i];
|
|
1598
|
+
if (a === "--") {
|
|
1599
|
+
i++;
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1602
|
+
if (a.startsWith("-") && a.length > 1) i += spec.withArg.includes(a) ? 2 : 1;
|
|
1603
|
+
else break;
|
|
1604
|
+
}
|
|
1605
|
+
if (spec.positional && /^\d+(?:\.\d+)?[smhd]?$/.test(words[i] ?? "")) i += spec.positional;
|
|
1606
|
+
words = words.slice(i);
|
|
1607
|
+
}
|
|
1608
|
+
return words;
|
|
1609
|
+
}
|
|
1610
|
+
function splitFlags(args) {
|
|
1611
|
+
const flags = [];
|
|
1612
|
+
const operands = [];
|
|
1613
|
+
let done = false;
|
|
1614
|
+
for (const a of args) {
|
|
1615
|
+
if (done) operands.push(a);
|
|
1616
|
+
else if (a === "--") done = true;
|
|
1617
|
+
else if (a.startsWith("-") && a.length > 1) flags.push(a);
|
|
1618
|
+
else operands.push(a);
|
|
1619
|
+
}
|
|
1620
|
+
return { flags, operands };
|
|
1621
|
+
}
|
|
1622
|
+
var isBlockDevice = (p) => /^\/dev\/(?:(?:s|h|v|xv)d[a-z]+[0-9]*|nvme[0-9]+n[0-9]+(?:p[0-9]+)?|mmcblk[0-9]+(?:p[0-9]+)?|r?disk[0-9]+(?:s[0-9]+)*)$/.test(p.trim());
|
|
1623
|
+
function substitutionBodies(src) {
|
|
1624
|
+
const text = src.replace(/<<(-?)[ \t]*(['"]?)([A-Za-z_][\w-]*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3(?=\n|$)/g, "");
|
|
1625
|
+
const out = [];
|
|
1626
|
+
const closing = (from) => {
|
|
1627
|
+
let depth = 0;
|
|
1628
|
+
for (let j = from; j < text.length; j++) {
|
|
1629
|
+
const c = text[j];
|
|
1630
|
+
if (c === "\\") {
|
|
1631
|
+
j++;
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
if (c === "'") {
|
|
1635
|
+
j = text.indexOf("'", j + 1);
|
|
1636
|
+
if (j < 0) return text.length;
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
if (c === '"') {
|
|
1640
|
+
for (j++; j < text.length && text[j] !== '"'; j++) if (text[j] === "\\") j++;
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (c === "(") depth++;
|
|
1644
|
+
else if (c === ")" && --depth === 0) return j;
|
|
1645
|
+
}
|
|
1646
|
+
return text.length;
|
|
1647
|
+
};
|
|
1648
|
+
let dq = false;
|
|
1649
|
+
for (let i = 0; i < text.length; i++) {
|
|
1650
|
+
const c = text[i];
|
|
1651
|
+
if (c === "\\") {
|
|
1652
|
+
i++;
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
if (c === "'" && !dq) {
|
|
1656
|
+
const j = text.indexOf("'", i + 1);
|
|
1657
|
+
if (j < 0) break;
|
|
1658
|
+
i = j;
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
if (c === '"') {
|
|
1662
|
+
dq = !dq;
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
if (text[i + 1] === "(" && (c === "$" && text[i + 2] !== "(" || (c === "<" || c === ">") && !dq)) {
|
|
1666
|
+
const end = closing(i + 1);
|
|
1667
|
+
out.push(text.slice(i + 2, end));
|
|
1668
|
+
i = end;
|
|
1669
|
+
} else if (c === "`") {
|
|
1670
|
+
let j = i + 1;
|
|
1671
|
+
while (j < text.length && text[j] !== "`") j += text[j] === "\\" ? 2 : 1;
|
|
1672
|
+
out.push(text.slice(i + 1, j));
|
|
1673
|
+
i = j;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return out;
|
|
1677
|
+
}
|
|
1388
1678
|
function redirectTargets(command, depth = 0) {
|
|
1389
1679
|
const toks = shellTokens(command);
|
|
1390
1680
|
const out = [];
|
|
@@ -1397,13 +1687,13 @@ function redirectTargets(command, depth = 0) {
|
|
|
1397
1687
|
}
|
|
1398
1688
|
if (!("word" in t)) return;
|
|
1399
1689
|
const prev = toks[i - 1];
|
|
1400
|
-
const atCommand = !prev || "op" in prev ||
|
|
1690
|
+
const atCommand = !prev || "op" in prev || (word(prev) ?? "") in WRAPPER_SPECS;
|
|
1401
1691
|
if (t.word === "tee" && atCommand) {
|
|
1402
1692
|
for (let j = i + 1; j < toks.length && "word" in toks[j]; j++) {
|
|
1403
1693
|
const w = word(toks[j]);
|
|
1404
1694
|
if (!w.startsWith("-")) out.push(w);
|
|
1405
1695
|
}
|
|
1406
|
-
} else if (depth <
|
|
1696
|
+
} else if (depth < MAX_NEST && SHELLS.has(t.word.split("/").pop())) {
|
|
1407
1697
|
const k = toks.findIndex((x, j) => j > i && "word" in x && /^-[a-z]*c$/.test(x.word));
|
|
1408
1698
|
const script = k >= 0 ? word(toks[k + 1]) : null;
|
|
1409
1699
|
if (script) out.push(...redirectTargets(script, depth + 1));
|
|
@@ -1421,18 +1711,18 @@ function fileVerbOperands(command, depth = 0) {
|
|
|
1421
1711
|
const t = toks[i];
|
|
1422
1712
|
if ("op" in t) {
|
|
1423
1713
|
if (SEPARATORS.has(t.op)) simple.push([]);
|
|
1424
|
-
else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<") i++;
|
|
1714
|
+
else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<" || t.op === "<<") i++;
|
|
1425
1715
|
continue;
|
|
1426
1716
|
}
|
|
1427
1717
|
simple[simple.length - 1].push(t.word);
|
|
1428
1718
|
}
|
|
1429
|
-
for (
|
|
1430
|
-
|
|
1719
|
+
for (const sw of simple) {
|
|
1720
|
+
const words = unwrapCommand(sw);
|
|
1431
1721
|
if (!words.length) continue;
|
|
1432
1722
|
const verb = words[0].split("/").pop();
|
|
1433
1723
|
const args = words.slice(1);
|
|
1434
|
-
const operands = args
|
|
1435
|
-
if (SHELLS.has(verb) && depth <
|
|
1724
|
+
const operands = splitFlags(args).operands.map((w) => w.replace(/^of=/, ""));
|
|
1725
|
+
if (SHELLS.has(verb) && depth < MAX_NEST) {
|
|
1436
1726
|
const k = args.findIndex((w) => /^-[a-z]*c$/.test(w));
|
|
1437
1727
|
if (k >= 0 && args[k + 1]) out.push(...fileVerbOperands(args[k + 1], depth + 1));
|
|
1438
1728
|
} else if (FILE_VERBS.has(verb)) out.push(...operands);
|
|
@@ -1441,16 +1731,179 @@ function fileVerbOperands(command, depth = 0) {
|
|
|
1441
1731
|
}
|
|
1442
1732
|
return out.filter((p) => p && !/[$*?{}`]/.test(p));
|
|
1443
1733
|
}
|
|
1734
|
+
var DYNAMIC = /[$*?{}`]/;
|
|
1735
|
+
var INLINE_INTERP = /* @__PURE__ */ new Set(["node", "nodejs", "python", "python3", "perl", "ruby", "deno", "bun", "php"]);
|
|
1736
|
+
var INLINE_WRITE = /writeFile|appendFile|createWriteStream|copyFile|rename|unlink|rmSync|mkdir|open\s*\(|\.write\s*\(|write_text|write_bytes|shutil\.|os\.remove|File\.write|IO\.write/;
|
|
1737
|
+
var GIT_TREE_CHANGERS = /* @__PURE__ */ new Set(["switch", "reset", "stash", "apply", "am", "merge", "pull", "rebase", "cherry-pick", "revert", "clean"]);
|
|
1738
|
+
function shellWriteTargets(command, depth = 0) {
|
|
1739
|
+
const out = [];
|
|
1740
|
+
const certain = [];
|
|
1741
|
+
let unparsed = false;
|
|
1742
|
+
const toks = shellTokens(command);
|
|
1743
|
+
let cond = false;
|
|
1744
|
+
let base3 = false;
|
|
1745
|
+
const orLists = /* @__PURE__ */ new Set();
|
|
1746
|
+
let list2 = 0;
|
|
1747
|
+
let construct = 0;
|
|
1748
|
+
let pendingFunc = false;
|
|
1749
|
+
const stack = [];
|
|
1750
|
+
const simple = [{ words: [], redirects: [], conditional: false, list: 0, depth: 0, construct: 0, after: ";" }];
|
|
1751
|
+
const next = (after) => simple.push({ words: [], redirects: [], conditional: cond, list: list2, depth: stack.length, construct, after });
|
|
1752
|
+
for (let i = 0; i < toks.length; i++) {
|
|
1753
|
+
const t = toks[i];
|
|
1754
|
+
if ("op" in t) {
|
|
1755
|
+
const cur2 = simple[simple.length - 1];
|
|
1756
|
+
if (t.op === ";") {
|
|
1757
|
+
cond = base3;
|
|
1758
|
+
list2++;
|
|
1759
|
+
pendingFunc = false;
|
|
1760
|
+
next(";");
|
|
1761
|
+
} else if (t.op === "&&") {
|
|
1762
|
+
cond = true;
|
|
1763
|
+
next("&&");
|
|
1764
|
+
} else if (t.op === "||") {
|
|
1765
|
+
cond = true;
|
|
1766
|
+
orLists.add(list2);
|
|
1767
|
+
next("||");
|
|
1768
|
+
} else if (t.op === "(") {
|
|
1769
|
+
if (cur2.words.length === 1 && /^[A-Za-z_][\w.-]*$/.test(cur2.words[0])) pendingFunc = true;
|
|
1770
|
+
stack.push({ cond, base: base3, func: false });
|
|
1771
|
+
base3 = cond;
|
|
1772
|
+
next("(");
|
|
1773
|
+
} else if (t.op === ")") {
|
|
1774
|
+
const top = stack.pop();
|
|
1775
|
+
cond = top?.cond ?? false;
|
|
1776
|
+
base3 = top?.base ?? false;
|
|
1777
|
+
next(")");
|
|
1778
|
+
} else if (SEPARATORS.has(t.op)) next(t.op);
|
|
1779
|
+
else if (WRITE_OPS.has(t.op)) {
|
|
1780
|
+
const target = toks[i + 1];
|
|
1781
|
+
const w = target && "word" in target ? target.word : null;
|
|
1782
|
+
const fds = t.fd !== void 0 ? [t.fd] : t.op === "&>" || t.op === "&>>" ? [1, 2] : [1];
|
|
1783
|
+
cur2.redirects.push({ fds, target: w });
|
|
1784
|
+
i++;
|
|
1785
|
+
} else if (t.op === "<" || t.op === "<<<" || t.op === "<<" || t.op === ">&" || t.op === "<&") i++;
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
const cur = simple[simple.length - 1];
|
|
1789
|
+
if (cur.words.length === 0) {
|
|
1790
|
+
if (["if", "while", "until", "for", "select", "case"].includes(t.word)) construct++;
|
|
1791
|
+
else if (["fi", "done", "esac"].includes(t.word)) construct = Math.max(0, construct - 1);
|
|
1792
|
+
else if (t.word === "function") pendingFunc = true;
|
|
1793
|
+
else if (t.word === "{") {
|
|
1794
|
+
stack.push({ cond, base: base3, func: pendingFunc });
|
|
1795
|
+
base3 = cond;
|
|
1796
|
+
if (pendingFunc) {
|
|
1797
|
+
construct++;
|
|
1798
|
+
cur.construct = construct;
|
|
1799
|
+
}
|
|
1800
|
+
pendingFunc = false;
|
|
1801
|
+
} else if (t.word === "}") {
|
|
1802
|
+
const top = stack.pop();
|
|
1803
|
+
cond = top?.cond ?? false;
|
|
1804
|
+
base3 = top?.base ?? false;
|
|
1805
|
+
if (top?.func) construct = Math.max(0, construct - 1);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
cur.words.push(t.word);
|
|
1809
|
+
}
|
|
1810
|
+
const fails = (sc) => {
|
|
1811
|
+
const w = unwrapCommand(sc.words);
|
|
1812
|
+
return w[0] === "false" || (w[0] === "exit" || w[0] === "return") && /^[1-9][0-9]*$/.test(w[1] ?? "");
|
|
1813
|
+
};
|
|
1814
|
+
const skipped = simple.map(() => false);
|
|
1815
|
+
simple.forEach((sc, i) => {
|
|
1816
|
+
if (i > 0 && sc.after === "&&" && (fails(simple[i - 1]) || skipped[i - 1])) skipped[i] = true;
|
|
1817
|
+
});
|
|
1818
|
+
const lastList = [...simple].reverse().find((x) => x.words.length || x.redirects.length)?.list ?? 0;
|
|
1819
|
+
simple.forEach((sc, idx) => {
|
|
1820
|
+
const sure = !skipped[idx] && sc.construct === 0 && (!sc.conditional || sc.depth === 0 && !orLists.has(sc.list) && sc.list === lastList);
|
|
1821
|
+
const last = /* @__PURE__ */ new Map();
|
|
1822
|
+
sc.redirects.forEach((r, j) => r.fds.forEach((fd) => last.set(fd, j)));
|
|
1823
|
+
sc.redirects.forEach((r, j) => {
|
|
1824
|
+
if (!r.target || DYNAMIC.test(r.target)) {
|
|
1825
|
+
unparsed = true;
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (DEVICE_SINK.test(r.target)) return;
|
|
1829
|
+
out.push(r.target);
|
|
1830
|
+
if (sure && r.fds.some((fd) => last.get(fd) === j)) certain.push(r.target);
|
|
1831
|
+
});
|
|
1832
|
+
const add = (ws, content = true) => {
|
|
1833
|
+
for (const w of ws) {
|
|
1834
|
+
if (!w) continue;
|
|
1835
|
+
if (DYNAMIC.test(w)) unparsed = true;
|
|
1836
|
+
else if (!DEVICE_SINK.test(w)) {
|
|
1837
|
+
out.push(w);
|
|
1838
|
+
if (content && sure) certain.push(w);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
const words = unwrapCommand(sc.words);
|
|
1843
|
+
if (!words.length) return;
|
|
1844
|
+
const verb = words[0].split("/").pop();
|
|
1845
|
+
const args = words.slice(1);
|
|
1846
|
+
const operands = splitFlags(args).operands;
|
|
1847
|
+
if (sc.words.slice(0, sc.words.length - words.length).some((x) => x.split("/").pop() === "xargs")) {
|
|
1848
|
+
if (FILE_VERBS.has(verb) || verb === "sed" || verb === "perl") unparsed = true;
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (SHELLS.has(verb)) {
|
|
1852
|
+
const k = args.findIndex((w) => /^-[a-z]*c$/.test(w));
|
|
1853
|
+
if (k >= 0 && args[k + 1] !== void 0) {
|
|
1854
|
+
if (depth >= MAX_NEST) unparsed = true;
|
|
1855
|
+
else {
|
|
1856
|
+
const r = shellWriteTargets(args[k + 1], depth + 1);
|
|
1857
|
+
out.push(...r.paths);
|
|
1858
|
+
if (sure) certain.push(...r.certain);
|
|
1859
|
+
unparsed ||= r.unparsed;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
} else if (["tee", "touch", "truncate", "rm", "unlink", "chmod", "chown"].includes(verb)) {
|
|
1863
|
+
add(verb === "chmod" || verb === "chown" ? operands.slice(1) : verb === "truncate" ? args.filter((w, j) => !w.startsWith("-") && !/^-s$|^--size$/.test(args[j - 1] ?? "")) : operands, !["touch", "chmod", "chown"].includes(verb));
|
|
1864
|
+
} else if (verb === "cp" || verb === "install" || verb === "ln") {
|
|
1865
|
+
const t = args.findIndex((w) => w === "-t" || w === "--target-directory");
|
|
1866
|
+
if (t >= 0 || args.some((w) => w.startsWith("--target-directory="))) unparsed = true;
|
|
1867
|
+
else if (operands.length >= 2) add([operands[operands.length - 1]]);
|
|
1868
|
+
} else if (verb === "mv") add(operands);
|
|
1869
|
+
else if (verb === "dd") add(args.filter((w) => w.startsWith("of=")).map((w) => w.slice(3)));
|
|
1870
|
+
else if (verb === "patch") unparsed = true;
|
|
1871
|
+
else if (verb === "sed" && args.some((w) => /^-[a-zA-Z]*i|^--in-place/.test(w))) {
|
|
1872
|
+
const ops = args.filter((w, j) => !w.startsWith("-") && !/^-[a-zA-Z]*[ef]$|^--expression$|^--file$/.test(args[j - 1] ?? "") && !(w === "" && /^-[a-zA-Z]*i$/.test(args[j - 1] ?? "")));
|
|
1873
|
+
const scripted = args.some((w) => /^-[a-zA-Z]*[ef]$|^--expression|^--file/.test(w));
|
|
1874
|
+
add(scripted ? ops : ops.slice(1));
|
|
1875
|
+
} else if (verb === "perl" && args.some((w) => /^-[a-zA-Z]*i/.test(w))) {
|
|
1876
|
+
add(args.filter((w, j) => !w.startsWith("-") && !/^-[a-zA-Z]*e$/.test(args[j - 1] ?? "")));
|
|
1877
|
+
} else if (verb === "git") {
|
|
1878
|
+
const sub = args[0] ?? "";
|
|
1879
|
+
if (sub === "rm" || sub === "mv") add(operands.slice(1));
|
|
1880
|
+
else if (sub === "checkout" || sub === "restore") {
|
|
1881
|
+
const dd = args.indexOf("--");
|
|
1882
|
+
if (dd >= 0) add(args.slice(dd + 1));
|
|
1883
|
+
else if (sub === "restore") add(operands.slice(1));
|
|
1884
|
+
else unparsed = true;
|
|
1885
|
+
} else if (GIT_TREE_CHANGERS.has(sub)) unparsed = true;
|
|
1886
|
+
} else if (INLINE_INTERP.has(verb.replace(/[0-9.]+$/, "")) && args.some((w) => /^-[a-zA-Z]*[ec]$|^eval$/.test(w)) && INLINE_WRITE.test(args.join(" "))) unparsed = true;
|
|
1887
|
+
});
|
|
1888
|
+
const bodies = substitutionBodies(command);
|
|
1889
|
+
if (bodies.length && depth >= MAX_NEST) unparsed = true;
|
|
1890
|
+
else for (const body of bodies) {
|
|
1891
|
+
const r = shellWriteTargets(body, depth + 1);
|
|
1892
|
+
out.push(...r.paths);
|
|
1893
|
+
unparsed ||= r.unparsed;
|
|
1894
|
+
}
|
|
1895
|
+
if (depth === 0 && unobservable(command)) unparsed = true;
|
|
1896
|
+
return { paths: [...new Set(out)], unparsed, certain: [...new Set(certain)] };
|
|
1897
|
+
}
|
|
1444
1898
|
function deletedOperands(command, depth = 0) {
|
|
1445
1899
|
const out = [];
|
|
1446
1900
|
for (const t of shellTokens(command).reduce((acc, x) => ("op" in x && SEPARATORS.has(x.op) ? acc.push([]) : "word" in x && acc[acc.length - 1].push(x.word), acc), [[]])) {
|
|
1447
|
-
|
|
1448
|
-
while (words.length && (WRAPPERS.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) words = words.slice(1);
|
|
1901
|
+
const words = unwrapCommand(t);
|
|
1449
1902
|
const verb = words[0]?.split("/").pop();
|
|
1450
|
-
const ops = words.slice(1)
|
|
1903
|
+
const ops = splitFlags(words.slice(1)).operands;
|
|
1451
1904
|
if (verb === "rm" || verb === "unlink") out.push(...ops);
|
|
1452
1905
|
else if (verb === "git" && words[1] === "rm") out.push(...ops.slice(1));
|
|
1453
|
-
else if (verb && SHELLS.has(verb) && depth <
|
|
1906
|
+
else if (verb && SHELLS.has(verb) && depth < MAX_NEST) {
|
|
1454
1907
|
const k = words.findIndex((w) => /^-[a-z]*c$/.test(w));
|
|
1455
1908
|
if (k >= 0 && words[k + 1]) out.push(...deletedOperands(words[k + 1], depth + 1));
|
|
1456
1909
|
}
|
|
@@ -1485,7 +1938,7 @@ function stripShellComments(src) {
|
|
|
1485
1938
|
}
|
|
1486
1939
|
function writeTargets(a, repoRoot2) {
|
|
1487
1940
|
if (a.kind !== "command" || !a.command) return { inside: [], outside: [] };
|
|
1488
|
-
return splitPaths(repoRoot2, redirectTargets(a.command));
|
|
1941
|
+
return splitPaths(repoRoot2, [...redirectTargets(a.command), ...shellWriteTargets(a.command).paths]);
|
|
1489
1942
|
}
|
|
1490
1943
|
var isWrite = (a) => a.kind === "edit" || a.kind === "write";
|
|
1491
1944
|
var outOfScopeEdit = (a, ctx) => {
|
|
@@ -1512,6 +1965,11 @@ var MUTATING_VERBS = /(^|[\s;&|(])(rm|mv|cp|truncate|tee|chmod|chown|ln|unlink|p
|
|
|
1512
1965
|
function commandPathTokens(command) {
|
|
1513
1966
|
return (command.match(/[A-Za-z0-9_./@+-]+/g) ?? []).filter((t) => t.includes("/") || t.includes("."));
|
|
1514
1967
|
}
|
|
1968
|
+
function allowedForTask(ctx, protectedPaths) {
|
|
1969
|
+
const allow = ctx.contract?.allow ?? [];
|
|
1970
|
+
if (!allow.length) return null;
|
|
1971
|
+
return allow.map((g) => protectedPaths.some((p) => matchesAny(p, [g])) ? `${g} (except protected files)` : g).join(", ");
|
|
1972
|
+
}
|
|
1515
1973
|
var protectedPathModified = (a, ctx) => {
|
|
1516
1974
|
const protect = ctx.contract?.protect ?? [];
|
|
1517
1975
|
if (isWrite(a)) {
|
|
@@ -1525,22 +1983,24 @@ var protectedPathModified = (a, ctx) => {
|
|
|
1525
1983
|
}
|
|
1526
1984
|
const hit = a.paths.filter((p) => matchesAny(p, protect));
|
|
1527
1985
|
if (!hit.length) return null;
|
|
1986
|
+
const allowed = allowedForTask(ctx, hit);
|
|
1528
1987
|
return {
|
|
1529
1988
|
rule: "protected_path_modified",
|
|
1530
1989
|
action: "block",
|
|
1531
|
-
message: `${PREFIX} ${hit.join(", ")} is protected by the task contract and must not change. Leave it as it is and make the implementation satisfy it. If the task truly requires changing it, stop and ask the user.`
|
|
1990
|
+
message: allowed ? `${PREFIX} ${hit.join(", ")} is protected. Allowed for this task: ${allowed}. Change those instead; if the protected file must change, stop and ask the user.` : `${PREFIX} ${hit.join(", ")} is protected by the task contract and must not change. Leave it as it is and make the implementation satisfy it. If the task truly requires changing it, stop and ask the user.`
|
|
1532
1991
|
};
|
|
1533
1992
|
}
|
|
1534
1993
|
if (a.kind === "command" && a.command) {
|
|
1535
1994
|
const guarded = (p) => matchesAny(p, protect) !== null || p === ".replume" || p.startsWith(".replume/");
|
|
1536
1995
|
const redirected = writeTargets(a, ctx.repoRoot).inside.filter(guarded);
|
|
1537
|
-
const operands = splitPaths(ctx.repoRoot,
|
|
1996
|
+
const operands = splitPaths(ctx.repoRoot, [a.command, ...innerScripts(a.command)].flatMap((c) => fileVerbOperands(c))).inside.filter(guarded);
|
|
1538
1997
|
const hit = [.../* @__PURE__ */ new Set([...redirected, ...operands])];
|
|
1539
1998
|
if (!hit.length) return null;
|
|
1999
|
+
const allowed = allowedForTask(ctx, hit);
|
|
1540
2000
|
return {
|
|
1541
2001
|
rule: "protected_path_modified",
|
|
1542
2002
|
action: "block",
|
|
1543
|
-
message: `${PREFIX} this command would modify ${hit.join(", ")}, which the task contract protects. Do not change protected files through the shell either. If it is required, stop and ask the user.`
|
|
2003
|
+
message: allowed ? `${PREFIX} this command would modify ${hit.join(", ")}, which is protected. Allowed for this task: ${allowed}. Change those instead; if the protected file must change, stop and ask the user.` : `${PREFIX} this command would modify ${hit.join(", ")}, which the task contract protects. Do not change protected files through the shell either. If it is required, stop and ask the user.`
|
|
1544
2004
|
};
|
|
1545
2005
|
}
|
|
1546
2006
|
return null;
|
|
@@ -1548,7 +2008,7 @@ var protectedPathModified = (a, ctx) => {
|
|
|
1548
2008
|
var ASSERTION = /\b(expect|assert(?:Equal|StrictEqual|DeepEqual|True|False|Raises|That)?|should)\s*[.(]|\bassert\s+\S|\bt\.(is|not|deepEqual|equal|ok|notOk|true|false|throws|same|match)\s*\(|\.(toBe|toEqual|toStrictEqual|toMatch|toThrow|toHaveBeenCalled\w*|toContain|toHaveLength)\s*\(/g;
|
|
1549
2009
|
var SKIP_MARK = /\b(?:it|test|describe|suite|context)\.(?:skip|only|todo)\s*\(|\b(?:xit|xtest|xdescribe|fit|fdescribe)\s*\(|@pytest\.mark\.skip|@unittest\.skip|\bt\.skip\s*\(|\bpytest\.skip\s*\(|\.skip\s*=\s*true/g;
|
|
1550
2010
|
var FORCED_SUCCESS = /(\b(test|jest|vitest|mocha|pytest|ava|tap|go test|cargo test|npm t)\b[^\n]*(\|\|\s*(true|:|exit 0)|;\s*exit 0))/;
|
|
1551
|
-
var
|
|
2011
|
+
var count3 = (re, s) => (s.match(re) ?? []).length;
|
|
1552
2012
|
function assertionLiterals(s) {
|
|
1553
2013
|
return s.split("\n").filter((l) => new RegExp(ASSERTION.source).test(l)).map((l) => l.trim()).sort();
|
|
1554
2014
|
}
|
|
@@ -1561,7 +2021,7 @@ var testWeakened = (a) => {
|
|
|
1561
2021
|
message: `${PREFIX} this command forces the test run to succeed (\`|| true\` / \`exit 0\`), which hides failures. Run the tests plainly and fix what fails.`
|
|
1562
2022
|
};
|
|
1563
2023
|
}
|
|
1564
|
-
const rmTests = [...new Set(
|
|
2024
|
+
const rmTests = [...new Set([a.command, ...innerScripts(a.command)].flatMap((c) => deletedOperands(c)).filter((t) => isTestFile(t)))];
|
|
1565
2025
|
if (rmTests.length) {
|
|
1566
2026
|
return {
|
|
1567
2027
|
rule: "test_weakened",
|
|
@@ -1580,8 +2040,8 @@ var testWeakened = (a) => {
|
|
|
1580
2040
|
severity = "block";
|
|
1581
2041
|
}
|
|
1582
2042
|
if (!isTestFile(ch.path)) continue;
|
|
1583
|
-
const removed =
|
|
1584
|
-
const skipsAdded =
|
|
2043
|
+
const removed = count3(ASSERTION, ch.before) - count3(ASSERTION, ch.after);
|
|
2044
|
+
const skipsAdded = count3(SKIP_MARK, ch.after) - count3(SKIP_MARK, ch.before);
|
|
1585
2045
|
if (ch.before && removed > 0) {
|
|
1586
2046
|
findings.push(`${ch.path}: ${removed} assertion${removed > 1 ? "s" : ""} removed`);
|
|
1587
2047
|
severity = "block";
|
|
@@ -1614,10 +2074,32 @@ var ALWAYS_DESTRUCTIVE = [
|
|
|
1614
2074
|
{ re: /\bgit\s+branch\s+-D\b/, why: "force-deletes a branch" },
|
|
1615
2075
|
{ re: /\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b/i, why: "drops or truncates database data" },
|
|
1616
2076
|
{ re: /\bDELETE\s+FROM\s+\w+\s*(;|$|")/i, why: "deletes every row of a table" },
|
|
1617
|
-
{ re: /\bmkfs(\.\w+)?\b|\bdd\s+[^\n]*of=\/dev\//, why: "overwrites a disk" },
|
|
1618
2077
|
{ re: /\bchmod\s+-R\s+777\s+\//, why: "makes the filesystem world-writable" },
|
|
1619
2078
|
{ re: /:\(\)\s*\{\s*:\|:&\s*\};:/, why: "is a fork bomb" }
|
|
1620
2079
|
];
|
|
2080
|
+
function shellScripts(command, depth = 0) {
|
|
2081
|
+
if (depth >= MAX_NEST) return [];
|
|
2082
|
+
const out = [];
|
|
2083
|
+
const groups = [[]];
|
|
2084
|
+
for (const t of shellTokens(command)) {
|
|
2085
|
+
if ("op" in t) {
|
|
2086
|
+
if (SEPARATORS.has(t.op)) groups.push([]);
|
|
2087
|
+
} else groups[groups.length - 1].push(t.word);
|
|
2088
|
+
}
|
|
2089
|
+
for (const g of groups) {
|
|
2090
|
+
const words = unwrapCommand(g);
|
|
2091
|
+
if (!words.length || !SHELLS.has(words[0].split("/").pop())) continue;
|
|
2092
|
+
const k = words.findIndex((w, i) => i > 0 && /^-[a-z]*c$/.test(w));
|
|
2093
|
+
const script = k >= 0 ? words[k + 1] : void 0;
|
|
2094
|
+
if (script) out.push(script, ...shellScripts(script, depth + 1));
|
|
2095
|
+
}
|
|
2096
|
+
return out;
|
|
2097
|
+
}
|
|
2098
|
+
function innerScripts(command, depth = 0) {
|
|
2099
|
+
if (depth >= MAX_NEST) return [];
|
|
2100
|
+
const direct = [...shellScripts(command, MAX_NEST - 1), ...substitutionBodies(command)];
|
|
2101
|
+
return [...new Set(direct)].flatMap((x) => [x, ...innerScripts(x, depth + 1)]);
|
|
2102
|
+
}
|
|
1621
2103
|
function deletionTargets(command) {
|
|
1622
2104
|
const groups = [[]];
|
|
1623
2105
|
for (const token of shellTokens(command)) {
|
|
@@ -1625,32 +2107,54 @@ function deletionTargets(command) {
|
|
|
1625
2107
|
if (SEPARATORS.has(token.op)) groups.push([]);
|
|
1626
2108
|
} else groups[groups.length - 1].push(token.word);
|
|
1627
2109
|
}
|
|
1628
|
-
const
|
|
1629
|
-
for (
|
|
1630
|
-
|
|
2110
|
+
const result2 = [];
|
|
2111
|
+
for (const g of groups) {
|
|
2112
|
+
const words = unwrapCommand(g);
|
|
1631
2113
|
const verb = words[0]?.split("/").pop();
|
|
1632
2114
|
if (verb === "rm") {
|
|
1633
|
-
const
|
|
1634
|
-
if (
|
|
1635
|
-
result.push({ targets: args.filter((x) => x !== "--" && !x.startsWith("-")), gitClean: false });
|
|
2115
|
+
const { flags, operands } = splitFlags(words.slice(1));
|
|
2116
|
+
if (flags.some((x) => /^-[^-]*[rR]/.test(x) || x === "--recursive")) result2.push({ targets: operands, gitClean: false });
|
|
1636
2117
|
} else if (verb === "rmdir") {
|
|
1637
|
-
|
|
2118
|
+
result2.push({ targets: splitFlags(words.slice(1)).operands, gitClean: false });
|
|
1638
2119
|
} else if (verb === "find" && words.includes("-delete")) {
|
|
1639
|
-
|
|
2120
|
+
result2.push({ targets: words.slice(1, words.indexOf("-delete")).filter((x) => x !== "--" && !x.startsWith("-") && x !== "-depth"), gitClean: false });
|
|
1640
2121
|
} else if (verb === "git") {
|
|
1641
2122
|
const clean = words.indexOf("clean");
|
|
1642
2123
|
if (clean < 0 || !words.slice(clean + 1).some((x) => /^-[^-]*f/.test(x) || x === "--force")) continue;
|
|
1643
2124
|
const c = words.indexOf("-C");
|
|
1644
2125
|
const cwd = c >= 0 && c < clean ? words[c + 1] : null;
|
|
1645
2126
|
const paths = words.slice(clean + 1).filter((x) => x !== "--" && !x.startsWith("-"));
|
|
1646
|
-
|
|
2127
|
+
result2.push({ targets: [...cwd ? [cwd] : [], ...paths], gitClean: true });
|
|
1647
2128
|
}
|
|
1648
2129
|
}
|
|
1649
|
-
return
|
|
2130
|
+
return result2;
|
|
2131
|
+
}
|
|
2132
|
+
function expandTmpdirVar(target, ctx) {
|
|
2133
|
+
const m = /^\$\{?TMPDIR\}?(?=\/|$)/.exec(target);
|
|
2134
|
+
if (!m) return target;
|
|
2135
|
+
const env = process.env.TMPDIR === void 0 ? tmpdir() : process.env.TMPDIR;
|
|
2136
|
+
if (!env || !isAbsolute3(env)) return null;
|
|
2137
|
+
const base3 = resolve2(env);
|
|
2138
|
+
if (!isTempDir(ctx, base3)) return null;
|
|
2139
|
+
return base3 + target.slice(m[0].length);
|
|
2140
|
+
}
|
|
2141
|
+
function isTempDir(ctx, abs) {
|
|
2142
|
+
const roots = ["/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp", ...ctx.scratchDirs ?? []].map((d) => resolve2(d));
|
|
2143
|
+
return roots.some((d) => abs === d || abs.startsWith(d + sep)) || /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(abs);
|
|
1650
2144
|
}
|
|
1651
2145
|
function deletionLocation(target, ctx) {
|
|
1652
|
-
|
|
1653
|
-
const
|
|
2146
|
+
const bare = target.replace(/^["']|["']$/g, "");
|
|
2147
|
+
const t = expandTmpdirVar(bare, ctx);
|
|
2148
|
+
if (t === null) return "critical";
|
|
2149
|
+
if (t.startsWith("~") || /[$?{}`]/.test(t) && !/[*?[]/.test(t)) return "critical";
|
|
2150
|
+
if (/[*?[]/.test(t)) {
|
|
2151
|
+
const segs = t.split("/");
|
|
2152
|
+
const gi = segs.findIndex((x) => /[*?[]/.test(x));
|
|
2153
|
+
const prefix = segs.slice(0, gi).join("/") || (t.startsWith("/") ? "/" : "");
|
|
2154
|
+
if (!/[$`{]/.test(prefix) && prefix && isTempDir(ctx, resolve2(ctx.repoRoot, prefix))) return "outside";
|
|
2155
|
+
return "critical";
|
|
2156
|
+
}
|
|
2157
|
+
const expanded = t === "~" || t.startsWith("~/") ? homedir() + t.slice(1) : t;
|
|
1654
2158
|
const abs = resolve2(ctx.repoRoot, expanded);
|
|
1655
2159
|
const root = resolve2(ctx.repoRoot);
|
|
1656
2160
|
const home = resolve2(homedir());
|
|
@@ -1658,6 +2162,32 @@ function deletionLocation(target, ctx) {
|
|
|
1658
2162
|
const rel = relative3(root, abs);
|
|
1659
2163
|
return !rel || !rel.startsWith("..") && !isAbsolute3(rel) ? "inside" : "outside";
|
|
1660
2164
|
}
|
|
2165
|
+
function resolvedTarget(target, ctx) {
|
|
2166
|
+
const t = expandTmpdirVar(target.replace(/^["']|["']$/g, ""), ctx) ?? target;
|
|
2167
|
+
return resolve2(ctx.repoRoot, t);
|
|
2168
|
+
}
|
|
2169
|
+
function blockDeviceWrites(scripts) {
|
|
2170
|
+
const out = shellWriteTargets(scripts[0] ?? "").paths.filter(isBlockDevice);
|
|
2171
|
+
for (const script of scripts) {
|
|
2172
|
+
const groups = [[]];
|
|
2173
|
+
const toks = shellTokens(script);
|
|
2174
|
+
for (let i = 0; i < toks.length; i++) {
|
|
2175
|
+
const t = toks[i];
|
|
2176
|
+
if ("op" in t) {
|
|
2177
|
+
if (SEPARATORS.has(t.op)) groups.push([]);
|
|
2178
|
+
else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<" || t.op === "<<") i++;
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
groups[groups.length - 1].push(t.word);
|
|
2182
|
+
}
|
|
2183
|
+
for (const g of groups) {
|
|
2184
|
+
const words = unwrapCommand(g);
|
|
2185
|
+
const verb = words[0]?.split("/").pop() ?? "";
|
|
2186
|
+
if (verb === "mkfs" || verb.startsWith("mkfs.") || verb === "mkswap") out.push(...splitFlags(words.slice(1)).operands.filter(isBlockDevice));
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
return [...new Set(out)];
|
|
2190
|
+
}
|
|
1661
2191
|
function rmRfTargets(command) {
|
|
1662
2192
|
const out = [];
|
|
1663
2193
|
for (const seg of command.split(/&&|\|\||;|\|/)) {
|
|
@@ -1691,7 +2221,14 @@ var destructiveCommand = (a, ctx) => {
|
|
|
1691
2221
|
};
|
|
1692
2222
|
}
|
|
1693
2223
|
}
|
|
1694
|
-
const
|
|
2224
|
+
const scripts = [cmd, ...innerScripts(cmd)];
|
|
2225
|
+
const devices = blockDeviceWrites(scripts);
|
|
2226
|
+
if (devices.length) return {
|
|
2227
|
+
rule: "destructive_command",
|
|
2228
|
+
action: "block",
|
|
2229
|
+
message: `${PREFIX} this command writes to a raw disk or block device (${devices.join(", ")}), which overwrites what is on it. It is blocked. If this is really intended, ask the user to run it themselves.`
|
|
2230
|
+
};
|
|
2231
|
+
const deletions = scripts.flatMap(deletionTargets);
|
|
1695
2232
|
const deletionPaths = deletions.flatMap((d) => d.targets);
|
|
1696
2233
|
const critical = deletionPaths.filter((p) => deletionLocation(p, ctx) === "critical");
|
|
1697
2234
|
if (critical.length) return {
|
|
@@ -1699,7 +2236,7 @@ var destructiveCommand = (a, ctx) => {
|
|
|
1699
2236
|
action: "block",
|
|
1700
2237
|
message: `${PREFIX} this command can delete the home directory, filesystem root, or a parent of this repository (${critical.join(", ")}). It is blocked.`
|
|
1701
2238
|
};
|
|
1702
|
-
const foreignDeletion = deletionPaths.filter((p) => deletionLocation(p, ctx) === "outside" && !matchesAny(
|
|
2239
|
+
const foreignDeletion = deletionPaths.filter((p) => deletionLocation(p, ctx) === "outside" && !matchesAny(resolvedTarget(p, ctx), ctx.contract?.allow ?? []));
|
|
1703
2240
|
if (foreignDeletion.length) return {
|
|
1704
2241
|
rule: "destructive_command",
|
|
1705
2242
|
action: "warn",
|
|
@@ -1719,11 +2256,11 @@ var destructiveCommand = (a, ctx) => {
|
|
|
1719
2256
|
};
|
|
1720
2257
|
}
|
|
1721
2258
|
}
|
|
1722
|
-
for (const targets of rmRfTargets
|
|
2259
|
+
for (const targets of scripts.flatMap(rmRfTargets)) {
|
|
1723
2260
|
const bad = targets.filter((t) => {
|
|
1724
2261
|
const clean = t.replace(/^["']|["']$/g, "");
|
|
1725
|
-
if (/^(\/|~|\$HOME|\*|\.{1,2}\/?)$/.test(clean) || clean.startsWith("~") || clean
|
|
1726
|
-
const { inside, outside } = splitPaths(ctx.repoRoot, [clean]);
|
|
2262
|
+
if (/^(\/|~|\$HOME|\*|\.{1,2}\/?)$/.test(clean) || clean.startsWith("~") || deletionLocation(clean, ctx) === "critical") return true;
|
|
2263
|
+
const { inside, outside } = splitPaths(ctx.repoRoot, [expandTmpdirVar(clean, ctx) ?? clean]);
|
|
1727
2264
|
if (outside.length) return !inScratch(ctx, outside[0]);
|
|
1728
2265
|
return !REGENERABLE.test(inside[0]);
|
|
1729
2266
|
});
|
|
@@ -1743,7 +2280,7 @@ var destructiveCommand = (a, ctx) => {
|
|
|
1743
2280
|
message: `${PREFIX} this command deletes backup files. It is blocked; ask the user before removing backups.`
|
|
1744
2281
|
};
|
|
1745
2282
|
}
|
|
1746
|
-
const verbTargets = MUTATING_VERBS.test(cmd) ? a.outsidePaths : [];
|
|
2283
|
+
const verbTargets = MUTATING_VERBS.test(cmd) ? a.outsidePaths.filter((p) => !p.startsWith("/dev/")) : [];
|
|
1747
2284
|
const foreign = [.../* @__PURE__ */ new Set([...verbTargets, ...writeTargets(a, ctx.repoRoot).outside])].filter((p) => !inScratch(ctx, p) && !DEVICE_SINK.test(p));
|
|
1748
2285
|
if (foreign.length) {
|
|
1749
2286
|
return {
|
|
@@ -2034,7 +2571,7 @@ function parseJudgeResponse(x) {
|
|
|
2034
2571
|
function commandJudge(executable, apiKey) {
|
|
2035
2572
|
return {
|
|
2036
2573
|
name: `command:${executable}`,
|
|
2037
|
-
judge: (req, signal) => new Promise((
|
|
2574
|
+
judge: (req, signal) => new Promise((resolve9, reject) => {
|
|
2038
2575
|
const env = { PATH: process.env.PATH, HOME: process.env.HOME };
|
|
2039
2576
|
if (apiKey) env[ENV.apiKey] = apiKey;
|
|
2040
2577
|
const child = spawn(executable, [], { env, stdio: ["pipe", "pipe", "pipe"], signal });
|
|
@@ -2051,7 +2588,7 @@ function commandJudge(executable, apiKey) {
|
|
|
2051
2588
|
if (code !== 0) return reject(new Error(`judge command exited ${code}: ${err.trim().slice(0, 200)}`));
|
|
2052
2589
|
if (stdinError) return reject(new Error(`judge command stdin failed: ${stdinError.message}`));
|
|
2053
2590
|
try {
|
|
2054
|
-
|
|
2591
|
+
resolve9(parseJudgeResponse(JSON.parse(out)));
|
|
2055
2592
|
} catch (e) {
|
|
2056
2593
|
reject(e);
|
|
2057
2594
|
}
|
|
@@ -2415,12 +2952,12 @@ async function supervise(input, opts) {
|
|
|
2415
2952
|
}
|
|
2416
2953
|
|
|
2417
2954
|
// src/supervisor/done.ts
|
|
2418
|
-
import { existsSync as
|
|
2419
|
-
import { join as
|
|
2955
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
|
|
2956
|
+
import { join as join9 } from "path";
|
|
2420
2957
|
|
|
2421
2958
|
// src/sandbox/scenarios.ts
|
|
2422
2959
|
import { lstat, readdir, readFile, realpath } from "fs/promises";
|
|
2423
|
-
import { join as
|
|
2960
|
+
import { join as join8, relative as relative4, sep as sep2 } from "path";
|
|
2424
2961
|
var required = (ok, file2, field) => {
|
|
2425
2962
|
if (!ok) throw new Error(`${file2}: invalid or missing ${field}`);
|
|
2426
2963
|
};
|
|
@@ -2463,14 +3000,14 @@ async function loadScenarios(dir, root = dir) {
|
|
|
2463
3000
|
if (!inside(await realpath(dir))) throw new Error(`scenario directory ${rel(dir)} resolves outside the commit; ${NOT_REGULAR}`);
|
|
2464
3001
|
const files = (await readdir(dir)).filter((n) => n.endsWith(".yaml") || n.endsWith(".yml")).sort();
|
|
2465
3002
|
for (const n of files) {
|
|
2466
|
-
const p =
|
|
3003
|
+
const p = join8(dir, n);
|
|
2467
3004
|
const st = await lstat(p);
|
|
2468
3005
|
if (st.isSymbolicLink()) throw new Error(`scenario file ${rel(p)} is a symbolic link; ${NOT_REGULAR}`);
|
|
2469
3006
|
if (!st.isFile()) throw new Error(`scenario file ${rel(p)} is not a regular file; ${NOT_REGULAR}`);
|
|
2470
3007
|
if (!inside(await realpath(p))) throw new Error(`scenario file ${rel(p)} resolves outside the commit; ${NOT_REGULAR}`);
|
|
2471
3008
|
}
|
|
2472
3009
|
const scenarios = await Promise.all(files.map(async (n) => {
|
|
2473
|
-
const s = parseScenario(await readFile(
|
|
3010
|
+
const s = parseScenario(await readFile(join8(dir, n), "utf8"), n);
|
|
2474
3011
|
required(n.replace(/\.ya?ml$/, "") === s.id, n, "id (must match filename)");
|
|
2475
3012
|
return s;
|
|
2476
3013
|
}));
|
|
@@ -2552,10 +3089,10 @@ function doneStatus(contract, sessionEvents, repoRoot2) {
|
|
|
2552
3089
|
for (const d of entries) {
|
|
2553
3090
|
if ("run" in d) need(d.run, `\`${d.run}\``, d.run);
|
|
2554
3091
|
else {
|
|
2555
|
-
const file2 =
|
|
3092
|
+
const file2 = join9(".replume", "scenarios", `${d.scenario}.yaml`);
|
|
2556
3093
|
let run = null;
|
|
2557
3094
|
try {
|
|
2558
|
-
if (
|
|
3095
|
+
if (existsSync7(join9(repoRoot2, file2))) run = parseScenario(readFileSync8(join9(repoRoot2, file2), "utf-8"), file2).run;
|
|
2559
3096
|
} catch {
|
|
2560
3097
|
run = null;
|
|
2561
3098
|
}
|
|
@@ -2572,7 +3109,7 @@ function doneMessage(s) {
|
|
|
2572
3109
|
}
|
|
2573
3110
|
|
|
2574
3111
|
// src/agents/claude/payload.ts
|
|
2575
|
-
import { existsSync as
|
|
3112
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
|
|
2576
3113
|
import { isAbsolute as isAbsolute4, resolve as resolve3 } from "path";
|
|
2577
3114
|
function parsePayload(text) {
|
|
2578
3115
|
const x = JSON.parse(text);
|
|
@@ -2585,13 +3122,13 @@ function parsePayload(text) {
|
|
|
2585
3122
|
var str = (v) => typeof v === "string" ? v : "";
|
|
2586
3123
|
function readIfExists(path) {
|
|
2587
3124
|
try {
|
|
2588
|
-
return
|
|
3125
|
+
return existsSync8(path) ? readFileSync9(path, "utf-8") : "";
|
|
2589
3126
|
} catch {
|
|
2590
3127
|
return "";
|
|
2591
3128
|
}
|
|
2592
3129
|
}
|
|
2593
3130
|
function absolutePathsIn(command) {
|
|
2594
|
-
return (command.match(/(?<![\w.:/])(?:\/|~\/)[\w.@+-]+(?:\/[\w.@+-]*)
|
|
3131
|
+
return (command.match(/(?<![\w.:/])(?:\/|~\/)[\w.@+-]+(?:\/[\w.@+-]*)*(?![\w.@+(/-])/g) ?? []).map(
|
|
2595
3132
|
(p) => p.startsWith("~/") ? resolve3(process.env.HOME ?? "/", p.slice(2)) : p
|
|
2596
3133
|
);
|
|
2597
3134
|
}
|
|
@@ -2601,14 +3138,24 @@ function toProposedAction(p, repoRoot2) {
|
|
|
2601
3138
|
let kind = "other";
|
|
2602
3139
|
let rawPaths = [];
|
|
2603
3140
|
let command = null;
|
|
3141
|
+
let writeTargetNotParsed = false;
|
|
3142
|
+
let mentioned = [];
|
|
3143
|
+
let certainRaw = [];
|
|
2604
3144
|
const pending = [];
|
|
2605
|
-
const abs = (fp) =>
|
|
3145
|
+
const abs = (fp) => {
|
|
3146
|
+
const h = expandHome(fp);
|
|
3147
|
+
return isAbsolute4(h) || h.startsWith("~") ? h : resolve3(p.cwd, h);
|
|
3148
|
+
};
|
|
2606
3149
|
switch (tool) {
|
|
2607
3150
|
case "Bash":
|
|
2608
3151
|
case "PowerShell": {
|
|
2609
3152
|
kind = "command";
|
|
2610
3153
|
command = str(input.command);
|
|
2611
|
-
|
|
3154
|
+
const w = tool === "Bash" ? shellWriteTargets(command) : { paths: [], unparsed: false };
|
|
3155
|
+
writeTargetNotParsed = w.unparsed;
|
|
3156
|
+
rawPaths = w.paths.map(abs);
|
|
3157
|
+
certainRaw = "certain" in w ? w.certain.map(abs) : [];
|
|
3158
|
+
mentioned = absolutePathsIn(command);
|
|
2612
3159
|
break;
|
|
2613
3160
|
}
|
|
2614
3161
|
case "Write": {
|
|
@@ -2654,13 +3201,15 @@ function toProposedAction(p, repoRoot2) {
|
|
|
2654
3201
|
break;
|
|
2655
3202
|
}
|
|
2656
3203
|
}
|
|
2657
|
-
const { inside, outside } = splitPaths(repoRoot2, rawPaths.filter(Boolean));
|
|
3204
|
+
const { inside, outside: writtenOutside } = splitPaths(repoRoot2, rawPaths.filter(Boolean));
|
|
3205
|
+
const outside = [.../* @__PURE__ */ new Set([...writtenOutside, ...splitPaths(repoRoot2, mentioned).outside])];
|
|
3206
|
+
const certainPaths = [...new Set(splitPaths(repoRoot2, certainRaw).inside)];
|
|
2658
3207
|
const changes = [];
|
|
2659
3208
|
for (const c of pending) {
|
|
2660
3209
|
const rel = splitPaths(repoRoot2, [c.abs]).inside[0];
|
|
2661
3210
|
if (rel !== void 0) changes.push({ path: rel, before: c.before, after: c.after });
|
|
2662
3211
|
}
|
|
2663
|
-
return { kind, tool, paths: inside, outsidePaths: outside, command, changes, fingerprint: fingerprint(tool, input) };
|
|
3212
|
+
return { kind, tool, paths: [...new Set(inside)], outsidePaths: outside, command, changes, fingerprint: fingerprint(tool, input), ...writeTargetNotParsed ? { writeTargetNotParsed } : {}, ...kind === "command" ? { certainPaths } : {} };
|
|
2664
3213
|
}
|
|
2665
3214
|
|
|
2666
3215
|
// src/agents/claude/handler.ts
|
|
@@ -2673,8 +3222,13 @@ function resolveRepoRoot(p, env = process.env) {
|
|
|
2673
3222
|
return p.cwd;
|
|
2674
3223
|
}
|
|
2675
3224
|
}
|
|
3225
|
+
var WRITE_NOT_PARSED = "write target not parsed";
|
|
3226
|
+
function writeFields(a) {
|
|
3227
|
+
if (a.kind !== "command") return {};
|
|
3228
|
+
return { writes: a.certainPaths ?? a.paths, ...a.writeTargetNotParsed ? { writeNote: WRITE_NOT_PARSED } : {} };
|
|
3229
|
+
}
|
|
2676
3230
|
function summarize(a) {
|
|
2677
|
-
if (a.command !== null) return `${a.tool}: ${a.command}`;
|
|
3231
|
+
if (a.command !== null) return `${a.tool}: ${a.command}${a.writeTargetNotParsed ? ` (${WRITE_NOT_PARSED})` : ""}`;
|
|
2678
3232
|
const targets = [...a.paths, ...a.outsidePaths];
|
|
2679
3233
|
return targets.length ? `${a.tool} ${targets.join(", ")}` : a.tool;
|
|
2680
3234
|
}
|
|
@@ -2762,14 +3316,14 @@ function decide(p, repoRoot2, proposal, sessionEvents, env, deps) {
|
|
|
2762
3316
|
};
|
|
2763
3317
|
const opts = { layer2, judge, sessionEvents, slot };
|
|
2764
3318
|
if (layer2Inactive(opts)) return { result: superviseSync(input, opts), contract };
|
|
2765
|
-
return supervise(input, opts).then((
|
|
3319
|
+
return supervise(input, opts).then((result2) => ({ result: result2, contract }));
|
|
2766
3320
|
}
|
|
2767
3321
|
function preToolUse(p, repoRoot2, env, deps) {
|
|
2768
3322
|
const ledger = ledgerPath(repoRoot2);
|
|
2769
3323
|
const action = toProposedAction(p, repoRoot2);
|
|
2770
3324
|
const session = readEvents(ledger).events.filter((e) => e.sessionId === p.session_id);
|
|
2771
|
-
return andThen(decide(p, repoRoot2, { kind: "action", action }, session, env, deps), ({ result, contract }) => {
|
|
2772
|
-
const deciding =
|
|
3325
|
+
return andThen(decide(p, repoRoot2, { kind: "action", action }, session, env, deps), ({ result: result2, contract }) => {
|
|
3326
|
+
const deciding = result2.findings.find((f) => f.decision === result2.decision);
|
|
2773
3327
|
appendEvent(ledger, {
|
|
2774
3328
|
...base(p, "tool_pre"),
|
|
2775
3329
|
tool: action.tool,
|
|
@@ -2777,21 +3331,22 @@ function preToolUse(p, repoRoot2, env, deps) {
|
|
|
2777
3331
|
command: action.command,
|
|
2778
3332
|
summary: summarize(action),
|
|
2779
3333
|
rule: deciding?.rule ?? null,
|
|
2780
|
-
action: ledgerAction(
|
|
2781
|
-
message:
|
|
3334
|
+
action: ledgerAction(result2.decision),
|
|
3335
|
+
message: result2.message,
|
|
2782
3336
|
raw: {
|
|
2783
3337
|
fingerprint: action.fingerprint,
|
|
2784
|
-
decision:
|
|
2785
|
-
layer:
|
|
2786
|
-
layer2Mode:
|
|
3338
|
+
decision: result2.decision,
|
|
3339
|
+
layer: result2.layer,
|
|
3340
|
+
layer2Mode: result2.layer2.mode,
|
|
2787
3341
|
tool_use_id: p.tool_use_id ?? null,
|
|
2788
|
-
tool_input: p.tool_input ?? null
|
|
3342
|
+
tool_input: p.tool_input ?? null,
|
|
3343
|
+
...writeFields(action)
|
|
2789
3344
|
}
|
|
2790
3345
|
});
|
|
2791
3346
|
const rec = { p, ledger, tool: action.tool, paths: action.paths, command: action.command, target: summarize(action), contractId: contract?.id ?? null };
|
|
2792
|
-
recordFindings(rec,
|
|
2793
|
-
recordLayer2(rec,
|
|
2794
|
-
return { exitCode: 0, stdout: decisionOutput("PreToolUse",
|
|
3347
|
+
recordFindings(rec, result2.findings);
|
|
3348
|
+
recordLayer2(rec, result2.layer2Run, result2.layer2.mode);
|
|
3349
|
+
return { exitCode: 0, stdout: decisionOutput("PreToolUse", result2), stderr: "" };
|
|
2795
3350
|
});
|
|
2796
3351
|
}
|
|
2797
3352
|
var DONE_SLOT_RULE = "done_stop_slot";
|
|
@@ -2819,18 +3374,18 @@ function stop(p, repoRoot2, env, deps) {
|
|
|
2819
3374
|
const session = readEvents(ledger).events.filter((e) => e.sessionId === p.session_id);
|
|
2820
3375
|
const lastAssistantMessage = typeof p.last_assistant_message === "string" ? p.last_assistant_message : "";
|
|
2821
3376
|
const stopHookActive = p.stop_hook_active === true;
|
|
2822
|
-
return andThen(decide(p, repoRoot2, { kind: "stop", lastAssistantMessage, stopHookActive }, session, env, deps), ({ result, contract }) => {
|
|
2823
|
-
const deciding =
|
|
3377
|
+
return andThen(decide(p, repoRoot2, { kind: "stop", lastAssistantMessage, stopHookActive }, session, env, deps), ({ result: result2, contract }) => {
|
|
3378
|
+
const deciding = result2.findings.find((f) => f.decision === result2.decision);
|
|
2824
3379
|
const done = contract?.done?.length && !stopHookActive ? doneStatus(contract, session, repoRoot2) : null;
|
|
2825
3380
|
const cap = contract?.max_stop_blocks ?? DEFAULT_MAX_STOP_BLOCKS;
|
|
2826
3381
|
const unmet = done !== null && done.missing.length > 0;
|
|
2827
3382
|
const slot = unmet ? reserveStopSlot(p, ledger) : null;
|
|
2828
3383
|
const blocksSoFar = slot?.ahead ?? 0;
|
|
2829
3384
|
const blockStop = unmet && blocksSoFar < cap;
|
|
2830
|
-
const steer =
|
|
3385
|
+
const steer = result2.decision === "block" ? { decision: "steer", message: result2.message } : result2;
|
|
2831
3386
|
const doneText = !unmet ? null : blockStop ? `${doneMessage(done)} (stop block ${blocksSoFar + 1} of ${cap})` : `${doneMessage(done)} Replume is not blocking again (max_stop_blocks ${cap} reached).`;
|
|
2832
3387
|
const message = doneText ? [doneText, steer.decision === "ok" ? "" : steer.message ?? ""].filter(Boolean).join("\n") : null;
|
|
2833
|
-
const stopDecision = doneText ? blockStop ? "block" : "steer" :
|
|
3388
|
+
const stopDecision = doneText ? blockStop ? "block" : "steer" : result2.decision;
|
|
2834
3389
|
appendEvent(ledger, {
|
|
2835
3390
|
...base(p, "stop"),
|
|
2836
3391
|
tool: null,
|
|
@@ -2839,19 +3394,19 @@ function stop(p, repoRoot2, env, deps) {
|
|
|
2839
3394
|
summary: stopHookActive ? "agent finished its turn (continuing after stop-hook feedback)" : "agent finished its turn",
|
|
2840
3395
|
rule: doneText ? DONE_RULE : deciding?.rule ?? null,
|
|
2841
3396
|
action: ledgerAction(stopDecision),
|
|
2842
|
-
message: message ??
|
|
3397
|
+
message: message ?? result2.message,
|
|
2843
3398
|
raw: {
|
|
2844
3399
|
decision: stopDecision,
|
|
2845
|
-
layer:
|
|
2846
|
-
layer2Mode:
|
|
3400
|
+
layer: result2.layer,
|
|
3401
|
+
layer2Mode: result2.layer2.mode,
|
|
2847
3402
|
stopHookActive,
|
|
2848
3403
|
lastAssistantMessage: lastAssistantMessage.slice(0, 2e3),
|
|
2849
3404
|
...done ? { done: { checked: done.checked, missing: done.missing, blocked: blockStop } } : {}
|
|
2850
3405
|
}
|
|
2851
3406
|
});
|
|
2852
3407
|
const rec = { p, ledger, tool: null, paths: [], command: null, target: "stop", contractId: contract?.id ?? null };
|
|
2853
|
-
recordFindings(rec,
|
|
2854
|
-
recordLayer2(rec,
|
|
3408
|
+
recordFindings(rec, result2.findings);
|
|
3409
|
+
recordLayer2(rec, result2.layer2Run, result2.layer2.mode);
|
|
2855
3410
|
if (!unmet || !doneText || !message) return { exitCode: 0, stdout: decisionOutput("Stop", steer), stderr: "" };
|
|
2856
3411
|
appendEvent(ledger, {
|
|
2857
3412
|
...base(p, "rule_hit"),
|
|
@@ -2886,6 +3441,7 @@ function postToolUse(p, repoRoot2, failed) {
|
|
|
2886
3441
|
tool_use_id: p.tool_use_id ?? null,
|
|
2887
3442
|
duration_ms: p.duration_ms ?? null,
|
|
2888
3443
|
ok: !failed,
|
|
3444
|
+
...writeFields(action),
|
|
2889
3445
|
...failed ? {
|
|
2890
3446
|
exitCode: exitMatch ? Number(exitMatch[1]) : null,
|
|
2891
3447
|
isInterrupt: p.is_interrupt === true,
|
|
@@ -2913,9 +3469,9 @@ function prompt(p, repoRoot2) {
|
|
|
2913
3469
|
}
|
|
2914
3470
|
function failOpen(err, repoRoot2, env) {
|
|
2915
3471
|
try {
|
|
2916
|
-
const dir =
|
|
3472
|
+
const dir = join10(repoRoot2 ?? env.CLAUDE_PROJECT_DIR ?? process.cwd(), REPLUME_DIR);
|
|
2917
3473
|
mkdirSync6(dir, { recursive: true });
|
|
2918
|
-
appendFileSync3(
|
|
3474
|
+
appendFileSync3(join10(dir, "hook-errors.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${err.stack ?? err}
|
|
2919
3475
|
`);
|
|
2920
3476
|
} catch {
|
|
2921
3477
|
}
|
|
@@ -2950,19 +3506,19 @@ function handleClaudeHook(stdinText, env = process.env, deps = {}) {
|
|
|
2950
3506
|
out = QUIET;
|
|
2951
3507
|
}
|
|
2952
3508
|
const root = repoRoot2;
|
|
2953
|
-
const finish = (
|
|
3509
|
+
const finish = (result2) => {
|
|
2954
3510
|
if ((p.hook_event_name === "Stop" || p.hook_event_name === "SessionEnd") && p.transcript_path) {
|
|
2955
3511
|
const contract = loadContract(contractPath(root));
|
|
2956
3512
|
if (contract && contract.status !== "draft") captureClaudeUsage({
|
|
2957
3513
|
repoRoot: root,
|
|
2958
|
-
allowedRoot:
|
|
3514
|
+
allowedRoot: join10(env.HOME ?? homedir2(), ".claude", "projects"),
|
|
2959
3515
|
transcriptPath: p.transcript_path,
|
|
2960
3516
|
taskId: contract.id,
|
|
2961
3517
|
sessionId: p.session_id,
|
|
2962
3518
|
final: p.hook_event_name === "SessionEnd"
|
|
2963
3519
|
});
|
|
2964
3520
|
}
|
|
2965
|
-
return
|
|
3521
|
+
return result2;
|
|
2966
3522
|
};
|
|
2967
3523
|
return out instanceof Promise ? out.then(finish).catch((err) => failOpen(err, root, env)) : Promise.resolve(finish(out));
|
|
2968
3524
|
} catch (err) {
|
|
@@ -3217,13 +3773,16 @@ function ruleDecisionToHookResponse(hookEventName, decision2) {
|
|
|
3217
3773
|
}
|
|
3218
3774
|
|
|
3219
3775
|
// src/agents/cursor/action.ts
|
|
3220
|
-
import { isAbsolute as isAbsolute6, join as
|
|
3776
|
+
import { isAbsolute as isAbsolute6, join as join11 } from "path";
|
|
3221
3777
|
var str2 = (v) => typeof v === "string" && v.length > 0 ? v : null;
|
|
3222
3778
|
function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
|
|
3223
3779
|
const hook2 = str2(raw3.hook_event_name);
|
|
3224
3780
|
const toolInput = raw3.tool_input && typeof raw3.tool_input === "object" ? raw3.tool_input : {};
|
|
3225
3781
|
const command = str2(raw3.command) && hook2 !== "beforeMCPExecution" ? str2(raw3.command) : str2(raw3.tool_name) === "Shell" ? str2(toolInput.command) : fallback.command ?? null;
|
|
3226
|
-
const abs = (p) =>
|
|
3782
|
+
const abs = (p) => {
|
|
3783
|
+
const h = expandHome(p);
|
|
3784
|
+
return isAbsolute6(h) || h.startsWith("~") ? h : join11(repoRoot2, h);
|
|
3785
|
+
};
|
|
3227
3786
|
const proposedPath = hook2 === "preToolUse" && /write|edit|delete/i.test(str2(raw3.tool_name) ?? "") ? str2(toolInput.file_path) : null;
|
|
3228
3787
|
const files = [str2(raw3.file_path), proposedPath, ...fallback.paths ?? []].filter((p) => !!p).map(abs);
|
|
3229
3788
|
const edits = Array.isArray(raw3.edits) ? raw3.edits : [];
|
|
@@ -3231,7 +3790,8 @@ function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
|
|
|
3231
3790
|
if (command) kind = "command";
|
|
3232
3791
|
else if (edits.length || hook2 === "afterFileEdit" || hook2 === "afterTabFileEdit" || /write|edit|delete/i.test(fallback.tool ?? "")) kind = "edit";
|
|
3233
3792
|
else if (hook2 === "beforeReadFile" || hook2 === "beforeTabFileRead" || /read|grep/i.test(fallback.tool ?? "")) kind = "read";
|
|
3234
|
-
const
|
|
3793
|
+
const w = kind === "command" && command ? shellWriteTargets(command) : null;
|
|
3794
|
+
const { inside, outside } = splitPaths(repoRoot2, w ? w.paths.map(abs) : [...new Set(files)]);
|
|
3235
3795
|
const changes = kind === "edit" && inside.length && edits.length ? [
|
|
3236
3796
|
{
|
|
3237
3797
|
path: inside[0],
|
|
@@ -3247,7 +3807,9 @@ function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
|
|
|
3247
3807
|
outsidePaths: outside,
|
|
3248
3808
|
command,
|
|
3249
3809
|
changes,
|
|
3250
|
-
fingerprint: fingerprint(tool, { files: inside, command, edits })
|
|
3810
|
+
fingerprint: fingerprint(tool, { files: inside, command, edits }),
|
|
3811
|
+
...w?.unparsed ? { writeTargetNotParsed: true } : {},
|
|
3812
|
+
...w ? { certainPaths: [...new Set(splitPaths(repoRoot2, w.certain.map(abs)).inside)] } : {}
|
|
3251
3813
|
};
|
|
3252
3814
|
}
|
|
3253
3815
|
|
|
@@ -3272,12 +3834,14 @@ function handleCursorHook(stdinText, env = process.env) {
|
|
|
3272
3834
|
action: evaluation?.action ?? "ok",
|
|
3273
3835
|
message: evaluation?.message ?? null
|
|
3274
3836
|
};
|
|
3837
|
+
const writes = action.kind === "command" ? { writes: action.certainPaths ?? action.paths, ...action.writeTargetNotParsed ? { writeNote: "write target not parsed" } : {} } : {};
|
|
3275
3838
|
appendEvent(ledger, sanitizeCursorLedgerEvent({
|
|
3276
3839
|
...event,
|
|
3840
|
+
...action.kind === "command" ? { paths: action.paths } : {},
|
|
3277
3841
|
rule: decision2.rule,
|
|
3278
3842
|
action: decision2.action,
|
|
3279
3843
|
message: decision2.message,
|
|
3280
|
-
raw: { ...event.raw, fingerprint: action.fingerprint }
|
|
3844
|
+
raw: { ...event.raw, fingerprint: action.fingerprint, ...writes }
|
|
3281
3845
|
}, root));
|
|
3282
3846
|
for (const hit of evaluation?.hits ?? []) {
|
|
3283
3847
|
appendEvent(ledger, sanitizeCursorLedgerEvent(ruleHitEvent(event, hit, { id: newEventId(), ts: (/* @__PURE__ */ new Date()).toISOString() }), root));
|
|
@@ -3289,10 +3853,221 @@ function handleCursorHook(stdinText, env = process.env) {
|
|
|
3289
3853
|
}
|
|
3290
3854
|
}
|
|
3291
3855
|
|
|
3292
|
-
// src/agents/
|
|
3856
|
+
// src/agents/codex/handler.ts
|
|
3857
|
+
import { execFileSync as execFileSync6 } from "child_process";
|
|
3293
3858
|
import { createHash as createHash2 } from "crypto";
|
|
3294
|
-
import { existsSync as
|
|
3295
|
-
import {
|
|
3859
|
+
import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
|
|
3860
|
+
import { resolve as resolve6 } from "path";
|
|
3861
|
+
|
|
3862
|
+
// src/agents/codex/action.ts
|
|
3863
|
+
import { resolve as resolve5 } from "path";
|
|
3864
|
+
function codexAction(payload, root) {
|
|
3865
|
+
const tool = typeof payload.tool_name === "string" ? payload.tool_name : "unknown";
|
|
3866
|
+
const input = payload.tool_input && typeof payload.tool_input === "object" && !Array.isArray(payload.tool_input) ? payload.tool_input : {};
|
|
3867
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
3868
|
+
if (tool === "Bash") {
|
|
3869
|
+
return toProposedAction({ session_id: "", cwd: String(payload.cwd), hook_event_name: "PreToolUse", tool_name: tool, tool_input: input }, root);
|
|
3870
|
+
}
|
|
3871
|
+
if (tool === "apply_patch") {
|
|
3872
|
+
const rawPaths = [...command.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm)].map((m) => m[1].trim());
|
|
3873
|
+
const { inside, outside } = splitPaths(root, rawPaths.map((p) => resolve5(String(payload.cwd), p)));
|
|
3874
|
+
return {
|
|
3875
|
+
kind: "edit",
|
|
3876
|
+
tool,
|
|
3877
|
+
paths: [...new Set(inside)],
|
|
3878
|
+
outsidePaths: outside,
|
|
3879
|
+
command: null,
|
|
3880
|
+
changes: [],
|
|
3881
|
+
fingerprint: fingerprint(tool, input)
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
return { kind: "other", tool, paths: [], outsidePaths: [], command: null, changes: [], fingerprint: fingerprint(tool, input) };
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
// src/agents/codex/handler.ts
|
|
3888
|
+
var QUIET2 = { stdout: "", stderr: "", exitCode: 0 };
|
|
3889
|
+
var object = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
3890
|
+
var str3 = (x) => typeof x === "string" ? x : "";
|
|
3891
|
+
var result = (stdout) => ({ ...QUIET2, stdout });
|
|
3892
|
+
var fileHash = (path) => {
|
|
3893
|
+
try {
|
|
3894
|
+
return createHash2("sha256").update(readFileSync10(path)).digest("hex");
|
|
3895
|
+
} catch {
|
|
3896
|
+
return null;
|
|
3897
|
+
}
|
|
3898
|
+
};
|
|
3899
|
+
function handleCodexHook(stdinText) {
|
|
3900
|
+
try {
|
|
3901
|
+
if (stdinText.length > 1024 * 1024) throw new Error("oversized hook input");
|
|
3902
|
+
const p = JSON.parse(stdinText);
|
|
3903
|
+
if (!object(p) || !str3(p.session_id) || !str3(p.cwd) || !str3(p.hook_event_name)) throw new Error("invalid hook input");
|
|
3904
|
+
const cwd = resolve6(str3(p.cwd));
|
|
3905
|
+
let root = cwd;
|
|
3906
|
+
try {
|
|
3907
|
+
root = execFileSync6("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
3908
|
+
} catch {
|
|
3909
|
+
}
|
|
3910
|
+
if (!existsSync9(resolve6(root, ".replume"))) return QUIET2;
|
|
3911
|
+
const ledger = ledgerPath(root);
|
|
3912
|
+
const base3 = (type) => ({
|
|
3913
|
+
agent: "codex",
|
|
3914
|
+
agentVersion: null,
|
|
3915
|
+
sessionId: str3(p.session_id),
|
|
3916
|
+
type,
|
|
3917
|
+
tool: null,
|
|
3918
|
+
paths: [],
|
|
3919
|
+
command: null,
|
|
3920
|
+
summary: str3(p.hook_event_name),
|
|
3921
|
+
rule: null,
|
|
3922
|
+
action: null,
|
|
3923
|
+
message: null
|
|
3924
|
+
});
|
|
3925
|
+
const event = str3(p.hook_event_name);
|
|
3926
|
+
if (event === "UserPromptSubmit") {
|
|
3927
|
+
appendEvent(ledger, { ...base3("prompt"), summary: "Codex prompt received" });
|
|
3928
|
+
return QUIET2;
|
|
3929
|
+
}
|
|
3930
|
+
if (event === "Stop" || event === "SessionEnd") {
|
|
3931
|
+
appendEvent(ledger, { ...base3("stop"), summary: `Codex ${event}` });
|
|
3932
|
+
return QUIET2;
|
|
3933
|
+
}
|
|
3934
|
+
if (event !== "PreToolUse" && event !== "PostToolUse") return QUIET2;
|
|
3935
|
+
const a = codexAction(p, root);
|
|
3936
|
+
const raw3 = {
|
|
3937
|
+
tool_use_id: str3(p.tool_use_id),
|
|
3938
|
+
fingerprint: a.fingerprint,
|
|
3939
|
+
...a.kind === "command" ? { writes: a.certainPaths ?? a.paths, ...a.writeTargetNotParsed ? { writeNote: "write target not parsed" } : {} } : {}
|
|
3940
|
+
};
|
|
3941
|
+
const loaded = loadContract(contractPath(root));
|
|
3942
|
+
const contract = isDraft(loaded) ? null : loaded;
|
|
3943
|
+
if (event === "PreToolUse") {
|
|
3944
|
+
const history = readEvents(ledger).events.filter((e) => e.sessionId === str3(p.session_id));
|
|
3945
|
+
const verdict = evaluate(a, { contract, history, repoRoot: root });
|
|
3946
|
+
const first = verdict.hits[0];
|
|
3947
|
+
appendEvent(ledger, {
|
|
3948
|
+
...base3("tool_pre"),
|
|
3949
|
+
tool: a.tool,
|
|
3950
|
+
paths: a.paths,
|
|
3951
|
+
command: a.command,
|
|
3952
|
+
summary: a.command ? `${a.tool}: ${a.command}` : `${a.tool} ${a.paths.join(", ")}`,
|
|
3953
|
+
rule: first?.rule ?? null,
|
|
3954
|
+
action: verdict.action,
|
|
3955
|
+
message: verdict.message,
|
|
3956
|
+
raw: { ...raw3, beforeHashes: a.kind === "edit" ? Object.fromEntries(a.paths.map((path) => [path, fileHash(resolve6(root, path))])) : void 0 }
|
|
3957
|
+
});
|
|
3958
|
+
for (const hit of verdict.hits) appendEvent(ledger, {
|
|
3959
|
+
...base3("rule_hit"),
|
|
3960
|
+
tool: a.tool,
|
|
3961
|
+
paths: a.paths,
|
|
3962
|
+
command: a.command,
|
|
3963
|
+
summary: `${hit.rule}: ${hit.action}`,
|
|
3964
|
+
rule: hit.rule,
|
|
3965
|
+
action: hit.action,
|
|
3966
|
+
message: hit.message,
|
|
3967
|
+
raw: { tool_use_id: str3(p.tool_use_id), layer: "layer1", sentToAgent: true }
|
|
3968
|
+
});
|
|
3969
|
+
if (verdict.action === "block") return result(JSON.stringify({ hookSpecificOutput: {
|
|
3970
|
+
hookEventName: "PreToolUse",
|
|
3971
|
+
permissionDecision: "deny",
|
|
3972
|
+
permissionDecisionReason: verdict.message
|
|
3973
|
+
} }));
|
|
3974
|
+
if (verdict.action === "warn") return result(JSON.stringify({ hookSpecificOutput: {
|
|
3975
|
+
hookEventName: "PreToolUse",
|
|
3976
|
+
additionalContext: verdict.message
|
|
3977
|
+
} }));
|
|
3978
|
+
return QUIET2;
|
|
3979
|
+
}
|
|
3980
|
+
const response = object(p.tool_response) ? p.tool_response : {};
|
|
3981
|
+
const responseText = str3(p.tool_response);
|
|
3982
|
+
const exit = typeof response.exit_code === "number" ? response.exit_code : typeof response.exitCode === "number" ? response.exitCode : /^Exit code: (\d+)\b/m.exec(responseText)?.[1];
|
|
3983
|
+
const success = exit === 0 || exit === "0";
|
|
3984
|
+
const prior = readEvents(ledger).events.reverse().find((e) => e.type === "tool_pre" && e.raw?.tool_use_id === str3(p.tool_use_id));
|
|
3985
|
+
const before = prior?.raw?.beforeHashes;
|
|
3986
|
+
const changed = a.paths.filter((path) => before && path in before && fileHash(resolve6(root, path)) !== before[path]);
|
|
3987
|
+
appendEvent(ledger, {
|
|
3988
|
+
...base3("tool_post"),
|
|
3989
|
+
tool: a.tool,
|
|
3990
|
+
paths: a.paths,
|
|
3991
|
+
command: a.command,
|
|
3992
|
+
summary: `${a.tool} completed`,
|
|
3993
|
+
raw: { ...raw3, ok: success }
|
|
3994
|
+
});
|
|
3995
|
+
if (a.kind === "edit" && changed.length && (success || responseText.includes("Success."))) {
|
|
3996
|
+
for (const path of changed)
|
|
3997
|
+
appendEvent(ledger, {
|
|
3998
|
+
...base3("file_edit"),
|
|
3999
|
+
tool: a.tool,
|
|
4000
|
+
paths: [path],
|
|
4001
|
+
summary: `${a.tool} changed ${path}`,
|
|
4002
|
+
action: "ok",
|
|
4003
|
+
raw: raw3
|
|
4004
|
+
});
|
|
4005
|
+
} else if (a.kind === "command") {
|
|
4006
|
+
appendEvent(ledger, {
|
|
4007
|
+
...base3("command"),
|
|
4008
|
+
tool: a.tool,
|
|
4009
|
+
paths: a.paths,
|
|
4010
|
+
command: a.command,
|
|
4011
|
+
summary: `${a.tool}: ${a.command}`,
|
|
4012
|
+
raw: { ...raw3, ok: success }
|
|
4013
|
+
});
|
|
4014
|
+
}
|
|
4015
|
+
return QUIET2;
|
|
4016
|
+
} catch {
|
|
4017
|
+
return { stdout: "", stderr: "replume codex hook: failed to process payload\n", exitCode: 0 };
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
// src/agents/codex/install.ts
|
|
4022
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
|
|
4023
|
+
import { dirname as dirname5, join as join12 } from "path";
|
|
4024
|
+
var CODEX_HOOK_COMMAND = "replume hook codex";
|
|
4025
|
+
var CODEX_HOOK_EVENTS = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SessionEnd"];
|
|
4026
|
+
var codexHooksPath = (root) => join12(root, ".codex", "hooks.json");
|
|
4027
|
+
var obj2 = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
4028
|
+
function mergeCodexHooks(config, command = CODEX_HOOK_COMMAND) {
|
|
4029
|
+
if (config.hooks !== void 0 && !obj2(config.hooks)) throw new Error("hooks must be an object");
|
|
4030
|
+
const hooks = { ...config.hooks ?? {} };
|
|
4031
|
+
const added = [];
|
|
4032
|
+
for (const event of CODEX_HOOK_EVENTS) {
|
|
4033
|
+
const groups = hooks[event] ?? [];
|
|
4034
|
+
if (!Array.isArray(groups)) throw new Error(`hooks.${event} must be an array`);
|
|
4035
|
+
if (groups.some((g) => obj2(g) && Array.isArray(g.hooks) && g.hooks.some((h) => obj2(h) && h.command === command))) continue;
|
|
4036
|
+
const entry = { hooks: [{ type: "command", command, timeout: event === "SessionEnd" ? 2 : 10 }] };
|
|
4037
|
+
hooks[event] = [...groups, event === "PreToolUse" || event === "PostToolUse" ? { matcher: "^(Bash|apply_patch)$", ...entry } : entry];
|
|
4038
|
+
added.push(event);
|
|
4039
|
+
}
|
|
4040
|
+
return { config: { ...config, hooks }, added };
|
|
4041
|
+
}
|
|
4042
|
+
function validateCodexHooks(root) {
|
|
4043
|
+
const path = codexHooksPath(root);
|
|
4044
|
+
if (!existsSync10(path)) return;
|
|
4045
|
+
try {
|
|
4046
|
+
const parsed = JSON.parse(readFileSync11(path, "utf8"));
|
|
4047
|
+
if (!obj2(parsed)) throw new Error("expected an object");
|
|
4048
|
+
mergeCodexHooks(parsed);
|
|
4049
|
+
} catch (e) {
|
|
4050
|
+
throw new Error(`${path} is invalid (${e.message}); nothing was changed. Fix it and run replume init again.`);
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
function installCodexHooks(root) {
|
|
4054
|
+
validateCodexHooks(root);
|
|
4055
|
+
const path = codexHooksPath(root);
|
|
4056
|
+
const original = existsSync10(path) ? readFileSync11(path, "utf8") : null;
|
|
4057
|
+
const parsed = original ? JSON.parse(original) : {};
|
|
4058
|
+
const { config, added } = mergeCodexHooks(parsed);
|
|
4059
|
+
if (added.length) {
|
|
4060
|
+
mkdirSync7(dirname5(path), { recursive: true });
|
|
4061
|
+
if (original !== null && !existsSync10(`${path}.replume-backup`)) copyFileSync2(path, `${path}.replume-backup`);
|
|
4062
|
+
writeFileSync5(path, JSON.stringify(config, null, 2) + "\n");
|
|
4063
|
+
}
|
|
4064
|
+
return { path, added, backupPath: original === null ? null : `${path}.replume-backup` };
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
// src/agents/cursor/install.ts
|
|
4068
|
+
import { createHash as createHash3 } from "crypto";
|
|
4069
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync12, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
4070
|
+
import { dirname as dirname6, join as join13 } from "path";
|
|
3296
4071
|
var CURSOR_HOOK_COMMAND = "replume hook cursor";
|
|
3297
4072
|
var CURSOR_HOOK_EVENTS = [
|
|
3298
4073
|
"preToolUse",
|
|
@@ -3306,9 +4081,9 @@ var CURSOR_HOOK_EVENTS = [
|
|
|
3306
4081
|
"stop",
|
|
3307
4082
|
"sessionEnd"
|
|
3308
4083
|
];
|
|
3309
|
-
var hash = (text) =>
|
|
4084
|
+
var hash = (text) => createHash3("sha256").update(text).digest("hex");
|
|
3310
4085
|
var isObject = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
3311
|
-
var cursorHooksPath = (root) =>
|
|
4086
|
+
var cursorHooksPath = (root) => join13(root, ".cursor", "hooks.json");
|
|
3312
4087
|
var backupPathFor = (path) => `${path}.replume-backup`;
|
|
3313
4088
|
var statePathFor = (path) => `${path}.replume-state.json`;
|
|
3314
4089
|
function parseConfig(text, path) {
|
|
@@ -3332,17 +4107,17 @@ function exactInstalledEntry(x, command) {
|
|
|
3332
4107
|
function validateCursorHooksConfig(root, opts = {}) {
|
|
3333
4108
|
const path = cursorHooksPath(root);
|
|
3334
4109
|
const command = opts.command ?? CURSOR_HOOK_COMMAND;
|
|
3335
|
-
const config =
|
|
4110
|
+
const config = existsSync11(path) ? parseConfig(readFileSync12(path, "utf8"), path) : {};
|
|
3336
4111
|
const hooks = config.hooks ?? {};
|
|
3337
4112
|
for (const event of CURSOR_HOOK_EVENTS) {
|
|
3338
4113
|
if (hooks[event] !== void 0 && !Array.isArray(hooks[event]))
|
|
3339
4114
|
throw new Error(`${path}.hooks.${event} is not an array; nothing was changed. Fix it and run replume init again.`);
|
|
3340
4115
|
}
|
|
3341
4116
|
const statePath = statePathFor(path);
|
|
3342
|
-
if (
|
|
4117
|
+
if (existsSync11(statePath)) {
|
|
3343
4118
|
let state;
|
|
3344
4119
|
try {
|
|
3345
|
-
state = JSON.parse(
|
|
4120
|
+
state = JSON.parse(readFileSync12(statePath, "utf8"));
|
|
3346
4121
|
} catch {
|
|
3347
4122
|
throw new Error(`${statePath} is not valid JSON; nothing was changed. Fix it and run replume init again.`);
|
|
3348
4123
|
}
|
|
@@ -3354,9 +4129,9 @@ function installCursorHooks(root, opts = {}) {
|
|
|
3354
4129
|
const path = cursorHooksPath(root);
|
|
3355
4130
|
const backupPath = backupPathFor(path);
|
|
3356
4131
|
const statePath = statePathFor(path);
|
|
3357
|
-
const hadOriginal =
|
|
4132
|
+
const hadOriginal = existsSync11(path);
|
|
3358
4133
|
const command = opts.command ?? CURSOR_HOOK_COMMAND;
|
|
3359
|
-
const original = hadOriginal ?
|
|
4134
|
+
const original = hadOriginal ? readFileSync12(path, "utf8") : null;
|
|
3360
4135
|
const config = original === null ? {} : parseConfig(original, path);
|
|
3361
4136
|
const hooks = { ...config.hooks ?? {} };
|
|
3362
4137
|
const added = [];
|
|
@@ -3368,36 +4143,36 @@ function installCursorHooks(root, opts = {}) {
|
|
|
3368
4143
|
added.push(event);
|
|
3369
4144
|
}
|
|
3370
4145
|
}
|
|
3371
|
-
if (!added.length) return { path, backupPath:
|
|
4146
|
+
if (!added.length) return { path, backupPath: existsSync11(backupPath) ? backupPath : null, added };
|
|
3372
4147
|
const updated = JSON.stringify({ ...config, version: 1, hooks }, null, 2) + "\n";
|
|
3373
|
-
|
|
3374
|
-
if (!
|
|
3375
|
-
if (original !== null)
|
|
3376
|
-
|
|
4148
|
+
mkdirSync8(dirname6(path), { recursive: true });
|
|
4149
|
+
if (!existsSync11(statePath)) {
|
|
4150
|
+
if (original !== null) writeFileSync6(backupPath, original, { flag: "wx" });
|
|
4151
|
+
writeFileSync6(statePath, JSON.stringify({ hadOriginal, installedHash: hash(updated), added, command }) + "\n", { flag: "wx" });
|
|
3377
4152
|
} else {
|
|
3378
|
-
const state = JSON.parse(
|
|
4153
|
+
const state = JSON.parse(readFileSync12(statePath, "utf8"));
|
|
3379
4154
|
if (state.command !== command) throw new Error(`Cursor hooks already installed with a different command; uninstall first`);
|
|
3380
4155
|
state.added = [.../* @__PURE__ */ new Set([...state.added, ...added])];
|
|
3381
4156
|
state.installedHash = hash(updated);
|
|
3382
|
-
|
|
4157
|
+
writeFileSync6(statePath, JSON.stringify(state) + "\n");
|
|
3383
4158
|
}
|
|
3384
4159
|
const temp = `${path}.replume-tmp`;
|
|
3385
|
-
|
|
4160
|
+
writeFileSync6(temp, updated);
|
|
3386
4161
|
renameSync(temp, path);
|
|
3387
|
-
return { path, backupPath:
|
|
4162
|
+
return { path, backupPath: existsSync11(backupPath) ? backupPath : null, added };
|
|
3388
4163
|
}
|
|
3389
4164
|
function uninstallCursorHooks(root) {
|
|
3390
4165
|
const path = cursorHooksPath(root);
|
|
3391
4166
|
const backupPath = backupPathFor(path);
|
|
3392
4167
|
const statePath = statePathFor(path);
|
|
3393
|
-
if (!
|
|
3394
|
-
const state = JSON.parse(
|
|
3395
|
-
const current =
|
|
4168
|
+
if (!existsSync11(statePath)) return { restored: false, path };
|
|
4169
|
+
const state = JSON.parse(readFileSync12(statePath, "utf8"));
|
|
4170
|
+
const current = existsSync11(path) ? readFileSync12(path, "utf8") : null;
|
|
3396
4171
|
const unchanged = current !== null && hash(current) === state.installedHash;
|
|
3397
4172
|
if (unchanged) {
|
|
3398
4173
|
if (state.hadOriginal) {
|
|
3399
|
-
if (!
|
|
3400
|
-
|
|
4174
|
+
if (!existsSync11(backupPath)) throw new Error(`Cursor hook backup missing: ${backupPath}`);
|
|
4175
|
+
writeFileSync6(path, readFileSync12(backupPath));
|
|
3401
4176
|
} else rmSync2(path);
|
|
3402
4177
|
} else if (current !== null) {
|
|
3403
4178
|
const config = parseConfig(current, path);
|
|
@@ -3413,20 +4188,20 @@ function uninstallCursorHooks(root) {
|
|
|
3413
4188
|
if (kept.length) hooks[event] = kept;
|
|
3414
4189
|
else delete hooks[event];
|
|
3415
4190
|
}
|
|
3416
|
-
|
|
4191
|
+
writeFileSync6(path, JSON.stringify({ ...config, hooks }, null, 2) + "\n");
|
|
3417
4192
|
}
|
|
3418
4193
|
rmSync2(statePath);
|
|
3419
4194
|
if (unchanged) rmSync2(backupPath, { force: true });
|
|
3420
|
-
return { restored: true, path, backupRetained: !unchanged &&
|
|
4195
|
+
return { restored: true, path, backupRetained: !unchanged && existsSync11(backupPath) };
|
|
3421
4196
|
}
|
|
3422
4197
|
|
|
3423
4198
|
// src/agents/claude/install.ts
|
|
3424
|
-
import { copyFileSync as
|
|
3425
|
-
import { dirname as
|
|
4199
|
+
import { copyFileSync as copyFileSync3, existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "fs";
|
|
4200
|
+
import { dirname as dirname7, join as join14 } from "path";
|
|
3426
4201
|
var DEFAULT_HOOK_COMMAND = "replume hook claude";
|
|
3427
4202
|
var HOOK_EVENTS = ["PreToolUse", "PostToolUse", "PostToolUseFailure", "UserPromptSubmit", "Stop", "SessionEnd"];
|
|
3428
4203
|
function claudeSettingsPath(repoRoot2) {
|
|
3429
|
-
return
|
|
4204
|
+
return join14(repoRoot2, ".claude", "settings.json");
|
|
3430
4205
|
}
|
|
3431
4206
|
var isReplume = (h, command) => typeof h.command === "string" && (h.command === command || /(^|\s)hook claude\s*$/.test(h.command));
|
|
3432
4207
|
function mergeHooks(settings, command = DEFAULT_HOOK_COMMAND) {
|
|
@@ -3455,8 +4230,8 @@ function installClaudeHooks(repoRoot2, opts = {}) {
|
|
|
3455
4230
|
const settingsPath = claudeSettingsPath(repoRoot2);
|
|
3456
4231
|
let current = {};
|
|
3457
4232
|
let original = null;
|
|
3458
|
-
if (
|
|
3459
|
-
original =
|
|
4233
|
+
if (existsSync12(settingsPath)) {
|
|
4234
|
+
original = readFileSync13(settingsPath, "utf-8");
|
|
3460
4235
|
try {
|
|
3461
4236
|
const parsed = original.trim() ? JSON.parse(original) : {};
|
|
3462
4237
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not a JSON object");
|
|
@@ -3471,24 +4246,24 @@ function installClaudeHooks(repoRoot2, opts = {}) {
|
|
|
3471
4246
|
if (original !== null) {
|
|
3472
4247
|
const stamp = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3473
4248
|
backupPath = `${settingsPath}.replume-backup-${stamp}`;
|
|
3474
|
-
|
|
4249
|
+
copyFileSync3(settingsPath, backupPath);
|
|
3475
4250
|
}
|
|
3476
|
-
|
|
3477
|
-
|
|
4251
|
+
mkdirSync9(dirname7(settingsPath), { recursive: true });
|
|
4252
|
+
writeFileSync7(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
3478
4253
|
}
|
|
3479
4254
|
return { settingsPath, backupPath, added, alreadyPresent };
|
|
3480
4255
|
}
|
|
3481
4256
|
|
|
3482
4257
|
// src/agents/claude/replay-hooks.ts
|
|
3483
|
-
import { mkdtempSync, mkdirSync as
|
|
4258
|
+
import { mkdtempSync, mkdirSync as mkdirSync10, rmSync as rmSync3, writeFileSync as writeFileSync8 } from "fs";
|
|
3484
4259
|
import { tmpdir as tmpdir2 } from "os";
|
|
3485
|
-
import { join as
|
|
4260
|
+
import { join as join15 } from "path";
|
|
3486
4261
|
var REPLAY_HOOKS_BANNER = "HOOK PAYLOAD REPLAY. Each line is a Claude Code hook payload run through Replume's live hook handler in a temporary project.\nThis shows Replume's answers to these payloads; it does not show a live agent receiving or following them.";
|
|
3487
4262
|
async function replayHooks(content, opts = {}) {
|
|
3488
|
-
const repo = mkdtempSync(
|
|
4263
|
+
const repo = mkdtempSync(join15(tmpdir2(), "replume-replay-hooks-"));
|
|
3489
4264
|
try {
|
|
3490
|
-
|
|
3491
|
-
if (opts.contractYaml)
|
|
4265
|
+
mkdirSync10(join15(repo, ".replume"), { recursive: true });
|
|
4266
|
+
if (opts.contractYaml) writeFileSync8(contractPath(repo), opts.contractYaml);
|
|
3492
4267
|
const env = { REPLUME_JUDGE: "off", ...opts.env, CLAUDE_PROJECT_DIR: repo };
|
|
3493
4268
|
const steps = [];
|
|
3494
4269
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
@@ -3518,10 +4293,10 @@ async function replayHooks(content, opts = {}) {
|
|
|
3518
4293
|
}
|
|
3519
4294
|
|
|
3520
4295
|
// src/core/init.ts
|
|
3521
|
-
import { execFileSync as
|
|
3522
|
-
import { existsSync as
|
|
4296
|
+
import { execFileSync as execFileSync8 } from "child_process";
|
|
4297
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync11, writeFileSync as writeFileSync10 } from "fs";
|
|
3523
4298
|
import { homedir as homedir3 } from "os";
|
|
3524
|
-
import { join as
|
|
4299
|
+
import { delimiter, join as join17 } from "path";
|
|
3525
4300
|
|
|
3526
4301
|
// src/core/capabilities.ts
|
|
3527
4302
|
var CAPABILITIES = [
|
|
@@ -3543,11 +4318,11 @@ var CAPABILITIES = [
|
|
|
3543
4318
|
},
|
|
3544
4319
|
{
|
|
3545
4320
|
agent: "Codex",
|
|
3546
|
-
observe: "
|
|
3547
|
-
block: "
|
|
3548
|
-
steer: "
|
|
4321
|
+
observe: "verified live (CLI)",
|
|
4322
|
+
block: "verified live (CLI)",
|
|
4323
|
+
steer: "documented",
|
|
3549
4324
|
installedByInit: false,
|
|
3550
|
-
notes: "
|
|
4325
|
+
notes: "Codex CLI 0.144.4: project hooks delivered PreToolUse and PostToolUse with trust enabled; Replume blocked one protected apply_patch and observed one allowed edit leading to a no-scenario PASS on 20.10.2026. Warning delivery and Stop continuation not tested; hook trust and disabled hooks limit capture; cloud project hooks unsupported."
|
|
3551
4326
|
}
|
|
3552
4327
|
];
|
|
3553
4328
|
function renderCapabilities(caps = CAPABILITIES, detected = {}) {
|
|
@@ -3576,10 +4351,10 @@ function renderCapabilities(caps = CAPABILITIES, detected = {}) {
|
|
|
3576
4351
|
}
|
|
3577
4352
|
|
|
3578
4353
|
// src/core/baseline.ts
|
|
3579
|
-
import { createHash as
|
|
3580
|
-
import { execFileSync as
|
|
3581
|
-
import { appendFileSync as appendFileSync4, existsSync as
|
|
3582
|
-
import { dirname as
|
|
4354
|
+
import { createHash as createHash4 } from "crypto";
|
|
4355
|
+
import { execFileSync as execFileSync7 } from "child_process";
|
|
4356
|
+
import { appendFileSync as appendFileSync4, existsSync as existsSync13, lstatSync, readFileSync as readFileSync14, readdirSync, readlinkSync, writeFileSync as writeFileSync9 } from "fs";
|
|
4357
|
+
import { dirname as dirname8, join as join16 } from "path";
|
|
3583
4358
|
var BASELINE_FILE = "baseline.json";
|
|
3584
4359
|
var DEFAULT_REPLUME_GITIGNORE = `events.jsonl
|
|
3585
4360
|
reports/
|
|
@@ -3591,22 +4366,22 @@ acceptance.jsonl
|
|
|
3591
4366
|
`;
|
|
3592
4367
|
var SCENARIOS_SUBDIR = "scenarios";
|
|
3593
4368
|
var PREFLIGHT_STATUS = ["works", "fails_before", "not_runnable", "skipped"];
|
|
3594
|
-
var sha256 = (data) =>
|
|
4369
|
+
var sha256 = (data) => createHash4("sha256").update(data).digest("hex");
|
|
3595
4370
|
var IGNORED_NAMES = /* @__PURE__ */ new Set([".DS_Store"]);
|
|
3596
4371
|
function hashTree(dir) {
|
|
3597
4372
|
const out = {};
|
|
3598
4373
|
const walk = (abs, rel) => {
|
|
3599
4374
|
for (const name of readdirSync(abs).sort()) {
|
|
3600
4375
|
if (IGNORED_NAMES.has(name)) continue;
|
|
3601
|
-
const p =
|
|
4376
|
+
const p = join16(abs, name);
|
|
3602
4377
|
const r = rel ? `${rel}/${name}` : name;
|
|
3603
4378
|
const st = lstatSync(p);
|
|
3604
4379
|
if (st.isSymbolicLink()) out[r] = sha256(`symlink:${readlinkSync(p)}`);
|
|
3605
4380
|
else if (st.isDirectory()) walk(p, r);
|
|
3606
|
-
else if (st.isFile()) out[r] = sha256(
|
|
4381
|
+
else if (st.isFile()) out[r] = sha256(readFileSync14(p));
|
|
3607
4382
|
}
|
|
3608
4383
|
};
|
|
3609
|
-
if (
|
|
4384
|
+
if (existsSync13(dir)) {
|
|
3610
4385
|
try {
|
|
3611
4386
|
if (lstatSync(dir).isDirectory()) walk(dir, "");
|
|
3612
4387
|
} catch {
|
|
@@ -3616,19 +4391,19 @@ function hashTree(dir) {
|
|
|
3616
4391
|
}
|
|
3617
4392
|
function headCommit(repoRoot2) {
|
|
3618
4393
|
try {
|
|
3619
|
-
return
|
|
4394
|
+
return execFileSync7("git", ["rev-parse", "--verify", "-q", "HEAD^{commit}"], { cwd: repoRoot2, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
3620
4395
|
} catch {
|
|
3621
4396
|
return null;
|
|
3622
4397
|
}
|
|
3623
4398
|
}
|
|
3624
|
-
var baselinePath = (contractPath2) =>
|
|
4399
|
+
var baselinePath = (contractPath2) => join16(dirname8(contractPath2), BASELINE_FILE);
|
|
3625
4400
|
function ensureBaselineIgnored(replumeDir) {
|
|
3626
|
-
const gi =
|
|
3627
|
-
if (!
|
|
3628
|
-
|
|
4401
|
+
const gi = join16(replumeDir, ".gitignore");
|
|
4402
|
+
if (!existsSync13(gi)) {
|
|
4403
|
+
writeFileSync9(gi, DEFAULT_REPLUME_GITIGNORE);
|
|
3629
4404
|
return;
|
|
3630
4405
|
}
|
|
3631
|
-
const text =
|
|
4406
|
+
const text = readFileSync14(gi, "utf-8");
|
|
3632
4407
|
if (text.split(/\r?\n/).some((l) => l.trim() === BASELINE_FILE)) return;
|
|
3633
4408
|
appendFileSync4(gi, `${text === "" || text.endsWith("\n") ? "" : "\n"}${BASELINE_FILE}
|
|
3634
4409
|
`);
|
|
@@ -3640,12 +4415,12 @@ function recordBaseline(opts) {
|
|
|
3640
4415
|
contract_id: opts.contractId,
|
|
3641
4416
|
recorded_at: (opts.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
3642
4417
|
base_commit: headCommit(opts.repoRoot),
|
|
3643
|
-
policy_hash: sha256(
|
|
3644
|
-
scenario_hashes: hashTree(opts.scenariosDir ??
|
|
4418
|
+
policy_hash: sha256(readFileSync14(opts.contractPath)),
|
|
4419
|
+
scenario_hashes: hashTree(opts.scenariosDir ?? join16(dirname8(opts.contractPath), SCENARIOS_SUBDIR)),
|
|
3645
4420
|
...opts.preflight ? { preflight: opts.preflight } : {}
|
|
3646
4421
|
};
|
|
3647
|
-
|
|
3648
|
-
ensureBaselineIgnored(
|
|
4422
|
+
writeFileSync9(path, JSON.stringify(baseline, null, 2) + "\n", "utf-8");
|
|
4423
|
+
ensureBaselineIgnored(dirname8(opts.contractPath));
|
|
3649
4424
|
return { path, baseline };
|
|
3650
4425
|
}
|
|
3651
4426
|
var isHash = (x) => typeof x === "string" && /^[0-9a-f]{64}$/.test(x);
|
|
@@ -3666,10 +4441,10 @@ function validateBaseline(x) {
|
|
|
3666
4441
|
return p;
|
|
3667
4442
|
}
|
|
3668
4443
|
function readBaseline(path) {
|
|
3669
|
-
if (!
|
|
4444
|
+
if (!existsSync13(path)) return { status: "absent" };
|
|
3670
4445
|
let raw3;
|
|
3671
4446
|
try {
|
|
3672
|
-
raw3 = JSON.parse(
|
|
4447
|
+
raw3 = JSON.parse(readFileSync14(path, "utf-8"));
|
|
3673
4448
|
} catch (e) {
|
|
3674
4449
|
return { status: "unreadable", detail: e.message };
|
|
3675
4450
|
}
|
|
@@ -3703,26 +4478,39 @@ function compareToBaseline(baseline, current, displayPaths) {
|
|
|
3703
4478
|
// src/core/init.ts
|
|
3704
4479
|
function onPath(bin) {
|
|
3705
4480
|
try {
|
|
3706
|
-
|
|
4481
|
+
execFileSync8(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
|
|
3707
4482
|
return true;
|
|
3708
4483
|
} catch {
|
|
3709
4484
|
return false;
|
|
3710
4485
|
}
|
|
3711
4486
|
}
|
|
4487
|
+
function launchedThroughNpx() {
|
|
4488
|
+
return process.env.npm_command === "exec" || (process.env.PATH ?? "").split(delimiter).some((entry) => entry.split(/[\\/]/).includes("_npx"));
|
|
4489
|
+
}
|
|
4490
|
+
function gitIdentity(repoRoot2, key) {
|
|
4491
|
+
try {
|
|
4492
|
+
return execFileSync8("git", ["-C", repoRoot2, "config", "--get", key], {
|
|
4493
|
+
encoding: "utf8",
|
|
4494
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
4495
|
+
}).trim().length > 0;
|
|
4496
|
+
} catch {
|
|
4497
|
+
return false;
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
3712
4500
|
function detectAgents(repoRoot2, home = homedir3()) {
|
|
3713
4501
|
return {
|
|
3714
|
-
"Claude Code": onPath("claude") ||
|
|
3715
|
-
Cursor: onPath("cursor") ||
|
|
3716
|
-
Codex: onPath("codex") ||
|
|
4502
|
+
"Claude Code": onPath("claude") || existsSync14(join17(repoRoot2, ".claude")) || existsSync14(join17(home, ".claude")),
|
|
4503
|
+
Cursor: onPath("cursor") || existsSync14(join17(repoRoot2, ".cursor")) || existsSync14(join17(home, ".cursor")),
|
|
4504
|
+
Codex: onPath("codex") || existsSync14(join17(home, ".codex"))
|
|
3717
4505
|
};
|
|
3718
4506
|
}
|
|
3719
4507
|
function runInit(repoRoot2, opts = {}) {
|
|
3720
4508
|
const lines = [];
|
|
3721
|
-
const dir =
|
|
3722
|
-
|
|
3723
|
-
const gi =
|
|
3724
|
-
if (!
|
|
3725
|
-
|
|
4509
|
+
const dir = join17(repoRoot2, REPLUME_DIR);
|
|
4510
|
+
mkdirSync11(join17(dir, "scenarios"), { recursive: true });
|
|
4511
|
+
const gi = join17(dir, ".gitignore");
|
|
4512
|
+
if (!existsSync14(gi)) {
|
|
4513
|
+
writeFileSync10(gi, DEFAULT_REPLUME_GITIGNORE);
|
|
3726
4514
|
}
|
|
3727
4515
|
lines.push(`\u2713 ${REPLUME_DIR}/ ready (scenarios/ for team scenarios; ledger and reports are git-ignored)`);
|
|
3728
4516
|
const detected = opts.detected ?? detectAgents(repoRoot2);
|
|
@@ -3734,11 +4522,14 @@ function runInit(repoRoot2, opts = {}) {
|
|
|
3734
4522
|
lines.push(`\u2713 Claude Code hooks added to .claude/settings.json: ${claude.added.join(", ")}`);
|
|
3735
4523
|
if (claude.backupPath) lines.push(` backup of your previous settings: ${claude.backupPath}`);
|
|
3736
4524
|
} else lines.push("\u2713 Claude Code hooks already present (nothing changed)");
|
|
3737
|
-
if (!opts.hookCommand && !onPath("replume")) {
|
|
3738
|
-
lines.push("! `replume` is not on PATH, so the hooks will fail (Claude Code then continues without them).");
|
|
3739
|
-
lines.push(" Install the published command with `npm i -g replume-cli`, then re-run `replume init`.");
|
|
3740
|
-
}
|
|
3741
4525
|
} else lines.push("- Claude Code not found: no hooks written");
|
|
4526
|
+
const npx = launchedThroughNpx();
|
|
4527
|
+
const persistentCommand = !npx && onPath("replume");
|
|
4528
|
+
const defaultHooks = detected["Claude Code"] && !opts.hookCommand || opts.cursorHooks && !opts.cursorHookCommand || opts.codexHooks;
|
|
4529
|
+
if (defaultHooks && !persistentCommand) {
|
|
4530
|
+
lines.push(npx ? "! `replume` is not guaranteed on PATH for later agent hooks: npx's temporary PATH entry does not persist, so those hooks may fail." : "! `replume` is not on PATH, so the hooks will fail (the agent then continues without them).");
|
|
4531
|
+
lines.push(" Install the persistent command with `npm install -g replume-cli`, then re-run `replume init`.");
|
|
4532
|
+
}
|
|
3742
4533
|
for (const c of CAPABILITIES.filter((x) => !x.installedByInit)) {
|
|
3743
4534
|
if (detected[c.agent]) lines.push(`- ${c.agent} found, but replume init does not install its hooks yet (${c.observe})`);
|
|
3744
4535
|
}
|
|
@@ -3746,15 +4537,18 @@ function runInit(repoRoot2, opts = {}) {
|
|
|
3746
4537
|
lines.push("Capability matrix");
|
|
3747
4538
|
lines.push(renderCapabilities(CAPABILITIES, detected));
|
|
3748
4539
|
lines.push("");
|
|
3749
|
-
|
|
3750
|
-
lines.push(
|
|
4540
|
+
const draftCommand = persistentCommand ? "replume draft" : "npm install -g replume-cli && replume draft";
|
|
4541
|
+
lines.push(`Next: ${draftCommand} --task "<what you asked the agent>"; review .replume/contract.yaml, then run \`replume confirm\`.`);
|
|
4542
|
+
lines.push(" Scenarios are optional (D-062): verify can PASS when the contract's test_command runs at least one test and passes, nothing is EXTRA or TAMPERING, and the hooks record the agent changing each file. A scenario in .replume/scenarios/ adds a stated behaviour check.");
|
|
4543
|
+
if (!gitIdentity(repoRoot2, "user.name")) lines.push('To make commits, set Git user.name: `git config user.name "Your Name"`.');
|
|
4544
|
+
if (!gitIdentity(repoRoot2, "user.email")) lines.push('To make commits, set Git user.email: `git config user.email "you@example.com"`.');
|
|
3751
4545
|
return { lines, claude };
|
|
3752
4546
|
}
|
|
3753
4547
|
|
|
3754
4548
|
// src/core/draft.ts
|
|
3755
|
-
import { execFileSync as
|
|
3756
|
-
import { existsSync as
|
|
3757
|
-
import { join as
|
|
4549
|
+
import { execFileSync as execFileSync9 } from "child_process";
|
|
4550
|
+
import { existsSync as existsSync15, lstatSync as lstatSync2, readFileSync as readFileSync15 } from "fs";
|
|
4551
|
+
import { join as join18, posix } from "path";
|
|
3758
4552
|
var LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "poetry.lock", "Pipfile.lock", "uv.lock", "Cargo.lock", "go.sum", "Gemfile.lock", "composer.lock"];
|
|
3759
4553
|
var CI_CONFIG = [
|
|
3760
4554
|
[".github/workflows/**", /^\.github\/workflows\//],
|
|
@@ -3772,20 +4566,20 @@ var STOPWORDS = new Set(
|
|
|
3772
4566
|
var NPM_PLACEHOLDER = /no test specified/;
|
|
3773
4567
|
function trackedFiles(repoRoot2) {
|
|
3774
4568
|
try {
|
|
3775
|
-
return
|
|
4569
|
+
return execFileSync9("git", ["ls-files", "-z"], { cwd: repoRoot2, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }).toString().split("\0").filter(Boolean);
|
|
3776
4570
|
} catch {
|
|
3777
4571
|
return [];
|
|
3778
4572
|
}
|
|
3779
4573
|
}
|
|
3780
4574
|
var read = (repoRoot2, rel) => {
|
|
3781
4575
|
try {
|
|
3782
|
-
return
|
|
4576
|
+
return readFileSync15(join18(repoRoot2, rel), "utf-8");
|
|
3783
4577
|
} catch {
|
|
3784
4578
|
return "";
|
|
3785
4579
|
}
|
|
3786
4580
|
};
|
|
3787
4581
|
function detectTestCommand(repoRoot2, files) {
|
|
3788
|
-
const has = (f) => files.includes(f) ||
|
|
4582
|
+
const has = (f) => files.includes(f) || existsSync15(join18(repoRoot2, f));
|
|
3789
4583
|
if (has("package.json")) {
|
|
3790
4584
|
let script;
|
|
3791
4585
|
try {
|
|
@@ -3815,10 +4609,10 @@ function draftContract(input) {
|
|
|
3815
4609
|
const t = detectTestCommand(input.repoRoot, files);
|
|
3816
4610
|
notes.push(t.command ? `test_command "${t.command}": ${t.reason}` : `test_command: none (${t.reason})`);
|
|
3817
4611
|
const scenarios = files.filter((f) => /^\.replume\/scenarios\/[^/]+\.ya?ml$/.test(f)).flatMap((f) => {
|
|
3818
|
-
const path =
|
|
4612
|
+
const path = join18(input.repoRoot, f);
|
|
3819
4613
|
try {
|
|
3820
4614
|
if (!lstatSync2(path).isFile() || lstatSync2(path).isSymbolicLink()) return [];
|
|
3821
|
-
const s = parseScenario(
|
|
4615
|
+
const s = parseScenario(readFileSync15(path, "utf8"), f);
|
|
3822
4616
|
return [s.id === posix.basename(f).replace(/\.ya?ml$/, "") ? s : null].filter((x) => x !== null);
|
|
3823
4617
|
} catch {
|
|
3824
4618
|
notes.push(`must_check: skipped invalid scenario ${f}; fix it before verification`);
|
|
@@ -3885,7 +4679,7 @@ function draftContract(input) {
|
|
|
3885
4679
|
for (const p of namedCandidates) notes.push(`candidate: ${p} (named in the task; review before allowing)`);
|
|
3886
4680
|
let recent = [];
|
|
3887
4681
|
try {
|
|
3888
|
-
recent =
|
|
4682
|
+
recent = execFileSync9("git", ["log", "-5", "--name-only", "--pretty=format:"], { cwd: input.repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").filter(Boolean);
|
|
3889
4683
|
} catch {
|
|
3890
4684
|
}
|
|
3891
4685
|
const recentCandidates = [...new Set(recent.filter((f) => source.includes(f) && !namedCandidates.includes(f) && !["README.md", "package.json"].includes(f)))].slice(0, Math.max(0, 5 - namedCandidates.length));
|
|
@@ -3900,10 +4694,14 @@ function draftContract(input) {
|
|
|
3900
4694
|
};
|
|
3901
4695
|
return { contract, notes, broad: broad.filter((g) => allow.includes(g)) };
|
|
3902
4696
|
}
|
|
4697
|
+
function allowHasNoSource(allow) {
|
|
4698
|
+
return allow.every((g) => isTestFile(g.replace(/\*+/g, "x")));
|
|
4699
|
+
}
|
|
4700
|
+
var ALLOW_NO_SOURCE_WARNING = "allow contains no source files; the agent may have no allowed way to finish this task. Name the files in --task or edit allow.";
|
|
3903
4701
|
|
|
3904
4702
|
// src/sandbox/preflight.ts
|
|
3905
|
-
import { existsSync as
|
|
3906
|
-
import { join as
|
|
4703
|
+
import { existsSync as existsSync18 } from "fs";
|
|
4704
|
+
import { join as join23 } from "path";
|
|
3907
4705
|
|
|
3908
4706
|
// src/sandbox/docker.ts
|
|
3909
4707
|
import { spawn as spawn2, spawnSync } from "child_process";
|
|
@@ -3911,12 +4709,17 @@ import { randomUUID } from "crypto";
|
|
|
3911
4709
|
import { rmSync as rmSync4 } from "fs";
|
|
3912
4710
|
import { chmod, cp, lstat as lstat2, mkdtemp, readdir as readdir2, rm, writeFile } from "fs/promises";
|
|
3913
4711
|
import { tmpdir as tmpdir3 } from "os";
|
|
3914
|
-
import { basename as basename2, join as
|
|
4712
|
+
import { basename as basename2, join as join19 } from "path";
|
|
3915
4713
|
import { performance } from "perf_hooks";
|
|
3916
4714
|
var DEFAULT_DOCKER_IMAGE = "node:20-bookworm-slim";
|
|
3917
|
-
var DOCKER_PROBE_TIMEOUT_MS =
|
|
3918
|
-
|
|
3919
|
-
|
|
4715
|
+
var DOCKER_PROBE_TIMEOUT_MS = 15e3;
|
|
4716
|
+
var remaining = (deadline) => Math.max(0, Math.ceil(deadline - performance.now()));
|
|
4717
|
+
var probeCli = (binary, args, deadline) => {
|
|
4718
|
+
const left = remaining(deadline);
|
|
4719
|
+
return left > 0 ? dockerCli(binary, args, left) : Promise.resolve({ code: null, stdout: "", stderr: "", timedOut: true, notFound: false });
|
|
4720
|
+
};
|
|
4721
|
+
async function probeDaemonUntil(binary, deadline) {
|
|
4722
|
+
const context = await probeCli(binary, ["context", "inspect"], deadline);
|
|
3920
4723
|
if (context.timedOut) return { status: "timeout", step: "docker context inspect" };
|
|
3921
4724
|
if (context.notFound) return { status: "unavailable", detail: "docker CLI not found" };
|
|
3922
4725
|
if (context.code !== 0) return { status: "unavailable" };
|
|
@@ -3926,12 +4729,12 @@ async function probeDaemon(binary = "docker", timeoutMs = DOCKER_PROBE_TIMEOUT_M
|
|
|
3926
4729
|
} catch {
|
|
3927
4730
|
return { status: "unavailable" };
|
|
3928
4731
|
}
|
|
3929
|
-
const info = await
|
|
4732
|
+
const info = await probeCli(binary, ["info", "--format", "{{.ServerVersion}}"], deadline);
|
|
3930
4733
|
if (info.timedOut) return { status: "timeout", step: "docker info" };
|
|
3931
4734
|
return info.code === 0 ? { status: "ok" } : { status: "unavailable" };
|
|
3932
4735
|
}
|
|
3933
|
-
async function
|
|
3934
|
-
const r = await
|
|
4736
|
+
async function probeImageUntil(image, binary, deadline) {
|
|
4737
|
+
const r = await probeCli(binary, ["image", "inspect", image], deadline);
|
|
3935
4738
|
return r.timedOut ? "timeout" : r.code === 0 ? "present" : "missing";
|
|
3936
4739
|
}
|
|
3937
4740
|
var seconds = (ms) => `${ms / 1e3} s`;
|
|
@@ -3939,19 +4742,20 @@ async function resolveIsolation(choice, image, probe = {}) {
|
|
|
3939
4742
|
if (choice === "worktree") return { mode: "worktree", reason: "requested: worktree" };
|
|
3940
4743
|
const legacy = "daemon" in probe;
|
|
3941
4744
|
const timeoutMs = legacy ? DOCKER_PROBE_TIMEOUT_MS : probe.timeoutMs ?? DOCKER_PROBE_TIMEOUT_MS;
|
|
3942
|
-
const
|
|
4745
|
+
const deadline = performance.now() + timeoutMs;
|
|
4746
|
+
const daemon = legacy ? await probe.daemon() ? { status: "ok" } : { status: "unavailable" } : await probeDaemonUntil(probe.binary ?? "docker", deadline);
|
|
3943
4747
|
if (daemon.status === "timeout") {
|
|
3944
4748
|
if (choice === "docker") throw new Error(`Docker was requested but did not answer within ${seconds(timeoutMs)} (${daemon.step})`);
|
|
3945
|
-
return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (${daemon.step});
|
|
4749
|
+
return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (${daemon.step}); using the isolated working copy instead (not a security sandbox)` };
|
|
3946
4750
|
}
|
|
3947
4751
|
if (daemon.status === "unavailable") {
|
|
3948
4752
|
if (choice === "docker") throw new Error(`Docker was requested but the Docker CLI or a local daemon is unavailable${daemon.detail ? ` (${daemon.detail})` : ""}`);
|
|
3949
4753
|
return { mode: "worktree", reason: `auto: no local Docker daemon${daemon.detail ? ` (${daemon.detail})` : ""}` };
|
|
3950
4754
|
}
|
|
3951
|
-
const img = legacy ? await probe.image(image) ? "present" : "missing" : await
|
|
4755
|
+
const img = legacy ? await probe.image(image) ? "present" : "missing" : await probeImageUntil(image, probe.binary ?? "docker", deadline);
|
|
3952
4756
|
if (img === "timeout") {
|
|
3953
4757
|
if (choice === "docker") throw new Error(`Docker was requested but did not answer within ${seconds(timeoutMs)} (docker image inspect)`);
|
|
3954
|
-
return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (docker image inspect);
|
|
4758
|
+
return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (docker image inspect); using the isolated working copy instead (not a security sandbox)` };
|
|
3955
4759
|
}
|
|
3956
4760
|
if (img === "missing") {
|
|
3957
4761
|
if (choice === "docker") throw new Error(`Docker was requested but image ${image} is not present locally; run: docker pull ${image}`);
|
|
@@ -3963,14 +4767,14 @@ function cliEnv() {
|
|
|
3963
4767
|
return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME, DOCKER_CONFIG: process.env.DOCKER_CONFIG };
|
|
3964
4768
|
}
|
|
3965
4769
|
function dockerCli(binary, args, timeoutMs) {
|
|
3966
|
-
return new Promise((
|
|
4770
|
+
return new Promise((resolve9) => {
|
|
3967
4771
|
const child = spawn2(binary, args, { env: cliEnv(), stdio: ["ignore", "pipe", "pipe"] });
|
|
3968
4772
|
let stdout = "", stderr = "", settled = false, timedOut = false, notFound = false;
|
|
3969
4773
|
const done = (code) => {
|
|
3970
4774
|
if (settled) return;
|
|
3971
4775
|
settled = true;
|
|
3972
4776
|
clearTimeout(timer);
|
|
3973
|
-
|
|
4777
|
+
resolve9({ code, stdout, stderr, timedOut, notFound });
|
|
3974
4778
|
};
|
|
3975
4779
|
child.stdout.on("data", (chunk) => {
|
|
3976
4780
|
stdout += chunk.toString();
|
|
@@ -3991,6 +4795,8 @@ function dockerCli(binary, args, timeoutMs) {
|
|
|
3991
4795
|
}, timeoutMs);
|
|
3992
4796
|
});
|
|
3993
4797
|
}
|
|
4798
|
+
var commandStartMarker = (name) => `__replume_command_start_${name}__`;
|
|
4799
|
+
var DOCKER_STARTUP_TIMEOUT_SEC = 120;
|
|
3994
4800
|
function buildDockerRunArgs(input) {
|
|
3995
4801
|
const uid = process.getuid?.() ?? 1e3, gid = process.getgid?.() ?? 1e3;
|
|
3996
4802
|
return [
|
|
@@ -4026,11 +4832,14 @@ function buildDockerRunArgs(input) {
|
|
|
4026
4832
|
"--env",
|
|
4027
4833
|
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
4028
4834
|
...Object.entries(input.labels ?? {}).flatMap(([k, v]) => ["--label", `${k}=${v}`]),
|
|
4835
|
+
// the marker, then exactly the old `sh -lc <command>` (passed as an argument, never spliced into this script)
|
|
4029
4836
|
"--workdir",
|
|
4030
4837
|
"/repo",
|
|
4031
4838
|
input.image,
|
|
4032
4839
|
"sh",
|
|
4033
|
-
"-
|
|
4840
|
+
"-c",
|
|
4841
|
+
`printf '%s\\n' ${commandStartMarker(input.name)} >&2; exec sh -lc "$1"`,
|
|
4842
|
+
"replume-scenario",
|
|
4034
4843
|
input.command
|
|
4035
4844
|
];
|
|
4036
4845
|
}
|
|
@@ -4047,11 +4856,11 @@ async function sweepStaleStaging(tmpRoot = tmpdir3(), now = Date.now()) {
|
|
|
4047
4856
|
}
|
|
4048
4857
|
for (const n of names) {
|
|
4049
4858
|
if (!n.startsWith(STAGING_PREFIX)) continue;
|
|
4050
|
-
const dir =
|
|
4859
|
+
const dir = join19(tmpRoot, n);
|
|
4051
4860
|
try {
|
|
4052
4861
|
const st = await lstat2(dir);
|
|
4053
4862
|
if (st.isSymbolicLink() || !st.isDirectory()) continue;
|
|
4054
|
-
const marker = await lstat2(
|
|
4863
|
+
const marker = await lstat2(join19(dir, STAGING_MARKER));
|
|
4055
4864
|
if (!marker.isFile() || now - marker.mtimeMs < STALE_AFTER_MS) continue;
|
|
4056
4865
|
await rm(dir, { recursive: true, force: true });
|
|
4057
4866
|
removed.push(dir);
|
|
@@ -4088,44 +4897,57 @@ async function runDockerScenario(input) {
|
|
|
4088
4897
|
const binary = input.dockerBinary ?? "docker";
|
|
4089
4898
|
const tmpRoot = input.tmpRoot ?? tmpdir3();
|
|
4090
4899
|
await sweepStaleStaging(tmpRoot);
|
|
4091
|
-
const temp = await mkdtemp(
|
|
4092
|
-
const staged =
|
|
4900
|
+
const temp = await mkdtemp(join19(tmpRoot, STAGING_PREFIX));
|
|
4901
|
+
const staged = join19(temp, "repo");
|
|
4093
4902
|
const name = `replume-${randomUUID()}`;
|
|
4094
4903
|
active.set(temp, { binary, name });
|
|
4095
4904
|
attachSignals();
|
|
4096
4905
|
try {
|
|
4097
|
-
await writeFile(
|
|
4906
|
+
await writeFile(join19(temp, STAGING_MARKER), JSON.stringify({ by: "replume", pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() }) + "\n");
|
|
4098
4907
|
await cp(input.root, staged, { recursive: true, filter: (path) => basename2(path) !== ".git" });
|
|
4099
4908
|
if (process.getuid?.() === 0) await chmod(temp, 493);
|
|
4100
4909
|
const args = buildDockerRunArgs({ name, image: input.image ?? DEFAULT_DOCKER_IMAGE, source: staged, command: input.command, labels: input.labels });
|
|
4101
|
-
return await new Promise((
|
|
4910
|
+
return await new Promise((resolve9) => {
|
|
4102
4911
|
const child = spawn2(binary, args, { env: cliEnv(), stdio: ["ignore", "pipe", "pipe"] });
|
|
4103
|
-
|
|
4912
|
+
const marker = `${commandStartMarker(name)}
|
|
4913
|
+
`;
|
|
4914
|
+
let stdout = "", stderr = "", timedOut = false, settled = false, commandStarted = false;
|
|
4915
|
+
let timer;
|
|
4104
4916
|
const finish = (exitCode) => {
|
|
4105
4917
|
if (settled) return;
|
|
4106
4918
|
settled = true;
|
|
4107
4919
|
clearTimeout(timer);
|
|
4108
|
-
|
|
4920
|
+
resolve9({ stdout, stderr, exitCode, timedOut, durationMs: Math.round(performance.now() - started) });
|
|
4921
|
+
};
|
|
4922
|
+
const expire = (note) => async () => {
|
|
4923
|
+
timedOut = true;
|
|
4924
|
+
if (note) stderr += note;
|
|
4925
|
+
const removed = await dockerCli(binary, ["rm", "--force", name], 1e4);
|
|
4926
|
+
if (removed.code !== 0) stderr += `
|
|
4927
|
+
Docker timeout cleanup: ${removed.stderr.trim()}`;
|
|
4928
|
+
child.kill("SIGKILL");
|
|
4929
|
+
finish(null);
|
|
4109
4930
|
};
|
|
4110
4931
|
child.stdout.on("data", (chunk) => {
|
|
4111
4932
|
stdout += chunk.toString();
|
|
4112
4933
|
});
|
|
4113
4934
|
child.stderr.on("data", (chunk) => {
|
|
4114
4935
|
stderr += chunk.toString();
|
|
4936
|
+
if (!commandStarted && stderr.includes(marker)) {
|
|
4937
|
+
commandStarted = true;
|
|
4938
|
+
stderr = stderr.replace(marker, "");
|
|
4939
|
+
clearTimeout(timer);
|
|
4940
|
+
timer = setTimeout(expire(""), input.timeoutSec * 1e3);
|
|
4941
|
+
}
|
|
4115
4942
|
});
|
|
4116
4943
|
child.on("error", (error) => {
|
|
4117
4944
|
stderr += error.message;
|
|
4118
4945
|
finish(null);
|
|
4119
4946
|
});
|
|
4120
4947
|
child.on("close", finish);
|
|
4121
|
-
const
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
if (removed.code !== 0) stderr += `
|
|
4125
|
-
Docker timeout cleanup: ${removed.stderr.trim()}`;
|
|
4126
|
-
child.kill("SIGKILL");
|
|
4127
|
-
finish(null);
|
|
4128
|
-
}, input.timeoutSec * 1e3);
|
|
4948
|
+
const startup = input.startupTimeoutSec ?? DOCKER_STARTUP_TIMEOUT_SEC;
|
|
4949
|
+
timer = setTimeout(expire(`
|
|
4950
|
+
Docker container did not start the scenario command within ${startup}s (start-up limit; timeout_sec counts from the command start)`), startup * 1e3);
|
|
4129
4951
|
});
|
|
4130
4952
|
} finally {
|
|
4131
4953
|
try {
|
|
@@ -4141,20 +4963,20 @@ Docker timeout cleanup: ${removed.stderr.trim()}`;
|
|
|
4141
4963
|
}
|
|
4142
4964
|
|
|
4143
4965
|
// src/sandbox/index.ts
|
|
4144
|
-
import { existsSync as
|
|
4145
|
-
import { execFileSync as
|
|
4966
|
+
import { existsSync as existsSync17, readFileSync as readFileSync17 } from "fs";
|
|
4967
|
+
import { execFileSync as execFileSync10 } from "child_process";
|
|
4146
4968
|
import { lstat as lstat3, mkdir, writeFile as writeFile2 } from "fs/promises";
|
|
4147
|
-
import { isAbsolute as isAbsolute7, join as
|
|
4969
|
+
import { isAbsolute as isAbsolute7, join as join22, relative as relative6, resolve as resolve7, sep as sep3 } from "path";
|
|
4148
4970
|
import { performance as performance3 } from "perf_hooks";
|
|
4149
4971
|
|
|
4150
4972
|
// src/sandbox/working-copy.ts
|
|
4151
4973
|
import { spawn as spawn3 } from "child_process";
|
|
4152
4974
|
import { mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
4153
4975
|
import { tmpdir as tmpdir4 } from "os";
|
|
4154
|
-
import { join as
|
|
4976
|
+
import { join as join20 } from "path";
|
|
4155
4977
|
import { performance as performance2 } from "perf_hooks";
|
|
4156
4978
|
async function git(cwd, args) {
|
|
4157
|
-
return new Promise((
|
|
4979
|
+
return new Promise((resolve9, reject) => {
|
|
4158
4980
|
const child = spawn3("git", args, { cwd, env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
4159
4981
|
let out = "", err = "";
|
|
4160
4982
|
child.stdout.on("data", (v) => {
|
|
@@ -4164,13 +4986,13 @@ async function git(cwd, args) {
|
|
|
4164
4986
|
err += v.toString();
|
|
4165
4987
|
});
|
|
4166
4988
|
child.on("error", reject);
|
|
4167
|
-
child.on("close", (code) => code === 0 ?
|
|
4989
|
+
child.on("close", (code) => code === 0 ? resolve9(out.trim()) : reject(new Error(`git ${args.join(" ")}: ${err.trim() || `exit ${code}`}`)));
|
|
4168
4990
|
});
|
|
4169
4991
|
}
|
|
4170
4992
|
async function withWorkingCopy(repo, commit, keep, fn) {
|
|
4171
|
-
const temp = await mkdtemp2(
|
|
4172
|
-
const root =
|
|
4173
|
-
const home =
|
|
4993
|
+
const temp = await mkdtemp2(join20(tmpdir4(), "replume-copy-"));
|
|
4994
|
+
const root = join20(temp, "repo");
|
|
4995
|
+
const home = join20(temp, "home");
|
|
4174
4996
|
let added = false;
|
|
4175
4997
|
try {
|
|
4176
4998
|
await git(repo, ["worktree", "add", "--detach", root, commit]);
|
|
@@ -4190,14 +5012,14 @@ async function withWorkingCopy(repo, commit, keep, fn) {
|
|
|
4190
5012
|
}
|
|
4191
5013
|
async function runCommand(command, cwd, home, timeoutSec) {
|
|
4192
5014
|
const started = performance2.now();
|
|
4193
|
-
return new Promise((
|
|
5015
|
+
return new Promise((resolve9) => {
|
|
4194
5016
|
const child = spawn3(command, { cwd, shell: true, detached: process.platform !== "win32", env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home }, stdio: ["ignore", "pipe", "pipe"] });
|
|
4195
5017
|
let stdout = "", stderr = "", timedOut = false, settled = false;
|
|
4196
5018
|
const finish = (exitCode) => {
|
|
4197
5019
|
if (settled) return;
|
|
4198
5020
|
settled = true;
|
|
4199
5021
|
clearTimeout(timer);
|
|
4200
|
-
|
|
5022
|
+
resolve9({ stdout, stderr, exitCode, durationMs: Math.round(performance2.now() - started), timedOut });
|
|
4201
5023
|
};
|
|
4202
5024
|
child.stdout.on("data", (v) => {
|
|
4203
5025
|
stdout += v.toString();
|
|
@@ -4301,10 +5123,22 @@ function buildReport(input) {
|
|
|
4301
5123
|
extra.push({ path: l.file, reason: `lockfile adds ${l.names.length} package${l.names.length === 1 ? "" : "s"} with no new direct dependency: ${l.names.slice(0, 10).join(", ")}${l.names.length > 10 ? ", \u2026" : ""}` });
|
|
4302
5124
|
const ran = new Set(input.results.filter((r) => !["not_run", "error"].includes(r.status)).map((r) => r.id));
|
|
4303
5125
|
const claims = scenarios.filter((s) => ran.has(s.id)).flatMap((s) => s.declared_covers);
|
|
4304
|
-
const
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
5126
|
+
const testsPassed = !input.draft && input.testCommand?.status === "passed";
|
|
5127
|
+
const obs = input.observation;
|
|
5128
|
+
const named = new Set(obs?.namedPaths ?? []);
|
|
5129
|
+
const reasonFor = (path) => {
|
|
5130
|
+
if (input.draft) return "changed in diff; the contract is an unconfirmed draft, so its policy was not applied (run `replume confirm`); no scenario declares coverage among scenarios that ran";
|
|
5131
|
+
if (!testsPassed) return `changed in diff; no scenario declares coverage, and ${!contract.test_command ? "the contract has no test_command" : "test_command did not pass with at least one test"}`;
|
|
5132
|
+
if (!obs || obs.sessions === 0) return "changed in diff; no scenario declares coverage; agent steps not observed (no agent session for this task in the ledger)";
|
|
5133
|
+
if (!named.has(path)) return "changed in diff; no scenario declares coverage; change not seen by hooks (no ledger event records a write to this file)";
|
|
5134
|
+
return null;
|
|
5135
|
+
};
|
|
5136
|
+
const uncovered = paths.filter((p) => !matches(claims, p));
|
|
5137
|
+
const notChecked = uncovered.flatMap((path) => {
|
|
5138
|
+
const reason = reasonFor(path);
|
|
5139
|
+
return reason ? [{ path, reason }] : [];
|
|
5140
|
+
});
|
|
5141
|
+
const observedOnly = uncovered.filter((p) => reasonFor(p) === null).map((path) => ({ path, reason: "checked by test_command and observed steps; no scenario" }));
|
|
4308
5142
|
const failed = results.filter((r) => r.status !== "passed" && r.status !== "unconfirmed").map((r) => ({ id: r.id, evidence: r.expectationFailures.join("; ") || r.status }));
|
|
4309
5143
|
const evidenceFor = (r) => {
|
|
4310
5144
|
if (r.id === TEST_COMMAND_ID) return `\`${contract.test_command}\` exit ${r.exitCode}`;
|
|
@@ -4332,7 +5166,7 @@ function buildReport(input) {
|
|
|
4332
5166
|
const checks = [
|
|
4333
5167
|
emptyDiff ? { name: "scope", status: "not_run", detail: `empty diff, nothing compared: base ${base3.slice(0, 7)} and commit ${commit.slice(0, 7)} have no differences` } : input.draft ? { name: "scope", status: "not_run", detail: "contract is an unconfirmed draft, so its allow globs were not applied" } : contract.allow.length ? { name: "scope", status: "done", detail: `${n(paths.length, "changed file")} compared with allow globs, ${outsideAllow} outside` } : { name: "scope", status: "not_run", detail: "contract has no allow globs, so changes outside the task were not checked" },
|
|
4334
5168
|
{ name: "tampering", status: "done", detail: `${n(testFiles, "changed test file")} inspected for removed assertions, skip/only and forced success; ${input.draft ? "protect globs of the unconfirmed draft not applied" : n(contract.protect.length, "protect glob")}; .replume/ changes` },
|
|
4335
|
-
!input.testCommand ? { name: "test_command", status: "not_run", detail: input.draft ? "contract is an unconfirmed draft, so its test_command was not run" : "contract lists no test_command" } : input.testCommand.status === "unconfirmed" ? { name: "test_command", status: "unconfirmed", detail: `\`${contract.test_command}\` exit ${input.testCommand.exitCode}; ${input.testCommand.expectationFailures.join("; ")}` } : input.testCommand.status === "passed" ? { name: "test_command", status: "done", detail: `\`${contract.test_command}\` exit 0 (declares no coverage)` } : { name: "test_command", status: "failed", detail: `\`${contract.test_command}\` ${input.testCommand.expectationFailures.join("; ") || input.testCommand.status}` },
|
|
5169
|
+
!input.testCommand ? { name: "test_command", status: "not_run", detail: input.draft ? "contract is an unconfirmed draft, so its test_command was not run" : "contract lists no test_command" } : input.testCommand.status === "unconfirmed" ? { name: "test_command", status: "unconfirmed", detail: `\`${contract.test_command}\` exit ${input.testCommand.exitCode}; ${input.testCommand.expectationFailures.join("; ")}` } : input.testCommand.status === "passed" ? { name: "test_command", status: "done", detail: `\`${contract.test_command}\` exit 0 (declares no coverage by itself; with observed agent steps it checks changed files no scenario declares, D-062)` } : { name: "test_command", status: "failed", detail: `\`${contract.test_command}\` ${input.testCommand.expectationFailures.join("; ") || input.testCommand.status}` },
|
|
4336
5170
|
scenarioRuns.length ? { name: "scenarios", status: scenarioRuns.every((r) => r.status === "passed") ? "done" : "failed", detail: `${n(scenarioRuns.length, "scenario")} ran` } : { name: "scenarios", status: "not_run", detail: scenarios.length ? "no scenario ran" : "no scenario files at this commit" },
|
|
4337
5171
|
...input.baselineCheck ? [input.baselineCheck] : [],
|
|
4338
5172
|
...deps ? [dependencyCheck(deps)] : [],
|
|
@@ -4355,6 +5189,7 @@ function buildReport(input) {
|
|
|
4355
5189
|
extra: lv(extra, () => "verified_here"),
|
|
4356
5190
|
tampering: lv(tampering, (t) => RECORDED.has(t.rule) ? "observed" : "verified_here"),
|
|
4357
5191
|
notChecked: lv(notChecked, () => "unknown"),
|
|
5192
|
+
...observedOnly.length ? { observedOnly: lv(observedOnly, () => "observed") } : {},
|
|
4358
5193
|
scenarios: [...results, ...doneRuns],
|
|
4359
5194
|
baseCommit: base3,
|
|
4360
5195
|
...input.baseSource ? { baseSource: input.baseSource } : {},
|
|
@@ -4401,15 +5236,15 @@ function parsePackageJson(text) {
|
|
|
4401
5236
|
return { error: "not valid JSON" };
|
|
4402
5237
|
}
|
|
4403
5238
|
if (!j || typeof j !== "object" || Array.isArray(j)) return { error: "not a package manifest" };
|
|
4404
|
-
const
|
|
5239
|
+
const obj3 = j;
|
|
4405
5240
|
for (const s of NPM_SECTIONS) {
|
|
4406
|
-
if (!(s in
|
|
4407
|
-
const sec =
|
|
5241
|
+
if (!(s in obj3) || obj3[s] == null) continue;
|
|
5242
|
+
const sec = obj3[s];
|
|
4408
5243
|
if (typeof sec !== "object" || Array.isArray(sec)) return { error: `${s} is not an object` };
|
|
4409
5244
|
}
|
|
4410
5245
|
const entries = [];
|
|
4411
5246
|
for (const s of NPM_SECTIONS) {
|
|
4412
|
-
const sec =
|
|
5247
|
+
const sec = obj3[s];
|
|
4413
5248
|
if (sec && typeof sec === "object" && !Array.isArray(sec)) {
|
|
4414
5249
|
for (const name of Object.keys(sec)) entries.push({ name, section: s });
|
|
4415
5250
|
}
|
|
@@ -4590,134 +5425,6 @@ async function checkRegistry(d, fetcher, timeoutMs = REGISTRY_TIMEOUT_MS) {
|
|
|
4590
5425
|
}
|
|
4591
5426
|
var dependencyAllowed = (name, allow) => !!allow?.length && matchesAny(name, allow) !== null;
|
|
4592
5427
|
|
|
4593
|
-
// src/core/capture.ts
|
|
4594
|
-
import { existsSync as existsSync13, readFileSync as readFileSync13 } from "fs";
|
|
4595
|
-
import { dirname as dirname7, join as join18 } from "path";
|
|
4596
|
-
var EXPECTED_TYPES = ["prompt", "tool_pre", "command", "stop"];
|
|
4597
|
-
var SHOWN_PATHS = 5;
|
|
4598
|
-
var count3 = (m, k) => {
|
|
4599
|
-
m[k] = (m[k] ?? 0) + 1;
|
|
4600
|
-
};
|
|
4601
|
-
var HOOK_CRASH = "hook-errors.log exists: at least one hook crashed, so some steps may not have been recorded";
|
|
4602
|
-
function captureCoverage(eventsFile, changedPaths, hooksConfigured = false) {
|
|
4603
|
-
const absence = hooksConfigured ? "Replume project hooks are configured, but no hook events were recorded; agent steps and scope were not observed" : "no Replume project hooks were found; agent steps and scope were not observed";
|
|
4604
|
-
const empty = (ledger, notes) => ({
|
|
4605
|
-
ledger,
|
|
4606
|
-
hooksConfigured,
|
|
4607
|
-
events: 0,
|
|
4608
|
-
sessions: 0,
|
|
4609
|
-
agents: [],
|
|
4610
|
-
byType: {},
|
|
4611
|
-
tools: {},
|
|
4612
|
-
firstTs: null,
|
|
4613
|
-
lastTs: null,
|
|
4614
|
-
badLines: 0,
|
|
4615
|
-
hookErrorsLogged: hookErrors(eventsFile),
|
|
4616
|
-
changedFiles: changedPaths.length,
|
|
4617
|
-
changedFilesSeen: 0,
|
|
4618
|
-
changedFilesLanded: 0,
|
|
4619
|
-
changedFilesAttemptedOnly: [],
|
|
4620
|
-
changedFilesNotSeen: changedPaths.slice(0, SHOWN_PATHS),
|
|
4621
|
-
unobservableCommands: 0,
|
|
4622
|
-
// a hook that crashed on every call leaves no ledger: say so here too, not only in the JSON flag
|
|
4623
|
-
notSeen: hookErrors(eventsFile) ? [...notes, HOOK_CRASH] : notes
|
|
4624
|
-
});
|
|
4625
|
-
if (!existsSync13(eventsFile)) {
|
|
4626
|
-
return empty("absent", [`no ledger file: ${absence}`]);
|
|
4627
|
-
}
|
|
4628
|
-
const text = readFileSync13(eventsFile, "utf-8");
|
|
4629
|
-
const { events, badLines } = parseEvents(text);
|
|
4630
|
-
if (!events.length) {
|
|
4631
|
-
return empty("empty", [
|
|
4632
|
-
badLines.length ? `the ledger has ${badLines.length} line(s) but none is a valid event` : `the ledger is empty: ${absence}`
|
|
4633
|
-
]);
|
|
4634
|
-
}
|
|
4635
|
-
const byType = {};
|
|
4636
|
-
const tools = {};
|
|
4637
|
-
const seenPaths = /* @__PURE__ */ new Set();
|
|
4638
|
-
const landedPaths = /* @__PURE__ */ new Set();
|
|
4639
|
-
const blockedPaths = /* @__PURE__ */ new Set();
|
|
4640
|
-
for (const e of events) {
|
|
4641
|
-
count3(byType, e.type);
|
|
4642
|
-
if (e.type === "tool_pre" && e.tool) count3(tools, e.tool);
|
|
4643
|
-
const blocked = e.action === "block" && e.type !== "file_edit" && e.type !== "command";
|
|
4644
|
-
const landed = e.type === "file_edit" || e.type === "command" && e.raw?.ok !== false;
|
|
4645
|
-
for (const p of e.paths) {
|
|
4646
|
-
if (blocked) blockedPaths.add(p);
|
|
4647
|
-
else seenPaths.add(p);
|
|
4648
|
-
if (landed) landedPaths.add(p);
|
|
4649
|
-
}
|
|
4650
|
-
}
|
|
4651
|
-
const ts = events.map((e) => e.ts).sort();
|
|
4652
|
-
const notSeen = [];
|
|
4653
|
-
const missing = EXPECTED_TYPES.filter((t) => !byType[t]);
|
|
4654
|
-
if (missing.length) notSeen.push(`no ${missing.join(", ")} events`);
|
|
4655
|
-
const truncated = badLines.length > 0 || !text.endsWith("\n");
|
|
4656
|
-
if (truncated) notSeen.push(`the ledger may be truncated or damaged (${badLines.length} unreadable line(s)${text.endsWith("\n") ? "" : ", last line has no line break"}); events after that point may be missing`);
|
|
4657
|
-
const attemptedOnly = changedPaths.filter((p) => !seenPaths.has(p) && blockedPaths.has(p));
|
|
4658
|
-
if (attemptedOnly.length)
|
|
4659
|
-
notSeen.push(
|
|
4660
|
-
`${attemptedOnly.length} changed file(s) appear in the ledger only as blocked attempts (${attemptedOnly.slice(0, SHOWN_PATHS).join(", ")}${attemptedOnly.length > SHOWN_PATHS ? ", \u2026" : ""}): the change that landed was not observed by any hook`
|
|
4661
|
-
);
|
|
4662
|
-
const unseen = changedPaths.filter((p) => !seenPaths.has(p) && !blockedPaths.has(p));
|
|
4663
|
-
if (unseen.length) {
|
|
4664
|
-
notSeen.push(
|
|
4665
|
-
`${unseen.length} of ${changedPaths.length} changed file(s) are named by no ledger event (${unseen.slice(0, SHOWN_PATHS).join(", ")}${unseen.length > SHOWN_PATHS ? ", \u2026" : ""}); a file changed through a shell command is seen only if the command text names it`
|
|
4666
|
-
);
|
|
4667
|
-
}
|
|
4668
|
-
const opaque = [...new Set(events.filter((e) => (e.type === "command" || e.type === "tool_pre") && e.command && unobservable(e.command)).map((e) => e.command))];
|
|
4669
|
-
if (opaque.length)
|
|
4670
|
-
notSeen.push(
|
|
4671
|
-
`${opaque.length} shell command${opaque.length === 1 ? "" : "s"} ran a script file or wrote to a path built at run time (e.g. \`${opaque[0].slice(0, 80)}\`); what ${opaque.length === 1 ? "it" : "they"} wrote is not observable by Replume's shell rules`
|
|
4672
|
-
);
|
|
4673
|
-
const hookErrorsLogged = hookErrors(eventsFile);
|
|
4674
|
-
if (hookErrorsLogged) notSeen.push(HOOK_CRASH);
|
|
4675
|
-
return {
|
|
4676
|
-
ledger: "present",
|
|
4677
|
-
events: events.length,
|
|
4678
|
-
sessions: new Set(events.map((e) => e.sessionId)).size,
|
|
4679
|
-
agents: [...new Set(events.map((e) => e.agent))].sort(),
|
|
4680
|
-
byType,
|
|
4681
|
-
tools,
|
|
4682
|
-
firstTs: ts[0],
|
|
4683
|
-
lastTs: ts[ts.length - 1],
|
|
4684
|
-
badLines: badLines.length,
|
|
4685
|
-
hookErrorsLogged,
|
|
4686
|
-
changedFiles: changedPaths.length,
|
|
4687
|
-
changedFilesSeen: changedPaths.filter((p) => seenPaths.has(p)).length,
|
|
4688
|
-
changedFilesLanded: changedPaths.filter((p) => landedPaths.has(p)).length,
|
|
4689
|
-
changedFilesAttemptedOnly: attemptedOnly.slice(0, SHOWN_PATHS),
|
|
4690
|
-
changedFilesNotSeen: unseen.slice(0, SHOWN_PATHS),
|
|
4691
|
-
unobservableCommands: opaque.length,
|
|
4692
|
-
notSeen
|
|
4693
|
-
};
|
|
4694
|
-
}
|
|
4695
|
-
var SCRIPT_RUN = /(?:^|[;&|(]\s*|\s(?:sudo|exec|env|nohup|time)\s+)(?:(?:node|nodejs|deno|bun|python[0-9.]*|ruby|perl|php|bash|sh|zsh|pwsh|tsx|ts-node)\s+(?:-[^-ce\s][^\s]*\s+)*[\w./~-]+\.(?:js|mjs|cjs|ts|py|rb|pl|php|sh|bash|zsh|ps1)\b|\.{1,2}\/[\w./-]+)/;
|
|
4696
|
-
var RUNTIME_TARGET = /(?:>{1,2}|>\||\btee(?:\s+-\w+)*)\s*["']?[^\s"';|&]*(?:\$|`)/;
|
|
4697
|
-
var unobservable = (command) => SCRIPT_RUN.test(command) || RUNTIME_TARGET.test(command);
|
|
4698
|
-
function hookErrors(eventsFile) {
|
|
4699
|
-
return existsSync13(join18(dirname7(eventsFile), "hook-errors.log"));
|
|
4700
|
-
}
|
|
4701
|
-
var list = (m) => Object.entries(m).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => `${k} ${n}`).join(", ");
|
|
4702
|
-
function renderCapture(c) {
|
|
4703
|
-
const out = ["CAPTURE COVERAGE"];
|
|
4704
|
-
if (c.ledger !== "present") {
|
|
4705
|
-
out.push(` seen nothing (ledger ${c.ledger === "absent" ? "file not found" : "empty"})`);
|
|
4706
|
-
} else {
|
|
4707
|
-
out.push(` seen ${c.events} events from ${c.agents.join(", ")} in ${c.sessions} session${c.sessions === 1 ? "" : "s"} (${c.firstTs} to ${c.lastTs}): ${list(c.byType)}`);
|
|
4708
|
-
if (Object.keys(c.tools).length) out.push(` tools ${list(c.tools)}`);
|
|
4709
|
-
if (c.changedFilesLanded !== void 0) {
|
|
4710
|
-
const a = c.changedFilesAttemptedOnly ?? [];
|
|
4711
|
-
out.push(
|
|
4712
|
-
` files ${c.changedFilesSeen} of ${c.changedFiles} changed file(s) named by an allowed step; ${c.changedFilesLanded} landed and observed (file_edit or successful command event); ${a.length} only attempted (blocked)${a.length ? `: ${a.join(", ")}` : ""}`
|
|
4713
|
-
);
|
|
4714
|
-
}
|
|
4715
|
-
}
|
|
4716
|
-
out.push(` not seen ${c.notSeen.length ? c.notSeen.join("; ") : "nothing flagged by these checks (they cannot see steps taken outside the hooks)"}`);
|
|
4717
|
-
out.push(" limits only agents with a Replume adapter and installed hooks write events; the ledger is a local file that can be edited or deleted. This is what was captured, not proof of what the agent did.");
|
|
4718
|
-
return out;
|
|
4719
|
-
}
|
|
4720
|
-
|
|
4721
5428
|
// src/sandbox/failing-tests.ts
|
|
4722
5429
|
var PATTERNS2 = [
|
|
4723
5430
|
/^[ \t]*✖ (.+?) \(\d+(?:\.\d+)?m?s\)[ \t]*$/,
|
|
@@ -4750,29 +5457,29 @@ function failingTestNames(output) {
|
|
|
4750
5457
|
}
|
|
4751
5458
|
|
|
4752
5459
|
// src/core/acceptance.ts
|
|
4753
|
-
import { appendFileSync as appendFileSync5, existsSync as
|
|
4754
|
-
import { join as
|
|
4755
|
-
var file = (root) =>
|
|
5460
|
+
import { appendFileSync as appendFileSync5, existsSync as existsSync16, mkdirSync as mkdirSync12, readFileSync as readFileSync16, writeFileSync as writeFileSync11 } from "fs";
|
|
5461
|
+
import { join as join21 } from "path";
|
|
5462
|
+
var file = (root) => join21(root, ".replume", "acceptance.jsonl");
|
|
4756
5463
|
function recordAcceptance(repoRoot2, opts) {
|
|
4757
5464
|
if (opts.verdict !== "pass") throw new Error("accept requires a fresh verify PASS");
|
|
4758
5465
|
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(opts.taskId) || !/^[0-9a-f]{40}$/.test(opts.commit)) throw new Error("invalid task ID or commit");
|
|
4759
5466
|
const existing = readAcceptance(repoRoot2, opts.taskId, opts.commit);
|
|
4760
5467
|
if (existing) return { ...existing, alreadyAccepted: true };
|
|
4761
|
-
const dir =
|
|
4762
|
-
|
|
4763
|
-
const ignore =
|
|
4764
|
-
const prior =
|
|
4765
|
-
if (!prior.split(/\r?\n/).includes("acceptance.jsonl"))
|
|
5468
|
+
const dir = join21(repoRoot2, ".replume");
|
|
5469
|
+
mkdirSync12(dir, { recursive: true });
|
|
5470
|
+
const ignore = join21(dir, ".gitignore");
|
|
5471
|
+
const prior = existsSync16(ignore) ? readFileSync16(ignore, "utf8") : "";
|
|
5472
|
+
if (!prior.split(/\r?\n/).includes("acceptance.jsonl")) writeFileSync11(ignore, `${prior}${prior && !prior.endsWith("\n") ? "\n" : ""}acceptance.jsonl
|
|
4766
5473
|
`);
|
|
4767
5474
|
const entry = { taskId: opts.taskId, commit: opts.commit, acceptedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString() };
|
|
4768
5475
|
appendFileSync5(file(repoRoot2), JSON.stringify(entry) + "\n");
|
|
4769
5476
|
return { ...entry, alreadyAccepted: false };
|
|
4770
5477
|
}
|
|
4771
5478
|
function readAcceptance(repoRoot2, taskId, commit) {
|
|
4772
|
-
if (!
|
|
5479
|
+
if (!existsSync16(file(repoRoot2))) return null;
|
|
4773
5480
|
let content;
|
|
4774
5481
|
try {
|
|
4775
|
-
content =
|
|
5482
|
+
content = readFileSync16(file(repoRoot2), "utf8");
|
|
4776
5483
|
} catch {
|
|
4777
5484
|
return null;
|
|
4778
5485
|
}
|
|
@@ -4901,15 +5608,15 @@ async function isLink(p) {
|
|
|
4901
5608
|
}
|
|
4902
5609
|
}
|
|
4903
5610
|
function safeRelative(root, path) {
|
|
4904
|
-
const full =
|
|
5611
|
+
const full = resolve7(root, path);
|
|
4905
5612
|
if (full !== root && !full.startsWith(root + "/")) throw new Error(`path outside repository: ${path}`);
|
|
4906
5613
|
return full;
|
|
4907
5614
|
}
|
|
4908
5615
|
function projectHooksConfigured(root) {
|
|
4909
|
-
for (const rel of [".claude/settings.json", ".cursor/hooks.json"]) {
|
|
5616
|
+
for (const rel of [".claude/settings.json", ".cursor/hooks.json", ".codex/hooks.json"]) {
|
|
4910
5617
|
try {
|
|
4911
|
-
const settings = JSON.parse(
|
|
4912
|
-
if (
|
|
5618
|
+
const settings = JSON.parse(readFileSync17(join22(root, rel), "utf8"));
|
|
5619
|
+
if (["claude", "cursor", "codex"].some((agent) => JSON.stringify(settings.hooks ?? {}).includes(`replume hook ${agent}`))) return true;
|
|
4913
5620
|
} catch {
|
|
4914
5621
|
}
|
|
4915
5622
|
}
|
|
@@ -4998,7 +5705,7 @@ var showRef = (t) => `${/^[0-9a-f]{40}$/.test(t.ref) ? t.ref.slice(0, 7) : t.ref
|
|
|
4998
5705
|
async function compareToTrustedBase(repo, t, commit, contractRel, scenariosRel, contractFile) {
|
|
4999
5706
|
const where = `the trusted base ${showRef(t)}`;
|
|
5000
5707
|
const out = [];
|
|
5001
|
-
const local =
|
|
5708
|
+
const local = existsSync17(contractFile) ? readFileSync17(contractFile, "utf-8").trim() : null;
|
|
5002
5709
|
if (local !== t.contractText.trim())
|
|
5003
5710
|
out.push({ rule: "policy_differs_from_trusted_base", path: contractRel, detail: local === null ? `contract missing in the working tree; ${where} has one` : `working-tree contract differs from the one at ${where}; the contract at the ref was used as the policy` });
|
|
5004
5711
|
const blobs = async (rev) => new Map(
|
|
@@ -5015,8 +5722,22 @@ async function compareToTrustedBase(repo, t, commit, contractRel, scenariosRel,
|
|
|
5015
5722
|
}
|
|
5016
5723
|
return out;
|
|
5017
5724
|
}
|
|
5725
|
+
function observedChanges(events, changedPaths, since) {
|
|
5726
|
+
const t = since ? Date.parse(since) : NaN;
|
|
5727
|
+
const inTask = events.filter((e) => !!e.sessionId && !!e.agent && (Number.isNaN(t) || Date.parse(e.ts) >= t));
|
|
5728
|
+
const named = /* @__PURE__ */ new Set();
|
|
5729
|
+
for (const e of inTask) {
|
|
5730
|
+
const raw3 = e.raw ?? {};
|
|
5731
|
+
if (e.action === "block" || raw3.ok === false) continue;
|
|
5732
|
+
if (e.type === "file_edit") for (const p of e.paths) named.add(p);
|
|
5733
|
+
else if (e.type === "command" && raw3.hook_event_name !== "beforeShellExecution" && Array.isArray(raw3.writes)) {
|
|
5734
|
+
for (const p of raw3.writes) if (typeof p === "string") named.add(p);
|
|
5735
|
+
}
|
|
5736
|
+
}
|
|
5737
|
+
return { sessions: new Set(inTask.map((e) => e.sessionId)).size, namedPaths: changedPaths.filter((p) => named.has(p)) };
|
|
5738
|
+
}
|
|
5018
5739
|
async function runVerify2(opts) {
|
|
5019
|
-
const repo =
|
|
5740
|
+
const repo = resolve7(opts.repoPath);
|
|
5020
5741
|
if (!isAbsolute7(opts.repoPath)) throw new Error("repoPath must be absolute");
|
|
5021
5742
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString(), started = performance3.now();
|
|
5022
5743
|
const image = opts.dockerImage ?? DEFAULT_DOCKER_IMAGE;
|
|
@@ -5039,9 +5760,9 @@ async function runVerify2(opts) {
|
|
|
5039
5760
|
}
|
|
5040
5761
|
const loaded = trusted ? parseContract(trusted.contractText, `${trusted.ref}:${contractRel}`) : loadContract(contractFile);
|
|
5041
5762
|
if (!loaded) {
|
|
5042
|
-
const example =
|
|
5763
|
+
const example = join22(repo, ".replume/contract.example.yaml");
|
|
5043
5764
|
throw new Error(
|
|
5044
|
-
`no task contract at ${contractFile}; write one with: replume task "<what you asked the agent>" --allow "<glob>"` + (
|
|
5765
|
+
`no task contract at ${contractFile}; write one with: replume task "<what you asked the agent>" --allow "<glob>"` + (existsSync17(example) ? ", or copy .replume/contract.example.yaml to .replume/contract.yaml" : "")
|
|
5045
5766
|
);
|
|
5046
5767
|
}
|
|
5047
5768
|
const draft = isDraft(loaded);
|
|
@@ -5096,7 +5817,7 @@ async function runVerify2(opts) {
|
|
|
5096
5817
|
const entries = await diffEntries(repo, base3, commit);
|
|
5097
5818
|
const setup = /* @__PURE__ */ new Set();
|
|
5098
5819
|
if (baseline?.base_commit === base3) {
|
|
5099
|
-
const committed = (p) =>
|
|
5820
|
+
const committed = (p) => execFileSync10("git", ["show", `${commit}:${p}`], { cwd: repo });
|
|
5100
5821
|
for (const e of entries) {
|
|
5101
5822
|
if (e.status !== "A" && e.status !== "M" || e.oldPath) continue;
|
|
5102
5823
|
if (e.path === contractRel && sha256(committed(e.path)) === baseline.policy_hash) setup.add(e.path);
|
|
@@ -5108,10 +5829,14 @@ async function runVerify2(opts) {
|
|
|
5108
5829
|
const tampering = await tamperingFindings(repo, base3, commit, agentEntries);
|
|
5109
5830
|
const newTests = agentEntries.filter((e) => e.status === "A" && TEST_PATH.test(e.path) && !tampering.some((t) => t.path === e.path)).map((e) => e.path);
|
|
5110
5831
|
const dependencies = await dependencySection(repo, base3, commit, paths, contract, opts);
|
|
5832
|
+
const eventsFile = safeRelative(repo, opts.eventsPath ?? ".replume/events.jsonl");
|
|
5833
|
+
const ledgerEvents = readEvents(eventsFile).events;
|
|
5834
|
+
const since = baseline?.recorded_at ?? null;
|
|
5835
|
+
const observation = observedChanges(ledgerEvents, paths, since);
|
|
5111
5836
|
const report = await withWorkingCopy(repo, commit, mode === "worktree" && (opts.keepWorkdir ?? false), async (root, home) => {
|
|
5112
5837
|
const run = (command, timeoutSec) => mode === "docker" ? runDockerScenario({ root, command, timeoutSec, image }) : runCommand(command, root, home, timeoutSec);
|
|
5113
5838
|
const dir = safeRelative(root, opts.scenariosDir ?? ".replume/scenarios");
|
|
5114
|
-
const scenarios =
|
|
5839
|
+
const scenarios = existsSync17(dir) || await isLink(dir) ? await loadScenarios(dir, root) : [];
|
|
5115
5840
|
if (scenarios.some((s) => s.id === TEST_COMMAND_ID)) throw new Error(`scenario id "${TEST_COMMAND_ID}" is reserved for the contract's test_command`);
|
|
5116
5841
|
const results = [];
|
|
5117
5842
|
for (const scenario of scenarios) results.push(await execute(scenario, run, opts.timeoutSecDefault ?? 60));
|
|
@@ -5139,7 +5864,7 @@ async function runVerify2(opts) {
|
|
|
5139
5864
|
}
|
|
5140
5865
|
const baselineFindings = trusted ? await compareToTrustedBase(repo, trusted, commit, contractRel, scenariosRel, contractFile) : baseline ? compareToBaseline(
|
|
5141
5866
|
baseline,
|
|
5142
|
-
{ policyHash: sha256(
|
|
5867
|
+
{ policyHash: sha256(readFileSync17(contractFile)), scenarioHashes: hashTree(dir) },
|
|
5143
5868
|
{ contract: opts.contractPath ?? ".replume/contract.yaml", scenariosDir: opts.scenariosDir ?? ".replume/scenarios" }
|
|
5144
5869
|
) : [];
|
|
5145
5870
|
return buildReport({
|
|
@@ -5157,6 +5882,7 @@ async function runVerify2(opts) {
|
|
|
5157
5882
|
draft,
|
|
5158
5883
|
dependencies,
|
|
5159
5884
|
newTests,
|
|
5885
|
+
observation,
|
|
5160
5886
|
baselineCheck: trusted ? {
|
|
5161
5887
|
name: "baseline",
|
|
5162
5888
|
status: "done",
|
|
@@ -5168,9 +5894,6 @@ async function runVerify2(opts) {
|
|
|
5168
5894
|
isolationReason: reason
|
|
5169
5895
|
});
|
|
5170
5896
|
});
|
|
5171
|
-
const eventsFile = safeRelative(repo, opts.eventsPath ?? ".replume/events.jsonl");
|
|
5172
|
-
const ledgerEvents = readEvents(eventsFile).events;
|
|
5173
|
-
const since = baseline?.recorded_at ?? null;
|
|
5174
5897
|
const { interventions, layer2, layer2Modes, scope, sessions } = supervisorSection(ledgerEvents, paths, since);
|
|
5175
5898
|
report.metrics = taskMetrics(ledgerEvents, {
|
|
5176
5899
|
repoRoot: repo,
|
|
@@ -5203,9 +5926,9 @@ async function runVerify2(opts) {
|
|
|
5203
5926
|
}
|
|
5204
5927
|
}
|
|
5205
5928
|
const safe = redactDeep(report, Number.POSITIVE_INFINITY);
|
|
5206
|
-
const reportsDir =
|
|
5929
|
+
const reportsDir = join22(repo, ".replume/reports");
|
|
5207
5930
|
await mkdir(reportsDir, { recursive: true });
|
|
5208
|
-
await writeFile2(
|
|
5931
|
+
await writeFile2(join22(reportsDir, `${commit}.json`), JSON.stringify(safe, null, 2) + "\n");
|
|
5209
5932
|
return safe;
|
|
5210
5933
|
}
|
|
5211
5934
|
function isolationFooter(report) {
|
|
@@ -5228,11 +5951,21 @@ function countTag(levels) {
|
|
|
5228
5951
|
if (counts.size <= 1) return TAG[levels[0] ?? "verified_here"];
|
|
5229
5952
|
return `[evidence: ${[...counts].map(([l, k]) => `${k} ${LEVEL_NAME[l]}`).join("; ")}]`;
|
|
5230
5953
|
}
|
|
5954
|
+
function notCheckedCauses(report) {
|
|
5955
|
+
const causes = [...new Set(report.notChecked.map((x) => {
|
|
5956
|
+
const m = /; (agent steps not observed|change not seen by hooks)/.exec(x.reason);
|
|
5957
|
+
if (m) return `no scenario declares coverage; ${m[1]}`;
|
|
5958
|
+
if (/the contract has no test_command/.test(x.reason)) return "no scenario declares coverage and no test_command";
|
|
5959
|
+
if (/test_command did not pass/.test(x.reason)) return "no scenario declares coverage and test_command did not pass with at least one test";
|
|
5960
|
+
return "no scenario declares coverage";
|
|
5961
|
+
}))];
|
|
5962
|
+
return causes.length ? ` (${causes.join("; ")})` : "";
|
|
5963
|
+
}
|
|
5231
5964
|
function renderReport(report, opts = {}) {
|
|
5232
5965
|
if (opts.json) return JSON.stringify(report, null, 2);
|
|
5233
5966
|
const quietCapture = report.capture?.ledger === "absent" || report.capture?.ledger === "empty";
|
|
5234
5967
|
const first = report.verdict === "pass" ? "Verdict: PASS \u2014 the checks that ran passed" : report.verdict === "fail" ? "Verdict: FAIL \u2014 at least one required check failed" : "Verdict: INCOMPLETE \u2014 the available checks cannot establish a pass";
|
|
5235
|
-
const why = report.verdict === "pass" ? `Why: ${report.done.length}
|
|
5968
|
+
const why = report.verdict === "pass" ? `Why: ${report.done.length} check(s) passed (${report.done.map((d) => d.id).join(", ")}); no failing check, blocking extra change, tampering or undeclared coverage was reported${report.observedOnly?.length ? `; ${report.observedOnly.length} changed file(s) checked by test_command and observed steps, no scenario` : ""}.` : report.verdict === "fail" ? `Why: ${report.failed.length} failed scenario(s), ${report.extra.filter((e) => !e.informational).length} blocking extra change(s), ${report.tampering.length} tampering finding(s); inspect the details below.` : `Why: ${report.notChecked.length} path(s) are not checked${notCheckedCauses(report)}, or a required check did not run; inspect NOT CHECKED and NOT RUN below.`;
|
|
5236
5969
|
const activity = quietCapture ? `WARNING: ${report.capture?.hooksConfigured ? "Replume project hooks are configured, but no hook events were recorded" : "no Replume project hooks were found"}; agent steps and scope were not observed.` : report.capture ? `Agent activity: ${report.capture.events} event(s) reached Replume; this is not proof that every step was captured.` : "Agent activity: no capture information is available in this report.";
|
|
5237
5970
|
const lines = [first, why, activity, `Replume report commit ${report.commit.slice(0, 7)} task: ${report.contractId}`];
|
|
5238
5971
|
if (report.baseSource) lines.push(`Base ${report.baseCommit.slice(0, 7)} ${BASE_SOURCE_TEXT[report.baseSource]} ${TAG[report.baseSource === "recorded_at_task" ? "observed" : "verified_here"]}`);
|
|
@@ -5266,7 +5999,9 @@ function renderReport(report, opts = {}) {
|
|
|
5266
5999
|
}
|
|
5267
6000
|
lines.push(`EXTRA (${report.extra.length}) ${report.extra.map((x) => `${x.dependency ? `${x.path}: ${x.dependency.name}` : x.path} ${x.reason}`).join(", ") || "\u2014"} ${countTag(report.extra.map((x) => levelOf(x, "verified_here")))}`);
|
|
5268
6001
|
lines.push(`TAMPERING (${report.tampering.length}) ${report.tampering.map((x) => `${x.path} ${x.detail}`).join(", ") || "\u2014"} ${countTag(report.tampering.map((x) => levelOf(x, "verified_here")))}`);
|
|
5269
|
-
|
|
6002
|
+
if (report.observedOnly?.length)
|
|
6003
|
+
lines.push(`OBSERVED (${report.observedOnly.length}) ${report.observedOnly.map((x) => x.path).join(", ")} checked by test_command and observed steps; no scenario [Observed]`);
|
|
6004
|
+
lines.push(`NOT CHECKED (${report.notChecked.length}) ${report.notChecked.map((x) => x.path).join(", ") || "\u2014"}${report.notChecked.length ? ` ${notCheckedCauses(report).replace(/^ \(|\)$/g, "")}` : ""} ${TAG.unknown}`);
|
|
5270
6005
|
if (report.supervisor) {
|
|
5271
6006
|
const [head, ...rest] = renderInterventions(report.supervisor, report.supervisor.scope);
|
|
5272
6007
|
lines.push(`${head} ${TAG.observed}`, " Meaning: SUPERVISOR lists permission decisions; matching Cursor shell hooks are counted once. Hook output alone does not confirm execution. These records are editable.", ...rest);
|
|
@@ -5326,8 +6061,8 @@ async function runPreflight(opts) {
|
|
|
5326
6061
|
const { mode, reason } = await resolveIsolation(opts.isolation ?? "auto", image);
|
|
5327
6062
|
const results = await withWorkingCopy(opts.repoPath, opts.commit, false, async (root, home) => {
|
|
5328
6063
|
const run = (command, t) => mode === "docker" ? runDockerScenario({ root, command, timeoutSec: t, image }) : runCommand(command, root, home, t);
|
|
5329
|
-
const dir =
|
|
5330
|
-
const scenarios =
|
|
6064
|
+
const dir = join23(root, opts.scenariosDir ?? ".replume/scenarios");
|
|
6065
|
+
const scenarios = existsSync18(dir) ? await loadScenarios(dir, root).catch(() => []) : [];
|
|
5331
6066
|
const out = [];
|
|
5332
6067
|
for (const id of c.must_check) {
|
|
5333
6068
|
const s = scenarios.find((x) => x.id === id);
|
|
@@ -5383,7 +6118,7 @@ function comparePreflight(p, now) {
|
|
|
5383
6118
|
}
|
|
5384
6119
|
|
|
5385
6120
|
// src/core/watch.ts
|
|
5386
|
-
import { existsSync as
|
|
6121
|
+
import { existsSync as existsSync19, readFileSync as readFileSync18, statSync as statSync2, watchFile, unwatchFile } from "fs";
|
|
5387
6122
|
function formatEvent(e) {
|
|
5388
6123
|
const time = e.ts.slice(11, 19);
|
|
5389
6124
|
const action = e.action && e.action !== "ok" ? e.action.toUpperCase() : "";
|
|
@@ -5394,11 +6129,11 @@ ${" ".repeat(19)}${e.message.split("\n")[0]}`;
|
|
|
5394
6129
|
return head;
|
|
5395
6130
|
}
|
|
5396
6131
|
function readNew(path, offset) {
|
|
5397
|
-
if (!
|
|
6132
|
+
if (!existsSync19(path)) return { lines: [], offset, bad: 0 };
|
|
5398
6133
|
const size = statSync2(path).size;
|
|
5399
6134
|
if (size < offset) offset = 0;
|
|
5400
6135
|
if (size === offset) return { lines: [], offset, bad: 0 };
|
|
5401
|
-
const buf =
|
|
6136
|
+
const buf = readFileSync18(path);
|
|
5402
6137
|
const chunk = buf.subarray(offset, size).toString("utf-8");
|
|
5403
6138
|
const lastNl = chunk.lastIndexOf("\n");
|
|
5404
6139
|
if (lastNl < 0) return { lines: [], offset, bad: 0 };
|
|
@@ -5422,7 +6157,7 @@ function watchLedger(path, print, opts = {}) {
|
|
|
5422
6157
|
}
|
|
5423
6158
|
|
|
5424
6159
|
// src/core/replay.ts
|
|
5425
|
-
import { isAbsolute as isAbsolute8, join as
|
|
6160
|
+
import { isAbsolute as isAbsolute8, join as join24 } from "path";
|
|
5426
6161
|
var REPLAY_ROOT = "/replay-repo";
|
|
5427
6162
|
var REPLAY_BANNER = "RECORDED SESSION (replay). These are recorded events run through Replume's rules now.\nThis shows what the rules decide on these events; it does not show a live agent being steered.";
|
|
5428
6163
|
function kindOf(e) {
|
|
@@ -5440,10 +6175,10 @@ function actionFromEvent(e) {
|
|
|
5440
6175
|
for (const k of ["file_path", "notebook_path", "path"]) {
|
|
5441
6176
|
const v = input[k];
|
|
5442
6177
|
if (typeof v !== "string") continue;
|
|
5443
|
-
if (!isAbsolute8(v)) input[k] =
|
|
6178
|
+
if (!isAbsolute8(v)) input[k] = join24(REPLAY_ROOT, v);
|
|
5444
6179
|
else {
|
|
5445
6180
|
const rel = e.paths.find((p) => v.endsWith(`/${p}`));
|
|
5446
|
-
if (rel) input[k] =
|
|
6181
|
+
if (rel) input[k] = join24(REPLAY_ROOT, rel);
|
|
5447
6182
|
}
|
|
5448
6183
|
}
|
|
5449
6184
|
return toProposedAction(
|
|
@@ -5459,7 +6194,7 @@ function actionFromEvent(e) {
|
|
|
5459
6194
|
});
|
|
5460
6195
|
}
|
|
5461
6196
|
const kind = kindOf(e);
|
|
5462
|
-
const { inside, outside } = splitPaths(REPLAY_ROOT, e.paths.map((p) => isAbsolute8(p) ? p :
|
|
6197
|
+
const { inside, outside } = splitPaths(REPLAY_ROOT, e.paths.map((p) => isAbsolute8(p) ? p : join24(REPLAY_ROOT, p)));
|
|
5463
6198
|
const rawEdit = raw3;
|
|
5464
6199
|
const before = rawEdit.before ?? rawEdit.old_string;
|
|
5465
6200
|
const after = rawEdit.after ?? rawEdit.new_string;
|
|
@@ -5557,17 +6292,17 @@ var builtInEvents = JSON.stringify({
|
|
|
5557
6292
|
|
|
5558
6293
|
// src/cli.ts
|
|
5559
6294
|
function readStdin() {
|
|
5560
|
-
return new Promise((
|
|
6295
|
+
return new Promise((resolve9, reject) => {
|
|
5561
6296
|
let data = "";
|
|
5562
6297
|
process.stdin.setEncoding("utf-8");
|
|
5563
6298
|
process.stdin.on("data", (chunk) => data += chunk);
|
|
5564
|
-
process.stdin.on("end", () =>
|
|
6299
|
+
process.stdin.on("end", () => resolve9(data));
|
|
5565
6300
|
process.stdin.on("error", reject);
|
|
5566
6301
|
});
|
|
5567
6302
|
}
|
|
5568
6303
|
function repoRoot() {
|
|
5569
6304
|
try {
|
|
5570
|
-
return
|
|
6305
|
+
return execFileSync11("git", ["rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
5571
6306
|
} catch {
|
|
5572
6307
|
return process.cwd();
|
|
5573
6308
|
}
|
|
@@ -5613,16 +6348,16 @@ usageCommand.command("import-cursor").requiredOption("--result <path>", "local C
|
|
|
5613
6348
|
});
|
|
5614
6349
|
program.name("replume").description("Verify what your AI coding agent actually did").version(package_default.version);
|
|
5615
6350
|
program.addHelpText("after", "\nOff-machine options: verify --legacy-judge sends the full git diff, task text, and agent action summary to api.openai.com; --post-github-comment posts task text to the PR on api.github.com; --check-registry sends package names to npm/PyPI. HTTP layer 2 is off unless REPLUME_JUDGE=shadow|on and REPLUME_JUDGE_PROVIDER=http with REPLUME_JUDGE_URL; HTTP needs REPLUME_JUDGE_ALLOW_INSECURE_HTTP=1.\n");
|
|
5616
|
-
program.command("init").description("Create .replume/, install agent hooks where supported, print the capability matrix").option("--hook-command <cmd>", "command Claude Code should run for hooks", void 0).option("--cursor", "install Cursor project hooks even when .cursor/ is absent").option("--cursor-hook-command <cmd>", "command Cursor should run for hooks").option("--remove-cursor-hooks", "restore the Cursor hook config from before Replume init").action((opts) => {
|
|
6351
|
+
program.command("init").description("Create .replume/, install agent hooks where supported, print the capability matrix").option("--hook-command <cmd>", "command Claude Code should run for hooks", void 0).option("--cursor", "install Cursor project hooks even when .cursor/ is absent").option("--codex", "install Codex project hooks even when .codex/ is absent").option("--cursor-hook-command <cmd>", "command Cursor should run for hooks").option("--remove-cursor-hooks", "restore the Cursor hook config from before Replume init").action((opts) => {
|
|
5617
6352
|
try {
|
|
5618
6353
|
const root = repoRoot();
|
|
5619
6354
|
if (opts.removeCursorHooks) {
|
|
5620
|
-
const
|
|
5621
|
-
console.log(
|
|
6355
|
+
const result2 = uninstallCursorHooks(root);
|
|
6356
|
+
console.log(result2.restored ? result2.backupRetained ? `Cursor hooks removed where still identifiable; edited settings kept at ${result2.path}; original backup retained beside it` : `Cursor hooks removed; previous configuration restored at ${result2.path}` : "No Replume Cursor installation found");
|
|
5622
6357
|
return;
|
|
5623
6358
|
}
|
|
5624
6359
|
const detected = detectAgents(root);
|
|
5625
|
-
const cursorDir =
|
|
6360
|
+
const cursorDir = join25(root, ".cursor");
|
|
5626
6361
|
let cursorEntry = null;
|
|
5627
6362
|
try {
|
|
5628
6363
|
cursorEntry = lstatSync3(cursorDir);
|
|
@@ -5632,12 +6367,23 @@ program.command("init").description("Create .replume/, install agent hooks where
|
|
|
5632
6367
|
if (opts.cursor && cursorEntry && !cursorEntry.isDirectory())
|
|
5633
6368
|
throw new Error(`${cursorDir} must be a directory; nothing was changed.`);
|
|
5634
6369
|
const wantCursor = Boolean(opts.cursor || cursorEntry?.isDirectory());
|
|
6370
|
+
const codexDir = join25(root, ".codex");
|
|
6371
|
+
let codexEntry = null;
|
|
6372
|
+
try {
|
|
6373
|
+
codexEntry = lstatSync3(codexDir);
|
|
6374
|
+
} catch (e) {
|
|
6375
|
+
if (e.code !== "ENOENT") throw e;
|
|
6376
|
+
}
|
|
6377
|
+
if (opts.codex && codexEntry && !codexEntry.isDirectory())
|
|
6378
|
+
throw new Error(`${codexDir} must be a directory; nothing was changed.`);
|
|
6379
|
+
const wantCodex = Boolean(opts.codex || codexEntry?.isDirectory());
|
|
5635
6380
|
if (wantCursor) validateCursorHooksConfig(root, { command: opts.cursorHookCommand });
|
|
5636
|
-
if (
|
|
6381
|
+
if (wantCodex) validateCodexHooks(root);
|
|
6382
|
+
if (detected["Claude Code"] && existsSync20(claudeSettingsPath(root))) {
|
|
5637
6383
|
const path = claudeSettingsPath(root);
|
|
5638
6384
|
let parsed;
|
|
5639
6385
|
try {
|
|
5640
|
-
parsed = JSON.parse(
|
|
6386
|
+
parsed = JSON.parse(readFileSync19(path, "utf8"));
|
|
5641
6387
|
} catch {
|
|
5642
6388
|
throw new Error(`${path} is not valid JSON; nothing was changed. Fix it and run replume init again.`);
|
|
5643
6389
|
}
|
|
@@ -5645,14 +6391,28 @@ program.command("init").description("Create .replume/, install agent hooks where
|
|
|
5645
6391
|
throw new Error(`${path} is not a JSON object; nothing was changed. Fix it and run replume init again.`);
|
|
5646
6392
|
mergeHooks(parsed, opts.hookCommand ?? DEFAULT_HOOK_COMMAND);
|
|
5647
6393
|
}
|
|
5648
|
-
const { lines } = runInit(root, {
|
|
6394
|
+
const { lines } = runInit(root, {
|
|
6395
|
+
hookCommand: opts.hookCommand,
|
|
6396
|
+
cursorHookCommand: opts.cursorHookCommand,
|
|
6397
|
+
cursorHooks: wantCursor,
|
|
6398
|
+
codexHooks: wantCodex,
|
|
6399
|
+
detected
|
|
6400
|
+
});
|
|
5649
6401
|
const oldNotice = lines.findIndex((l) => l.startsWith("- Cursor found, but"));
|
|
5650
6402
|
if (oldNotice >= 0) lines.splice(oldNotice, 1);
|
|
6403
|
+
const codexNotice = lines.findIndex((l) => l.startsWith("- Codex found, but"));
|
|
6404
|
+
if (codexNotice >= 0) lines.splice(codexNotice, 1);
|
|
5651
6405
|
const matrixAt = lines.indexOf("Capability matrix");
|
|
5652
6406
|
lines[matrixAt + 1] = renderCapabilities(CAPABILITIES.map((c) => c.agent === "Cursor" ? {
|
|
5653
6407
|
...c,
|
|
5654
6408
|
installedByInit: wantCursor
|
|
5655
|
-
} : c), detected);
|
|
6409
|
+
} : c.agent === "Codex" ? { ...c, installedByInit: wantCodex } : c), detected);
|
|
6410
|
+
if (wantCodex) {
|
|
6411
|
+
const codex = installCodexHooks(root);
|
|
6412
|
+
lines.splice(matrixAt, 0, codex.added.length ? `\u2713 Codex hooks added to .codex/hooks.json: ${codex.added.join(", ")}; run /hooks in Codex to trust this definition` : "\u2713 Codex hooks already present; run /hooks in Codex if the definition changed");
|
|
6413
|
+
} else {
|
|
6414
|
+
lines.splice(matrixAt, 0, "- Codex project hooks not installed: run replume init --codex; otherwise Codex agent steps will not be recorded in the ledger");
|
|
6415
|
+
}
|
|
5656
6416
|
if (wantCursor) {
|
|
5657
6417
|
const cursor = installCursorHooks(root, { command: opts.cursorHookCommand });
|
|
5658
6418
|
lines.splice(matrixAt, 0, cursor.added.length ? `\u2713 Cursor hooks added to .cursor/hooks.json: ${cursor.added.join(", ")}${cursor.backupPath ? ` (backup: ${cursor.backupPath})` : ""}` : "\u2713 Cursor hooks already present (nothing changed)");
|
|
@@ -5695,6 +6455,9 @@ addPreflightOptions(program.command("task")).description("Write the Task Contrac
|
|
|
5695
6455
|
fail(e.message);
|
|
5696
6456
|
}
|
|
5697
6457
|
});
|
|
6458
|
+
function passPathHint(c) {
|
|
6459
|
+
return c.test_command ? ` Scenarios are optional: verify can PASS when \`${c.test_command}\` runs at least one test and passes, nothing is EXTRA or TAMPERING, and the hooks record the agent changing each changed file. A scenario adds a stated behaviour check.` : " No test_command: without a test_command or a scenario, verify cannot reach PASS. Set test_command in .replume/contract.yaml (scenarios are optional).";
|
|
6460
|
+
}
|
|
5698
6461
|
addPreflightOptions(program.command("draft")).description("Propose a Task Contract from the repository and the task text (status: draft, not policy until confirmed)").requiredOption("--task <text>", "what the agent will be asked to do").option("--yes", "confirm at once (for CI): the draft becomes policy and the baseline is recorded").option("--accept-broad", "with --yes: also confirm allow globs broader than the paths the task names").option("--refine", "model refinement of the draft (not built)").action(async (opts) => {
|
|
5699
6462
|
if (opts.refine) fail("model refinement is not built; the draft is deterministic only (no model is called). Edit .replume/contract.yaml by hand instead.");
|
|
5700
6463
|
const root = repoRoot();
|
|
@@ -5725,8 +6488,10 @@ addPreflightOptions(program.command("draft")).description("Propose a Task Contra
|
|
|
5725
6488
|
console.log(serializeContract(c).trimEnd());
|
|
5726
6489
|
console.log("");
|
|
5727
6490
|
console.log(finalAllow);
|
|
6491
|
+
if (allowHasNoSource(c.allow)) console.log(` WARNING: ${ALLOW_NO_SOURCE_WARNING}`);
|
|
5728
6492
|
if (opts.yes) await preflightAndRecord(root, path, c, opts);
|
|
5729
6493
|
else console.log(" This draft is NOT policy yet: verify reports the contract as NOT CHECKED and the hooks ignore it. Review the file, then run `replume confirm`.");
|
|
6494
|
+
console.log(passPathHint(c));
|
|
5730
6495
|
} catch (e) {
|
|
5731
6496
|
fail(e.message);
|
|
5732
6497
|
}
|
|
@@ -5744,14 +6509,15 @@ addPreflightOptions(program.command("confirm")).description("Confirm the draft c
|
|
|
5744
6509
|
writeContract(path, confirmed);
|
|
5745
6510
|
console.log(`\u2713 confirmed ${REPLUME_DIR}/contract.yaml (${c.id}, status: confirmed)`);
|
|
5746
6511
|
await preflightAndRecord(root, path, confirmed, opts);
|
|
5747
|
-
console.log(
|
|
6512
|
+
console.log(passPathHint(confirmed));
|
|
6513
|
+
console.log(" commit .replume/contract.yaml before running the agent (and .replume/.gitignore, and any scenario files)");
|
|
5748
6514
|
} catch (e) {
|
|
5749
6515
|
fail(e.message);
|
|
5750
6516
|
}
|
|
5751
6517
|
});
|
|
5752
6518
|
program.command("watch").description("Live view of .replume/events.jsonl with rule hits").option("--once", "print what is there and exit").action((opts) => {
|
|
5753
6519
|
const path = ledgerPath(repoRoot());
|
|
5754
|
-
if (!
|
|
6520
|
+
if (!existsSync20(path)) console.log(`(no events yet at ${path}; waiting)`);
|
|
5755
6521
|
watchLedger(path, (l) => console.log(l), { once: opts.once });
|
|
5756
6522
|
});
|
|
5757
6523
|
program.command("verify").description("Run the contract and scenarios on a commit in an isolated working copy (exit 0 pass, 1 fail, 2 incomplete)").option("--commit <sha>", "commit to verify", "HEAD").option("--base <sha>", "commit to diff against").option("--trusted-base <ref>", "git ref (commit or protected branch) whose contract and scenarios are the policy; also the diff base unless --base is given").option("--json", "print the report as JSON").option("--keep-workdir", "keep the isolated working copy for debugging (worktree mode only)").addOption(
|
|
@@ -5765,7 +6531,7 @@ program.command("verify").description("Run the contract and scenarios on a commi
|
|
|
5765
6531
|
if (opts.checkRegistry) console.error("replume: opt-in registry check sends package names from dependency changes to registry.npmjs.org or pypi.org; no source file contents are sent by this lookup.");
|
|
5766
6532
|
try {
|
|
5767
6533
|
const root = repoRoot();
|
|
5768
|
-
const commit =
|
|
6534
|
+
const commit = execFileSync11("git", ["rev-parse", "--verify", `${opts.commit}^{commit}`], { cwd: root }).toString().trim();
|
|
5769
6535
|
const report = await runVerify2({
|
|
5770
6536
|
repoPath: root,
|
|
5771
6537
|
commit,
|
|
@@ -5785,7 +6551,7 @@ program.command("verify").description("Run the contract and scenarios on a commi
|
|
|
5785
6551
|
program.command("accept").description("Explicitly accept HEAD after a fresh verification PASS; only then count cost per accepted change").option("--base <sha>", "commit to diff against").addOption(new Option("--isolation <mode>", "verification mode").choices(["auto", "docker", "worktree"]).default("auto")).action(async (opts) => {
|
|
5786
6552
|
const root = repoRoot();
|
|
5787
6553
|
try {
|
|
5788
|
-
const commit =
|
|
6554
|
+
const commit = execFileSync11("git", ["rev-parse", "--verify", "HEAD^{commit}"], { cwd: root, encoding: "utf8" }).trim();
|
|
5789
6555
|
const report = await runVerify2({ repoPath: root, commit, baseCommit: opts.base, isolation: opts.isolation });
|
|
5790
6556
|
if (report.isolationMode === "worktree") console.error(WORKTREE_WARNING);
|
|
5791
6557
|
if (report.verdict !== "pass") fail(`accept requires a fresh verify PASS; this commit is ${report.verdict.toUpperCase()}`);
|
|
@@ -5799,15 +6565,15 @@ program.command("report").description("Re-print the report saved for a commit (.
|
|
|
5799
6565
|
const root = repoRoot();
|
|
5800
6566
|
let commit;
|
|
5801
6567
|
try {
|
|
5802
|
-
commit =
|
|
6568
|
+
commit = execFileSync11("git", ["rev-parse", "--verify", `${opts.commit}^{commit}`], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
|
|
5803
6569
|
} catch {
|
|
5804
6570
|
fail(`not a commit: ${opts.commit}`);
|
|
5805
6571
|
}
|
|
5806
|
-
const file2 =
|
|
5807
|
-
if (!
|
|
6572
|
+
const file2 = join25(root, REPLUME_DIR, "reports", `${commit}.json`);
|
|
6573
|
+
if (!existsSync20(file2)) fail(`no report for ${commit.slice(0, 7)} at ${file2}; run replume verify --commit ${opts.commit}`);
|
|
5808
6574
|
let report;
|
|
5809
6575
|
try {
|
|
5810
|
-
report = JSON.parse(
|
|
6576
|
+
report = JSON.parse(readFileSync19(file2, "utf-8"));
|
|
5811
6577
|
} catch (e) {
|
|
5812
6578
|
fail(`${file2} is not valid JSON: ${e.message}`);
|
|
5813
6579
|
}
|
|
@@ -5831,25 +6597,25 @@ demo.option("--replay <file>", "run a recorded events.jsonl session through the
|
|
|
5831
6597
|
console.log(renderReplay(replay(builtInEvents, builtInContract), builtInContract));
|
|
5832
6598
|
return;
|
|
5833
6599
|
}
|
|
5834
|
-
const file2 =
|
|
5835
|
-
if (!
|
|
6600
|
+
const file2 = resolve8(opts.replay);
|
|
6601
|
+
if (!existsSync20(file2)) fail(`replay file not found: ${file2}`);
|
|
5836
6602
|
try {
|
|
5837
6603
|
const sibling = file2.replace(/\.jsonl?$/, "") + ".contract.yaml";
|
|
5838
|
-
const contractFile = opts.contract ?
|
|
6604
|
+
const contractFile = opts.contract ? resolve8(opts.contract) : existsSync20(sibling) ? sibling : contractPath(repoRoot());
|
|
5839
6605
|
const contract = loadContract(contractFile);
|
|
5840
|
-
console.log(renderReplay(replay(
|
|
6606
|
+
console.log(renderReplay(replay(readFileSync19(file2, "utf-8"), contract), contract, contract ? contractFile : void 0));
|
|
5841
6607
|
} catch (e) {
|
|
5842
6608
|
fail(e.message);
|
|
5843
6609
|
}
|
|
5844
6610
|
});
|
|
5845
6611
|
async function demoReplayHooks(path, contractOpt) {
|
|
5846
|
-
const file2 =
|
|
5847
|
-
if (!
|
|
6612
|
+
const file2 = resolve8(path);
|
|
6613
|
+
if (!existsSync20(file2)) fail(`replay file not found: ${file2}`);
|
|
5848
6614
|
const sibling = file2.replace(/\.jsonl?$/, "") + ".contract.yaml";
|
|
5849
|
-
const contractFile = contractOpt ?
|
|
6615
|
+
const contractFile = contractOpt ? resolve8(contractOpt) : existsSync20(sibling) ? sibling : null;
|
|
5850
6616
|
try {
|
|
5851
|
-
const r = await replayHooks(
|
|
5852
|
-
contractYaml: contractFile ?
|
|
6617
|
+
const r = await replayHooks(readFileSync19(file2, "utf-8"), {
|
|
6618
|
+
contractYaml: contractFile ? readFileSync19(contractFile, "utf-8") : void 0,
|
|
5853
6619
|
env: process.env
|
|
5854
6620
|
});
|
|
5855
6621
|
console.log(REPLAY_HOOKS_BANNER);
|
|
@@ -5872,13 +6638,13 @@ program.command("install").description("[legacy, August MVP] Register SessionSta
|
|
|
5872
6638
|
});
|
|
5873
6639
|
async function legacyVerify(postGithubComment) {
|
|
5874
6640
|
const repoDir = process.cwd();
|
|
5875
|
-
const headSha = process.env.REPLUME_HEAD_SHA ??
|
|
6641
|
+
const headSha = process.env.REPLUME_HEAD_SHA ?? execFileSync11("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString().trim();
|
|
5876
6642
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? "/").split("/");
|
|
5877
6643
|
const prNumber = Number(process.env.REPLUME_PR_NUMBER ?? "0");
|
|
5878
6644
|
const baseRef = process.env.REPLUME_BASE_REF ?? "main";
|
|
5879
6645
|
let diff = "";
|
|
5880
6646
|
try {
|
|
5881
|
-
diff =
|
|
6647
|
+
diff = execFileSync11("git", ["diff", `origin/${baseRef}...HEAD`], { cwd: repoDir }).toString();
|
|
5882
6648
|
} catch (err) {
|
|
5883
6649
|
console.warn(`replume: could not compute diff against origin/${baseRef}, continuing with empty diff:`, err);
|
|
5884
6650
|
}
|
|
@@ -5897,6 +6663,12 @@ async function legacyVerify(postGithubComment) {
|
|
|
5897
6663
|
console.log(`replume verdict: ${verdict}`);
|
|
5898
6664
|
}
|
|
5899
6665
|
var hook = program.command("hook").description("Internal: agent hook entry points");
|
|
6666
|
+
hook.command("codex").description("Codex CLI hook handler").action(async () => {
|
|
6667
|
+
const out = handleCodexHook(await readStdin());
|
|
6668
|
+
if (out.stdout) process.stdout.write(out.stdout);
|
|
6669
|
+
if (out.stderr) process.stderr.write(out.stderr);
|
|
6670
|
+
process.exitCode = out.exitCode;
|
|
6671
|
+
});
|
|
5900
6672
|
hook.command("cursor").description("Cursor hook handler").action(async () => {
|
|
5901
6673
|
const out = handleCursorHook(await readStdin());
|
|
5902
6674
|
process.stdout.write(out.stdout);
|