tamperward 1.0.0 → 1.1.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 CHANGED
@@ -99,8 +99,8 @@ with the same engine it ships.
99
99
  - `src/engine.ts` — runs the enabled rules over `Change[]`; honours `policy.ignore`.
100
100
  - `src/cli/` — `tamperward check --staged | --worktree | --diff <base>...<head>`,
101
101
  exit 1 on any blocking finding.
102
- - `test/` — 158 tests, including the AST-vs-regex, self-hosting precision, and
103
- pre-go-live audit regression cases.
102
+ - `test/` — 211 tests, including the AST-vs-regex, self-hosting precision, and
103
+ pre-go-live audit regression cases, and the renderer accessibility contract.
104
104
 
105
105
  - `src/adapters/claude/` + `src/cli/hook.ts` — the agent layer: `tamperward hook claude`
106
106
  (PreToolUse deny, fail-closed) and `tamperward sweep claude` (Stop sweep, compared against
@@ -120,6 +120,24 @@ npx tamperward check --staged # pre-commit view
120
120
  npx tamperward check --diff "main...HEAD" # CI view — the authority for main
121
121
  ```
122
122
 
123
+ ### Reading the verdict
124
+
125
+ One verdict, rendered for whoever is reading it. `--format` picks the view; the default,
126
+ `auto`, picks `github` when `GITHUB_ACTIONS=true` and `text` otherwise, so the CI wiring
127
+ stays a single line.
128
+
129
+ | Format | Where it goes |
130
+ | --- | --- |
131
+ | `text` | The terminal. Blocking findings first, then by file and line, wrapped to the terminal width. |
132
+ | `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. |
133
+ | `json` | The findings verbatim, plus a summary count. |
134
+
135
+ Severity is always spelled out (`BLOCK` / `warn`) and never carried by colour or a glyph
136
+ alone, so the output reads the same piped, in a CI log, on a colour-blind reader's
137
+ terminal, and through a screen reader. Colour honours
138
+ [`NO_COLOR`](https://no-color.org) and `FORCE_COLOR`, and is off whenever stdout is not a
139
+ terminal.
140
+
123
141
  From a clone:
124
142
 
125
143
  ```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
- return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
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 {
@@ -659,10 +670,14 @@ function underCoverageKey(node) {
659
670
  }
660
671
  return false;
661
672
  }
673
+ function asExpression(src) {
674
+ const t = src.trim();
675
+ return t.startsWith("{") ? `(${t})` : src;
676
+ }
662
677
  function parseThresholds(src) {
663
678
  const res = { paths: /* @__PURE__ */ new Map(), present: false };
664
679
  try {
665
- const sf = ts2.createSourceFile("cfg.ts", src, ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
680
+ const sf = ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
666
681
  const visit = (node) => {
667
682
  if (ts2.isPropertyAssignment(node) && ts2.isObjectLiteralExpression(node.initializer)) {
668
683
  const key2 = keyName(node.name);
@@ -1362,46 +1377,226 @@ function oobFromEnv(env = process.env) {
1362
1377
  return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1363
1378
  }
1364
1379
 
1365
- // src/cli/report.ts
1366
- function report(input) {
1367
- const { findings, scanned, ignoredFiles } = input;
1368
- if (input.json) {
1369
- process.stdout.write(JSON.stringify({ findings, scanned, ignoredFiles }, null, 2) + "\n");
1370
- return;
1380
+ // src/cli/render/text.ts
1381
+ var ESC = "\x1B";
1382
+ var RESET = `${ESC}[0m`;
1383
+ var BOLD = `${ESC}[1m`;
1384
+ var DIM = `${ESC}[2m`;
1385
+ var RED = `${ESC}[31m`;
1386
+ var YELLOW = `${ESC}[33m`;
1387
+ var GREEN = `${ESC}[32m`;
1388
+ function colourEnabled(env = process.env, stream = process.stdout) {
1389
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1390
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
1391
+ if (env.TERM === "dumb") return false;
1392
+ return Boolean(stream.isTTY);
1393
+ }
1394
+ function terminalWidth(stream = process.stdout) {
1395
+ const c = stream.columns;
1396
+ if (!c || c < 40) return 80;
1397
+ return Math.min(c, 100);
1398
+ }
1399
+ function paint(s, code, on) {
1400
+ return on ? code + s + RESET : s;
1401
+ }
1402
+ function wrap(text, width) {
1403
+ const out = [];
1404
+ let cur = "";
1405
+ for (const w of text.split(/\s+/).filter(Boolean)) {
1406
+ if (cur === "") cur = w;
1407
+ else if (cur.length + 1 + w.length <= width) cur += " " + w;
1408
+ else {
1409
+ out.push(cur);
1410
+ cur = w;
1411
+ }
1371
1412
  }
1372
- const ignoredNote = ignoredFiles > 0 ? `, ${ignoredFiles} file(s) ignored by policy` : "";
1413
+ if (cur) out.push(cur);
1414
+ return out.length ? out : [""];
1415
+ }
1416
+ function clip(s, max) {
1417
+ const one = s.replace(/\s+/g, " ").trim();
1418
+ return one.length > max ? one.slice(0, Math.max(1, max - 1)) + "\u2026" : one;
1419
+ }
1420
+ function orderFindings(findings) {
1421
+ return [...findings].sort((a, b) => {
1422
+ if (a.severity !== b.severity) return a.severity === "block" ? -1 : 1;
1423
+ const fa = a.file ?? "";
1424
+ const fb = b.file ?? "";
1425
+ if (fa !== fb) return fa < fb ? -1 : 1;
1426
+ return (a.line ?? 0) - (b.line ?? 0);
1427
+ });
1428
+ }
1429
+ function countsPhrase(blocks, warns) {
1430
+ const parts = [];
1431
+ if (blocks) parts.push(`${blocks} blocking`);
1432
+ if (warns) parts.push(`${warns} warning${warns === 1 ? "" : "s"}`);
1433
+ return parts.join(", ");
1434
+ }
1435
+ function scopePhrase(scanned, ignoredFiles) {
1436
+ const ignored = ignoredFiles > 0 ? `, ${ignoredFiles} file${ignoredFiles === 1 ? "" : "s"} ignored by policy` : "";
1437
+ return `${scanned} change${scanned === 1 ? "" : "s"} scanned${ignored}`;
1438
+ }
1439
+ 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.";
1440
+ function renderText(input, opts) {
1441
+ const { scanned, ignoredFiles } = input;
1442
+ const findings = orderFindings(input.findings);
1443
+ const blocks = findings.filter((f) => f.severity === "block");
1444
+ const warns = findings.filter((f) => f.severity === "warn");
1445
+ const c = opts.colour;
1446
+ const out = [];
1373
1447
  if (findings.length === 0) {
1374
- process.stdout.write(`tamperward: clean \u2014 no integrity findings (${scanned} change(s) scanned${ignoredNote}).
1375
- `);
1376
- return;
1448
+ return `${paint("tamperward: clean", GREEN, c)} \u2014 no integrity findings (${scopePhrase(scanned, ignoredFiles)}).
1449
+ `;
1450
+ }
1451
+ const headColour = blocks.length ? RED : YELLOW;
1452
+ out.push(paint(`tamperward: ${countsPhrase(blocks.length, warns.length)}`, BOLD + headColour, c));
1453
+ out.push(paint(`(${scopePhrase(scanned, ignoredFiles)})`, DIM, c));
1454
+ out.push("");
1455
+ const LABEL = "sign-off".length;
1456
+ const pad = (k) => (k + " ".repeat(LABEL)).slice(0, LABEL);
1457
+ const body = Math.max(20, opts.width - LABEL - 6);
1458
+ for (const f of findings) {
1459
+ const isBlock = f.severity === "block";
1460
+ const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
1461
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
1462
+ out.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
1463
+ for (const l of wrap(f.message, opts.width - 4)) out.push(` ${l}`);
1464
+ const row = (key2, lines) => {
1465
+ lines.forEach((l, i) => out.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
1466
+ };
1467
+ row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
1468
+ row("instead", wrap(f.remediation, body));
1469
+ if (f.signoff.required) row("sign-off", [f.signoff.command]);
1470
+ out.push("");
1377
1471
  }
1472
+ if (blocks.length > 0) {
1473
+ out.push(
1474
+ wrap(SIGNOFF_NOTE, opts.width - 2).map((l) => paint(l, DIM, c)).join("\n")
1475
+ );
1476
+ }
1477
+ return out.join("\n").replace(/\n+$/, "") + "\n";
1478
+ }
1479
+
1480
+ // src/cli/render/github.ts
1481
+ import { appendFileSync as appendFileSync2 } from "node:fs";
1482
+ function escData(s) {
1483
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
1484
+ }
1485
+ function escProp(s) {
1486
+ return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
1487
+ }
1488
+ function annotations(findings) {
1489
+ return orderFindings(findings).map((f) => {
1490
+ const kind = f.severity === "block" ? "error" : "warning";
1491
+ const props = [];
1492
+ if (f.file) {
1493
+ props.push(`file=${escProp(f.file)}`);
1494
+ if (f.line) props.push(`line=${f.line}`);
1495
+ }
1496
+ props.push(`title=${escProp(`tamperward: ${f.rule}`)}`);
1497
+ const body = `${f.message} Instead: ${f.remediation}` + (f.signoff.required ? ` Sign-off: ${f.signoff.command}` : "");
1498
+ return `::${kind} ${props.join(",")}::${escData(body)}`;
1499
+ });
1500
+ }
1501
+ function cell(s) {
1502
+ return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
1503
+ }
1504
+ function locationCell(f, env) {
1505
+ if (!f.file) return "`(command)`";
1506
+ const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
1507
+ const server = env.GITHUB_SERVER_URL;
1508
+ const repo = env.GITHUB_REPOSITORY;
1509
+ const sha = env.GITHUB_SHA;
1510
+ if (!server || !repo || !sha) return `\`${cell(label)}\``;
1511
+ const frag = f.line ? `#L${f.line}` : "";
1512
+ return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha}/${f.file}${frag})`;
1513
+ }
1514
+ function renderSummary(input, env = process.env) {
1515
+ const findings = orderFindings(input.findings);
1378
1516
  const blocks = findings.filter((f) => f.severity === "block");
1379
1517
  const warns = findings.filter((f) => f.severity === "warn");
1518
+ const scope = scopePhrase(input.scanned, input.ignoredFiles);
1519
+ if (findings.length === 0) {
1520
+ return `## Tamperward: clean
1521
+
1522
+ No integrity findings (${scope}).
1523
+ `;
1524
+ }
1525
+ const out = [
1526
+ `## Tamperward: ${countsPhrase(blocks.length, warns.length)}`,
1527
+ "",
1528
+ `${scope}. Every finding below is also annotated on the diff in **Files changed**.`,
1529
+ "",
1530
+ "| Severity | Rule | Location | Finding | Instead |",
1531
+ "| --- | --- | --- | --- | --- |"
1532
+ ];
1380
1533
  for (const f of findings) {
1381
- const mark = f.severity === "block" ? "BLOCK" : "warn";
1382
- const loc = f.file ? ` ${f.file}${f.line ? `:${f.line}` : ""}` : "";
1383
- process.stdout.write(`
1384
- [${mark}] ${f.rule}${loc}
1385
- `);
1386
- process.stdout.write(` ${f.message}
1387
- `);
1388
- process.stdout.write(` evidence: ${f.evidence}
1389
- `);
1390
- process.stdout.write(` fix: ${f.remediation}
1391
- `);
1392
- if (f.signoff.required) process.stdout.write(` sign-off: ${f.signoff.command}
1393
- `);
1534
+ const sev = f.severity === "block" ? "**BLOCK**" : "warn";
1535
+ out.push(
1536
+ `| ${sev} | \`${cell(f.rule)}\` | ${locationCell(f, env)} | ${cell(f.message)} | ${cell(f.remediation)} |`
1537
+ );
1394
1538
  }
1395
- process.stdout.write(
1396
- `
1397
- tamperward: ${blocks.length} blocking, ${warns.length} warning (${scanned} change(s) scanned${ignoredNote}).
1398
- `
1399
- );
1400
- if (blocks.length > 0) {
1539
+ const needSignoff = findings.filter((f) => f.signoff.required);
1540
+ if (needSignoff.length) {
1541
+ out.push("", "### Clearing a blocking finding", "", SIGNOFF_NOTE, "");
1542
+ const seen = /* @__PURE__ */ new Set();
1543
+ for (const f of needSignoff) {
1544
+ if (seen.has(f.signoff.command)) continue;
1545
+ seen.add(f.signoff.command);
1546
+ out.push(`- \`${cell(f.signoff.command)}\``);
1547
+ }
1548
+ }
1549
+ return out.join("\n") + "\n";
1550
+ }
1551
+ function writeSummary(md, env = process.env) {
1552
+ const path = env.GITHUB_STEP_SUMMARY;
1553
+ if (!path) return false;
1554
+ try {
1555
+ appendFileSync2(path, md + "\n");
1556
+ return true;
1557
+ } catch {
1558
+ return false;
1559
+ }
1560
+ }
1561
+ function isGitHubActions(env = process.env) {
1562
+ return env.GITHUB_ACTIONS === "true";
1563
+ }
1564
+
1565
+ // src/cli/report.ts
1566
+ var FORMATS = ["auto", "text", "json", "github"];
1567
+ function isFormat(s) {
1568
+ return FORMATS.includes(s);
1569
+ }
1570
+ function resolveFormat(input, env = process.env) {
1571
+ if (input.json) return "json";
1572
+ if (input.format && input.format !== "auto") return input.format;
1573
+ return isGitHubActions(env) ? "github" : "text";
1574
+ }
1575
+ function report(input) {
1576
+ const { findings, scanned, ignoredFiles } = input;
1577
+ const format = resolveFormat(input);
1578
+ if (format === "json") {
1579
+ const blocks = findings.filter((f) => f.severity === "block").length;
1401
1580
  process.stdout.write(
1402
- "A blocking finding clears only with a human sign-off. In CI, sign-off is out-of-band (a reviewed PR label), never a committed file \u2014 see SPEC \xA75.4.\n"
1581
+ JSON.stringify(
1582
+ // `findings`, `scanned` and `ignoredFiles` keep their exact prior shape; `summary`
1583
+ // is additive, so an existing consumer reading only the old keys is unaffected.
1584
+ { findings, scanned, ignoredFiles, summary: { block: blocks, warn: findings.length - blocks } },
1585
+ null,
1586
+ 2
1587
+ ) + "\n"
1403
1588
  );
1589
+ return;
1404
1590
  }
1591
+ const text = renderText(
1592
+ { findings, scanned, ignoredFiles },
1593
+ { colour: colourEnabled(), width: terminalWidth() }
1594
+ );
1595
+ if (format === "github") {
1596
+ for (const a of annotations(findings)) process.stdout.write(a + "\n");
1597
+ writeSummary(renderSummary({ findings, scanned, ignoredFiles }));
1598
+ }
1599
+ process.stdout.write(text);
1405
1600
  }
1406
1601
 
1407
1602
  // src/cli/check.ts
@@ -1439,7 +1634,7 @@ function check(opts) {
1439
1634
  process.stderr.write(`tamperward: ${cleared.length} blocking finding(s) cleared by ${how}: ${cleared.map((f) => f.rule + (f.file ? `(${f.file})` : "")).join(", ")}
1440
1635
  `);
1441
1636
  }
1442
- report({ findings, scanned: changes.length, ignoredFiles, json: opts.json });
1637
+ report({ findings, scanned: changes.length, ignoredFiles, json: opts.json, format: opts.format });
1443
1638
  return hasBlocking(findings) ? 1 : 0;
1444
1639
  }
1445
1640
  function runCheck(opts) {
@@ -1456,7 +1651,7 @@ function runCheck(opts) {
1456
1651
  }
1457
1652
 
1458
1653
  // src/cli/hook.ts
1459
- import { readFileSync as readFileSync6, appendFileSync as appendFileSync2 } from "node:fs";
1654
+ import { readFileSync as readFileSync6, appendFileSync as appendFileSync3 } from "node:fs";
1460
1655
 
1461
1656
  // src/adapters/claude/changes.ts
1462
1657
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -1631,7 +1826,7 @@ function recordDenylog(blocks) {
1631
1826
  const log = process.env.TAMPERWARD_DENYLOG;
1632
1827
  if (!log) return;
1633
1828
  try {
1634
- appendFileSync2(log, blocks.map((b) => b.rule).join(",") + "\n");
1829
+ appendFileSync3(log, blocks.map((b) => b.rule).join(",") + "\n");
1635
1830
  } catch {
1636
1831
  }
1637
1832
  }
@@ -1782,7 +1977,12 @@ function parseCheck(args) {
1782
1977
  if (a === "--staged") o.staged = true;
1783
1978
  else if (a === "--worktree") o.worktree = true;
1784
1979
  else if (a === "--json") o.json = true;
1785
- else if (a === "--diff") o.diff = args[++i];
1980
+ else if (a === "--format") {
1981
+ const v = args[++i];
1982
+ if (v !== void 0 && isFormat(v)) o.format = v;
1983
+ else process.stderr.write(`tamperward: unknown --format "${v ?? ""}" (expected ${FORMATS.join(" | ")})
1984
+ `);
1985
+ } else if (a === "--diff") o.diff = args[++i];
1786
1986
  else if (a === "--cwd") o.cwd = args[++i];
1787
1987
  else {
1788
1988
  process.stderr.write(`tamperward: unknown flag "${a}"
@@ -1798,7 +1998,18 @@ Usage:
1798
1998
  tamperward check --staged check staged changes (pre-commit)
1799
1999
  tamperward check --worktree check working-tree changes (stop sweep)
1800
2000
  tamperward check --diff <base>...<head> check a commit range (CI authority)
1801
- tamperward check ... --json machine-readable output
2001
+ tamperward check ... --json machine-readable output (alias for --format json)
2002
+ tamperward check ... --format <fmt> text | json | github | auto (default)
2003
+
2004
+ Formats:
2005
+ text grouped, wrapped, colour-optional terminal output. Severity is always
2006
+ spelled out ("BLOCK" / "warn"), never carried by colour alone. Honours
2007
+ NO_COLOR and FORCE_COLOR; colour is off when stdout is not a terminal.
2008
+ github GitHub Actions view: one inline annotation per finding (so it shows up
2009
+ on the line in "Files changed") plus a job-summary table on the run
2010
+ page, alongside the same text output in the log.
2011
+ json the findings verbatim, plus a summary count.
2012
+ auto github when GITHUB_ACTIONS=true, otherwise text.
1802
2013
  tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
1803
2014
  tamperward sweep claude Stop sweep (re-scan the turn's working tree)
1804
2015
  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.0.0",
3
+ "version": "1.1.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",