tamperward 1.1.0-rc.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/dist/cli/index.js +314 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,15 +92,18 @@ with the same engine it ships.
|
|
|
92
92
|
per-line old/new line numbers correct across multiple hunks.
|
|
93
93
|
- `src/git/build.ts` — the git adapter: range / staged / worktree views, enriching
|
|
94
94
|
`before`/`after` with full file content for the AST detectors.
|
|
95
|
-
- `src/detectors/` — the **
|
|
95
|
+
- `src/detectors/` — the **nine mechanical rules**: `no-verify`, `ts-any-cast`,
|
|
96
96
|
`lint-suppression`, `test-skip`, `coverage-lowering`, `ci-tampering`,
|
|
97
97
|
`hook-tampering`, `test-deletion` (the last counts `it()/test()` via the TS AST,
|
|
98
|
-
and handles delete / rename-out-of-glob / shell mutation)
|
|
98
|
+
and handles delete / rename-out-of-glob / shell mutation), and `snapshot-rewrite`
|
|
99
|
+
(a `warn`: re-recording a snapshot/golden expectation from current output — the one
|
|
100
|
+
rule built from measured demand, after the affordance experiment put the move at a
|
|
101
|
+
70% attempt and 100% through rate; see `harness/PREDICTION-affordance.md`).
|
|
99
102
|
- `src/engine.ts` — runs the enabled rules over `Change[]`; honours `policy.ignore`.
|
|
100
103
|
- `src/cli/` — `tamperward check --staged | --worktree | --diff <base>...<head>`,
|
|
101
104
|
exit 1 on any blocking finding.
|
|
102
|
-
- `test/` —
|
|
103
|
-
pre-go-live audit regression cases.
|
|
105
|
+
- `test/` — 226 tests, including the AST-vs-regex, self-hosting precision, and
|
|
106
|
+
pre-go-live audit regression cases, and the renderer accessibility contract.
|
|
104
107
|
|
|
105
108
|
- `src/adapters/claude/` + `src/cli/hook.ts` — the agent layer: `tamperward hook claude`
|
|
106
109
|
(PreToolUse deny, fail-closed) and `tamperward sweep claude` (Stop sweep, compared against
|
|
@@ -120,6 +123,24 @@ npx tamperward check --staged # pre-commit view
|
|
|
120
123
|
npx tamperward check --diff "main...HEAD" # CI view — the authority for main
|
|
121
124
|
```
|
|
122
125
|
|
|
126
|
+
### Reading the verdict
|
|
127
|
+
|
|
128
|
+
One verdict, rendered for whoever is reading it. `--format` picks the view; the default,
|
|
129
|
+
`auto`, picks `github` when `GITHUB_ACTIONS=true` and `text` otherwise, so the CI wiring
|
|
130
|
+
stays a single line.
|
|
131
|
+
|
|
132
|
+
| Format | Where it goes |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `text` | The terminal. Blocking findings first, then by file and line, wrapped to the terminal width. |
|
|
135
|
+
| `github` | An inline annotation per finding — so it lands **on the line** in *Files changed*, not four clicks deep in a job log — plus a job-summary table on the run page. The full text output still goes to the log. |
|
|
136
|
+
| `json` | The findings verbatim, plus a summary count. |
|
|
137
|
+
|
|
138
|
+
Severity is always spelled out (`BLOCK` / `warn`) and never carried by colour or a glyph
|
|
139
|
+
alone, so the output reads the same piped, in a CI log, on a colour-blind reader's
|
|
140
|
+
terminal, and through a screen reader. Colour honours
|
|
141
|
+
[`NO_COLOR`](https://no-color.org) and `FORCE_COLOR`, and is off whenever stdout is not a
|
|
142
|
+
terminal.
|
|
143
|
+
|
|
123
144
|
From a clone:
|
|
124
145
|
|
|
125
146
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -182,7 +182,18 @@ function parseHunk(lines, start) {
|
|
|
182
182
|
|
|
183
183
|
// src/git/build.ts
|
|
184
184
|
function git(args, cwd) {
|
|
185
|
-
|
|
185
|
+
try {
|
|
186
|
+
return execFileSync("git", args, {
|
|
187
|
+
cwd,
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
190
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
191
|
+
});
|
|
192
|
+
} catch (e) {
|
|
193
|
+
const err = e;
|
|
194
|
+
const detail = String(err.stderr ?? "").trim();
|
|
195
|
+
throw new Error(detail ? `git ${args[0]}: ${detail}` : err.message ?? `git ${args[0]} failed`);
|
|
196
|
+
}
|
|
186
197
|
}
|
|
187
198
|
function blobAt(rev, path, cwd) {
|
|
188
199
|
try {
|
|
@@ -414,6 +425,10 @@ function defaultPolicy() {
|
|
|
414
425
|
"**/package.json"
|
|
415
426
|
],
|
|
416
427
|
ci: [".github/workflows/**"],
|
|
428
|
+
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
429
|
+
// rewriting it from current output is the snapshot-update move the affordance
|
|
430
|
+
// experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
|
|
431
|
+
snapshots: ["**/*.snap", "**/__snapshots__/**", "**/golden/**", "**/*.golden.*"],
|
|
417
432
|
hooks: [".husky/**", "**/lefthook.*", ".tamperward.yml", "**/.tamperward.yml"]
|
|
418
433
|
},
|
|
419
434
|
rules: {
|
|
@@ -429,6 +444,9 @@ function defaultPolicy() {
|
|
|
429
444
|
"ci-tampering": { severity: "block" },
|
|
430
445
|
"hook-tampering": { severity: "block" },
|
|
431
446
|
"no-verify": { severity: "block" },
|
|
447
|
+
// mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
|
|
448
|
+
// when intended output changes. WARN until the §7 evidence path earns block.
|
|
449
|
+
"snapshot-rewrite": { severity: "warn" },
|
|
432
450
|
// heuristic — warn until precision is measured (SPEC §7)
|
|
433
451
|
"assertion-weakening": { severity: "warn" },
|
|
434
452
|
"guard-removal": { severity: "warn" }
|
|
@@ -659,10 +677,14 @@ function underCoverageKey(node) {
|
|
|
659
677
|
}
|
|
660
678
|
return false;
|
|
661
679
|
}
|
|
680
|
+
function asExpression(src) {
|
|
681
|
+
const t = src.trim();
|
|
682
|
+
return t.startsWith("{") ? `(${t})` : src;
|
|
683
|
+
}
|
|
662
684
|
function parseThresholds(src) {
|
|
663
685
|
const res = { paths: /* @__PURE__ */ new Map(), present: false };
|
|
664
686
|
try {
|
|
665
|
-
const sf = ts2.createSourceFile("cfg.ts", src, ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
|
|
687
|
+
const sf = ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
|
|
666
688
|
const visit = (node) => {
|
|
667
689
|
if (ts2.isPropertyAssignment(node) && ts2.isObjectLiteralExpression(node.initializer)) {
|
|
668
690
|
const key2 = keyName(node.name);
|
|
@@ -1208,6 +1230,63 @@ var testDeletion = {
|
|
|
1208
1230
|
}
|
|
1209
1231
|
};
|
|
1210
1232
|
|
|
1233
|
+
// src/detectors/snapshot-rewrite.ts
|
|
1234
|
+
var RULE8 = "snapshot-rewrite";
|
|
1235
|
+
var RUNNER2 = /\b(?:jest|vitest|playwright|ava)\b/;
|
|
1236
|
+
var UPDATE_ANY = /--update-?[sS]napshots?\b/;
|
|
1237
|
+
var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
|
|
1238
|
+
var REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
|
|
1239
|
+
var MUTATE = /(?:^|\s)(?:rm|cp|mv|tee|truncate|dd)(?:\s|$)|sed\s+-i|>{1,2}/;
|
|
1240
|
+
function namesProtectedSnapshot(seg, policy) {
|
|
1241
|
+
for (const t of tokens(seg)) {
|
|
1242
|
+
const path = unquote(t).replace(/^>{1,2}/, "");
|
|
1243
|
+
if (isProtected(path, policy, "snapshots")) return path;
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
var snapshotRewrite = {
|
|
1248
|
+
id: RULE8,
|
|
1249
|
+
surface: ["command", "file"],
|
|
1250
|
+
certainty: "mechanical",
|
|
1251
|
+
run(changes, policy) {
|
|
1252
|
+
const out = [];
|
|
1253
|
+
const warn = (input) => out.push(makeFinding(RULE8, policy, { ...input, defaultSeverity: "warn" }));
|
|
1254
|
+
for (const c of changes) {
|
|
1255
|
+
if (c.kind === "command") {
|
|
1256
|
+
for (const seg of segments(c.raw)) {
|
|
1257
|
+
let why = null;
|
|
1258
|
+
if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
|
|
1259
|
+
else if (RUNNER2.test(seg) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
|
|
1260
|
+
else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
|
|
1261
|
+
else {
|
|
1262
|
+
const path = MUTATE.test(seg) ? namesProtectedSnapshot(seg, policy) : null;
|
|
1263
|
+
if (path) why = `the command overwrites or removes the protected snapshot ${path}`;
|
|
1264
|
+
}
|
|
1265
|
+
if (why) {
|
|
1266
|
+
warn({
|
|
1267
|
+
message: `Snapshot rewrite: ${why}.`,
|
|
1268
|
+
evidence: seg,
|
|
1269
|
+
remediation: "A snapshot is an assertion. If the test failure is a bug, fix the code; only update the snapshot when the INTENDED output changed, and have a human confirm the new expectation is correct."
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
const wasSnap = c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
|
|
1276
|
+
const isSnap = isProtected(c.path, policy, "snapshots");
|
|
1277
|
+
if (c.op === "add" || !isSnap && !wasSnap) continue;
|
|
1278
|
+
const what = c.op === "delete" ? "deleted" : c.op === "rename" && !isSnap ? `renamed out of the protected snapshot set (to ${c.path})` : "rewritten";
|
|
1279
|
+
warn({
|
|
1280
|
+
file: c.op === "rename" && !isSnap ? c.oldPath : c.path,
|
|
1281
|
+
message: `A recorded expected output was ${what}.`,
|
|
1282
|
+
evidence: c.op === "rename" ? `${c.oldPath} -> ${c.path}` : c.path,
|
|
1283
|
+
remediation: "If the intended output genuinely changed, a human should confirm the new expectation against the requirement; if the test was failing, fix the code instead of re-recording the expectation from it."
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
return out;
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1211
1290
|
// src/detectors/index.ts
|
|
1212
1291
|
var allDetectors = [
|
|
1213
1292
|
noVerify,
|
|
@@ -1217,7 +1296,8 @@ var allDetectors = [
|
|
|
1217
1296
|
coverageLowering,
|
|
1218
1297
|
ciTampering,
|
|
1219
1298
|
hookTampering,
|
|
1220
|
-
testDeletion
|
|
1299
|
+
testDeletion,
|
|
1300
|
+
snapshotRewrite
|
|
1221
1301
|
];
|
|
1222
1302
|
|
|
1223
1303
|
// src/engine.ts
|
|
@@ -1362,46 +1442,226 @@ function oobFromEnv(env = process.env) {
|
|
|
1362
1442
|
return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1363
1443
|
}
|
|
1364
1444
|
|
|
1365
|
-
// src/cli/
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1445
|
+
// src/cli/render/text.ts
|
|
1446
|
+
var ESC = "\x1B";
|
|
1447
|
+
var RESET = `${ESC}[0m`;
|
|
1448
|
+
var BOLD = `${ESC}[1m`;
|
|
1449
|
+
var DIM = `${ESC}[2m`;
|
|
1450
|
+
var RED = `${ESC}[31m`;
|
|
1451
|
+
var YELLOW = `${ESC}[33m`;
|
|
1452
|
+
var GREEN = `${ESC}[32m`;
|
|
1453
|
+
function colourEnabled(env = process.env, stream = process.stdout) {
|
|
1454
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1455
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
|
|
1456
|
+
if (env.TERM === "dumb") return false;
|
|
1457
|
+
return Boolean(stream.isTTY);
|
|
1458
|
+
}
|
|
1459
|
+
function terminalWidth(stream = process.stdout) {
|
|
1460
|
+
const c = stream.columns;
|
|
1461
|
+
if (!c || c < 40) return 80;
|
|
1462
|
+
return Math.min(c, 100);
|
|
1463
|
+
}
|
|
1464
|
+
function paint(s, code, on) {
|
|
1465
|
+
return on ? code + s + RESET : s;
|
|
1466
|
+
}
|
|
1467
|
+
function wrap(text, width) {
|
|
1468
|
+
const out = [];
|
|
1469
|
+
let cur = "";
|
|
1470
|
+
for (const w of text.split(/\s+/).filter(Boolean)) {
|
|
1471
|
+
if (cur === "") cur = w;
|
|
1472
|
+
else if (cur.length + 1 + w.length <= width) cur += " " + w;
|
|
1473
|
+
else {
|
|
1474
|
+
out.push(cur);
|
|
1475
|
+
cur = w;
|
|
1476
|
+
}
|
|
1371
1477
|
}
|
|
1372
|
-
|
|
1478
|
+
if (cur) out.push(cur);
|
|
1479
|
+
return out.length ? out : [""];
|
|
1480
|
+
}
|
|
1481
|
+
function clip(s, max) {
|
|
1482
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
1483
|
+
return one.length > max ? one.slice(0, Math.max(1, max - 1)) + "\u2026" : one;
|
|
1484
|
+
}
|
|
1485
|
+
function orderFindings(findings) {
|
|
1486
|
+
return [...findings].sort((a, b) => {
|
|
1487
|
+
if (a.severity !== b.severity) return a.severity === "block" ? -1 : 1;
|
|
1488
|
+
const fa = a.file ?? "";
|
|
1489
|
+
const fb = b.file ?? "";
|
|
1490
|
+
if (fa !== fb) return fa < fb ? -1 : 1;
|
|
1491
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
function countsPhrase(blocks, warns) {
|
|
1495
|
+
const parts = [];
|
|
1496
|
+
if (blocks) parts.push(`${blocks} blocking`);
|
|
1497
|
+
if (warns) parts.push(`${warns} warning${warns === 1 ? "" : "s"}`);
|
|
1498
|
+
return parts.join(", ");
|
|
1499
|
+
}
|
|
1500
|
+
function scopePhrase(scanned, ignoredFiles) {
|
|
1501
|
+
const ignored = ignoredFiles > 0 ? `, ${ignoredFiles} file${ignoredFiles === 1 ? "" : "s"} ignored by policy` : "";
|
|
1502
|
+
return `${scanned} change${scanned === 1 ? "" : "s"} scanned${ignored}`;
|
|
1503
|
+
}
|
|
1504
|
+
var SIGNOFF_NOTE = "A blocking finding clears only with a human sign-off. In CI that sign-off is out-of-band \u2014 a PR label applied by a reviewer \u2014 never a file committed on the branch under review. See SPEC \xA75.4.";
|
|
1505
|
+
function renderText(input, opts) {
|
|
1506
|
+
const { scanned, ignoredFiles } = input;
|
|
1507
|
+
const findings = orderFindings(input.findings);
|
|
1508
|
+
const blocks = findings.filter((f) => f.severity === "block");
|
|
1509
|
+
const warns = findings.filter((f) => f.severity === "warn");
|
|
1510
|
+
const c = opts.colour;
|
|
1511
|
+
const out = [];
|
|
1373
1512
|
if (findings.length === 0) {
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1513
|
+
return `${paint("tamperward: clean", GREEN, c)} \u2014 no integrity findings (${scopePhrase(scanned, ignoredFiles)}).
|
|
1514
|
+
`;
|
|
1515
|
+
}
|
|
1516
|
+
const headColour = blocks.length ? RED : YELLOW;
|
|
1517
|
+
out.push(paint(`tamperward: ${countsPhrase(blocks.length, warns.length)}`, BOLD + headColour, c));
|
|
1518
|
+
out.push(paint(`(${scopePhrase(scanned, ignoredFiles)})`, DIM, c));
|
|
1519
|
+
out.push("");
|
|
1520
|
+
const LABEL = "sign-off".length;
|
|
1521
|
+
const pad = (k) => (k + " ".repeat(LABEL)).slice(0, LABEL);
|
|
1522
|
+
const body = Math.max(20, opts.width - LABEL - 6);
|
|
1523
|
+
for (const f of findings) {
|
|
1524
|
+
const isBlock = f.severity === "block";
|
|
1525
|
+
const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
|
|
1526
|
+
const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
1527
|
+
out.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
1528
|
+
for (const l of wrap(f.message, opts.width - 4)) out.push(` ${l}`);
|
|
1529
|
+
const row = (key2, lines) => {
|
|
1530
|
+
lines.forEach((l, i) => out.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
|
|
1531
|
+
};
|
|
1532
|
+
row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
|
|
1533
|
+
row("instead", wrap(f.remediation, body));
|
|
1534
|
+
if (f.signoff.required) row("sign-off", [f.signoff.command]);
|
|
1535
|
+
out.push("");
|
|
1536
|
+
}
|
|
1537
|
+
if (blocks.length > 0) {
|
|
1538
|
+
out.push(
|
|
1539
|
+
wrap(SIGNOFF_NOTE, opts.width - 2).map((l) => paint(l, DIM, c)).join("\n")
|
|
1540
|
+
);
|
|
1377
1541
|
}
|
|
1542
|
+
return out.join("\n").replace(/\n+$/, "") + "\n";
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/cli/render/github.ts
|
|
1546
|
+
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
1547
|
+
function escData(s) {
|
|
1548
|
+
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
1549
|
+
}
|
|
1550
|
+
function escProp(s) {
|
|
1551
|
+
return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
1552
|
+
}
|
|
1553
|
+
function annotations(findings) {
|
|
1554
|
+
return orderFindings(findings).map((f) => {
|
|
1555
|
+
const kind = f.severity === "block" ? "error" : "warning";
|
|
1556
|
+
const props = [];
|
|
1557
|
+
if (f.file) {
|
|
1558
|
+
props.push(`file=${escProp(f.file)}`);
|
|
1559
|
+
if (f.line) props.push(`line=${f.line}`);
|
|
1560
|
+
}
|
|
1561
|
+
props.push(`title=${escProp(`tamperward: ${f.rule}`)}`);
|
|
1562
|
+
const body = `${f.message} Instead: ${f.remediation}` + (f.signoff.required ? ` Sign-off: ${f.signoff.command}` : "");
|
|
1563
|
+
return `::${kind} ${props.join(",")}::${escData(body)}`;
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
function cell(s) {
|
|
1567
|
+
return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
1568
|
+
}
|
|
1569
|
+
function locationCell(f, env) {
|
|
1570
|
+
if (!f.file) return "`(command)`";
|
|
1571
|
+
const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
|
|
1572
|
+
const server = env.GITHUB_SERVER_URL;
|
|
1573
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
1574
|
+
const sha = env.GITHUB_SHA;
|
|
1575
|
+
if (!server || !repo || !sha) return `\`${cell(label)}\``;
|
|
1576
|
+
const frag = f.line ? `#L${f.line}` : "";
|
|
1577
|
+
return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha}/${f.file}${frag})`;
|
|
1578
|
+
}
|
|
1579
|
+
function renderSummary(input, env = process.env) {
|
|
1580
|
+
const findings = orderFindings(input.findings);
|
|
1378
1581
|
const blocks = findings.filter((f) => f.severity === "block");
|
|
1379
1582
|
const warns = findings.filter((f) => f.severity === "warn");
|
|
1583
|
+
const scope = scopePhrase(input.scanned, input.ignoredFiles);
|
|
1584
|
+
if (findings.length === 0) {
|
|
1585
|
+
return `## Tamperward: clean
|
|
1586
|
+
|
|
1587
|
+
No integrity findings (${scope}).
|
|
1588
|
+
`;
|
|
1589
|
+
}
|
|
1590
|
+
const out = [
|
|
1591
|
+
`## Tamperward: ${countsPhrase(blocks.length, warns.length)}`,
|
|
1592
|
+
"",
|
|
1593
|
+
`${scope}. Every finding below is also annotated on the diff in **Files changed**.`,
|
|
1594
|
+
"",
|
|
1595
|
+
"| Severity | Rule | Location | Finding | Instead |",
|
|
1596
|
+
"| --- | --- | --- | --- | --- |"
|
|
1597
|
+
];
|
|
1380
1598
|
for (const f of findings) {
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
`);
|
|
1599
|
+
const sev = f.severity === "block" ? "**BLOCK**" : "warn";
|
|
1600
|
+
out.push(
|
|
1601
|
+
`| ${sev} | \`${cell(f.rule)}\` | ${locationCell(f, env)} | ${cell(f.message)} | ${cell(f.remediation)} |`
|
|
1602
|
+
);
|
|
1394
1603
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1604
|
+
const needSignoff = findings.filter((f) => f.signoff.required);
|
|
1605
|
+
if (needSignoff.length) {
|
|
1606
|
+
out.push("", "### Clearing a blocking finding", "", SIGNOFF_NOTE, "");
|
|
1607
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1608
|
+
for (const f of needSignoff) {
|
|
1609
|
+
if (seen.has(f.signoff.command)) continue;
|
|
1610
|
+
seen.add(f.signoff.command);
|
|
1611
|
+
out.push(`- \`${cell(f.signoff.command)}\``);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return out.join("\n") + "\n";
|
|
1615
|
+
}
|
|
1616
|
+
function writeSummary(md, env = process.env) {
|
|
1617
|
+
const path = env.GITHUB_STEP_SUMMARY;
|
|
1618
|
+
if (!path) return false;
|
|
1619
|
+
try {
|
|
1620
|
+
appendFileSync2(path, md + "\n");
|
|
1621
|
+
return true;
|
|
1622
|
+
} catch {
|
|
1623
|
+
return false;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
function isGitHubActions(env = process.env) {
|
|
1627
|
+
return env.GITHUB_ACTIONS === "true";
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/cli/report.ts
|
|
1631
|
+
var FORMATS = ["auto", "text", "json", "github"];
|
|
1632
|
+
function isFormat(s) {
|
|
1633
|
+
return FORMATS.includes(s);
|
|
1634
|
+
}
|
|
1635
|
+
function resolveFormat(input, env = process.env) {
|
|
1636
|
+
if (input.json) return "json";
|
|
1637
|
+
if (input.format && input.format !== "auto") return input.format;
|
|
1638
|
+
return isGitHubActions(env) ? "github" : "text";
|
|
1639
|
+
}
|
|
1640
|
+
function report(input) {
|
|
1641
|
+
const { findings, scanned, ignoredFiles } = input;
|
|
1642
|
+
const format = resolveFormat(input);
|
|
1643
|
+
if (format === "json") {
|
|
1644
|
+
const blocks = findings.filter((f) => f.severity === "block").length;
|
|
1401
1645
|
process.stdout.write(
|
|
1402
|
-
|
|
1646
|
+
JSON.stringify(
|
|
1647
|
+
// `findings`, `scanned` and `ignoredFiles` keep their exact prior shape; `summary`
|
|
1648
|
+
// is additive, so an existing consumer reading only the old keys is unaffected.
|
|
1649
|
+
{ findings, scanned, ignoredFiles, summary: { block: blocks, warn: findings.length - blocks } },
|
|
1650
|
+
null,
|
|
1651
|
+
2
|
|
1652
|
+
) + "\n"
|
|
1403
1653
|
);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
const text = renderText(
|
|
1657
|
+
{ findings, scanned, ignoredFiles },
|
|
1658
|
+
{ colour: colourEnabled(), width: terminalWidth() }
|
|
1659
|
+
);
|
|
1660
|
+
if (format === "github") {
|
|
1661
|
+
for (const a of annotations(findings)) process.stdout.write(a + "\n");
|
|
1662
|
+
writeSummary(renderSummary({ findings, scanned, ignoredFiles }));
|
|
1404
1663
|
}
|
|
1664
|
+
process.stdout.write(text);
|
|
1405
1665
|
}
|
|
1406
1666
|
|
|
1407
1667
|
// src/cli/check.ts
|
|
@@ -1439,7 +1699,7 @@ function check(opts) {
|
|
|
1439
1699
|
process.stderr.write(`tamperward: ${cleared.length} blocking finding(s) cleared by ${how}: ${cleared.map((f) => f.rule + (f.file ? `(${f.file})` : "")).join(", ")}
|
|
1440
1700
|
`);
|
|
1441
1701
|
}
|
|
1442
|
-
report({ findings, scanned: changes.length, ignoredFiles, json: opts.json });
|
|
1702
|
+
report({ findings, scanned: changes.length, ignoredFiles, json: opts.json, format: opts.format });
|
|
1443
1703
|
return hasBlocking(findings) ? 1 : 0;
|
|
1444
1704
|
}
|
|
1445
1705
|
function runCheck(opts) {
|
|
@@ -1456,7 +1716,7 @@ function runCheck(opts) {
|
|
|
1456
1716
|
}
|
|
1457
1717
|
|
|
1458
1718
|
// src/cli/hook.ts
|
|
1459
|
-
import { readFileSync as readFileSync6, appendFileSync as
|
|
1719
|
+
import { readFileSync as readFileSync6, appendFileSync as appendFileSync3 } from "node:fs";
|
|
1460
1720
|
|
|
1461
1721
|
// src/adapters/claude/changes.ts
|
|
1462
1722
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
@@ -1631,7 +1891,7 @@ function recordDenylog(blocks) {
|
|
|
1631
1891
|
const log = process.env.TAMPERWARD_DENYLOG;
|
|
1632
1892
|
if (!log) return;
|
|
1633
1893
|
try {
|
|
1634
|
-
|
|
1894
|
+
appendFileSync3(log, blocks.map((b) => b.rule).join(",") + "\n");
|
|
1635
1895
|
} catch {
|
|
1636
1896
|
}
|
|
1637
1897
|
}
|
|
@@ -1782,7 +2042,12 @@ function parseCheck(args) {
|
|
|
1782
2042
|
if (a === "--staged") o.staged = true;
|
|
1783
2043
|
else if (a === "--worktree") o.worktree = true;
|
|
1784
2044
|
else if (a === "--json") o.json = true;
|
|
1785
|
-
else if (a === "--
|
|
2045
|
+
else if (a === "--format") {
|
|
2046
|
+
const v = args[++i];
|
|
2047
|
+
if (v !== void 0 && isFormat(v)) o.format = v;
|
|
2048
|
+
else process.stderr.write(`tamperward: unknown --format "${v ?? ""}" (expected ${FORMATS.join(" | ")})
|
|
2049
|
+
`);
|
|
2050
|
+
} else if (a === "--diff") o.diff = args[++i];
|
|
1786
2051
|
else if (a === "--cwd") o.cwd = args[++i];
|
|
1787
2052
|
else {
|
|
1788
2053
|
process.stderr.write(`tamperward: unknown flag "${a}"
|
|
@@ -1798,7 +2063,18 @@ Usage:
|
|
|
1798
2063
|
tamperward check --staged check staged changes (pre-commit)
|
|
1799
2064
|
tamperward check --worktree check working-tree changes (stop sweep)
|
|
1800
2065
|
tamperward check --diff <base>...<head> check a commit range (CI authority)
|
|
1801
|
-
tamperward check ... --json machine-readable output
|
|
2066
|
+
tamperward check ... --json machine-readable output (alias for --format json)
|
|
2067
|
+
tamperward check ... --format <fmt> text | json | github | auto (default)
|
|
2068
|
+
|
|
2069
|
+
Formats:
|
|
2070
|
+
text grouped, wrapped, colour-optional terminal output. Severity is always
|
|
2071
|
+
spelled out ("BLOCK" / "warn"), never carried by colour alone. Honours
|
|
2072
|
+
NO_COLOR and FORCE_COLOR; colour is off when stdout is not a terminal.
|
|
2073
|
+
github GitHub Actions view: one inline annotation per finding (so it shows up
|
|
2074
|
+
on the line in "Files changed") plus a job-summary table on the run
|
|
2075
|
+
page, alongside the same text output in the log.
|
|
2076
|
+
json the findings verbatim, plus a summary count.
|
|
2077
|
+
auto github when GITHUB_ACTIONS=true, otherwise text.
|
|
1802
2078
|
tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
|
|
1803
2079
|
tamperward sweep claude Stop sweep (re-scan the turn's working tree)
|
|
1804
2080
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "hexrift",
|