tamperward 2.1.1 → 2.2.1
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 +1 -1
- package/dist/cli/index.js +581 -172
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -356,31 +356,59 @@ function tokens(seg) {
|
|
|
356
356
|
function unquote(t) {
|
|
357
357
|
return t.replace(/^['"]+|['"]+$/g, "");
|
|
358
358
|
}
|
|
359
|
+
function words(seg) {
|
|
360
|
+
return tokens(seg).map(unquote);
|
|
361
|
+
}
|
|
362
|
+
var GIT_OPT_WITH_ARG = /* @__PURE__ */ new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"]);
|
|
363
|
+
function gitSubcommand(toks) {
|
|
364
|
+
const i = toks.findIndex((t) => t === "git" || t.endsWith("/git"));
|
|
365
|
+
if (i < 0) return null;
|
|
366
|
+
for (let j = i + 1; j < toks.length; j++) {
|
|
367
|
+
const t = toks[j];
|
|
368
|
+
if (GIT_OPT_WITH_ARG.has(t)) {
|
|
369
|
+
j++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (t.startsWith("-")) continue;
|
|
373
|
+
return t;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
359
377
|
|
|
360
378
|
// src/detectors/no-verify.ts
|
|
361
379
|
var RULE = "no-verify";
|
|
362
|
-
var
|
|
363
|
-
{ re: /(?:^|\s)--no-verify(?:\s|$)/, why: "--no-verify skips the pre-commit/pre-push hooks" },
|
|
364
|
-
{ re: /(?:^|\s)--no-hooks(?:\s|$)/, why: "--no-hooks disables hook execution" },
|
|
365
|
-
{ re: /(?:^|[\s;&|=])HUSKY=0(?:\s|$)/, why: "HUSKY=0 disables all Husky hooks" },
|
|
366
|
-
{ re: /\bHUSKY_SKIP_HOOKS=1\b/, why: "HUSKY_SKIP_HOOKS=1 skips Husky hooks" },
|
|
367
|
-
// git-native hook disabling. core.hooksPath is strictly MORE general than the
|
|
368
|
-
// flags above: it turns off husky, lefthook, plain .git/hooks and pre-commit
|
|
369
|
-
// alike, and it was reachable in three spellings the literal list missed
|
|
370
|
-
// (-c, `git config`, and GIT_CONFIG_* env injection). (P1-9, external review.)
|
|
380
|
+
var RAW = [
|
|
371
381
|
{ re: /\bcore\.hooksPath\b/i, why: "core.hooksPath redirects git away from the installed hooks" },
|
|
372
|
-
{ re: /\bGIT_CONFIG_KEY_\d+\s*=\s*["']?core\.hooksPath/i, why: "GIT_CONFIG_* injection sets core.hooksPath" }
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
{ re:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
{ re:
|
|
381
|
-
{ re: /(?:^|[\s;&|])LEFTHOOK_EXCLUDE=\S+/, why: "LEFTHOOK_EXCLUDE=<name> skips the named Lefthook commands" }
|
|
382
|
+
{ re: /\bGIT_CONFIG_KEY_\d+\s*=\s*["']?core\.hooksPath/i, why: "GIT_CONFIG_* injection sets core.hooksPath" }
|
|
383
|
+
];
|
|
384
|
+
var ENV = [
|
|
385
|
+
{ re: /^HUSKY=0$/, why: "HUSKY=0 disables all Husky hooks" },
|
|
386
|
+
{ re: /^HUSKY_SKIP_HOOKS=1$/, why: "HUSKY_SKIP_HOOKS=1 skips Husky hooks" },
|
|
387
|
+
{ re: /^LEFTHOOK=0$/, why: "LEFTHOOK=0 disables Lefthook hooks" },
|
|
388
|
+
{ re: /^SKIP_SIMPLE_GIT_HOOKS=1$/, why: "SKIP_SIMPLE_GIT_HOOKS=1 skips simple-git-hooks" },
|
|
389
|
+
{ re: /^PRE_COMMIT_ALLOW_NO_CONFIG=1$/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" },
|
|
390
|
+
{ re: /^LEFTHOOK_EXCLUDE=./, why: "LEFTHOOK_EXCLUDE=<name> skips the named Lefthook commands" }
|
|
382
391
|
];
|
|
392
|
+
var SKIP = /^SKIP=./;
|
|
393
|
+
var SKIP_WHY = "SKIP=<hook-id> tells the pre-commit framework to skip that hook";
|
|
394
|
+
var ENV_PREFIX = /^(?:export|env|sudo)$/;
|
|
395
|
+
var ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
396
|
+
var HOOK_SUBCOMMANDS = /* @__PURE__ */ new Set(["commit", "push", "merge"]);
|
|
397
|
+
var NO_VERIFY = /^--no-veri(?:f|fy)?$/;
|
|
398
|
+
var NO_HOOKS = "--no-hooks";
|
|
383
399
|
var SHORT_N = /^-[a-z]*n[a-z]*$/i;
|
|
400
|
+
var TAKES_VALUE = /* @__PURE__ */ new Set(["-m", "--message", "-F", "--file", "--author", "--date", "--trailer", "-c", "-C", "--reuse-message", "--reedit-message"]);
|
|
401
|
+
function gitArgs(toks, sub) {
|
|
402
|
+
const out2 = [];
|
|
403
|
+
for (let j = toks.indexOf(sub) + 1; j < toks.length; j++) {
|
|
404
|
+
if (TAKES_VALUE.has(toks[j])) {
|
|
405
|
+
j++;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
out2.push(toks[j]);
|
|
409
|
+
}
|
|
410
|
+
return out2;
|
|
411
|
+
}
|
|
384
412
|
var noVerify = {
|
|
385
413
|
id: RULE,
|
|
386
414
|
surface: ["command"],
|
|
@@ -389,24 +417,53 @@ var noVerify = {
|
|
|
389
417
|
const out2 = [];
|
|
390
418
|
for (const c of changes) {
|
|
391
419
|
if (c.kind !== "command") continue;
|
|
420
|
+
let carriedSkip = null;
|
|
392
421
|
for (const seg of segments(c.raw)) {
|
|
393
422
|
let why = null;
|
|
394
|
-
|
|
395
|
-
|
|
423
|
+
let evidence = seg;
|
|
424
|
+
for (const p of RAW) {
|
|
425
|
+
if (p.re.test(seg)) {
|
|
396
426
|
why = p.why;
|
|
397
427
|
break;
|
|
398
428
|
}
|
|
399
429
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
430
|
+
const toks = words(seg);
|
|
431
|
+
let k = 0;
|
|
432
|
+
while (k < toks.length && ENV_PREFIX.test(toks[k])) k++;
|
|
433
|
+
const exported = k > 0 && toks[0] === "export";
|
|
434
|
+
const assigns = [];
|
|
435
|
+
while (k < toks.length && ASSIGNMENT.test(toks[k])) assigns.push(toks[k++]);
|
|
436
|
+
const bareAssignment = k === toks.length && assigns.length > 0;
|
|
437
|
+
const sub = gitSubcommand(toks);
|
|
438
|
+
const runsHookTool = sub !== null || toks.includes("pre-commit");
|
|
439
|
+
if (!why) {
|
|
440
|
+
for (const a of assigns) {
|
|
441
|
+
const hit = ENV.find((p) => p.re.test(a));
|
|
442
|
+
if (hit) {
|
|
443
|
+
why = hit.why;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
if (SKIP.test(a)) {
|
|
447
|
+
if (runsHookTool) why = SKIP_WHY;
|
|
448
|
+
else if (exported || bareAssignment) carriedSkip = seg;
|
|
449
|
+
}
|
|
403
450
|
}
|
|
404
451
|
}
|
|
452
|
+
if (!why && carriedSkip && runsHookTool) {
|
|
453
|
+
why = `${SKIP_WHY} (set earlier in the same command)`;
|
|
454
|
+
evidence = `${carriedSkip}; ${seg}`;
|
|
455
|
+
}
|
|
456
|
+
if (!why && sub && HOOK_SUBCOMMANDS.has(sub)) {
|
|
457
|
+
const args = gitArgs(toks, sub);
|
|
458
|
+
if (args.some((t) => NO_VERIFY.test(t))) why = "--no-verify skips the pre-commit/pre-push hooks";
|
|
459
|
+
else if (sub === "commit" && args.some((t) => SHORT_N.test(t))) why = "git commit -n skips the pre-commit hook";
|
|
460
|
+
}
|
|
461
|
+
if (!why && toks.includes(NO_HOOKS)) why = "--no-hooks disables hook execution";
|
|
405
462
|
if (why) {
|
|
406
463
|
out2.push(
|
|
407
464
|
makeFinding(RULE, policy, {
|
|
408
465
|
message: `Command bypasses git hooks: ${why}.`,
|
|
409
|
-
evidence
|
|
466
|
+
evidence,
|
|
410
467
|
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."
|
|
411
468
|
})
|
|
412
469
|
);
|
|
@@ -443,6 +500,16 @@ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
|
443
500
|
}
|
|
444
501
|
return out2;
|
|
445
502
|
}
|
|
503
|
+
function normalizeGlob(glob) {
|
|
504
|
+
return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
|
|
505
|
+
}
|
|
506
|
+
function mergeRules(base, user) {
|
|
507
|
+
const out2 = { ...base };
|
|
508
|
+
for (const [name, cfg] of Object.entries(user ?? {})) {
|
|
509
|
+
out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
|
|
510
|
+
}
|
|
511
|
+
return out2;
|
|
512
|
+
}
|
|
446
513
|
var cache = /* @__PURE__ */ new Map();
|
|
447
514
|
function matcher(glob) {
|
|
448
515
|
let m = cache.get(glob);
|
|
@@ -452,12 +519,12 @@ function matcher(glob) {
|
|
|
452
519
|
}
|
|
453
520
|
return m;
|
|
454
521
|
}
|
|
455
|
-
function matchesAny(path,
|
|
456
|
-
return !!
|
|
522
|
+
function matchesAny(path, globs2) {
|
|
523
|
+
return !!globs2 && globs2.some((g) => matcher(g)(path));
|
|
457
524
|
}
|
|
458
525
|
function protectedCategory(path, policy) {
|
|
459
|
-
for (const [cat,
|
|
460
|
-
if (matchesAny(path,
|
|
526
|
+
for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
|
|
527
|
+
if (matchesAny(path, globs2)) return cat;
|
|
461
528
|
}
|
|
462
529
|
return null;
|
|
463
530
|
}
|
|
@@ -473,9 +540,9 @@ function isIgnored(path, policy) {
|
|
|
473
540
|
}
|
|
474
541
|
function mergeProtected(base, user) {
|
|
475
542
|
const out2 = { ...base };
|
|
476
|
-
for (const [cat,
|
|
543
|
+
for (const [cat, globs2] of Object.entries(user ?? {})) {
|
|
477
544
|
const have = new Set(out2[cat] ?? []);
|
|
478
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
545
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
|
|
479
546
|
}
|
|
480
547
|
return out2;
|
|
481
548
|
}
|
|
@@ -635,12 +702,38 @@ function isSignificantLine(trimmed, lang) {
|
|
|
635
702
|
if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
|
|
636
703
|
return true;
|
|
637
704
|
}
|
|
705
|
+
var SLASH_COMMENT = /^(?:\/\/|\/\*|\*(?:\s|\/|$))/;
|
|
706
|
+
var HASH_COMMENT_LANGS = /* @__PURE__ */ new Set(["py", "rb", "php"]);
|
|
707
|
+
function isCommentLine(trimmed, lang) {
|
|
708
|
+
const l = lang ?? "js";
|
|
709
|
+
if (HASH_COMMENT_LANGS.has(l) && trimmed.startsWith("#") && !trimmed.startsWith("#[")) return true;
|
|
710
|
+
if (l === "py" || l === "rb") return false;
|
|
711
|
+
return SLASH_COMMENT.test(trimmed);
|
|
712
|
+
}
|
|
713
|
+
function insideStringLiteral(line, idx, lang) {
|
|
714
|
+
const hash = HASH_COMMENT_LANGS.has(lang ?? "js");
|
|
715
|
+
let quote = null;
|
|
716
|
+
for (let j = 0; j < idx; j++) {
|
|
717
|
+
const ch = line[j];
|
|
718
|
+
if (quote) {
|
|
719
|
+
if (ch === "\\") j++;
|
|
720
|
+
else if (ch === quote) quote = null;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
724
|
+
else if (ch === "/" && (line[j + 1] === "/" || line[j + 1] === "*")) return false;
|
|
725
|
+
else if (hash && ch === "#") return false;
|
|
726
|
+
}
|
|
727
|
+
return quote !== null;
|
|
728
|
+
}
|
|
638
729
|
|
|
639
730
|
// src/detectors/ts-any-cast.ts
|
|
640
731
|
var BLOCK_RULE = "ts-any-cast";
|
|
641
732
|
var WARN_RULE = "ts-any-launder";
|
|
642
733
|
var DOUBLE_CAST = /\bas\s+unknown\s+as\b/g;
|
|
643
734
|
var SUPPRESS = /@ts-(?:ignore|expect-error|nocheck)\b/g;
|
|
735
|
+
var JSDOC_ANY_CAST = /\/\*\*\s*@type\s*\{\s*any\s*\}\s*\*\/\s*\(/g;
|
|
736
|
+
var JS_FILE = /\.(?:js|jsx|mjs|cjs)$/;
|
|
644
737
|
var countMatches = (s, re) => (s.match(re) || []).length;
|
|
645
738
|
function countAny(src) {
|
|
646
739
|
const r = { cast: 0, broad: 0 };
|
|
@@ -648,8 +741,13 @@ function countAny(src) {
|
|
|
648
741
|
const sf = ts.createSourceFile("f.ts", src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
649
742
|
const visit = (node) => {
|
|
650
743
|
if (node.kind === ts.SyntaxKind.AnyKeyword) {
|
|
651
|
-
|
|
652
|
-
|
|
744
|
+
let target = node;
|
|
745
|
+
let p = target.parent;
|
|
746
|
+
while (p && ts.isParenthesizedTypeNode(p)) {
|
|
747
|
+
target = p;
|
|
748
|
+
p = p.parent;
|
|
749
|
+
}
|
|
750
|
+
const isCast = p && ts.isAsExpression(p) && p.type === target || p && ts.isTypeAssertionExpression(p) && p.type === target;
|
|
653
751
|
if (isCast) r.cast++;
|
|
654
752
|
else r.broad++;
|
|
655
753
|
}
|
|
@@ -660,7 +758,7 @@ function countAny(src) {
|
|
|
660
758
|
}
|
|
661
759
|
return r;
|
|
662
760
|
}
|
|
663
|
-
var NARROW_LINE = /\bas\s
|
|
761
|
+
var NARROW_LINE = /\bas\s+\(*\s*any\b|<\s*any\s*>|\bas\s+unknown\s+as\b|@ts-(?:ignore|expect-error|nocheck)\b/;
|
|
664
762
|
var BROAD_LINE = /:\s*any\b|<[^<>]*\bany\b[^<>]*>/;
|
|
665
763
|
var BLOCK_REMEDIATION = "Fix the underlying type instead of silencing the checker; do not cast to `any`.";
|
|
666
764
|
var WARN_REMEDIATION = "Prefer a precise type or `unknown` + a guard over `any` here \u2014 flagged for review.";
|
|
@@ -681,10 +779,12 @@ var tsAnyCast = {
|
|
|
681
779
|
const dBroad = a.broad - b.broad;
|
|
682
780
|
const dDouble = countMatches(c.after, DOUBLE_CAST) - countMatches(before, DOUBLE_CAST);
|
|
683
781
|
const dSuppr = countMatches(c.after, SUPPRESS) - countMatches(before, SUPPRESS);
|
|
782
|
+
const dJsdoc = JS_FILE.test(c.path) ? countMatches(c.after, JSDOC_ANY_CAST) - countMatches(before, JSDOC_ANY_CAST) : 0;
|
|
684
783
|
const blockReasons = [];
|
|
685
784
|
if (dCast > 0) blockReasons.push("`as any` cast");
|
|
686
785
|
if (dDouble > 0) blockReasons.push("`as unknown as` double cast");
|
|
687
786
|
if (dSuppr > 0) blockReasons.push("@ts-ignore/@ts-expect-error/@ts-nocheck suppression");
|
|
787
|
+
if (dJsdoc > 0) blockReasons.push("JSDoc `@type {any}` cast");
|
|
688
788
|
if (blockReasons.length) {
|
|
689
789
|
out2.push(
|
|
690
790
|
makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
|
|
@@ -709,8 +809,9 @@ var tsAnyCast = {
|
|
|
709
809
|
}
|
|
710
810
|
continue;
|
|
711
811
|
}
|
|
812
|
+
const jsdocCast = JS_FILE.test(c.path) ? new RegExp(JSDOC_ANY_CAST.source) : null;
|
|
712
813
|
for (const l of addedLines(c)) {
|
|
713
|
-
if (NARROW_LINE.test(l.content)) {
|
|
814
|
+
if (NARROW_LINE.test(l.content) || jsdocCast && jsdocCast.test(l.content)) {
|
|
714
815
|
out2.push(
|
|
715
816
|
makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
|
|
716
817
|
file: c.path,
|
|
@@ -744,30 +845,47 @@ var RULE2 = "lint-suppression";
|
|
|
744
845
|
var PATTERNS = {
|
|
745
846
|
js: [
|
|
746
847
|
{ re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
|
|
848
|
+
// Inline config is the other spelling of the same directive: `/* eslint rule: off */`,
|
|
849
|
+
// `: "off"`, `: 0`, `: [0, …]`. `/* eslint-env */` and `/* eslint rule: "error" */` are not.
|
|
850
|
+
{ re: /\/\*\s*eslint\s+(?!-)(?:[^*]*?[\s,])?[\w@/.-]+\s*:\s*\[?\s*(?:["']off["']|off\b|0\b)/, why: "an inline eslint config turns a rule off" },
|
|
747
851
|
{ re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
|
|
748
852
|
{ re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
|
|
749
853
|
],
|
|
750
854
|
py: [
|
|
751
855
|
{ re: /#\s*noqa\b/, why: "# noqa suppresses flake8/ruff findings" },
|
|
856
|
+
{ re: /#\s*(?:ruff|flake8):\s*noqa\b/, why: "# ruff: noqa / # flake8: noqa suppresses every finding in the file" },
|
|
752
857
|
{ re: /#\s*type:\s*ignore\b/, why: "# type: ignore suppresses the type checker" },
|
|
858
|
+
{ re: /#\s*mypy:\s*ignore-errors\b/, why: "# mypy: ignore-errors suppresses the type checker for the file" },
|
|
753
859
|
{ re: /#\s*pyright:\s*ignore\b/, why: "# pyright: ignore suppresses the type checker" },
|
|
754
860
|
{ re: /#\s*pylint:\s*disable\b/, why: "# pylint: disable suppresses lint rules" },
|
|
861
|
+
{ re: /#\s*pylint:\s*skip-file\b/, why: "# pylint: skip-file suppresses lint for the file" },
|
|
755
862
|
{ re: /#\s*nosec\b/, why: "# nosec suppresses bandit security findings" },
|
|
756
863
|
{ re: /#\s*fmt:\s*(?:off|skip)\b/, why: "# fmt: off/skip suppresses formatting checks" }
|
|
757
864
|
],
|
|
758
|
-
go: [
|
|
865
|
+
go: [
|
|
866
|
+
{ re: /\/\/\s*nolint\b/, why: "//nolint suppresses golangci-lint findings" },
|
|
867
|
+
{ re: /\/\/\s*lint:ignore\b/, why: "//lint:ignore suppresses staticcheck findings" }
|
|
868
|
+
],
|
|
759
869
|
rs: [],
|
|
760
870
|
rb: [{ re: /#\s*rubocop:\s*(?:disable|todo)\b/, why: "# rubocop:disable suppresses lint rules" }],
|
|
761
|
-
java: [
|
|
762
|
-
|
|
871
|
+
java: [
|
|
872
|
+
{ re: /@SuppressWarnings\s*\(/, why: "@SuppressWarnings suppresses compiler and lint warnings" },
|
|
873
|
+
{ re: /\/\/\s*NOSONAR\b/, why: "// NOSONAR suppresses SonarQube findings" }
|
|
874
|
+
],
|
|
875
|
+
kt: [
|
|
876
|
+
{ re: /@(?:file:)?Suppress(?:Warnings)?\s*\(/, why: "@Suppress suppresses compiler and lint warnings" },
|
|
877
|
+
{ re: /\/\/\s*NOSONAR\b/, why: "// NOSONAR suppresses SonarQube findings" }
|
|
878
|
+
],
|
|
763
879
|
php: [
|
|
764
880
|
{ re: /\bphpcs:(?:ignore|disable)\b/, why: "phpcs:ignore/disable suppresses coding-standard checks" },
|
|
881
|
+
{ re: /@codingStandardsIgnore(?:Line|Start|File)\b/, why: "@codingStandardsIgnore* suppresses coding-standard checks" },
|
|
765
882
|
{ re: /@phpstan-ignore\b/, why: "@phpstan-ignore suppresses static-analysis findings" },
|
|
766
883
|
{ re: /@psalm-suppress\b/, why: "@psalm-suppress suppresses static-analysis findings" }
|
|
767
884
|
],
|
|
768
885
|
cs: [
|
|
769
886
|
{ re: /#pragma\s+warning\s+disable\b/, why: "#pragma warning disable suppresses compiler warnings" },
|
|
770
|
-
{ re: /\[SuppressMessage\s*\(/, why: "[SuppressMessage] suppresses analyzer findings" }
|
|
887
|
+
{ re: /\[SuppressMessage\s*\(/, why: "[SuppressMessage] suppresses analyzer findings" },
|
|
888
|
+
{ re: /\/\/\s*ReSharper\s+disable\b/, why: "// ReSharper disable suppresses inspections" }
|
|
771
889
|
]
|
|
772
890
|
};
|
|
773
891
|
var lintSuppression = {
|
|
@@ -784,18 +902,18 @@ var lintSuppression = {
|
|
|
784
902
|
if (patterns.length === 0) continue;
|
|
785
903
|
for (const l of addedLines(c)) {
|
|
786
904
|
for (const p of patterns) {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
)
|
|
797
|
-
|
|
798
|
-
|
|
905
|
+
const m = p.re.exec(l.content);
|
|
906
|
+
if (!m || insideStringLiteral(l.content, m.index, lang)) continue;
|
|
907
|
+
out2.push(
|
|
908
|
+
makeFinding(RULE2, policy, {
|
|
909
|
+
file: c.path,
|
|
910
|
+
line: l.newLine ?? void 0,
|
|
911
|
+
message: `Lint suppression added: ${p.why}.`,
|
|
912
|
+
evidence: l.content.trim(),
|
|
913
|
+
remediation: "Resolve the finding rather than suppressing the rule."
|
|
914
|
+
})
|
|
915
|
+
);
|
|
916
|
+
break;
|
|
799
917
|
}
|
|
800
918
|
}
|
|
801
919
|
}
|
|
@@ -807,40 +925,66 @@ var lintSuppression = {
|
|
|
807
925
|
var RULE3 = "test-skip";
|
|
808
926
|
var PATTERNS2 = {
|
|
809
927
|
js: [
|
|
810
|
-
|
|
811
|
-
{ re: /\b(?:
|
|
812
|
-
{ re: /\b(?:
|
|
928
|
+
// vitest chains a concurrency modifier before the marker: it.concurrent.skip.
|
|
929
|
+
{ re: /\b(?:it|test|describe|suite)(?:\.(?:concurrent|sequential|shuffle))?\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
|
|
930
|
+
{ re: /\b(?:it|test|describe|suite)\.(?:skipIf|runIf)\s*\(/, why: "a .skipIf()/.runIf() condition (the test runs only when the condition allows)" },
|
|
931
|
+
{ re: /\b(?:it|test)\.(?:fails|failing)\s*\(/, why: "a .fails/.failing marker (the test now passes by failing)" },
|
|
932
|
+
{ re: /\b(?:it|test|describe)\.each\s*\(\s*\[\s*\]\s*\)/, why: "an empty .each table (no case ever runs)" },
|
|
933
|
+
{ re: /\b(?:xit|xtest|xdescribe)\s*\(/, why: "an x-prefixed disabled test" },
|
|
934
|
+
{ re: /\b(?:fit|ftest|fdescribe)\s*\(/, why: "an f-prefixed focused test (narrows the suite)" },
|
|
813
935
|
{ re: /\bpending\(\s*\)/, why: "a pending() marker" },
|
|
814
936
|
// node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
|
|
815
937
|
// a runtime call. A live evasion used the options form the day this gap was measured
|
|
816
938
|
// (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
|
|
817
|
-
|
|
939
|
+
// In options position (right after the title) ANY value but false/0 skips — a
|
|
940
|
+
// non-literal `{ skip: process.env.CI }` included. Elsewhere on a line only the
|
|
941
|
+
// literal forms count: `list({ skip: 1, take: 5 })` is pagination.
|
|
942
|
+
{ 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)" },
|
|
943
|
+
{ re: /\bskip:\s*(?:true|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
|
|
818
944
|
{ re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
|
|
819
|
-
{ re: /\b(?:t|ctx|context)\.skip\(/, why: "a runtime t.skip() call" }
|
|
945
|
+
{ re: /\b(?:t|ctx|context|this)\.skip\(/, why: "a runtime t.skip()/this.skip() call" }
|
|
820
946
|
],
|
|
821
947
|
py: [
|
|
822
|
-
|
|
948
|
+
// `import pytest as pt` makes the decorator `@pt.mark.skip`; the module alias is free.
|
|
949
|
+
{ re: /@\w+\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
|
|
950
|
+
{ re: /\bpytestmark\s*=.*\.mark\.(?:skip|skipif|xfail)\b/, why: "a module-level pytestmark skip (every test in the file is skipped)" },
|
|
823
951
|
{ re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
|
|
824
|
-
|
|
952
|
+
// `from unittest import skip` makes the decorator bare `@skip`.
|
|
953
|
+
{ re: /@(?:unittest\.)?(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
|
|
825
954
|
{ re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
|
|
826
|
-
{ re: /\braise\s+(?:unittest\.)?SkipTest\b/, why: "a raised SkipTest" }
|
|
955
|
+
{ re: /\braise\s+(?:unittest\.)?SkipTest\b/, why: "a raised SkipTest" },
|
|
956
|
+
{ re: /\b__test__\s*=\s*False\b/, why: "__test__ = False hides the test from collection" }
|
|
827
957
|
],
|
|
828
958
|
go: [
|
|
829
|
-
{ re: /\b[tb]\.Skip(?:f|Now)?\(/, why: "a runtime t.Skip()/Skipf()/SkipNow() call" }
|
|
959
|
+
{ re: /\b[tb]\.Skip(?:f|Now)?\(/, why: "a runtime t.Skip()/Skipf()/SkipNow() call" },
|
|
960
|
+
{ re: /\bif\s+testing\.Short\(\)/, why: "a testing.Short() guard (the body is skipped under -short)" },
|
|
961
|
+
{ re: /^\s*\/\/\s*(?:go:build|\+build)\s+ignore\b/, why: "a build-ignore constraint (the file is excluded from the test run)", comment: true }
|
|
962
|
+
],
|
|
963
|
+
rs: [
|
|
964
|
+
{ re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" },
|
|
965
|
+
{ re: /#\[cfg_attr\(.*\bignore\b/, why: "a cfg_attr(\u2026, ignore) attribute (the test is ignored under that configuration)" }
|
|
830
966
|
],
|
|
831
|
-
rs: [{ re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" }],
|
|
832
967
|
rb: [
|
|
833
|
-
{ re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*$)/, why: "a skip/pending call" },
|
|
834
|
-
{ re:
|
|
835
|
-
{ re:
|
|
836
|
-
{ re: /\b(?:skip|pending):\s*(?:true|['"])/, why: "a skip
|
|
968
|
+
{ re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*\}|\s*$)/, why: "a skip/pending call" },
|
|
969
|
+
{ re: /(?:^|[\s;{(])x(?:it|describe|context|specify|example)\s*[('"]/, why: "an x-prefixed disabled example" },
|
|
970
|
+
{ re: /(?:^|[\s;{(])f(?:it|describe|context)\s*[('"]/, why: "an f-prefixed focused example (narrows the suite)" },
|
|
971
|
+
{ re: /\b(?:skip|pending):\s*(?:true|['"])/, why: "a skip/pending metadata flag" },
|
|
972
|
+
{ re: /^\s*(?:it|specify|example)\s+(['"])(?:(?!\1).)*\1\s*$/, why: "an example with no block (RSpec reports it as pending)" }
|
|
973
|
+
],
|
|
974
|
+
java: [
|
|
975
|
+
{ re: /@(?:[\w.]+\.)?(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" },
|
|
976
|
+
{ re: /\bassume(?:True\(\s*false|False\(\s*true)\s*[,)]/, why: "an assumption that never holds (the test aborts as skipped)" }
|
|
977
|
+
],
|
|
978
|
+
kt: [
|
|
979
|
+
{ re: /@(?:[\w.]+\.)?(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" },
|
|
980
|
+
{ re: /\bassume(?:True\(\s*false|False\(\s*true)\s*[,)]/, why: "an assumption that never holds (the test aborts as skipped)" }
|
|
837
981
|
],
|
|
838
|
-
java: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
|
|
839
|
-
kt: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
|
|
840
982
|
php: [{ re: /\bmarkTest(?:Skipped|Incomplete)\(/, why: "a markTestSkipped()/markTestIncomplete() call" }],
|
|
841
983
|
cs: [
|
|
842
984
|
{ re: /\[Ignore\b/, why: "an [Ignore] attribute" },
|
|
843
|
-
{ re: /\bSkip\s*=\s*"/, why: 'a Skip = "..." attribute argument' }
|
|
985
|
+
{ re: /\bSkip\s*=\s*"/, why: 'a Skip = "..." attribute argument' },
|
|
986
|
+
{ re: /(?:\[|,)\s*Explicit\b/, why: "an [Explicit] attribute (the test runs only when selected by name)" },
|
|
987
|
+
{ re: /\bAssert\.Inconclusive\(/, why: "an Assert.Inconclusive() call (the test ends without a verdict)" }
|
|
844
988
|
]
|
|
845
989
|
};
|
|
846
990
|
var testSkip = {
|
|
@@ -852,9 +996,12 @@ var testSkip = {
|
|
|
852
996
|
for (const c of changes) {
|
|
853
997
|
if (c.kind !== "file") continue;
|
|
854
998
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
855
|
-
const
|
|
999
|
+
const lang = langOf(c.path);
|
|
1000
|
+
const patterns = PATTERNS2[lang ?? "js"];
|
|
856
1001
|
for (const l of addedLines(c)) {
|
|
1002
|
+
const comment = isCommentLine(l.content.trim(), lang);
|
|
857
1003
|
for (const p of patterns) {
|
|
1004
|
+
if (comment && !p.comment) continue;
|
|
858
1005
|
if (p.re.test(l.content)) {
|
|
859
1006
|
out2.push(
|
|
860
1007
|
makeFinding(RULE3, policy, {
|
|
@@ -1164,6 +1311,7 @@ var ciTampering = {
|
|
|
1164
1311
|
|
|
1165
1312
|
// src/detectors/policy-diff.ts
|
|
1166
1313
|
import { parse } from "yaml";
|
|
1314
|
+
var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
1167
1315
|
function safeParse(src) {
|
|
1168
1316
|
try {
|
|
1169
1317
|
const v = parse(src);
|
|
@@ -1177,12 +1325,19 @@ function effective(raw) {
|
|
|
1177
1325
|
const v = raw.version;
|
|
1178
1326
|
const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
|
|
1179
1327
|
const base = defaultPolicy(version);
|
|
1328
|
+
const rules = { ...base.rules };
|
|
1329
|
+
for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
|
|
1330
|
+
const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
1331
|
+
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs(over.exclude) } : {} };
|
|
1332
|
+
}
|
|
1333
|
+
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs(list)])) : void 0;
|
|
1180
1334
|
return {
|
|
1181
1335
|
version,
|
|
1182
|
-
rules
|
|
1183
|
-
ignore: raw.ignore
|
|
1184
|
-
protected: mergeProtected(base.protected,
|
|
1185
|
-
requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor
|
|
1336
|
+
rules,
|
|
1337
|
+
ignore: raw.ignore ? globs(raw.ignore) : base.ignore ?? [],
|
|
1338
|
+
protected: mergeProtected(base.protected, userProtected),
|
|
1339
|
+
requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1340
|
+
ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
|
|
1186
1341
|
};
|
|
1187
1342
|
}
|
|
1188
1343
|
function policyWeakening(before, after) {
|
|
@@ -1234,6 +1389,9 @@ function policyWeakening(before, after) {
|
|
|
1234
1389
|
if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
|
|
1235
1390
|
reasons.push("sign-off no longer required for blocking findings");
|
|
1236
1391
|
}
|
|
1392
|
+
if (ae.ledger !== be.ledger) {
|
|
1393
|
+
reasons.push(`sign-off ledger moved (${be.ledger} \u2192 ${ae.ledger}) \u2014 the local layer would trust a different file`);
|
|
1394
|
+
}
|
|
1237
1395
|
const bv = b.verify;
|
|
1238
1396
|
const av = a.verify;
|
|
1239
1397
|
if (bv?.command) {
|
|
@@ -1633,10 +1791,13 @@ var testContentRemoval = {
|
|
|
1633
1791
|
// src/detectors/snapshot-rewrite.ts
|
|
1634
1792
|
var RULE9 = "snapshot-rewrite";
|
|
1635
1793
|
var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
|
|
1636
|
-
var
|
|
1794
|
+
var PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
|
|
1795
|
+
var UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
|
|
1637
1796
|
var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
|
|
1797
|
+
var INSTA_ACCEPT = /\bcargo\s+insta\s+(?:accept\b|.*\s--accept\b)/;
|
|
1798
|
+
var UPDATE_EXPECT = /(?:^|\s)UPDATE_EXPECT=\S+/;
|
|
1638
1799
|
var REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
|
|
1639
|
-
var MUTATE = /(?:^|\s)(?:rm|cp|mv|tee|truncate|dd)(?:\s|$)|sed\s+-i|>{1,2}/;
|
|
1800
|
+
var MUTATE = /(?:^|\s)(?:rm|cp|mv|tee|truncate|dd)(?:\s|$)|sed\s+-i|>{1,2}|\bfind\b.*\s-(?:delete\b|exec\s+rm\b)|\bgit\s+(?:checkout|restore)\b/;
|
|
1640
1801
|
function namesProtectedSnapshot(seg, policy) {
|
|
1641
1802
|
for (const t of tokens(seg)) {
|
|
1642
1803
|
const path = unquote(t).replace(/^>{1,2}/, "");
|
|
@@ -1656,7 +1817,9 @@ var snapshotRewrite = {
|
|
|
1656
1817
|
for (const seg of segments(c.raw)) {
|
|
1657
1818
|
let why = null;
|
|
1658
1819
|
if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
|
|
1659
|
-
else if (RUNNER.test(seg) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
|
|
1820
|
+
else if ((RUNNER.test(seg) || PM_TEST_SCRIPT.test(seg)) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
|
|
1821
|
+
else if (INSTA_ACCEPT.test(seg)) why = "cargo insta accept rewrites every pending snapshot to match current output";
|
|
1822
|
+
else if (UPDATE_EXPECT.test(seg)) why = "UPDATE_EXPECT=1 rewrites every expect-test expectation to match current output";
|
|
1660
1823
|
else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
|
|
1661
1824
|
else {
|
|
1662
1825
|
const path = MUTATE.test(seg) ? namesProtectedSnapshot(seg, policy) : null;
|
|
@@ -1789,7 +1952,7 @@ function hasBlocking(findings) {
|
|
|
1789
1952
|
|
|
1790
1953
|
// src/policy-load.ts
|
|
1791
1954
|
import { readFileSync as readFileSync2, existsSync } from "node:fs";
|
|
1792
|
-
import { join as join2 } from "node:path";
|
|
1955
|
+
import { isAbsolute, join as join2, posix } from "node:path";
|
|
1793
1956
|
import { parse as parse2 } from "yaml";
|
|
1794
1957
|
var PolicyError = class extends Error {
|
|
1795
1958
|
};
|
|
@@ -1804,11 +1967,21 @@ var SEVERITIES = ["block", "warn"];
|
|
|
1804
1967
|
var isSeverity = (v) => SEVERITIES.includes(v);
|
|
1805
1968
|
var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
1806
1969
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1970
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
1971
|
+
function ledgerInsideRepo(ledger) {
|
|
1972
|
+
if (!ledger.trim() || isAbsolute(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
1973
|
+
const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
|
|
1974
|
+
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
1975
|
+
}
|
|
1807
1976
|
function validate(r, where) {
|
|
1808
1977
|
function bad(msg) {
|
|
1809
1978
|
throw new PolicyError(`${where}: ${msg}`);
|
|
1810
1979
|
}
|
|
1811
1980
|
const show = (v) => JSON.stringify(v) ?? String(v);
|
|
1981
|
+
const unknown = Object.keys(r).filter((k) => !TOP_LEVEL_KEYS.has(k));
|
|
1982
|
+
if (unknown.length) {
|
|
1983
|
+
bad(`unknown top-level key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => JSON.stringify(k)).join(", ")} (expected one of ${[...TOP_LEVEL_KEYS].join(", ")})`);
|
|
1984
|
+
}
|
|
1812
1985
|
if (r.rules !== void 0) {
|
|
1813
1986
|
if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
|
|
1814
1987
|
for (const [name, cfg] of Object.entries(r.rules)) {
|
|
@@ -1827,8 +2000,8 @@ function validate(r, where) {
|
|
|
1827
2000
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
1828
2001
|
if (r.protected !== void 0) {
|
|
1829
2002
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
1830
|
-
for (const [cat,
|
|
1831
|
-
if (!isStringList(
|
|
2003
|
+
for (const [cat, globs2] of Object.entries(r.protected)) {
|
|
2004
|
+
if (!isStringList(globs2)) bad(`protected.${cat} must be a list of globs, got ${show(globs2)}`);
|
|
1832
2005
|
}
|
|
1833
2006
|
}
|
|
1834
2007
|
if (r.signoff !== void 0) {
|
|
@@ -1841,6 +2014,9 @@ function validate(r, where) {
|
|
|
1841
2014
|
}
|
|
1842
2015
|
const ledger = r.signoff.ledger;
|
|
1843
2016
|
if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
|
|
2017
|
+
if (typeof ledger === "string" && !ledgerInsideRepo(ledger)) {
|
|
2018
|
+
bad(`signoff.ledger must be a relative path inside the repository, got ${show(ledger)}`);
|
|
2019
|
+
}
|
|
1844
2020
|
}
|
|
1845
2021
|
if (r.verify !== void 0) {
|
|
1846
2022
|
if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
|
|
@@ -1857,14 +2033,20 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
1857
2033
|
const version = normalizeVersion(r.version, where);
|
|
1858
2034
|
validate(r, where);
|
|
1859
2035
|
const base = defaultPolicy(version);
|
|
2036
|
+
const globs2 = (list) => list?.map(normalizeGlob);
|
|
2037
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs2(list) ?? []])) : void 0;
|
|
2038
|
+
const userRules = r.rules ? Object.fromEntries(
|
|
2039
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs2(cfg.exclude) } : cfg])
|
|
2040
|
+
) : void 0;
|
|
1860
2041
|
return {
|
|
1861
2042
|
version,
|
|
1862
2043
|
// Merge with the baseline, never replace. For an integrity tool, a config that sets
|
|
1863
2044
|
// one rule's severity must NOT silently drop the other nine — nor may naming one
|
|
1864
|
-
// protected glob wipe out the rest of its category (see mergeProtected)
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2045
|
+
// protected glob wipe out the rest of its category (see mergeProtected), nor may
|
|
2046
|
+
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
2047
|
+
protected: mergeProtected(base.protected, userProtected),
|
|
2048
|
+
rules: mergeRules(base.rules, userRules),
|
|
2049
|
+
ignore: globs2(r.ignore) ?? base.ignore,
|
|
1868
2050
|
signoff: {
|
|
1869
2051
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1870
2052
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -1914,7 +2096,11 @@ function fingerprint(rule, file, evidence) {
|
|
|
1914
2096
|
}
|
|
1915
2097
|
var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
|
|
1916
2098
|
function ledgerPath(cwd, policy) {
|
|
1917
|
-
|
|
2099
|
+
const rel = policy.signoff?.ledger ?? ".tamperward/ledger.jsonl";
|
|
2100
|
+
if (!ledgerInsideRepo(rel)) {
|
|
2101
|
+
throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
|
|
2102
|
+
}
|
|
2103
|
+
return join3(cwd, rel);
|
|
1918
2104
|
}
|
|
1919
2105
|
function readLedger(cwd, policy) {
|
|
1920
2106
|
const p = ledgerPath(cwd, policy);
|
|
@@ -2008,7 +2194,7 @@ function paint(s, code, on) {
|
|
|
2008
2194
|
function wrap(text, width) {
|
|
2009
2195
|
const out2 = [];
|
|
2010
2196
|
let cur = "";
|
|
2011
|
-
for (const w of text.split(/\s+/).filter(Boolean)) {
|
|
2197
|
+
for (const w of stripControl(text).split(/\s+/).filter(Boolean)) {
|
|
2012
2198
|
if (cur === "") cur = w;
|
|
2013
2199
|
else if (cur.length + 1 + w.length <= width) cur += " " + w;
|
|
2014
2200
|
else {
|
|
@@ -2073,15 +2259,15 @@ function renderText(input, opts) {
|
|
|
2073
2259
|
for (const f of findings) {
|
|
2074
2260
|
const isBlock = f.severity === "block";
|
|
2075
2261
|
const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
|
|
2076
|
-
const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
2077
|
-
out2.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
2262
|
+
const loc = f.file ? `${stripControl(f.file)}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
2263
|
+
out2.push(` ${mark} ${paint(stripControl(f.rule), BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
2078
2264
|
for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
|
|
2079
2265
|
const row = (key2, lines) => {
|
|
2080
2266
|
lines.forEach((l, i) => out2.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
|
|
2081
2267
|
};
|
|
2082
2268
|
row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
|
|
2083
2269
|
row("instead", wrap(f.remediation, body));
|
|
2084
|
-
if (f.signoff.required) row("sign-off", [f.signoff.command]);
|
|
2270
|
+
if (f.signoff.required) row("sign-off", [stripControl(f.signoff.command)]);
|
|
2085
2271
|
out2.push("");
|
|
2086
2272
|
}
|
|
2087
2273
|
if (blocks.length > 0) {
|
|
@@ -2095,7 +2281,7 @@ function renderText(input, opts) {
|
|
|
2095
2281
|
// src/cli/render/github.ts
|
|
2096
2282
|
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
2097
2283
|
function escData(s) {
|
|
2098
|
-
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
2284
|
+
return stripControl(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
2099
2285
|
}
|
|
2100
2286
|
function escProp(s) {
|
|
2101
2287
|
return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
@@ -2114,7 +2300,7 @@ function annotations(findings) {
|
|
|
2114
2300
|
});
|
|
2115
2301
|
}
|
|
2116
2302
|
function cell(s) {
|
|
2117
|
-
return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
2303
|
+
return stripControl(s).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
2118
2304
|
}
|
|
2119
2305
|
function mdCode(s) {
|
|
2120
2306
|
return s.replace(/`/g, "'").replace(/[\r\n]/g, " ");
|
|
@@ -2222,8 +2408,13 @@ function report(input) {
|
|
|
2222
2408
|
|
|
2223
2409
|
// src/cli/check.ts
|
|
2224
2410
|
function check(opts) {
|
|
2225
|
-
let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
|
|
2226
2411
|
const cwd = opts.cwd ?? process.cwd();
|
|
2412
|
+
if (!isGitRepo(cwd)) {
|
|
2413
|
+
process.stderr.write(`tamperward: ${cwd} is not inside a git repository \u2014 nothing to check
|
|
2414
|
+
`);
|
|
2415
|
+
return 2;
|
|
2416
|
+
}
|
|
2417
|
+
let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
|
|
2227
2418
|
let changes;
|
|
2228
2419
|
let layer;
|
|
2229
2420
|
let view;
|
|
@@ -2236,13 +2427,20 @@ function check(opts) {
|
|
|
2236
2427
|
layer = opts.ciLayer ? "ci" : "local";
|
|
2237
2428
|
view = "worktree";
|
|
2238
2429
|
} else if (opts.diff) {
|
|
2239
|
-
const
|
|
2240
|
-
|
|
2430
|
+
const m = opts.diff.match(/^(.+?)\.{2,3}(.+)$/);
|
|
2431
|
+
const [base, head] = m ? [m[1], m[2]] : ["", ""];
|
|
2432
|
+
if (!base || !head || base.endsWith(".") || head.startsWith(".")) {
|
|
2241
2433
|
process.stderr.write(`tamperward: invalid --diff range "${opts.diff}" (expected <base>...<head>)
|
|
2242
2434
|
`);
|
|
2243
2435
|
return 2;
|
|
2244
2436
|
}
|
|
2245
2437
|
changes = diffRange(base, head, { cwd: opts.cwd });
|
|
2438
|
+
if (changes.length === 0) {
|
|
2439
|
+
process.stderr.write(
|
|
2440
|
+
`tamperward: note: the range ${base}...${head} contains no changes \u2014 if this is not an empty pull request, check that the range resolves to the intended commits
|
|
2441
|
+
`
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2246
2444
|
layer = "ci";
|
|
2247
2445
|
view = "range";
|
|
2248
2446
|
policy = opts.policyOverride ?? loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
|
|
@@ -2283,7 +2481,7 @@ import { createHash as createHash4 } from "node:crypto";
|
|
|
2283
2481
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2284
2482
|
import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
2285
2483
|
import { tmpdir } from "node:os";
|
|
2286
|
-
import { isAbsolute, join as join4, relative, resolve } from "node:path";
|
|
2484
|
+
import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
|
|
2287
2485
|
function asStr(v) {
|
|
2288
2486
|
return typeof v === "string" ? v : "";
|
|
2289
2487
|
}
|
|
@@ -2299,7 +2497,7 @@ function abs(path, cwd) {
|
|
|
2299
2497
|
}
|
|
2300
2498
|
function relForDisplay(path, cwd) {
|
|
2301
2499
|
const rel = relative(cwd, path);
|
|
2302
|
-
return rel && !rel.startsWith("..") && !
|
|
2500
|
+
return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
|
|
2303
2501
|
}
|
|
2304
2502
|
function applyEdit(content, oldStr, newStr) {
|
|
2305
2503
|
if (content === null) return newStr;
|
|
@@ -2443,7 +2641,7 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2443
2641
|
// src/effect.ts
|
|
2444
2642
|
import { createHash as createHash2 } from "node:crypto";
|
|
2445
2643
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2446
|
-
import { isAbsolute as
|
|
2644
|
+
import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
|
|
2447
2645
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2448
2646
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2449
2647
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -2455,7 +2653,7 @@ function gitDir2(cwd) {
|
|
|
2455
2653
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2456
2654
|
}).trim();
|
|
2457
2655
|
if (!d) return null;
|
|
2458
|
-
return
|
|
2656
|
+
return isAbsolute3(d) ? d : join6(cwd, d);
|
|
2459
2657
|
} catch {
|
|
2460
2658
|
return null;
|
|
2461
2659
|
}
|
|
@@ -2535,7 +2733,7 @@ function savePtree(cwd, sessionId, tree) {
|
|
|
2535
2733
|
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
|
|
2536
2734
|
import { join as join7 } from "node:path";
|
|
2537
2735
|
import { createHash as createHash3 } from "node:crypto";
|
|
2538
|
-
var
|
|
2736
|
+
var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
2539
2737
|
function snap(abs2) {
|
|
2540
2738
|
try {
|
|
2541
2739
|
const st = statSync2(abs2);
|
|
@@ -2562,7 +2760,7 @@ function watchTree(dir, cb) {
|
|
|
2562
2760
|
}
|
|
2563
2761
|
const watchers = /* @__PURE__ */ new Map();
|
|
2564
2762
|
const addDir = (rel) => {
|
|
2565
|
-
if (watchers.has(rel) ||
|
|
2763
|
+
if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
|
|
2566
2764
|
let w;
|
|
2567
2765
|
try {
|
|
2568
2766
|
w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
|
|
@@ -2589,7 +2787,7 @@ function watchTree(dir, cb) {
|
|
|
2589
2787
|
}
|
|
2590
2788
|
for (const name of names) {
|
|
2591
2789
|
const child = rel ? `${rel}/${name}` : name;
|
|
2592
|
-
if (
|
|
2790
|
+
if (SKIP2.test(child + "/")) continue;
|
|
2593
2791
|
try {
|
|
2594
2792
|
if (statSync2(join7(dir, child)).isDirectory()) walk(child);
|
|
2595
2793
|
} catch {
|
|
@@ -2608,7 +2806,7 @@ function startWatcher(dir, log, policy) {
|
|
|
2608
2806
|
}
|
|
2609
2807
|
const last = /* @__PURE__ */ new Map();
|
|
2610
2808
|
return watchTree(dir, (kind, rel) => {
|
|
2611
|
-
if (
|
|
2809
|
+
if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
|
|
2612
2810
|
const s = snap(join7(dir, rel));
|
|
2613
2811
|
const key2 = `${s.hash}:${s.mode}`;
|
|
2614
2812
|
if (last.get(rel) === key2) return;
|
|
@@ -2972,9 +3170,18 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2972
3170
|
// src/cli/init.ts
|
|
2973
3171
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
2974
3172
|
import { createHash as createHash5 } from "node:crypto";
|
|
2975
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2976
|
-
import { dirname as dirname3, join as join8 } from "node:path";
|
|
3173
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
3174
|
+
import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join8, relative as relative2, resolve as resolve2 } from "node:path";
|
|
2977
3175
|
import { fileURLToPath } from "node:url";
|
|
3176
|
+
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
3177
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3178
|
+
function gitConfig(cwd, key2) {
|
|
3179
|
+
try {
|
|
3180
|
+
return execFileSync4("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
3181
|
+
} catch {
|
|
3182
|
+
return null;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
2978
3185
|
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
2979
3186
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2980
3187
|
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
@@ -2987,25 +3194,28 @@ function coveredBy(existing, critical) {
|
|
|
2987
3194
|
for (const line of existing.split("\n")) {
|
|
2988
3195
|
const t = line.trim();
|
|
2989
3196
|
if (!t || t.startsWith("#")) continue;
|
|
2990
|
-
const
|
|
2991
|
-
if (!t.slice(
|
|
2992
|
-
if (
|
|
2993
|
-
|
|
3197
|
+
const raw = t.split(/\s+/)[0];
|
|
3198
|
+
if (!t.slice(raw.length).trim()) continue;
|
|
3199
|
+
if (raw === "*" || raw === "**") return true;
|
|
3200
|
+
const pattern = raw.replace(/\/\*{1,2}$/, "/");
|
|
3201
|
+
if (pattern.includes("*")) continue;
|
|
3202
|
+
const anchored = pattern.startsWith("/") ? pattern : "/" + pattern;
|
|
3203
|
+
if (anchored === critical) return true;
|
|
3204
|
+
if (anchored.endsWith("/") && critical.startsWith(anchored)) return true;
|
|
3205
|
+
if (!pattern.startsWith("/")) {
|
|
3206
|
+
if (critical.endsWith("/" + pattern)) return true;
|
|
3207
|
+
if (pattern.endsWith("/") && critical.includes("/" + pattern)) return true;
|
|
3208
|
+
}
|
|
2994
3209
|
}
|
|
2995
3210
|
return false;
|
|
2996
3211
|
}
|
|
2997
3212
|
function inferOwner(cwd) {
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
const m = url.match(/github\.com[:/]+([^/]+)\//);
|
|
3005
|
-
return m ? `@${m[1]}` : null;
|
|
3006
|
-
} catch {
|
|
3007
|
-
return null;
|
|
3008
|
-
}
|
|
3213
|
+
const url = gitConfig(cwd, "remote.origin.url");
|
|
3214
|
+
const m = url?.match(/github\.com[:/]+([^/]+)\//);
|
|
3215
|
+
if (!m) return null;
|
|
3216
|
+
const segment = m[1];
|
|
3217
|
+
const self = [gitConfig(cwd, "github.user"), gitConfig(cwd, "user.name")].filter((v) => v !== null);
|
|
3218
|
+
return { owner: `@${segment}`, confirmed: self.some((v) => v.toLowerCase() === segment.toLowerCase()) };
|
|
3009
3219
|
}
|
|
3010
3220
|
var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
|
|
3011
3221
|
# without this file \u2014 everything here is an override, so an empty file changes nothing.
|
|
@@ -3143,9 +3353,31 @@ function missingTools(matcher2) {
|
|
|
3143
3353
|
if (have === null) return [];
|
|
3144
3354
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
3145
3355
|
}
|
|
3356
|
+
function hooksShapeError(hooks) {
|
|
3357
|
+
if (hooks === void 0 || hooks === null) return null;
|
|
3358
|
+
if (!isPlainObject(hooks)) return "hooks is not an object";
|
|
3359
|
+
for (const [event, arr] of Object.entries(hooks)) {
|
|
3360
|
+
if (arr === void 0 || arr === null) continue;
|
|
3361
|
+
if (!Array.isArray(arr)) return `hooks.${event} is not a list`;
|
|
3362
|
+
for (const m of arr) {
|
|
3363
|
+
if (!isPlainObject(m)) return `hooks.${event} contains an entry that is not an object`;
|
|
3364
|
+
if (m.hooks !== void 0 && m.hooks !== null && !(Array.isArray(m.hooks) && m.hooks.every(isPlainObject))) {
|
|
3365
|
+
return `hooks.${event} has a "hooks" value that is not a list of { type, command }`;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
return null;
|
|
3370
|
+
}
|
|
3146
3371
|
function planPolicy(cwd) {
|
|
3147
3372
|
const path = join8(cwd, POLICY_FILE);
|
|
3148
|
-
if (existsSync7(path))
|
|
3373
|
+
if (existsSync7(path)) {
|
|
3374
|
+
try {
|
|
3375
|
+
loadPolicy(cwd);
|
|
3376
|
+
} catch (e) {
|
|
3377
|
+
return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${errText2(e)}; fix it, then re-run init (left untouched)` };
|
|
3378
|
+
}
|
|
3379
|
+
return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present and loads \u2014 left untouched" };
|
|
3380
|
+
}
|
|
3149
3381
|
return {
|
|
3150
3382
|
item: "policy",
|
|
3151
3383
|
path: POLICY_FILE,
|
|
@@ -3175,6 +3407,10 @@ function planClaudeHooks(cwd) {
|
|
|
3175
3407
|
}
|
|
3176
3408
|
settings = parsed;
|
|
3177
3409
|
}
|
|
3410
|
+
const shapeError = hooksShapeError(settings.hooks);
|
|
3411
|
+
if (shapeError) {
|
|
3412
|
+
return { item: "agent", path: rel, status: "error", detail: `${shapeError} \u2014 fix it, then re-run init (refusing to overwrite)` };
|
|
3413
|
+
}
|
|
3178
3414
|
const hooks = settings.hooks ??= {};
|
|
3179
3415
|
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
|
|
3180
3416
|
const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
|
|
@@ -3220,47 +3456,170 @@ function planClaudeHooks(cwd) {
|
|
|
3220
3456
|
}
|
|
3221
3457
|
};
|
|
3222
3458
|
}
|
|
3459
|
+
var MANAGED_HOOKS = [
|
|
3460
|
+
{
|
|
3461
|
+
name: "lefthook",
|
|
3462
|
+
configs: ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml", "lefthook-local.yml", "lefthook-local.yaml"],
|
|
3463
|
+
hookRe: /\blefthook\b/i,
|
|
3464
|
+
hint: `add this under \`pre-commit:\` \u2192 \`commands:\` in lefthook.yml:
|
|
3465
|
+
tamperward:
|
|
3466
|
+
run: ${PRECOMMIT_CMD}`
|
|
3467
|
+
},
|
|
3468
|
+
{
|
|
3469
|
+
name: "pre-commit",
|
|
3470
|
+
configs: [".pre-commit-config.yaml", ".pre-commit-config.yml"],
|
|
3471
|
+
hookRe: /pre-commit\.com|File generated by pre-commit/i,
|
|
3472
|
+
hint: `add this under \`repos:\` in .pre-commit-config.yaml:
|
|
3473
|
+
- repo: local
|
|
3474
|
+
hooks:
|
|
3475
|
+
- id: tamperward
|
|
3476
|
+
name: tamperward
|
|
3477
|
+
entry: ${PRECOMMIT_CMD}
|
|
3478
|
+
language: system
|
|
3479
|
+
pass_filenames: false
|
|
3480
|
+
always_run: true`
|
|
3481
|
+
}
|
|
3482
|
+
];
|
|
3483
|
+
function hooksDir(cwd) {
|
|
3484
|
+
try {
|
|
3485
|
+
const out2 = execFileSync4("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
3486
|
+
if (out2) return resolve2(cwd, out2);
|
|
3487
|
+
} catch {
|
|
3488
|
+
}
|
|
3489
|
+
return existsSync7(join8(cwd, ".git")) ? join8(cwd, ".git", "hooks") : null;
|
|
3490
|
+
}
|
|
3491
|
+
function display(cwd, path) {
|
|
3492
|
+
const rel = relative2(cwd, path);
|
|
3493
|
+
return rel && !rel.startsWith("..") && !isAbsolute4(rel) ? rel : path;
|
|
3494
|
+
}
|
|
3495
|
+
function unconditionalExitAt(lines) {
|
|
3496
|
+
let depth = 0;
|
|
3497
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3498
|
+
const t = lines[i].trim();
|
|
3499
|
+
if (!t || t.startsWith("#")) continue;
|
|
3500
|
+
if (depth === 0) {
|
|
3501
|
+
if (/^exit(\s|$|;)/.test(t)) return i;
|
|
3502
|
+
const exec = t.match(/^exec(?:\s+(.*))?$/);
|
|
3503
|
+
if (exec && exec[1] && !/^(\d*[<>]|&>)/.test(exec[1])) return i;
|
|
3504
|
+
}
|
|
3505
|
+
const opens = (t.match(/(?:^|[;(]\s*|&&\s*|\|\|\s*)(?:if|case|for|while|until)\s/g) ?? []).length + (/(?:^|\s)\{\s*$/.test(t) ? 1 : 0);
|
|
3506
|
+
const closes = (t.match(/(?:^|[;]\s*)(?:fi|esac|done)(?:\s|;|$)/g) ?? []).length + (/^\}/.test(t) ? 1 : 0);
|
|
3507
|
+
depth = Math.max(0, depth + opens - closes);
|
|
3508
|
+
}
|
|
3509
|
+
return -1;
|
|
3510
|
+
}
|
|
3223
3511
|
function planPreCommit(cwd) {
|
|
3224
|
-
const
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3512
|
+
const block = [MARKER, PRECOMMIT_CMD];
|
|
3513
|
+
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
3514
|
+
let path;
|
|
3515
|
+
let note;
|
|
3516
|
+
if (existsSync7(join8(cwd, ".husky"))) {
|
|
3517
|
+
path = join8(cwd, ".husky", "pre-commit");
|
|
3518
|
+
note = "husky";
|
|
3519
|
+
} else {
|
|
3520
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join8(cwd, c))));
|
|
3521
|
+
if (managed) {
|
|
3522
|
+
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}`);
|
|
3523
|
+
}
|
|
3524
|
+
const dir = hooksDir(cwd);
|
|
3525
|
+
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
3526
|
+
if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
|
|
3527
|
+
path = join8(dirname3(dir), "pre-commit");
|
|
3528
|
+
note = "husky";
|
|
3529
|
+
} else {
|
|
3530
|
+
path = join8(dir, "pre-commit");
|
|
3531
|
+
const rel2 = display(cwd, dir);
|
|
3532
|
+
note = rel2 === join8(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
const rel = display(cwd, path);
|
|
3536
|
+
if (existsSync7(path) && !statSync5(path).isFile()) {
|
|
3537
|
+
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
3538
|
+
}
|
|
3232
3539
|
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
3233
|
-
if (existing
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3540
|
+
if (existing === null) {
|
|
3541
|
+
return {
|
|
3542
|
+
item: "pre-commit",
|
|
3543
|
+
path: rel,
|
|
3544
|
+
status: "create",
|
|
3545
|
+
detail: `create via ${note}`,
|
|
3546
|
+
apply: () => {
|
|
3547
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
3548
|
+
writeFileSync5(path, `#!/bin/sh
|
|
3549
|
+
${block.join("\n")}
|
|
3550
|
+
`);
|
|
3551
|
+
chmodSync(path, 493);
|
|
3552
|
+
}
|
|
3553
|
+
};
|
|
3554
|
+
}
|
|
3555
|
+
const owner = MANAGED_HOOKS.find((m) => m.hookRe.test(existing));
|
|
3556
|
+
if (owner) {
|
|
3557
|
+
return { item: "pre-commit", path: rel, status: "skip", detail: `generated by ${owner.name}, which overwrites it on install \u2014 ${owner.hint}` };
|
|
3558
|
+
}
|
|
3559
|
+
const lines = existing.split("\n");
|
|
3560
|
+
const dead = unconditionalExitAt(lines);
|
|
3561
|
+
const at = lines.findIndex((l) => PRECOMMIT_RE.test(l));
|
|
3562
|
+
const write = (out2) => {
|
|
3563
|
+
writeFileSync5(path, out2.join("\n"));
|
|
3564
|
+
chmodSync(path, 493);
|
|
3565
|
+
};
|
|
3566
|
+
if (at !== -1 && (dead === -1 || at < dead)) {
|
|
3567
|
+
if (!stalePin(lines[at])) {
|
|
3568
|
+
return { item: "pre-commit", path: rel, status: "ok", detail: "already runs the staged check" };
|
|
3238
3569
|
}
|
|
3239
|
-
const was = pinOf(lines[
|
|
3570
|
+
const was = pinOf(lines[at]) || "unpinned";
|
|
3240
3571
|
return {
|
|
3241
3572
|
item: "pre-commit",
|
|
3242
|
-
path:
|
|
3573
|
+
path: rel,
|
|
3243
3574
|
status: "update",
|
|
3244
3575
|
detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
|
|
3245
3576
|
apply: () => {
|
|
3246
|
-
lines[
|
|
3247
|
-
|
|
3248
|
-
|
|
3577
|
+
lines[at] = PRECOMMIT_CMD;
|
|
3578
|
+
write(lines);
|
|
3579
|
+
}
|
|
3580
|
+
};
|
|
3581
|
+
}
|
|
3582
|
+
if (at !== -1) {
|
|
3583
|
+
const ours = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
|
|
3584
|
+
if (!ours) {
|
|
3585
|
+
return {
|
|
3586
|
+
item: "pre-commit",
|
|
3587
|
+
path: rel,
|
|
3588
|
+
status: "error",
|
|
3589
|
+
detail: `the staged check on line ${at + 1} never runs: line ${dead + 1} (\`${lines[dead].trim()}\`) ends the script first \u2014 move the tamperward line above it`
|
|
3590
|
+
};
|
|
3591
|
+
}
|
|
3592
|
+
return {
|
|
3593
|
+
item: "pre-commit",
|
|
3594
|
+
path: rel,
|
|
3595
|
+
status: "update",
|
|
3596
|
+
detail: `move the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ended the script before it ran`,
|
|
3597
|
+
apply: () => {
|
|
3598
|
+
const out2 = lines.filter((_, i) => i !== at && i !== at - 1);
|
|
3599
|
+
out2.splice(dead, 0, ...block);
|
|
3600
|
+
write(out2);
|
|
3601
|
+
}
|
|
3602
|
+
};
|
|
3603
|
+
}
|
|
3604
|
+
if (dead !== -1) {
|
|
3605
|
+
return {
|
|
3606
|
+
item: "pre-commit",
|
|
3607
|
+
path: rel,
|
|
3608
|
+
status: "update",
|
|
3609
|
+
detail: `insert the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ends the script \u2014 ${note}`,
|
|
3610
|
+
apply: () => {
|
|
3611
|
+
const out2 = [...lines];
|
|
3612
|
+
out2.splice(dead, 0, ...block);
|
|
3613
|
+
write(out2);
|
|
3249
3614
|
}
|
|
3250
3615
|
};
|
|
3251
3616
|
}
|
|
3252
3617
|
return {
|
|
3253
3618
|
item: "pre-commit",
|
|
3254
|
-
path:
|
|
3255
|
-
status:
|
|
3256
|
-
detail:
|
|
3257
|
-
apply: () =>
|
|
3258
|
-
mkdirSync5(dirname3(path), { recursive: true });
|
|
3259
|
-
const content = existing === null ? `#!/bin/sh
|
|
3260
|
-
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
3261
|
-
writeFileSync5(path, content);
|
|
3262
|
-
chmodSync(path, 493);
|
|
3263
|
-
}
|
|
3619
|
+
path: rel,
|
|
3620
|
+
status: "update",
|
|
3621
|
+
detail: `append the staged check (${note})`,
|
|
3622
|
+
apply: () => write([...existing.replace(/\n?$/, "\n").split("\n").slice(0, -1), ...block, ""])
|
|
3264
3623
|
};
|
|
3265
3624
|
}
|
|
3266
3625
|
function planCodeowners(cwd) {
|
|
@@ -3274,18 +3633,26 @@ function planCodeowners(cwd) {
|
|
|
3274
3633
|
if (missing.length === 0) {
|
|
3275
3634
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
3276
3635
|
}
|
|
3277
|
-
const
|
|
3636
|
+
const inferred = inferOwner(cwd);
|
|
3637
|
+
const owner = inferred?.owner ?? null;
|
|
3638
|
+
const unconfirmed = inferred !== null && !inferred.confirmed;
|
|
3278
3639
|
const block = `
|
|
3279
3640
|
${CODEOWNERS_MARK}
|
|
3280
3641
|
# A pull request that rewrites the workflow can keep the job name, replace the
|
|
3281
3642
|
# gate with \`true\`, and still report a green required check. Only a human
|
|
3282
3643
|
# requirement on these paths prevents that.
|
|
3283
|
-
` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" :
|
|
3644
|
+
` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : unconfirmed ? `# TODO: replace ${owner} with ${owner}/<team> if "${owner.slice(1)}" is an organisation. GitHub
|
|
3645
|
+
# rejects a bare organisation as an owner, and a rule with an invalid owner protects
|
|
3646
|
+
# nothing. A user login is fine as written.
|
|
3647
|
+
` : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
|
|
3284
3648
|
return {
|
|
3285
3649
|
item: "codeowners",
|
|
3286
3650
|
path: existingRel ?? rel,
|
|
3287
3651
|
status: existing === null ? "create" : "update",
|
|
3288
|
-
detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner})`),
|
|
3652
|
+
detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner}${unconfirmed ? ", unconfirmed \u2014 see warning below" : ""})`),
|
|
3653
|
+
...inferred && unconfirmed ? {
|
|
3654
|
+
warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
|
|
3655
|
+
} : {},
|
|
3289
3656
|
apply: () => {
|
|
3290
3657
|
mkdirSync5(dirname3(path), { recursive: true });
|
|
3291
3658
|
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
@@ -3344,21 +3711,49 @@ function planWorkflow(cwd, force) {
|
|
|
3344
3711
|
detail: (mark ? `edited since tamperward v${mark.version} generated it` : "present but not generated by tamperward") + ` \u2014 left untouched. Compare it against the v${TW_VERSION} template (an older one may lack the pristine-verify step) and re-run with --force-workflow to replace it.`
|
|
3345
3712
|
};
|
|
3346
3713
|
}
|
|
3714
|
+
function planned(item, path, plan) {
|
|
3715
|
+
try {
|
|
3716
|
+
return plan();
|
|
3717
|
+
} catch (e) {
|
|
3718
|
+
return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText2(e)}` };
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3347
3721
|
function planInit(cwd, opts = {}) {
|
|
3348
|
-
return [
|
|
3722
|
+
return [
|
|
3723
|
+
planned("policy", POLICY_FILE, () => planPolicy(cwd)),
|
|
3724
|
+
planned("agent", ".claude/settings.json", () => planClaudeHooks(cwd)),
|
|
3725
|
+
planned("pre-commit", "(hooks)", () => planPreCommit(cwd)),
|
|
3726
|
+
planned("ci", ".github/workflows/tamperward.yml", () => planWorkflow(cwd, opts.forceWorkflow ?? false)),
|
|
3727
|
+
planned("codeowners", ".github/CODEOWNERS", () => planCodeowners(cwd))
|
|
3728
|
+
];
|
|
3349
3729
|
}
|
|
3350
3730
|
function runInit(opts) {
|
|
3351
3731
|
const cwd = opts.cwd ?? process.cwd();
|
|
3352
3732
|
const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
|
|
3353
3733
|
const w = process.stdout;
|
|
3734
|
+
let applied = 0;
|
|
3354
3735
|
for (const a of plan) {
|
|
3736
|
+
if (!opts.dryRun && a.apply) {
|
|
3737
|
+
try {
|
|
3738
|
+
a.apply();
|
|
3739
|
+
applied++;
|
|
3740
|
+
} catch (e) {
|
|
3741
|
+
a.status = "error";
|
|
3742
|
+
a.detail = `could not be written \u2014 ${errText2(e)}`;
|
|
3743
|
+
a.apply = void 0;
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3355
3746
|
const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
|
|
3356
3747
|
w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
|
|
3357
3748
|
`);
|
|
3358
|
-
|
|
3749
|
+
}
|
|
3750
|
+
for (const a of plan) {
|
|
3751
|
+
if (a.warning) w.write(`
|
|
3752
|
+
WARNING (${a.item}): ${a.warning}
|
|
3753
|
+
`);
|
|
3359
3754
|
}
|
|
3360
3755
|
const errors = plan.filter((a) => a.status === "error");
|
|
3361
|
-
const changed = plan.filter((a) => a.apply).length;
|
|
3756
|
+
const changed = opts.dryRun ? plan.filter((a) => a.apply).length : applied;
|
|
3362
3757
|
if (errors.length) {
|
|
3363
3758
|
w.write(`
|
|
3364
3759
|
tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
|
|
@@ -3381,15 +3776,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
3381
3776
|
// src/cli/verify.ts
|
|
3382
3777
|
import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
|
|
3383
3778
|
import { createHash as createHash8 } from "node:crypto";
|
|
3384
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as
|
|
3779
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
|
|
3385
3780
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3386
|
-
import { dirname as dirname4, join as join11, resolve as
|
|
3781
|
+
import { dirname as dirname4, join as join11, resolve as resolve3 } from "node:path";
|
|
3387
3782
|
|
|
3388
3783
|
// src/git/trusted.ts
|
|
3389
3784
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3390
3785
|
import { createHash as createHash6 } from "node:crypto";
|
|
3391
3786
|
import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
|
|
3392
|
-
import { isAbsolute as
|
|
3787
|
+
import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
|
|
3393
3788
|
function trustedGitEnv() {
|
|
3394
3789
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3395
3790
|
}
|
|
@@ -3402,7 +3797,7 @@ function gitDirOf(cwd) {
|
|
|
3402
3797
|
env: trustedGitEnv()
|
|
3403
3798
|
}).trim();
|
|
3404
3799
|
if (!d) return null;
|
|
3405
|
-
return
|
|
3800
|
+
return isAbsolute5(d) ? d : join9(cwd, d);
|
|
3406
3801
|
} catch {
|
|
3407
3802
|
return null;
|
|
3408
3803
|
}
|
|
@@ -3440,7 +3835,7 @@ function objectRewriteState(cwd) {
|
|
|
3440
3835
|
// src/fingerprint.ts
|
|
3441
3836
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
3442
3837
|
import { createHash as createHash7 } from "node:crypto";
|
|
3443
|
-
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as
|
|
3838
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
3444
3839
|
import { join as join10 } from "node:path";
|
|
3445
3840
|
function treeFingerprint(cwd) {
|
|
3446
3841
|
const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
@@ -3479,7 +3874,7 @@ function depsFingerprint(cwd) {
|
|
|
3479
3874
|
h.update(p);
|
|
3480
3875
|
try {
|
|
3481
3876
|
h.update(readFileSync12(p));
|
|
3482
|
-
h.update(String(
|
|
3877
|
+
h.update(String(statSync6(p).mode));
|
|
3483
3878
|
} catch {
|
|
3484
3879
|
h.update("<unreadable>");
|
|
3485
3880
|
}
|
|
@@ -3494,7 +3889,7 @@ function depsFingerprint(cwd) {
|
|
|
3494
3889
|
}
|
|
3495
3890
|
};
|
|
3496
3891
|
try {
|
|
3497
|
-
if (!
|
|
3892
|
+
if (!statSync6(root).isDirectory()) return "none";
|
|
3498
3893
|
} catch {
|
|
3499
3894
|
return "none";
|
|
3500
3895
|
}
|
|
@@ -3552,7 +3947,7 @@ function materialize(cwd, dest) {
|
|
|
3552
3947
|
const src = join11(cwd, rel);
|
|
3553
3948
|
let st;
|
|
3554
3949
|
try {
|
|
3555
|
-
st =
|
|
3950
|
+
st = statSync7(src);
|
|
3556
3951
|
} catch {
|
|
3557
3952
|
continue;
|
|
3558
3953
|
}
|
|
@@ -3567,7 +3962,7 @@ function materialize(cwd, dest) {
|
|
|
3567
3962
|
}
|
|
3568
3963
|
chmodSync2(out2, st.mode);
|
|
3569
3964
|
}
|
|
3570
|
-
const nm =
|
|
3965
|
+
const nm = resolve3(cwd, "node_modules");
|
|
3571
3966
|
if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
|
|
3572
3967
|
}
|
|
3573
3968
|
function dropSymlink(p) {
|
|
@@ -3583,9 +3978,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
3583
3978
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
3584
3979
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
3585
3980
|
}
|
|
3586
|
-
const
|
|
3587
|
-
if (
|
|
3588
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
3981
|
+
const globs2 = policy.verify?.inputs;
|
|
3982
|
+
if (globs2 && globs2.length) {
|
|
3983
|
+
for (const rel of atBase) if (matchesAny(rel, globs2)) picked.add(rel);
|
|
3589
3984
|
}
|
|
3590
3985
|
return picked;
|
|
3591
3986
|
}
|
|
@@ -3857,7 +4252,7 @@ function parseVerify(args) {
|
|
|
3857
4252
|
// src/cli/run.ts
|
|
3858
4253
|
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
3859
4254
|
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
|
|
3860
|
-
import { resolve as
|
|
4255
|
+
import { resolve as resolve4 } from "node:path";
|
|
3861
4256
|
var out = (s) => process.stdout.write(s + "\n");
|
|
3862
4257
|
var err = (s) => process.stderr.write(s + "\n");
|
|
3863
4258
|
function git3(args, cwd) {
|
|
@@ -3904,7 +4299,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3904
4299
|
return out2;
|
|
3905
4300
|
}
|
|
3906
4301
|
function runEnvelope(opts) {
|
|
3907
|
-
const cwd =
|
|
4302
|
+
const cwd = resolve4(opts.cwd ?? process.cwd());
|
|
3908
4303
|
if (!opts.argv.length) {
|
|
3909
4304
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
3910
4305
|
return 2;
|
|
@@ -4041,7 +4436,7 @@ function parseRun(args) {
|
|
|
4041
4436
|
return o;
|
|
4042
4437
|
}
|
|
4043
4438
|
|
|
4044
|
-
// src/cli/
|
|
4439
|
+
// src/cli/main.ts
|
|
4045
4440
|
function parseAllow(args) {
|
|
4046
4441
|
const o = {};
|
|
4047
4442
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -4149,9 +4544,11 @@ Formats:
|
|
|
4149
4544
|
Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
|
|
4150
4545
|
(verify), any blocking finding or masked failure (run) \xB7 2 cannot
|
|
4151
4546
|
evaluate, failing closed: policy error, bad --diff range, no view,
|
|
4152
|
-
unresolvable base, --require-ancestor
|
|
4153
|
-
|
|
4154
|
-
|
|
4547
|
+
not a git repository, unresolvable base or revision, --require-ancestor
|
|
4548
|
+
refused, budget exceeded \u2014 always one line on stderr, never a stack
|
|
4549
|
+
trace. hook/sweep \u2192 0 with a deny emitted as JSON on stdout (exit 2
|
|
4550
|
+
would make Claude Code ignore the JSON); 2 only for an unsupported
|
|
4551
|
+
agent name.
|
|
4155
4552
|
`);
|
|
4156
4553
|
}
|
|
4157
4554
|
function main(argv2) {
|
|
@@ -4183,9 +4580,21 @@ function main(argv2) {
|
|
|
4183
4580
|
return 2;
|
|
4184
4581
|
}
|
|
4185
4582
|
}
|
|
4583
|
+
function guardedMain(argv2, stderr = process.stderr) {
|
|
4584
|
+
try {
|
|
4585
|
+
return main(argv2);
|
|
4586
|
+
} catch (e) {
|
|
4587
|
+
const first = (e instanceof Error ? e.message : String(e)).split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
|
4588
|
+
stderr.write(`tamperward: ${first || "unexpected failure"}
|
|
4589
|
+
`);
|
|
4590
|
+
return 2;
|
|
4591
|
+
}
|
|
4592
|
+
}
|
|
4593
|
+
|
|
4594
|
+
// src/cli/index.ts
|
|
4186
4595
|
var argv = process.argv.slice(2);
|
|
4187
4596
|
if (argv[0] === "watch") {
|
|
4188
4597
|
runWatch(argv.slice(1));
|
|
4189
4598
|
} else {
|
|
4190
|
-
process.exit(
|
|
4599
|
+
process.exit(guardedMain(argv));
|
|
4191
4600
|
}
|