tamperward 1.0.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.
@@ -0,0 +1,1833 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/git/build.ts
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ // src/diff/parse.ts
9
+ var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
10
+ var C_ESCAPES = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, "\\": 92, '"': 34 };
11
+ function unquotePath(p) {
12
+ if (p.length < 2 || !p.startsWith('"') || !p.endsWith('"')) return p;
13
+ const body = p.slice(1, -1);
14
+ const bytes = [];
15
+ for (let i = 0; i < body.length; i++) {
16
+ if (body[i] !== "\\") {
17
+ for (const b of Buffer.from(body[i], "utf8")) bytes.push(b);
18
+ continue;
19
+ }
20
+ const oct = body.slice(i + 1, i + 4);
21
+ if (/^[0-7]{3}$/.test(oct)) {
22
+ bytes.push(parseInt(oct, 8));
23
+ i += 3;
24
+ continue;
25
+ }
26
+ const esc = C_ESCAPES[body[i + 1]];
27
+ if (esc !== void 0) {
28
+ bytes.push(esc);
29
+ i += 1;
30
+ continue;
31
+ }
32
+ bytes.push(92);
33
+ }
34
+ return Buffer.from(bytes).toString("utf8");
35
+ }
36
+ function endOfQuoted(s) {
37
+ for (let i = 1; i < s.length; i++) {
38
+ if (s[i] === "\\") {
39
+ i++;
40
+ continue;
41
+ }
42
+ if (s[i] === '"') return i;
43
+ }
44
+ return -1;
45
+ }
46
+ function headerPaths(line) {
47
+ let rest = line.slice("diff --git ".length);
48
+ let first = null;
49
+ if (rest.startsWith('"')) {
50
+ const end = endOfQuoted(rest);
51
+ if (end > 0) {
52
+ first = rest.slice(0, end + 1);
53
+ rest = rest.slice(end + 1).trimStart();
54
+ }
55
+ }
56
+ if (first === null) {
57
+ const q = rest.indexOf(' "b/');
58
+ if (q >= 0) {
59
+ first = rest.slice(0, q);
60
+ rest = rest.slice(q + 1);
61
+ } else {
62
+ const m = rest.match(/^(.*) (b\/.*)$/);
63
+ if (!m) return [null, null];
64
+ first = m[1];
65
+ rest = m[2];
66
+ }
67
+ }
68
+ return [stripAB(first), stripAB(rest)];
69
+ }
70
+ function stripAB(p) {
71
+ if (p === null) return null;
72
+ const u = unquotePath(p);
73
+ if (u === "/dev/null") return null;
74
+ return u.replace(/^[ab]\//, "");
75
+ }
76
+ function parseDiff(diff) {
77
+ const lines = diff.split("\n");
78
+ const changes = [];
79
+ let i = 0;
80
+ while (i < lines.length) {
81
+ if (!lines[i].startsWith("diff --git ")) {
82
+ i++;
83
+ continue;
84
+ }
85
+ const [headerOld, headerNew] = headerPaths(lines[i]);
86
+ i++;
87
+ let op = "modify";
88
+ let binary = false;
89
+ let renameFrom = null;
90
+ let renameTo = null;
91
+ let minusPath = null;
92
+ let plusPath = null;
93
+ const hunks = [];
94
+ while (i < lines.length && !lines[i].startsWith("diff --git ")) {
95
+ const l = lines[i];
96
+ if (l.startsWith("@@")) {
97
+ const parsed = parseHunk(lines, i);
98
+ if (parsed) {
99
+ hunks.push(parsed.hunk);
100
+ i = parsed.next;
101
+ continue;
102
+ }
103
+ i++;
104
+ continue;
105
+ }
106
+ if (l.startsWith("new file mode")) op = "add";
107
+ else if (l.startsWith("deleted file mode")) op = "delete";
108
+ else if (l.startsWith("rename from ")) {
109
+ renameFrom = unquotePath(l.slice(12));
110
+ op = "rename";
111
+ } else if (l.startsWith("rename to ")) {
112
+ renameTo = unquotePath(l.slice(10));
113
+ op = "rename";
114
+ } else if (l.startsWith("copy from ")) {
115
+ renameFrom = unquotePath(l.slice(10));
116
+ if (op === "modify") op = "rename";
117
+ } else if (l.startsWith("copy to ")) {
118
+ renameTo = unquotePath(l.slice(8));
119
+ if (op === "modify") op = "rename";
120
+ } else if (l.startsWith("Binary files ") || l.startsWith("GIT binary patch")) binary = true;
121
+ else if (l.startsWith("--- ")) minusPath = l.slice(4);
122
+ else if (l.startsWith("+++ ")) plusPath = l.slice(4);
123
+ i++;
124
+ }
125
+ let path;
126
+ let oldPath = null;
127
+ if (op === "rename") {
128
+ oldPath = renameFrom ?? stripAB(minusPath) ?? headerOld;
129
+ path = renameTo ?? stripAB(plusPath) ?? headerNew ?? oldPath ?? "";
130
+ } else if (op === "delete") {
131
+ path = stripAB(minusPath) ?? headerOld ?? "";
132
+ } else {
133
+ path = stripAB(plusPath) ?? headerNew ?? headerOld ?? "";
134
+ }
135
+ const change = {
136
+ kind: "file",
137
+ path,
138
+ oldPath,
139
+ op,
140
+ before: null,
141
+ after: null,
142
+ binary,
143
+ hunks
144
+ };
145
+ changes.push(change);
146
+ }
147
+ return changes;
148
+ }
149
+ function parseHunk(lines, start) {
150
+ const m = lines[start].match(HUNK_RE);
151
+ if (!m) return null;
152
+ const oldStart = Number(m[1]);
153
+ const oldLines = m[2] !== void 0 ? Number(m[2]) : 1;
154
+ const newStart = Number(m[3]);
155
+ const newLines = m[4] !== void 0 ? Number(m[4]) : 1;
156
+ const out = [];
157
+ let oldLine = oldStart;
158
+ let newLine = newStart;
159
+ let i = start + 1;
160
+ for (; i < lines.length; i++) {
161
+ const l = lines[i];
162
+ if (l.startsWith("@@") || l.startsWith("diff --git ")) break;
163
+ if (l.startsWith("\\")) continue;
164
+ const tag = l[0];
165
+ const content = l.slice(1);
166
+ if (tag === "+") {
167
+ out.push({ type: "add", content, oldLine: null, newLine });
168
+ newLine++;
169
+ } else if (tag === "-") {
170
+ out.push({ type: "del", content, oldLine, newLine: null });
171
+ oldLine++;
172
+ } else if (tag === " ") {
173
+ out.push({ type: "context", content, oldLine, newLine });
174
+ oldLine++;
175
+ newLine++;
176
+ } else {
177
+ break;
178
+ }
179
+ }
180
+ return { hunk: { oldStart, oldLines, newStart, newLines, lines: out }, next: i };
181
+ }
182
+
183
+ // src/git/build.ts
184
+ function git(args, cwd) {
185
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
186
+ }
187
+ function blobAt(rev, path, cwd) {
188
+ try {
189
+ return git(["show", `${rev}:${path}`], cwd);
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ function fromDisk(path, cwd) {
195
+ try {
196
+ return readFileSync(join(cwd ?? process.cwd(), path), "utf8");
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ function enrich(c, beforeReader, afterReader) {
202
+ if (c.kind !== "file" || c.binary) return c;
203
+ const before = c.op !== "add" ? beforeReader(c.oldPath ?? c.path) : null;
204
+ const after = c.op !== "delete" ? afterReader(c.path) : null;
205
+ return { ...c, before, after };
206
+ }
207
+ function mergeBaseOf(base, head, opts = {}) {
208
+ try {
209
+ return git(["merge-base", base, head], opts.cwd).trim() || base;
210
+ } catch {
211
+ return base;
212
+ }
213
+ }
214
+ function fileAt(rev, path, opts = {}) {
215
+ return blobAt(rev, path, opts.cwd);
216
+ }
217
+ function isGitRepo(cwd) {
218
+ try {
219
+ git(["rev-parse", "--git-dir"], cwd);
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+ function gitDir(cwd) {
226
+ try {
227
+ return git(["rev-parse", "--absolute-git-dir"], cwd).trim() || null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+ function headSha(cwd) {
233
+ try {
234
+ return git(["rev-parse", "HEAD"], cwd).trim() || null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+ function diffSince(rev, opts = {}) {
240
+ const raw = git(["diff", "--no-color", "-M", rev], opts.cwd);
241
+ return parseDiff(raw).map(
242
+ (c) => enrich(c, (p) => blobAt(rev, p, opts.cwd), (p) => fromDisk(p, opts.cwd))
243
+ );
244
+ }
245
+ function diffRange(base, head, opts = {}) {
246
+ const raw = git(["diff", "--no-color", "-M", `${base}...${head}`], opts.cwd);
247
+ const mergeBase = mergeBaseOf(base, head, opts);
248
+ return parseDiff(raw).map(
249
+ (c) => enrich(c, (p) => blobAt(mergeBase, p, opts.cwd), (p) => blobAt(head, p, opts.cwd))
250
+ );
251
+ }
252
+ function diffStaged(opts = {}) {
253
+ const raw = git(["diff", "--no-color", "-M", "--cached"], opts.cwd);
254
+ return parseDiff(raw).map(
255
+ (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => blobAt("", p, opts.cwd))
256
+ );
257
+ }
258
+ function diffWorktree(opts = {}) {
259
+ const raw = git(["diff", "--no-color", "-M", "HEAD"], opts.cwd);
260
+ return parseDiff(raw).map(
261
+ (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
262
+ );
263
+ }
264
+
265
+ // src/detectors/finding.ts
266
+ function severityOf(rule, policy, fallback = "block") {
267
+ return policy.rules?.[rule]?.severity ?? fallback;
268
+ }
269
+ function isEnabled(rule, policy) {
270
+ const r = policy.rules?.[rule];
271
+ return !r || r.enabled !== false;
272
+ }
273
+ function makeFinding(rule, policy, input) {
274
+ const severity = severityOf(rule, policy, input.defaultSeverity ?? "block");
275
+ const required = (policy.signoff?.requiredFor ?? ["block"]).includes(severity);
276
+ return {
277
+ rule,
278
+ severity,
279
+ ...input.file !== void 0 ? { file: input.file } : {},
280
+ ...input.line !== void 0 ? { line: input.line } : {},
281
+ message: input.message,
282
+ evidence: input.evidence,
283
+ remediation: input.remediation,
284
+ signoff: {
285
+ required,
286
+ command: `tamperward allow ${rule}${input.file ? ` --file ${input.file}` : ""} --reason "..."`
287
+ }
288
+ };
289
+ }
290
+
291
+ // src/detectors/command.ts
292
+ function segments(raw) {
293
+ return raw.split(/(?:&&|\|\||[;&|\n])+/).map((s) => s.trim()).filter(Boolean);
294
+ }
295
+ function tokens(seg) {
296
+ return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
297
+ }
298
+ function unquote(t) {
299
+ return t.replace(/^['"]+|['"]+$/g, "");
300
+ }
301
+
302
+ // src/detectors/no-verify.ts
303
+ var RULE = "no-verify";
304
+ var LITERAL = [
305
+ { re: /(?:^|\s)--no-verify(?:\s|$)/, why: "--no-verify skips the pre-commit/pre-push hooks" },
306
+ { re: /(?:^|\s)--no-hooks(?:\s|$)/, why: "--no-hooks disables hook execution" },
307
+ { re: /(?:^|[\s;&|=])HUSKY=0(?:\s|$)/, why: "HUSKY=0 disables all Husky hooks" },
308
+ { re: /\bHUSKY_SKIP_HOOKS=1\b/, why: "HUSKY_SKIP_HOOKS=1 skips Husky hooks" }
309
+ ];
310
+ var SHORT_N = /^-[a-z]*n[a-z]*$/i;
311
+ var noVerify = {
312
+ id: RULE,
313
+ surface: ["command"],
314
+ certainty: "mechanical",
315
+ run(changes, policy) {
316
+ const out = [];
317
+ for (const c of changes) {
318
+ if (c.kind !== "command") continue;
319
+ for (const seg of segments(c.raw)) {
320
+ let why = null;
321
+ for (const p of LITERAL) {
322
+ if (p.re.test(seg)) {
323
+ why = p.why;
324
+ break;
325
+ }
326
+ }
327
+ if (!why && /\bgit\b/.test(seg) && /\bcommit\b/.test(seg)) {
328
+ if (tokens(seg).some((t) => SHORT_N.test(t))) {
329
+ why = "git commit -n skips the pre-commit hook";
330
+ }
331
+ }
332
+ if (why) {
333
+ out.push(
334
+ makeFinding(RULE, policy, {
335
+ message: `Command bypasses git hooks: ${why}.`,
336
+ evidence: seg,
337
+ remediation: "Let the hooks run and fix what they catch. Skipping verification is exactly the move this gate blocks \u2014 editing the hook and --no-hooks are blocked too."
338
+ })
339
+ );
340
+ }
341
+ }
342
+ }
343
+ return out;
344
+ }
345
+ };
346
+
347
+ // src/detectors/ts-any-cast.ts
348
+ import ts from "typescript";
349
+
350
+ // src/diff/select.ts
351
+ function addedLines(c) {
352
+ if (c.kind !== "file") return [];
353
+ return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "add"));
354
+ }
355
+ function removedLines(c) {
356
+ if (c.kind !== "file") return [];
357
+ return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "del"));
358
+ }
359
+
360
+ // src/policy.ts
361
+ import picomatch from "picomatch";
362
+ var POLICY_FILE = ".tamperward.yml";
363
+ var cache = /* @__PURE__ */ new Map();
364
+ function matcher(glob) {
365
+ let m = cache.get(glob);
366
+ if (!m) {
367
+ m = picomatch(glob, { dot: true });
368
+ cache.set(glob, m);
369
+ }
370
+ return m;
371
+ }
372
+ function matchesAny(path, globs) {
373
+ return !!globs && globs.some((g) => matcher(g)(path));
374
+ }
375
+ function protectedCategory(path, policy) {
376
+ for (const [cat, globs] of Object.entries(policy.protected ?? {})) {
377
+ if (matchesAny(path, globs)) return cat;
378
+ }
379
+ return null;
380
+ }
381
+ function isProtected(path, policy, category) {
382
+ if (category) return matchesAny(path, policy.protected?.[category]);
383
+ return protectedCategory(path, policy) !== null;
384
+ }
385
+ function isPolicyFile(path) {
386
+ return path === POLICY_FILE || path.endsWith("/" + POLICY_FILE);
387
+ }
388
+ function isIgnored(path, policy) {
389
+ return matchesAny(path, policy.ignore);
390
+ }
391
+ function mergeProtected(base, user) {
392
+ const out = { ...base };
393
+ for (const [cat, globs] of Object.entries(user ?? {})) {
394
+ const have = new Set(out[cat] ?? []);
395
+ out[cat] = [...out[cat] ?? [], ...(globs ?? []).filter((g) => !have.has(g))];
396
+ }
397
+ return out;
398
+ }
399
+ function defaultPolicy() {
400
+ return {
401
+ version: 1,
402
+ protected: {
403
+ // Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
404
+ // .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
405
+ // instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
406
+ // strengthening, not a narrowing. picomatch expands the brace.
407
+ tests: ["**/*.test.ts", "**/*.spec.ts", "**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}", "**/__tests__/**"],
408
+ config: [
409
+ "**/jest.config.*",
410
+ "**/vitest.config.*",
411
+ "**/tsconfig*.json",
412
+ "**/.eslintrc*",
413
+ "**/eslint.config.*",
414
+ "**/package.json"
415
+ ],
416
+ ci: [".github/workflows/**"],
417
+ hooks: [".husky/**", "**/lefthook.*", ".tamperward.yml", "**/.tamperward.yml"]
418
+ },
419
+ rules: {
420
+ "test-deletion": { severity: "block" },
421
+ "test-skip": { severity: "block" },
422
+ "ts-any-cast": { severity: "block" },
423
+ // the unambiguous explicit casts + ts-suppression directives
424
+ // broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
425
+ // block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
426
+ "ts-any-launder": { severity: "warn" },
427
+ "lint-suppression": { severity: "block" },
428
+ "coverage-lowering": { severity: "block" },
429
+ "ci-tampering": { severity: "block" },
430
+ "hook-tampering": { severity: "block" },
431
+ "no-verify": { severity: "block" },
432
+ // heuristic — warn until precision is measured (SPEC §7)
433
+ "assertion-weakening": { severity: "warn" },
434
+ "guard-removal": { severity: "warn" }
435
+ },
436
+ ignore: [],
437
+ signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
438
+ };
439
+ }
440
+
441
+ // src/detectors/files.ts
442
+ var CODE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
443
+ function isCodeFile(path) {
444
+ return CODE.test(path);
445
+ }
446
+
447
+ // src/detectors/ts-any-cast.ts
448
+ var BLOCK_RULE = "ts-any-cast";
449
+ var WARN_RULE = "ts-any-launder";
450
+ var DOUBLE_CAST = /\bas\s+unknown\s+as\b/g;
451
+ var SUPPRESS = /@ts-(?:ignore|expect-error|nocheck)\b/g;
452
+ var countMatches = (s, re) => (s.match(re) || []).length;
453
+ function countAny(src) {
454
+ const r = { cast: 0, broad: 0 };
455
+ try {
456
+ const sf = ts.createSourceFile("f.ts", src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
457
+ const visit = (node) => {
458
+ if (node.kind === ts.SyntaxKind.AnyKeyword) {
459
+ const p = node.parent;
460
+ const isCast = p && ts.isAsExpression(p) && p.type === node || p && ts.isTypeAssertionExpression(p) && p.type === node;
461
+ if (isCast) r.cast++;
462
+ else r.broad++;
463
+ }
464
+ ts.forEachChild(node, visit);
465
+ };
466
+ visit(sf);
467
+ } catch {
468
+ }
469
+ return r;
470
+ }
471
+ var NARROW_LINE = /\bas\s+any\b|<\s*any\s*>|\bas\s+unknown\s+as\b|@ts-(?:ignore|expect-error|nocheck)\b/;
472
+ var BROAD_LINE = /:\s*any\b|<[^<>]*\bany\b[^<>]*>/;
473
+ var BLOCK_REMEDIATION = "Fix the underlying type instead of silencing the checker; do not cast to `any`.";
474
+ var WARN_REMEDIATION = "Prefer a precise type or `unknown` + a guard over `any` here \u2014 flagged for review.";
475
+ var tsAnyCast = {
476
+ id: BLOCK_RULE,
477
+ surface: ["file"],
478
+ certainty: "mechanical",
479
+ run(changes, policy) {
480
+ const out = [];
481
+ for (const c of changes) {
482
+ if (c.kind !== "file" || !isCodeFile(c.path)) continue;
483
+ const inTest = protectedCategory(c.path, policy) === "tests";
484
+ if (c.after != null && c.op !== "delete") {
485
+ const before = c.before ?? "";
486
+ const a = countAny(c.after);
487
+ const b = countAny(before);
488
+ const dCast = a.cast - b.cast;
489
+ const dBroad = a.broad - b.broad;
490
+ const dDouble = countMatches(c.after, DOUBLE_CAST) - countMatches(before, DOUBLE_CAST);
491
+ const dSuppr = countMatches(c.after, SUPPRESS) - countMatches(before, SUPPRESS);
492
+ const blockReasons = [];
493
+ if (dCast > 0) blockReasons.push("`as any` cast");
494
+ if (dDouble > 0) blockReasons.push("`as unknown as` double cast");
495
+ if (dSuppr > 0) blockReasons.push("@ts-ignore/@ts-expect-error/@ts-nocheck suppression");
496
+ if (blockReasons.length) {
497
+ out.push(
498
+ makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
499
+ file: c.path,
500
+ message: inTest ? `Type-checker escape in a test file: ${blockReasons.join("; ")} (test infrastructure \u2014 flagged, not blocked).` : `Type safety discarded: ${blockReasons.join("; ")}.`,
501
+ evidence: blockReasons[0],
502
+ remediation: inTest ? WARN_REMEDIATION : BLOCK_REMEDIATION,
503
+ defaultSeverity: inTest ? "warn" : "block"
504
+ })
505
+ );
506
+ }
507
+ if (dBroad > 0) {
508
+ out.push(
509
+ makeFinding(WARN_RULE, policy, {
510
+ file: c.path,
511
+ message: `Type laundered to \`any\`: introduces ${dBroad} new \`any\`-typed value(s) in a type/generic position (e.g. : any, Record<string, any>).`,
512
+ evidence: "net-new `any` in a type position",
513
+ remediation: WARN_REMEDIATION,
514
+ defaultSeverity: "warn"
515
+ })
516
+ );
517
+ }
518
+ continue;
519
+ }
520
+ for (const l of addedLines(c)) {
521
+ if (NARROW_LINE.test(l.content)) {
522
+ out.push(
523
+ makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
524
+ file: c.path,
525
+ line: l.newLine ?? void 0,
526
+ message: inTest ? "Type-checker escape in a test file (test infrastructure \u2014 flagged, not blocked)." : "Type safety discarded: an explicit cast/suppression was added.",
527
+ evidence: l.content.trim(),
528
+ remediation: inTest ? WARN_REMEDIATION : BLOCK_REMEDIATION,
529
+ defaultSeverity: inTest ? "warn" : "block"
530
+ })
531
+ );
532
+ } else if (BROAD_LINE.test(l.content)) {
533
+ out.push(
534
+ makeFinding(WARN_RULE, policy, {
535
+ file: c.path,
536
+ line: l.newLine ?? void 0,
537
+ message: "Type laundered to `any` in a type/generic position.",
538
+ evidence: l.content.trim(),
539
+ remediation: WARN_REMEDIATION,
540
+ defaultSeverity: "warn"
541
+ })
542
+ );
543
+ }
544
+ }
545
+ }
546
+ return out;
547
+ }
548
+ };
549
+
550
+ // src/detectors/lint-suppression.ts
551
+ var RULE2 = "lint-suppression";
552
+ var PATTERNS = [
553
+ { re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
554
+ { re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
555
+ { re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
556
+ ];
557
+ var lintSuppression = {
558
+ id: RULE2,
559
+ surface: ["file"],
560
+ certainty: "mechanical",
561
+ run(changes, policy) {
562
+ const out = [];
563
+ for (const c of changes) {
564
+ if (c.kind !== "file" || !isCodeFile(c.path)) continue;
565
+ for (const l of addedLines(c)) {
566
+ for (const p of PATTERNS) {
567
+ if (p.re.test(l.content)) {
568
+ out.push(
569
+ makeFinding(RULE2, policy, {
570
+ file: c.path,
571
+ line: l.newLine ?? void 0,
572
+ message: `Lint suppression added: ${p.why}.`,
573
+ evidence: l.content.trim(),
574
+ remediation: "Resolve the finding rather than suppressing the rule."
575
+ })
576
+ );
577
+ break;
578
+ }
579
+ }
580
+ }
581
+ }
582
+ return out;
583
+ }
584
+ };
585
+
586
+ // src/detectors/test-skip.ts
587
+ var RULE3 = "test-skip";
588
+ var PATTERNS2 = [
589
+ { re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
590
+ { re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
591
+ { re: /\b(?:fit|ftest|fdescribe)\b/, why: "an f-prefixed focused test (narrows the suite)" },
592
+ { re: /\bpending\(\s*\)/, why: "a pending() marker" }
593
+ ];
594
+ var testSkip = {
595
+ id: RULE3,
596
+ surface: ["file"],
597
+ certainty: "mechanical",
598
+ run(changes, policy) {
599
+ const out = [];
600
+ for (const c of changes) {
601
+ if (c.kind !== "file") continue;
602
+ if (!isProtected(c.path, policy, "tests")) continue;
603
+ for (const l of addedLines(c)) {
604
+ for (const p of PATTERNS2) {
605
+ if (p.re.test(l.content)) {
606
+ out.push(
607
+ makeFinding(RULE3, policy, {
608
+ file: c.path,
609
+ line: l.newLine ?? void 0,
610
+ message: `Test skipped or narrowed: ${p.why}.`,
611
+ evidence: l.content.trim(),
612
+ remediation: "Make the test pass rather than skipping it. If it is genuinely obsolete, a human must sign off."
613
+ })
614
+ );
615
+ break;
616
+ }
617
+ }
618
+ }
619
+ }
620
+ return out;
621
+ }
622
+ };
623
+
624
+ // src/detectors/coverage-lowering.ts
625
+ import ts2 from "typescript";
626
+ var RULE4 = "coverage-lowering";
627
+ var METRICS = ["branches", "functions", "lines", "statements"];
628
+ var isMetric = (k) => METRICS.includes(k);
629
+ var norm = (p) => p.replace(/^\.\//, "");
630
+ function keyName(name) {
631
+ if (ts2.isIdentifier(name) || ts2.isStringLiteral(name) || ts2.isNumericLiteral(name)) return name.text;
632
+ return null;
633
+ }
634
+ function numericOf(e) {
635
+ if (ts2.isNumericLiteral(e)) return Number(e.text);
636
+ if (ts2.isPrefixUnaryExpression(e) && e.operator === ts2.SyntaxKind.MinusToken && ts2.isNumericLiteral(e.operand)) {
637
+ return -Number(e.operand.text);
638
+ }
639
+ return void 0;
640
+ }
641
+ function metricsOf(obj) {
642
+ if (!ts2.isObjectLiteralExpression(obj)) return void 0;
643
+ const out = {};
644
+ let any = false;
645
+ for (const p of obj.properties) {
646
+ if (!ts2.isPropertyAssignment(p)) continue;
647
+ const k = keyName(p.name);
648
+ if (k === null || !isMetric(k)) continue;
649
+ const n = numericOf(p.initializer);
650
+ if (n === void 0) continue;
651
+ out[k] = n;
652
+ any = true;
653
+ }
654
+ return any ? out : void 0;
655
+ }
656
+ function underCoverageKey(node) {
657
+ for (let p = node.parent; p; p = p.parent) {
658
+ if (ts2.isPropertyAssignment(p) && keyName(p.name) === "coverage") return true;
659
+ }
660
+ return false;
661
+ }
662
+ function parseThresholds(src) {
663
+ const res = { paths: /* @__PURE__ */ new Map(), present: false };
664
+ try {
665
+ const sf = ts2.createSourceFile("cfg.ts", src, ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
666
+ const visit = (node) => {
667
+ if (ts2.isPropertyAssignment(node) && ts2.isObjectLiteralExpression(node.initializer)) {
668
+ const key2 = keyName(node.name);
669
+ if (key2 === "coverageThreshold" || key2 === "thresholds" && underCoverageKey(node)) {
670
+ res.present = true;
671
+ const flat = metricsOf(node.initializer);
672
+ if (flat) res.global = { ...res.global ?? {}, ...flat };
673
+ for (const p of node.initializer.properties) {
674
+ if (!ts2.isPropertyAssignment(p)) continue;
675
+ const k = keyName(p.name);
676
+ if (k === null || isMetric(k)) continue;
677
+ const m = metricsOf(p.initializer);
678
+ if (!m) continue;
679
+ if (k === "global") res.global = { ...res.global ?? {}, ...m };
680
+ else res.paths.set(norm(k), m);
681
+ }
682
+ }
683
+ }
684
+ ts2.forEachChild(node, visit);
685
+ };
686
+ visit(sf);
687
+ } catch {
688
+ }
689
+ return res;
690
+ }
691
+ var eff = (t, path) => t.paths.get(norm(path)) ?? t.global;
692
+ function compareMetrics(scope, before, after, out) {
693
+ if (!before) return;
694
+ for (const m of METRICS) {
695
+ const bv = before[m];
696
+ if (bv === void 0) continue;
697
+ const av = after?.[m];
698
+ if (av === void 0) out.push(`${scope} ${m} threshold removed (was ${bv})`);
699
+ else if (av < bv) out.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
700
+ }
701
+ }
702
+ function weakenings(before, after) {
703
+ const out = [];
704
+ if (before.present && !after.present) {
705
+ out.push("the coverage threshold gate was removed");
706
+ return out;
707
+ }
708
+ compareMetrics("global", before.global, after.global, out);
709
+ const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
710
+ for (const p of paths) {
711
+ compareMetrics(`required coverage for ${p}:`, eff(before, p), eff(after, p), out);
712
+ }
713
+ return out;
714
+ }
715
+ var coverageLowering = {
716
+ id: RULE4,
717
+ surface: ["file"],
718
+ certainty: "mechanical",
719
+ run(changes, policy) {
720
+ const out = [];
721
+ for (const c of changes) {
722
+ if (c.kind !== "file") continue;
723
+ if (!isProtected(c.path, policy, "config")) continue;
724
+ if (c.after != null && c.op !== "delete") {
725
+ for (const reason of weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after))) {
726
+ out.push(
727
+ makeFinding(RULE4, policy, {
728
+ file: c.path,
729
+ message: `Coverage gate weakened: ${reason}.`,
730
+ evidence: reason,
731
+ remediation: "Raise real coverage by adding tests; do not lower or exempt the gate to pass."
732
+ })
733
+ );
734
+ }
735
+ } else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
736
+ if (parseThresholds(c.before ?? "").present) {
737
+ out.push(
738
+ makeFinding(RULE4, policy, {
739
+ file: c.path,
740
+ message: "Coverage gate weakened: the coverage config was deleted.",
741
+ evidence: c.path,
742
+ remediation: "Restore the coverage config; raise real coverage instead of removing the gate."
743
+ })
744
+ );
745
+ }
746
+ }
747
+ const added = addedLines(c);
748
+ const removed = removedLines(c);
749
+ const removedCoverage = removed.find((l) => /--coverage\b/.test(l.content));
750
+ if (removedCoverage && !added.some((l) => /--coverage\b/.test(l.content))) {
751
+ out.push(
752
+ makeFinding(RULE4, policy, {
753
+ file: c.path,
754
+ message: "The --coverage flag was removed from a test script.",
755
+ evidence: removedCoverage.content.trim(),
756
+ remediation: "Keep coverage reporting on; do not strip the flag to dodge the gate."
757
+ })
758
+ );
759
+ }
760
+ for (const l of added) {
761
+ if (/--passWithNoTests\b/.test(l.content)) {
762
+ out.push(
763
+ makeFinding(RULE4, policy, {
764
+ file: c.path,
765
+ line: l.newLine ?? void 0,
766
+ message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
767
+ evidence: l.content.trim(),
768
+ remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
769
+ })
770
+ );
771
+ }
772
+ }
773
+ if (c.after == null) {
774
+ const KEY = /\b(branches|functions|lines|statements)\b\s*:\s*(\d+(?:\.\d+)?)/;
775
+ const oldNums = /* @__PURE__ */ new Map();
776
+ for (const l of removed) {
777
+ const m = l.content.match(KEY);
778
+ if (m) oldNums.set(m[1], Number(m[2]));
779
+ }
780
+ for (const l of added) {
781
+ const m = l.content.match(KEY);
782
+ if (!m) continue;
783
+ const ov = oldNums.get(m[1]);
784
+ if (ov !== void 0 && Number(m[2]) < ov) {
785
+ out.push(
786
+ makeFinding(RULE4, policy, {
787
+ file: c.path,
788
+ line: l.newLine ?? void 0,
789
+ message: `Coverage threshold for ${m[1]} lowered ${ov} \u2192 ${m[2]}.`,
790
+ evidence: l.content.trim(),
791
+ remediation: "Restore the threshold and raise real coverage; do not lower the gate to pass."
792
+ })
793
+ );
794
+ }
795
+ }
796
+ }
797
+ }
798
+ const seen = /* @__PURE__ */ new Set();
799
+ return out.filter((f) => {
800
+ const k = `${f.rule}|${f.message}`;
801
+ if (seen.has(k)) return false;
802
+ seen.add(k);
803
+ return true;
804
+ });
805
+ }
806
+ };
807
+
808
+ // src/detectors/ci-tampering.ts
809
+ var RULE5 = "ci-tampering";
810
+ var STEP = /^\s*-?\s*(?:run|uses):/;
811
+ var CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
812
+ var YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
813
+ var RUNNER = /\b(npm|npx|pnpm|yarn|bun|make|cargo|pytest|python|jest|vitest|eslint|tsc|playwright|tamperward|gradle|mvn|dotnet)\b/;
814
+ var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
815
+ function isAlwaysFalse(raw) {
816
+ const v = uncommented(raw);
817
+ if (/^false$/i.test(v)) return true;
818
+ const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
819
+ if (!m) return false;
820
+ const e = m[1];
821
+ if (/^(false|0|''|"")$/i.test(e)) return true;
822
+ const cmp = e.match(/^(-?\d+)\s*(==|!=)\s*(-?\d+)$/);
823
+ if (cmp) return cmp[2] === "==" ? cmp[1] !== cmp[3] : cmp[1] === cmp[3];
824
+ return false;
825
+ }
826
+ function isTruthy(raw) {
827
+ const v = uncommented(raw);
828
+ if (/^true$/i.test(v)) return true;
829
+ const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
830
+ return !!m && /^(true|1)$/i.test(m[1]);
831
+ }
832
+ var ciTampering = {
833
+ id: RULE5,
834
+ surface: ["file"],
835
+ certainty: "mechanical",
836
+ run(changes, policy) {
837
+ const out = [];
838
+ for (const c of changes) {
839
+ if (c.kind !== "file") continue;
840
+ if (!isProtected(c.path, policy, "ci")) continue;
841
+ for (const l of addedLines(c)) {
842
+ const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
843
+ const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
844
+ if (coe && isTruthy(coe[1])) {
845
+ out.push(
846
+ makeFinding(RULE5, policy, {
847
+ file: c.path,
848
+ line: l.newLine ?? void 0,
849
+ message: "continue-on-error: true added \u2014 failures will no longer fail the job.",
850
+ evidence: l.content.trim(),
851
+ remediation: "Remove it. A check that cannot fail is not a check."
852
+ })
853
+ );
854
+ } else if (cond && isAlwaysFalse(cond[1])) {
855
+ out.push(
856
+ makeFinding(RULE5, policy, {
857
+ file: c.path,
858
+ line: l.newLine ?? void 0,
859
+ message: `if: ${uncommented(cond[1])} added \u2014 this step can never run.`,
860
+ evidence: l.content.trim(),
861
+ remediation: "Re-enable the step instead of conditioning it off."
862
+ })
863
+ );
864
+ }
865
+ }
866
+ const stillPresent = new Set(addedLines(c).map((l) => l.content.trim()));
867
+ for (const l of removedLines(c)) {
868
+ if (stillPresent.has(l.content.trim())) continue;
869
+ const isStepLine = STEP.test(l.content) && CHECK.test(l.content);
870
+ const isRunBodyLine = !YAML_KEY.test(l.content) && RUNNER.test(l.content) && CHECK.test(l.content);
871
+ if (!isStepLine && !isRunBodyLine) continue;
872
+ out.push(
873
+ makeFinding(RULE5, policy, {
874
+ file: c.path,
875
+ message: isStepLine ? "A CI check step (test/lint/typecheck) was removed." : "A CI check command was removed from a run block.",
876
+ evidence: l.content.trim(),
877
+ remediation: "Restore the step. Removing the check that protects main is itself the tamper."
878
+ })
879
+ );
880
+ }
881
+ }
882
+ return out;
883
+ }
884
+ };
885
+
886
+ // src/detectors/policy-diff.ts
887
+ import { parse } from "yaml";
888
+ function safeParse(src) {
889
+ try {
890
+ const v = parse(src);
891
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
892
+ return null;
893
+ } catch {
894
+ return null;
895
+ }
896
+ }
897
+ function effective(raw) {
898
+ const base = defaultPolicy();
899
+ return {
900
+ rules: { ...base.rules, ...raw.rules ?? {} },
901
+ ignore: raw.ignore ?? base.ignore ?? [],
902
+ protected: mergeProtected(base.protected, raw.protected),
903
+ requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor
904
+ };
905
+ }
906
+ function policyWeakening(before, after) {
907
+ const b = safeParse(before);
908
+ if (b === null) return null;
909
+ const a = safeParse(after);
910
+ if (a === null) return ["the policy no longer parses as a valid policy (emptied or corrupted)"];
911
+ const be = effective(b);
912
+ const ae = effective(a);
913
+ const reasons = [];
914
+ for (const name of /* @__PURE__ */ new Set([...Object.keys(be.rules), ...Object.keys(ae.rules)])) {
915
+ const br = be.rules[name];
916
+ const ar = ae.rules[name];
917
+ if (!br) continue;
918
+ if (!ar) {
919
+ reasons.push(`rule "${name}" removed from policy`);
920
+ continue;
921
+ }
922
+ if (br.severity === "block" && ar.severity === "warn") {
923
+ reasons.push(`rule "${name}" lowered block \u2192 warn`);
924
+ }
925
+ if (ar.enabled === false && br.enabled !== false) {
926
+ reasons.push(`rule "${name}" disabled (enabled: false)`);
927
+ }
928
+ }
929
+ const beforeIgnore = new Set(be.ignore);
930
+ const addedIgnore = ae.ignore.filter((g) => !beforeIgnore.has(g));
931
+ if (addedIgnore.length) {
932
+ reasons.push(`ignore globs added (${addedIgnore.join(", ")}) \u2014 disables detection on those paths`);
933
+ }
934
+ for (const cat of /* @__PURE__ */ new Set([...Object.keys(be.protected), ...Object.keys(ae.protected)])) {
935
+ const stillThere = new Set(ae.protected[cat] ?? []);
936
+ const removed = (be.protected[cat] ?? []).filter((g) => !stillThere.has(g));
937
+ if (removed.length) reasons.push(`protected.${cat} narrowed (removed ${removed.join(", ")})`);
938
+ }
939
+ if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
940
+ reasons.push("sign-off no longer required for blocking findings");
941
+ }
942
+ return reasons;
943
+ }
944
+
945
+ // src/detectors/hook-tampering.ts
946
+ var RULE6 = "hook-tampering";
947
+ var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
948
+ var isShellComment = (s) => /^\s*#/.test(s);
949
+ var hookTampering = {
950
+ id: RULE6,
951
+ surface: ["file", "command"],
952
+ certainty: "mechanical",
953
+ run(changes, policy) {
954
+ const out = [];
955
+ for (const c of changes) {
956
+ if (c.kind === "file") {
957
+ const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
958
+ if (!targetsHook) continue;
959
+ if (c.op === "delete") {
960
+ out.push(
961
+ makeFinding(RULE6, policy, {
962
+ file: c.path,
963
+ message: "A protected hook/policy file was deleted.",
964
+ evidence: c.path,
965
+ remediation: "Restore it. The hooks and the policy are protected assets, not obstacles to remove."
966
+ })
967
+ );
968
+ } else if (c.op === "rename") {
969
+ out.push(
970
+ makeFinding(RULE6, policy, {
971
+ file: c.path,
972
+ message: `A protected hook/policy file was renamed (${c.oldPath} \u2192 ${c.path}).`,
973
+ evidence: `${c.oldPath} \u2192 ${c.path}`,
974
+ remediation: "Renaming a hook out of place disables it. Restore the original path."
975
+ })
976
+ );
977
+ } else if (c.path.endsWith(POLICY_FILE) && c.before != null && c.after != null && policyWeakening(c.before, c.after) !== null) {
978
+ for (const reason of policyWeakening(c.before, c.after) ?? []) {
979
+ out.push(
980
+ makeFinding(RULE6, policy, {
981
+ file: c.path,
982
+ message: `The policy was weakened: ${reason}.`,
983
+ evidence: reason,
984
+ remediation: "A change that weakens the guardrail is a high-risk edit requiring human sign-off, not an automated pass."
985
+ })
986
+ );
987
+ }
988
+ } else if (!c.path.endsWith(POLICY_FILE)) {
989
+ const added = addedLines(c);
990
+ const removed = removedLines(c);
991
+ const live = added.filter((l) => !isShellComment(l.content));
992
+ const earlyExit = added.find((l) => /^\s*exit\s+0\s*$/.test(l.content));
993
+ if (earlyExit) {
994
+ out.push(
995
+ makeFinding(RULE6, policy, {
996
+ file: c.path,
997
+ line: earlyExit.newLine ?? void 0,
998
+ message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
999
+ evidence: earlyExit.content.trim(),
1000
+ remediation: "Remove the early exit. A hook that always succeeds is a disabled hook."
1001
+ })
1002
+ );
1003
+ }
1004
+ for (const l of removed) {
1005
+ if (isShellComment(l.content) || !l.content.trim()) continue;
1006
+ const m = l.content.match(HOOK_INVOCATION);
1007
+ if (!m) continue;
1008
+ if (live.some((a) => a.content.includes(m[0]))) continue;
1009
+ out.push(
1010
+ makeFinding(RULE6, policy, {
1011
+ file: c.path,
1012
+ message: `A check invocation (\`${m[0]}\`) was removed from a protected hook.`,
1013
+ evidence: l.content.trim(),
1014
+ remediation: "Restore the check. Removing or commenting out the gate's invocation disables it just as surely as deleting the hook."
1015
+ })
1016
+ );
1017
+ break;
1018
+ }
1019
+ } else {
1020
+ const INLINE = /^\s*([A-Za-z0-9_-]+):\s*\{[^}]*\bseverity:\s*(block|warn)\b/;
1021
+ const removedSeverity = /* @__PURE__ */ new Map();
1022
+ for (const l of removedLines(c)) {
1023
+ const m = l.content.match(INLINE);
1024
+ if (m) removedSeverity.set(m[1], m[2]);
1025
+ }
1026
+ for (const l of addedLines(c)) {
1027
+ const m = l.content.match(INLINE);
1028
+ if (m && m[2] === "warn" && removedSeverity.get(m[1]) === "block") {
1029
+ out.push(
1030
+ makeFinding(RULE6, policy, {
1031
+ file: c.path,
1032
+ line: l.newLine ?? void 0,
1033
+ message: `Rule "${m[1]}" was lowered from block to warn in the policy.`,
1034
+ evidence: l.content.trim(),
1035
+ remediation: "Do not weaken the policy to pass. Lowering a gate is a human sign-off decision."
1036
+ })
1037
+ );
1038
+ } else if (/enabled:\s*false\b/.test(l.content)) {
1039
+ out.push(
1040
+ makeFinding(RULE6, policy, {
1041
+ file: c.path,
1042
+ line: l.newLine ?? void 0,
1043
+ message: "A rule was disabled (enabled: false) in the policy.",
1044
+ evidence: l.content.trim(),
1045
+ remediation: "Re-enable the rule. Disabling the check to pass is the tamper."
1046
+ })
1047
+ );
1048
+ }
1049
+ }
1050
+ }
1051
+ } else {
1052
+ for (const seg of segments(c.raw)) {
1053
+ const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
1054
+ if (!hitsHook) continue;
1055
+ const octal = seg.match(/\bchmod\b[^|;&]*?(?:^|\s)([0-7]{3,4})(?:\s|$)/);
1056
+ const octalDropsExec = octal ? (Number(octal[1].slice(-3)[0]) & 1) === 0 : false;
1057
+ let why = null;
1058
+ if (/\bchmod\b/.test(seg) && (/(?:^|\s)[-+=]?[a-z]*x/i.test(seg) || octalDropsExec)) {
1059
+ why = "chmod alters execute permission on a hook, which can disable it";
1060
+ } else if (/\brm\b/.test(seg)) {
1061
+ why = "rm deletes a protected hook";
1062
+ } else if (/\btruncate\b/.test(seg) || /(?:^|\s)>\s*\S/.test(seg)) {
1063
+ why = "a redirect/truncate empties a protected hook";
1064
+ } else if (/\b(tee|sed|cp|dd|install|mv)\b/.test(seg)) {
1065
+ why = "a shell rewrite (tee/sed/cp/dd/install/mv) replaces a protected hook in place";
1066
+ }
1067
+ if (why) {
1068
+ out.push(
1069
+ makeFinding(RULE6, policy, {
1070
+ message: `Hook tampering via shell: ${why}.`,
1071
+ evidence: seg,
1072
+ remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
1073
+ })
1074
+ );
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+ return out;
1080
+ }
1081
+ };
1082
+
1083
+ // src/detectors/test-deletion.ts
1084
+ import ts3 from "typescript";
1085
+ var RULE7 = "test-deletion";
1086
+ function calleeName(expr) {
1087
+ if (ts3.isIdentifier(expr)) return expr.text;
1088
+ if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
1089
+ return expr.expression.text;
1090
+ }
1091
+ return null;
1092
+ }
1093
+ function countTestBlocks(src) {
1094
+ const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
1095
+ let n = 0;
1096
+ const visit = (node) => {
1097
+ if (ts3.isCallExpression(node)) {
1098
+ const name = calleeName(node.expression);
1099
+ if (name === "it" || name === "test") n++;
1100
+ }
1101
+ ts3.forEachChild(node, visit);
1102
+ };
1103
+ visit(sf);
1104
+ return n;
1105
+ }
1106
+ function significantLines(src) {
1107
+ const out = /* @__PURE__ */ new Set();
1108
+ for (const raw of src.split("\n")) {
1109
+ const l = raw.trim();
1110
+ if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out.add(l);
1111
+ }
1112
+ return out;
1113
+ }
1114
+ var testDeletion = {
1115
+ id: RULE7,
1116
+ surface: ["file", "command"],
1117
+ certainty: "mechanical",
1118
+ run(changes, policy) {
1119
+ const out = [];
1120
+ const addedTestLines = /* @__PURE__ */ new Set();
1121
+ let addedTestBlocks = 0;
1122
+ for (const c of changes) {
1123
+ if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
1124
+ for (const l of significantLines(c.after)) addedTestLines.add(l);
1125
+ addedTestBlocks += countTestBlocks(c.after);
1126
+ }
1127
+ }
1128
+ const isRelocation = (before) => {
1129
+ const sig = significantLines(before);
1130
+ if (sig.size === 0 || addedTestLines.size === 0) return false;
1131
+ let hit = 0;
1132
+ for (const l of sig) if (addedTestLines.has(l)) hit++;
1133
+ return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before);
1134
+ };
1135
+ for (const c of changes) {
1136
+ if (c.kind === "file") {
1137
+ const isTest = isProtected(c.path, policy, "tests");
1138
+ if (c.op === "delete" && isTest) {
1139
+ if (c.before != null && isRelocation(c.before)) continue;
1140
+ out.push(
1141
+ makeFinding(RULE7, policy, {
1142
+ file: c.path,
1143
+ message: "A test file was deleted.",
1144
+ evidence: c.path,
1145
+ remediation: "Fix the code under test, not the suite. Deleting a failing test to pass is the move this blocks."
1146
+ })
1147
+ );
1148
+ } else if (c.op === "rename" && c.oldPath && isProtected(c.oldPath, policy, "tests") && !isTest) {
1149
+ out.push(
1150
+ makeFinding(RULE7, policy, {
1151
+ file: c.path,
1152
+ message: `A test file was renamed out of the test glob (${c.oldPath} \u2192 ${c.path}).`,
1153
+ evidence: `${c.oldPath} \u2192 ${c.path}`,
1154
+ remediation: "Restoring the path. Renaming a spec out of the glob silently removes it from the suite."
1155
+ })
1156
+ );
1157
+ } else if (c.op === "modify" && isTest && c.before != null && c.after != null) {
1158
+ const before = countTestBlocks(c.before);
1159
+ const after = countTestBlocks(c.after);
1160
+ if (after < before) {
1161
+ out.push(
1162
+ makeFinding(RULE7, policy, {
1163
+ file: c.path,
1164
+ message: `Test blocks removed: ${before} \u2192 ${after} it()/test() in this spec.`,
1165
+ evidence: `${before - after} test block(s) removed from ${c.path}`,
1166
+ remediation: "Keep the assertions and fix the code. Removing test blocks to go green is the tamper."
1167
+ })
1168
+ );
1169
+ }
1170
+ }
1171
+ } else {
1172
+ for (const seg of segments(c.raw)) {
1173
+ const toks = tokens(seg).map(unquote);
1174
+ const testToks = toks.filter((t) => isProtected(t, policy, "tests"));
1175
+ if (testToks.length === 0) continue;
1176
+ let why = null;
1177
+ let evidence = seg;
1178
+ const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
1179
+ const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
1180
+ if (/\brm\b/.test(seg)) {
1181
+ why = "rm deletes a test file";
1182
+ } else if (/\bsed\b/.test(seg) && /(?:^|\s)-i\b/.test(seg)) {
1183
+ why = "sed -i rewrites a test file in place";
1184
+ } else if (/\btruncate\b/.test(seg)) {
1185
+ why = "truncate empties a test file";
1186
+ } else if (redirectsOntoTest) {
1187
+ why = "a redirect overwrites/empties a test file";
1188
+ } else if (/\bmv\b/.test(seg)) {
1189
+ const dest = toks[toks.length - 1];
1190
+ if (dest && !isProtected(dest, policy, "tests")) {
1191
+ why = "mv renames a test file out of the test glob";
1192
+ evidence = `${testToks[0]} \u2192 ${dest}`;
1193
+ }
1194
+ }
1195
+ if (why) {
1196
+ out.push(
1197
+ makeFinding(RULE7, policy, {
1198
+ message: `Test removed via shell: ${why}.`,
1199
+ evidence,
1200
+ remediation: "Fix the code under test. Mutating a spec from the shell is still deleting the test \u2014 and is blocked the same way."
1201
+ })
1202
+ );
1203
+ }
1204
+ }
1205
+ }
1206
+ }
1207
+ return out;
1208
+ }
1209
+ };
1210
+
1211
+ // src/detectors/index.ts
1212
+ var allDetectors = [
1213
+ noVerify,
1214
+ tsAnyCast,
1215
+ lintSuppression,
1216
+ testSkip,
1217
+ coverageLowering,
1218
+ ciTampering,
1219
+ hookTampering,
1220
+ testDeletion
1221
+ ];
1222
+
1223
+ // src/engine.ts
1224
+ function key(f) {
1225
+ return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
1226
+ }
1227
+ function isSuppressed(c, policy) {
1228
+ if (c.kind !== "file") return false;
1229
+ if (isPolicyFile(c.path) || c.oldPath != null && isPolicyFile(c.oldPath)) return false;
1230
+ return isIgnored(c.path, policy);
1231
+ }
1232
+ function activeChanges(changes, policy) {
1233
+ return changes.filter((c) => !isSuppressed(c, policy));
1234
+ }
1235
+ function evaluate(changes, policy, detectors = allDetectors) {
1236
+ const active = activeChanges(changes, policy);
1237
+ const out = [];
1238
+ for (const d of detectors) {
1239
+ if (!isEnabled(d.id, policy)) continue;
1240
+ try {
1241
+ out.push(...d.run(active, policy));
1242
+ } catch (e) {
1243
+ process.stderr.write(`tamperward: detector "${d.id}" errored and was skipped: ${String(e)}
1244
+ `);
1245
+ }
1246
+ }
1247
+ const seen = /* @__PURE__ */ new Set();
1248
+ return out.filter((f) => {
1249
+ const k = key(f);
1250
+ if (seen.has(k)) return false;
1251
+ seen.add(k);
1252
+ return true;
1253
+ });
1254
+ }
1255
+ function hasBlocking(findings) {
1256
+ return findings.some((f) => f.severity === "block");
1257
+ }
1258
+
1259
+ // src/policy-load.ts
1260
+ import { readFileSync as readFileSync2, existsSync } from "node:fs";
1261
+ import { join as join2 } from "node:path";
1262
+ import { parse as parse2 } from "yaml";
1263
+ var PolicyError = class extends Error {
1264
+ };
1265
+ function parsePolicy(raw) {
1266
+ const base = defaultPolicy();
1267
+ const r = raw ?? {};
1268
+ return {
1269
+ version: 1,
1270
+ // Merge with the baseline, never replace. For an integrity tool, a config that sets
1271
+ // one rule's severity must NOT silently drop the other nine — nor may naming one
1272
+ // protected glob wipe out the rest of its category (see mergeProtected).
1273
+ protected: mergeProtected(base.protected, r.protected),
1274
+ rules: { ...base.rules, ...r.rules ?? {} },
1275
+ ignore: r.ignore ?? base.ignore,
1276
+ signoff: {
1277
+ requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
1278
+ ledger: r.signoff?.ledger ?? base.signoff.ledger
1279
+ }
1280
+ };
1281
+ }
1282
+ function parseOrThrow(src, where) {
1283
+ let raw;
1284
+ try {
1285
+ raw = parse2(src);
1286
+ } catch (e) {
1287
+ throw new PolicyError(`${where} is not valid YAML: ${e.message}`);
1288
+ }
1289
+ if (raw !== null && raw !== void 0 && (typeof raw !== "object" || Array.isArray(raw))) {
1290
+ throw new PolicyError(`${where} is not a policy mapping`);
1291
+ }
1292
+ return parsePolicy(raw);
1293
+ }
1294
+ function loadPolicy(cwd = process.cwd()) {
1295
+ const path = join2(cwd, POLICY_FILE);
1296
+ if (!existsSync(path)) return defaultPolicy();
1297
+ return parseOrThrow(readFileSync2(path, "utf8"), POLICY_FILE);
1298
+ }
1299
+ function loadPolicyAt(rev, cwd) {
1300
+ const src = fileAt(rev, POLICY_FILE, { cwd });
1301
+ if (src == null) return null;
1302
+ return parseOrThrow(src, `${POLICY_FILE} at ${rev}`);
1303
+ }
1304
+
1305
+ // src/signoff.ts
1306
+ import { createHash } from "node:crypto";
1307
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
1308
+ import { dirname, join as join3 } from "node:path";
1309
+ var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
1310
+ function fingerprint(rule, file, evidence) {
1311
+ return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
1312
+ }
1313
+ var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
1314
+ function ledgerPath(cwd, policy) {
1315
+ return join3(cwd, policy.signoff?.ledger ?? ".tamperward/ledger.jsonl");
1316
+ }
1317
+ function readLedger(cwd, policy) {
1318
+ const p = ledgerPath(cwd, policy);
1319
+ if (!existsSync2(p)) return [];
1320
+ const out = [];
1321
+ for (const line of readFileSync3(p, "utf8").split("\n")) {
1322
+ if (!line.trim()) continue;
1323
+ try {
1324
+ const e = JSON.parse(line);
1325
+ if (e && typeof e.fingerprint === "string") out.push(e);
1326
+ } catch {
1327
+ }
1328
+ }
1329
+ return out;
1330
+ }
1331
+ function appendEntry(cwd, policy, e) {
1332
+ const p = ledgerPath(cwd, policy);
1333
+ mkdirSync(dirname(p), { recursive: true });
1334
+ appendFileSync(p, JSON.stringify(e) + "\n");
1335
+ }
1336
+ function makeEntry(f, reason, now, ttlMs = DEFAULT_TTL_MS) {
1337
+ return { rule: f.rule, file: f.file, fingerprint: fingerprintOf(f), reason, recordedAt: now, expiresAt: now + ttlMs };
1338
+ }
1339
+ function applyLocalSignoffs(findings, cwd, policy, now = Date.now()) {
1340
+ const ledger = readLedger(cwd, policy).filter((e) => e.expiresAt > now);
1341
+ const valid = new Set(ledger.map((e) => e.fingerprint));
1342
+ const cleared = [];
1343
+ const remaining = [];
1344
+ for (const f of findings) {
1345
+ if (f.severity === "block" && valid.has(fingerprintOf(f))) cleared.push(f);
1346
+ else remaining.push(f);
1347
+ }
1348
+ return { findings: remaining, cleared };
1349
+ }
1350
+ function applyOobSignoffs(findings, oob) {
1351
+ const set = new Set(oob.map((s) => s.trim()).filter(Boolean));
1352
+ const cleared = [];
1353
+ const remaining = [];
1354
+ for (const f of findings) {
1355
+ const ok = f.severity === "block" && (set.has(f.rule) || (f.file ? set.has(`${f.rule}:${f.file}`) : false));
1356
+ if (ok) cleared.push(f);
1357
+ else remaining.push(f);
1358
+ }
1359
+ return { findings: remaining, cleared };
1360
+ }
1361
+ function oobFromEnv(env = process.env) {
1362
+ return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1363
+ }
1364
+
1365
+ // src/cli/report.ts
1366
+ function report(input) {
1367
+ const { findings, scanned, ignoredFiles } = input;
1368
+ if (input.json) {
1369
+ process.stdout.write(JSON.stringify({ findings, scanned, ignoredFiles }, null, 2) + "\n");
1370
+ return;
1371
+ }
1372
+ const ignoredNote = ignoredFiles > 0 ? `, ${ignoredFiles} file(s) ignored by policy` : "";
1373
+ if (findings.length === 0) {
1374
+ process.stdout.write(`tamperward: clean \u2014 no integrity findings (${scanned} change(s) scanned${ignoredNote}).
1375
+ `);
1376
+ return;
1377
+ }
1378
+ const blocks = findings.filter((f) => f.severity === "block");
1379
+ const warns = findings.filter((f) => f.severity === "warn");
1380
+ for (const f of findings) {
1381
+ const mark = f.severity === "block" ? "BLOCK" : "warn";
1382
+ const loc = f.file ? ` ${f.file}${f.line ? `:${f.line}` : ""}` : "";
1383
+ process.stdout.write(`
1384
+ [${mark}] ${f.rule}${loc}
1385
+ `);
1386
+ process.stdout.write(` ${f.message}
1387
+ `);
1388
+ process.stdout.write(` evidence: ${f.evidence}
1389
+ `);
1390
+ process.stdout.write(` fix: ${f.remediation}
1391
+ `);
1392
+ if (f.signoff.required) process.stdout.write(` sign-off: ${f.signoff.command}
1393
+ `);
1394
+ }
1395
+ process.stdout.write(
1396
+ `
1397
+ tamperward: ${blocks.length} blocking, ${warns.length} warning (${scanned} change(s) scanned${ignoredNote}).
1398
+ `
1399
+ );
1400
+ if (blocks.length > 0) {
1401
+ process.stdout.write(
1402
+ "A blocking finding clears only with a human sign-off. In CI, sign-off is out-of-band (a reviewed PR label), never a committed file \u2014 see SPEC \xA75.4.\n"
1403
+ );
1404
+ }
1405
+ }
1406
+
1407
+ // src/cli/check.ts
1408
+ function check(opts) {
1409
+ let policy = loadPolicy(opts.cwd);
1410
+ const cwd = opts.cwd ?? process.cwd();
1411
+ let changes;
1412
+ let layer;
1413
+ if (opts.staged) {
1414
+ changes = diffStaged({ cwd: opts.cwd });
1415
+ layer = "local";
1416
+ } else if (opts.worktree) {
1417
+ changes = diffWorktree({ cwd: opts.cwd });
1418
+ layer = "local";
1419
+ } else if (opts.diff) {
1420
+ const [base, head] = opts.diff.split(/\.{2,3}/);
1421
+ if (!base || !head) {
1422
+ process.stderr.write(`tamperward: invalid --diff range "${opts.diff}" (expected <base>...<head>)
1423
+ `);
1424
+ return 2;
1425
+ }
1426
+ changes = diffRange(base, head, { cwd: opts.cwd });
1427
+ layer = "ci";
1428
+ policy = loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
1429
+ } else {
1430
+ process.stderr.write("tamperward: specify --staged, --worktree, or --diff <base>...<head>\n");
1431
+ return 2;
1432
+ }
1433
+ const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
1434
+ let findings = evaluate(changes, policy);
1435
+ const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv());
1436
+ findings = remaining;
1437
+ if (cleared.length) {
1438
+ const how = layer === "local" ? "local human sign-off (ledger)" : "out-of-band approval";
1439
+ process.stderr.write(`tamperward: ${cleared.length} blocking finding(s) cleared by ${how}: ${cleared.map((f) => f.rule + (f.file ? `(${f.file})` : "")).join(", ")}
1440
+ `);
1441
+ }
1442
+ report({ findings, scanned: changes.length, ignoredFiles, json: opts.json });
1443
+ return hasBlocking(findings) ? 1 : 0;
1444
+ }
1445
+ function runCheck(opts) {
1446
+ try {
1447
+ return check(opts);
1448
+ } catch (e) {
1449
+ if (e instanceof PolicyError) {
1450
+ process.stderr.write(`tamperward: ${e.message}
1451
+ `);
1452
+ return 2;
1453
+ }
1454
+ throw e;
1455
+ }
1456
+ }
1457
+
1458
+ // src/cli/hook.ts
1459
+ import { readFileSync as readFileSync6, appendFileSync as appendFileSync2 } from "node:fs";
1460
+
1461
+ // src/adapters/claude/changes.ts
1462
+ import { execFileSync as execFileSync2 } from "node:child_process";
1463
+ import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
1464
+ import { tmpdir } from "node:os";
1465
+ import { join as join4, isAbsolute } from "node:path";
1466
+ function asStr(v) {
1467
+ return typeof v === "string" ? v : "";
1468
+ }
1469
+ function readDisk(path) {
1470
+ try {
1471
+ return existsSync3(path) ? readFileSync4(path, "utf8") : null;
1472
+ } catch {
1473
+ return null;
1474
+ }
1475
+ }
1476
+ function abs(path, cwd) {
1477
+ return isAbsolute(path) ? path : join4(cwd, path);
1478
+ }
1479
+ function relForDisplay(path, cwd) {
1480
+ return path.startsWith(cwd + "/") ? path.slice(cwd.length + 1) : path;
1481
+ }
1482
+ function applyEdit(content, oldStr, newStr) {
1483
+ if (content === null) return newStr;
1484
+ return content.replace(oldStr, () => newStr);
1485
+ }
1486
+ function synthFileChange(displayPath, before, after) {
1487
+ if (before === after) return [];
1488
+ let op;
1489
+ if (before === null) op = "add";
1490
+ else if (after === null) op = "delete";
1491
+ else op = "modify";
1492
+ const dir = mkdtempSync(join4(tmpdir(), "hf-"));
1493
+ let raw = "";
1494
+ try {
1495
+ const a = join4(dir, "a");
1496
+ const b = join4(dir, "b");
1497
+ writeFileSync(a, before ?? "");
1498
+ writeFileSync(b, after ?? "");
1499
+ try {
1500
+ raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], { encoding: "utf8" });
1501
+ } catch (e) {
1502
+ const err = e;
1503
+ raw = err.stdout ? String(err.stdout) : "";
1504
+ }
1505
+ } finally {
1506
+ rmSync(dir, { recursive: true, force: true });
1507
+ }
1508
+ const first = parseDiff(raw)[0];
1509
+ const hunks = first && first.kind === "file" ? first.hunks : [];
1510
+ return [{ kind: "file", path: displayPath, oldPath: null, op, before, after, binary: false, hunks }];
1511
+ }
1512
+ function changesFromClaudeHook(input, cwd) {
1513
+ const ti = input.tool_input ?? {};
1514
+ switch (input.tool_name) {
1515
+ case "Bash": {
1516
+ const raw = asStr(ti.command);
1517
+ return raw ? [{ kind: "command", raw, argv: raw.split(/\s+/) }] : [];
1518
+ }
1519
+ case "Write": {
1520
+ const fp = asStr(ti.file_path);
1521
+ if (!fp) return [];
1522
+ const before = readDisk(abs(fp, cwd));
1523
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, asStr(ti.content));
1524
+ }
1525
+ case "Edit": {
1526
+ const fp = asStr(ti.file_path);
1527
+ if (!fp) return [];
1528
+ const before = readDisk(abs(fp, cwd));
1529
+ const after = applyEdit(before, asStr(ti.old_string), asStr(ti.new_string));
1530
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
1531
+ }
1532
+ case "MultiEdit": {
1533
+ const fp = asStr(ti.file_path);
1534
+ if (!fp) return [];
1535
+ const before = readDisk(abs(fp, cwd));
1536
+ let after = before;
1537
+ const edits = Array.isArray(ti.edits) ? ti.edits : [];
1538
+ for (const raw of edits) {
1539
+ const ed = raw;
1540
+ after = applyEdit(after, ed.old_string ?? "", ed.new_string ?? "");
1541
+ }
1542
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
1543
+ }
1544
+ case "NotebookEdit": {
1545
+ const fp = asStr(ti.notebook_path);
1546
+ const src = asStr(ti.new_source);
1547
+ if (!fp || !src) return [];
1548
+ return synthFileChange(relForDisplay(abs(fp, cwd), cwd), "", src);
1549
+ }
1550
+ default:
1551
+ return [];
1552
+ }
1553
+ }
1554
+
1555
+ // src/adapters/claude/deny.ts
1556
+ function formatDenial(blocks) {
1557
+ const lead = blocks[0];
1558
+ const lines = ["Tamperward blocked this change \u2014 it weakens a protected safety net to pass checks.", ""];
1559
+ for (const f of blocks) {
1560
+ const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
1561
+ lines.push(` \u2022 ${f.rule}${loc}: ${f.message}`);
1562
+ }
1563
+ lines.push("");
1564
+ lines.push(`Fix the underlying failure in the code under test, not the safety net. ${lead.remediation}`);
1565
+ lines.push(
1566
+ "The other shortcuts are blocked too: skipping the hooks, editing the hook or CI workflow, lowering the coverage gate, and rewriting a protected file from the shell will each be denied."
1567
+ );
1568
+ if (blocks.some((b) => b.signoff.required)) {
1569
+ lines.push(`If this change is genuinely correct, a human must sign off: ${lead.signoff.command}`);
1570
+ }
1571
+ return lines.join("\n") + "\n";
1572
+ }
1573
+
1574
+ // src/session.ts
1575
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
1576
+ import { dirname as dirname2, join as join5 } from "node:path";
1577
+ var UNSAFE = /[^A-Za-z0-9_-]/g;
1578
+ var SHA = /^[0-9a-f]{7,40}$/;
1579
+ function baselinePath(cwd, sessionId) {
1580
+ const gd = gitDir(cwd);
1581
+ if (!gd) return null;
1582
+ return join5(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
1583
+ }
1584
+ function turnBaseline(cwd, sessionId) {
1585
+ if (!sessionId) return null;
1586
+ try {
1587
+ const p = baselinePath(cwd, sessionId);
1588
+ if (!p) return null;
1589
+ if (existsSync4(p)) {
1590
+ const v = readFileSync5(p, "utf8").trim();
1591
+ if (SHA.test(v)) return v;
1592
+ }
1593
+ const head = headSha(cwd);
1594
+ if (!head) return null;
1595
+ mkdirSync2(dirname2(p), { recursive: true });
1596
+ writeFileSync2(p, head);
1597
+ return head;
1598
+ } catch {
1599
+ return null;
1600
+ }
1601
+ }
1602
+ function advanceTurnBaseline(cwd, sessionId) {
1603
+ if (!sessionId) return;
1604
+ try {
1605
+ const p = baselinePath(cwd, sessionId);
1606
+ const head = headSha(cwd);
1607
+ if (!p || !head) return;
1608
+ mkdirSync2(dirname2(p), { recursive: true });
1609
+ writeFileSync2(p, head);
1610
+ } catch {
1611
+ }
1612
+ }
1613
+
1614
+ // src/cli/hook.ts
1615
+ function readStdin() {
1616
+ try {
1617
+ return readFileSync6(0, "utf8");
1618
+ } catch {
1619
+ return "";
1620
+ }
1621
+ }
1622
+ function parseInput(raw) {
1623
+ if (!raw.trim()) return {};
1624
+ try {
1625
+ return JSON.parse(raw);
1626
+ } catch {
1627
+ return {};
1628
+ }
1629
+ }
1630
+ function recordDenylog(blocks) {
1631
+ const log = process.env.TAMPERWARD_DENYLOG;
1632
+ if (!log) return;
1633
+ try {
1634
+ appendFileSync2(log, blocks.map((b) => b.rule).join(",") + "\n");
1635
+ } catch {
1636
+ }
1637
+ }
1638
+ function errText(e) {
1639
+ const m = e instanceof Error ? e.message : String(e);
1640
+ return m.length > 200 ? m.slice(0, 200) + "\u2026" : m;
1641
+ }
1642
+ function failClosed(kind, detail) {
1643
+ const f = {
1644
+ rule: "tamperward-unavailable",
1645
+ severity: "block",
1646
+ message: `Tamperward could not evaluate this change (${detail}), so it is denied rather than allowed.`,
1647
+ evidence: detail,
1648
+ remediation: "Repair the Tamperward setup \u2014 most often an unparseable .tamperward.yml (check for merge-conflict markers) \u2014 then retry. Do not work around the gate while it is down.",
1649
+ signoff: { required: true, command: 'tamperward allow --reason "..."' }
1650
+ };
1651
+ return verdict([f], kind);
1652
+ }
1653
+ function verdict(blocks, kind) {
1654
+ if (blocks.length === 0) return { exitCode: 0, stdout: "" };
1655
+ recordDenylog(blocks);
1656
+ const reason = formatDenial(blocks);
1657
+ const payload = kind === "PreToolUse" ? {
1658
+ hookSpecificOutput: {
1659
+ hookEventName: "PreToolUse",
1660
+ permissionDecision: "deny",
1661
+ permissionDecisionReason: reason
1662
+ }
1663
+ } : { decision: "block", reason };
1664
+ return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
1665
+ }
1666
+ function preToolUseVerdict(input) {
1667
+ try {
1668
+ const cwd = input.cwd ?? process.cwd();
1669
+ turnBaseline(cwd, input.session_id);
1670
+ const changes = changesFromClaudeHook(input, cwd);
1671
+ const blocks = evaluate(changes, loadPolicy(cwd)).filter((f) => f.severity === "block");
1672
+ return verdict(blocks, "PreToolUse");
1673
+ } catch (e) {
1674
+ return failClosed("PreToolUse", errText(e));
1675
+ }
1676
+ }
1677
+ function stopVerdict(input) {
1678
+ if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
1679
+ const cwd = input.cwd ?? process.cwd();
1680
+ if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
1681
+ let blocks;
1682
+ try {
1683
+ const base = turnBaseline(cwd, input.session_id);
1684
+ const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
1685
+ blocks = evaluate(changes, loadPolicy(cwd)).filter((f) => f.severity === "block");
1686
+ } catch (e) {
1687
+ return failClosed("Stop", errText(e));
1688
+ }
1689
+ if (blocks.length === 0) advanceTurnBaseline(cwd, input.session_id);
1690
+ return verdict(blocks, "Stop");
1691
+ }
1692
+ function emit(r) {
1693
+ if (r.stdout) process.stdout.write(r.stdout);
1694
+ return r.exitCode;
1695
+ }
1696
+ function runHookClaude() {
1697
+ try {
1698
+ return emit(preToolUseVerdict(parseInput(readStdin())));
1699
+ } catch (e) {
1700
+ return emit(failClosed("PreToolUse", errText(e)));
1701
+ }
1702
+ }
1703
+ function runSweepClaude() {
1704
+ try {
1705
+ return emit(stopVerdict(parseInput(readStdin())));
1706
+ } catch (e) {
1707
+ return emit(failClosed("Stop", errText(e)));
1708
+ }
1709
+ }
1710
+
1711
+ // src/cli/allow.ts
1712
+ function runAllow(opts) {
1713
+ if (!opts.rule) {
1714
+ process.stderr.write('tamperward allow <rule> [--file <path>] --reason "<why>"\n');
1715
+ return 2;
1716
+ }
1717
+ if (!opts.reason) {
1718
+ process.stderr.write("tamperward: a --reason is required \u2014 sign-offs must be justified.\n");
1719
+ return 2;
1720
+ }
1721
+ const cwd = opts.cwd ?? process.cwd();
1722
+ const policy = loadPolicy(cwd);
1723
+ let findings;
1724
+ try {
1725
+ findings = evaluate(diffWorktree({ cwd }), policy);
1726
+ } catch {
1727
+ process.stderr.write("tamperward: cannot read the working tree (not a git repo?).\n");
1728
+ return 2;
1729
+ }
1730
+ const targets = findings.filter(
1731
+ (f) => f.severity === "block" && f.rule === opts.rule && (!opts.file || f.file === opts.file)
1732
+ );
1733
+ if (targets.length === 0) {
1734
+ process.stderr.write(
1735
+ `tamperward: no current blocking "${opts.rule}"${opts.file ? ` (${opts.file})` : ""} finding to sign off \u2014 nothing recorded.
1736
+ A sign-off must clear a specific tamper that the gate is currently flagging.
1737
+ `
1738
+ );
1739
+ return 2;
1740
+ }
1741
+ const now = Date.now();
1742
+ const seen = /* @__PURE__ */ new Set();
1743
+ for (const f of targets) {
1744
+ const fp = fingerprintOf(f);
1745
+ if (seen.has(fp)) continue;
1746
+ seen.add(fp);
1747
+ appendEntry(cwd, policy, makeEntry(f, opts.reason, now));
1748
+ }
1749
+ process.stdout.write(
1750
+ `Recorded ${seen.size} human sign-off(s) for ${opts.rule}${opts.file ? ` (${opts.file})` : ""}, bound to the current tamper (expires in 30 days).
1751
+ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI requires an out-of-band approval, never a committed entry.
1752
+ `
1753
+ );
1754
+ return 0;
1755
+ }
1756
+
1757
+ // src/cli/index.ts
1758
+ function parseAllow(args) {
1759
+ const o = {};
1760
+ for (let i = 0; i < args.length; i++) {
1761
+ const a = args[i];
1762
+ if (a === "--file") o.file = args[++i];
1763
+ else if (a === "--reason") o.reason = args[++i];
1764
+ else if (a === "--cwd") o.cwd = args[++i];
1765
+ else if (!a.startsWith("--") && !o.rule) o.rule = a;
1766
+ }
1767
+ return o;
1768
+ }
1769
+ function runAgentCommand(kind, args) {
1770
+ const agent = args[0];
1771
+ if (agent !== "claude") {
1772
+ process.stderr.write(`tamperward: unsupported ${kind} agent "${agent ?? ""}" (only "claude" so far)
1773
+ `);
1774
+ return 2;
1775
+ }
1776
+ return kind === "hook" ? runHookClaude() : runSweepClaude();
1777
+ }
1778
+ function parseCheck(args) {
1779
+ const o = {};
1780
+ for (let i = 0; i < args.length; i++) {
1781
+ const a = args[i];
1782
+ if (a === "--staged") o.staged = true;
1783
+ else if (a === "--worktree") o.worktree = true;
1784
+ else if (a === "--json") o.json = true;
1785
+ else if (a === "--diff") o.diff = args[++i];
1786
+ else if (a === "--cwd") o.cwd = args[++i];
1787
+ else {
1788
+ process.stderr.write(`tamperward: unknown flag "${a}"
1789
+ `);
1790
+ }
1791
+ }
1792
+ return o;
1793
+ }
1794
+ function printHelp() {
1795
+ process.stdout.write(`tamperward \u2014 the deterministic agent-integrity gate
1796
+
1797
+ Usage:
1798
+ tamperward check --staged check staged changes (pre-commit)
1799
+ tamperward check --worktree check working-tree changes (stop sweep)
1800
+ tamperward check --diff <base>...<head> check a commit range (CI authority)
1801
+ tamperward check ... --json machine-readable output
1802
+ tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
1803
+ tamperward sweep claude Stop sweep (re-scan the turn's working tree)
1804
+ tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
1805
+
1806
+ Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
1807
+ emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
1808
+ `);
1809
+ }
1810
+ function main(argv) {
1811
+ const [cmd, ...rest] = argv;
1812
+ switch (cmd) {
1813
+ case "check":
1814
+ return runCheck(parseCheck(rest));
1815
+ case "hook":
1816
+ return runAgentCommand("hook", rest);
1817
+ case "sweep":
1818
+ return runAgentCommand("sweep", rest);
1819
+ case "allow":
1820
+ return runAllow(parseAllow(rest));
1821
+ case void 0:
1822
+ case "-h":
1823
+ case "--help":
1824
+ printHelp();
1825
+ return 0;
1826
+ default:
1827
+ process.stderr.write(`tamperward: unknown command "${cmd}"
1828
+ `);
1829
+ printHelp();
1830
+ return 2;
1831
+ }
1832
+ }
1833
+ process.exit(main(process.argv.slice(2)));