tamperward 2.10.3 → 2.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/cli/index.js +367 -314
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/git/build.ts
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
5
5
|
|
|
6
6
|
// src/disk.ts
|
|
7
7
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync } from "node:fs";
|
|
@@ -602,10 +602,62 @@ function addHunks(content) {
|
|
|
602
602
|
];
|
|
603
603
|
}
|
|
604
604
|
|
|
605
|
+
// src/git/trusted.ts
|
|
606
|
+
import { execFileSync } from "node:child_process";
|
|
607
|
+
import { createHash } from "node:crypto";
|
|
608
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
609
|
+
import { isAbsolute, join as join2 } from "node:path";
|
|
610
|
+
function trustedGitEnv() {
|
|
611
|
+
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
612
|
+
}
|
|
613
|
+
function gitDirOf(cwd) {
|
|
614
|
+
try {
|
|
615
|
+
const d = execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
616
|
+
cwd,
|
|
617
|
+
encoding: "utf8",
|
|
618
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
619
|
+
env: trustedGitEnv()
|
|
620
|
+
}).trim();
|
|
621
|
+
if (!d) return null;
|
|
622
|
+
return isAbsolute(d) ? d : join2(cwd, d);
|
|
623
|
+
} catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function objectRewriteState(cwd) {
|
|
628
|
+
const parts = [];
|
|
629
|
+
try {
|
|
630
|
+
parts.push(
|
|
631
|
+
"replace:" + execFileSync("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
632
|
+
cwd,
|
|
633
|
+
encoding: "utf8",
|
|
634
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
635
|
+
env: trustedGitEnv()
|
|
636
|
+
}).trim()
|
|
637
|
+
);
|
|
638
|
+
} catch {
|
|
639
|
+
parts.push("replace:<unreadable>");
|
|
640
|
+
}
|
|
641
|
+
const dir = gitDirOf(cwd);
|
|
642
|
+
for (const rel of ["info/grafts", "shallow"]) {
|
|
643
|
+
if (dir === null) {
|
|
644
|
+
parts.push(`${rel}:<no-git-dir>`);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const p = join2(dir, rel);
|
|
648
|
+
try {
|
|
649
|
+
parts.push(`${rel}:` + (existsSync(p) ? readFileSync(p, "utf8").trim() : ""));
|
|
650
|
+
} catch {
|
|
651
|
+
parts.push(`${rel}:<unreadable>`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return createHash("sha256").update(parts.join("\n")).digest("hex");
|
|
655
|
+
}
|
|
656
|
+
|
|
605
657
|
// src/git/build.ts
|
|
606
658
|
function git(args, cwd) {
|
|
607
659
|
try {
|
|
608
|
-
return
|
|
660
|
+
return execFileSync2("git", args, {
|
|
609
661
|
cwd,
|
|
610
662
|
encoding: "utf8",
|
|
611
663
|
// 256 MiB, matching the hook adapter. Every diff below runs with `--text`, so a
|
|
@@ -614,7 +666,12 @@ function git(args, cwd) {
|
|
|
614
666
|
// the cap git() throws and the view fails CLOSED (a truncated patch would parse
|
|
615
667
|
// as a smaller change than the one landing), which is the documented stance.
|
|
616
668
|
maxBuffer: 256 * 1024 * 1024,
|
|
617
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
669
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
670
|
+
// Every object read in this shared adapter can feed a trust decision:
|
|
671
|
+
// policy-at-base, merge-base selection, range enrichment, and the HEAD
|
|
672
|
+
// side of worktree/staged comparisons. A pre-existing replace ref must
|
|
673
|
+
// never make those callers see an attacker-selected object.
|
|
674
|
+
env: trustedGitEnv()
|
|
618
675
|
});
|
|
619
676
|
} catch (e) {
|
|
620
677
|
const err2 = e;
|
|
@@ -765,6 +822,104 @@ function hiddenTrackedPaths(opts = {}) {
|
|
|
765
822
|
return out2;
|
|
766
823
|
}
|
|
767
824
|
|
|
825
|
+
// src/adapters/claude/changes.ts
|
|
826
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
827
|
+
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
828
|
+
import { tmpdir } from "node:os";
|
|
829
|
+
import { isAbsolute as isAbsolute2, join as join3, relative, resolve } from "node:path";
|
|
830
|
+
function asStr(v) {
|
|
831
|
+
return typeof v === "string" ? v : "";
|
|
832
|
+
}
|
|
833
|
+
function readDisk(path) {
|
|
834
|
+
return textOf(inspectResolved(path));
|
|
835
|
+
}
|
|
836
|
+
function abs(path, cwd) {
|
|
837
|
+
return resolve(cwd, path);
|
|
838
|
+
}
|
|
839
|
+
function relForDisplay(path, cwd) {
|
|
840
|
+
const rel = relative(cwd, path);
|
|
841
|
+
return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
|
|
842
|
+
}
|
|
843
|
+
function applyEdit(content, oldStr, newStr) {
|
|
844
|
+
if (content === null) return newStr;
|
|
845
|
+
if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
|
|
846
|
+
return content.replace(oldStr, () => newStr);
|
|
847
|
+
}
|
|
848
|
+
function synthFileChange(displayPath, before, after) {
|
|
849
|
+
if (before === after) return [];
|
|
850
|
+
let op;
|
|
851
|
+
if (before === null) op = "add";
|
|
852
|
+
else if (after === null) op = "delete";
|
|
853
|
+
else op = "modify";
|
|
854
|
+
const dir = mkdtempSync(join3(tmpdir(), "hf-"));
|
|
855
|
+
let raw = "";
|
|
856
|
+
try {
|
|
857
|
+
const a = join3(dir, "a");
|
|
858
|
+
const b = join3(dir, "b");
|
|
859
|
+
writeFileSync(a, before ?? "");
|
|
860
|
+
writeFileSync(b, after ?? "");
|
|
861
|
+
try {
|
|
862
|
+
raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
|
|
863
|
+
encoding: "utf8",
|
|
864
|
+
maxBuffer: 256 * 1024 * 1024
|
|
865
|
+
});
|
|
866
|
+
} catch (e) {
|
|
867
|
+
const err2 = e;
|
|
868
|
+
if (err2.status !== 1) {
|
|
869
|
+
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
|
|
870
|
+
}
|
|
871
|
+
raw = err2.stdout ? String(err2.stdout) : "";
|
|
872
|
+
}
|
|
873
|
+
} finally {
|
|
874
|
+
rmSync(dir, { recursive: true, force: true });
|
|
875
|
+
}
|
|
876
|
+
const first = parseDiff(raw)[0];
|
|
877
|
+
const hunks = first && first.kind === "file" ? first.hunks : [];
|
|
878
|
+
return [{ kind: "file", path: displayPath, oldPath: null, op, before, after, binary: false, hunks }];
|
|
879
|
+
}
|
|
880
|
+
function changesFromClaudeHook(input, cwd) {
|
|
881
|
+
const ti = input.tool_input ?? {};
|
|
882
|
+
switch (input.tool_name) {
|
|
883
|
+
case "Bash": {
|
|
884
|
+
const raw = asStr(ti.command);
|
|
885
|
+
return raw ? [{ kind: "command", raw, argv: raw.split(/\s+/) }] : [];
|
|
886
|
+
}
|
|
887
|
+
case "Write": {
|
|
888
|
+
const fp = asStr(ti.file_path);
|
|
889
|
+
if (!fp) return [];
|
|
890
|
+
const before = readDisk(abs(fp, cwd));
|
|
891
|
+
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, asStr(ti.content));
|
|
892
|
+
}
|
|
893
|
+
case "Edit": {
|
|
894
|
+
const fp = asStr(ti.file_path);
|
|
895
|
+
if (!fp) return [];
|
|
896
|
+
const before = readDisk(abs(fp, cwd));
|
|
897
|
+
const after = applyEdit(before, asStr(ti.old_string), asStr(ti.new_string));
|
|
898
|
+
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
|
|
899
|
+
}
|
|
900
|
+
case "MultiEdit": {
|
|
901
|
+
const fp = asStr(ti.file_path);
|
|
902
|
+
if (!fp) return [];
|
|
903
|
+
const before = readDisk(abs(fp, cwd));
|
|
904
|
+
let after = before;
|
|
905
|
+
const edits = Array.isArray(ti.edits) ? ti.edits : [];
|
|
906
|
+
for (const raw of edits) {
|
|
907
|
+
const ed = raw;
|
|
908
|
+
after = applyEdit(after, ed.old_string ?? "", ed.new_string ?? "");
|
|
909
|
+
}
|
|
910
|
+
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
|
|
911
|
+
}
|
|
912
|
+
case "NotebookEdit": {
|
|
913
|
+
const fp = asStr(ti.notebook_path);
|
|
914
|
+
const src = asStr(ti.new_source);
|
|
915
|
+
if (!fp || !src) return [];
|
|
916
|
+
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), "", src);
|
|
917
|
+
}
|
|
918
|
+
default:
|
|
919
|
+
return [];
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
768
923
|
// src/detectors/finding.ts
|
|
769
924
|
function severityOf(rule, policy, fallback = "block") {
|
|
770
925
|
return policy.rules?.[rule]?.severity ?? fallback;
|
|
@@ -1230,12 +1385,16 @@ var lintSuppression = {
|
|
|
1230
1385
|
|
|
1231
1386
|
// src/detectors/test-skip.ts
|
|
1232
1387
|
var RULE3 = "test-skip";
|
|
1388
|
+
var acc = (names) => `(?:\\s*\\.\\s*(?:${names})(?![\\w$])|\\s*\\[\\s*['"\`](?:${names})['"\`]\\s*\\])`;
|
|
1389
|
+
var JS_RUNNER = "\\b(?:it|test|describe|suite)";
|
|
1390
|
+
var JS_MOD = "concurrent|sequential|shuffle";
|
|
1233
1391
|
var PATTERNS2 = {
|
|
1234
1392
|
js: [
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
{ re:
|
|
1238
|
-
{ re:
|
|
1393
|
+
// The skip/only/todo family on a runner, by dot-or-bracket access, with one optional
|
|
1394
|
+
// vitest concurrency modifier hop: test.skip, test . skip, test['skip'], it.concurrent.skip.
|
|
1395
|
+
{ re: new RegExp(JS_RUNNER + acc(JS_MOD) + "?" + acc("skip|only|todo")), why: "a .skip/.only/.todo marker" },
|
|
1396
|
+
{ re: new RegExp(JS_RUNNER + acc("skipIf|runIf") + "\\s*\\("), why: "a .skipIf()/.runIf() condition (the test runs only when the condition allows)" },
|
|
1397
|
+
{ re: new RegExp(JS_RUNNER + acc("fails|failing") + "\\s*\\("), why: "a .fails/.failing marker (the test now passes by failing)" },
|
|
1239
1398
|
{ re: /\b(?:it|test|describe)\.each\s*\(\s*\[\s*\]\s*\)/, why: "an empty .each table (no case ever runs)" },
|
|
1240
1399
|
{ re: /\b(?:xit|xtest|xdescribe)\s*\(/, why: "an x-prefixed disabled test" },
|
|
1241
1400
|
{ re: /\b(?:fit|ftest|fdescribe)\s*\(/, why: "an f-prefixed focused test (narrows the suite)" },
|
|
@@ -1249,7 +1408,7 @@ var PATTERNS2 = {
|
|
|
1249
1408
|
{ re: /\b(?:it|test|describe|suite)\s*\(\s*(['"`])(?:(?!\1).)*\1\s*,\s*\{[^}]*?\bskip:(?!\s*(?:false|0)\b)/, why: "a { skip: ... } option disabling the test (node:test options form)" },
|
|
1250
1409
|
{ re: /\bskip:\s*(?:true|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
|
|
1251
1410
|
{ re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
|
|
1252
|
-
{ re:
|
|
1411
|
+
{ re: new RegExp("\\b(?:t|ctx|context|this)" + acc("skip") + "\\s*\\("), why: "a runtime t.skip()/this.skip() call" }
|
|
1253
1412
|
],
|
|
1254
1413
|
py: [
|
|
1255
1414
|
// `import pytest as pt` makes the decorator `@pt.mark.skip`; the module alias is free.
|
|
@@ -1306,6 +1465,17 @@ var PATTERNS2 = {
|
|
|
1306
1465
|
{ re: /\bAssert\.Inconclusive\(/, why: "an Assert.Inconclusive() call (the test ends without a verdict)" }
|
|
1307
1466
|
]
|
|
1308
1467
|
};
|
|
1468
|
+
function matchesOutsideString(p, content, lang) {
|
|
1469
|
+
if (p.comment) return p.re.test(content);
|
|
1470
|
+
const re = p.re.global ? p.re : new RegExp(p.re.source, p.re.flags + "g");
|
|
1471
|
+
re.lastIndex = 0;
|
|
1472
|
+
let m;
|
|
1473
|
+
while ((m = re.exec(content)) !== null) {
|
|
1474
|
+
if (!insideStringLiteral(content, m.index, lang)) return true;
|
|
1475
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
1476
|
+
}
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
1309
1479
|
var testSkip = {
|
|
1310
1480
|
id: RULE3,
|
|
1311
1481
|
surface: ["file"],
|
|
@@ -1321,7 +1491,7 @@ var testSkip = {
|
|
|
1321
1491
|
const comment = isCommentLine(l.content.trim(), lang);
|
|
1322
1492
|
for (const p of patterns) {
|
|
1323
1493
|
if (comment && !p.comment) continue;
|
|
1324
|
-
if (p
|
|
1494
|
+
if (matchesOutsideString(p, l.content, lang)) {
|
|
1325
1495
|
out2.push(
|
|
1326
1496
|
makeFinding(RULE3, policy, {
|
|
1327
1497
|
file: c.path,
|
|
@@ -1345,9 +1515,9 @@ import picomatch2 from "picomatch";
|
|
|
1345
1515
|
import ts2 from "typescript";
|
|
1346
1516
|
|
|
1347
1517
|
// src/detectors/repo.ts
|
|
1348
|
-
import { execFileSync as
|
|
1349
|
-
import { readFileSync } from "node:fs";
|
|
1350
|
-
import { join as
|
|
1518
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
1519
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1520
|
+
import { join as join4 } from "node:path";
|
|
1351
1521
|
var cache = /* @__PURE__ */ new Map();
|
|
1352
1522
|
function trackedFiles(ctx) {
|
|
1353
1523
|
if (!ctx) return null;
|
|
@@ -1357,7 +1527,7 @@ function trackedFiles(ctx) {
|
|
|
1357
1527
|
if (hit !== void 0) return hit;
|
|
1358
1528
|
let files = null;
|
|
1359
1529
|
try {
|
|
1360
|
-
files =
|
|
1530
|
+
files = execFileSync4("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1361
1531
|
cwd: ctx.cwd,
|
|
1362
1532
|
encoding: "utf8",
|
|
1363
1533
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -1379,7 +1549,7 @@ function trackedContent(path, ctx) {
|
|
|
1379
1549
|
if (hit !== void 0) return hit;
|
|
1380
1550
|
let src = null;
|
|
1381
1551
|
try {
|
|
1382
|
-
src =
|
|
1552
|
+
src = readFileSync2(join4(ctx.cwd, path), "utf8");
|
|
1383
1553
|
} catch {
|
|
1384
1554
|
src = null;
|
|
1385
1555
|
}
|
|
@@ -1394,7 +1564,7 @@ function branchRefs(ctx) {
|
|
|
1394
1564
|
if (hit !== void 0) return hit;
|
|
1395
1565
|
let refs = null;
|
|
1396
1566
|
try {
|
|
1397
|
-
const out2 =
|
|
1567
|
+
const out2 = execFileSync4("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], {
|
|
1398
1568
|
cwd: ctx.cwd,
|
|
1399
1569
|
encoding: "utf8",
|
|
1400
1570
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -1417,7 +1587,7 @@ function revIsHead(rev, ctx) {
|
|
|
1417
1587
|
if (!ctx?.cwd || rev.startsWith("-")) return null;
|
|
1418
1588
|
const parse3 = (r) => {
|
|
1419
1589
|
try {
|
|
1420
|
-
return
|
|
1590
|
+
return execFileSync4("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
|
|
1421
1591
|
cwd: ctx.cwd,
|
|
1422
1592
|
encoding: "utf8",
|
|
1423
1593
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -1437,7 +1607,7 @@ function defaultBranch(ctx) {
|
|
|
1437
1607
|
if (hit !== void 0) return hit;
|
|
1438
1608
|
let name = null;
|
|
1439
1609
|
try {
|
|
1440
|
-
const ref =
|
|
1610
|
+
const ref = execFileSync4("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
|
|
1441
1611
|
cwd: ctx.cwd,
|
|
1442
1612
|
encoding: "utf8",
|
|
1443
1613
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -2691,20 +2861,20 @@ var ciTampering = {
|
|
|
2691
2861
|
};
|
|
2692
2862
|
|
|
2693
2863
|
// src/detectors/hook-tampering.ts
|
|
2694
|
-
import { existsSync as
|
|
2695
|
-
import { isAbsolute as
|
|
2864
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
|
|
2865
|
+
import { isAbsolute as isAbsolute5, join as join6, relative as relative2 } from "node:path";
|
|
2696
2866
|
|
|
2697
2867
|
// src/wiring.ts
|
|
2698
|
-
import { readFileSync as
|
|
2868
|
+
import { readFileSync as readFileSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
2699
2869
|
import { homedir } from "node:os";
|
|
2700
|
-
import { basename, dirname, isAbsolute, join as
|
|
2870
|
+
import { basename, dirname, isAbsolute as isAbsolute3, join as join5, posix } from "node:path";
|
|
2701
2871
|
import { fileURLToPath } from "node:url";
|
|
2702
2872
|
function shippedVersion() {
|
|
2703
2873
|
try {
|
|
2704
2874
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2705
2875
|
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
2706
2876
|
try {
|
|
2707
|
-
const pkg = JSON.parse(
|
|
2877
|
+
const pkg = JSON.parse(readFileSync3(join5(here, rel), "utf8"));
|
|
2708
2878
|
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
2709
2879
|
} catch {
|
|
2710
2880
|
}
|
|
@@ -2717,10 +2887,12 @@ var TW_VERSION = shippedVersion();
|
|
|
2717
2887
|
var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
|
|
2718
2888
|
var PRE_MATCHER = PRE_TOOLS.join("|");
|
|
2719
2889
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2720
|
-
var
|
|
2721
|
-
var
|
|
2722
|
-
var
|
|
2723
|
-
var
|
|
2890
|
+
var NPX_AUTHORITY = "npx --yes --global --userconfig=/dev/null --globalconfig=/dev/null/npmrc-global --registry=https://registry.npmjs.org/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online";
|
|
2891
|
+
var AUTHORITY_FAIL_CLOSED = " || (echo tamperward: authority failed to start >&2 && exit 2)";
|
|
2892
|
+
var HOOK_CMD = `${NPX_AUTHORITY} tamperward@${TW_VERSION} hook claude${AUTHORITY_FAIL_CLOSED}`;
|
|
2893
|
+
var SWEEP_CMD = `${NPX_AUTHORITY} tamperward@${TW_VERSION} sweep claude${AUTHORITY_FAIL_CLOSED}`;
|
|
2894
|
+
var PRECOMMIT_CMD = `${NPX_AUTHORITY} tamperward@${TW_VERSION} check --staged`;
|
|
2895
|
+
var OURS = /^\s*npx --yes --global --userconfig=\/dev\/null --globalconfig=\/dev\/null\/npmrc-global --registry=https:\/\/registry\.npmjs\.org\/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward(?:@(\S+))? (?:(hook claude|sweep claude) \|\| \(echo tamperward: authority failed to start >&2 && exit 2\)|(check --staged))\s*$/;
|
|
2724
2896
|
var PLAIN_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
2725
2897
|
function compareVersions(a, b) {
|
|
2726
2898
|
if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
|
|
@@ -2734,7 +2906,7 @@ function pinNotBelow(now, floor) {
|
|
|
2734
2906
|
const c = compareVersions(now, floor);
|
|
2735
2907
|
return c !== null && c >= 0;
|
|
2736
2908
|
}
|
|
2737
|
-
var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n*$/;
|
|
2909
|
+
var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes --global --userconfig=\/dev\/null --globalconfig=\/dev\/null\/npmrc-global --registry=https:\/\/registry\.npmjs\.org\/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward(?:@(\S+))? check --staged\n*$/;
|
|
2738
2910
|
function initScriptPin(src) {
|
|
2739
2911
|
const m = src.match(INIT_SCRIPT);
|
|
2740
2912
|
return m ? m[1] ?? "" : null;
|
|
@@ -2772,7 +2944,7 @@ function canonicalPath(path) {
|
|
|
2772
2944
|
}
|
|
2773
2945
|
function resolvesToClaudeSettings(path, env = process.env) {
|
|
2774
2946
|
if (isClaudeSettings(path, env)) return true;
|
|
2775
|
-
return
|
|
2947
|
+
return isAbsolute3(path) && isClaudeSettings(canonicalPath(path), env);
|
|
2776
2948
|
}
|
|
2777
2949
|
|
|
2778
2950
|
// src/detectors/policy-diff.ts
|
|
@@ -2900,8 +3072,8 @@ function policyAddWeakening(after) {
|
|
|
2900
3072
|
}
|
|
2901
3073
|
|
|
2902
3074
|
// src/detectors/hook-wiring.ts
|
|
2903
|
-
import { existsSync } from "node:fs";
|
|
2904
|
-
import { isAbsolute as
|
|
3075
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
3076
|
+
import { isAbsolute as isAbsolute4, resolve as resolve2 } from "node:path";
|
|
2905
3077
|
import { parse as parseYaml } from "yaml";
|
|
2906
3078
|
function stripComment(line) {
|
|
2907
3079
|
let single = false;
|
|
@@ -4015,16 +4187,16 @@ function expandShellPath(t, env = process.env) {
|
|
|
4015
4187
|
}
|
|
4016
4188
|
function hookTests(policy, ctx) {
|
|
4017
4189
|
const cwd = ctx?.cwd;
|
|
4018
|
-
const onDisk = cwd !== void 0 &&
|
|
4190
|
+
const onDisk = cwd !== void 0 && existsSync2(cwd);
|
|
4019
4191
|
const root = onDisk ? canonicalPath(cwd) : cwd;
|
|
4020
4192
|
const memo = /* @__PURE__ */ new Map();
|
|
4021
4193
|
const rel = (t) => {
|
|
4022
4194
|
const hit = memo.get(t);
|
|
4023
4195
|
if (hit !== void 0) return hit;
|
|
4024
4196
|
let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
|
|
4025
|
-
const pathy =
|
|
4026
|
-
if (pathy && (
|
|
4027
|
-
p = canonicalPath(
|
|
4197
|
+
const pathy = isAbsolute4(p) || p.includes("/") && !/^[-:]/.test(p);
|
|
4198
|
+
if (pathy && (isAbsolute4(p) || onDisk)) {
|
|
4199
|
+
p = canonicalPath(isAbsolute4(p) ? p : resolve2(cwd, p));
|
|
4028
4200
|
if (root !== void 0) {
|
|
4029
4201
|
if (p === root) p = ".";
|
|
4030
4202
|
else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
|
|
@@ -4211,7 +4383,6 @@ var declares = (s, event) => eventEntries(s, event) !== null;
|
|
|
4211
4383
|
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout", "statusMessage"]);
|
|
4212
4384
|
var HOOK_TYPES = /* @__PURE__ */ new Set(["command", "prompt", "agent", "http"]);
|
|
4213
4385
|
var SIBLING_KEYS = GATE_KEYS;
|
|
4214
|
-
var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
|
|
4215
4386
|
var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
|
|
4216
4387
|
var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
|
|
4217
4388
|
var ENV_MECHANISM = [
|
|
@@ -4229,10 +4400,11 @@ function analyseGateCommand(cmd, event) {
|
|
|
4229
4400
|
const want = event === "PreToolUse" ? "hook" : "sweep";
|
|
4230
4401
|
const problems = [];
|
|
4231
4402
|
let runs = true;
|
|
4232
|
-
const canonical2 = cmd.match(
|
|
4403
|
+
const canonical2 = cmd.match(OURS);
|
|
4233
4404
|
if (canonical2) {
|
|
4234
|
-
|
|
4235
|
-
|
|
4405
|
+
const kind = canonical2[2];
|
|
4406
|
+
if (kind !== `${want} claude`) {
|
|
4407
|
+
problems.push(`runs \`${kind}\` under ${event}`);
|
|
4236
4408
|
runs = false;
|
|
4237
4409
|
}
|
|
4238
4410
|
if (!PLAIN_SEMVER.test(canonical2[1])) {
|
|
@@ -4582,8 +4754,8 @@ function lefthookBase(c, changes, ctx) {
|
|
|
4582
4754
|
if (!ctx?.cwd) return null;
|
|
4583
4755
|
for (const n of names) {
|
|
4584
4756
|
try {
|
|
4585
|
-
const p =
|
|
4586
|
-
if (
|
|
4757
|
+
const p = join6(ctx.cwd, n);
|
|
4758
|
+
if (existsSync3(p)) return parseDoc(readFileSync4(p, "utf8"));
|
|
4587
4759
|
} catch {
|
|
4588
4760
|
}
|
|
4589
4761
|
}
|
|
@@ -4653,9 +4825,9 @@ function updateIndexHiding(toks, policy) {
|
|
|
4653
4825
|
return null;
|
|
4654
4826
|
}
|
|
4655
4827
|
function repoRelative(path, ctx) {
|
|
4656
|
-
if (!ctx?.cwd || !
|
|
4657
|
-
const rel =
|
|
4658
|
-
return rel && !rel.startsWith("..") && !
|
|
4828
|
+
if (!ctx?.cwd || !isAbsolute5(path)) return path;
|
|
4829
|
+
const rel = relative2(ctx.cwd, path);
|
|
4830
|
+
return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
|
|
4659
4831
|
}
|
|
4660
4832
|
var HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"';
|
|
4661
4833
|
var HUSKY_SHIM_NAMES = /* @__PURE__ */ new Set(["applypatch-msg", "commit-msg", "post-applypatch", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-applypatch", "pre-auto-gc", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg"]);
|
|
@@ -4704,7 +4876,7 @@ function huskyRuntimeWrite(path, content, ctx) {
|
|
|
4704
4876
|
if (!ok) return false;
|
|
4705
4877
|
if (!ctx?.cwd) return false;
|
|
4706
4878
|
const root = path.slice(0, path.length - m[0].length + (m[0].startsWith("/") ? 1 : 0));
|
|
4707
|
-
const entry = inspectRel(
|
|
4879
|
+
const entry = inspectRel(join6(ctx.cwd, root), ".husky/pre-commit");
|
|
4708
4880
|
if (entry.kind !== "file") return false;
|
|
4709
4881
|
const gate = textOf(entry);
|
|
4710
4882
|
if (gate == null) return false;
|
|
@@ -4957,7 +5129,7 @@ var hookTampering = {
|
|
|
4957
5129
|
if (why) {
|
|
4958
5130
|
const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
|
|
4959
5131
|
const file = target ? repoRelative(target, ctx) : void 0;
|
|
4960
|
-
const outside = file !== void 0 && (
|
|
5132
|
+
const outside = file !== void 0 && (isAbsolute5(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
|
|
4961
5133
|
out2.push(
|
|
4962
5134
|
makeFinding(RULE6, policy, {
|
|
4963
5135
|
...file !== void 0 ? { file } : {},
|
|
@@ -6084,8 +6256,8 @@ function hasBlocking(findings) {
|
|
|
6084
6256
|
}
|
|
6085
6257
|
|
|
6086
6258
|
// src/policy-load.ts
|
|
6087
|
-
import { readFileSync as
|
|
6088
|
-
import { isAbsolute as
|
|
6259
|
+
import { readFileSync as readFileSync5, existsSync as existsSync4 } from "node:fs";
|
|
6260
|
+
import { isAbsolute as isAbsolute6, join as join7, posix as posix2 } from "node:path";
|
|
6089
6261
|
import { parse as parse2 } from "yaml";
|
|
6090
6262
|
var PolicyError = class extends Error {
|
|
6091
6263
|
};
|
|
@@ -6102,7 +6274,7 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
|
|
|
6102
6274
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6103
6275
|
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
6104
6276
|
function ledgerInsideRepo(ledger) {
|
|
6105
|
-
if (!ledger.trim() ||
|
|
6277
|
+
if (!ledger.trim() || isAbsolute6(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
6106
6278
|
const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
|
|
6107
6279
|
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
6108
6280
|
}
|
|
@@ -6209,9 +6381,9 @@ function parseOrThrow(src, where) {
|
|
|
6209
6381
|
return parsePolicy(raw, where);
|
|
6210
6382
|
}
|
|
6211
6383
|
function loadPolicy(cwd = process.cwd()) {
|
|
6212
|
-
const path =
|
|
6213
|
-
if (!
|
|
6214
|
-
return parseOrThrow(
|
|
6384
|
+
const path = join7(cwd, POLICY_FILE);
|
|
6385
|
+
if (!existsSync4(path)) return defaultPolicy();
|
|
6386
|
+
return parseOrThrow(readFileSync5(path, "utf8"), POLICY_FILE);
|
|
6215
6387
|
}
|
|
6216
6388
|
function loadPolicyAt(rev, cwd) {
|
|
6217
6389
|
const src = fileAt(rev, POLICY_FILE, { cwd });
|
|
@@ -6220,12 +6392,12 @@ function loadPolicyAt(rev, cwd) {
|
|
|
6220
6392
|
}
|
|
6221
6393
|
|
|
6222
6394
|
// src/signoff.ts
|
|
6223
|
-
import { createHash } from "node:crypto";
|
|
6224
|
-
import { appendFileSync, mkdirSync, readFileSync as
|
|
6225
|
-
import { dirname as dirname2, join as
|
|
6395
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
6396
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "node:fs";
|
|
6397
|
+
import { dirname as dirname2, join as join8 } from "node:path";
|
|
6226
6398
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
6227
6399
|
function fingerprint(rule, file, evidence) {
|
|
6228
|
-
return
|
|
6400
|
+
return createHash2("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
|
|
6229
6401
|
}
|
|
6230
6402
|
var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
|
|
6231
6403
|
function ledgerPath(cwd, policy) {
|
|
@@ -6233,13 +6405,13 @@ function ledgerPath(cwd, policy) {
|
|
|
6233
6405
|
if (!ledgerInsideRepo(rel)) {
|
|
6234
6406
|
throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
|
|
6235
6407
|
}
|
|
6236
|
-
return
|
|
6408
|
+
return join8(cwd, rel);
|
|
6237
6409
|
}
|
|
6238
6410
|
function readLedger(cwd, policy) {
|
|
6239
6411
|
const p = ledgerPath(cwd, policy);
|
|
6240
|
-
if (!
|
|
6412
|
+
if (!existsSync5(p)) return [];
|
|
6241
6413
|
const out2 = [];
|
|
6242
|
-
for (const line of
|
|
6414
|
+
for (const line of readFileSync6(p, "utf8").split("\n")) {
|
|
6243
6415
|
if (!line.trim()) continue;
|
|
6244
6416
|
try {
|
|
6245
6417
|
const e = JSON.parse(line);
|
|
@@ -6279,7 +6451,9 @@ function oobToken(want, oob, head) {
|
|
|
6279
6451
|
}
|
|
6280
6452
|
const [rule, sha2] = [t.slice(0, at), t.slice(at + 1)];
|
|
6281
6453
|
if (rule !== want) continue;
|
|
6282
|
-
|
|
6454
|
+
const normalizedHead = head?.trim().toLowerCase();
|
|
6455
|
+
const normalizedSha = sha2.trim().toLowerCase();
|
|
6456
|
+
if (!head || /^[0-9a-f]+$/.test(normalizedHead) && normalizedSha === normalizedHead) return t;
|
|
6283
6457
|
}
|
|
6284
6458
|
return null;
|
|
6285
6459
|
}
|
|
@@ -6295,8 +6469,8 @@ function applyOobSignoffs(findings, oob, head) {
|
|
|
6295
6469
|
return { findings: remaining, cleared };
|
|
6296
6470
|
}
|
|
6297
6471
|
function oobHeadFromEnv(env = process.env) {
|
|
6298
|
-
const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim();
|
|
6299
|
-
return v
|
|
6472
|
+
const v = (env.TAMPERWARD_OOB_HEAD ?? "").trim().toLowerCase();
|
|
6473
|
+
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(v) ? v : void 0;
|
|
6300
6474
|
}
|
|
6301
6475
|
function oobFromEnv(env = process.env) {
|
|
6302
6476
|
return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim().replace(/^tamperward:allow:/, "")).filter(Boolean);
|
|
@@ -6558,6 +6732,11 @@ function check(opts) {
|
|
|
6558
6732
|
} else if (opts.worktree) {
|
|
6559
6733
|
const p = policy;
|
|
6560
6734
|
changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }, (rel) => isProtected(rel, p)) : diffWorktree({ cwd: opts.cwd });
|
|
6735
|
+
const seen = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
|
|
6736
|
+
for (const rel of hiddenTrackedPaths({ cwd: opts.cwd })) {
|
|
6737
|
+
if (seen.has(rel) || !isProtected(rel, p)) continue;
|
|
6738
|
+
changes.push(...synthFileChange(rel, fileAt("HEAD", rel, { cwd: opts.cwd }), fileOnDisk(rel, { cwd: opts.cwd })));
|
|
6739
|
+
}
|
|
6561
6740
|
layer = opts.ciLayer ? "ci" : "local";
|
|
6562
6741
|
view = "worktree";
|
|
6563
6742
|
} else if (opts.diff) {
|
|
@@ -6609,106 +6788,8 @@ function runCheck(opts) {
|
|
|
6609
6788
|
}
|
|
6610
6789
|
|
|
6611
6790
|
// src/cli/hook.ts
|
|
6612
|
-
import { readFileSync as
|
|
6613
|
-
import { createHash as
|
|
6614
|
-
|
|
6615
|
-
// src/adapters/claude/changes.ts
|
|
6616
|
-
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
6617
|
-
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
6618
|
-
import { tmpdir } from "node:os";
|
|
6619
|
-
import { isAbsolute as isAbsolute5, join as join7, relative as relative2, resolve as resolve2 } from "node:path";
|
|
6620
|
-
function asStr(v) {
|
|
6621
|
-
return typeof v === "string" ? v : "";
|
|
6622
|
-
}
|
|
6623
|
-
function readDisk(path) {
|
|
6624
|
-
return textOf(inspectResolved(path));
|
|
6625
|
-
}
|
|
6626
|
-
function abs(path, cwd) {
|
|
6627
|
-
return resolve2(cwd, path);
|
|
6628
|
-
}
|
|
6629
|
-
function relForDisplay(path, cwd) {
|
|
6630
|
-
const rel = relative2(cwd, path);
|
|
6631
|
-
return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
|
|
6632
|
-
}
|
|
6633
|
-
function applyEdit(content, oldStr, newStr) {
|
|
6634
|
-
if (content === null) return newStr;
|
|
6635
|
-
if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
|
|
6636
|
-
return content.replace(oldStr, () => newStr);
|
|
6637
|
-
}
|
|
6638
|
-
function synthFileChange(displayPath, before, after) {
|
|
6639
|
-
if (before === after) return [];
|
|
6640
|
-
let op;
|
|
6641
|
-
if (before === null) op = "add";
|
|
6642
|
-
else if (after === null) op = "delete";
|
|
6643
|
-
else op = "modify";
|
|
6644
|
-
const dir = mkdtempSync(join7(tmpdir(), "hf-"));
|
|
6645
|
-
let raw = "";
|
|
6646
|
-
try {
|
|
6647
|
-
const a = join7(dir, "a");
|
|
6648
|
-
const b = join7(dir, "b");
|
|
6649
|
-
writeFileSync(a, before ?? "");
|
|
6650
|
-
writeFileSync(b, after ?? "");
|
|
6651
|
-
try {
|
|
6652
|
-
raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
|
|
6653
|
-
encoding: "utf8",
|
|
6654
|
-
maxBuffer: 256 * 1024 * 1024
|
|
6655
|
-
});
|
|
6656
|
-
} catch (e) {
|
|
6657
|
-
const err2 = e;
|
|
6658
|
-
if (err2.status !== 1) {
|
|
6659
|
-
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
|
|
6660
|
-
}
|
|
6661
|
-
raw = err2.stdout ? String(err2.stdout) : "";
|
|
6662
|
-
}
|
|
6663
|
-
} finally {
|
|
6664
|
-
rmSync(dir, { recursive: true, force: true });
|
|
6665
|
-
}
|
|
6666
|
-
const first = parseDiff(raw)[0];
|
|
6667
|
-
const hunks = first && first.kind === "file" ? first.hunks : [];
|
|
6668
|
-
return [{ kind: "file", path: displayPath, oldPath: null, op, before, after, binary: false, hunks }];
|
|
6669
|
-
}
|
|
6670
|
-
function changesFromClaudeHook(input, cwd) {
|
|
6671
|
-
const ti = input.tool_input ?? {};
|
|
6672
|
-
switch (input.tool_name) {
|
|
6673
|
-
case "Bash": {
|
|
6674
|
-
const raw = asStr(ti.command);
|
|
6675
|
-
return raw ? [{ kind: "command", raw, argv: raw.split(/\s+/) }] : [];
|
|
6676
|
-
}
|
|
6677
|
-
case "Write": {
|
|
6678
|
-
const fp = asStr(ti.file_path);
|
|
6679
|
-
if (!fp) return [];
|
|
6680
|
-
const before = readDisk(abs(fp, cwd));
|
|
6681
|
-
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, asStr(ti.content));
|
|
6682
|
-
}
|
|
6683
|
-
case "Edit": {
|
|
6684
|
-
const fp = asStr(ti.file_path);
|
|
6685
|
-
if (!fp) return [];
|
|
6686
|
-
const before = readDisk(abs(fp, cwd));
|
|
6687
|
-
const after = applyEdit(before, asStr(ti.old_string), asStr(ti.new_string));
|
|
6688
|
-
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
|
|
6689
|
-
}
|
|
6690
|
-
case "MultiEdit": {
|
|
6691
|
-
const fp = asStr(ti.file_path);
|
|
6692
|
-
if (!fp) return [];
|
|
6693
|
-
const before = readDisk(abs(fp, cwd));
|
|
6694
|
-
let after = before;
|
|
6695
|
-
const edits = Array.isArray(ti.edits) ? ti.edits : [];
|
|
6696
|
-
for (const raw of edits) {
|
|
6697
|
-
const ed = raw;
|
|
6698
|
-
after = applyEdit(after, ed.old_string ?? "", ed.new_string ?? "");
|
|
6699
|
-
}
|
|
6700
|
-
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
|
|
6701
|
-
}
|
|
6702
|
-
case "NotebookEdit": {
|
|
6703
|
-
const fp = asStr(ti.notebook_path);
|
|
6704
|
-
const src = asStr(ti.new_source);
|
|
6705
|
-
if (!fp || !src) return [];
|
|
6706
|
-
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), "", src);
|
|
6707
|
-
}
|
|
6708
|
-
default:
|
|
6709
|
-
return [];
|
|
6710
|
-
}
|
|
6711
|
-
}
|
|
6791
|
+
import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync8 } from "node:fs";
|
|
6792
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
6712
6793
|
|
|
6713
6794
|
// src/adapters/claude/deny.ts
|
|
6714
6795
|
function formatDenial(blocks) {
|
|
@@ -6730,22 +6811,22 @@ function formatDenial(blocks) {
|
|
|
6730
6811
|
}
|
|
6731
6812
|
|
|
6732
6813
|
// src/session.ts
|
|
6733
|
-
import { existsSync as
|
|
6734
|
-
import { dirname as dirname3, join as
|
|
6814
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
|
|
6815
|
+
import { dirname as dirname3, join as join9 } from "node:path";
|
|
6735
6816
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
6736
6817
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
6737
6818
|
function baselinePath(cwd, sessionId) {
|
|
6738
6819
|
const gd = gitDir(cwd);
|
|
6739
6820
|
if (!gd) return null;
|
|
6740
|
-
return
|
|
6821
|
+
return join9(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
|
|
6741
6822
|
}
|
|
6742
6823
|
function turnBaseline(cwd, sessionId) {
|
|
6743
6824
|
if (!sessionId) return null;
|
|
6744
6825
|
try {
|
|
6745
6826
|
const p = baselinePath(cwd, sessionId);
|
|
6746
6827
|
if (!p) return null;
|
|
6747
|
-
if (
|
|
6748
|
-
const v =
|
|
6828
|
+
if (existsSync6(p)) {
|
|
6829
|
+
const v = readFileSync7(p, "utf8").trim();
|
|
6749
6830
|
if (SHA.test(v)) return v;
|
|
6750
6831
|
}
|
|
6751
6832
|
const head = headSha(cwd);
|
|
@@ -6770,21 +6851,21 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
6770
6851
|
}
|
|
6771
6852
|
|
|
6772
6853
|
// src/effect.ts
|
|
6773
|
-
import { createHash as
|
|
6774
|
-
import { existsSync as
|
|
6775
|
-
import { isAbsolute as
|
|
6776
|
-
import { execFileSync as
|
|
6854
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
6855
|
+
import { existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
6856
|
+
import { isAbsolute as isAbsolute7, join as join10 } from "node:path";
|
|
6857
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
6777
6858
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
6778
6859
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
6779
6860
|
function gitDir2(cwd) {
|
|
6780
6861
|
try {
|
|
6781
|
-
const d =
|
|
6862
|
+
const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
|
|
6782
6863
|
cwd,
|
|
6783
6864
|
encoding: "utf8",
|
|
6784
6865
|
stdio: ["ignore", "pipe", "pipe"]
|
|
6785
6866
|
}).trim();
|
|
6786
6867
|
if (!d) return null;
|
|
6787
|
-
return
|
|
6868
|
+
return isAbsolute7(d) ? d : join10(cwd, d);
|
|
6788
6869
|
} catch {
|
|
6789
6870
|
return null;
|
|
6790
6871
|
}
|
|
@@ -6793,8 +6874,8 @@ function statePath(cwd, sessionId, kind) {
|
|
|
6793
6874
|
if (!sessionId) return null;
|
|
6794
6875
|
const gd = gitDir2(cwd);
|
|
6795
6876
|
if (!gd) return null;
|
|
6796
|
-
const dir =
|
|
6797
|
-
return
|
|
6877
|
+
const dir = join10(gd, "tamperward");
|
|
6878
|
+
return join10(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
6798
6879
|
}
|
|
6799
6880
|
function ptreePath(cwd, sessionId) {
|
|
6800
6881
|
return statePath(cwd, sessionId, "ptree");
|
|
@@ -6802,7 +6883,7 @@ function ptreePath(cwd, sessionId) {
|
|
|
6802
6883
|
function turnTreePath(cwd, sessionId) {
|
|
6803
6884
|
return statePath(cwd, sessionId, "turntree");
|
|
6804
6885
|
}
|
|
6805
|
-
var sha = (buf) =>
|
|
6886
|
+
var sha = (buf) => createHash3("sha256").update(buf).digest("hex").slice(0, 16);
|
|
6806
6887
|
function contentHash(content) {
|
|
6807
6888
|
return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
|
|
6808
6889
|
}
|
|
@@ -6823,7 +6904,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
6823
6904
|
} catch {
|
|
6824
6905
|
}
|
|
6825
6906
|
const walk2 = (rel) => {
|
|
6826
|
-
const abs2 = rel ?
|
|
6907
|
+
const abs2 = rel ? join10(cwd, rel) : cwd;
|
|
6827
6908
|
let names;
|
|
6828
6909
|
try {
|
|
6829
6910
|
names = readdirSync(abs2);
|
|
@@ -6836,7 +6917,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
6836
6917
|
if (pruned.has(r)) continue;
|
|
6837
6918
|
let st;
|
|
6838
6919
|
try {
|
|
6839
|
-
st = lstatSync2(
|
|
6920
|
+
st = lstatSync2(join10(cwd, r));
|
|
6840
6921
|
} catch {
|
|
6841
6922
|
continue;
|
|
6842
6923
|
}
|
|
@@ -6863,9 +6944,9 @@ function driftBetween(expected, current) {
|
|
|
6863
6944
|
return { changed, deleted, added };
|
|
6864
6945
|
}
|
|
6865
6946
|
function loadTree(p) {
|
|
6866
|
-
if (!p || !
|
|
6947
|
+
if (!p || !existsSync7(p)) return null;
|
|
6867
6948
|
try {
|
|
6868
|
-
return JSON.parse(
|
|
6949
|
+
return JSON.parse(readFileSync8(p, "utf8"));
|
|
6869
6950
|
} catch {
|
|
6870
6951
|
return null;
|
|
6871
6952
|
}
|
|
@@ -6873,7 +6954,7 @@ function loadTree(p) {
|
|
|
6873
6954
|
function saveTree(p, tree) {
|
|
6874
6955
|
if (!p) return;
|
|
6875
6956
|
try {
|
|
6876
|
-
mkdirSync3(
|
|
6957
|
+
mkdirSync3(join10(p, ".."), { recursive: true });
|
|
6877
6958
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
6878
6959
|
writeFileSync3(tmp, JSON.stringify(tree));
|
|
6879
6960
|
renameSync(tmp, p);
|
|
@@ -6905,19 +6986,19 @@ function dropTurnTree(cwd, sessionId) {
|
|
|
6905
6986
|
|
|
6906
6987
|
// src/cli/watch.ts
|
|
6907
6988
|
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync, watch } from "node:fs";
|
|
6908
|
-
import { join as
|
|
6909
|
-
import { createHash as
|
|
6989
|
+
import { join as join11 } from "node:path";
|
|
6990
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
6910
6991
|
var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
6911
6992
|
function snap(abs2) {
|
|
6912
6993
|
const e = inspectPath(abs2);
|
|
6913
6994
|
if (e.kind === "absent" || e.kind === "unreadable") return { mode: null, size: null, hash: null };
|
|
6914
6995
|
if (e.kind !== "file" || e.content == null) return { mode: e.mode, size: null, hash: null };
|
|
6915
|
-
const hash =
|
|
6996
|
+
const hash = createHash4("sha256").update(e.content).digest("hex").slice(0, 16);
|
|
6916
6997
|
return { mode: e.mode, size: e.size, hash };
|
|
6917
6998
|
}
|
|
6918
6999
|
function defaultEventLog(cwd) {
|
|
6919
7000
|
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
6920
|
-
return
|
|
7001
|
+
return join11(gitDir(cwd) ?? join11(cwd, ".git"), "tamperward", "fsevents.jsonl");
|
|
6921
7002
|
}
|
|
6922
7003
|
function watchTree(dir, cb) {
|
|
6923
7004
|
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
@@ -6934,12 +7015,12 @@ function watchTree(dir, cb) {
|
|
|
6934
7015
|
if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
|
|
6935
7016
|
let w;
|
|
6936
7017
|
try {
|
|
6937
|
-
w = watch(rel ?
|
|
7018
|
+
w = watch(rel ? join11(dir, rel) : dir, (kind, fname) => {
|
|
6938
7019
|
if (!fname) return;
|
|
6939
7020
|
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
6940
7021
|
cb(kind, child);
|
|
6941
7022
|
try {
|
|
6942
|
-
if (statSync(
|
|
7023
|
+
if (statSync(join11(dir, child)).isDirectory()) addDir(child);
|
|
6943
7024
|
} catch {
|
|
6944
7025
|
}
|
|
6945
7026
|
});
|
|
@@ -6952,7 +7033,7 @@ function watchTree(dir, cb) {
|
|
|
6952
7033
|
addDir(rel);
|
|
6953
7034
|
let names = [];
|
|
6954
7035
|
try {
|
|
6955
|
-
names = readdirSync2(rel ?
|
|
7036
|
+
names = readdirSync2(rel ? join11(dir, rel) : dir);
|
|
6956
7037
|
} catch {
|
|
6957
7038
|
return;
|
|
6958
7039
|
}
|
|
@@ -6960,7 +7041,7 @@ function watchTree(dir, cb) {
|
|
|
6960
7041
|
const child = rel ? `${rel}/${name}` : name;
|
|
6961
7042
|
if (SKIP2.test(child + "/")) continue;
|
|
6962
7043
|
try {
|
|
6963
|
-
if (statSync(
|
|
7044
|
+
if (statSync(join11(dir, child)).isDirectory()) walk2(child);
|
|
6964
7045
|
} catch {
|
|
6965
7046
|
}
|
|
6966
7047
|
}
|
|
@@ -6972,13 +7053,13 @@ function watchTree(dir, cb) {
|
|
|
6972
7053
|
}
|
|
6973
7054
|
function startWatcher(dir, log, policy) {
|
|
6974
7055
|
try {
|
|
6975
|
-
mkdirSync4(
|
|
7056
|
+
mkdirSync4(join11(log, ".."), { recursive: true });
|
|
6976
7057
|
} catch {
|
|
6977
7058
|
}
|
|
6978
7059
|
const last = /* @__PURE__ */ new Map();
|
|
6979
7060
|
return watchTree(dir, (kind, rel) => {
|
|
6980
7061
|
if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
|
|
6981
|
-
const s = snap(
|
|
7062
|
+
const s = snap(join11(dir, rel));
|
|
6982
7063
|
const key2 = `${s.hash}:${s.mode}`;
|
|
6983
7064
|
if (last.get(rel) === key2) return;
|
|
6984
7065
|
last.set(rel, key2);
|
|
@@ -7005,7 +7086,7 @@ function runWatch(args) {
|
|
|
7005
7086
|
}
|
|
7006
7087
|
|
|
7007
7088
|
// src/detectors/fs-events.ts
|
|
7008
|
-
import { readFileSync as
|
|
7089
|
+
import { readFileSync as readFileSync10, statSync as statSync2 } from "node:fs";
|
|
7009
7090
|
var RULE11 = "transient-protected-mutation";
|
|
7010
7091
|
function readEvents(log, offset) {
|
|
7011
7092
|
let size = 0;
|
|
@@ -7015,7 +7096,7 @@ function readEvents(log, offset) {
|
|
|
7015
7096
|
return { events: [], newOffset: offset };
|
|
7016
7097
|
}
|
|
7017
7098
|
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
7018
|
-
const raw =
|
|
7099
|
+
const raw = readFileSync10(log, "utf8").slice(offset);
|
|
7019
7100
|
const events = [];
|
|
7020
7101
|
for (const line of raw.split("\n")) {
|
|
7021
7102
|
if (!line.trim()) continue;
|
|
@@ -7068,7 +7149,7 @@ var HookInputError = class extends Error {
|
|
|
7068
7149
|
};
|
|
7069
7150
|
function readStdin() {
|
|
7070
7151
|
try {
|
|
7071
|
-
return
|
|
7152
|
+
return readFileSync11(0, "utf8");
|
|
7072
7153
|
} catch (e) {
|
|
7073
7154
|
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
7074
7155
|
}
|
|
@@ -7284,7 +7365,7 @@ function sanctionPredictedWrites(cwd, sessionId, policy, changes) {
|
|
|
7284
7365
|
let dirty = false;
|
|
7285
7366
|
for (const c of changes) {
|
|
7286
7367
|
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy)) continue;
|
|
7287
|
-
const hash =
|
|
7368
|
+
const hash = createHash5("sha256").update(c.after).digest("hex").slice(0, 16);
|
|
7288
7369
|
const prev = tree[c.path];
|
|
7289
7370
|
tree[c.path] = {
|
|
7290
7371
|
hash,
|
|
@@ -7321,10 +7402,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
7321
7402
|
} };
|
|
7322
7403
|
const log = defaultEventLog(cwd);
|
|
7323
7404
|
const cp = cursorPath(cwd, sessionId);
|
|
7324
|
-
if (!
|
|
7405
|
+
if (!existsSync8(log)) return none;
|
|
7325
7406
|
let offset = 0;
|
|
7326
|
-
if (cp &&
|
|
7327
|
-
const n = Number(
|
|
7407
|
+
if (cp && existsSync8(cp)) {
|
|
7408
|
+
const n = Number(readFileSync11(cp, "utf8"));
|
|
7328
7409
|
offset = Number.isFinite(n) ? n : 0;
|
|
7329
7410
|
}
|
|
7330
7411
|
const { events, newOffset } = readEvents(log, offset);
|
|
@@ -7475,15 +7556,15 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
7475
7556
|
}
|
|
7476
7557
|
|
|
7477
7558
|
// src/cli/init.ts
|
|
7478
|
-
import { execFileSync as
|
|
7479
|
-
import { createHash as
|
|
7480
|
-
import { existsSync as
|
|
7481
|
-
import { basename as basename2, dirname as dirname4, isAbsolute as
|
|
7559
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
7560
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
7561
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
|
|
7562
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join12, relative as relative3, resolve as resolve3 } from "node:path";
|
|
7482
7563
|
var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
7483
7564
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7484
7565
|
function gitConfig(cwd, key2) {
|
|
7485
7566
|
try {
|
|
7486
|
-
return
|
|
7567
|
+
return execFileSync6("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
7487
7568
|
} catch {
|
|
7488
7569
|
return null;
|
|
7489
7570
|
}
|
|
@@ -7491,6 +7572,8 @@ function gitConfig(cwd, key2) {
|
|
|
7491
7572
|
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
7492
7573
|
var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
|
|
7493
7574
|
var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
|
|
7575
|
+
var LEGACY_OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
7576
|
+
var PRE_GLOBAL_OURS = /^\s*npx --yes --registry=https:\/\/registry\.npmjs\.org\/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward(?:@(\S+))? (hook claude|sweep claude|check --staged)\s*$/;
|
|
7494
7577
|
var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
|
|
7495
7578
|
var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
|
|
7496
7579
|
function coveredBy(existing, critical) {
|
|
@@ -7535,12 +7618,16 @@ version: 1
|
|
|
7535
7618
|
# ignore: [] # visible blind spots \u2014 the count is always reported
|
|
7536
7619
|
`;
|
|
7537
7620
|
function pinOf(command) {
|
|
7538
|
-
const m = command
|
|
7621
|
+
const m = ours(command);
|
|
7539
7622
|
return m ? m[1] ?? "" : null;
|
|
7540
7623
|
}
|
|
7624
|
+
function ours(command) {
|
|
7625
|
+
const previous = command.replace(" --global", "").replace(" || (echo tamperward: authority failed to start >&2 && exit 2)", "");
|
|
7626
|
+
return command.match(OURS) ?? previous.match(PRE_GLOBAL_OURS) ?? command.match(LEGACY_OURS);
|
|
7627
|
+
}
|
|
7541
7628
|
function stalePin(command) {
|
|
7542
7629
|
const pin = pinOf(command);
|
|
7543
|
-
return pin !== null && pin !== TW_VERSION;
|
|
7630
|
+
return pin !== null && (pin !== TW_VERSION || !OURS.test(command));
|
|
7544
7631
|
}
|
|
7545
7632
|
var WORKFLOW_CONTENT = `name: tamperward
|
|
7546
7633
|
|
|
@@ -7564,22 +7651,20 @@ jobs:
|
|
|
7564
7651
|
tamperward:
|
|
7565
7652
|
runs-on: ubuntu-latest
|
|
7566
7653
|
timeout-minutes: 10
|
|
7567
|
-
# The gate is fetched from the registry AT GATE TIME, from inside the
|
|
7568
|
-
# checked-out pull request \u2014 and \`npm\` reads \`.npmrc\` from the working
|
|
7569
|
-
# directory. A PR that commits one line, \`registry=https://\u2026\`, chooses where
|
|
7570
|
-
# the authority judging it is downloaded from; the version pin below pins the
|
|
7571
|
-
# STRING, not the SOURCE. An environment variable outranks a project
|
|
7572
|
-
# \`.npmrc\` in npm's config precedence, so this is what makes the pin mean
|
|
7573
|
-
# something.
|
|
7574
|
-
env:
|
|
7575
|
-
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
|
7576
7654
|
steps:
|
|
7577
|
-
- uses: actions/checkout@v5
|
|
7578
|
-
with:
|
|
7579
|
-
fetch-depth: 0 # the range diff needs both endpoints
|
|
7580
7655
|
- uses: actions/setup-node@v6
|
|
7581
7656
|
with:
|
|
7582
7657
|
node-version: 22
|
|
7658
|
+
# Install the authority before candidate files exist in the job workspace.
|
|
7659
|
+
# npm therefore cannot discover a pull-request .npmrc, and every security-
|
|
7660
|
+
# relevant npm setting is fixed on the command line. Later steps invoke the
|
|
7661
|
+
# installed binary directly: npm/npx never starts from candidate-controlled cwd.
|
|
7662
|
+
- name: Install Tamperward authority
|
|
7663
|
+
working-directory: \${{ runner.temp }}
|
|
7664
|
+
run: npm install --global --registry=https://registry.npmjs.org/ --node-options=' ' --script-shell= --ignore-scripts --offline=false --prefer-online tamperward@${TW_VERSION}
|
|
7665
|
+
- uses: actions/checkout@v5
|
|
7666
|
+
with:
|
|
7667
|
+
fetch-depth: 0 # the range diff needs both endpoints
|
|
7583
7668
|
- name: Resolve out-of-band sign-off from PR labels
|
|
7584
7669
|
id: oob
|
|
7585
7670
|
env:
|
|
@@ -7594,7 +7679,7 @@ jobs:
|
|
|
7594
7679
|
# across pushes, so an unbound one would clear every later finding on
|
|
7595
7680
|
# the same PR. Labels must read tamperward:allow:<rule>@<head-sha>.
|
|
7596
7681
|
TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
|
|
7597
|
-
run:
|
|
7682
|
+
run: tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
|
|
7598
7683
|
# Diff-time detection is spelling-dependent by nature; pristine
|
|
7599
7684
|
# re-execution is not, and it was the layer nothing got past in the
|
|
7600
7685
|
# taskbench sweeps. Shipping a CI template WITHOUT it meant every
|
|
@@ -7612,12 +7697,12 @@ jobs:
|
|
|
7612
7697
|
# stays red whatever the labels say.
|
|
7613
7698
|
TAMPERWARD_OOB_SIGNOFF: \${{ steps.oob.outputs.rules }}
|
|
7614
7699
|
TAMPERWARD_OOB_HEAD: \${{ github.event.pull_request.head.sha }}
|
|
7615
|
-
run:
|
|
7700
|
+
run: tamperward verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
|
|
7616
7701
|
`;
|
|
7617
7702
|
var WORKFLOW_MARK = "# tamperward:generated";
|
|
7618
7703
|
var WORKFLOW_MARK_RE = /^# tamperward:generated v(\S+) sha256:([0-9a-f]+)\n/m;
|
|
7619
7704
|
function bodyHash(body) {
|
|
7620
|
-
return
|
|
7705
|
+
return createHash6("sha256").update(body).digest("hex").slice(0, 16);
|
|
7621
7706
|
}
|
|
7622
7707
|
function workflowFile(body) {
|
|
7623
7708
|
return `${WORKFLOW_MARK} v${TW_VERSION} sha256:${bodyHash(body)}
|
|
@@ -7654,8 +7739,8 @@ function hooksShapeError(hooks) {
|
|
|
7654
7739
|
return null;
|
|
7655
7740
|
}
|
|
7656
7741
|
function planPolicy(cwd) {
|
|
7657
|
-
const path =
|
|
7658
|
-
if (
|
|
7742
|
+
const path = join12(cwd, POLICY_FILE);
|
|
7743
|
+
if (existsSync9(path)) {
|
|
7659
7744
|
try {
|
|
7660
7745
|
loadPolicy(cwd);
|
|
7661
7746
|
} catch (e) {
|
|
@@ -7673,12 +7758,12 @@ function planPolicy(cwd) {
|
|
|
7673
7758
|
}
|
|
7674
7759
|
function planClaudeHooks(cwd) {
|
|
7675
7760
|
const rel = ".claude/settings.json";
|
|
7676
|
-
const path =
|
|
7761
|
+
const path = join12(cwd, rel);
|
|
7677
7762
|
let settings = {};
|
|
7678
|
-
if (
|
|
7763
|
+
if (existsSync9(path)) {
|
|
7679
7764
|
let parsed;
|
|
7680
7765
|
try {
|
|
7681
|
-
parsed = JSON.parse(
|
|
7766
|
+
parsed = JSON.parse(readFileSync12(path, "utf8"));
|
|
7682
7767
|
} catch {
|
|
7683
7768
|
return {
|
|
7684
7769
|
item: "agent",
|
|
@@ -7713,7 +7798,7 @@ function planClaudeHooks(cwd) {
|
|
|
7713
7798
|
return {
|
|
7714
7799
|
item: "agent",
|
|
7715
7800
|
path: rel,
|
|
7716
|
-
status:
|
|
7801
|
+
status: existsSync9(path) ? "update" : "create",
|
|
7717
7802
|
detail: [
|
|
7718
7803
|
needPre && "wire PreToolUse deny",
|
|
7719
7804
|
needStop && "wire Stop sweep",
|
|
@@ -7723,7 +7808,7 @@ function planClaudeHooks(cwd) {
|
|
|
7723
7808
|
].filter(Boolean).join(" + "),
|
|
7724
7809
|
apply: () => {
|
|
7725
7810
|
for (const h of repin) {
|
|
7726
|
-
const kind = String(h.command ?? "")
|
|
7811
|
+
const kind = ours(String(h.command ?? ""))?.[2];
|
|
7727
7812
|
h.command = kind === "hook claude" ? HOOK_CMD : kind === "sweep claude" ? SWEEP_CMD : h.command;
|
|
7728
7813
|
}
|
|
7729
7814
|
if (needPre) {
|
|
@@ -7770,15 +7855,15 @@ var MANAGED_HOOKS = [
|
|
|
7770
7855
|
];
|
|
7771
7856
|
function hooksDir(cwd) {
|
|
7772
7857
|
try {
|
|
7773
|
-
const out2 =
|
|
7858
|
+
const out2 = execFileSync6("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
7774
7859
|
if (out2) return resolve3(cwd, out2);
|
|
7775
7860
|
} catch {
|
|
7776
7861
|
}
|
|
7777
|
-
return
|
|
7862
|
+
return existsSync9(join12(cwd, ".git")) ? join12(cwd, ".git", "hooks") : null;
|
|
7778
7863
|
}
|
|
7779
7864
|
function display(cwd, path) {
|
|
7780
7865
|
const rel = relative3(cwd, path);
|
|
7781
|
-
return rel && !rel.startsWith("..") && !
|
|
7866
|
+
return rel && !rel.startsWith("..") && !isAbsolute8(rel) ? rel : path;
|
|
7782
7867
|
}
|
|
7783
7868
|
function unconditionalExitAt(lines) {
|
|
7784
7869
|
let depth = 0;
|
|
@@ -7801,30 +7886,30 @@ function planPreCommit(cwd) {
|
|
|
7801
7886
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
7802
7887
|
let path;
|
|
7803
7888
|
let note;
|
|
7804
|
-
if (
|
|
7805
|
-
path =
|
|
7889
|
+
if (existsSync9(join12(cwd, ".husky"))) {
|
|
7890
|
+
path = join12(cwd, ".husky", "pre-commit");
|
|
7806
7891
|
note = "husky";
|
|
7807
7892
|
} else {
|
|
7808
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) =>
|
|
7893
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync9(join12(cwd, c))));
|
|
7809
7894
|
if (managed) {
|
|
7810
7895
|
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}`);
|
|
7811
7896
|
}
|
|
7812
7897
|
const dir = hooksDir(cwd);
|
|
7813
7898
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
7814
7899
|
if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
|
|
7815
|
-
path =
|
|
7900
|
+
path = join12(dirname4(dir), "pre-commit");
|
|
7816
7901
|
note = "husky";
|
|
7817
7902
|
} else {
|
|
7818
|
-
path =
|
|
7903
|
+
path = join12(dir, "pre-commit");
|
|
7819
7904
|
const rel2 = display(cwd, dir);
|
|
7820
|
-
note = rel2 ===
|
|
7905
|
+
note = rel2 === join12(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
|
|
7821
7906
|
}
|
|
7822
7907
|
}
|
|
7823
7908
|
const rel = display(cwd, path);
|
|
7824
|
-
if (
|
|
7909
|
+
if (existsSync9(path) && !statSync3(path).isFile()) {
|
|
7825
7910
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
7826
7911
|
}
|
|
7827
|
-
const existing =
|
|
7912
|
+
const existing = existsSync9(path) ? readFileSync12(path, "utf8") : null;
|
|
7828
7913
|
if (existing === null) {
|
|
7829
7914
|
return {
|
|
7830
7915
|
item: "pre-commit",
|
|
@@ -7868,8 +7953,8 @@ ${block.join("\n")}
|
|
|
7868
7953
|
};
|
|
7869
7954
|
}
|
|
7870
7955
|
if (at !== -1) {
|
|
7871
|
-
const
|
|
7872
|
-
if (!
|
|
7956
|
+
const ours2 = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
|
|
7957
|
+
if (!ours2) {
|
|
7873
7958
|
return {
|
|
7874
7959
|
item: "pre-commit",
|
|
7875
7960
|
path: rel,
|
|
@@ -7913,10 +7998,10 @@ ${block.join("\n")}
|
|
|
7913
7998
|
function planCodeowners(cwd) {
|
|
7914
7999
|
const rel = ".github/CODEOWNERS";
|
|
7915
8000
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
7916
|
-
(r) =>
|
|
8001
|
+
(r) => existsSync9(join12(cwd, r))
|
|
7917
8002
|
);
|
|
7918
|
-
const path =
|
|
7919
|
-
const existing = existingRel ?
|
|
8003
|
+
const path = join12(cwd, existingRel ?? rel);
|
|
8004
|
+
const existing = existingRel ? readFileSync12(path, "utf8") : null;
|
|
7920
8005
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
7921
8006
|
if (missing.length === 0) {
|
|
7922
8007
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
@@ -7949,12 +8034,12 @@ ${CODEOWNERS_MARK}
|
|
|
7949
8034
|
}
|
|
7950
8035
|
function planWorkflow(cwd, force) {
|
|
7951
8036
|
const rel = ".github/workflows/tamperward.yml";
|
|
7952
|
-
const path =
|
|
8037
|
+
const path = join12(cwd, rel);
|
|
7953
8038
|
const write = () => {
|
|
7954
8039
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
7955
8040
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
7956
8041
|
};
|
|
7957
|
-
if (!
|
|
8042
|
+
if (!existsSync9(path)) {
|
|
7958
8043
|
return {
|
|
7959
8044
|
item: "ci",
|
|
7960
8045
|
path: rel,
|
|
@@ -7965,7 +8050,7 @@ function planWorkflow(cwd, force) {
|
|
|
7965
8050
|
}
|
|
7966
8051
|
let src;
|
|
7967
8052
|
try {
|
|
7968
|
-
src =
|
|
8053
|
+
src = readFileSync12(path, "utf8");
|
|
7969
8054
|
} catch {
|
|
7970
8055
|
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
7971
8056
|
}
|
|
@@ -8068,75 +8153,29 @@ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as
|
|
|
8068
8153
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
8069
8154
|
import { dirname as dirname5, join as join14, resolve as resolve4 } from "node:path";
|
|
8070
8155
|
|
|
8071
|
-
// src/git/trusted.ts
|
|
8072
|
-
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
8073
|
-
import { createHash as createHash6 } from "node:crypto";
|
|
8074
|
-
import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
|
|
8075
|
-
import { isAbsolute as isAbsolute8, join as join12 } from "node:path";
|
|
8076
|
-
function trustedGitEnv() {
|
|
8077
|
-
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
8078
|
-
}
|
|
8079
|
-
function gitDirOf(cwd) {
|
|
8080
|
-
try {
|
|
8081
|
-
const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
|
|
8082
|
-
cwd,
|
|
8083
|
-
encoding: "utf8",
|
|
8084
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
8085
|
-
env: trustedGitEnv()
|
|
8086
|
-
}).trim();
|
|
8087
|
-
if (!d) return null;
|
|
8088
|
-
return isAbsolute8(d) ? d : join12(cwd, d);
|
|
8089
|
-
} catch {
|
|
8090
|
-
return null;
|
|
8091
|
-
}
|
|
8092
|
-
}
|
|
8093
|
-
function objectRewriteState(cwd) {
|
|
8094
|
-
const parts = [];
|
|
8095
|
-
try {
|
|
8096
|
-
parts.push(
|
|
8097
|
-
"replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
8098
|
-
cwd,
|
|
8099
|
-
encoding: "utf8",
|
|
8100
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
8101
|
-
env: trustedGitEnv()
|
|
8102
|
-
}).trim()
|
|
8103
|
-
);
|
|
8104
|
-
} catch {
|
|
8105
|
-
parts.push("replace:<unreadable>");
|
|
8106
|
-
}
|
|
8107
|
-
const dir = gitDirOf(cwd);
|
|
8108
|
-
for (const rel of ["info/grafts", "shallow"]) {
|
|
8109
|
-
if (dir === null) {
|
|
8110
|
-
parts.push(`${rel}:<no-git-dir>`);
|
|
8111
|
-
continue;
|
|
8112
|
-
}
|
|
8113
|
-
const p = join12(dir, rel);
|
|
8114
|
-
try {
|
|
8115
|
-
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
|
|
8116
|
-
} catch {
|
|
8117
|
-
parts.push(`${rel}:<unreadable>`);
|
|
8118
|
-
}
|
|
8119
|
-
}
|
|
8120
|
-
return createHash6("sha256").update(parts.join("\n")).digest("hex");
|
|
8121
|
-
}
|
|
8122
|
-
|
|
8123
8156
|
// src/fingerprint.ts
|
|
8124
8157
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
8125
8158
|
import { createHash as createHash7 } from "node:crypto";
|
|
8126
8159
|
import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync as readlinkSync2, statSync as statSync4 } from "node:fs";
|
|
8127
8160
|
import { join as join13 } from "node:path";
|
|
8128
|
-
function treeFingerprint(cwd) {
|
|
8129
|
-
const
|
|
8161
|
+
function treeFingerprint(cwd, keepIgnored) {
|
|
8162
|
+
const listed = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
8130
8163
|
cwd,
|
|
8131
8164
|
encoding: "utf8",
|
|
8132
8165
|
maxBuffer: 1 << 28,
|
|
8133
8166
|
env: trustedGitEnv()
|
|
8134
|
-
}).split("\0").filter(Boolean)
|
|
8167
|
+
}).split("\0").filter(Boolean);
|
|
8168
|
+
const files = [.../* @__PURE__ */ new Set([...listed, ...keepIgnored ? ignoredPaths({ cwd }, keepIgnored) : []])].sort();
|
|
8135
8169
|
const h = createHash7("sha256");
|
|
8136
8170
|
for (const rel of files) {
|
|
8137
8171
|
h.update(rel);
|
|
8138
8172
|
h.update("\0");
|
|
8139
|
-
|
|
8173
|
+
const entry = inspectRel(cwd, rel);
|
|
8174
|
+
h.update(entry.kind);
|
|
8175
|
+
h.update("\0");
|
|
8176
|
+
h.update(String(entry.mode));
|
|
8177
|
+
h.update("\0");
|
|
8178
|
+
h.update(entry.content ?? `<${entry.kind}:${entry.detail}>`);
|
|
8140
8179
|
h.update("\0");
|
|
8141
8180
|
}
|
|
8142
8181
|
return h.digest("hex");
|
|
@@ -8490,7 +8529,8 @@ function runVerify(opts) {
|
|
|
8490
8529
|
if (opts.keep) return;
|
|
8491
8530
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
8492
8531
|
};
|
|
8493
|
-
const
|
|
8532
|
+
const protectedIgnored = (rel) => isProtected(rel, policy);
|
|
8533
|
+
const treeBefore = treeFingerprint(cwd, protectedIgnored);
|
|
8494
8534
|
const depsBefore = depsFingerprint(cwd);
|
|
8495
8535
|
try {
|
|
8496
8536
|
mkdirSync6(visDir);
|
|
@@ -8501,7 +8541,7 @@ function runVerify(opts) {
|
|
|
8501
8541
|
return 2;
|
|
8502
8542
|
}
|
|
8503
8543
|
const visible = runSuite(visDir, cmd, budget);
|
|
8504
|
-
if (treeFingerprint(cwd) !== treeBefore) {
|
|
8544
|
+
if (treeFingerprint(cwd, protectedIgnored) !== treeBefore) {
|
|
8505
8545
|
cleanup([visRoot]);
|
|
8506
8546
|
out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
|
|
8507
8547
|
out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
|
|
@@ -8529,7 +8569,7 @@ function runVerify(opts) {
|
|
|
8529
8569
|
const overlayBefore = overlayDigest(priDir, restored);
|
|
8530
8570
|
const pristine = runSuite(priDir, cmd, budget);
|
|
8531
8571
|
const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
|
|
8532
|
-
const treeMoved = treeFingerprint(cwd) !== treeBefore;
|
|
8572
|
+
const treeMoved = treeFingerprint(cwd, protectedIgnored) !== treeBefore;
|
|
8533
8573
|
const depsMoved = depsFingerprint(cwd) !== depsBefore;
|
|
8534
8574
|
cleanup([visRoot, priRoot]);
|
|
8535
8575
|
if (overlayMoved || treeMoved || depsMoved) {
|
|
@@ -8626,7 +8666,7 @@ function parseVerify(args) {
|
|
|
8626
8666
|
|
|
8627
8667
|
// src/cli/run.ts
|
|
8628
8668
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
8629
|
-
import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync as realpathSync3 } from "node:fs";
|
|
8669
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync15, readlinkSync as readlinkSync4, realpathSync as realpathSync3 } from "node:fs";
|
|
8630
8670
|
import { resolve as resolve5 } from "node:path";
|
|
8631
8671
|
var out = (s) => process.stdout.write(s + "\n");
|
|
8632
8672
|
var err = (s) => process.stderr.write(s + "\n");
|
|
@@ -8657,19 +8697,31 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
8657
8697
|
for (const pid of pids) {
|
|
8658
8698
|
const n = Number(pid);
|
|
8659
8699
|
if (n === process.pid) continue;
|
|
8660
|
-
let held;
|
|
8661
8700
|
try {
|
|
8662
|
-
|
|
8701
|
+
const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
|
|
8702
|
+
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
8703
|
+
if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
|
|
8663
8704
|
} catch {
|
|
8664
8705
|
continue;
|
|
8665
8706
|
}
|
|
8666
|
-
|
|
8707
|
+
const procLinks = [`/proc/${pid}/cwd`, `/proc/${pid}/exe`];
|
|
8667
8708
|
try {
|
|
8668
|
-
const
|
|
8669
|
-
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
8670
|
-
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
8709
|
+
for (const fd of readdirSync4(`/proc/${pid}/fd`)) procLinks.push(`/proc/${pid}/fd/${fd}`);
|
|
8671
8710
|
} catch {
|
|
8672
8711
|
}
|
|
8712
|
+
let holds = false;
|
|
8713
|
+
for (const link of procLinks) {
|
|
8714
|
+
try {
|
|
8715
|
+
const raw = readlinkSync4(link).replace(/ \(deleted\)$/, "");
|
|
8716
|
+
const held = raw.startsWith("/") ? resolve5(raw) : raw;
|
|
8717
|
+
if (held === real || held.startsWith(real + "/")) {
|
|
8718
|
+
holds = true;
|
|
8719
|
+
break;
|
|
8720
|
+
}
|
|
8721
|
+
} catch {
|
|
8722
|
+
}
|
|
8723
|
+
}
|
|
8724
|
+
if (holds) out2.push(n);
|
|
8673
8725
|
}
|
|
8674
8726
|
return out2;
|
|
8675
8727
|
}
|
|
@@ -8743,7 +8795,8 @@ tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEME
|
|
|
8743
8795
|
tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
8744
8796
|
return 1;
|
|
8745
8797
|
}
|
|
8746
|
-
const
|
|
8798
|
+
const protectedIgnored = (rel) => isProtected(rel, frozenPolicy);
|
|
8799
|
+
const fpBefore = treeFingerprint(cwd, protectedIgnored);
|
|
8747
8800
|
let diffCode = 0;
|
|
8748
8801
|
if (head !== base) {
|
|
8749
8802
|
out(`
|
|
@@ -8760,7 +8813,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
8760
8813
|
} catch {
|
|
8761
8814
|
}
|
|
8762
8815
|
}
|
|
8763
|
-
const mutatedDuringAdjudication = treeFingerprint(cwd) !== fpBefore;
|
|
8816
|
+
const mutatedDuringAdjudication = treeFingerprint(cwd, protectedIgnored) !== fpBefore;
|
|
8764
8817
|
if (mutatedDuringAdjudication) {
|
|
8765
8818
|
err("tamperward run: the working tree changed while it was being adjudicated \u2014");
|
|
8766
8819
|
err("the verdict below would describe a tree that no longer exists.");
|
|
@@ -8781,7 +8834,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
8781
8834
|
err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
|
|
8782
8835
|
}
|
|
8783
8836
|
const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
|
|
8784
|
-
const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
|
|
8837
|
+
const cannot = !notQuiescent && (diffCode === 2 || workCode === 2 || verifyCode === 2);
|
|
8785
8838
|
const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
|
|
8786
8839
|
const enforcement = cannot ? 2 : blocked ? 1 : 0;
|
|
8787
8840
|
const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
|