tamperward 1.8.0 → 1.10.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 +392 -121
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -153,7 +153,7 @@ function parseHunk(lines, start) {
|
|
|
153
153
|
const oldLines = m[2] !== void 0 ? Number(m[2]) : 1;
|
|
154
154
|
const newStart = Number(m[3]);
|
|
155
155
|
const newLines = m[4] !== void 0 ? Number(m[4]) : 1;
|
|
156
|
-
const
|
|
156
|
+
const out2 = [];
|
|
157
157
|
let oldLine = oldStart;
|
|
158
158
|
let newLine = newStart;
|
|
159
159
|
let i = start + 1;
|
|
@@ -164,20 +164,20 @@ function parseHunk(lines, start) {
|
|
|
164
164
|
const tag = l[0];
|
|
165
165
|
const content = l.slice(1);
|
|
166
166
|
if (tag === "+") {
|
|
167
|
-
|
|
167
|
+
out2.push({ type: "add", content, oldLine: null, newLine });
|
|
168
168
|
newLine++;
|
|
169
169
|
} else if (tag === "-") {
|
|
170
|
-
|
|
170
|
+
out2.push({ type: "del", content, oldLine, newLine: null });
|
|
171
171
|
oldLine++;
|
|
172
172
|
} else if (tag === " ") {
|
|
173
|
-
|
|
173
|
+
out2.push({ type: "context", content, oldLine, newLine });
|
|
174
174
|
oldLine++;
|
|
175
175
|
newLine++;
|
|
176
176
|
} else {
|
|
177
177
|
break;
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
|
-
return { hunk: { oldStart, oldLines, newStart, newLines, lines:
|
|
180
|
+
return { hunk: { oldStart, oldLines, newStart, newLines, lines: out2 }, next: i };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
// src/git/build.ts
|
|
@@ -190,9 +190,9 @@ function git(args, cwd) {
|
|
|
190
190
|
stdio: ["ignore", "pipe", "pipe"]
|
|
191
191
|
});
|
|
192
192
|
} catch (e) {
|
|
193
|
-
const
|
|
194
|
-
const detail = String(
|
|
195
|
-
throw new Error(detail ? `git ${args[0]}: ${detail}` :
|
|
193
|
+
const err2 = e;
|
|
194
|
+
const detail = String(err2.stderr ?? "").trim();
|
|
195
|
+
throw new Error(detail ? `git ${args[0]}: ${detail}` : err2.message ?? `git ${args[0]} failed`);
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
function blobAt(rev, path, cwd) {
|
|
@@ -324,7 +324,7 @@ var noVerify = {
|
|
|
324
324
|
surface: ["command"],
|
|
325
325
|
certainty: "mechanical",
|
|
326
326
|
run(changes, policy) {
|
|
327
|
-
const
|
|
327
|
+
const out2 = [];
|
|
328
328
|
for (const c of changes) {
|
|
329
329
|
if (c.kind !== "command") continue;
|
|
330
330
|
for (const seg of segments(c.raw)) {
|
|
@@ -341,7 +341,7 @@ var noVerify = {
|
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
if (why) {
|
|
344
|
-
|
|
344
|
+
out2.push(
|
|
345
345
|
makeFinding(RULE, policy, {
|
|
346
346
|
message: `Command bypasses git hooks: ${why}.`,
|
|
347
347
|
evidence: seg,
|
|
@@ -351,7 +351,7 @@ var noVerify = {
|
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
|
-
return
|
|
354
|
+
return out2;
|
|
355
355
|
}
|
|
356
356
|
};
|
|
357
357
|
|
|
@@ -373,13 +373,13 @@ import picomatch from "picomatch";
|
|
|
373
373
|
var POLICY_FILE = ".tamperward.yml";
|
|
374
374
|
var BLOCK_SINCE = {};
|
|
375
375
|
function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
376
|
-
const
|
|
376
|
+
const out2 = { ...rules };
|
|
377
377
|
for (const [rule, since] of Object.entries(gates)) {
|
|
378
|
-
if (since > version &&
|
|
379
|
-
|
|
378
|
+
if (since > version && out2[rule]?.severity === "block") {
|
|
379
|
+
out2[rule] = { ...out2[rule], severity: "warn" };
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
|
-
return
|
|
382
|
+
return out2;
|
|
383
383
|
}
|
|
384
384
|
var cache = /* @__PURE__ */ new Map();
|
|
385
385
|
function matcher(glob) {
|
|
@@ -410,12 +410,12 @@ function isIgnored(path, policy) {
|
|
|
410
410
|
return matchesAny(path, policy.ignore);
|
|
411
411
|
}
|
|
412
412
|
function mergeProtected(base, user) {
|
|
413
|
-
const
|
|
413
|
+
const out2 = { ...base };
|
|
414
414
|
for (const [cat, globs] of Object.entries(user ?? {})) {
|
|
415
|
-
const have = new Set(
|
|
416
|
-
|
|
415
|
+
const have = new Set(out2[cat] ?? []);
|
|
416
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs ?? []).filter((g) => !have.has(g))];
|
|
417
417
|
}
|
|
418
|
-
return
|
|
418
|
+
return out2;
|
|
419
419
|
}
|
|
420
420
|
function defaultPolicy(version = 1) {
|
|
421
421
|
const p = {
|
|
@@ -522,7 +522,7 @@ var tsAnyCast = {
|
|
|
522
522
|
surface: ["file"],
|
|
523
523
|
certainty: "mechanical",
|
|
524
524
|
run(changes, policy) {
|
|
525
|
-
const
|
|
525
|
+
const out2 = [];
|
|
526
526
|
for (const c of changes) {
|
|
527
527
|
if (c.kind !== "file" || !isCodeFile(c.path)) continue;
|
|
528
528
|
const inTest = protectedCategory(c.path, policy) === "tests";
|
|
@@ -539,7 +539,7 @@ var tsAnyCast = {
|
|
|
539
539
|
if (dDouble > 0) blockReasons.push("`as unknown as` double cast");
|
|
540
540
|
if (dSuppr > 0) blockReasons.push("@ts-ignore/@ts-expect-error/@ts-nocheck suppression");
|
|
541
541
|
if (blockReasons.length) {
|
|
542
|
-
|
|
542
|
+
out2.push(
|
|
543
543
|
makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
|
|
544
544
|
file: c.path,
|
|
545
545
|
message: inTest ? `Type-checker escape in a test file: ${blockReasons.join("; ")} (test infrastructure \u2014 flagged, not blocked).` : `Type safety discarded: ${blockReasons.join("; ")}.`,
|
|
@@ -550,7 +550,7 @@ var tsAnyCast = {
|
|
|
550
550
|
);
|
|
551
551
|
}
|
|
552
552
|
if (dBroad > 0) {
|
|
553
|
-
|
|
553
|
+
out2.push(
|
|
554
554
|
makeFinding(WARN_RULE, policy, {
|
|
555
555
|
file: c.path,
|
|
556
556
|
message: `Type laundered to \`any\`: introduces ${dBroad} new \`any\`-typed value(s) in a type/generic position (e.g. : any, Record<string, any>).`,
|
|
@@ -564,7 +564,7 @@ var tsAnyCast = {
|
|
|
564
564
|
}
|
|
565
565
|
for (const l of addedLines(c)) {
|
|
566
566
|
if (NARROW_LINE.test(l.content)) {
|
|
567
|
-
|
|
567
|
+
out2.push(
|
|
568
568
|
makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
|
|
569
569
|
file: c.path,
|
|
570
570
|
line: l.newLine ?? void 0,
|
|
@@ -575,7 +575,7 @@ var tsAnyCast = {
|
|
|
575
575
|
})
|
|
576
576
|
);
|
|
577
577
|
} else if (BROAD_LINE.test(l.content)) {
|
|
578
|
-
|
|
578
|
+
out2.push(
|
|
579
579
|
makeFinding(WARN_RULE, policy, {
|
|
580
580
|
file: c.path,
|
|
581
581
|
line: l.newLine ?? void 0,
|
|
@@ -588,7 +588,7 @@ var tsAnyCast = {
|
|
|
588
588
|
}
|
|
589
589
|
}
|
|
590
590
|
}
|
|
591
|
-
return
|
|
591
|
+
return out2;
|
|
592
592
|
}
|
|
593
593
|
};
|
|
594
594
|
|
|
@@ -604,13 +604,13 @@ var lintSuppression = {
|
|
|
604
604
|
surface: ["file"],
|
|
605
605
|
certainty: "mechanical",
|
|
606
606
|
run(changes, policy) {
|
|
607
|
-
const
|
|
607
|
+
const out2 = [];
|
|
608
608
|
for (const c of changes) {
|
|
609
609
|
if (c.kind !== "file" || !isCodeFile(c.path)) continue;
|
|
610
610
|
for (const l of addedLines(c)) {
|
|
611
611
|
for (const p of PATTERNS) {
|
|
612
612
|
if (p.re.test(l.content)) {
|
|
613
|
-
|
|
613
|
+
out2.push(
|
|
614
614
|
makeFinding(RULE2, policy, {
|
|
615
615
|
file: c.path,
|
|
616
616
|
line: l.newLine ?? void 0,
|
|
@@ -624,7 +624,7 @@ var lintSuppression = {
|
|
|
624
624
|
}
|
|
625
625
|
}
|
|
626
626
|
}
|
|
627
|
-
return
|
|
627
|
+
return out2;
|
|
628
628
|
}
|
|
629
629
|
};
|
|
630
630
|
|
|
@@ -647,14 +647,14 @@ var testSkip = {
|
|
|
647
647
|
surface: ["file"],
|
|
648
648
|
certainty: "mechanical",
|
|
649
649
|
run(changes, policy) {
|
|
650
|
-
const
|
|
650
|
+
const out2 = [];
|
|
651
651
|
for (const c of changes) {
|
|
652
652
|
if (c.kind !== "file") continue;
|
|
653
653
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
654
654
|
for (const l of addedLines(c)) {
|
|
655
655
|
for (const p of PATTERNS2) {
|
|
656
656
|
if (p.re.test(l.content)) {
|
|
657
|
-
|
|
657
|
+
out2.push(
|
|
658
658
|
makeFinding(RULE3, policy, {
|
|
659
659
|
file: c.path,
|
|
660
660
|
line: l.newLine ?? void 0,
|
|
@@ -668,7 +668,7 @@ var testSkip = {
|
|
|
668
668
|
}
|
|
669
669
|
}
|
|
670
670
|
}
|
|
671
|
-
return
|
|
671
|
+
return out2;
|
|
672
672
|
}
|
|
673
673
|
};
|
|
674
674
|
|
|
@@ -691,7 +691,7 @@ function numericOf(e) {
|
|
|
691
691
|
}
|
|
692
692
|
function metricsOf(obj) {
|
|
693
693
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
694
|
-
const
|
|
694
|
+
const out2 = {};
|
|
695
695
|
let any = false;
|
|
696
696
|
for (const p of obj.properties) {
|
|
697
697
|
if (!ts2.isPropertyAssignment(p)) continue;
|
|
@@ -699,10 +699,10 @@ function metricsOf(obj) {
|
|
|
699
699
|
if (k === null || !isMetric(k)) continue;
|
|
700
700
|
const n = numericOf(p.initializer);
|
|
701
701
|
if (n === void 0) continue;
|
|
702
|
-
|
|
702
|
+
out2[k] = n;
|
|
703
703
|
any = true;
|
|
704
704
|
}
|
|
705
|
-
return any ?
|
|
705
|
+
return any ? out2 : void 0;
|
|
706
706
|
}
|
|
707
707
|
function underCoverageKey(node) {
|
|
708
708
|
for (let p = node.parent; p; p = p.parent) {
|
|
@@ -744,41 +744,41 @@ function parseThresholds(src) {
|
|
|
744
744
|
return res;
|
|
745
745
|
}
|
|
746
746
|
var eff = (t, path) => t.paths.get(norm(path)) ?? t.global;
|
|
747
|
-
function compareMetrics(scope, before, after,
|
|
747
|
+
function compareMetrics(scope, before, after, out2) {
|
|
748
748
|
if (!before) return;
|
|
749
749
|
for (const m of METRICS) {
|
|
750
750
|
const bv = before[m];
|
|
751
751
|
if (bv === void 0) continue;
|
|
752
752
|
const av = after?.[m];
|
|
753
|
-
if (av === void 0)
|
|
754
|
-
else if (av < bv)
|
|
753
|
+
if (av === void 0) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
|
|
754
|
+
else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
|
|
755
755
|
}
|
|
756
756
|
}
|
|
757
757
|
function weakenings(before, after) {
|
|
758
|
-
const
|
|
758
|
+
const out2 = [];
|
|
759
759
|
if (before.present && !after.present) {
|
|
760
|
-
|
|
761
|
-
return
|
|
760
|
+
out2.push("the coverage threshold gate was removed");
|
|
761
|
+
return out2;
|
|
762
762
|
}
|
|
763
|
-
compareMetrics("global", before.global, after.global,
|
|
763
|
+
compareMetrics("global", before.global, after.global, out2);
|
|
764
764
|
const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
|
|
765
765
|
for (const p of paths) {
|
|
766
|
-
compareMetrics(`required coverage for ${p}:`, eff(before, p), eff(after, p),
|
|
766
|
+
compareMetrics(`required coverage for ${p}:`, eff(before, p), eff(after, p), out2);
|
|
767
767
|
}
|
|
768
|
-
return
|
|
768
|
+
return out2;
|
|
769
769
|
}
|
|
770
770
|
var coverageLowering = {
|
|
771
771
|
id: RULE4,
|
|
772
772
|
surface: ["file"],
|
|
773
773
|
certainty: "mechanical",
|
|
774
774
|
run(changes, policy) {
|
|
775
|
-
const
|
|
775
|
+
const out2 = [];
|
|
776
776
|
for (const c of changes) {
|
|
777
777
|
if (c.kind !== "file") continue;
|
|
778
778
|
if (!isProtected(c.path, policy, "config")) continue;
|
|
779
779
|
if (c.after != null && c.op !== "delete") {
|
|
780
780
|
for (const reason of weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after))) {
|
|
781
|
-
|
|
781
|
+
out2.push(
|
|
782
782
|
makeFinding(RULE4, policy, {
|
|
783
783
|
file: c.path,
|
|
784
784
|
message: `Coverage gate weakened: ${reason}.`,
|
|
@@ -789,7 +789,7 @@ var coverageLowering = {
|
|
|
789
789
|
}
|
|
790
790
|
} else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
|
|
791
791
|
if (parseThresholds(c.before ?? "").present) {
|
|
792
|
-
|
|
792
|
+
out2.push(
|
|
793
793
|
makeFinding(RULE4, policy, {
|
|
794
794
|
file: c.path,
|
|
795
795
|
message: "Coverage gate weakened: the coverage config was deleted.",
|
|
@@ -803,7 +803,7 @@ var coverageLowering = {
|
|
|
803
803
|
const removed = removedLines(c);
|
|
804
804
|
const removedCoverage = removed.find((l) => /--coverage\b/.test(l.content));
|
|
805
805
|
if (removedCoverage && !added.some((l) => /--coverage\b/.test(l.content))) {
|
|
806
|
-
|
|
806
|
+
out2.push(
|
|
807
807
|
makeFinding(RULE4, policy, {
|
|
808
808
|
file: c.path,
|
|
809
809
|
message: "The --coverage flag was removed from a test script.",
|
|
@@ -814,7 +814,7 @@ var coverageLowering = {
|
|
|
814
814
|
}
|
|
815
815
|
for (const l of added) {
|
|
816
816
|
if (/--passWithNoTests\b/.test(l.content)) {
|
|
817
|
-
|
|
817
|
+
out2.push(
|
|
818
818
|
makeFinding(RULE4, policy, {
|
|
819
819
|
file: c.path,
|
|
820
820
|
line: l.newLine ?? void 0,
|
|
@@ -837,7 +837,7 @@ var coverageLowering = {
|
|
|
837
837
|
if (!m) continue;
|
|
838
838
|
const ov = oldNums.get(m[1]);
|
|
839
839
|
if (ov !== void 0 && Number(m[2]) < ov) {
|
|
840
|
-
|
|
840
|
+
out2.push(
|
|
841
841
|
makeFinding(RULE4, policy, {
|
|
842
842
|
file: c.path,
|
|
843
843
|
line: l.newLine ?? void 0,
|
|
@@ -851,7 +851,7 @@ var coverageLowering = {
|
|
|
851
851
|
}
|
|
852
852
|
}
|
|
853
853
|
const seen = /* @__PURE__ */ new Set();
|
|
854
|
-
return
|
|
854
|
+
return out2.filter((f) => {
|
|
855
855
|
const k = `${f.rule}|${f.message}`;
|
|
856
856
|
if (seen.has(k)) return false;
|
|
857
857
|
seen.add(k);
|
|
@@ -894,7 +894,7 @@ var ciTampering = {
|
|
|
894
894
|
surface: ["file"],
|
|
895
895
|
certainty: "mechanical",
|
|
896
896
|
run(changes, policy) {
|
|
897
|
-
const
|
|
897
|
+
const out2 = [];
|
|
898
898
|
for (const c of changes) {
|
|
899
899
|
if (c.kind !== "file") continue;
|
|
900
900
|
if (!isProtected(c.path, policy, "ci")) continue;
|
|
@@ -902,7 +902,7 @@ var ciTampering = {
|
|
|
902
902
|
const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
|
|
903
903
|
const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
|
|
904
904
|
if (coe && isTruthy(coe[1])) {
|
|
905
|
-
|
|
905
|
+
out2.push(
|
|
906
906
|
makeFinding(RULE5, policy, {
|
|
907
907
|
file: c.path,
|
|
908
908
|
line: l.newLine ?? void 0,
|
|
@@ -912,7 +912,7 @@ var ciTampering = {
|
|
|
912
912
|
})
|
|
913
913
|
);
|
|
914
914
|
} else if (cond && isAlwaysFalse(cond[1])) {
|
|
915
|
-
|
|
915
|
+
out2.push(
|
|
916
916
|
makeFinding(RULE5, policy, {
|
|
917
917
|
file: c.path,
|
|
918
918
|
line: l.newLine ?? void 0,
|
|
@@ -931,7 +931,7 @@ var ciTampering = {
|
|
|
931
931
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
932
932
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
933
933
|
if (afterCores.has(commandCore(l.content))) continue;
|
|
934
|
-
|
|
934
|
+
out2.push(
|
|
935
935
|
makeFinding(RULE5, policy, {
|
|
936
936
|
file: c.path,
|
|
937
937
|
message: isUsesLine ? "A CI check step (test/lint/typecheck) was removed." : "A CI check command was removed from a run block.",
|
|
@@ -941,7 +941,7 @@ var ciTampering = {
|
|
|
941
941
|
);
|
|
942
942
|
}
|
|
943
943
|
}
|
|
944
|
-
return
|
|
944
|
+
return out2;
|
|
945
945
|
}
|
|
946
946
|
};
|
|
947
947
|
|
|
@@ -1014,6 +1014,24 @@ function policyWeakening(before, after) {
|
|
|
1014
1014
|
if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
|
|
1015
1015
|
reasons.push("sign-off no longer required for blocking findings");
|
|
1016
1016
|
}
|
|
1017
|
+
const bv = b.verify;
|
|
1018
|
+
const av = a.verify;
|
|
1019
|
+
if (bv?.command) {
|
|
1020
|
+
if (!av?.command) {
|
|
1021
|
+
reasons.push("verify block removed \u2014 pristine-suite re-execution disabled");
|
|
1022
|
+
} else {
|
|
1023
|
+
if (av.command !== bv.command) {
|
|
1024
|
+
reasons.push(
|
|
1025
|
+
`verify.command changed ("${bv.command}" \u2192 "${av.command}") \u2014 the re-executed suite is no longer the one that was configured`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
const bb = bv.budget ?? 300;
|
|
1029
|
+
const ab = av.budget ?? 300;
|
|
1030
|
+
if (ab < bb) {
|
|
1031
|
+
reasons.push(`verify.budget lowered ${bb}s \u2192 ${ab}s \u2014 starving the budget turns "cannot verify" into a pressure point`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1017
1035
|
return reasons;
|
|
1018
1036
|
}
|
|
1019
1037
|
|
|
@@ -1026,13 +1044,13 @@ var hookTampering = {
|
|
|
1026
1044
|
surface: ["file", "command"],
|
|
1027
1045
|
certainty: "mechanical",
|
|
1028
1046
|
run(changes, policy) {
|
|
1029
|
-
const
|
|
1047
|
+
const out2 = [];
|
|
1030
1048
|
for (const c of changes) {
|
|
1031
1049
|
if (c.kind === "file") {
|
|
1032
1050
|
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
|
|
1033
1051
|
if (!targetsHook) continue;
|
|
1034
1052
|
if (c.op === "delete") {
|
|
1035
|
-
|
|
1053
|
+
out2.push(
|
|
1036
1054
|
makeFinding(RULE6, policy, {
|
|
1037
1055
|
file: c.path,
|
|
1038
1056
|
message: "A protected hook/policy file was deleted.",
|
|
@@ -1041,7 +1059,7 @@ var hookTampering = {
|
|
|
1041
1059
|
})
|
|
1042
1060
|
);
|
|
1043
1061
|
} else if (c.op === "rename") {
|
|
1044
|
-
|
|
1062
|
+
out2.push(
|
|
1045
1063
|
makeFinding(RULE6, policy, {
|
|
1046
1064
|
file: c.path,
|
|
1047
1065
|
message: `A protected hook/policy file was renamed (${c.oldPath} \u2192 ${c.path}).`,
|
|
@@ -1051,7 +1069,7 @@ var hookTampering = {
|
|
|
1051
1069
|
);
|
|
1052
1070
|
} else if (c.path.endsWith(POLICY_FILE) && c.before != null && c.after != null && policyWeakening(c.before, c.after) !== null) {
|
|
1053
1071
|
for (const reason of policyWeakening(c.before, c.after) ?? []) {
|
|
1054
|
-
|
|
1072
|
+
out2.push(
|
|
1055
1073
|
makeFinding(RULE6, policy, {
|
|
1056
1074
|
file: c.path,
|
|
1057
1075
|
message: `The policy was weakened: ${reason}.`,
|
|
@@ -1066,7 +1084,7 @@ var hookTampering = {
|
|
|
1066
1084
|
const live = added.filter((l) => !isShellComment(l.content));
|
|
1067
1085
|
const earlyExit = added.find((l) => /^\s*exit\s+0\s*$/.test(l.content));
|
|
1068
1086
|
if (earlyExit) {
|
|
1069
|
-
|
|
1087
|
+
out2.push(
|
|
1070
1088
|
makeFinding(RULE6, policy, {
|
|
1071
1089
|
file: c.path,
|
|
1072
1090
|
line: earlyExit.newLine ?? void 0,
|
|
@@ -1081,7 +1099,7 @@ var hookTampering = {
|
|
|
1081
1099
|
const m = l.content.match(HOOK_INVOCATION);
|
|
1082
1100
|
if (!m) continue;
|
|
1083
1101
|
if (live.some((a) => a.content.includes(m[0]))) continue;
|
|
1084
|
-
|
|
1102
|
+
out2.push(
|
|
1085
1103
|
makeFinding(RULE6, policy, {
|
|
1086
1104
|
file: c.path,
|
|
1087
1105
|
message: `A check invocation (\`${m[0]}\`) was removed from a protected hook.`,
|
|
@@ -1101,7 +1119,7 @@ var hookTampering = {
|
|
|
1101
1119
|
for (const l of addedLines(c)) {
|
|
1102
1120
|
const m = l.content.match(INLINE);
|
|
1103
1121
|
if (m && m[2] === "warn" && removedSeverity.get(m[1]) === "block") {
|
|
1104
|
-
|
|
1122
|
+
out2.push(
|
|
1105
1123
|
makeFinding(RULE6, policy, {
|
|
1106
1124
|
file: c.path,
|
|
1107
1125
|
line: l.newLine ?? void 0,
|
|
@@ -1111,7 +1129,7 @@ var hookTampering = {
|
|
|
1111
1129
|
})
|
|
1112
1130
|
);
|
|
1113
1131
|
} else if (/enabled:\s*false\b/.test(l.content)) {
|
|
1114
|
-
|
|
1132
|
+
out2.push(
|
|
1115
1133
|
makeFinding(RULE6, policy, {
|
|
1116
1134
|
file: c.path,
|
|
1117
1135
|
line: l.newLine ?? void 0,
|
|
@@ -1140,7 +1158,7 @@ var hookTampering = {
|
|
|
1140
1158
|
why = "a shell rewrite (tee/sed/cp/dd/install/mv) replaces a protected hook in place";
|
|
1141
1159
|
}
|
|
1142
1160
|
if (why) {
|
|
1143
|
-
|
|
1161
|
+
out2.push(
|
|
1144
1162
|
makeFinding(RULE6, policy, {
|
|
1145
1163
|
message: `Hook tampering via shell: ${why}.`,
|
|
1146
1164
|
evidence: seg,
|
|
@@ -1151,7 +1169,7 @@ var hookTampering = {
|
|
|
1151
1169
|
}
|
|
1152
1170
|
}
|
|
1153
1171
|
}
|
|
1154
|
-
return
|
|
1172
|
+
return out2;
|
|
1155
1173
|
}
|
|
1156
1174
|
};
|
|
1157
1175
|
|
|
@@ -1179,19 +1197,19 @@ function countTestBlocks(src) {
|
|
|
1179
1197
|
return n;
|
|
1180
1198
|
}
|
|
1181
1199
|
function significantLines(src) {
|
|
1182
|
-
const
|
|
1200
|
+
const out2 = /* @__PURE__ */ new Set();
|
|
1183
1201
|
for (const raw of src.split("\n")) {
|
|
1184
1202
|
const l = raw.trim();
|
|
1185
|
-
if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l))
|
|
1203
|
+
if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out2.add(l);
|
|
1186
1204
|
}
|
|
1187
|
-
return
|
|
1205
|
+
return out2;
|
|
1188
1206
|
}
|
|
1189
1207
|
var testDeletion = {
|
|
1190
1208
|
id: RULE7,
|
|
1191
1209
|
surface: ["file", "command"],
|
|
1192
1210
|
certainty: "mechanical",
|
|
1193
1211
|
run(changes, policy) {
|
|
1194
|
-
const
|
|
1212
|
+
const out2 = [];
|
|
1195
1213
|
const addedTestLines = /* @__PURE__ */ new Set();
|
|
1196
1214
|
let addedTestBlocks = 0;
|
|
1197
1215
|
for (const c of changes) {
|
|
@@ -1212,7 +1230,7 @@ var testDeletion = {
|
|
|
1212
1230
|
const isTest = isProtected(c.path, policy, "tests");
|
|
1213
1231
|
if (c.op === "delete" && isTest) {
|
|
1214
1232
|
if (c.before != null && isRelocation(c.before)) continue;
|
|
1215
|
-
|
|
1233
|
+
out2.push(
|
|
1216
1234
|
makeFinding(RULE7, policy, {
|
|
1217
1235
|
file: c.path,
|
|
1218
1236
|
message: "A test file was deleted.",
|
|
@@ -1221,7 +1239,7 @@ var testDeletion = {
|
|
|
1221
1239
|
})
|
|
1222
1240
|
);
|
|
1223
1241
|
} else if (c.op === "rename" && c.oldPath && isProtected(c.oldPath, policy, "tests") && !isTest) {
|
|
1224
|
-
|
|
1242
|
+
out2.push(
|
|
1225
1243
|
makeFinding(RULE7, policy, {
|
|
1226
1244
|
file: c.path,
|
|
1227
1245
|
message: `A test file was renamed out of the test glob (${c.oldPath} \u2192 ${c.path}).`,
|
|
@@ -1233,7 +1251,7 @@ var testDeletion = {
|
|
|
1233
1251
|
const before = countTestBlocks(c.before);
|
|
1234
1252
|
const after = countTestBlocks(c.after);
|
|
1235
1253
|
if (after < before) {
|
|
1236
|
-
|
|
1254
|
+
out2.push(
|
|
1237
1255
|
makeFinding(RULE7, policy, {
|
|
1238
1256
|
file: c.path,
|
|
1239
1257
|
message: `Test blocks removed: ${before} \u2192 ${after} it()/test() in this spec.`,
|
|
@@ -1268,7 +1286,7 @@ var testDeletion = {
|
|
|
1268
1286
|
}
|
|
1269
1287
|
}
|
|
1270
1288
|
if (why) {
|
|
1271
|
-
|
|
1289
|
+
out2.push(
|
|
1272
1290
|
makeFinding(RULE7, policy, {
|
|
1273
1291
|
message: `Test removed via shell: ${why}.`,
|
|
1274
1292
|
evidence,
|
|
@@ -1279,7 +1297,7 @@ var testDeletion = {
|
|
|
1279
1297
|
}
|
|
1280
1298
|
}
|
|
1281
1299
|
}
|
|
1282
|
-
return
|
|
1300
|
+
return out2;
|
|
1283
1301
|
}
|
|
1284
1302
|
};
|
|
1285
1303
|
|
|
@@ -1288,19 +1306,19 @@ var RULE8 = "test-content-removal";
|
|
|
1288
1306
|
var MIN_REMOVED_LINES = 3;
|
|
1289
1307
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
1290
1308
|
function significantLinesOrdered(src) {
|
|
1291
|
-
const
|
|
1309
|
+
const out2 = [];
|
|
1292
1310
|
for (const raw of src.split("\n")) {
|
|
1293
1311
|
const l = raw.trim();
|
|
1294
|
-
if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l))
|
|
1312
|
+
if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out2.push(l);
|
|
1295
1313
|
}
|
|
1296
|
-
return
|
|
1314
|
+
return out2;
|
|
1297
1315
|
}
|
|
1298
1316
|
var testContentRemoval = {
|
|
1299
1317
|
id: RULE8,
|
|
1300
1318
|
surface: ["file"],
|
|
1301
1319
|
certainty: "mechanical",
|
|
1302
1320
|
run(changes, policy) {
|
|
1303
|
-
const
|
|
1321
|
+
const out2 = [];
|
|
1304
1322
|
let keptPool = "";
|
|
1305
1323
|
for (const c of changes) {
|
|
1306
1324
|
if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
|
|
@@ -1323,7 +1341,7 @@ var testContentRemoval = {
|
|
|
1323
1341
|
gone.push(line);
|
|
1324
1342
|
}
|
|
1325
1343
|
if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
|
|
1326
|
-
|
|
1344
|
+
out2.push(
|
|
1327
1345
|
makeFinding(RULE8, policy, {
|
|
1328
1346
|
file: c.path,
|
|
1329
1347
|
message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
|
|
@@ -1333,7 +1351,7 @@ var testContentRemoval = {
|
|
|
1333
1351
|
);
|
|
1334
1352
|
}
|
|
1335
1353
|
}
|
|
1336
|
-
return
|
|
1354
|
+
return out2;
|
|
1337
1355
|
}
|
|
1338
1356
|
};
|
|
1339
1357
|
|
|
@@ -1356,8 +1374,8 @@ var snapshotRewrite = {
|
|
|
1356
1374
|
surface: ["command", "file"],
|
|
1357
1375
|
certainty: "mechanical",
|
|
1358
1376
|
run(changes, policy) {
|
|
1359
|
-
const
|
|
1360
|
-
const warn = (input) =>
|
|
1377
|
+
const out2 = [];
|
|
1378
|
+
const warn = (input) => out2.push(makeFinding(RULE9, policy, { ...input, defaultSeverity: "warn" }));
|
|
1361
1379
|
for (const c of changes) {
|
|
1362
1380
|
if (c.kind === "command") {
|
|
1363
1381
|
for (const seg of segments(c.raw)) {
|
|
@@ -1390,7 +1408,7 @@ var snapshotRewrite = {
|
|
|
1390
1408
|
remediation: "If the intended output genuinely changed, a human should confirm the new expectation against the requirement; if the test was failing, fix the code instead of re-recording the expectation from it."
|
|
1391
1409
|
});
|
|
1392
1410
|
}
|
|
1393
|
-
return
|
|
1411
|
+
return out2;
|
|
1394
1412
|
}
|
|
1395
1413
|
};
|
|
1396
1414
|
|
|
@@ -1456,18 +1474,18 @@ function activeChanges(changes, policy) {
|
|
|
1456
1474
|
}
|
|
1457
1475
|
function evaluate(changes, policy, detectors = allDetectors, view) {
|
|
1458
1476
|
const active = activeChanges(changes, policy);
|
|
1459
|
-
const
|
|
1477
|
+
const out2 = [];
|
|
1460
1478
|
for (const d of detectors) {
|
|
1461
1479
|
if (!isEnabled(d.id, policy)) continue;
|
|
1462
1480
|
try {
|
|
1463
|
-
|
|
1481
|
+
out2.push(...d.run(active, policy, view));
|
|
1464
1482
|
} catch (e) {
|
|
1465
1483
|
process.stderr.write(`tamperward: detector "${d.id}" errored and was skipped: ${String(e)}
|
|
1466
1484
|
`);
|
|
1467
1485
|
}
|
|
1468
1486
|
}
|
|
1469
1487
|
const seen = /* @__PURE__ */ new Set();
|
|
1470
|
-
const deduped =
|
|
1488
|
+
const deduped = out2.filter((f) => {
|
|
1471
1489
|
const k = key(f);
|
|
1472
1490
|
if (seen.has(k)) return false;
|
|
1473
1491
|
seen.add(k);
|
|
@@ -1511,7 +1529,8 @@ function parsePolicy(raw) {
|
|
|
1511
1529
|
signoff: {
|
|
1512
1530
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1513
1531
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
1514
|
-
}
|
|
1532
|
+
},
|
|
1533
|
+
...r.verify?.command ? { verify: { command: r.verify.command, budget: r.verify.budget ?? 300 } } : {}
|
|
1515
1534
|
};
|
|
1516
1535
|
}
|
|
1517
1536
|
function parseOrThrow(src, where) {
|
|
@@ -1552,16 +1571,16 @@ function ledgerPath(cwd, policy) {
|
|
|
1552
1571
|
function readLedger(cwd, policy) {
|
|
1553
1572
|
const p = ledgerPath(cwd, policy);
|
|
1554
1573
|
if (!existsSync2(p)) return [];
|
|
1555
|
-
const
|
|
1574
|
+
const out2 = [];
|
|
1556
1575
|
for (const line of readFileSync3(p, "utf8").split("\n")) {
|
|
1557
1576
|
if (!line.trim()) continue;
|
|
1558
1577
|
try {
|
|
1559
1578
|
const e = JSON.parse(line);
|
|
1560
|
-
if (e && typeof e.fingerprint === "string")
|
|
1579
|
+
if (e && typeof e.fingerprint === "string") out2.push(e);
|
|
1561
1580
|
} catch {
|
|
1562
1581
|
}
|
|
1563
1582
|
}
|
|
1564
|
-
return
|
|
1583
|
+
return out2;
|
|
1565
1584
|
}
|
|
1566
1585
|
function appendEntry(cwd, policy, e) {
|
|
1567
1586
|
const p = ledgerPath(cwd, policy);
|
|
@@ -1620,18 +1639,18 @@ function paint(s, code, on) {
|
|
|
1620
1639
|
return on ? code + s + RESET : s;
|
|
1621
1640
|
}
|
|
1622
1641
|
function wrap(text, width) {
|
|
1623
|
-
const
|
|
1642
|
+
const out2 = [];
|
|
1624
1643
|
let cur = "";
|
|
1625
1644
|
for (const w of text.split(/\s+/).filter(Boolean)) {
|
|
1626
1645
|
if (cur === "") cur = w;
|
|
1627
1646
|
else if (cur.length + 1 + w.length <= width) cur += " " + w;
|
|
1628
1647
|
else {
|
|
1629
|
-
|
|
1648
|
+
out2.push(cur);
|
|
1630
1649
|
cur = w;
|
|
1631
1650
|
}
|
|
1632
1651
|
}
|
|
1633
|
-
if (cur)
|
|
1634
|
-
return
|
|
1652
|
+
if (cur) out2.push(cur);
|
|
1653
|
+
return out2.length ? out2 : [""];
|
|
1635
1654
|
}
|
|
1636
1655
|
function clip(s, max) {
|
|
1637
1656
|
const one = s.replace(/\s+/g, " ").trim();
|
|
@@ -1663,15 +1682,15 @@ function renderText(input, opts) {
|
|
|
1663
1682
|
const blocks = findings.filter((f) => f.severity === "block");
|
|
1664
1683
|
const warns = findings.filter((f) => f.severity === "warn");
|
|
1665
1684
|
const c = opts.colour;
|
|
1666
|
-
const
|
|
1685
|
+
const out2 = [];
|
|
1667
1686
|
if (findings.length === 0) {
|
|
1668
1687
|
return `${paint("tamperward: clean", GREEN, c)} \u2014 no integrity findings (${scopePhrase(scanned, ignoredFiles)}).
|
|
1669
1688
|
`;
|
|
1670
1689
|
}
|
|
1671
1690
|
const headColour = blocks.length ? RED : YELLOW;
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1691
|
+
out2.push(paint(`tamperward: ${countsPhrase(blocks.length, warns.length)}`, BOLD + headColour, c));
|
|
1692
|
+
out2.push(paint(`(${scopePhrase(scanned, ignoredFiles)})`, DIM, c));
|
|
1693
|
+
out2.push("");
|
|
1675
1694
|
const LABEL = "sign-off".length;
|
|
1676
1695
|
const pad = (k) => (k + " ".repeat(LABEL)).slice(0, LABEL);
|
|
1677
1696
|
const body = Math.max(20, opts.width - LABEL - 6);
|
|
@@ -1679,22 +1698,22 @@ function renderText(input, opts) {
|
|
|
1679
1698
|
const isBlock = f.severity === "block";
|
|
1680
1699
|
const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
|
|
1681
1700
|
const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
1682
|
-
|
|
1683
|
-
for (const l of wrap(f.message, opts.width - 4))
|
|
1701
|
+
out2.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
1702
|
+
for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
|
|
1684
1703
|
const row = (key2, lines) => {
|
|
1685
|
-
lines.forEach((l, i) =>
|
|
1704
|
+
lines.forEach((l, i) => out2.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
|
|
1686
1705
|
};
|
|
1687
1706
|
row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
|
|
1688
1707
|
row("instead", wrap(f.remediation, body));
|
|
1689
1708
|
if (f.signoff.required) row("sign-off", [f.signoff.command]);
|
|
1690
|
-
|
|
1709
|
+
out2.push("");
|
|
1691
1710
|
}
|
|
1692
1711
|
if (blocks.length > 0) {
|
|
1693
|
-
|
|
1712
|
+
out2.push(
|
|
1694
1713
|
wrap(SIGNOFF_NOTE, opts.width - 2).map((l) => paint(l, DIM, c)).join("\n")
|
|
1695
1714
|
);
|
|
1696
1715
|
}
|
|
1697
|
-
return
|
|
1716
|
+
return out2.join("\n").replace(/\n+$/, "") + "\n";
|
|
1698
1717
|
}
|
|
1699
1718
|
|
|
1700
1719
|
// src/cli/render/github.ts
|
|
@@ -1742,7 +1761,7 @@ function renderSummary(input, env = process.env) {
|
|
|
1742
1761
|
No integrity findings (${scope}).
|
|
1743
1762
|
`;
|
|
1744
1763
|
}
|
|
1745
|
-
const
|
|
1764
|
+
const out2 = [
|
|
1746
1765
|
`## Tamperward: ${countsPhrase(blocks.length, warns.length)}`,
|
|
1747
1766
|
"",
|
|
1748
1767
|
`${scope}. Every finding below is also annotated on the diff in **Files changed**.`,
|
|
@@ -1752,21 +1771,21 @@ No integrity findings (${scope}).
|
|
|
1752
1771
|
];
|
|
1753
1772
|
for (const f of findings) {
|
|
1754
1773
|
const sev = f.severity === "block" ? "**BLOCK**" : "warn";
|
|
1755
|
-
|
|
1774
|
+
out2.push(
|
|
1756
1775
|
`| ${sev} | \`${cell(f.rule)}\` | ${locationCell(f, env)} | ${cell(f.message)} | ${cell(f.remediation)} |`
|
|
1757
1776
|
);
|
|
1758
1777
|
}
|
|
1759
1778
|
const needSignoff = findings.filter((f) => f.signoff.required);
|
|
1760
1779
|
if (needSignoff.length) {
|
|
1761
|
-
|
|
1780
|
+
out2.push("", "### Clearing a blocking finding", "", SIGNOFF_NOTE, "");
|
|
1762
1781
|
const seen = /* @__PURE__ */ new Set();
|
|
1763
1782
|
for (const f of needSignoff) {
|
|
1764
1783
|
if (seen.has(f.signoff.command)) continue;
|
|
1765
1784
|
seen.add(f.signoff.command);
|
|
1766
|
-
|
|
1785
|
+
out2.push(`- \`${cell(f.signoff.command)}\``);
|
|
1767
1786
|
}
|
|
1768
1787
|
}
|
|
1769
|
-
return
|
|
1788
|
+
return out2.join("\n") + "\n";
|
|
1770
1789
|
}
|
|
1771
1790
|
function writeSummary(md, env = process.env) {
|
|
1772
1791
|
const path = env.GITHUB_STEP_SUMMARY;
|
|
@@ -1920,8 +1939,8 @@ function synthFileChange(displayPath, before, after) {
|
|
|
1920
1939
|
try {
|
|
1921
1940
|
raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], { encoding: "utf8" });
|
|
1922
1941
|
} catch (e) {
|
|
1923
|
-
const
|
|
1924
|
-
raw =
|
|
1942
|
+
const err2 = e;
|
|
1943
|
+
raw = err2.stdout ? String(err2.stdout) : "";
|
|
1925
1944
|
}
|
|
1926
1945
|
} finally {
|
|
1927
1946
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -2055,7 +2074,7 @@ function ptreePath(cwd, sessionId) {
|
|
|
2055
2074
|
}
|
|
2056
2075
|
var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
|
|
2057
2076
|
function snapshotProtected(cwd, policy, prev) {
|
|
2058
|
-
const
|
|
2077
|
+
const out2 = {};
|
|
2059
2078
|
const walk = (rel) => {
|
|
2060
2079
|
const abs2 = rel ? join6(cwd, rel) : cwd;
|
|
2061
2080
|
let names;
|
|
@@ -2077,7 +2096,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
2077
2096
|
else if (st.isFile() && isProtected(r, policy)) {
|
|
2078
2097
|
const p = prev?.[r];
|
|
2079
2098
|
if (p && p.mtimeMs === st.mtimeMs && p.size === st.size && p.mode === st.mode) {
|
|
2080
|
-
|
|
2099
|
+
out2[r] = p;
|
|
2081
2100
|
} else {
|
|
2082
2101
|
let hash = "";
|
|
2083
2102
|
try {
|
|
@@ -2085,13 +2104,13 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
2085
2104
|
} catch {
|
|
2086
2105
|
hash = "unreadable";
|
|
2087
2106
|
}
|
|
2088
|
-
|
|
2107
|
+
out2[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
|
|
2089
2108
|
}
|
|
2090
2109
|
}
|
|
2091
2110
|
}
|
|
2092
2111
|
};
|
|
2093
2112
|
walk("");
|
|
2094
|
-
return
|
|
2113
|
+
return out2;
|
|
2095
2114
|
}
|
|
2096
2115
|
function driftBetween(expected, current) {
|
|
2097
2116
|
const changed = [];
|
|
@@ -2218,9 +2237,9 @@ function runWatch(args) {
|
|
|
2218
2237
|
else if (args[i] === "--log" && args[i + 1]) log = args[++i];
|
|
2219
2238
|
}
|
|
2220
2239
|
const policy = loadPolicy(dir);
|
|
2221
|
-
const
|
|
2222
|
-
startWatcher(dir,
|
|
2223
|
-
process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${
|
|
2240
|
+
const out2 = log ?? defaultEventLog(dir);
|
|
2241
|
+
startWatcher(dir, out2, policy);
|
|
2242
|
+
process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${out2}
|
|
2224
2243
|
`);
|
|
2225
2244
|
return -1;
|
|
2226
2245
|
}
|
|
@@ -2255,7 +2274,7 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
|
2255
2274
|
arr.push(e);
|
|
2256
2275
|
byPath.set(e.path, arr);
|
|
2257
2276
|
}
|
|
2258
|
-
const
|
|
2277
|
+
const out2 = [];
|
|
2259
2278
|
const strict = process.env.TAMPERWARD_TRANSIENT === "block";
|
|
2260
2279
|
for (const [path, evs] of byPath) {
|
|
2261
2280
|
if (persistentPaths.has(path)) continue;
|
|
@@ -2278,9 +2297,9 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
|
2278
2297
|
f.severity = "block";
|
|
2279
2298
|
f.signoff.required = true;
|
|
2280
2299
|
}
|
|
2281
|
-
|
|
2300
|
+
out2.push(f);
|
|
2282
2301
|
}
|
|
2283
|
-
return
|
|
2302
|
+
return out2;
|
|
2284
2303
|
}
|
|
2285
2304
|
|
|
2286
2305
|
// src/cli/hook.ts
|
|
@@ -2728,6 +2747,241 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
2728
2747
|
return 0;
|
|
2729
2748
|
}
|
|
2730
2749
|
|
|
2750
|
+
// src/cli/verify.ts
|
|
2751
|
+
import { execFileSync as execFileSync4, spawnSync } from "node:child_process";
|
|
2752
|
+
import { chmodSync as chmodSync2, cpSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "node:fs";
|
|
2753
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
2754
|
+
import { dirname as dirname4, join as join9 } from "node:path";
|
|
2755
|
+
var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
|
|
2756
|
+
function git2(args, cwd) {
|
|
2757
|
+
return execFileSync4("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
2758
|
+
}
|
|
2759
|
+
function resolveBase(base, cwd) {
|
|
2760
|
+
const rev = git2(["rev-parse", "--verify", `${base}^{commit}`], cwd).trim();
|
|
2761
|
+
try {
|
|
2762
|
+
return git2(["merge-base", rev, "HEAD"], cwd).trim();
|
|
2763
|
+
} catch {
|
|
2764
|
+
return rev;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
function materialize(cwd, dest) {
|
|
2768
|
+
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2769
|
+
for (const rel of listed) {
|
|
2770
|
+
const src = join9(cwd, rel);
|
|
2771
|
+
let st;
|
|
2772
|
+
try {
|
|
2773
|
+
st = statSync5(src);
|
|
2774
|
+
} catch {
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
2777
|
+
if (!st.isFile()) continue;
|
|
2778
|
+
const out2 = join9(dest, rel);
|
|
2779
|
+
mkdirSync6(dirname4(out2), { recursive: true });
|
|
2780
|
+
cpSync(src, out2);
|
|
2781
|
+
chmodSync2(out2, st.mode);
|
|
2782
|
+
}
|
|
2783
|
+
const nm = join9(cwd, "node_modules");
|
|
2784
|
+
if (existsSync8(nm)) symlinkSync(nm, join9(dest, "node_modules"), "dir");
|
|
2785
|
+
}
|
|
2786
|
+
function overlayPristine(cwd, base, dest, policy) {
|
|
2787
|
+
const atBase = git2(["ls-tree", "-r", "--name-only", "-z", base], cwd).split("\0").filter(Boolean);
|
|
2788
|
+
const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c));
|
|
2789
|
+
let restored = 0;
|
|
2790
|
+
const baseProtected = /* @__PURE__ */ new Set();
|
|
2791
|
+
for (const rel of atBase) {
|
|
2792
|
+
if (!isOverlay(rel)) continue;
|
|
2793
|
+
baseProtected.add(rel);
|
|
2794
|
+
const content = execFileSync4("git", ["show", `${base}:${rel}`], { cwd, maxBuffer: 1 << 28 });
|
|
2795
|
+
const out2 = join9(dest, rel);
|
|
2796
|
+
mkdirSync6(dirname4(out2), { recursive: true });
|
|
2797
|
+
writeFileSync6(out2, content);
|
|
2798
|
+
restored++;
|
|
2799
|
+
}
|
|
2800
|
+
let kept = 0;
|
|
2801
|
+
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2802
|
+
for (const rel of inCopy) if (isOverlay(rel) && !baseProtected.has(rel)) kept++;
|
|
2803
|
+
return [restored, kept];
|
|
2804
|
+
}
|
|
2805
|
+
function runSuite(dir, cmd, budgetSecs) {
|
|
2806
|
+
const t0 = Date.now();
|
|
2807
|
+
const r = spawnSync("sh", ["-c", cmd], {
|
|
2808
|
+
cwd: dir,
|
|
2809
|
+
stdio: "ignore",
|
|
2810
|
+
timeout: budgetSecs * 1e3,
|
|
2811
|
+
killSignal: "SIGKILL"
|
|
2812
|
+
});
|
|
2813
|
+
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
2814
|
+
if (r.error && r.error.code === "ETIMEDOUT") return { exit: null, secs };
|
|
2815
|
+
if (r.status === null && r.signal) return { exit: null, secs };
|
|
2816
|
+
return { exit: r.status ?? 2, secs };
|
|
2817
|
+
}
|
|
2818
|
+
function runVerify(opts) {
|
|
2819
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2820
|
+
const out2 = (s) => void process.stdout.write(s + "\n");
|
|
2821
|
+
let policy;
|
|
2822
|
+
try {
|
|
2823
|
+
policy = loadPolicy(cwd);
|
|
2824
|
+
} catch (e) {
|
|
2825
|
+
out2(`verify: cannot load policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
2826
|
+
return 2;
|
|
2827
|
+
}
|
|
2828
|
+
const cmd = opts.cmd ?? policy.verify?.command;
|
|
2829
|
+
if (!cmd) {
|
|
2830
|
+
out2("verify: no suite command \u2014 set policy `verify: { command: ... }` or pass --cmd");
|
|
2831
|
+
return 2;
|
|
2832
|
+
}
|
|
2833
|
+
const budget = opts.budget ?? policy.verify?.budget ?? 300;
|
|
2834
|
+
let base;
|
|
2835
|
+
try {
|
|
2836
|
+
base = resolveBase(opts.base ?? "HEAD", cwd);
|
|
2837
|
+
} catch {
|
|
2838
|
+
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
2839
|
+
return 2;
|
|
2840
|
+
}
|
|
2841
|
+
const work = mkdtempSync2(join9(tmpdir2(), "tw-verify-"));
|
|
2842
|
+
const visDir = join9(work, "visible");
|
|
2843
|
+
const priDir = join9(work, "pristine");
|
|
2844
|
+
let restored = 0;
|
|
2845
|
+
let kept = 0;
|
|
2846
|
+
try {
|
|
2847
|
+
mkdirSync6(visDir);
|
|
2848
|
+
mkdirSync6(priDir);
|
|
2849
|
+
materialize(cwd, visDir);
|
|
2850
|
+
materialize(cwd, priDir);
|
|
2851
|
+
[restored, kept] = overlayPristine(cwd, base, priDir, policy);
|
|
2852
|
+
} catch (e) {
|
|
2853
|
+
if (!opts.keep) rmSync2(work, { recursive: true, force: true });
|
|
2854
|
+
out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
2855
|
+
return 2;
|
|
2856
|
+
}
|
|
2857
|
+
const visible = runSuite(visDir, cmd, budget);
|
|
2858
|
+
const pristine = runSuite(priDir, cmd, budget);
|
|
2859
|
+
if (!opts.keep) rmSync2(work, { recursive: true, force: true });
|
|
2860
|
+
let verdict2;
|
|
2861
|
+
let code;
|
|
2862
|
+
if (visible.exit === null || pristine.exit === null) {
|
|
2863
|
+
verdict2 = "BUDGET_EXCEEDED";
|
|
2864
|
+
code = 2;
|
|
2865
|
+
} else if (visible.exit === 0 && pristine.exit === 0) {
|
|
2866
|
+
verdict2 = "VERIFIED";
|
|
2867
|
+
code = 0;
|
|
2868
|
+
} else if (visible.exit === 0) {
|
|
2869
|
+
verdict2 = "MASKED_FAILURE";
|
|
2870
|
+
code = 1;
|
|
2871
|
+
} else {
|
|
2872
|
+
verdict2 = "SUITE_RED";
|
|
2873
|
+
code = 1;
|
|
2874
|
+
}
|
|
2875
|
+
if (opts.json) {
|
|
2876
|
+
out2(
|
|
2877
|
+
JSON.stringify({
|
|
2878
|
+
verdict: verdict2,
|
|
2879
|
+
base,
|
|
2880
|
+
command: cmd,
|
|
2881
|
+
budget_secs: budget,
|
|
2882
|
+
visible: { exit: visible.exit, secs: visible.secs },
|
|
2883
|
+
pristine: { exit: pristine.exit, secs: pristine.secs },
|
|
2884
|
+
protected_restored: restored,
|
|
2885
|
+
added_protected_kept: kept,
|
|
2886
|
+
...opts.keep ? { workdir: work } : {}
|
|
2887
|
+
})
|
|
2888
|
+
);
|
|
2889
|
+
} else {
|
|
2890
|
+
const lines = {
|
|
2891
|
+
VERIFIED: `verified: the suite passes, and still passes with all ${restored} protected files restored from ${base.slice(0, 10)}.`,
|
|
2892
|
+
MASKED_FAILURE: `MASKED FAILURE: the visible suite passes, but with the ${restored} protected files restored from ${base.slice(0, 10)} it FAILS (exit ${pristine.exit}). Something weakened the checks; the code does not pass the original suite.`,
|
|
2893
|
+
SUITE_RED: `suite red: the visible suite fails (exit ${visible.exit}) \u2014 fix the code first (pristine exit ${pristine.exit}).`,
|
|
2894
|
+
BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
|
|
2895
|
+
};
|
|
2896
|
+
out2(`tamperward verify \u2014 ${lines[verdict2]}`);
|
|
2897
|
+
if (kept > 0) out2(`(${kept} protected file(s) added since ${base.slice(0, 10)} were kept in the pristine run.)`);
|
|
2898
|
+
}
|
|
2899
|
+
return code;
|
|
2900
|
+
}
|
|
2901
|
+
function parseVerify(args) {
|
|
2902
|
+
const o = {};
|
|
2903
|
+
for (let i = 0; i < args.length; i++) {
|
|
2904
|
+
const a = args[i];
|
|
2905
|
+
if (a === "--base" && args[i + 1]) o.base = args[++i];
|
|
2906
|
+
else if (a === "--cmd" && args[i + 1]) o.cmd = args[++i];
|
|
2907
|
+
else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
|
|
2908
|
+
else if (a === "--json") o.json = true;
|
|
2909
|
+
else if (a === "--keep") o.keep = true;
|
|
2910
|
+
else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
|
|
2911
|
+
}
|
|
2912
|
+
return o;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// src/cli/run.ts
|
|
2916
|
+
import { execFileSync as execFileSync5, spawnSync as spawnSync2 } from "node:child_process";
|
|
2917
|
+
var out = (s) => process.stdout.write(s + "\n");
|
|
2918
|
+
var err = (s) => process.stderr.write(s + "\n");
|
|
2919
|
+
function git3(args, cwd) {
|
|
2920
|
+
return execFileSync5("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
2921
|
+
}
|
|
2922
|
+
function runEnvelope(opts) {
|
|
2923
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2924
|
+
if (!opts.argv.length) {
|
|
2925
|
+
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
2926
|
+
return 2;
|
|
2927
|
+
}
|
|
2928
|
+
let base;
|
|
2929
|
+
try {
|
|
2930
|
+
base = git3(["rev-parse", "--verify", `${opts.base ?? "HEAD"}^{commit}`], cwd).trim();
|
|
2931
|
+
} catch {
|
|
2932
|
+
err(`tamperward run: cannot resolve trusted base ${opts.base ?? "HEAD"} \u2014 failing closed.`);
|
|
2933
|
+
return 2;
|
|
2934
|
+
}
|
|
2935
|
+
if (!opts.allowDirty) {
|
|
2936
|
+
const dirty = git3(["status", "--porcelain"], cwd).trim();
|
|
2937
|
+
if (dirty) {
|
|
2938
|
+
err("tamperward run: working tree is dirty before the agent starts \u2014 the envelope cannot");
|
|
2939
|
+
err("attribute changes. Commit or stash first, or pass --allow-dirty to own the risk.");
|
|
2940
|
+
return 2;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
|
|
2944
|
+
const agent = spawnSync2(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
|
|
2945
|
+
const agentExit = agent.status ?? 1;
|
|
2946
|
+
const head = git3(["rev-parse", "HEAD"], cwd).trim();
|
|
2947
|
+
let diffCode = 0;
|
|
2948
|
+
if (head !== base) {
|
|
2949
|
+
out(`
|
|
2950
|
+
[envelope] policy check over committed changes (${base.slice(0, 10)}...${head.slice(0, 10)}):`);
|
|
2951
|
+
diffCode = runCheck({ diff: `${base}...${head}`, cwd });
|
|
2952
|
+
}
|
|
2953
|
+
out("\n[envelope] policy check over the working tree:");
|
|
2954
|
+
const workCode = runCheck({ worktree: true, cwd });
|
|
2955
|
+
out("\n[envelope] pristine verification against the trusted base:");
|
|
2956
|
+
const verifyCode = runVerify({ cwd, base, cmd: opts.cmd, budget: opts.budget });
|
|
2957
|
+
const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
|
|
2958
|
+
const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1;
|
|
2959
|
+
const enforcement = cannot ? 2 : blocked ? 1 : 0;
|
|
2960
|
+
const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
|
|
2961
|
+
out(`
|
|
2962
|
+
tamperward run \u2014 agent exit ${agentExit}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
|
|
2963
|
+
return enforcement !== 0 ? enforcement : agentExit;
|
|
2964
|
+
}
|
|
2965
|
+
function parseRun(args) {
|
|
2966
|
+
const o = { argv: [] };
|
|
2967
|
+
for (let i = 0; i < args.length; i++) {
|
|
2968
|
+
const a = args[i];
|
|
2969
|
+
if (a === "--") {
|
|
2970
|
+
o.argv = args.slice(i + 1);
|
|
2971
|
+
break;
|
|
2972
|
+
} else if (a === "--base") o.base = args[++i];
|
|
2973
|
+
else if (a === "--cmd") o.cmd = args[++i];
|
|
2974
|
+
else if (a === "--budget") o.budget = Number(args[++i]);
|
|
2975
|
+
else if (a === "--allow-dirty") o.allowDirty = true;
|
|
2976
|
+
else if (a === "--cwd") o.cwd = args[++i];
|
|
2977
|
+
else {
|
|
2978
|
+
o.argv = args.slice(i);
|
|
2979
|
+
break;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
return o;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2731
2985
|
// src/cli/index.ts
|
|
2732
2986
|
function parseAllow(args) {
|
|
2733
2987
|
const o = {};
|
|
@@ -2803,6 +3057,19 @@ Formats:
|
|
|
2803
3057
|
tamperward watch [--dir D] [--log F] filesystem-event observer daemon: records
|
|
2804
3058
|
protected-file events so the sweep can
|
|
2805
3059
|
observe supported transient effects
|
|
3060
|
+
tamperward verify [--base R] [--cmd C] pristine-suite re-execution: run the suite
|
|
3061
|
+
[--budget S] [--json] [--keep] as-is AND with protected files restored
|
|
3062
|
+
from the trusted base; a visible-green /
|
|
3063
|
+
pristine-red result is a MASKED FAILURE
|
|
3064
|
+
(exit 1); cannot-verify fails closed (2)
|
|
3065
|
+
tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
|
|
3066
|
+
[--base R] [--cmd C] base, run the agent, treat its exit as
|
|
3067
|
+
[--budget S] [--allow-dirty] untrusted, then re-adjudicate the tree it
|
|
3068
|
+
left (policy over base...HEAD and the
|
|
3069
|
+
worktree, plus verify). Exit: agent's code
|
|
3070
|
+
when clean; 1 on any blocking finding or
|
|
3071
|
+
masked failure; 2 when it cannot
|
|
3072
|
+
adjudicate (fails closed)
|
|
2806
3073
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
2807
3074
|
tamperward init [--dry-run] wire the policy file plus every
|
|
2808
3075
|
enforcement point: Claude Code hooks,
|
|
@@ -2826,6 +3093,10 @@ function main(argv2) {
|
|
|
2826
3093
|
return runAllow(parseAllow(rest));
|
|
2827
3094
|
case "init":
|
|
2828
3095
|
return runInit(parseInit(rest));
|
|
3096
|
+
case "verify":
|
|
3097
|
+
return runVerify(parseVerify(rest));
|
|
3098
|
+
case "run":
|
|
3099
|
+
return runEnvelope(parseRun(rest));
|
|
2829
3100
|
case void 0:
|
|
2830
3101
|
case "-h":
|
|
2831
3102
|
case "--help":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "hexrift",
|