tamperward 2.4.0 → 2.5.0
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/dist/cli/index.js +1858 -291
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -560,12 +560,12 @@ function matcher(glob) {
|
|
|
560
560
|
}
|
|
561
561
|
return m;
|
|
562
562
|
}
|
|
563
|
-
function matchesAny(path,
|
|
564
|
-
return !!
|
|
563
|
+
function matchesAny(path, globs3) {
|
|
564
|
+
return !!globs3 && globs3.some((g) => matcher(g)(path));
|
|
565
565
|
}
|
|
566
566
|
function protectedCategory(path, policy) {
|
|
567
|
-
for (const [cat,
|
|
568
|
-
if (matchesAny(path,
|
|
567
|
+
for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
|
|
568
|
+
if (matchesAny(path, globs3)) return cat;
|
|
569
569
|
}
|
|
570
570
|
return null;
|
|
571
571
|
}
|
|
@@ -581,9 +581,9 @@ function isIgnored(path, policy) {
|
|
|
581
581
|
}
|
|
582
582
|
function mergeProtected(base, user) {
|
|
583
583
|
const out2 = { ...base };
|
|
584
|
-
for (const [cat,
|
|
584
|
+
for (const [cat, globs3] of Object.entries(user ?? {})) {
|
|
585
585
|
const have = new Set(out2[cat] ?? []);
|
|
586
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
586
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
|
|
587
587
|
}
|
|
588
588
|
return out2;
|
|
589
589
|
}
|
|
@@ -631,7 +631,19 @@ function defaultPolicy(version = 1) {
|
|
|
631
631
|
"**/tsconfig*.json",
|
|
632
632
|
"**/.eslintrc*",
|
|
633
633
|
"**/eslint.config.*",
|
|
634
|
-
"**/package.json"
|
|
634
|
+
"**/package.json",
|
|
635
|
+
// Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
|
|
636
|
+
// see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
|
|
637
|
+
// codecov `target` lived in files no glob protected, so lowering them fired
|
|
638
|
+
// nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
|
|
639
|
+
// as a strengthening). pyproject.toml carries [tool.coverage.report].
|
|
640
|
+
"**/.coveragerc",
|
|
641
|
+
"**/pyproject.toml",
|
|
642
|
+
"**/.nycrc",
|
|
643
|
+
"**/.nycrc.*",
|
|
644
|
+
"**/nyc.config.*",
|
|
645
|
+
"**/codecov.yml",
|
|
646
|
+
"**/.codecov.yml"
|
|
635
647
|
],
|
|
636
648
|
ci: [".github/workflows/**"],
|
|
637
649
|
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
@@ -735,9 +747,11 @@ var IMPORTS = {
|
|
|
735
747
|
cs: /^(?:using\b|namespace\b)/
|
|
736
748
|
};
|
|
737
749
|
var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
|
|
750
|
+
var DEBUG_ONLY = /^(?:console\.\w+\(.*\)\s*;?|debugger\s*;?)$/;
|
|
738
751
|
function isSignificantLine(trimmed, lang) {
|
|
739
752
|
if (trimmed.length < 10) return false;
|
|
740
753
|
if (COMMON.test(trimmed)) return false;
|
|
754
|
+
if (DEBUG_ONLY.test(trimmed)) return false;
|
|
741
755
|
const l = lang ?? "js";
|
|
742
756
|
if (IMPORTS[l].test(trimmed)) return false;
|
|
743
757
|
if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
|
|
@@ -1075,7 +1089,47 @@ var testSkip = {
|
|
|
1075
1089
|
};
|
|
1076
1090
|
|
|
1077
1091
|
// src/detectors/coverage-lowering.ts
|
|
1092
|
+
import picomatch2 from "picomatch";
|
|
1078
1093
|
import ts2 from "typescript";
|
|
1094
|
+
|
|
1095
|
+
// src/detectors/repo.ts
|
|
1096
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1097
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
1098
|
+
function trackedFiles(ctx) {
|
|
1099
|
+
if (!ctx) return null;
|
|
1100
|
+
if (ctx.trackedFiles) return ctx.trackedFiles;
|
|
1101
|
+
if (!ctx.cwd) return null;
|
|
1102
|
+
const hit = cache2.get(ctx.cwd);
|
|
1103
|
+
if (hit !== void 0) return hit;
|
|
1104
|
+
let files = null;
|
|
1105
|
+
try {
|
|
1106
|
+
files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1107
|
+
cwd: ctx.cwd,
|
|
1108
|
+
encoding: "utf8",
|
|
1109
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1110
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1111
|
+
}).split("\0").filter(Boolean);
|
|
1112
|
+
} catch {
|
|
1113
|
+
files = null;
|
|
1114
|
+
}
|
|
1115
|
+
cache2.set(ctx.cwd, files);
|
|
1116
|
+
return files;
|
|
1117
|
+
}
|
|
1118
|
+
var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1119
|
+
function containsProtected(token, policy, category, ctx) {
|
|
1120
|
+
const dir = dirOf(token);
|
|
1121
|
+
if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
|
|
1122
|
+
const files = trackedFiles(ctx);
|
|
1123
|
+
if (files) {
|
|
1124
|
+
const prefix = dir + "/";
|
|
1125
|
+
return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
|
|
1126
|
+
}
|
|
1127
|
+
if (isProtected(dir + "/probe", policy, category)) return true;
|
|
1128
|
+
const base = dir.split("/").pop() ?? "";
|
|
1129
|
+
return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/detectors/coverage-lowering.ts
|
|
1079
1133
|
var RULE4 = "coverage-lowering";
|
|
1080
1134
|
var METRICS = ["branches", "functions", "lines", "statements"];
|
|
1081
1135
|
var isMetric = (k) => METRICS.includes(k);
|
|
@@ -1086,29 +1140,36 @@ function keyName(name) {
|
|
|
1086
1140
|
}
|
|
1087
1141
|
function numericOf(e) {
|
|
1088
1142
|
if (ts2.isNumericLiteral(e)) return Number(e.text);
|
|
1143
|
+
if (ts2.isStringLiteral(e) && /^-?\d+(?:\.\d+)?$/.test(e.text.trim())) return Number(e.text);
|
|
1089
1144
|
if (ts2.isPrefixUnaryExpression(e) && e.operator === ts2.SyntaxKind.MinusToken && ts2.isNumericLiteral(e.operand)) {
|
|
1090
1145
|
return -Number(e.operand.text);
|
|
1091
1146
|
}
|
|
1092
1147
|
return void 0;
|
|
1093
1148
|
}
|
|
1094
|
-
function metricsOf(
|
|
1149
|
+
function metricsOf(expr, resolve5) {
|
|
1150
|
+
const obj = resolve5(expr);
|
|
1095
1151
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
1096
|
-
const out2 = {};
|
|
1152
|
+
const out2 = { values: {}, opaque: false };
|
|
1097
1153
|
let any = false;
|
|
1098
1154
|
for (const p of obj.properties) {
|
|
1155
|
+
if (ts2.isSpreadAssignment(p)) {
|
|
1156
|
+
out2.opaque = true;
|
|
1157
|
+
any = true;
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1099
1160
|
if (!ts2.isPropertyAssignment(p)) continue;
|
|
1100
1161
|
const k = keyName(p.name);
|
|
1101
1162
|
if (k === null || !isMetric(k)) continue;
|
|
1102
|
-
const n = numericOf(p.initializer);
|
|
1103
|
-
if (n === void 0) continue;
|
|
1104
|
-
out2[k] = n;
|
|
1105
1163
|
any = true;
|
|
1164
|
+
const n = numericOf(resolve5(p.initializer));
|
|
1165
|
+
if (n === void 0) out2.opaque = true;
|
|
1166
|
+
else out2.values[k] = n;
|
|
1106
1167
|
}
|
|
1107
1168
|
return any ? out2 : void 0;
|
|
1108
1169
|
}
|
|
1109
|
-
function
|
|
1170
|
+
function underKey(node, key2) {
|
|
1110
1171
|
for (let p = node.parent; p; p = p.parent) {
|
|
1111
|
-
if (ts2.isPropertyAssignment(p) && keyName(p.name) ===
|
|
1172
|
+
if (ts2.isPropertyAssignment(p) && keyName(p.name) === key2) return true;
|
|
1112
1173
|
}
|
|
1113
1174
|
return false;
|
|
1114
1175
|
}
|
|
@@ -1116,25 +1177,54 @@ function asExpression(src) {
|
|
|
1116
1177
|
const t = src.trim();
|
|
1117
1178
|
return t.startsWith("{") ? `(${t})` : src;
|
|
1118
1179
|
}
|
|
1180
|
+
function sourceOf(src) {
|
|
1181
|
+
return ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
|
|
1182
|
+
}
|
|
1183
|
+
function resolverFor(sf) {
|
|
1184
|
+
const consts = /* @__PURE__ */ new Map();
|
|
1185
|
+
for (const st of sf.statements) {
|
|
1186
|
+
if (!ts2.isVariableStatement(st)) continue;
|
|
1187
|
+
for (const d of st.declarationList.declarations) {
|
|
1188
|
+
if (ts2.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return (e) => {
|
|
1192
|
+
if (ts2.isIdentifier(e)) {
|
|
1193
|
+
const bound = consts.get(e.text);
|
|
1194
|
+
return bound ?? e;
|
|
1195
|
+
}
|
|
1196
|
+
return e;
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1119
1199
|
function parseThresholds(src) {
|
|
1120
|
-
const res = { paths: /* @__PURE__ */ new Map(), present: false };
|
|
1200
|
+
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
1121
1201
|
try {
|
|
1122
|
-
const sf =
|
|
1202
|
+
const sf = sourceOf(src);
|
|
1203
|
+
const resolve5 = resolverFor(sf);
|
|
1123
1204
|
const visit = (node) => {
|
|
1124
|
-
if (ts2.isPropertyAssignment(node)
|
|
1205
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1125
1206
|
const key2 = keyName(node.name);
|
|
1126
|
-
if (key2 === "coverageThreshold" || key2 === "thresholds" &&
|
|
1207
|
+
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
1127
1208
|
res.present = true;
|
|
1128
|
-
const
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1133
|
-
if (
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1209
|
+
const init = resolve5(node.initializer);
|
|
1210
|
+
if (!ts2.isObjectLiteralExpression(init)) {
|
|
1211
|
+
res.opaque = true;
|
|
1212
|
+
} else {
|
|
1213
|
+
const flat = metricsOf(init, resolve5);
|
|
1214
|
+
if (flat) res.global = merge(res.global, flat);
|
|
1215
|
+
for (const p of init.properties) {
|
|
1216
|
+
if (ts2.isSpreadAssignment(p)) {
|
|
1217
|
+
res.opaque = true;
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (!ts2.isPropertyAssignment(p)) continue;
|
|
1221
|
+
const k = keyName(p.name);
|
|
1222
|
+
if (k === null || isMetric(k)) continue;
|
|
1223
|
+
const m = metricsOf(p.initializer, resolve5);
|
|
1224
|
+
if (!m) continue;
|
|
1225
|
+
if (k === "global") res.global = merge(res.global, m);
|
|
1226
|
+
else res.paths.set(norm(k), m);
|
|
1227
|
+
}
|
|
1138
1228
|
}
|
|
1139
1229
|
}
|
|
1140
1230
|
}
|
|
@@ -1145,41 +1235,242 @@ function parseThresholds(src) {
|
|
|
1145
1235
|
}
|
|
1146
1236
|
return res;
|
|
1147
1237
|
}
|
|
1148
|
-
|
|
1238
|
+
function merge(a, b) {
|
|
1239
|
+
return { values: { ...a?.values ?? {}, ...b.values }, opaque: (a?.opaque ?? false) || b.opaque };
|
|
1240
|
+
}
|
|
1241
|
+
function literals(e) {
|
|
1242
|
+
if (ts2.isStringLiteral(e)) return { items: [e.text], opaque: false };
|
|
1243
|
+
if (!ts2.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
1244
|
+
const items = [];
|
|
1245
|
+
let opaque = false;
|
|
1246
|
+
for (const el of e.elements) {
|
|
1247
|
+
if (ts2.isStringLiteral(el) || ts2.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
|
|
1248
|
+
else opaque = true;
|
|
1249
|
+
}
|
|
1250
|
+
return { items, opaque };
|
|
1251
|
+
}
|
|
1252
|
+
function parseLists(src) {
|
|
1253
|
+
const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1254
|
+
try {
|
|
1255
|
+
const sf = sourceOf(src);
|
|
1256
|
+
const resolve5 = resolverFor(sf);
|
|
1257
|
+
const visit = (node) => {
|
|
1258
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1259
|
+
const k = keyName(node.name);
|
|
1260
|
+
if (k === "collectCoverageFrom") {
|
|
1261
|
+
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1262
|
+
res.collectFrom = items;
|
|
1263
|
+
if (opaque) res.opaqueCollect = true;
|
|
1264
|
+
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
|
|
1265
|
+
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1266
|
+
res.ignore.push(...items);
|
|
1267
|
+
if (opaque) res.opaqueIgnore = true;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
ts2.forEachChild(node, visit);
|
|
1271
|
+
};
|
|
1272
|
+
visit(sf);
|
|
1273
|
+
} catch {
|
|
1274
|
+
}
|
|
1275
|
+
return res;
|
|
1276
|
+
}
|
|
1277
|
+
var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)\.|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
|
|
1149
1278
|
function compareMetrics(scope, before, after, out2) {
|
|
1150
1279
|
if (!before) return;
|
|
1151
1280
|
for (const m of METRICS) {
|
|
1152
|
-
const bv = before[m];
|
|
1281
|
+
const bv = before.values[m];
|
|
1153
1282
|
if (bv === void 0) continue;
|
|
1154
|
-
const av = after?.[m];
|
|
1155
|
-
if (av === void 0)
|
|
1156
|
-
|
|
1283
|
+
const av = after?.values[m];
|
|
1284
|
+
if (av === void 0) {
|
|
1285
|
+
if (!after?.opaque) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
|
|
1286
|
+
} else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
function overrideTargetExists(key2, configPath, ctx) {
|
|
1290
|
+
const files = trackedFiles(ctx);
|
|
1291
|
+
if (!files) return null;
|
|
1292
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
1293
|
+
const rel = norm(key2);
|
|
1294
|
+
let m;
|
|
1295
|
+
try {
|
|
1296
|
+
m = /[*?{}[\]]/.test(rel) ? picomatch2(rel, { dot: true }) : (p) => p === rel;
|
|
1297
|
+
} catch {
|
|
1298
|
+
return null;
|
|
1157
1299
|
}
|
|
1300
|
+
return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
|
|
1158
1301
|
}
|
|
1159
|
-
function weakenings(before, after) {
|
|
1302
|
+
function weakenings(before, after, configPath, ctx) {
|
|
1160
1303
|
const out2 = [];
|
|
1161
1304
|
if (before.present && !after.present) {
|
|
1162
1305
|
out2.push("the coverage threshold gate was removed");
|
|
1163
1306
|
return out2;
|
|
1164
1307
|
}
|
|
1308
|
+
if (after.opaque && !after.global && after.paths.size === 0) return out2;
|
|
1165
1309
|
compareMetrics("global", before.global, after.global, out2);
|
|
1166
1310
|
const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
|
|
1167
1311
|
for (const p of paths) {
|
|
1168
|
-
|
|
1312
|
+
const scope = `required coverage for ${p}:`;
|
|
1313
|
+
const b = before.paths.get(p);
|
|
1314
|
+
const a = after.paths.get(p);
|
|
1315
|
+
if (b && a) {
|
|
1316
|
+
compareMetrics(scope, b, a, out2);
|
|
1317
|
+
} else if (a) {
|
|
1318
|
+
const g = before.global;
|
|
1319
|
+
if (!g) continue;
|
|
1320
|
+
for (const m of METRICS) {
|
|
1321
|
+
const gv = g.values[m];
|
|
1322
|
+
const av = a.values[m];
|
|
1323
|
+
if (gv !== void 0 && av !== void 0 && av < gv) out2.push(`${scope} ${m} threshold lowered ${gv} \u2192 ${av}`);
|
|
1324
|
+
}
|
|
1325
|
+
} else if (b) {
|
|
1326
|
+
if (overrideTargetExists(p, configPath, ctx) === false) continue;
|
|
1327
|
+
compareMetrics(scope, b, after.global, out2);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return out2;
|
|
1331
|
+
}
|
|
1332
|
+
function narrowings(before, after) {
|
|
1333
|
+
const out2 = [];
|
|
1334
|
+
if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
|
|
1335
|
+
const kept = new Set(after.collectFrom);
|
|
1336
|
+
for (const g of before.collectFrom) {
|
|
1337
|
+
if (!g.startsWith("!") && !kept.has(g)) out2.push(`collectCoverageFrom no longer measures ${JSON.stringify(g)}`);
|
|
1338
|
+
}
|
|
1339
|
+
const had2 = new Set(before.collectFrom);
|
|
1340
|
+
for (const g of after.collectFrom) {
|
|
1341
|
+
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`collectCoverageFrom now exempts ${JSON.stringify(g)}`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const had = new Set(before.ignore);
|
|
1345
|
+
for (const g of after.ignore) {
|
|
1346
|
+
if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns / coverage.exclude)`);
|
|
1347
|
+
}
|
|
1348
|
+
return out2;
|
|
1349
|
+
}
|
|
1350
|
+
var SIMPLE_FILES = [
|
|
1351
|
+
{
|
|
1352
|
+
// coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
|
|
1353
|
+
// [tool.coverage.report] (pyproject.toml)
|
|
1354
|
+
file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
|
|
1355
|
+
keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
|
|
1356
|
+
removable: true
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
// nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
|
|
1360
|
+
file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
|
|
1361
|
+
keys: METRICS.map((m) => ({
|
|
1362
|
+
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1363
|
+
label: m,
|
|
1364
|
+
weakens: "lower"
|
|
1365
|
+
})),
|
|
1366
|
+
removable: false
|
|
1367
|
+
},
|
|
1368
|
+
{
|
|
1369
|
+
// codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
|
|
1370
|
+
// permitted drop (raising weakens)
|
|
1371
|
+
file: /(?:^|\/)\.?codecov\.yml$/,
|
|
1372
|
+
keys: [
|
|
1373
|
+
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
|
|
1374
|
+
{ key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
|
|
1375
|
+
],
|
|
1376
|
+
removable: false
|
|
1377
|
+
}
|
|
1378
|
+
];
|
|
1379
|
+
function numbersOf(src, key2) {
|
|
1380
|
+
const out2 = [];
|
|
1381
|
+
for (const line of src.split("\n")) {
|
|
1382
|
+
if (key2.global) {
|
|
1383
|
+
for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
|
|
1384
|
+
} else {
|
|
1385
|
+
const m = line.match(key2);
|
|
1386
|
+
if (m) out2.push(Number(m[1]));
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return out2;
|
|
1390
|
+
}
|
|
1391
|
+
function simpleWeakenings(path, before, after) {
|
|
1392
|
+
const out2 = [];
|
|
1393
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1394
|
+
if (!spec) return out2;
|
|
1395
|
+
for (const k of spec.keys) {
|
|
1396
|
+
const b = numbersOf(before, k.key).sort((x, y) => y - x);
|
|
1397
|
+
const a = numbersOf(after, k.key).sort((x, y) => y - x);
|
|
1398
|
+
if (b.length === 0) continue;
|
|
1399
|
+
if (a.length === 0) {
|
|
1400
|
+
if (spec.removable) out2.push(`${k.label} removed (was ${b.join(", ")})`);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (k.weakens === "lower") {
|
|
1404
|
+
for (let i = 0; i < b.length; i++) {
|
|
1405
|
+
const av = a[Math.min(i, a.length - 1)];
|
|
1406
|
+
if (av < b[i]) {
|
|
1407
|
+
out2.push(`${k.label} lowered ${b[i]} \u2192 ${av}`);
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
} else {
|
|
1412
|
+
const bMax = b[0];
|
|
1413
|
+
const aMax = a[0];
|
|
1414
|
+
if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
|
|
1415
|
+
}
|
|
1169
1416
|
}
|
|
1170
1417
|
return out2;
|
|
1171
1418
|
}
|
|
1419
|
+
var SWITCHES = [
|
|
1420
|
+
{
|
|
1421
|
+
re: /--passWithNoTests\b/,
|
|
1422
|
+
message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
|
|
1423
|
+
remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
re: /\bpassWithNoTests"?\s*:\s*true\b/,
|
|
1427
|
+
message: "passWithNoTests: true added to the runner config \u2014 the suite can now pass with zero tests.",
|
|
1428
|
+
remediation: "Remove passWithNoTests; an empty suite must not be a green check."
|
|
1429
|
+
},
|
|
1430
|
+
{
|
|
1431
|
+
re: /--coverage=false\b|--no-coverage\b|--coverage\.enabled=false\b/,
|
|
1432
|
+
message: "coverage switched off on the command line (--coverage=false).",
|
|
1433
|
+
remediation: "Keep coverage reporting on; do not switch it off to dodge the gate."
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
re: /--coverageThreshold[= ]/,
|
|
1437
|
+
message: "the coverage threshold is overridden on the command line (--coverageThreshold), which replaces the configured gate.",
|
|
1438
|
+
remediation: "Remove the override; the configured threshold is the gate."
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
re: /\bautoUpdate"?\s*:\s*true\b/,
|
|
1442
|
+
message: "thresholds.autoUpdate: true added \u2014 the gate now follows the measured number.",
|
|
1443
|
+
remediation: "Remove autoUpdate; a threshold that tracks the result gates nothing."
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
re: /"?check-coverage"?\s*[:=]\s*false\b/,
|
|
1447
|
+
message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
|
|
1448
|
+
remediation: "Keep check-coverage on; raise real coverage instead."
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
re: /^\s*informational:\s*true\b/,
|
|
1452
|
+
message: "informational: true added \u2014 the codecov status can no longer fail.",
|
|
1453
|
+
remediation: "Remove informational; a status that cannot fail is not a gate."
|
|
1454
|
+
}
|
|
1455
|
+
];
|
|
1456
|
+
var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
|
|
1172
1457
|
var coverageLowering = {
|
|
1173
1458
|
id: RULE4,
|
|
1174
1459
|
surface: ["file"],
|
|
1175
1460
|
certainty: "mechanical",
|
|
1176
|
-
run(changes, policy) {
|
|
1461
|
+
run(changes, policy, _view, ctx) {
|
|
1177
1462
|
const out2 = [];
|
|
1178
1463
|
for (const c of changes) {
|
|
1179
1464
|
if (c.kind !== "file") continue;
|
|
1180
1465
|
if (!isProtected(c.path, policy, "config")) continue;
|
|
1181
1466
|
if (c.after != null && c.op !== "delete") {
|
|
1182
|
-
|
|
1467
|
+
const reasons = [];
|
|
1468
|
+
if (isJsConfig(c.path)) {
|
|
1469
|
+
reasons.push(...weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after), c.path, ctx));
|
|
1470
|
+
reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after)));
|
|
1471
|
+
}
|
|
1472
|
+
reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
|
|
1473
|
+
for (const reason of reasons) {
|
|
1183
1474
|
out2.push(
|
|
1184
1475
|
makeFinding(RULE4, policy, {
|
|
1185
1476
|
file: c.path,
|
|
@@ -1190,7 +1481,9 @@ var coverageLowering = {
|
|
|
1190
1481
|
);
|
|
1191
1482
|
}
|
|
1192
1483
|
} else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
|
|
1193
|
-
|
|
1484
|
+
const before = c.before ?? "";
|
|
1485
|
+
const gated = isJsConfig(c.path) ? parseThresholds(before).present : simpleWeakenings(c.path, before, "").length > 0 || SIMPLE_FILES.some((s) => s.file.test(c.path) && s.keys.some((k) => numbersOf(before, k.key).length));
|
|
1486
|
+
if (gated) {
|
|
1194
1487
|
out2.push(
|
|
1195
1488
|
makeFinding(RULE4, policy, {
|
|
1196
1489
|
file: c.path,
|
|
@@ -1215,20 +1508,22 @@ var coverageLowering = {
|
|
|
1215
1508
|
);
|
|
1216
1509
|
}
|
|
1217
1510
|
for (const l of added) {
|
|
1218
|
-
|
|
1511
|
+
for (const s of SWITCHES) {
|
|
1512
|
+
if (!s.re.test(l.content)) continue;
|
|
1513
|
+
if (removed.some((r) => s.re.test(r.content))) continue;
|
|
1219
1514
|
out2.push(
|
|
1220
1515
|
makeFinding(RULE4, policy, {
|
|
1221
1516
|
file: c.path,
|
|
1222
1517
|
line: l.newLine ?? void 0,
|
|
1223
|
-
message:
|
|
1518
|
+
message: s.message,
|
|
1224
1519
|
evidence: l.content.trim(),
|
|
1225
|
-
remediation:
|
|
1520
|
+
remediation: s.remediation
|
|
1226
1521
|
})
|
|
1227
1522
|
);
|
|
1228
1523
|
}
|
|
1229
1524
|
}
|
|
1230
1525
|
if (c.after == null) {
|
|
1231
|
-
const KEY = /\b(branches|functions|lines|statements)\b\s
|
|
1526
|
+
const KEY = /\b(branches|functions|lines|statements|fail[_-]under|target)\b"?\s*[:=]\s*"?(\d+(?:\.\d+)?)/;
|
|
1232
1527
|
const oldNums = /* @__PURE__ */ new Map();
|
|
1233
1528
|
for (const l of removed) {
|
|
1234
1529
|
const m = l.content.match(KEY);
|
|
@@ -1254,7 +1549,7 @@ var coverageLowering = {
|
|
|
1254
1549
|
}
|
|
1255
1550
|
const seen = /* @__PURE__ */ new Set();
|
|
1256
1551
|
return out2.filter((f) => {
|
|
1257
|
-
const k = `${f.rule}|${f.message}`;
|
|
1552
|
+
const k = `${f.rule}|${f.file ?? ""}|${f.message}`;
|
|
1258
1553
|
if (seen.has(k)) return false;
|
|
1259
1554
|
seen.add(k);
|
|
1260
1555
|
return true;
|
|
@@ -1276,23 +1571,287 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
|
|
|
1276
1571
|
function commandCore(line) {
|
|
1277
1572
|
return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
|
|
1278
1573
|
}
|
|
1574
|
+
function canonical(core) {
|
|
1575
|
+
let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1576
|
+
s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
|
|
1577
|
+
s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
|
|
1578
|
+
s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
|
|
1579
|
+
s = s.replace(/^exec\s+/, "");
|
|
1580
|
+
return s.replace(/\s+--\s*$/, "").trim();
|
|
1581
|
+
}
|
|
1582
|
+
var usesRef = (core) => core.replace(/@.*$/, "");
|
|
1583
|
+
var NEUTRALISING_SUFFIX = /\|\||;|(?<!&)&(?!&)|\|(?!\|)|--passWithNoTests\b|--coverage=false\b|--coverageThreshold\b|--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b/;
|
|
1584
|
+
function survives(removedCore, isUses, afterCores) {
|
|
1585
|
+
if (isUses) {
|
|
1586
|
+
const ref = usesRef(removedCore);
|
|
1587
|
+
return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
|
|
1588
|
+
}
|
|
1589
|
+
const r = canonical(removedCore);
|
|
1590
|
+
let neutralised;
|
|
1591
|
+
for (const raw of afterCores) {
|
|
1592
|
+
const a = canonical(raw);
|
|
1593
|
+
if (a === r) return { state: "kept" };
|
|
1594
|
+
if (a.startsWith(r + " ")) {
|
|
1595
|
+
const rest = a.slice(r.length);
|
|
1596
|
+
if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
|
|
1597
|
+
else return { state: "kept" };
|
|
1598
|
+
} else if (r.startsWith(a + " ") && invokesCheck(a)) {
|
|
1599
|
+
return { state: "kept" };
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
|
|
1603
|
+
}
|
|
1279
1604
|
var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
|
|
1280
|
-
function
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1605
|
+
function lex(src) {
|
|
1606
|
+
const out2 = [];
|
|
1607
|
+
let i = 0;
|
|
1608
|
+
while (i < src.length) {
|
|
1609
|
+
const ch = src[i];
|
|
1610
|
+
if (/\s/.test(ch)) {
|
|
1611
|
+
i++;
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
if (ch === "'") {
|
|
1615
|
+
let j = i + 1;
|
|
1616
|
+
let s = "";
|
|
1617
|
+
while (j < src.length) {
|
|
1618
|
+
if (src[j] === "'" && src[j + 1] === "'") {
|
|
1619
|
+
s += "'";
|
|
1620
|
+
j += 2;
|
|
1621
|
+
} else if (src[j] === "'") break;
|
|
1622
|
+
else s += src[j++];
|
|
1623
|
+
}
|
|
1624
|
+
if (j >= src.length) return null;
|
|
1625
|
+
out2.push({ t: "str", v: s });
|
|
1626
|
+
i = j + 1;
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
|
|
1630
|
+
if (num) {
|
|
1631
|
+
out2.push({ t: "num", v: num[0] });
|
|
1632
|
+
i += num[0].length;
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
|
|
1636
|
+
if (op) {
|
|
1637
|
+
out2.push({ t: "op", v: op[0] });
|
|
1638
|
+
i += op[0].length;
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
|
|
1642
|
+
if (id) {
|
|
1643
|
+
out2.push({ t: "id", v: id[0] });
|
|
1644
|
+
i += id[0].length;
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
return out2;
|
|
1650
|
+
}
|
|
1651
|
+
function foldConst(src) {
|
|
1652
|
+
const toks = lex(src);
|
|
1653
|
+
if (!toks) return void 0;
|
|
1654
|
+
let p = 0;
|
|
1655
|
+
const peek = () => toks[p];
|
|
1656
|
+
const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
|
|
1657
|
+
const truthy = (v2) => typeof v2 === "string" ? v2 !== "" : typeof v2 === "number" ? v2 !== 0 : v2 === true;
|
|
1658
|
+
const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1659
|
+
const eq = (a, b) => {
|
|
1660
|
+
if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
|
|
1661
|
+
if (a === null && b === null) return true;
|
|
1662
|
+
const x = num(a);
|
|
1663
|
+
const y = num(b);
|
|
1664
|
+
return !Number.isNaN(x) && x === y;
|
|
1665
|
+
};
|
|
1666
|
+
const or = () => {
|
|
1667
|
+
let l = and();
|
|
1668
|
+
while (eat("||")) {
|
|
1669
|
+
const r = and();
|
|
1670
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1671
|
+
l = truthy(l) ? l : r;
|
|
1672
|
+
}
|
|
1673
|
+
return l;
|
|
1674
|
+
};
|
|
1675
|
+
const and = () => {
|
|
1676
|
+
let l = cmp();
|
|
1677
|
+
while (eat("&&")) {
|
|
1678
|
+
const r = cmp();
|
|
1679
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1680
|
+
l = truthy(l) ? r : l;
|
|
1681
|
+
}
|
|
1682
|
+
return l;
|
|
1683
|
+
};
|
|
1684
|
+
const cmp = () => {
|
|
1685
|
+
let l = unary();
|
|
1686
|
+
for (; ; ) {
|
|
1687
|
+
const t = peek();
|
|
1688
|
+
if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
|
|
1689
|
+
p++;
|
|
1690
|
+
const r = unary();
|
|
1691
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1692
|
+
if (t.v === "==") l = eq(l, r);
|
|
1693
|
+
else if (t.v === "!=") l = !eq(l, r);
|
|
1694
|
+
else {
|
|
1695
|
+
const x = num(l);
|
|
1696
|
+
const y = num(r);
|
|
1697
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return void 0;
|
|
1698
|
+
l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
const unary = () => {
|
|
1703
|
+
if (eat("!")) {
|
|
1704
|
+
const v2 = unary();
|
|
1705
|
+
return v2 === void 0 ? void 0 : !truthy(v2);
|
|
1706
|
+
}
|
|
1707
|
+
return primary();
|
|
1708
|
+
};
|
|
1709
|
+
const primary = () => {
|
|
1710
|
+
const t = peek();
|
|
1711
|
+
if (!t) return void 0;
|
|
1712
|
+
if (eat("(")) {
|
|
1713
|
+
const v2 = or();
|
|
1714
|
+
return eat(")") ? v2 : void 0;
|
|
1715
|
+
}
|
|
1716
|
+
p++;
|
|
1717
|
+
if (t.t === "str") return t.v;
|
|
1718
|
+
if (t.t === "num") return Number(t.v);
|
|
1719
|
+
if (t.t === "id") {
|
|
1720
|
+
if (/^true$/i.test(t.v)) return true;
|
|
1721
|
+
if (/^false$/i.test(t.v)) return false;
|
|
1722
|
+
if (/^null$/i.test(t.v)) return null;
|
|
1723
|
+
return void 0;
|
|
1724
|
+
}
|
|
1725
|
+
return void 0;
|
|
1726
|
+
};
|
|
1727
|
+
const v = or();
|
|
1728
|
+
return p === toks.length ? v : void 0;
|
|
1729
|
+
}
|
|
1730
|
+
function expressionOf(raw) {
|
|
1731
|
+
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1283
1732
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
const
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1733
|
+
return m ? m[1] : v;
|
|
1734
|
+
}
|
|
1735
|
+
function isAlwaysFalse(raw) {
|
|
1736
|
+
const e = expressionOf(raw);
|
|
1737
|
+
if (e === "") return false;
|
|
1738
|
+
const v = foldConst(e);
|
|
1739
|
+
return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
|
|
1290
1740
|
}
|
|
1291
1741
|
function isTruthy(raw) {
|
|
1292
|
-
const v = uncommented(raw);
|
|
1293
|
-
if (/^true$/i.test(v)) return true;
|
|
1742
|
+
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1743
|
+
if (/^(?:true|yes|on|1)$/i.test(v)) return true;
|
|
1294
1744
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1295
|
-
|
|
1745
|
+
if (!m) return false;
|
|
1746
|
+
const f = foldConst(m[1]);
|
|
1747
|
+
return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
|
|
1748
|
+
}
|
|
1749
|
+
var indentOf = (l) => l.match(/^\s*/)[0].length;
|
|
1750
|
+
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
1751
|
+
function stepOf(lines, i) {
|
|
1752
|
+
const ind = indentOf(lines[i]);
|
|
1753
|
+
let s = i - 1;
|
|
1754
|
+
while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
|
|
1755
|
+
if (s < 0 || !STEP_START.test(lines[s])) return null;
|
|
1756
|
+
const stepInd = indentOf(lines[s]);
|
|
1757
|
+
let e = i + 1;
|
|
1758
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
|
|
1759
|
+
return [s, e];
|
|
1760
|
+
}
|
|
1761
|
+
var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
|
|
1762
|
+
function runBlocks(lines) {
|
|
1763
|
+
const out2 = [];
|
|
1764
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1765
|
+
if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
|
|
1766
|
+
const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
|
|
1767
|
+
let e = i + 1;
|
|
1768
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
|
|
1769
|
+
out2.push({ start: i, end: e, body: lines.slice(i + 1, e) });
|
|
1770
|
+
}
|
|
1771
|
+
return out2;
|
|
1772
|
+
}
|
|
1773
|
+
var NEUTRALISERS = [
|
|
1774
|
+
{ re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
|
|
1775
|
+
{ re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
|
|
1776
|
+
{ re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
|
|
1777
|
+
{ re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
|
|
1778
|
+
];
|
|
1779
|
+
function flowList(v) {
|
|
1780
|
+
const m = v.trim().match(/^\[(.*)\]$/);
|
|
1781
|
+
if (!m) return null;
|
|
1782
|
+
return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
|
|
1783
|
+
}
|
|
1784
|
+
function parseTriggers(src) {
|
|
1785
|
+
const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
|
|
1786
|
+
const lines = src.split("\n");
|
|
1787
|
+
const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
|
|
1788
|
+
if (i < 0) return t;
|
|
1789
|
+
t.present = true;
|
|
1790
|
+
const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
|
|
1791
|
+
const inline = uncommented(head);
|
|
1792
|
+
if (inline) {
|
|
1793
|
+
const list = flowList(inline);
|
|
1794
|
+
for (const e of list ?? [inline]) t.events.add(e);
|
|
1795
|
+
return t;
|
|
1796
|
+
}
|
|
1797
|
+
let event = null;
|
|
1798
|
+
let listKey = null;
|
|
1799
|
+
let listInd = -1;
|
|
1800
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1801
|
+
const l = lines[j];
|
|
1802
|
+
if (l.trim() === "" || /^\s*#/.test(l)) continue;
|
|
1803
|
+
const ind = indentOf(l);
|
|
1804
|
+
if (ind === 0) break;
|
|
1805
|
+
const body = uncommented(l);
|
|
1806
|
+
const item = body.match(/^\s*-\s+(.+)$/);
|
|
1807
|
+
if (item && listKey && event && ind > listInd) {
|
|
1808
|
+
const target = t[listKey].get(event) ?? [];
|
|
1809
|
+
target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
|
|
1810
|
+
t[listKey].set(event, target);
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
|
|
1814
|
+
if (!kv) continue;
|
|
1815
|
+
if (ind === 2 || event === null && ind < 4) {
|
|
1816
|
+
event = kv[1];
|
|
1817
|
+
t.events.add(event);
|
|
1818
|
+
listKey = null;
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
|
|
1822
|
+
listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
|
|
1823
|
+
listInd = ind;
|
|
1824
|
+
const list = flowList(kv[2]);
|
|
1825
|
+
t[listKey].set(event, list ?? []);
|
|
1826
|
+
} else listKey = null;
|
|
1827
|
+
}
|
|
1828
|
+
return t;
|
|
1829
|
+
}
|
|
1830
|
+
var DEFAULT_BRANCH = /^(?:main|master)$/;
|
|
1831
|
+
var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
|
|
1832
|
+
function triggerNarrowings(before, after) {
|
|
1833
|
+
const out2 = [];
|
|
1834
|
+
if (!before.present || !after.present) return out2;
|
|
1835
|
+
for (const e of ["push", "pull_request", "pull_request_target"]) {
|
|
1836
|
+
if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
|
|
1837
|
+
}
|
|
1838
|
+
for (const [e, list] of after.pathsIgnore) {
|
|
1839
|
+
const had = before.pathsIgnore.get(e) ?? [];
|
|
1840
|
+
if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
|
|
1841
|
+
}
|
|
1842
|
+
for (const [e, list] of after.branchesIgnore) {
|
|
1843
|
+
const had = before.branchesIgnore.get(e) ?? [];
|
|
1844
|
+
if (list.some((g) => (ALL(g) || DEFAULT_BRANCH.test(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
1845
|
+
}
|
|
1846
|
+
for (const [e, was] of before.branches) {
|
|
1847
|
+
const now = after.branches.get(e);
|
|
1848
|
+
if (!now || !after.events.has(e)) continue;
|
|
1849
|
+
if (now.some(ALL)) continue;
|
|
1850
|
+
const lostDefault = was.some((b) => DEFAULT_BRANCH.test(b) && !now.includes(b));
|
|
1851
|
+
const replaced = was.length > 0 && !was.some((b) => now.includes(b));
|
|
1852
|
+
if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
|
|
1853
|
+
}
|
|
1854
|
+
return out2;
|
|
1296
1855
|
}
|
|
1297
1856
|
var ciTampering = {
|
|
1298
1857
|
id: RULE5,
|
|
@@ -1314,9 +1873,14 @@ var ciTampering = {
|
|
|
1314
1873
|
continue;
|
|
1315
1874
|
}
|
|
1316
1875
|
if (!isProtected(c.path, policy, "ci")) continue;
|
|
1876
|
+
const afterLines = c.after != null ? c.after.split("\n") : null;
|
|
1877
|
+
const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
|
|
1317
1878
|
for (const l of addedLines(c)) {
|
|
1318
1879
|
const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
|
|
1319
1880
|
const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
|
|
1881
|
+
if (!coe && !cond) continue;
|
|
1882
|
+
const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
|
|
1883
|
+
if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
|
|
1320
1884
|
if (coe && isTruthy(coe[1])) {
|
|
1321
1885
|
out2.push(
|
|
1322
1886
|
makeFinding(RULE5, policy, {
|
|
@@ -1339,15 +1903,72 @@ var ciTampering = {
|
|
|
1339
1903
|
);
|
|
1340
1904
|
}
|
|
1341
1905
|
}
|
|
1342
|
-
|
|
1343
|
-
(
|
|
1344
|
-
|
|
1906
|
+
if (afterLines) {
|
|
1907
|
+
for (const b of runBlocks(afterLines)) {
|
|
1908
|
+
if (!b.body.some(invokesCheck)) continue;
|
|
1909
|
+
const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
|
|
1910
|
+
for (let k = 0; k < b.body.length; k++) {
|
|
1911
|
+
const idx = b.start + 1 + k;
|
|
1912
|
+
if (!addedAt.has(idx + 1)) continue;
|
|
1913
|
+
const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
|
|
1914
|
+
if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
|
|
1915
|
+
out2.push(
|
|
1916
|
+
makeFinding(RULE5, policy, {
|
|
1917
|
+
file: c.path,
|
|
1918
|
+
line: idx + 1,
|
|
1919
|
+
message: `A check's run block was neutralised: ${n.what}.`,
|
|
1920
|
+
evidence: b.body[k].trim(),
|
|
1921
|
+
remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
|
|
1922
|
+
})
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
for (const l of addedLines(c)) {
|
|
1927
|
+
if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
|
|
1928
|
+
const step = stepOf(afterLines, l.newLine - 1);
|
|
1929
|
+
if (step && !stepHasCheck(afterLines, step)) continue;
|
|
1930
|
+
out2.push(
|
|
1931
|
+
makeFinding(RULE5, policy, {
|
|
1932
|
+
file: c.path,
|
|
1933
|
+
line: l.newLine,
|
|
1934
|
+
message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
|
|
1935
|
+
evidence: l.content.trim(),
|
|
1936
|
+
remediation: "Keep the default shell (fail-fast) on steps that run checks."
|
|
1937
|
+
})
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
if (c.before != null) {
|
|
1941
|
+
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
|
|
1942
|
+
out2.push(
|
|
1943
|
+
makeFinding(RULE5, policy, {
|
|
1944
|
+
file: c.path,
|
|
1945
|
+
message: `The workflow's triggers were narrowed: ${reason}.`,
|
|
1946
|
+
evidence: reason,
|
|
1947
|
+
remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
|
|
1948
|
+
})
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
|
|
1345
1954
|
for (const l of removedLines(c)) {
|
|
1346
1955
|
if (/^\s*#/.test(l.content)) continue;
|
|
1347
1956
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1348
1957
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
1349
1958
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
1350
|
-
|
|
1959
|
+
const s = survives(commandCore(l.content), isUsesLine, afterCores);
|
|
1960
|
+
if (s.state === "kept") continue;
|
|
1961
|
+
if (s.state === "neutralised") {
|
|
1962
|
+
out2.push(
|
|
1963
|
+
makeFinding(RULE5, policy, {
|
|
1964
|
+
file: c.path,
|
|
1965
|
+
message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
|
|
1966
|
+
evidence: `${l.content.trim()} \u2192 ${s.by}`,
|
|
1967
|
+
remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
|
|
1968
|
+
})
|
|
1969
|
+
);
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1351
1972
|
out2.push(
|
|
1352
1973
|
makeFinding(RULE5, policy, {
|
|
1353
1974
|
file: c.path,
|
|
@@ -1362,6 +1983,10 @@ var ciTampering = {
|
|
|
1362
1983
|
}
|
|
1363
1984
|
};
|
|
1364
1985
|
|
|
1986
|
+
// src/detectors/hook-tampering.ts
|
|
1987
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1988
|
+
import { join as join2 } from "node:path";
|
|
1989
|
+
|
|
1365
1990
|
// src/detectors/policy-diff.ts
|
|
1366
1991
|
import { parse } from "yaml";
|
|
1367
1992
|
var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
@@ -1472,19 +2097,390 @@ function policyWeakening(before, after) {
|
|
|
1472
2097
|
}
|
|
1473
2098
|
return reasons;
|
|
1474
2099
|
}
|
|
2100
|
+
function policyAddWeakening(after) {
|
|
2101
|
+
let doc;
|
|
2102
|
+
try {
|
|
2103
|
+
doc = parse(after);
|
|
2104
|
+
} catch {
|
|
2105
|
+
return ["the added policy is not valid YAML \u2014 the gate fails closed until it is fixed"];
|
|
2106
|
+
}
|
|
2107
|
+
if (doc === null || doc === void 0) return [];
|
|
2108
|
+
if (typeof doc !== "object" || Array.isArray(doc)) {
|
|
2109
|
+
return ["the added policy is not a policy mapping \u2014 the gate fails closed until it is fixed"];
|
|
2110
|
+
}
|
|
2111
|
+
return policyWeakening("{}", after) ?? [];
|
|
2112
|
+
}
|
|
1475
2113
|
|
|
1476
|
-
// src/detectors/hook-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
2114
|
+
// src/detectors/hook-wiring.ts
|
|
2115
|
+
import { parse as parseYaml } from "yaml";
|
|
2116
|
+
function stripComment(line) {
|
|
2117
|
+
let single = false;
|
|
2118
|
+
let double = false;
|
|
2119
|
+
for (let i = 0; i < line.length; i++) {
|
|
2120
|
+
const ch = line[i];
|
|
2121
|
+
if (ch === "'" && !double) single = !single;
|
|
2122
|
+
else if (ch === '"' && !single) double = !double;
|
|
2123
|
+
else if (ch === "#" && !single && !double && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
|
|
2124
|
+
}
|
|
2125
|
+
return line;
|
|
2126
|
+
}
|
|
2127
|
+
var isComment = (line) => /^\s*#/.test(line);
|
|
2128
|
+
var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:--yes|-y|--no-install|--silent|-s|--)\s+)*/;
|
|
2129
|
+
var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
|
|
2130
|
+
var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
|
|
2131
|
+
var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
|
|
2132
|
+
var PREFIX = /^(?:(?:then|do|else|\{|\(|!|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
|
|
2133
|
+
function checkIdentity(segment) {
|
|
2134
|
+
const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
|
|
2135
|
+
const viaRunner = RUNNER.test(core);
|
|
2136
|
+
const rest = core.replace(RUNNER, "");
|
|
2137
|
+
const tool = rest.match(TOOL)?.[1] ?? (viaRunner ? rest.match(SCRIPT)?.[1] : void 0);
|
|
2138
|
+
if (!tool) return null;
|
|
2139
|
+
const toks = rest.replace(/^tamperward@\S+/, "tamperward").split(" ");
|
|
2140
|
+
if (tool === "tamperward") {
|
|
2141
|
+
const sub = toks[1];
|
|
2142
|
+
if (!sub || sub.startsWith("-")) return null;
|
|
2143
|
+
return `tamperward ${sub}`;
|
|
2144
|
+
}
|
|
2145
|
+
if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
|
|
2146
|
+
return tool;
|
|
2147
|
+
}
|
|
2148
|
+
function split(stmt) {
|
|
2149
|
+
const segs = [];
|
|
2150
|
+
const ops = [];
|
|
2151
|
+
const re = /(&&|\|\||;|\|)/g;
|
|
2152
|
+
let last = 0;
|
|
2153
|
+
for (const m of stmt.matchAll(re)) {
|
|
2154
|
+
segs.push(stmt.slice(last, m.index));
|
|
2155
|
+
ops.push(m[1]);
|
|
2156
|
+
last = (m.index ?? 0) + m[1].length;
|
|
2157
|
+
}
|
|
2158
|
+
segs.push(stmt.slice(last));
|
|
2159
|
+
return { segs, ops };
|
|
2160
|
+
}
|
|
2161
|
+
var unq = (s) => s.replace(/^["']|["']$/g, "");
|
|
2162
|
+
function alwaysFalse(cond) {
|
|
2163
|
+
const c = cond.trim().replace(/;$/, "").trim();
|
|
2164
|
+
if (/^(?:false|!\s*true|\(\s*exit\s+[1-9]\d*\s*\))$/.test(c)) return true;
|
|
2165
|
+
const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
|
|
2166
|
+
if (!m) return false;
|
|
2167
|
+
const inner = (m[1] ?? m[2] ?? m[3]).trim();
|
|
2168
|
+
if (inner.includes("$")) return false;
|
|
2169
|
+
const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
|
|
2170
|
+
if (parts) {
|
|
2171
|
+
const [, a, op, b] = parts;
|
|
2172
|
+
const x = unq(a);
|
|
2173
|
+
const y = unq(b);
|
|
2174
|
+
const nx = Number(x);
|
|
2175
|
+
const ny = Number(y);
|
|
2176
|
+
const ints = /^-?\d+$/.test(x) && /^-?\d+$/.test(y);
|
|
2177
|
+
switch (op) {
|
|
2178
|
+
case "-eq":
|
|
2179
|
+
return ints && nx !== ny;
|
|
2180
|
+
case "-ne":
|
|
2181
|
+
return ints && nx === ny;
|
|
2182
|
+
case "-lt":
|
|
2183
|
+
return ints && !(nx < ny);
|
|
2184
|
+
case "-le":
|
|
2185
|
+
return ints && !(nx <= ny);
|
|
2186
|
+
case "-gt":
|
|
2187
|
+
return ints && !(nx > ny);
|
|
2188
|
+
case "-ge":
|
|
2189
|
+
return ints && !(nx >= ny);
|
|
2190
|
+
case "=":
|
|
2191
|
+
case "==":
|
|
2192
|
+
return x !== y;
|
|
2193
|
+
case "!=":
|
|
2194
|
+
return x === y;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
const unary = inner.match(/^(-n|-z)\s+(\S+)$/);
|
|
2198
|
+
if (unary) {
|
|
2199
|
+
const v = unq(unary[2]);
|
|
2200
|
+
return unary[1] === "-n" ? v === "" : v !== "";
|
|
2201
|
+
}
|
|
2202
|
+
if (/^(?:""|'')$/.test(inner)) return true;
|
|
2203
|
+
return false;
|
|
2204
|
+
}
|
|
2205
|
+
var alwaysTrue = (cond) => /^(?:true|:|\[\s+1\s+-eq\s+1\s+\]|\[\s+-n\s+"?\S"?\s+\])$/.test(cond.trim().replace(/;$/, "").trim());
|
|
2206
|
+
var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
|
|
2207
|
+
var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
|
|
2208
|
+
var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
|
|
2209
|
+
function insertsPassingExit(line) {
|
|
2210
|
+
if (isComment(line)) return false;
|
|
2211
|
+
return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
|
|
2212
|
+
}
|
|
2213
|
+
function insertsDeadGuard(line) {
|
|
2214
|
+
if (isComment(line)) return false;
|
|
2215
|
+
const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
|
|
2216
|
+
return !!m && alwaysFalse(m[1]);
|
|
2217
|
+
}
|
|
2218
|
+
function invocations(lines) {
|
|
2219
|
+
const out2 = [];
|
|
2220
|
+
const pipefail = lines.some((l) => /\bpipefail\b/.test(stripComment(l)));
|
|
2221
|
+
const frames = [{ dead: false }];
|
|
2222
|
+
const dead = () => frames.some((f) => f.dead);
|
|
2223
|
+
for (const raw of lines) {
|
|
2224
|
+
if (isComment(raw)) {
|
|
2225
|
+
const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
|
|
2226
|
+
if (id) out2.push({ identity: id, line: raw, state: "comment" });
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
const stmt = stripComment(raw).trim();
|
|
2230
|
+
if (!stmt) continue;
|
|
2231
|
+
const ifm = stmt.match(/^(if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
|
|
2232
|
+
if (ifm) {
|
|
2233
|
+
if (ifm[1] === "if") frames.push({ dead: alwaysFalse(ifm[2]) });
|
|
2234
|
+
else if (frames.length > 1) frames[frames.length - 1].dead = alwaysFalse(ifm[2]);
|
|
2235
|
+
continue;
|
|
2236
|
+
}
|
|
2237
|
+
if (/^else\b/.test(stmt)) {
|
|
2238
|
+
if (frames.length > 1) frames[frames.length - 1].dead = false;
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
if (/^fi\b/.test(stmt)) {
|
|
2242
|
+
if (frames.length > 1) frames.pop();
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
const { segs, ops } = split(stmt);
|
|
2246
|
+
for (let i = 0; i < segs.length; i++) {
|
|
2247
|
+
const seg = segs[i].trim().replace(PREFIX, "");
|
|
2248
|
+
const id = checkIdentity(seg);
|
|
2249
|
+
if (!id) {
|
|
2250
|
+
if (EXIT_ANY.test(seg) && !dead() && (i === 0 || ops[i - 1] === ";")) frames[frames.length - 1].dead = true;
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
let state = dead() ? "unreachable" : "live";
|
|
2254
|
+
if (state === "live" && i > 0) {
|
|
2255
|
+
if (ops[i - 1] === "&&" && alwaysFalse(segs[i - 1])) state = "unreachable";
|
|
2256
|
+
if (ops[i - 1] === "||" && alwaysTrue(segs[i - 1])) state = "unreachable";
|
|
2257
|
+
}
|
|
2258
|
+
if (state === "live" && i < ops.length) {
|
|
2259
|
+
const after = segs.slice(i + 1).join(" ");
|
|
2260
|
+
if (ops[i] === "||" && !FAILS_LOUDLY.test(after)) state = "neutered";
|
|
2261
|
+
else if (ops[i] === "|" && !pipefail) state = "neutered";
|
|
2262
|
+
else if (ops[i] === ";" && segs.slice(i + 1).some((s) => /^(?:true|:)$/.test(s.trim()) || EXIT_ZERO.test(s.trim()))) state = "neutered";
|
|
2263
|
+
}
|
|
2264
|
+
out2.push({ identity: id, line: raw, state });
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return out2;
|
|
2268
|
+
}
|
|
2269
|
+
function scriptWeakening(before, after) {
|
|
2270
|
+
const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
|
|
2271
|
+
const bl = live(invocations(before));
|
|
2272
|
+
const afterInv = invocations(after);
|
|
2273
|
+
const al = live(afterInv);
|
|
2274
|
+
const out2 = [];
|
|
2275
|
+
for (const id of bl) {
|
|
2276
|
+
if (al.has(id)) continue;
|
|
2277
|
+
const trace = afterInv.find((i) => i.identity === id);
|
|
2278
|
+
const how = trace?.state === "neutered" ? "neutralised in place (its failure can no longer fail the hook)" : trace?.state === "unreachable" ? "made unreachable (behind an always-false guard or an early exit)" : trace?.state === "comment" ? "commented out" : "removed";
|
|
2279
|
+
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
|
|
2280
|
+
out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
|
|
2281
|
+
}
|
|
2282
|
+
return out2;
|
|
2283
|
+
}
|
|
2284
|
+
var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
|
|
2285
|
+
var GATE_CRITICAL = [
|
|
2286
|
+
"/.github/workflows/",
|
|
2287
|
+
"/.tamperward.yml",
|
|
2288
|
+
"/.github/CODEOWNERS",
|
|
2289
|
+
"/CODEOWNERS",
|
|
2290
|
+
"/docs/CODEOWNERS",
|
|
2291
|
+
"/.husky/",
|
|
2292
|
+
"/.claude/settings.json",
|
|
2293
|
+
"/.claude/settings.local.json",
|
|
2294
|
+
"/.pre-commit-config.yaml",
|
|
2295
|
+
"/lefthook.yml",
|
|
2296
|
+
"/lefthook.yaml"
|
|
2297
|
+
];
|
|
2298
|
+
function covers(pattern, critical) {
|
|
2299
|
+
if (pattern === "*" || pattern === "**") return true;
|
|
2300
|
+
let p = pattern.replace(/\/\*\*$/, "/").replace(/\/\*$/, "/");
|
|
2301
|
+
if (!p.startsWith("/")) p = "/" + p;
|
|
2302
|
+
if (p === critical) return true;
|
|
2303
|
+
if (p.endsWith("/") && critical.startsWith(p)) return true;
|
|
2304
|
+
return critical.startsWith(p + "/");
|
|
2305
|
+
}
|
|
2306
|
+
function ownersOf(content, critical) {
|
|
2307
|
+
let owners = null;
|
|
2308
|
+
for (const line of content.split("\n")) {
|
|
2309
|
+
const t = line.trim();
|
|
2310
|
+
if (!t || t.startsWith("#")) continue;
|
|
2311
|
+
const [pattern, ...rest] = t.split(/\s+/);
|
|
2312
|
+
if (covers(pattern, critical)) owners = rest.filter((o) => !o.startsWith("#"));
|
|
2313
|
+
}
|
|
2314
|
+
return owners;
|
|
2315
|
+
}
|
|
2316
|
+
function codeownersWeakening(before, after) {
|
|
2317
|
+
const out2 = [];
|
|
2318
|
+
for (const critical of GATE_CRITICAL) {
|
|
2319
|
+
const was = ownersOf(before, critical);
|
|
2320
|
+
if (!was || was.length === 0) continue;
|
|
2321
|
+
const now = ownersOf(after, critical);
|
|
2322
|
+
if (!now) out2.push(`the code-owner rule covering ${critical} was removed \u2014 the gate's own files no longer require a human reviewer`);
|
|
2323
|
+
else if (now.length === 0) out2.push(`the code-owner rule covering ${critical} lost its owners \u2014 the gate's own files no longer require a human reviewer`);
|
|
2324
|
+
}
|
|
2325
|
+
return out2;
|
|
2326
|
+
}
|
|
2327
|
+
var isDoc = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2328
|
+
function parseDoc(src) {
|
|
2329
|
+
try {
|
|
2330
|
+
const v = parseYaml(src);
|
|
2331
|
+
return isDoc(v) ? v : null;
|
|
2332
|
+
} catch {
|
|
2333
|
+
return null;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
|
|
2337
|
+
var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
|
|
2338
|
+
var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
|
|
2339
|
+
var GATE = /\btamperward\b/;
|
|
2340
|
+
var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
|
|
2341
|
+
function lefthookEntries(doc) {
|
|
2342
|
+
const out2 = /* @__PURE__ */ new Map();
|
|
2343
|
+
for (const [section, v] of Object.entries(doc)) {
|
|
2344
|
+
if (!isDoc(v)) continue;
|
|
2345
|
+
for (const group of ["commands", "scripts"]) {
|
|
2346
|
+
const g = v[group];
|
|
2347
|
+
if (!isDoc(g)) continue;
|
|
2348
|
+
for (const [name, cfg] of Object.entries(g)) {
|
|
2349
|
+
if (!isDoc(cfg) || !GATE.test(str(cfg.run ?? cfg.runner))) continue;
|
|
2350
|
+
out2.set(`${section}.${group}.${name}`, {
|
|
2351
|
+
skip: cfg.skip,
|
|
2352
|
+
sectionSkip: v.skip,
|
|
2353
|
+
glob: str(cfg.glob),
|
|
2354
|
+
exclude: str(cfg.exclude),
|
|
2355
|
+
only: str(cfg.only)
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return out2;
|
|
2361
|
+
}
|
|
2362
|
+
function lefthookWeakening(before, after) {
|
|
2363
|
+
const out2 = [];
|
|
2364
|
+
const b = lefthookEntries(before);
|
|
2365
|
+
const a = lefthookEntries(after);
|
|
2366
|
+
for (const [key2, was] of b) {
|
|
2367
|
+
const now = a.get(key2);
|
|
2368
|
+
if (!now) {
|
|
2369
|
+
out2.push(`lefthook entry ${key2} (the gate) was removed`);
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
if (now.skip === true && was.skip !== true) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: true\``);
|
|
2373
|
+
if (now.sectionSkip === true && was.sectionSkip !== true) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: true\``);
|
|
2374
|
+
if (now.glob !== was.glob && now.glob) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`glob\` (${now.glob}) \u2014 it no longer runs on every commit`);
|
|
2375
|
+
if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2376
|
+
if (now.only !== was.only && now.only) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${now.only})`);
|
|
2377
|
+
}
|
|
2378
|
+
return out2;
|
|
2379
|
+
}
|
|
2380
|
+
var COMMIT_STAGES = /* @__PURE__ */ new Set(["commit", "pre-commit", "push", "pre-push", "commit-msg", "pre-merge-commit", "prepare-commit-msg"]);
|
|
2381
|
+
function preCommitEntries(doc) {
|
|
2382
|
+
const out2 = /* @__PURE__ */ new Map();
|
|
2383
|
+
const repos = Array.isArray(doc.repos) ? doc.repos : [];
|
|
2384
|
+
for (const repo of repos) {
|
|
2385
|
+
if (!isDoc(repo)) continue;
|
|
2386
|
+
const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
|
|
2387
|
+
for (const h of hooks) {
|
|
2388
|
+
if (!isDoc(h)) continue;
|
|
2389
|
+
if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
|
|
2390
|
+
out2.set(`${str(repo.repo)}:${str(h.id)}`, {
|
|
2391
|
+
stages: Array.isArray(h.stages) ? h.stages.map(String) : null,
|
|
2392
|
+
exclude: str(h.exclude),
|
|
2393
|
+
files: str(h.files)
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return out2;
|
|
2398
|
+
}
|
|
2399
|
+
function preCommitWeakening(before, after) {
|
|
2400
|
+
const out2 = [];
|
|
2401
|
+
const b = preCommitEntries(before);
|
|
2402
|
+
const a = preCommitEntries(after);
|
|
2403
|
+
for (const [key2, was] of b) {
|
|
2404
|
+
const now = a.get(key2);
|
|
2405
|
+
if (!now) {
|
|
2406
|
+
out2.push(`pre-commit hook ${key2} (the gate) was removed`);
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
const wasStages = was.stages ?? [...COMMIT_STAGES];
|
|
2410
|
+
if (now.stages && !now.stages.some((s) => COMMIT_STAGES.has(s)) && wasStages.some((s) => COMMIT_STAGES.has(s))) {
|
|
2411
|
+
out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
|
|
2412
|
+
} else if (now.stages && was.stages) {
|
|
2413
|
+
const lost = was.stages.filter((s) => COMMIT_STAGES.has(s) && !now.stages.includes(s));
|
|
2414
|
+
if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
|
|
2415
|
+
}
|
|
2416
|
+
if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2417
|
+
if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
|
|
2418
|
+
}
|
|
2419
|
+
const topB = str(before.exclude);
|
|
2420
|
+
const topA = str(after.exclude);
|
|
2421
|
+
if (topA && topA !== topB && b.size > 0) out2.push(`a top-level pre-commit \`exclude\` was added or changed (${topA}) \u2014 it scopes every hook, the gate included`);
|
|
2422
|
+
return out2;
|
|
2423
|
+
}
|
|
2424
|
+
var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
|
|
2425
|
+
var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
|
|
2426
|
+
function packageJsonWeakening(before, after) {
|
|
2427
|
+
let b;
|
|
2428
|
+
let a;
|
|
2429
|
+
try {
|
|
2430
|
+
b = JSON.parse(before);
|
|
2431
|
+
a = JSON.parse(after);
|
|
2432
|
+
} catch {
|
|
2433
|
+
return [];
|
|
2434
|
+
}
|
|
2435
|
+
if (!isDoc(b) || !isDoc(a)) return [];
|
|
2436
|
+
const scripts = (d) => {
|
|
2437
|
+
const sc = d.scripts;
|
|
2438
|
+
return isDoc(sc) ? INSTALL_SCRIPTS.map((k) => str(sc[k])).join(" ") : "";
|
|
2439
|
+
};
|
|
2440
|
+
const was = scripts(b).match(INSTALLER)?.[0];
|
|
2441
|
+
if (!was) return [];
|
|
2442
|
+
if (INSTALLER.test(scripts(a))) return [];
|
|
2443
|
+
return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
|
|
2444
|
+
}
|
|
2445
|
+
var GIT_HOOKS = /* @__PURE__ */ new Set([
|
|
2446
|
+
"applypatch-msg",
|
|
2447
|
+
"pre-applypatch",
|
|
2448
|
+
"post-applypatch",
|
|
2449
|
+
"pre-commit",
|
|
2450
|
+
"pre-merge-commit",
|
|
2451
|
+
"prepare-commit-msg",
|
|
2452
|
+
"commit-msg",
|
|
2453
|
+
"post-commit",
|
|
2454
|
+
"pre-rebase",
|
|
2455
|
+
"post-checkout",
|
|
2456
|
+
"post-merge",
|
|
2457
|
+
"pre-push",
|
|
2458
|
+
"pre-receive",
|
|
2459
|
+
"update",
|
|
2460
|
+
"post-receive",
|
|
2461
|
+
"post-update",
|
|
2462
|
+
"push-to-checkout",
|
|
2463
|
+
"pre-auto-gc",
|
|
2464
|
+
"post-rewrite",
|
|
2465
|
+
"sendemail-validate"
|
|
2466
|
+
]);
|
|
2467
|
+
function hookIdentity(path) {
|
|
2468
|
+
const base = path.split("/").pop() ?? "";
|
|
2469
|
+
if (isLefthook(path)) return "lefthook";
|
|
2470
|
+
if (isPreCommitConfig(path)) return "pre-commit-config";
|
|
2471
|
+
if (base === "CODEOWNERS") return "codeowners";
|
|
2472
|
+
const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
|
|
2473
|
+
if (husky && GIT_HOOKS.has(husky[1])) return `husky:${husky[1]}`;
|
|
2474
|
+
return null;
|
|
2475
|
+
}
|
|
2476
|
+
var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
|
|
1481
2477
|
var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
|
|
1482
|
-
function
|
|
1483
|
-
const
|
|
1484
|
-
const at = toks.findIndex((t) => /^chmod$/.test(t));
|
|
2478
|
+
function chmodDropsExec(toks) {
|
|
2479
|
+
const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
|
|
1485
2480
|
if (at === -1) return false;
|
|
1486
2481
|
for (const tok of toks.slice(at + 1)) {
|
|
1487
|
-
if (
|
|
2482
|
+
if (/^--reference=/.test(tok)) return true;
|
|
2483
|
+
if (/^[0-7]{1,5}$/.test(tok)) {
|
|
1488
2484
|
if (!ownerExec(tok)) return true;
|
|
1489
2485
|
continue;
|
|
1490
2486
|
}
|
|
@@ -1492,23 +2488,260 @@ function chmodTouchesExec(seg) {
|
|
|
1492
2488
|
const m = clause.match(SYMBOLIC);
|
|
1493
2489
|
if (!m) continue;
|
|
1494
2490
|
const [, op, perms] = m;
|
|
1495
|
-
|
|
1496
|
-
if (op === "="
|
|
2491
|
+
if (op === "-" && /[xX]/.test(perms)) return true;
|
|
2492
|
+
if (op === "=" && !/[xX]/.test(perms)) return true;
|
|
1497
2493
|
}
|
|
1498
2494
|
}
|
|
1499
2495
|
return false;
|
|
1500
2496
|
}
|
|
2497
|
+
var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
|
|
2498
|
+
function shellWritesHook(seg, toks, policy) {
|
|
2499
|
+
const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
|
|
2500
|
+
for (const m of seg.matchAll(/(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g)) {
|
|
2501
|
+
if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
|
|
2502
|
+
}
|
|
2503
|
+
let at = 0;
|
|
2504
|
+
while (at < toks.length && WRAPPERS.test(toks[at])) at++;
|
|
2505
|
+
const cmd = (toks[at] ?? "").replace(/^.*\//, "");
|
|
2506
|
+
const args = toks.slice(at + 1);
|
|
2507
|
+
const positional = args.filter((a) => a !== "--" && !a.startsWith("-"));
|
|
2508
|
+
const last = positional[positional.length - 1] ?? "";
|
|
2509
|
+
const anyHook = args.some(hook);
|
|
2510
|
+
const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
|
|
2511
|
+
switch (cmd) {
|
|
2512
|
+
case "rm":
|
|
2513
|
+
case "unlink":
|
|
2514
|
+
case "shred":
|
|
2515
|
+
return anyHook ? "rm deletes a protected hook" : null;
|
|
2516
|
+
case "truncate":
|
|
2517
|
+
return anyHook ? "truncate empties a protected hook" : null;
|
|
2518
|
+
case "chmod":
|
|
2519
|
+
return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
|
|
2520
|
+
case "mv":
|
|
2521
|
+
return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
|
|
2522
|
+
case "cp":
|
|
2523
|
+
case "install":
|
|
2524
|
+
case "ln":
|
|
2525
|
+
return hook(last) ? `${cmd} replaces a protected hook in place` : null;
|
|
2526
|
+
case "tee":
|
|
2527
|
+
case "sponge":
|
|
2528
|
+
return anyHook ? `${cmd} rewrites a protected hook in place` : null;
|
|
2529
|
+
case "dd":
|
|
2530
|
+
return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
|
|
2531
|
+
case "sed":
|
|
2532
|
+
case "perl":
|
|
2533
|
+
return anyHook && inPlace ? `an in-place ${cmd} rewrites a protected hook` : null;
|
|
2534
|
+
case "awk":
|
|
2535
|
+
case "gawk":
|
|
2536
|
+
return anyHook && args.some((a, i) => a === "-i" && args[i + 1] === "inplace" || a === "--in-place" || a === "-i=inplace") ? "an in-place awk rewrites a protected hook" : null;
|
|
2537
|
+
case "git": {
|
|
2538
|
+
let i = 0;
|
|
2539
|
+
while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
|
|
2540
|
+
const sub = args[i];
|
|
2541
|
+
const rest = args.slice(i + 1);
|
|
2542
|
+
if (!rest.some(hook)) return null;
|
|
2543
|
+
if (sub === "checkout") {
|
|
2544
|
+
const dd = rest.indexOf("--");
|
|
2545
|
+
const before = dd === -1 ? rest : rest.slice(0, dd);
|
|
2546
|
+
const revs = before.filter((p) => !p.startsWith("-") && !hook(p));
|
|
2547
|
+
return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
|
|
2548
|
+
}
|
|
2549
|
+
if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
|
|
2550
|
+
return "git restore --source=<rev> <hook> puts an older version of a protected hook back";
|
|
2551
|
+
}
|
|
2552
|
+
return null;
|
|
2553
|
+
}
|
|
2554
|
+
default:
|
|
2555
|
+
return null;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// src/detectors/hook-tampering.ts
|
|
2560
|
+
var RULE6 = "hook-tampering";
|
|
2561
|
+
var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
|
|
2562
|
+
var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
|
|
2563
|
+
var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
|
|
2564
|
+
function siblingSettings(p) {
|
|
2565
|
+
return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
|
|
2566
|
+
}
|
|
2567
|
+
var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
|
|
2568
|
+
var EVENTS = ["PreToolUse", "Stop"];
|
|
2569
|
+
function parseSettings(src) {
|
|
2570
|
+
try {
|
|
2571
|
+
const v = JSON.parse(src);
|
|
2572
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
2573
|
+
} catch {
|
|
2574
|
+
return null;
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
function toolSet(matcher2) {
|
|
2578
|
+
const m = typeof matcher2 === "string" ? matcher2.trim() : "";
|
|
2579
|
+
if (m === "" || m === "*") return null;
|
|
2580
|
+
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
2581
|
+
}
|
|
2582
|
+
function eventEntries(s, event) {
|
|
2583
|
+
const hooks = s.hooks;
|
|
2584
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return null;
|
|
2585
|
+
const arr = hooks[event];
|
|
2586
|
+
return Array.isArray(arr) ? arr : null;
|
|
2587
|
+
}
|
|
2588
|
+
var declares = (s, event) => eventEntries(s, event) !== null;
|
|
2589
|
+
function gateWiring(s, event) {
|
|
2590
|
+
const out2 = [];
|
|
2591
|
+
for (const m of eventEntries(s, event) ?? []) {
|
|
2592
|
+
if (m === null || typeof m !== "object") continue;
|
|
2593
|
+
const matcher2 = m;
|
|
2594
|
+
if (!Array.isArray(matcher2.hooks)) continue;
|
|
2595
|
+
for (const h of matcher2.hooks) {
|
|
2596
|
+
if (h === null || typeof h !== "object") continue;
|
|
2597
|
+
const entry = h;
|
|
2598
|
+
if (entry.type !== void 0 && entry.type !== "command") continue;
|
|
2599
|
+
if (typeof entry.command !== "string" || !TW_INVOCATION.test(entry.command)) continue;
|
|
2600
|
+
out2.push({ tools: toolSet(matcher2.matcher), command: entry.command });
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
return out2;
|
|
2604
|
+
}
|
|
2605
|
+
function coverage(ws2) {
|
|
2606
|
+
if (ws2.length === 0) return "none";
|
|
2607
|
+
if (ws2.some((w) => w.tools === null)) return null;
|
|
2608
|
+
return new Set(ws2.flatMap((w) => [...w.tools]));
|
|
2609
|
+
}
|
|
2610
|
+
var hooksDisabled = (s) => s.disableAllHooks === true;
|
|
2611
|
+
function settingsWeakening(before, after, sibling) {
|
|
2612
|
+
const reasons = [];
|
|
2613
|
+
if (hooksDisabled(after) && !(before !== null && hooksDisabled(before))) {
|
|
2614
|
+
reasons.push("`disableAllHooks: true` switches every hook off, the gate included");
|
|
2615
|
+
}
|
|
2616
|
+
for (const event of EVENTS) {
|
|
2617
|
+
const was = before ? coverage(gateWiring(before, event)) : "none";
|
|
2618
|
+
const now = coverage(gateWiring(after, event));
|
|
2619
|
+
if (was !== "none") {
|
|
2620
|
+
if (now === "none") {
|
|
2621
|
+
reasons.push(`the tamperward ${event} hook entry was removed`);
|
|
2622
|
+
} else if (event === "PreToolUse") {
|
|
2623
|
+
if (was === null && now !== null) {
|
|
2624
|
+
reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
|
|
2625
|
+
} else if (was !== null && now !== null) {
|
|
2626
|
+
const lost = [...was].filter((t) => !now.has(t));
|
|
2627
|
+
if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
continue;
|
|
2631
|
+
}
|
|
2632
|
+
if (now !== "none" || !declares(after, event)) continue;
|
|
2633
|
+
if (before !== null && declares(before, event)) continue;
|
|
2634
|
+
const empty = (eventEntries(after, event) ?? []).length === 0;
|
|
2635
|
+
const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
|
|
2636
|
+
if (empty || shadowed) {
|
|
2637
|
+
reasons.push(
|
|
2638
|
+
`${event} hooks declared ${empty ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
return reasons;
|
|
2643
|
+
}
|
|
2644
|
+
function siblingOf(c, changes, ctx) {
|
|
2645
|
+
const path = siblingSettings(c.path);
|
|
2646
|
+
const inSet = changes.find((o) => o.kind === "file" && o.path === path);
|
|
2647
|
+
if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
|
|
2648
|
+
if (!ctx?.cwd) return null;
|
|
2649
|
+
try {
|
|
2650
|
+
const p = join2(ctx.cwd, path);
|
|
2651
|
+
return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
|
|
2652
|
+
} catch {
|
|
2653
|
+
return null;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
function claudeSettingsFindings(c, changes, policy, ctx) {
|
|
2657
|
+
if (c.after == null) return null;
|
|
2658
|
+
const after = parseSettings(c.after);
|
|
2659
|
+
const before = c.before != null ? parseSettings(c.before) : null;
|
|
2660
|
+
const finding = (reason) => makeFinding(RULE6, policy, {
|
|
2661
|
+
file: c.path,
|
|
2662
|
+
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
2663
|
+
evidence: reason,
|
|
2664
|
+
remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate."
|
|
2665
|
+
});
|
|
2666
|
+
if (c.before != null && before === null) return null;
|
|
2667
|
+
if (after === null) {
|
|
2668
|
+
if (c.before == null) return [];
|
|
2669
|
+
return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
|
|
2670
|
+
}
|
|
2671
|
+
return settingsWeakening(before, after, siblingOf(c, changes, ctx)).map(finding);
|
|
2672
|
+
}
|
|
2673
|
+
function policyFindings(c, policy) {
|
|
2674
|
+
if (c.after == null) return null;
|
|
2675
|
+
const added = c.before == null;
|
|
2676
|
+
const reasons = added ? policyAddWeakening(c.after) : policyWeakening(c.before, c.after);
|
|
2677
|
+
if (reasons === null) return null;
|
|
2678
|
+
return reasons.map(
|
|
2679
|
+
(reason) => makeFinding(RULE6, policy, {
|
|
2680
|
+
file: c.path,
|
|
2681
|
+
message: added ? `A policy file was added that weakens the baseline: ${reason}.` : `The policy was weakened: ${reason}.`,
|
|
2682
|
+
evidence: reason,
|
|
2683
|
+
remediation: "A change that weakens the guardrail is a high-risk edit requiring human sign-off, not an automated pass."
|
|
2684
|
+
})
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
function renameKept(c) {
|
|
2688
|
+
if (!c.oldPath) return false;
|
|
2689
|
+
const id = hookIdentity(c.oldPath);
|
|
2690
|
+
if (id === null || hookIdentity(c.path) !== id) return false;
|
|
2691
|
+
return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
|
|
2692
|
+
}
|
|
2693
|
+
function configFindings(c, policy) {
|
|
2694
|
+
if (c.before == null || c.after == null) return null;
|
|
2695
|
+
const before = parseDoc(c.before);
|
|
2696
|
+
const after = parseDoc(c.after);
|
|
2697
|
+
if (before === null) return null;
|
|
2698
|
+
const reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
|
|
2699
|
+
return reasons.map(
|
|
2700
|
+
(reason) => makeFinding(RULE6, policy, {
|
|
2701
|
+
file: c.path,
|
|
2702
|
+
message: `The hook configuration was weakened: ${reason}.`,
|
|
2703
|
+
evidence: reason,
|
|
2704
|
+
remediation: "Leave the entry that runs the gate as `tamperward init` wrote it. Skipping, staging-off or scoping it is disabling it."
|
|
2705
|
+
})
|
|
2706
|
+
);
|
|
2707
|
+
}
|
|
2708
|
+
function updateIndexHiding(toks, policy) {
|
|
2709
|
+
if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
|
|
2710
|
+
const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
|
|
2711
|
+
if (hide && toks.some((t) => isProtected(t, policy))) {
|
|
2712
|
+
return `git update-index ${hide} hides a protected file from every git diff \u2014 edits to it no longer reach the sweep or the pre-commit gate`;
|
|
2713
|
+
}
|
|
2714
|
+
if (toks.some((t) => /^--chmod=-x$/.test(t)) && toks.some((t) => isProtected(t, policy, "hooks"))) {
|
|
2715
|
+
return "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it";
|
|
2716
|
+
}
|
|
2717
|
+
return null;
|
|
2718
|
+
}
|
|
1501
2719
|
var hookTampering = {
|
|
1502
2720
|
id: RULE6,
|
|
1503
2721
|
surface: ["file", "command"],
|
|
1504
2722
|
certainty: "mechanical",
|
|
1505
|
-
run(changes, policy) {
|
|
2723
|
+
run(changes, policy, _view, ctx) {
|
|
1506
2724
|
const out2 = [];
|
|
1507
2725
|
for (const c of changes) {
|
|
2726
|
+
let semantic;
|
|
1508
2727
|
if (c.kind === "file") {
|
|
1509
2728
|
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
|
|
1510
|
-
if (!targetsHook)
|
|
1511
|
-
|
|
2729
|
+
if (!targetsHook) {
|
|
2730
|
+
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
2731
|
+
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
2732
|
+
out2.push(
|
|
2733
|
+
makeFinding(RULE6, policy, {
|
|
2734
|
+
file: c.path,
|
|
2735
|
+
message: `The hook installation was weakened: ${reason}.`,
|
|
2736
|
+
evidence: reason,
|
|
2737
|
+
remediation: "Keep the lifecycle script that installs the git hooks. Without it a clone runs no hook at all."
|
|
2738
|
+
})
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec2(c.oldMode) && !ownerExec2(c.newMode)) {
|
|
1512
2745
|
out2.push(
|
|
1513
2746
|
makeFinding(RULE6, policy, {
|
|
1514
2747
|
file: c.path,
|
|
@@ -1527,7 +2760,7 @@ var hookTampering = {
|
|
|
1527
2760
|
remediation: "Restore it. The hooks and the policy are protected assets, not obstacles to remove."
|
|
1528
2761
|
})
|
|
1529
2762
|
);
|
|
1530
|
-
} else if (c.op === "rename") {
|
|
2763
|
+
} else if (c.op === "rename" && !renameKept(c)) {
|
|
1531
2764
|
out2.push(
|
|
1532
2765
|
makeFinding(RULE6, policy, {
|
|
1533
2766
|
file: c.path,
|
|
@@ -1536,22 +2769,27 @@ var hookTampering = {
|
|
|
1536
2769
|
remediation: "Renaming a hook out of place disables it. Restore the original path."
|
|
1537
2770
|
})
|
|
1538
2771
|
);
|
|
1539
|
-
} else if (c.path.endsWith(POLICY_FILE)
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
2772
|
+
} else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, changes, policy, ctx)) !== null) {
|
|
2773
|
+
out2.push(...semantic);
|
|
2774
|
+
} else if (c.op === "rename") {
|
|
2775
|
+
} else if (isCodeowners(c.path)) {
|
|
2776
|
+
if (c.before != null && c.after != null) {
|
|
2777
|
+
for (const reason of codeownersWeakening(c.before, c.after)) {
|
|
2778
|
+
out2.push(
|
|
2779
|
+
makeFinding(RULE6, policy, {
|
|
2780
|
+
file: c.path,
|
|
2781
|
+
message: `CODEOWNERS was weakened: ${reason}.`,
|
|
2782
|
+
evidence: reason,
|
|
2783
|
+
remediation: "Keep a code owner on the workflows, the policy and the hooks. That rule is the only thing requiring a human on a change to the gate itself."
|
|
2784
|
+
})
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
1549
2787
|
}
|
|
2788
|
+
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
|
|
2789
|
+
out2.push(...semantic);
|
|
1550
2790
|
} else if (!c.path.endsWith(POLICY_FILE)) {
|
|
1551
2791
|
const added = addedLines(c);
|
|
1552
|
-
const
|
|
1553
|
-
const live = added.filter((l) => !isShellComment(l.content));
|
|
1554
|
-
const earlyExit = added.find((l) => /^\s*exit\s+0\s*$/.test(l.content));
|
|
2792
|
+
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
1555
2793
|
if (earlyExit) {
|
|
1556
2794
|
out2.push(
|
|
1557
2795
|
makeFinding(RULE6, policy, {
|
|
@@ -1563,20 +2801,30 @@ var hookTampering = {
|
|
|
1563
2801
|
})
|
|
1564
2802
|
);
|
|
1565
2803
|
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
const m = l.content.match(HOOK_INVOCATION);
|
|
1569
|
-
if (!m) continue;
|
|
1570
|
-
if (live.some((a) => a.content.includes(m[0]))) continue;
|
|
2804
|
+
const deadGuard = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
|
|
2805
|
+
if (deadGuard) {
|
|
1571
2806
|
out2.push(
|
|
1572
2807
|
makeFinding(RULE6, policy, {
|
|
1573
2808
|
file: c.path,
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2809
|
+
line: deadGuard.newLine ?? void 0,
|
|
2810
|
+
message: "A guard that can never be true was inserted into a protected hook \u2014 what it wraps no longer runs.",
|
|
2811
|
+
evidence: deadGuard.content.trim(),
|
|
2812
|
+
remediation: "Remove the dead condition. A check behind `if false` is a removed check."
|
|
2813
|
+
})
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
|
|
2817
|
+
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
2818
|
+
const lost = scriptWeakening(beforeLines, afterLines);
|
|
2819
|
+
if (lost.length) {
|
|
2820
|
+
out2.push(
|
|
2821
|
+
makeFinding(RULE6, policy, {
|
|
2822
|
+
file: c.path,
|
|
2823
|
+
message: `A check invocation was removed from a protected hook: ${lost[0].reason}.`,
|
|
2824
|
+
evidence: lost[0].evidence,
|
|
2825
|
+
remediation: "Restore the check. Removing, commenting out, guarding off or swallowing the gate's exit status disables it just as surely as deleting the hook."
|
|
1577
2826
|
})
|
|
1578
2827
|
);
|
|
1579
|
-
break;
|
|
1580
2828
|
}
|
|
1581
2829
|
} else {
|
|
1582
2830
|
const INLINE = /^\s*([A-Za-z0-9_-]+):\s*\{[^}]*\bseverity:\s*(block|warn)\b/;
|
|
@@ -1612,18 +2860,19 @@ var hookTampering = {
|
|
|
1612
2860
|
}
|
|
1613
2861
|
} else {
|
|
1614
2862
|
for (const seg of segments(c.raw)) {
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
2863
|
+
const toks = tokens(seg).map(unquote);
|
|
2864
|
+
const hiding = updateIndexHiding(toks, policy);
|
|
2865
|
+
if (hiding) {
|
|
2866
|
+
out2.push(
|
|
2867
|
+
makeFinding(RULE6, policy, {
|
|
2868
|
+
message: `Hook tampering via shell: ${hiding}.`,
|
|
2869
|
+
evidence: seg,
|
|
2870
|
+
remediation: "Leave protected files visible to git. Hiding one from the diff hides it from the gate, which is the tamper \u2014 use `--no-skip-worktree` / `--no-assume-unchanged` to undo it."
|
|
2871
|
+
})
|
|
2872
|
+
);
|
|
2873
|
+
continue;
|
|
1626
2874
|
}
|
|
2875
|
+
const why = shellWritesHook(seg, toks, policy);
|
|
1627
2876
|
if (why) {
|
|
1628
2877
|
out2.push(
|
|
1629
2878
|
makeFinding(RULE6, policy, {
|
|
@@ -1641,44 +2890,215 @@ var hookTampering = {
|
|
|
1641
2890
|
};
|
|
1642
2891
|
|
|
1643
2892
|
// src/detectors/test-deletion.ts
|
|
2893
|
+
import ts4 from "typescript";
|
|
2894
|
+
|
|
2895
|
+
// src/detectors/suite-config.ts
|
|
2896
|
+
import picomatch3 from "picomatch";
|
|
1644
2897
|
import ts3 from "typescript";
|
|
2898
|
+
var JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
|
|
2899
|
+
var JEST_DEFAULT_IGNORE = ["/node_modules/"];
|
|
2900
|
+
var VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
|
|
2901
|
+
var VITEST_DEFAULT_EXCLUDE = [
|
|
2902
|
+
"**/node_modules/**",
|
|
2903
|
+
"**/dist/**",
|
|
2904
|
+
"**/cypress/**",
|
|
2905
|
+
"**/.{idea,git,cache,output,temp}/**",
|
|
2906
|
+
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
|
|
2907
|
+
];
|
|
2908
|
+
var CANONICAL_SAMPLES = [
|
|
2909
|
+
"src/a.test.ts",
|
|
2910
|
+
"src/a.spec.ts",
|
|
2911
|
+
"test/a.test.ts",
|
|
2912
|
+
"tests/a.test.js",
|
|
2913
|
+
"src/__tests__/a.ts",
|
|
2914
|
+
"a.test.tsx",
|
|
2915
|
+
"a.spec.js",
|
|
2916
|
+
"test/a.test.mjs"
|
|
2917
|
+
];
|
|
2918
|
+
function keyName2(name) {
|
|
2919
|
+
if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
|
|
2920
|
+
return null;
|
|
2921
|
+
}
|
|
2922
|
+
function literals2(e) {
|
|
2923
|
+
if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
|
|
2924
|
+
if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
2925
|
+
const items = [];
|
|
2926
|
+
let opaque = false;
|
|
2927
|
+
for (const el of e.elements) {
|
|
2928
|
+
if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
|
|
2929
|
+
else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
|
|
2930
|
+
items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
|
|
2931
|
+
} else opaque = true;
|
|
2932
|
+
}
|
|
2933
|
+
return { items, opaque };
|
|
2934
|
+
}
|
|
2935
|
+
function underKey2(node, key2) {
|
|
2936
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
2937
|
+
if (ts3.isPropertyAssignment(p) && keyName2(p.name) === key2) return true;
|
|
2938
|
+
}
|
|
2939
|
+
return false;
|
|
2940
|
+
}
|
|
2941
|
+
function asExpression2(src) {
|
|
2942
|
+
const t = src.trim();
|
|
2943
|
+
return t.startsWith("{") ? `(${t})` : src;
|
|
2944
|
+
}
|
|
2945
|
+
function runnerOf(path) {
|
|
2946
|
+
const base = path.split("/").pop() ?? path;
|
|
2947
|
+
if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
|
|
2948
|
+
if (/^vitest\.config\./.test(base)) return "vitest";
|
|
2949
|
+
return null;
|
|
2950
|
+
}
|
|
2951
|
+
function parseSelection(src, runner) {
|
|
2952
|
+
const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
|
|
2953
|
+
try {
|
|
2954
|
+
const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
|
|
2955
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2956
|
+
const take = (key2, e) => {
|
|
2957
|
+
const { items, opaque } = literals2(e);
|
|
2958
|
+
if (opaque || seen.has(key2)) sel.opaque = true;
|
|
2959
|
+
seen.add(key2);
|
|
2960
|
+
sel.present = true;
|
|
2961
|
+
return items.map((g) => g.replace(/^<rootDir>\//, ""));
|
|
2962
|
+
};
|
|
2963
|
+
const visit = (node) => {
|
|
2964
|
+
if (ts3.isPropertyAssignment(node)) {
|
|
2965
|
+
const k = keyName2(node.name);
|
|
2966
|
+
if (runner === "jest") {
|
|
2967
|
+
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
2968
|
+
else if (k === "testRegex") sel.regex = take(k, node.initializer);
|
|
2969
|
+
else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
|
|
2970
|
+
} else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
|
|
2971
|
+
if (k === "include") sel.include = take(k, node.initializer);
|
|
2972
|
+
else if (k === "exclude") sel.ignore = take(k, node.initializer);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
ts3.forEachChild(node, visit);
|
|
2976
|
+
};
|
|
2977
|
+
visit(sf);
|
|
2978
|
+
} catch {
|
|
2979
|
+
}
|
|
2980
|
+
return sel;
|
|
2981
|
+
}
|
|
2982
|
+
var globs2 = (patterns) => {
|
|
2983
|
+
const ms = patterns.map((g) => {
|
|
2984
|
+
try {
|
|
2985
|
+
return picomatch3(g, { dot: true });
|
|
2986
|
+
} catch {
|
|
2987
|
+
return () => false;
|
|
2988
|
+
}
|
|
2989
|
+
});
|
|
2990
|
+
return (path) => ms.some((m) => m(path));
|
|
2991
|
+
};
|
|
2992
|
+
var regexes = (patterns) => {
|
|
2993
|
+
const rs = patterns.flatMap((p) => {
|
|
2994
|
+
try {
|
|
2995
|
+
return [new RegExp(p.replace(/<rootDir>/g, ""))];
|
|
2996
|
+
} catch {
|
|
2997
|
+
return [];
|
|
2998
|
+
}
|
|
2999
|
+
});
|
|
3000
|
+
return (path) => rs.some((r) => r.test("/" + path));
|
|
3001
|
+
};
|
|
3002
|
+
function predicate(sel, runner) {
|
|
3003
|
+
const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
|
|
3004
|
+
const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
|
|
3005
|
+
const included = includeList ? globs2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
|
|
3006
|
+
const ignored = runner === "jest" ? regexes(ignoreList) : globs2(ignoreList);
|
|
3007
|
+
return {
|
|
3008
|
+
selects: (p) => included(p) && !ignored(p),
|
|
3009
|
+
why: (p) => {
|
|
3010
|
+
if (ignored(p)) {
|
|
3011
|
+
const key3 = runner === "jest" ? "testPathIgnorePatterns" : "test.exclude";
|
|
3012
|
+
const hit = ignoreList.find((g) => (runner === "jest" ? regexes([g]) : globs2([g]))(p));
|
|
3013
|
+
return `${key3} now matches it (${JSON.stringify(hit ?? "")})`;
|
|
3014
|
+
}
|
|
3015
|
+
const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
|
|
3016
|
+
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
|
|
3017
|
+
}
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
function suiteNarrowings(before, after, path, samples) {
|
|
3021
|
+
const runner = runnerOf(path);
|
|
3022
|
+
if (!runner) return [];
|
|
3023
|
+
const b = parseSelection(before ?? "", runner);
|
|
3024
|
+
const a = parseSelection(after, runner);
|
|
3025
|
+
if (!a.present || a.opaque) return [];
|
|
3026
|
+
const pb = predicate(b, runner);
|
|
3027
|
+
const pa = predicate(a, runner);
|
|
3028
|
+
const out2 = [];
|
|
3029
|
+
for (const s of samples) {
|
|
3030
|
+
if (pb.selects(s) && !pa.selects(s)) out2.push({ path: s, reason: pa.why(s) });
|
|
3031
|
+
}
|
|
3032
|
+
return out2;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// src/detectors/test-deletion.ts
|
|
1645
3036
|
var RULE7 = "test-deletion";
|
|
3037
|
+
var JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
|
|
1646
3038
|
var TEST_DEFS = {
|
|
1647
3039
|
py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
|
|
1648
3040
|
go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
|
|
1649
3041
|
rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
|
|
1650
3042
|
rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
|
|
1651
|
-
java:
|
|
1652
|
-
kt:
|
|
3043
|
+
java: JVM_TEST,
|
|
3044
|
+
kt: JVM_TEST,
|
|
1653
3045
|
php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
|
|
1654
3046
|
cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
|
|
1655
3047
|
};
|
|
1656
3048
|
function calleeName(expr) {
|
|
1657
|
-
if (
|
|
1658
|
-
if (
|
|
3049
|
+
if (ts4.isIdentifier(expr)) return expr.text;
|
|
3050
|
+
if (ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression)) {
|
|
1659
3051
|
return expr.expression.text;
|
|
1660
3052
|
}
|
|
1661
3053
|
return null;
|
|
1662
3054
|
}
|
|
1663
|
-
|
|
3055
|
+
var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
|
|
3056
|
+
function eachRows(call) {
|
|
3057
|
+
const arg = call.arguments[0];
|
|
3058
|
+
if (!arg) return { n: 1, open: true };
|
|
3059
|
+
if (ts4.isArrayLiteralExpression(arg)) {
|
|
3060
|
+
let n = 0;
|
|
3061
|
+
let open = false;
|
|
3062
|
+
for (const el of arg.elements) {
|
|
3063
|
+
if (ts4.isSpreadElement(el)) open = true;
|
|
3064
|
+
else n++;
|
|
3065
|
+
}
|
|
3066
|
+
return { n: Math.max(n, open ? 0 : 1), open };
|
|
3067
|
+
}
|
|
3068
|
+
return { n: 1, open: true };
|
|
3069
|
+
}
|
|
3070
|
+
function countTests(src, path = "spec.ts") {
|
|
1664
3071
|
const lang = langOf(path);
|
|
1665
3072
|
if (lang && lang !== "js") {
|
|
1666
3073
|
const re = TEST_DEFS[lang];
|
|
1667
3074
|
let n2 = 0;
|
|
1668
3075
|
for (const line of src.split("\n")) if (re.test(line)) n2++;
|
|
1669
|
-
return n2;
|
|
3076
|
+
return { min: n2, open: false };
|
|
1670
3077
|
}
|
|
1671
|
-
const sf =
|
|
3078
|
+
const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
|
|
1672
3079
|
let n = 0;
|
|
3080
|
+
let open = false;
|
|
1673
3081
|
const visit = (node) => {
|
|
1674
|
-
if (
|
|
1675
|
-
|
|
1676
|
-
|
|
3082
|
+
if (ts4.isCallExpression(node)) {
|
|
3083
|
+
if (isEachOf(node.expression)) {
|
|
3084
|
+
const r = eachRows(node);
|
|
3085
|
+
n += r.n;
|
|
3086
|
+
if (r.open) open = true;
|
|
3087
|
+
} else {
|
|
3088
|
+
const name = calleeName(node.expression);
|
|
3089
|
+
if (name === "it" || name === "test") n++;
|
|
3090
|
+
}
|
|
3091
|
+
} else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
|
|
3092
|
+
const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
|
|
3093
|
+
n += Math.max(rows, 1);
|
|
1677
3094
|
}
|
|
1678
|
-
|
|
3095
|
+
ts4.forEachChild(node, visit);
|
|
1679
3096
|
};
|
|
1680
3097
|
visit(sf);
|
|
1681
|
-
return n;
|
|
3098
|
+
return { min: n, open };
|
|
3099
|
+
}
|
|
3100
|
+
function countTestBlocks(src, path = "spec.ts") {
|
|
3101
|
+
return countTests(src, path).min;
|
|
1682
3102
|
}
|
|
1683
3103
|
function significantLines(src, path) {
|
|
1684
3104
|
const out2 = /* @__PURE__ */ new Set();
|
|
@@ -1689,32 +3109,63 @@ function significantLines(src, path) {
|
|
|
1689
3109
|
}
|
|
1690
3110
|
return out2;
|
|
1691
3111
|
}
|
|
3112
|
+
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
|
|
3113
|
+
function runnerSamples(configPath, policy, ctx) {
|
|
3114
|
+
const files = trackedFiles(ctx);
|
|
3115
|
+
if (!files) return CANONICAL_SAMPLES;
|
|
3116
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
3117
|
+
const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
|
|
3118
|
+
return own.length ? own : CANONICAL_SAMPLES;
|
|
3119
|
+
}
|
|
3120
|
+
function joinRoot(root, to) {
|
|
3121
|
+
const parts = root ? root.split("/") : [];
|
|
3122
|
+
for (const seg of to.replace(/\/+$/, "").split("/")) {
|
|
3123
|
+
if (seg === "" || seg === ".") continue;
|
|
3124
|
+
if (seg === "..") {
|
|
3125
|
+
if (parts.length === 0) return null;
|
|
3126
|
+
parts.pop();
|
|
3127
|
+
} else parts.push(seg);
|
|
3128
|
+
}
|
|
3129
|
+
return parts.join("/");
|
|
3130
|
+
}
|
|
3131
|
+
function leavesGlob(dir, dest, policy, ctx) {
|
|
3132
|
+
const files = trackedFiles(ctx);
|
|
3133
|
+
if (!files || !dir) return false;
|
|
3134
|
+
const src = dir.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
|
|
3135
|
+
const to = dest.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
|
|
3136
|
+
return files.some((f) => f.startsWith(src) && isProtected(f, policy, "tests") && !isProtected(to + f.slice(src.length), policy, "tests"));
|
|
3137
|
+
}
|
|
3138
|
+
var SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
|
|
1692
3139
|
var testDeletion = {
|
|
1693
3140
|
id: RULE7,
|
|
1694
3141
|
surface: ["file", "command"],
|
|
1695
3142
|
certainty: "mechanical",
|
|
1696
|
-
run(changes, policy) {
|
|
3143
|
+
run(changes, policy, _view, ctx) {
|
|
1697
3144
|
const out2 = [];
|
|
1698
3145
|
const addedTestLines = /* @__PURE__ */ new Set();
|
|
1699
3146
|
let addedTestBlocks = 0;
|
|
1700
3147
|
for (const c of changes) {
|
|
1701
|
-
if (c.kind
|
|
3148
|
+
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
|
|
3149
|
+
if (c.op === "add") {
|
|
1702
3150
|
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
1703
3151
|
addedTestBlocks += countTestBlocks(c.after, c.path);
|
|
3152
|
+
} else if (c.before != null) {
|
|
3153
|
+
const before = significantLines(c.before, c.path);
|
|
3154
|
+
for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
|
|
3155
|
+
addedTestBlocks += Math.max(0, countTestBlocks(c.after, c.path) - countTestBlocks(c.before, c.path));
|
|
1704
3156
|
}
|
|
1705
3157
|
}
|
|
1706
|
-
const isRelocation = (
|
|
1707
|
-
|
|
1708
|
-
if (sig.size === 0 || addedTestLines.size === 0) return false;
|
|
3158
|
+
const isRelocation = (removed, blocksRemoved) => {
|
|
3159
|
+
if (removed.size === 0 || addedTestLines.size === 0) return false;
|
|
1709
3160
|
let hit = 0;
|
|
1710
|
-
for (const l of
|
|
1711
|
-
return hit /
|
|
3161
|
+
for (const l of removed) if (addedTestLines.has(l)) hit++;
|
|
3162
|
+
return hit / removed.size >= 0.6 && addedTestBlocks >= blocksRemoved;
|
|
1712
3163
|
};
|
|
1713
3164
|
for (const c of changes) {
|
|
1714
3165
|
if (c.kind === "file") {
|
|
1715
3166
|
const isTest = isProtected(c.path, policy, "tests");
|
|
1716
3167
|
if (c.op === "delete" && isTest) {
|
|
1717
|
-
if (c.before != null && isRelocation(c.before, c.path)) continue;
|
|
3168
|
+
if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
|
|
1718
3169
|
out2.push(
|
|
1719
3170
|
makeFinding(RULE7, policy, {
|
|
1720
3171
|
file: c.path,
|
|
@@ -1733,41 +3184,95 @@ var testDeletion = {
|
|
|
1733
3184
|
})
|
|
1734
3185
|
);
|
|
1735
3186
|
} else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
|
|
1736
|
-
const before =
|
|
1737
|
-
const after =
|
|
1738
|
-
if (after < before) {
|
|
3187
|
+
const before = countTests(c.before, c.path);
|
|
3188
|
+
const after = countTests(c.after, c.path);
|
|
3189
|
+
if (after.min < before.min && !after.open) {
|
|
3190
|
+
const afterLines = significantLines(c.after, c.path);
|
|
3191
|
+
const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
|
|
3192
|
+
if (isRelocation(removed, before.min - after.min)) continue;
|
|
1739
3193
|
out2.push(
|
|
1740
3194
|
makeFinding(RULE7, policy, {
|
|
1741
3195
|
file: c.path,
|
|
1742
|
-
message: `Test blocks removed: ${before} \u2192 ${after} it()/test() in this spec.`,
|
|
1743
|
-
evidence: `${before - after} test block(s) removed from ${c.path}`,
|
|
3196
|
+
message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
|
|
3197
|
+
evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
|
|
1744
3198
|
remediation: "Keep the assertions and fix the code. Removing test blocks to go green is the tamper."
|
|
1745
3199
|
})
|
|
1746
3200
|
);
|
|
1747
3201
|
}
|
|
3202
|
+
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
|
|
3203
|
+
for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
|
|
3204
|
+
out2.push(
|
|
3205
|
+
makeFinding(RULE7, policy, {
|
|
3206
|
+
file: c.path,
|
|
3207
|
+
message: `The runner no longer selects ${n.path}: ${n.reason}.`,
|
|
3208
|
+
evidence: `${n.path} dropped from the suite by ${c.path}`,
|
|
3209
|
+
remediation: "Keep the runner pointed at the whole suite. Excluding a spec from the runner removes its tests without touching the file."
|
|
3210
|
+
})
|
|
3211
|
+
);
|
|
3212
|
+
}
|
|
1748
3213
|
}
|
|
1749
3214
|
} else {
|
|
3215
|
+
let root = "";
|
|
1750
3216
|
for (const seg of segments(c.raw)) {
|
|
1751
3217
|
const toks = tokens(seg).map(unquote);
|
|
1752
|
-
const
|
|
1753
|
-
if (
|
|
3218
|
+
const cdTo = toks[0] === "cd" ? toks[1] : null;
|
|
3219
|
+
if (cdTo !== null) {
|
|
3220
|
+
root = cdTo === void 0 || /^[/~$]/.test(cdTo) || root === null ? null : joinRoot(root, cdTo);
|
|
3221
|
+
continue;
|
|
3222
|
+
}
|
|
3223
|
+
const inRepo = (t) => root ? `${root}/${t}` : t;
|
|
3224
|
+
const listing = trackedFiles(ctx);
|
|
3225
|
+
const testToks = toks.filter((t) => {
|
|
3226
|
+
if (!isProtected(t, policy, "tests")) return false;
|
|
3227
|
+
if (!listing || root === null) return true;
|
|
3228
|
+
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(t.split("/").pop() ?? "");
|
|
3229
|
+
if (looksLikeFile || listing.includes(inRepo(t))) return true;
|
|
3230
|
+
return containsProtected(inRepo(t), policy, "tests", ctx);
|
|
3231
|
+
});
|
|
3232
|
+
const dirToks = toks.filter(
|
|
3233
|
+
(t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
|
|
3234
|
+
);
|
|
3235
|
+
if (testToks.length === 0 && dirToks.length === 0) continue;
|
|
3236
|
+
const named = [...testToks, ...dirToks];
|
|
3237
|
+
const cmd = toks[0] ?? "";
|
|
1754
3238
|
let why = null;
|
|
1755
3239
|
let evidence = seg;
|
|
1756
3240
|
const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
|
|
1757
3241
|
const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
|
|
1758
|
-
|
|
3242
|
+
const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
|
|
3243
|
+
const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
|
|
3244
|
+
const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
|
|
3245
|
+
const rev = src ?? toks.slice(2).find((t) => !t.startsWith("-") && !named.includes(t) && t !== "--");
|
|
3246
|
+
return !!rev && rev !== "HEAD" && (!!src || seg.includes(" -- "));
|
|
3247
|
+
})();
|
|
3248
|
+
if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
|
|
1759
3249
|
why = "rm deletes a test file";
|
|
1760
|
-
} else if (
|
|
3250
|
+
} else if (cmd === "git" && /^git\s+rm\b/.test(seg)) {
|
|
3251
|
+
why = "git rm deletes a test file";
|
|
3252
|
+
} else if (cmd === "sed" && SED_INPLACE.test(seg)) {
|
|
1761
3253
|
why = "sed -i rewrites a test file in place";
|
|
3254
|
+
} else if (cmd === "perl" && /(?:^|\s)-[a-zA-Z]*i\b|\s-i\S*/.test(seg)) {
|
|
3255
|
+
why = "perl -i rewrites a test file in place";
|
|
1762
3256
|
} else if (/\btruncate\b/.test(seg)) {
|
|
1763
3257
|
why = "truncate empties a test file";
|
|
1764
3258
|
} else if (redirectsOntoTest) {
|
|
1765
3259
|
why = "a redirect overwrites/empties a test file";
|
|
3260
|
+
} else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
|
|
3261
|
+
why = "cp overwrites a test file with something else";
|
|
3262
|
+
} else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
|
|
3263
|
+
why = "tee overwrites a test file";
|
|
3264
|
+
} else if (findDeletes) {
|
|
3265
|
+
why = "find -delete removes test files";
|
|
3266
|
+
} else if (gitRestoresOld) {
|
|
3267
|
+
why = "git checkout <rev> replaces a test file with an older version";
|
|
1766
3268
|
} else if (/\bmv\b/.test(seg)) {
|
|
1767
3269
|
const dest = toks[toks.length - 1];
|
|
1768
|
-
if (dest && !isProtected(dest, policy, "tests")) {
|
|
3270
|
+
if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
|
|
1769
3271
|
why = "mv renames a test file out of the test glob";
|
|
1770
3272
|
evidence = `${testToks[0]} \u2192 ${dest}`;
|
|
3273
|
+
} else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
|
|
3274
|
+
why = "mv renames a test directory so its specs leave the test glob";
|
|
3275
|
+
evidence = `${dirToks[0]} \u2192 ${dest}`;
|
|
1771
3276
|
}
|
|
1772
3277
|
}
|
|
1773
3278
|
if (why) {
|
|
@@ -1787,9 +3292,40 @@ var testDeletion = {
|
|
|
1787
3292
|
};
|
|
1788
3293
|
|
|
1789
3294
|
// src/detectors/test-content-removal.ts
|
|
3295
|
+
import ts5 from "typescript";
|
|
1790
3296
|
var RULE8 = "test-content-removal";
|
|
1791
3297
|
var MIN_REMOVED_LINES = 3;
|
|
3298
|
+
var MIN_REMOVED_ROWS = 2;
|
|
1792
3299
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
3300
|
+
function eachTables(src, path) {
|
|
3301
|
+
const rows = /* @__PURE__ */ new Set();
|
|
3302
|
+
let open = false;
|
|
3303
|
+
if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
|
|
3304
|
+
try {
|
|
3305
|
+
const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
|
|
3306
|
+
const visit = (node) => {
|
|
3307
|
+
if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && node.expression.name.text === "each" && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
|
|
3308
|
+
const arg = node.arguments[0];
|
|
3309
|
+
if (arg && ts5.isArrayLiteralExpression(arg)) {
|
|
3310
|
+
for (const el of arg.elements) {
|
|
3311
|
+
if (ts5.isSpreadElement(el)) {
|
|
3312
|
+
open = true;
|
|
3313
|
+
continue;
|
|
3314
|
+
}
|
|
3315
|
+
for (const raw of el.getText(sf).split("\n")) {
|
|
3316
|
+
const l = raw.trim().replace(/,$/, "");
|
|
3317
|
+
if (isSignificantLine(l, "js")) rows.add(l);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
} else open = true;
|
|
3321
|
+
}
|
|
3322
|
+
ts5.forEachChild(node, visit);
|
|
3323
|
+
};
|
|
3324
|
+
visit(sf);
|
|
3325
|
+
} catch {
|
|
3326
|
+
}
|
|
3327
|
+
return { rows, open };
|
|
3328
|
+
}
|
|
1793
3329
|
function significantLinesOrdered(src, path) {
|
|
1794
3330
|
const out2 = [];
|
|
1795
3331
|
const lang = langOf(path);
|
|
@@ -1806,16 +3342,23 @@ var testContentRemoval = {
|
|
|
1806
3342
|
run(changes, policy) {
|
|
1807
3343
|
const out2 = [];
|
|
1808
3344
|
let keptPool = "";
|
|
3345
|
+
let addedPool = "";
|
|
1809
3346
|
for (const c of changes) {
|
|
1810
|
-
if (c.kind
|
|
1811
|
-
|
|
3347
|
+
if (c.kind !== "file" || c.after == null) continue;
|
|
3348
|
+
if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
|
|
3349
|
+
if (c.op === "add") addedPool += ws(c.after);
|
|
3350
|
+
else if (c.before != null) {
|
|
3351
|
+
const had = new Set(c.before.split("\n").map((l) => l.trim()));
|
|
3352
|
+
addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
|
|
1812
3353
|
}
|
|
1813
3354
|
}
|
|
1814
3355
|
for (const c of changes) {
|
|
1815
3356
|
if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
|
|
1816
3357
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
1817
3358
|
if (isProtected(c.path, policy, "snapshots")) continue;
|
|
1818
|
-
|
|
3359
|
+
const blocksBefore = countTests(c.before, c.path);
|
|
3360
|
+
const blocksAfter = countTests(c.after, c.path);
|
|
3361
|
+
if (blocksAfter.min < blocksBefore.min && !blocksAfter.open) continue;
|
|
1819
3362
|
const beforeSig = significantLinesOrdered(c.before, c.path);
|
|
1820
3363
|
const afterSig = significantLinesOrdered(c.after, c.path);
|
|
1821
3364
|
const netDrop = beforeSig.length - afterSig.length;
|
|
@@ -1826,6 +3369,19 @@ var testContentRemoval = {
|
|
|
1826
3369
|
if (keptPool.includes(ws(line))) continue;
|
|
1827
3370
|
gone.push(line);
|
|
1828
3371
|
}
|
|
3372
|
+
const table = eachTables(c.before, c.path);
|
|
3373
|
+
const goneRows = table.rows.size && eachTables(c.after, c.path).open ? gone.filter((l) => table.rows.has(l.replace(/,$/, "")) && !addedPool.includes(ws(l))) : [];
|
|
3374
|
+
if (goneRows.length >= MIN_REMOVED_ROWS) {
|
|
3375
|
+
out2.push(
|
|
3376
|
+
makeFinding(RULE8, policy, {
|
|
3377
|
+
file: c.path,
|
|
3378
|
+
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that now spreads from elsewhere, and they reappear nowhere in the change.`,
|
|
3379
|
+
evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
3380
|
+
remediation: "Fix the code under test. A row of it.each is a test; moving the table out of the spec must carry every row with it."
|
|
3381
|
+
})
|
|
3382
|
+
);
|
|
3383
|
+
continue;
|
|
3384
|
+
}
|
|
1829
3385
|
if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
|
|
1830
3386
|
out2.push(
|
|
1831
3387
|
makeFinding(RULE8, policy, {
|
|
@@ -1843,7 +3399,7 @@ var testContentRemoval = {
|
|
|
1843
3399
|
|
|
1844
3400
|
// src/detectors/snapshot-rewrite.ts
|
|
1845
3401
|
var RULE9 = "snapshot-rewrite";
|
|
1846
|
-
var
|
|
3402
|
+
var RUNNER2 = /\b(?:jest|vitest|playwright|ava)\b/;
|
|
1847
3403
|
var PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
|
|
1848
3404
|
var UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
|
|
1849
3405
|
var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
|
|
@@ -1870,7 +3426,7 @@ var snapshotRewrite = {
|
|
|
1870
3426
|
for (const seg of segments(c.raw)) {
|
|
1871
3427
|
let why = null;
|
|
1872
3428
|
if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
|
|
1873
|
-
else if ((
|
|
3429
|
+
else if ((RUNNER2.test(seg) || PM_TEST_SCRIPT.test(seg)) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
|
|
1874
3430
|
else if (INSTA_ACCEPT.test(seg)) why = "cargo insta accept rewrites every pending snapshot to match current output";
|
|
1875
3431
|
else if (UPDATE_EXPECT.test(seg)) why = "UPDATE_EXPECT=1 rewrites every expect-test expectation to match current output";
|
|
1876
3432
|
else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
|
|
@@ -1952,6 +3508,12 @@ var allDetectors = [
|
|
|
1952
3508
|
];
|
|
1953
3509
|
|
|
1954
3510
|
// src/engine.ts
|
|
3511
|
+
var GUARDED_RULE = "hook-tampering";
|
|
3512
|
+
var BASELINE_HOOKS = defaultPolicy().protected.hooks;
|
|
3513
|
+
function isGuardedFinding(f) {
|
|
3514
|
+
if (f.rule !== GUARDED_RULE || !f.file) return false;
|
|
3515
|
+
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
|
|
3516
|
+
}
|
|
1955
3517
|
function key(f) {
|
|
1956
3518
|
return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
|
|
1957
3519
|
}
|
|
@@ -1963,13 +3525,13 @@ function isSuppressed(c, policy) {
|
|
|
1963
3525
|
function activeChanges(changes, policy) {
|
|
1964
3526
|
return changes.filter((c) => !isSuppressed(c, policy));
|
|
1965
3527
|
}
|
|
1966
|
-
function evaluate(changes, policy, detectors = allDetectors, view) {
|
|
3528
|
+
function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
|
|
1967
3529
|
const active = activeChanges(changes, policy);
|
|
1968
3530
|
const out2 = [];
|
|
1969
3531
|
for (const d of detectors) {
|
|
1970
|
-
if (!isEnabled(d.id, policy)) continue;
|
|
3532
|
+
if (d.id !== GUARDED_RULE && !isEnabled(d.id, policy)) continue;
|
|
1971
3533
|
try {
|
|
1972
|
-
out2.push(...d.run(active, policy, view));
|
|
3534
|
+
out2.push(...d.run(active, policy, view, ctx));
|
|
1973
3535
|
} catch (e) {
|
|
1974
3536
|
process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
|
|
1975
3537
|
`);
|
|
@@ -1992,20 +3554,25 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
|
|
|
1992
3554
|
seen.add(k);
|
|
1993
3555
|
return true;
|
|
1994
3556
|
});
|
|
1995
|
-
return deduped.filter((f) => {
|
|
1996
|
-
if (f.rule
|
|
3557
|
+
return deduped.map(pinGuarded).filter((f) => {
|
|
3558
|
+
if (f.rule === "detector-error" || isGuardedFinding(f)) return true;
|
|
3559
|
+
if (!isEnabled(f.rule, policy)) return false;
|
|
1997
3560
|
const ex = policy.rules[f.rule]?.exclude;
|
|
1998
3561
|
if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
|
|
1999
3562
|
return !matchesAny(f.file, ex);
|
|
2000
3563
|
});
|
|
2001
3564
|
}
|
|
3565
|
+
function pinGuarded(f) {
|
|
3566
|
+
if (!isGuardedFinding(f) || f.severity === "block") return f;
|
|
3567
|
+
return { ...f, severity: "block", signoff: { ...f.signoff, required: true } };
|
|
3568
|
+
}
|
|
2002
3569
|
function hasBlocking(findings) {
|
|
2003
3570
|
return findings.some((f) => f.severity === "block");
|
|
2004
3571
|
}
|
|
2005
3572
|
|
|
2006
3573
|
// src/policy-load.ts
|
|
2007
|
-
import { readFileSync as
|
|
2008
|
-
import { isAbsolute, join as
|
|
3574
|
+
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
|
|
3575
|
+
import { isAbsolute, join as join3, posix } from "node:path";
|
|
2009
3576
|
import { parse as parse2 } from "yaml";
|
|
2010
3577
|
var PolicyError = class extends Error {
|
|
2011
3578
|
};
|
|
@@ -2053,8 +3620,8 @@ function validate(r, where) {
|
|
|
2053
3620
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
2054
3621
|
if (r.protected !== void 0) {
|
|
2055
3622
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
2056
|
-
for (const [cat,
|
|
2057
|
-
if (!isStringList(
|
|
3623
|
+
for (const [cat, globs3] of Object.entries(r.protected)) {
|
|
3624
|
+
if (!isStringList(globs3)) bad(`protected.${cat} must be a list of globs, got ${show(globs3)}`);
|
|
2058
3625
|
}
|
|
2059
3626
|
}
|
|
2060
3627
|
if (r.signoff !== void 0) {
|
|
@@ -2086,10 +3653,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
2086
3653
|
const version = normalizeVersion(r.version, where);
|
|
2087
3654
|
validate(r, where);
|
|
2088
3655
|
const base = defaultPolicy(version);
|
|
2089
|
-
const
|
|
2090
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat,
|
|
3656
|
+
const globs3 = (list) => list?.map(normalizeGlob);
|
|
3657
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs3(list) ?? []])) : void 0;
|
|
2091
3658
|
const userRules = r.rules ? Object.fromEntries(
|
|
2092
|
-
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude:
|
|
3659
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
|
|
2093
3660
|
) : void 0;
|
|
2094
3661
|
return {
|
|
2095
3662
|
version,
|
|
@@ -2099,7 +3666,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
2099
3666
|
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
2100
3667
|
protected: mergeProtected(base.protected, userProtected),
|
|
2101
3668
|
rules: mergeRules(base.rules, userRules),
|
|
2102
|
-
ignore:
|
|
3669
|
+
ignore: globs3(r.ignore) ?? base.ignore,
|
|
2103
3670
|
signoff: {
|
|
2104
3671
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
2105
3672
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -2129,9 +3696,9 @@ function parseOrThrow(src, where) {
|
|
|
2129
3696
|
return parsePolicy(raw, where);
|
|
2130
3697
|
}
|
|
2131
3698
|
function loadPolicy(cwd = process.cwd()) {
|
|
2132
|
-
const path =
|
|
2133
|
-
if (!
|
|
2134
|
-
return parseOrThrow(
|
|
3699
|
+
const path = join3(cwd, POLICY_FILE);
|
|
3700
|
+
if (!existsSync2(path)) return defaultPolicy();
|
|
3701
|
+
return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
|
|
2135
3702
|
}
|
|
2136
3703
|
function loadPolicyAt(rev, cwd) {
|
|
2137
3704
|
const src = fileAt(rev, POLICY_FILE, { cwd });
|
|
@@ -2141,8 +3708,8 @@ function loadPolicyAt(rev, cwd) {
|
|
|
2141
3708
|
|
|
2142
3709
|
// src/signoff.ts
|
|
2143
3710
|
import { createHash } from "node:crypto";
|
|
2144
|
-
import { appendFileSync, mkdirSync, readFileSync as
|
|
2145
|
-
import { dirname, join as
|
|
3711
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
|
|
3712
|
+
import { dirname, join as join4 } from "node:path";
|
|
2146
3713
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
2147
3714
|
function fingerprint(rule, file, evidence) {
|
|
2148
3715
|
return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
|
|
@@ -2153,13 +3720,13 @@ function ledgerPath(cwd, policy) {
|
|
|
2153
3720
|
if (!ledgerInsideRepo(rel)) {
|
|
2154
3721
|
throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
|
|
2155
3722
|
}
|
|
2156
|
-
return
|
|
3723
|
+
return join4(cwd, rel);
|
|
2157
3724
|
}
|
|
2158
3725
|
function readLedger(cwd, policy) {
|
|
2159
3726
|
const p = ledgerPath(cwd, policy);
|
|
2160
|
-
if (!
|
|
3727
|
+
if (!existsSync3(p)) return [];
|
|
2161
3728
|
const out2 = [];
|
|
2162
|
-
for (const line of
|
|
3729
|
+
for (const line of readFileSync4(p, "utf8").split("\n")) {
|
|
2163
3730
|
if (!line.trim()) continue;
|
|
2164
3731
|
try {
|
|
2165
3732
|
const e = JSON.parse(line);
|
|
@@ -2502,7 +4069,7 @@ function check(opts) {
|
|
|
2502
4069
|
return 2;
|
|
2503
4070
|
}
|
|
2504
4071
|
const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
|
|
2505
|
-
let findings = evaluate(changes, policy, void 0, view);
|
|
4072
|
+
let findings = evaluate(changes, policy, void 0, view, { cwd });
|
|
2506
4073
|
const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
|
|
2507
4074
|
findings = remaining;
|
|
2508
4075
|
if (cleared.length) {
|
|
@@ -2527,21 +4094,21 @@ function runCheck(opts) {
|
|
|
2527
4094
|
}
|
|
2528
4095
|
|
|
2529
4096
|
// src/cli/hook.ts
|
|
2530
|
-
import { readFileSync as
|
|
4097
|
+
import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
|
|
2531
4098
|
import { createHash as createHash4 } from "node:crypto";
|
|
2532
|
-
import { join as
|
|
4099
|
+
import { join as join9 } from "node:path";
|
|
2533
4100
|
|
|
2534
4101
|
// src/adapters/claude/changes.ts
|
|
2535
|
-
import { execFileSync as
|
|
2536
|
-
import { readFileSync as
|
|
4102
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
4103
|
+
import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
2537
4104
|
import { tmpdir } from "node:os";
|
|
2538
|
-
import { isAbsolute as isAbsolute2, join as
|
|
4105
|
+
import { isAbsolute as isAbsolute2, join as join5, relative, resolve } from "node:path";
|
|
2539
4106
|
function asStr(v) {
|
|
2540
4107
|
return typeof v === "string" ? v : "";
|
|
2541
4108
|
}
|
|
2542
4109
|
function readDisk(path) {
|
|
2543
4110
|
try {
|
|
2544
|
-
return
|
|
4111
|
+
return existsSync4(path) ? readFileSync5(path, "utf8") : null;
|
|
2545
4112
|
} catch {
|
|
2546
4113
|
return null;
|
|
2547
4114
|
}
|
|
@@ -2564,15 +4131,15 @@ function synthFileChange(displayPath, before, after) {
|
|
|
2564
4131
|
if (before === null) op = "add";
|
|
2565
4132
|
else if (after === null) op = "delete";
|
|
2566
4133
|
else op = "modify";
|
|
2567
|
-
const dir = mkdtempSync(
|
|
4134
|
+
const dir = mkdtempSync(join5(tmpdir(), "hf-"));
|
|
2568
4135
|
let raw = "";
|
|
2569
4136
|
try {
|
|
2570
|
-
const a =
|
|
2571
|
-
const b =
|
|
4137
|
+
const a = join5(dir, "a");
|
|
4138
|
+
const b = join5(dir, "b");
|
|
2572
4139
|
writeFileSync(a, before ?? "");
|
|
2573
4140
|
writeFileSync(b, after ?? "");
|
|
2574
4141
|
try {
|
|
2575
|
-
raw =
|
|
4142
|
+
raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
|
|
2576
4143
|
encoding: "utf8",
|
|
2577
4144
|
maxBuffer: 256 * 1024 * 1024
|
|
2578
4145
|
});
|
|
@@ -2653,22 +4220,22 @@ function formatDenial(blocks) {
|
|
|
2653
4220
|
}
|
|
2654
4221
|
|
|
2655
4222
|
// src/session.ts
|
|
2656
|
-
import { existsSync as
|
|
2657
|
-
import { dirname as dirname2, join as
|
|
4223
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
|
|
4224
|
+
import { dirname as dirname2, join as join6 } from "node:path";
|
|
2658
4225
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
2659
4226
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
2660
4227
|
function baselinePath(cwd, sessionId) {
|
|
2661
4228
|
const gd = gitDir(cwd);
|
|
2662
4229
|
if (!gd) return null;
|
|
2663
|
-
return
|
|
4230
|
+
return join6(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
|
|
2664
4231
|
}
|
|
2665
4232
|
function turnBaseline(cwd, sessionId) {
|
|
2666
4233
|
if (!sessionId) return null;
|
|
2667
4234
|
try {
|
|
2668
4235
|
const p = baselinePath(cwd, sessionId);
|
|
2669
4236
|
if (!p) return null;
|
|
2670
|
-
if (
|
|
2671
|
-
const v =
|
|
4237
|
+
if (existsSync5(p)) {
|
|
4238
|
+
const v = readFileSync6(p, "utf8").trim();
|
|
2672
4239
|
if (SHA.test(v)) return v;
|
|
2673
4240
|
}
|
|
2674
4241
|
const head = headSha(cwd);
|
|
@@ -2694,20 +4261,20 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2694
4261
|
|
|
2695
4262
|
// src/effect.ts
|
|
2696
4263
|
import { createHash as createHash2 } from "node:crypto";
|
|
2697
|
-
import { existsSync as
|
|
2698
|
-
import { isAbsolute as isAbsolute3, join as
|
|
2699
|
-
import { execFileSync as
|
|
4264
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
4265
|
+
import { isAbsolute as isAbsolute3, join as join7 } from "node:path";
|
|
4266
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
2700
4267
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2701
4268
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
2702
4269
|
function gitDir2(cwd) {
|
|
2703
4270
|
try {
|
|
2704
|
-
const d =
|
|
4271
|
+
const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
|
|
2705
4272
|
cwd,
|
|
2706
4273
|
encoding: "utf8",
|
|
2707
4274
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2708
4275
|
}).trim();
|
|
2709
4276
|
if (!d) return null;
|
|
2710
|
-
return isAbsolute3(d) ? d :
|
|
4277
|
+
return isAbsolute3(d) ? d : join7(cwd, d);
|
|
2711
4278
|
} catch {
|
|
2712
4279
|
return null;
|
|
2713
4280
|
}
|
|
@@ -2716,8 +4283,8 @@ function statePath(cwd, sessionId, kind) {
|
|
|
2716
4283
|
if (!sessionId) return null;
|
|
2717
4284
|
const gd = gitDir2(cwd);
|
|
2718
4285
|
if (!gd) return null;
|
|
2719
|
-
const dir =
|
|
2720
|
-
return
|
|
4286
|
+
const dir = join7(gd, "tamperward");
|
|
4287
|
+
return join7(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
2721
4288
|
}
|
|
2722
4289
|
function ptreePath(cwd, sessionId) {
|
|
2723
4290
|
return statePath(cwd, sessionId, "ptree");
|
|
@@ -2732,7 +4299,7 @@ function contentHash(content) {
|
|
|
2732
4299
|
function snapshotProtected(cwd, policy, prev) {
|
|
2733
4300
|
const out2 = {};
|
|
2734
4301
|
const walk = (rel) => {
|
|
2735
|
-
const abs2 = rel ?
|
|
4302
|
+
const abs2 = rel ? join7(cwd, rel) : cwd;
|
|
2736
4303
|
let names;
|
|
2737
4304
|
try {
|
|
2738
4305
|
names = readdirSync(abs2);
|
|
@@ -2744,7 +4311,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
2744
4311
|
const r = rel ? `${rel}/${name}` : name;
|
|
2745
4312
|
let st;
|
|
2746
4313
|
try {
|
|
2747
|
-
st = statSync(
|
|
4314
|
+
st = statSync(join7(cwd, r));
|
|
2748
4315
|
} catch {
|
|
2749
4316
|
continue;
|
|
2750
4317
|
}
|
|
@@ -2752,7 +4319,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
2752
4319
|
else if (st.isFile() && isProtected(r, policy)) {
|
|
2753
4320
|
let hash = "";
|
|
2754
4321
|
try {
|
|
2755
|
-
hash = sha(
|
|
4322
|
+
hash = sha(readFileSync7(join7(cwd, r)));
|
|
2756
4323
|
} catch {
|
|
2757
4324
|
hash = "unreadable";
|
|
2758
4325
|
}
|
|
@@ -2774,9 +4341,9 @@ function driftBetween(expected, current) {
|
|
|
2774
4341
|
return { changed, deleted };
|
|
2775
4342
|
}
|
|
2776
4343
|
function loadTree(p) {
|
|
2777
|
-
if (!p || !
|
|
4344
|
+
if (!p || !existsSync6(p)) return null;
|
|
2778
4345
|
try {
|
|
2779
|
-
return JSON.parse(
|
|
4346
|
+
return JSON.parse(readFileSync7(p, "utf8"));
|
|
2780
4347
|
} catch {
|
|
2781
4348
|
return null;
|
|
2782
4349
|
}
|
|
@@ -2784,7 +4351,7 @@ function loadTree(p) {
|
|
|
2784
4351
|
function saveTree(p, tree) {
|
|
2785
4352
|
if (!p) return;
|
|
2786
4353
|
try {
|
|
2787
|
-
mkdirSync3(
|
|
4354
|
+
mkdirSync3(join7(p, ".."), { recursive: true });
|
|
2788
4355
|
writeFileSync3(p, JSON.stringify(tree));
|
|
2789
4356
|
} catch {
|
|
2790
4357
|
}
|
|
@@ -2803,15 +4370,15 @@ function saveTurnTree(cwd, sessionId, tree) {
|
|
|
2803
4370
|
}
|
|
2804
4371
|
|
|
2805
4372
|
// src/cli/watch.ts
|
|
2806
|
-
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as
|
|
2807
|
-
import { join as
|
|
4373
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
|
|
4374
|
+
import { join as join8 } from "node:path";
|
|
2808
4375
|
import { createHash as createHash3 } from "node:crypto";
|
|
2809
4376
|
var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
2810
4377
|
function snap(abs2) {
|
|
2811
4378
|
try {
|
|
2812
4379
|
const st = statSync2(abs2);
|
|
2813
4380
|
if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
|
|
2814
|
-
const hash = createHash3("sha256").update(
|
|
4381
|
+
const hash = createHash3("sha256").update(readFileSync8(abs2)).digest("hex").slice(0, 16);
|
|
2815
4382
|
return { mode: st.mode, size: st.size, hash };
|
|
2816
4383
|
} catch {
|
|
2817
4384
|
return { mode: null, size: null, hash: null };
|
|
@@ -2819,7 +4386,7 @@ function snap(abs2) {
|
|
|
2819
4386
|
}
|
|
2820
4387
|
function defaultEventLog(cwd) {
|
|
2821
4388
|
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
2822
|
-
return
|
|
4389
|
+
return join8(gitDir(cwd) ?? join8(cwd, ".git"), "tamperward", "fsevents.jsonl");
|
|
2823
4390
|
}
|
|
2824
4391
|
function watchTree(dir, cb) {
|
|
2825
4392
|
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
@@ -2836,12 +4403,12 @@ function watchTree(dir, cb) {
|
|
|
2836
4403
|
if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
|
|
2837
4404
|
let w;
|
|
2838
4405
|
try {
|
|
2839
|
-
w = watch(rel ?
|
|
4406
|
+
w = watch(rel ? join8(dir, rel) : dir, (kind, fname) => {
|
|
2840
4407
|
if (!fname) return;
|
|
2841
4408
|
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
2842
4409
|
cb(kind, child);
|
|
2843
4410
|
try {
|
|
2844
|
-
if (statSync2(
|
|
4411
|
+
if (statSync2(join8(dir, child)).isDirectory()) addDir(child);
|
|
2845
4412
|
} catch {
|
|
2846
4413
|
}
|
|
2847
4414
|
});
|
|
@@ -2854,7 +4421,7 @@ function watchTree(dir, cb) {
|
|
|
2854
4421
|
addDir(rel);
|
|
2855
4422
|
let names = [];
|
|
2856
4423
|
try {
|
|
2857
|
-
names = readdirSync2(rel ?
|
|
4424
|
+
names = readdirSync2(rel ? join8(dir, rel) : dir);
|
|
2858
4425
|
} catch {
|
|
2859
4426
|
return;
|
|
2860
4427
|
}
|
|
@@ -2862,7 +4429,7 @@ function watchTree(dir, cb) {
|
|
|
2862
4429
|
const child = rel ? `${rel}/${name}` : name;
|
|
2863
4430
|
if (SKIP2.test(child + "/")) continue;
|
|
2864
4431
|
try {
|
|
2865
|
-
if (statSync2(
|
|
4432
|
+
if (statSync2(join8(dir, child)).isDirectory()) walk(child);
|
|
2866
4433
|
} catch {
|
|
2867
4434
|
}
|
|
2868
4435
|
}
|
|
@@ -2874,13 +4441,13 @@ function watchTree(dir, cb) {
|
|
|
2874
4441
|
}
|
|
2875
4442
|
function startWatcher(dir, log, policy) {
|
|
2876
4443
|
try {
|
|
2877
|
-
mkdirSync4(
|
|
4444
|
+
mkdirSync4(join8(log, ".."), { recursive: true });
|
|
2878
4445
|
} catch {
|
|
2879
4446
|
}
|
|
2880
4447
|
const last = /* @__PURE__ */ new Map();
|
|
2881
4448
|
return watchTree(dir, (kind, rel) => {
|
|
2882
4449
|
if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
|
|
2883
|
-
const s = snap(
|
|
4450
|
+
const s = snap(join8(dir, rel));
|
|
2884
4451
|
const key2 = `${s.hash}:${s.mode}`;
|
|
2885
4452
|
if (last.get(rel) === key2) return;
|
|
2886
4453
|
last.set(rel, key2);
|
|
@@ -2907,7 +4474,7 @@ function runWatch(args) {
|
|
|
2907
4474
|
}
|
|
2908
4475
|
|
|
2909
4476
|
// src/detectors/fs-events.ts
|
|
2910
|
-
import { readFileSync as
|
|
4477
|
+
import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
|
|
2911
4478
|
var RULE11 = "transient-protected-mutation";
|
|
2912
4479
|
function readEvents(log, offset) {
|
|
2913
4480
|
let size = 0;
|
|
@@ -2917,7 +4484,7 @@ function readEvents(log, offset) {
|
|
|
2917
4484
|
return { events: [], newOffset: offset };
|
|
2918
4485
|
}
|
|
2919
4486
|
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
2920
|
-
const raw =
|
|
4487
|
+
const raw = readFileSync9(log, "utf8").slice(offset);
|
|
2921
4488
|
const events = [];
|
|
2922
4489
|
for (const line of raw.split("\n")) {
|
|
2923
4490
|
if (!line.trim()) continue;
|
|
@@ -2970,7 +4537,7 @@ var HookInputError = class extends Error {
|
|
|
2970
4537
|
};
|
|
2971
4538
|
function readStdin() {
|
|
2972
4539
|
try {
|
|
2973
|
-
return
|
|
4540
|
+
return readFileSync10(0, "utf8");
|
|
2974
4541
|
} catch (e) {
|
|
2975
4542
|
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
2976
4543
|
}
|
|
@@ -3029,7 +4596,7 @@ function fileChanges(changes) {
|
|
|
3029
4596
|
}
|
|
3030
4597
|
function hashOnDisk(cwd, rel) {
|
|
3031
4598
|
try {
|
|
3032
|
-
return contentHash(
|
|
4599
|
+
return contentHash(readFileSync10(join9(cwd, rel)));
|
|
3033
4600
|
} catch {
|
|
3034
4601
|
return null;
|
|
3035
4602
|
}
|
|
@@ -3139,7 +4706,7 @@ function effectDriftBlocks(cwd, sessionId, policy) {
|
|
|
3139
4706
|
(c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
|
|
3140
4707
|
);
|
|
3141
4708
|
const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
|
|
3142
|
-
const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn").filter((f) => f.severity === "block").concat(gap.blocks);
|
|
4709
|
+
const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks);
|
|
3143
4710
|
if (blocks.length > 0) return blocks;
|
|
3144
4711
|
savePtree(cwd, sessionId, current);
|
|
3145
4712
|
return null;
|
|
@@ -3172,7 +4739,7 @@ function preToolUseVerdict(input) {
|
|
|
3172
4739
|
const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
|
|
3173
4740
|
if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
|
|
3174
4741
|
const changes = changesFromClaudeHook(input, cwd);
|
|
3175
|
-
const blocks = evaluate(changes, policy, void 0, "tool-call").filter((f) => f.severity === "block");
|
|
4742
|
+
const blocks = evaluate(changes, policy, void 0, "tool-call", { cwd }).filter((f) => f.severity === "block");
|
|
3176
4743
|
if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
|
|
3177
4744
|
return verdict(blocks, "PreToolUse");
|
|
3178
4745
|
} catch (e) {
|
|
@@ -3188,10 +4755,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
3188
4755
|
} };
|
|
3189
4756
|
const log = defaultEventLog(cwd);
|
|
3190
4757
|
const cp = cursorPath(cwd, sessionId);
|
|
3191
|
-
if (!
|
|
4758
|
+
if (!existsSync7(log)) return none;
|
|
3192
4759
|
let offset = 0;
|
|
3193
|
-
if (cp &&
|
|
3194
|
-
const n = Number(
|
|
4760
|
+
if (cp && existsSync7(cp)) {
|
|
4761
|
+
const n = Number(readFileSync10(cp, "utf8"));
|
|
3195
4762
|
offset = Number.isFinite(n) ? n : 0;
|
|
3196
4763
|
}
|
|
3197
4764
|
const { events, newOffset } = readEvents(log, offset);
|
|
@@ -3205,7 +4772,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
3205
4772
|
const finalHash = (path) => {
|
|
3206
4773
|
try {
|
|
3207
4774
|
if (!statSync4(`${cwd}/${path}`).isFile()) return null;
|
|
3208
|
-
return createHash4("sha256").update(
|
|
4775
|
+
return createHash4("sha256").update(readFileSync10(`${cwd}/${path}`)).digest("hex").slice(0, 16);
|
|
3209
4776
|
} catch {
|
|
3210
4777
|
return null;
|
|
3211
4778
|
}
|
|
@@ -3249,7 +4816,7 @@ function stopVerdict(input) {
|
|
|
3249
4816
|
changes = changes.concat(gap.changes);
|
|
3250
4817
|
hiddenBlocks = gap.blocks;
|
|
3251
4818
|
}
|
|
3252
|
-
blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block").concat(hiddenBlocks);
|
|
4819
|
+
blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks);
|
|
3253
4820
|
const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
|
|
3254
4821
|
blocks = blocks.concat(transient.blocks);
|
|
3255
4822
|
commitCursor = transient.commit;
|
|
@@ -3313,7 +4880,7 @@ function runAllow(opts) {
|
|
|
3313
4880
|
const policy = loadPolicy(cwd);
|
|
3314
4881
|
let findings;
|
|
3315
4882
|
try {
|
|
3316
|
-
findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree");
|
|
4883
|
+
findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree", { cwd });
|
|
3317
4884
|
} catch {
|
|
3318
4885
|
process.stderr.write("tamperward: cannot read the working tree (not a git repo?).\n");
|
|
3319
4886
|
return 2;
|
|
@@ -3346,16 +4913,16 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
3346
4913
|
}
|
|
3347
4914
|
|
|
3348
4915
|
// src/cli/init.ts
|
|
3349
|
-
import { execFileSync as
|
|
4916
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3350
4917
|
import { createHash as createHash5 } from "node:crypto";
|
|
3351
|
-
import { existsSync as
|
|
3352
|
-
import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as
|
|
4918
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
4919
|
+
import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join10, relative as relative2, resolve as resolve2 } from "node:path";
|
|
3353
4920
|
import { fileURLToPath } from "node:url";
|
|
3354
4921
|
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
3355
4922
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3356
4923
|
function gitConfig(cwd, key2) {
|
|
3357
4924
|
try {
|
|
3358
|
-
return
|
|
4925
|
+
return execFileSync5("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
3359
4926
|
} catch {
|
|
3360
4927
|
return null;
|
|
3361
4928
|
}
|
|
@@ -3414,7 +4981,7 @@ function shippedVersion() {
|
|
|
3414
4981
|
const here = dirname3(fileURLToPath(import.meta.url));
|
|
3415
4982
|
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
3416
4983
|
try {
|
|
3417
|
-
const pkg = JSON.parse(
|
|
4984
|
+
const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
|
|
3418
4985
|
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
3419
4986
|
} catch {
|
|
3420
4987
|
}
|
|
@@ -3521,13 +5088,13 @@ function readWorkflowMark(src) {
|
|
|
3521
5088
|
if (!m) return null;
|
|
3522
5089
|
return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
|
|
3523
5090
|
}
|
|
3524
|
-
function
|
|
5091
|
+
function toolSet2(matcher2) {
|
|
3525
5092
|
const m = String(matcher2 ?? "").trim();
|
|
3526
5093
|
if (m === "" || m === "*") return null;
|
|
3527
5094
|
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
3528
5095
|
}
|
|
3529
5096
|
function missingTools(matcher2) {
|
|
3530
|
-
const have =
|
|
5097
|
+
const have = toolSet2(matcher2);
|
|
3531
5098
|
if (have === null) return [];
|
|
3532
5099
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
3533
5100
|
}
|
|
@@ -3547,8 +5114,8 @@ function hooksShapeError(hooks) {
|
|
|
3547
5114
|
return null;
|
|
3548
5115
|
}
|
|
3549
5116
|
function planPolicy(cwd) {
|
|
3550
|
-
const path =
|
|
3551
|
-
if (
|
|
5117
|
+
const path = join10(cwd, POLICY_FILE);
|
|
5118
|
+
if (existsSync8(path)) {
|
|
3552
5119
|
try {
|
|
3553
5120
|
loadPolicy(cwd);
|
|
3554
5121
|
} catch (e) {
|
|
@@ -3566,12 +5133,12 @@ function planPolicy(cwd) {
|
|
|
3566
5133
|
}
|
|
3567
5134
|
function planClaudeHooks(cwd) {
|
|
3568
5135
|
const rel = ".claude/settings.json";
|
|
3569
|
-
const path =
|
|
5136
|
+
const path = join10(cwd, rel);
|
|
3570
5137
|
let settings = {};
|
|
3571
|
-
if (
|
|
5138
|
+
if (existsSync8(path)) {
|
|
3572
5139
|
let parsed;
|
|
3573
5140
|
try {
|
|
3574
|
-
parsed = JSON.parse(
|
|
5141
|
+
parsed = JSON.parse(readFileSync11(path, "utf8"));
|
|
3575
5142
|
} catch {
|
|
3576
5143
|
return {
|
|
3577
5144
|
item: "agent",
|
|
@@ -3605,7 +5172,7 @@ function planClaudeHooks(cwd) {
|
|
|
3605
5172
|
return {
|
|
3606
5173
|
item: "agent",
|
|
3607
5174
|
path: rel,
|
|
3608
|
-
status:
|
|
5175
|
+
status: existsSync8(path) ? "update" : "create",
|
|
3609
5176
|
detail: [
|
|
3610
5177
|
needPre && "wire PreToolUse deny",
|
|
3611
5178
|
needStop && "wire Stop sweep",
|
|
@@ -3624,7 +5191,7 @@ function planClaudeHooks(cwd) {
|
|
|
3624
5191
|
});
|
|
3625
5192
|
}
|
|
3626
5193
|
for (const m of stale) {
|
|
3627
|
-
m.matcher = [...
|
|
5194
|
+
m.matcher = [...toolSet2(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
|
|
3628
5195
|
}
|
|
3629
5196
|
if (needStop) {
|
|
3630
5197
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
@@ -3660,11 +5227,11 @@ var MANAGED_HOOKS = [
|
|
|
3660
5227
|
];
|
|
3661
5228
|
function hooksDir(cwd) {
|
|
3662
5229
|
try {
|
|
3663
|
-
const out2 =
|
|
5230
|
+
const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
3664
5231
|
if (out2) return resolve2(cwd, out2);
|
|
3665
5232
|
} catch {
|
|
3666
5233
|
}
|
|
3667
|
-
return
|
|
5234
|
+
return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
|
|
3668
5235
|
}
|
|
3669
5236
|
function display(cwd, path) {
|
|
3670
5237
|
const rel = relative2(cwd, path);
|
|
@@ -3691,30 +5258,30 @@ function planPreCommit(cwd) {
|
|
|
3691
5258
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
3692
5259
|
let path;
|
|
3693
5260
|
let note;
|
|
3694
|
-
if (
|
|
3695
|
-
path =
|
|
5261
|
+
if (existsSync8(join10(cwd, ".husky"))) {
|
|
5262
|
+
path = join10(cwd, ".husky", "pre-commit");
|
|
3696
5263
|
note = "husky";
|
|
3697
5264
|
} else {
|
|
3698
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) =>
|
|
5265
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
|
|
3699
5266
|
if (managed) {
|
|
3700
5267
|
return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
|
|
3701
5268
|
}
|
|
3702
5269
|
const dir = hooksDir(cwd);
|
|
3703
5270
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
3704
5271
|
if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
|
|
3705
|
-
path =
|
|
5272
|
+
path = join10(dirname3(dir), "pre-commit");
|
|
3706
5273
|
note = "husky";
|
|
3707
5274
|
} else {
|
|
3708
|
-
path =
|
|
5275
|
+
path = join10(dir, "pre-commit");
|
|
3709
5276
|
const rel2 = display(cwd, dir);
|
|
3710
|
-
note = rel2 ===
|
|
5277
|
+
note = rel2 === join10(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
|
|
3711
5278
|
}
|
|
3712
5279
|
}
|
|
3713
5280
|
const rel = display(cwd, path);
|
|
3714
|
-
if (
|
|
5281
|
+
if (existsSync8(path) && !statSync5(path).isFile()) {
|
|
3715
5282
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
3716
5283
|
}
|
|
3717
|
-
const existing =
|
|
5284
|
+
const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
|
|
3718
5285
|
if (existing === null) {
|
|
3719
5286
|
return {
|
|
3720
5287
|
item: "pre-commit",
|
|
@@ -3803,10 +5370,10 @@ ${block.join("\n")}
|
|
|
3803
5370
|
function planCodeowners(cwd) {
|
|
3804
5371
|
const rel = ".github/CODEOWNERS";
|
|
3805
5372
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
3806
|
-
(r) =>
|
|
5373
|
+
(r) => existsSync8(join10(cwd, r))
|
|
3807
5374
|
);
|
|
3808
|
-
const path =
|
|
3809
|
-
const existing = existingRel ?
|
|
5375
|
+
const path = join10(cwd, existingRel ?? rel);
|
|
5376
|
+
const existing = existingRel ? readFileSync11(path, "utf8") : null;
|
|
3810
5377
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
3811
5378
|
if (missing.length === 0) {
|
|
3812
5379
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
@@ -3839,12 +5406,12 @@ ${CODEOWNERS_MARK}
|
|
|
3839
5406
|
}
|
|
3840
5407
|
function planWorkflow(cwd, force) {
|
|
3841
5408
|
const rel = ".github/workflows/tamperward.yml";
|
|
3842
|
-
const path =
|
|
5409
|
+
const path = join10(cwd, rel);
|
|
3843
5410
|
const write = () => {
|
|
3844
5411
|
mkdirSync5(dirname3(path), { recursive: true });
|
|
3845
5412
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
3846
5413
|
};
|
|
3847
|
-
if (!
|
|
5414
|
+
if (!existsSync8(path)) {
|
|
3848
5415
|
return {
|
|
3849
5416
|
item: "ci",
|
|
3850
5417
|
path: rel,
|
|
@@ -3855,7 +5422,7 @@ function planWorkflow(cwd, force) {
|
|
|
3855
5422
|
}
|
|
3856
5423
|
let src;
|
|
3857
5424
|
try {
|
|
3858
|
-
src =
|
|
5425
|
+
src = readFileSync11(path, "utf8");
|
|
3859
5426
|
} catch {
|
|
3860
5427
|
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
3861
5428
|
}
|
|
@@ -3952,30 +5519,30 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
3952
5519
|
}
|
|
3953
5520
|
|
|
3954
5521
|
// src/cli/verify.ts
|
|
3955
|
-
import { execFileSync as
|
|
5522
|
+
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
3956
5523
|
import { createHash as createHash8 } from "node:crypto";
|
|
3957
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
5524
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
|
|
3958
5525
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3959
|
-
import { dirname as dirname4, join as
|
|
5526
|
+
import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
|
|
3960
5527
|
|
|
3961
5528
|
// src/git/trusted.ts
|
|
3962
|
-
import { execFileSync as
|
|
5529
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
3963
5530
|
import { createHash as createHash6 } from "node:crypto";
|
|
3964
|
-
import { existsSync as
|
|
3965
|
-
import { isAbsolute as isAbsolute5, join as
|
|
5531
|
+
import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
|
|
5532
|
+
import { isAbsolute as isAbsolute5, join as join11 } from "node:path";
|
|
3966
5533
|
function trustedGitEnv() {
|
|
3967
5534
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3968
5535
|
}
|
|
3969
5536
|
function gitDirOf(cwd) {
|
|
3970
5537
|
try {
|
|
3971
|
-
const d =
|
|
5538
|
+
const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
|
|
3972
5539
|
cwd,
|
|
3973
5540
|
encoding: "utf8",
|
|
3974
5541
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3975
5542
|
env: trustedGitEnv()
|
|
3976
5543
|
}).trim();
|
|
3977
5544
|
if (!d) return null;
|
|
3978
|
-
return isAbsolute5(d) ? d :
|
|
5545
|
+
return isAbsolute5(d) ? d : join11(cwd, d);
|
|
3979
5546
|
} catch {
|
|
3980
5547
|
return null;
|
|
3981
5548
|
}
|
|
@@ -3984,7 +5551,7 @@ function objectRewriteState(cwd) {
|
|
|
3984
5551
|
const parts = [];
|
|
3985
5552
|
try {
|
|
3986
5553
|
parts.push(
|
|
3987
|
-
"replace:" +
|
|
5554
|
+
"replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
3988
5555
|
cwd,
|
|
3989
5556
|
encoding: "utf8",
|
|
3990
5557
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -4000,9 +5567,9 @@ function objectRewriteState(cwd) {
|
|
|
4000
5567
|
parts.push(`${rel}:<no-git-dir>`);
|
|
4001
5568
|
continue;
|
|
4002
5569
|
}
|
|
4003
|
-
const p =
|
|
5570
|
+
const p = join11(dir, rel);
|
|
4004
5571
|
try {
|
|
4005
|
-
parts.push(`${rel}:` + (
|
|
5572
|
+
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
|
|
4006
5573
|
} catch {
|
|
4007
5574
|
parts.push(`${rel}:<unreadable>`);
|
|
4008
5575
|
}
|
|
@@ -4011,12 +5578,12 @@ function objectRewriteState(cwd) {
|
|
|
4011
5578
|
}
|
|
4012
5579
|
|
|
4013
5580
|
// src/fingerprint.ts
|
|
4014
|
-
import { execFileSync as
|
|
5581
|
+
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
4015
5582
|
import { createHash as createHash7 } from "node:crypto";
|
|
4016
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
4017
|
-
import { join as
|
|
5583
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
5584
|
+
import { join as join12 } from "node:path";
|
|
4018
5585
|
function treeFingerprint(cwd) {
|
|
4019
|
-
const files =
|
|
5586
|
+
const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
4020
5587
|
cwd,
|
|
4021
5588
|
encoding: "utf8",
|
|
4022
5589
|
maxBuffer: 1 << 28,
|
|
@@ -4027,7 +5594,7 @@ function treeFingerprint(cwd) {
|
|
|
4027
5594
|
h.update(rel);
|
|
4028
5595
|
h.update("\0");
|
|
4029
5596
|
try {
|
|
4030
|
-
h.update(
|
|
5597
|
+
h.update(readFileSync13(join12(cwd, rel)));
|
|
4031
5598
|
} catch {
|
|
4032
5599
|
h.update("<unreadable>");
|
|
4033
5600
|
}
|
|
@@ -4036,7 +5603,7 @@ function treeFingerprint(cwd) {
|
|
|
4036
5603
|
return h.digest("hex");
|
|
4037
5604
|
}
|
|
4038
5605
|
function depsFingerprint(cwd) {
|
|
4039
|
-
const root =
|
|
5606
|
+
const root = join12(cwd, "node_modules");
|
|
4040
5607
|
const h = createHash7("sha256");
|
|
4041
5608
|
const walk = (dir) => {
|
|
4042
5609
|
let entries;
|
|
@@ -4046,12 +5613,12 @@ function depsFingerprint(cwd) {
|
|
|
4046
5613
|
return;
|
|
4047
5614
|
}
|
|
4048
5615
|
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
4049
|
-
const p =
|
|
5616
|
+
const p = join12(dir, e.name);
|
|
4050
5617
|
if (e.isDirectory()) walk(p);
|
|
4051
5618
|
else if (e.isFile()) {
|
|
4052
5619
|
h.update(p);
|
|
4053
5620
|
try {
|
|
4054
|
-
h.update(
|
|
5621
|
+
h.update(readFileSync13(p));
|
|
4055
5622
|
h.update(String(statSync6(p).mode));
|
|
4056
5623
|
} catch {
|
|
4057
5624
|
h.update("<unreadable>");
|
|
@@ -4148,7 +5715,7 @@ var VERIFICATION_SURFACE = [
|
|
|
4148
5715
|
"**/*.runsettings"
|
|
4149
5716
|
];
|
|
4150
5717
|
function git2(args, cwd) {
|
|
4151
|
-
return
|
|
5718
|
+
return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
4152
5719
|
}
|
|
4153
5720
|
function resolveBase(base, cwd) {
|
|
4154
5721
|
assertRev(base);
|
|
@@ -4166,7 +5733,7 @@ function baseIsAncestorOfHead(base, cwd) {
|
|
|
4166
5733
|
function materialize(cwd, dest) {
|
|
4167
5734
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
4168
5735
|
for (const rel of listed) {
|
|
4169
|
-
const src =
|
|
5736
|
+
const src = join13(cwd, rel);
|
|
4170
5737
|
let st;
|
|
4171
5738
|
try {
|
|
4172
5739
|
st = statSync7(src);
|
|
@@ -4174,18 +5741,18 @@ function materialize(cwd, dest) {
|
|
|
4174
5741
|
continue;
|
|
4175
5742
|
}
|
|
4176
5743
|
if (!st.isFile()) continue;
|
|
4177
|
-
const out2 =
|
|
5744
|
+
const out2 = join13(dest, rel);
|
|
4178
5745
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
4179
5746
|
dropSymlink(out2);
|
|
4180
5747
|
if (lstatSync(src).isSymbolicLink()) {
|
|
4181
|
-
writeFileSync6(out2,
|
|
5748
|
+
writeFileSync6(out2, readFileSync14(src));
|
|
4182
5749
|
} else {
|
|
4183
5750
|
cpSync(src, out2, { dereference: true });
|
|
4184
5751
|
}
|
|
4185
5752
|
chmodSync2(out2, st.mode);
|
|
4186
5753
|
}
|
|
4187
5754
|
const nm = resolve3(cwd, "node_modules");
|
|
4188
|
-
if (
|
|
5755
|
+
if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
|
|
4189
5756
|
}
|
|
4190
5757
|
function dropSymlink(p) {
|
|
4191
5758
|
try {
|
|
@@ -4200,9 +5767,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
4200
5767
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
4201
5768
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
4202
5769
|
}
|
|
4203
|
-
const
|
|
4204
|
-
if (
|
|
4205
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
5770
|
+
const globs3 = policy.verify?.inputs;
|
|
5771
|
+
if (globs3 && globs3.length) {
|
|
5772
|
+
for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
|
|
4206
5773
|
}
|
|
4207
5774
|
return picked;
|
|
4208
5775
|
}
|
|
@@ -4218,11 +5785,11 @@ function overlayDigest(dest, paths) {
|
|
|
4218
5785
|
for (const rel of [...paths].sort()) {
|
|
4219
5786
|
h.update(rel);
|
|
4220
5787
|
h.update("\0");
|
|
4221
|
-
const p =
|
|
5788
|
+
const p = join13(dest, rel);
|
|
4222
5789
|
try {
|
|
4223
5790
|
const st = lstatSync(p);
|
|
4224
5791
|
h.update(String(st.mode));
|
|
4225
|
-
h.update(st.isSymbolicLink() ? readlinkSync2(p) :
|
|
5792
|
+
h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync14(p));
|
|
4226
5793
|
} catch {
|
|
4227
5794
|
h.update("<gone>");
|
|
4228
5795
|
}
|
|
@@ -4244,8 +5811,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
4244
5811
|
if (e.type === "commit") {
|
|
4245
5812
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
4246
5813
|
}
|
|
4247
|
-
const content =
|
|
4248
|
-
const out2 =
|
|
5814
|
+
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
5815
|
+
const out2 = join13(dest, e.path);
|
|
4249
5816
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
4250
5817
|
rmSync2(out2, { force: true });
|
|
4251
5818
|
if (e.mode === "120000") {
|
|
@@ -4260,7 +5827,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
4260
5827
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
4261
5828
|
for (const rel of inCopy) {
|
|
4262
5829
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
4263
|
-
rmSync2(
|
|
5830
|
+
rmSync2(join13(dest, rel), { force: true });
|
|
4264
5831
|
removed++;
|
|
4265
5832
|
}
|
|
4266
5833
|
return { restored, removed };
|
|
@@ -4302,8 +5869,8 @@ function suiteEnv(scratch) {
|
|
|
4302
5869
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
4303
5870
|
env[k] = v;
|
|
4304
5871
|
}
|
|
4305
|
-
const userRc =
|
|
4306
|
-
const globalRc =
|
|
5872
|
+
const userRc = join13(scratch, "npmrc-user");
|
|
5873
|
+
const globalRc = join13(scratch, "npmrc-global");
|
|
4307
5874
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
4308
5875
|
env.npm_config_userconfig = userRc;
|
|
4309
5876
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -4313,8 +5880,8 @@ function suiteEnv(scratch) {
|
|
|
4313
5880
|
}
|
|
4314
5881
|
function runSuite(dir, cmd, budgetSecs) {
|
|
4315
5882
|
const t0 = Date.now();
|
|
4316
|
-
const outDir = mkdtempSync2(
|
|
4317
|
-
const outFile =
|
|
5883
|
+
const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
|
|
5884
|
+
const outFile = join13(outDir, "result.json");
|
|
4318
5885
|
try {
|
|
4319
5886
|
spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
4320
5887
|
cwd: dir,
|
|
@@ -4327,7 +5894,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
4327
5894
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
4328
5895
|
let r;
|
|
4329
5896
|
try {
|
|
4330
|
-
r = JSON.parse(
|
|
5897
|
+
r = JSON.parse(readFileSync14(outFile, "utf8"));
|
|
4331
5898
|
} catch {
|
|
4332
5899
|
return { exit: null, secs };
|
|
4333
5900
|
}
|
|
@@ -4378,8 +5945,8 @@ function runVerify(opts) {
|
|
|
4378
5945
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
4379
5946
|
return 2;
|
|
4380
5947
|
}
|
|
4381
|
-
const visRoot = mkdtempSync2(
|
|
4382
|
-
const visDir =
|
|
5948
|
+
const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
|
|
5949
|
+
const visDir = join13(visRoot, "t");
|
|
4383
5950
|
const cleanup = (dirs) => {
|
|
4384
5951
|
if (opts.keep) return;
|
|
4385
5952
|
for (const d of dirs) rmSync2(d, { recursive: true, force: true });
|
|
@@ -4407,8 +5974,8 @@ function runVerify(opts) {
|
|
|
4407
5974
|
out2("pristine run executes through it too. Failing closed, not open.");
|
|
4408
5975
|
return 2;
|
|
4409
5976
|
}
|
|
4410
|
-
const priRoot = mkdtempSync2(
|
|
4411
|
-
const priDir =
|
|
5977
|
+
const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
|
|
5978
|
+
const priDir = join13(priRoot, "t");
|
|
4412
5979
|
let restored = [];
|
|
4413
5980
|
let removedAdded = 0;
|
|
4414
5981
|
try {
|
|
@@ -4519,17 +6086,17 @@ function parseVerify(args) {
|
|
|
4519
6086
|
}
|
|
4520
6087
|
|
|
4521
6088
|
// src/cli/run.ts
|
|
4522
|
-
import { execFileSync as
|
|
4523
|
-
import { readdirSync as readdirSync4, readFileSync as
|
|
6089
|
+
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
6090
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
|
|
4524
6091
|
import { resolve as resolve4 } from "node:path";
|
|
4525
6092
|
var out = (s) => process.stdout.write(s + "\n");
|
|
4526
6093
|
var err = (s) => process.stderr.write(s + "\n");
|
|
4527
6094
|
function git3(args, cwd) {
|
|
4528
|
-
return
|
|
6095
|
+
return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
4529
6096
|
}
|
|
4530
6097
|
function nowTicks() {
|
|
4531
6098
|
try {
|
|
4532
|
-
return Math.floor(parseFloat(
|
|
6099
|
+
return Math.floor(parseFloat(readFileSync15("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
4533
6100
|
} catch {
|
|
4534
6101
|
return Number.POSITIVE_INFINITY;
|
|
4535
6102
|
}
|
|
@@ -4559,7 +6126,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
4559
6126
|
}
|
|
4560
6127
|
if (held !== real && !held.startsWith(real + "/")) continue;
|
|
4561
6128
|
try {
|
|
4562
|
-
const stat =
|
|
6129
|
+
const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
|
|
4563
6130
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
4564
6131
|
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
4565
6132
|
} catch {
|
|
@@ -4650,7 +6217,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
4650
6217
|
const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
|
|
4651
6218
|
if (opts.settle && opts.settle > 0) {
|
|
4652
6219
|
try {
|
|
4653
|
-
|
|
6220
|
+
execFileSync9("sleep", [String(opts.settle)]);
|
|
4654
6221
|
} catch {
|
|
4655
6222
|
}
|
|
4656
6223
|
}
|