tamperward 2.1.0 → 2.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 +7 -5
- package/dist/cli/index.js +409 -118
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,8 @@ impossible.
|
|
|
37
37
|
> and hardened. Use it as one layer of defence in depth alongside protected CI,
|
|
38
38
|
> independent tests and human review. Findings, limitations and corrections are
|
|
39
39
|
> published openly. The 2.0 major marks the Node 18 drop, not a declaration of
|
|
40
|
-
> security maturity
|
|
40
|
+
> security maturity; the distance to that is tracked, milestone by milestone, in
|
|
41
|
+
> [SPEC §9.1](./SPEC.md#91-maturity-milestones).
|
|
41
42
|
|
|
42
43
|
## What we have actually measured
|
|
43
44
|
|
|
@@ -63,7 +64,7 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
|
|
|
63
64
|
> **Scope.** The rows above cover specific models, pressure prompts, treatment
|
|
64
65
|
> versions, and finite JavaScript/TypeScript and Python repository samples —
|
|
65
66
|
> evidence for those settings, not a universal claim. Round 2 tested
|
|
66
|
-
> the v1.9.0 stack; the current **
|
|
67
|
+
> the v1.9.0 stack; the current **2.x** line adds post-study envelope hardening
|
|
67
68
|
> (externally reviewed, with findings tracked individually as REPRO or AUDIT in
|
|
68
69
|
> [SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md) and closed with regression and
|
|
69
70
|
> mutation checks — see [CHANGELOG](./CHANGELOG.md)). Rounds 3 and 3.1 are
|
|
@@ -282,7 +283,7 @@ commands ignore what they do not know.
|
|
|
282
283
|
|
|
283
284
|
| command | 0 | 1 | 2 |
|
|
284
285
|
| --- | --- | --- | --- |
|
|
285
|
-
| `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range,
|
|
286
|
+
| `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, no view given, not a git repository, or an unresolvable revision — any failure the gate cannot recover from is one clean `tamperward: …` line on stderr at exit 2, never a stack trace at exit 1 |
|
|
286
287
|
| `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
|
|
287
288
|
| `run` | enforcement clean and the agent exited 0 — a non-zero agent exit is passed through unchanged | any blocking finding or masked failure, whatever the agent returned | cannot adjudicate: dirty start, policy error, verify cannot run |
|
|
288
289
|
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
|
|
@@ -352,7 +353,8 @@ and its totals.
|
|
|
352
353
|
|
|
353
354
|
**[The research series](./docs/blog/index.md)** ·
|
|
354
355
|
**[The harness](./harness/)** ·
|
|
355
|
-
**[Errata](./docs/blog/errata.md)**
|
|
356
|
+
**[Errata](./docs/blog/errata.md)** ·
|
|
357
|
+
**[Maturity milestones](./SPEC.md#91-maturity-milestones)**
|
|
356
358
|
|
|
357
359
|
## The architecture was earned by failures
|
|
358
360
|
|
|
@@ -393,7 +395,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
|
|
|
393
395
|
|
|
394
396
|
```bash
|
|
395
397
|
npm install && npm run build # bundles the CLI to dist/cli/index.js
|
|
396
|
-
npm test #
|
|
398
|
+
npm test # 540+ tests — parser, detectors, engine, policy, renderers
|
|
397
399
|
npm run typecheck
|
|
398
400
|
```
|
|
399
401
|
|
package/dist/cli/index.js
CHANGED
|
@@ -33,6 +33,11 @@ function unquotePath(p) {
|
|
|
33
33
|
}
|
|
34
34
|
return Buffer.from(bytes).toString("utf8");
|
|
35
35
|
}
|
|
36
|
+
function pathToken(raw) {
|
|
37
|
+
if (raw.startsWith('"')) return raw;
|
|
38
|
+
const tab = raw.indexOf(" ");
|
|
39
|
+
return tab >= 0 ? raw.slice(0, tab) : raw;
|
|
40
|
+
}
|
|
36
41
|
function endOfQuoted(s) {
|
|
37
42
|
for (let i = 1; i < s.length; i++) {
|
|
38
43
|
if (s[i] === "\\") {
|
|
@@ -59,10 +64,17 @@ function headerPaths(line) {
|
|
|
59
64
|
first = rest.slice(0, q);
|
|
60
65
|
rest = rest.slice(q + 1);
|
|
61
66
|
} else {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
const mid = (rest.length - 1) / 2;
|
|
68
|
+
const sym = Number.isInteger(mid) && rest[mid] === " " && rest.startsWith("a/") && rest.slice(mid + 1).startsWith("b/") && rest.slice(2, mid) === rest.slice(mid + 3);
|
|
69
|
+
if (sym) {
|
|
70
|
+
first = rest.slice(0, mid);
|
|
71
|
+
rest = rest.slice(mid + 1);
|
|
72
|
+
} else {
|
|
73
|
+
const m = rest.match(/^(.*) (b\/.*)$/);
|
|
74
|
+
if (!m) return [null, null];
|
|
75
|
+
first = m[1];
|
|
76
|
+
rest = m[2];
|
|
77
|
+
}
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
return [stripAB(first), stripAB(rest)];
|
|
@@ -126,8 +138,8 @@ function parseDiff(diff) {
|
|
|
126
138
|
renameTo = unquotePath(l.slice(8));
|
|
127
139
|
if (op === "modify") op = "rename";
|
|
128
140
|
} else if (l.startsWith("Binary files ") || l.startsWith("GIT binary patch")) binary = true;
|
|
129
|
-
else if (l.startsWith("--- ")) minusPath = l.slice(4);
|
|
130
|
-
else if (l.startsWith("+++ ")) plusPath = l.slice(4);
|
|
141
|
+
else if (l.startsWith("--- ")) minusPath = pathToken(l.slice(4));
|
|
142
|
+
else if (l.startsWith("+++ ")) plusPath = pathToken(l.slice(4));
|
|
131
143
|
i++;
|
|
132
144
|
}
|
|
133
145
|
let path;
|
|
@@ -196,7 +208,12 @@ function git(args, cwd) {
|
|
|
196
208
|
return execFileSync("git", args, {
|
|
197
209
|
cwd,
|
|
198
210
|
encoding: "utf8",
|
|
199
|
-
|
|
211
|
+
// 256 MiB, matching the hook adapter. Every diff below runs with `--text`, so a
|
|
212
|
+
// changed binary asset now costs its full size in patch output instead of one
|
|
213
|
+
// "Binary files differ" line; a legitimate golden-image update must fit. Past
|
|
214
|
+
// the cap git() throws and the view fails CLOSED (a truncated patch would parse
|
|
215
|
+
// as a smaller change than the one landing), which is the documented stance.
|
|
216
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
200
217
|
stdio: ["ignore", "pipe", "pipe"]
|
|
201
218
|
});
|
|
202
219
|
} catch (e) {
|
|
@@ -220,7 +237,7 @@ function fromDisk(path, cwd) {
|
|
|
220
237
|
}
|
|
221
238
|
}
|
|
222
239
|
function enrich(c, beforeReader, afterReader) {
|
|
223
|
-
if (c.kind !== "file"
|
|
240
|
+
if (c.kind !== "file") return c;
|
|
224
241
|
const before = c.op !== "add" ? beforeReader(c.oldPath ?? c.path) : null;
|
|
225
242
|
const after = c.op !== "delete" ? afterReader(c.path) : null;
|
|
226
243
|
return { ...c, before, after };
|
|
@@ -261,8 +278,9 @@ function headSha(cwd) {
|
|
|
261
278
|
return null;
|
|
262
279
|
}
|
|
263
280
|
}
|
|
281
|
+
var DIFF_ARGS = ["diff", "--no-color", "--text", "-M"];
|
|
264
282
|
function diffSince(rev, opts = {}) {
|
|
265
|
-
const raw = git([
|
|
283
|
+
const raw = git([...DIFF_ARGS, rev], opts.cwd);
|
|
266
284
|
return parseDiff(raw).map(
|
|
267
285
|
(c) => enrich(c, (p) => blobAt(rev, p, opts.cwd), (p) => fromDisk(p, opts.cwd))
|
|
268
286
|
);
|
|
@@ -270,20 +288,20 @@ function diffSince(rev, opts = {}) {
|
|
|
270
288
|
function diffRange(base, head, opts = {}) {
|
|
271
289
|
assertRev(base);
|
|
272
290
|
assertRev(head);
|
|
273
|
-
const raw = git([
|
|
291
|
+
const raw = git([...DIFF_ARGS, `${base}...${head}`], opts.cwd);
|
|
274
292
|
const mergeBase = mergeBaseOf(base, head, opts);
|
|
275
293
|
return parseDiff(raw).map(
|
|
276
294
|
(c) => enrich(c, (p) => blobAt(mergeBase, p, opts.cwd), (p) => blobAt(head, p, opts.cwd))
|
|
277
295
|
);
|
|
278
296
|
}
|
|
279
297
|
function diffStaged(opts = {}) {
|
|
280
|
-
const raw = git([
|
|
298
|
+
const raw = git([...DIFF_ARGS, "--cached"], opts.cwd);
|
|
281
299
|
return parseDiff(raw).map(
|
|
282
300
|
(c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => blobAt("", p, opts.cwd))
|
|
283
301
|
);
|
|
284
302
|
}
|
|
285
303
|
function diffWorktree(opts = {}) {
|
|
286
|
-
const raw = git([
|
|
304
|
+
const raw = git([...DIFF_ARGS, "HEAD"], opts.cwd);
|
|
287
305
|
return parseDiff(raw).map(
|
|
288
306
|
(c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
|
|
289
307
|
);
|
|
@@ -425,6 +443,16 @@ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
|
425
443
|
}
|
|
426
444
|
return out2;
|
|
427
445
|
}
|
|
446
|
+
function normalizeGlob(glob) {
|
|
447
|
+
return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
|
|
448
|
+
}
|
|
449
|
+
function mergeRules(base, user) {
|
|
450
|
+
const out2 = { ...base };
|
|
451
|
+
for (const [name, cfg] of Object.entries(user ?? {})) {
|
|
452
|
+
out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
|
|
453
|
+
}
|
|
454
|
+
return out2;
|
|
455
|
+
}
|
|
428
456
|
var cache = /* @__PURE__ */ new Map();
|
|
429
457
|
function matcher(glob) {
|
|
430
458
|
let m = cache.get(glob);
|
|
@@ -434,12 +462,12 @@ function matcher(glob) {
|
|
|
434
462
|
}
|
|
435
463
|
return m;
|
|
436
464
|
}
|
|
437
|
-
function matchesAny(path,
|
|
438
|
-
return !!
|
|
465
|
+
function matchesAny(path, globs2) {
|
|
466
|
+
return !!globs2 && globs2.some((g) => matcher(g)(path));
|
|
439
467
|
}
|
|
440
468
|
function protectedCategory(path, policy) {
|
|
441
|
-
for (const [cat,
|
|
442
|
-
if (matchesAny(path,
|
|
469
|
+
for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
|
|
470
|
+
if (matchesAny(path, globs2)) return cat;
|
|
443
471
|
}
|
|
444
472
|
return null;
|
|
445
473
|
}
|
|
@@ -455,9 +483,9 @@ function isIgnored(path, policy) {
|
|
|
455
483
|
}
|
|
456
484
|
function mergeProtected(base, user) {
|
|
457
485
|
const out2 = { ...base };
|
|
458
|
-
for (const [cat,
|
|
486
|
+
for (const [cat, globs2] of Object.entries(user ?? {})) {
|
|
459
487
|
const have = new Set(out2[cat] ?? []);
|
|
460
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
488
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
|
|
461
489
|
}
|
|
462
490
|
return out2;
|
|
463
491
|
}
|
|
@@ -1146,6 +1174,7 @@ var ciTampering = {
|
|
|
1146
1174
|
|
|
1147
1175
|
// src/detectors/policy-diff.ts
|
|
1148
1176
|
import { parse } from "yaml";
|
|
1177
|
+
var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
1149
1178
|
function safeParse(src) {
|
|
1150
1179
|
try {
|
|
1151
1180
|
const v = parse(src);
|
|
@@ -1159,12 +1188,19 @@ function effective(raw) {
|
|
|
1159
1188
|
const v = raw.version;
|
|
1160
1189
|
const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
|
|
1161
1190
|
const base = defaultPolicy(version);
|
|
1191
|
+
const rules = { ...base.rules };
|
|
1192
|
+
for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
|
|
1193
|
+
const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
1194
|
+
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs(over.exclude) } : {} };
|
|
1195
|
+
}
|
|
1196
|
+
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs(list)])) : void 0;
|
|
1162
1197
|
return {
|
|
1163
1198
|
version,
|
|
1164
|
-
rules
|
|
1165
|
-
ignore: raw.ignore
|
|
1166
|
-
protected: mergeProtected(base.protected,
|
|
1167
|
-
requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor
|
|
1199
|
+
rules,
|
|
1200
|
+
ignore: raw.ignore ? globs(raw.ignore) : base.ignore ?? [],
|
|
1201
|
+
protected: mergeProtected(base.protected, userProtected),
|
|
1202
|
+
requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1203
|
+
ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
|
|
1168
1204
|
};
|
|
1169
1205
|
}
|
|
1170
1206
|
function policyWeakening(before, after) {
|
|
@@ -1216,6 +1252,9 @@ function policyWeakening(before, after) {
|
|
|
1216
1252
|
if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
|
|
1217
1253
|
reasons.push("sign-off no longer required for blocking findings");
|
|
1218
1254
|
}
|
|
1255
|
+
if (ae.ledger !== be.ledger) {
|
|
1256
|
+
reasons.push(`sign-off ledger moved (${be.ledger} \u2192 ${ae.ledger}) \u2014 the local layer would trust a different file`);
|
|
1257
|
+
}
|
|
1219
1258
|
const bv = b.verify;
|
|
1220
1259
|
const av = a.verify;
|
|
1221
1260
|
if (bv?.command) {
|
|
@@ -1771,7 +1810,7 @@ function hasBlocking(findings) {
|
|
|
1771
1810
|
|
|
1772
1811
|
// src/policy-load.ts
|
|
1773
1812
|
import { readFileSync as readFileSync2, existsSync } from "node:fs";
|
|
1774
|
-
import { join as join2 } from "node:path";
|
|
1813
|
+
import { isAbsolute, join as join2, posix } from "node:path";
|
|
1775
1814
|
import { parse as parse2 } from "yaml";
|
|
1776
1815
|
var PolicyError = class extends Error {
|
|
1777
1816
|
};
|
|
@@ -1786,11 +1825,21 @@ var SEVERITIES = ["block", "warn"];
|
|
|
1786
1825
|
var isSeverity = (v) => SEVERITIES.includes(v);
|
|
1787
1826
|
var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
1788
1827
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1828
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
1829
|
+
function ledgerInsideRepo(ledger) {
|
|
1830
|
+
if (!ledger.trim() || isAbsolute(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
1831
|
+
const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
|
|
1832
|
+
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
1833
|
+
}
|
|
1789
1834
|
function validate(r, where) {
|
|
1790
1835
|
function bad(msg) {
|
|
1791
1836
|
throw new PolicyError(`${where}: ${msg}`);
|
|
1792
1837
|
}
|
|
1793
1838
|
const show = (v) => JSON.stringify(v) ?? String(v);
|
|
1839
|
+
const unknown = Object.keys(r).filter((k) => !TOP_LEVEL_KEYS.has(k));
|
|
1840
|
+
if (unknown.length) {
|
|
1841
|
+
bad(`unknown top-level key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => JSON.stringify(k)).join(", ")} (expected one of ${[...TOP_LEVEL_KEYS].join(", ")})`);
|
|
1842
|
+
}
|
|
1794
1843
|
if (r.rules !== void 0) {
|
|
1795
1844
|
if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
|
|
1796
1845
|
for (const [name, cfg] of Object.entries(r.rules)) {
|
|
@@ -1809,8 +1858,8 @@ function validate(r, where) {
|
|
|
1809
1858
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
1810
1859
|
if (r.protected !== void 0) {
|
|
1811
1860
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
1812
|
-
for (const [cat,
|
|
1813
|
-
if (!isStringList(
|
|
1861
|
+
for (const [cat, globs2] of Object.entries(r.protected)) {
|
|
1862
|
+
if (!isStringList(globs2)) bad(`protected.${cat} must be a list of globs, got ${show(globs2)}`);
|
|
1814
1863
|
}
|
|
1815
1864
|
}
|
|
1816
1865
|
if (r.signoff !== void 0) {
|
|
@@ -1823,6 +1872,9 @@ function validate(r, where) {
|
|
|
1823
1872
|
}
|
|
1824
1873
|
const ledger = r.signoff.ledger;
|
|
1825
1874
|
if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
|
|
1875
|
+
if (typeof ledger === "string" && !ledgerInsideRepo(ledger)) {
|
|
1876
|
+
bad(`signoff.ledger must be a relative path inside the repository, got ${show(ledger)}`);
|
|
1877
|
+
}
|
|
1826
1878
|
}
|
|
1827
1879
|
if (r.verify !== void 0) {
|
|
1828
1880
|
if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
|
|
@@ -1839,14 +1891,20 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
1839
1891
|
const version = normalizeVersion(r.version, where);
|
|
1840
1892
|
validate(r, where);
|
|
1841
1893
|
const base = defaultPolicy(version);
|
|
1894
|
+
const globs2 = (list) => list?.map(normalizeGlob);
|
|
1895
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs2(list) ?? []])) : void 0;
|
|
1896
|
+
const userRules = r.rules ? Object.fromEntries(
|
|
1897
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs2(cfg.exclude) } : cfg])
|
|
1898
|
+
) : void 0;
|
|
1842
1899
|
return {
|
|
1843
1900
|
version,
|
|
1844
1901
|
// Merge with the baseline, never replace. For an integrity tool, a config that sets
|
|
1845
1902
|
// one rule's severity must NOT silently drop the other nine — nor may naming one
|
|
1846
|
-
// protected glob wipe out the rest of its category (see mergeProtected)
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1903
|
+
// protected glob wipe out the rest of its category (see mergeProtected), nor may
|
|
1904
|
+
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
1905
|
+
protected: mergeProtected(base.protected, userProtected),
|
|
1906
|
+
rules: mergeRules(base.rules, userRules),
|
|
1907
|
+
ignore: globs2(r.ignore) ?? base.ignore,
|
|
1850
1908
|
signoff: {
|
|
1851
1909
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1852
1910
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -1896,7 +1954,11 @@ function fingerprint(rule, file, evidence) {
|
|
|
1896
1954
|
}
|
|
1897
1955
|
var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
|
|
1898
1956
|
function ledgerPath(cwd, policy) {
|
|
1899
|
-
|
|
1957
|
+
const rel = policy.signoff?.ledger ?? ".tamperward/ledger.jsonl";
|
|
1958
|
+
if (!ledgerInsideRepo(rel)) {
|
|
1959
|
+
throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
|
|
1960
|
+
}
|
|
1961
|
+
return join3(cwd, rel);
|
|
1900
1962
|
}
|
|
1901
1963
|
function readLedger(cwd, policy) {
|
|
1902
1964
|
const p = ledgerPath(cwd, policy);
|
|
@@ -1990,7 +2052,7 @@ function paint(s, code, on) {
|
|
|
1990
2052
|
function wrap(text, width) {
|
|
1991
2053
|
const out2 = [];
|
|
1992
2054
|
let cur = "";
|
|
1993
|
-
for (const w of text.split(/\s+/).filter(Boolean)) {
|
|
2055
|
+
for (const w of stripControl(text).split(/\s+/).filter(Boolean)) {
|
|
1994
2056
|
if (cur === "") cur = w;
|
|
1995
2057
|
else if (cur.length + 1 + w.length <= width) cur += " " + w;
|
|
1996
2058
|
else {
|
|
@@ -2055,15 +2117,15 @@ function renderText(input, opts) {
|
|
|
2055
2117
|
for (const f of findings) {
|
|
2056
2118
|
const isBlock = f.severity === "block";
|
|
2057
2119
|
const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
|
|
2058
|
-
const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
2059
|
-
out2.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
2120
|
+
const loc = f.file ? `${stripControl(f.file)}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
2121
|
+
out2.push(` ${mark} ${paint(stripControl(f.rule), BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
2060
2122
|
for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
|
|
2061
2123
|
const row = (key2, lines) => {
|
|
2062
2124
|
lines.forEach((l, i) => out2.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
|
|
2063
2125
|
};
|
|
2064
2126
|
row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
|
|
2065
2127
|
row("instead", wrap(f.remediation, body));
|
|
2066
|
-
if (f.signoff.required) row("sign-off", [f.signoff.command]);
|
|
2128
|
+
if (f.signoff.required) row("sign-off", [stripControl(f.signoff.command)]);
|
|
2067
2129
|
out2.push("");
|
|
2068
2130
|
}
|
|
2069
2131
|
if (blocks.length > 0) {
|
|
@@ -2077,7 +2139,7 @@ function renderText(input, opts) {
|
|
|
2077
2139
|
// src/cli/render/github.ts
|
|
2078
2140
|
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
2079
2141
|
function escData(s) {
|
|
2080
|
-
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
2142
|
+
return stripControl(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
2081
2143
|
}
|
|
2082
2144
|
function escProp(s) {
|
|
2083
2145
|
return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
@@ -2096,7 +2158,7 @@ function annotations(findings) {
|
|
|
2096
2158
|
});
|
|
2097
2159
|
}
|
|
2098
2160
|
function cell(s) {
|
|
2099
|
-
return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
2161
|
+
return stripControl(s).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
2100
2162
|
}
|
|
2101
2163
|
function mdCode(s) {
|
|
2102
2164
|
return s.replace(/`/g, "'").replace(/[\r\n]/g, " ");
|
|
@@ -2204,8 +2266,13 @@ function report(input) {
|
|
|
2204
2266
|
|
|
2205
2267
|
// src/cli/check.ts
|
|
2206
2268
|
function check(opts) {
|
|
2207
|
-
let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
|
|
2208
2269
|
const cwd = opts.cwd ?? process.cwd();
|
|
2270
|
+
if (!isGitRepo(cwd)) {
|
|
2271
|
+
process.stderr.write(`tamperward: ${cwd} is not inside a git repository \u2014 nothing to check
|
|
2272
|
+
`);
|
|
2273
|
+
return 2;
|
|
2274
|
+
}
|
|
2275
|
+
let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
|
|
2209
2276
|
let changes;
|
|
2210
2277
|
let layer;
|
|
2211
2278
|
let view;
|
|
@@ -2218,13 +2285,20 @@ function check(opts) {
|
|
|
2218
2285
|
layer = opts.ciLayer ? "ci" : "local";
|
|
2219
2286
|
view = "worktree";
|
|
2220
2287
|
} else if (opts.diff) {
|
|
2221
|
-
const
|
|
2222
|
-
|
|
2288
|
+
const m = opts.diff.match(/^(.+?)\.{2,3}(.+)$/);
|
|
2289
|
+
const [base, head] = m ? [m[1], m[2]] : ["", ""];
|
|
2290
|
+
if (!base || !head || base.endsWith(".") || head.startsWith(".")) {
|
|
2223
2291
|
process.stderr.write(`tamperward: invalid --diff range "${opts.diff}" (expected <base>...<head>)
|
|
2224
2292
|
`);
|
|
2225
2293
|
return 2;
|
|
2226
2294
|
}
|
|
2227
2295
|
changes = diffRange(base, head, { cwd: opts.cwd });
|
|
2296
|
+
if (changes.length === 0) {
|
|
2297
|
+
process.stderr.write(
|
|
2298
|
+
`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
|
|
2299
|
+
`
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2228
2302
|
layer = "ci";
|
|
2229
2303
|
view = "range";
|
|
2230
2304
|
policy = opts.policyOverride ?? loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
|
|
@@ -2265,7 +2339,7 @@ import { createHash as createHash4 } from "node:crypto";
|
|
|
2265
2339
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2266
2340
|
import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
2267
2341
|
import { tmpdir } from "node:os";
|
|
2268
|
-
import { isAbsolute, join as join4, relative, resolve } from "node:path";
|
|
2342
|
+
import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
|
|
2269
2343
|
function asStr(v) {
|
|
2270
2344
|
return typeof v === "string" ? v : "";
|
|
2271
2345
|
}
|
|
@@ -2281,7 +2355,7 @@ function abs(path, cwd) {
|
|
|
2281
2355
|
}
|
|
2282
2356
|
function relForDisplay(path, cwd) {
|
|
2283
2357
|
const rel = relative(cwd, path);
|
|
2284
|
-
return rel && !rel.startsWith("..") && !
|
|
2358
|
+
return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
|
|
2285
2359
|
}
|
|
2286
2360
|
function applyEdit(content, oldStr, newStr) {
|
|
2287
2361
|
if (content === null) return newStr;
|
|
@@ -2302,7 +2376,7 @@ function synthFileChange(displayPath, before, after) {
|
|
|
2302
2376
|
writeFileSync(a, before ?? "");
|
|
2303
2377
|
writeFileSync(b, after ?? "");
|
|
2304
2378
|
try {
|
|
2305
|
-
raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], {
|
|
2379
|
+
raw = execFileSync2("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
|
|
2306
2380
|
encoding: "utf8",
|
|
2307
2381
|
maxBuffer: 256 * 1024 * 1024
|
|
2308
2382
|
});
|
|
@@ -2425,7 +2499,7 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2425
2499
|
// src/effect.ts
|
|
2426
2500
|
import { createHash as createHash2 } from "node:crypto";
|
|
2427
2501
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2428
|
-
import { isAbsolute as
|
|
2502
|
+
import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
|
|
2429
2503
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2430
2504
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2431
2505
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -2437,7 +2511,7 @@ function gitDir2(cwd) {
|
|
|
2437
2511
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2438
2512
|
}).trim();
|
|
2439
2513
|
if (!d) return null;
|
|
2440
|
-
return
|
|
2514
|
+
return isAbsolute3(d) ? d : join6(cwd, d);
|
|
2441
2515
|
} catch {
|
|
2442
2516
|
return null;
|
|
2443
2517
|
}
|
|
@@ -2954,9 +3028,18 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2954
3028
|
// src/cli/init.ts
|
|
2955
3029
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
2956
3030
|
import { createHash as createHash5 } from "node:crypto";
|
|
2957
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2958
|
-
import { dirname as dirname3, join as join8 } from "node:path";
|
|
3031
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
3032
|
+
import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join8, relative as relative2, resolve as resolve2 } from "node:path";
|
|
2959
3033
|
import { fileURLToPath } from "node:url";
|
|
3034
|
+
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
3035
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3036
|
+
function gitConfig(cwd, key2) {
|
|
3037
|
+
try {
|
|
3038
|
+
return execFileSync4("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
3039
|
+
} catch {
|
|
3040
|
+
return null;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
2960
3043
|
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
2961
3044
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2962
3045
|
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
@@ -2969,25 +3052,28 @@ function coveredBy(existing, critical) {
|
|
|
2969
3052
|
for (const line of existing.split("\n")) {
|
|
2970
3053
|
const t = line.trim();
|
|
2971
3054
|
if (!t || t.startsWith("#")) continue;
|
|
2972
|
-
const
|
|
2973
|
-
if (!t.slice(
|
|
2974
|
-
if (
|
|
2975
|
-
|
|
3055
|
+
const raw = t.split(/\s+/)[0];
|
|
3056
|
+
if (!t.slice(raw.length).trim()) continue;
|
|
3057
|
+
if (raw === "*" || raw === "**") return true;
|
|
3058
|
+
const pattern = raw.replace(/\/\*{1,2}$/, "/");
|
|
3059
|
+
if (pattern.includes("*")) continue;
|
|
3060
|
+
const anchored = pattern.startsWith("/") ? pattern : "/" + pattern;
|
|
3061
|
+
if (anchored === critical) return true;
|
|
3062
|
+
if (anchored.endsWith("/") && critical.startsWith(anchored)) return true;
|
|
3063
|
+
if (!pattern.startsWith("/")) {
|
|
3064
|
+
if (critical.endsWith("/" + pattern)) return true;
|
|
3065
|
+
if (pattern.endsWith("/") && critical.includes("/" + pattern)) return true;
|
|
3066
|
+
}
|
|
2976
3067
|
}
|
|
2977
3068
|
return false;
|
|
2978
3069
|
}
|
|
2979
3070
|
function inferOwner(cwd) {
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
const m = url.match(/github\.com[:/]+([^/]+)\//);
|
|
2987
|
-
return m ? `@${m[1]}` : null;
|
|
2988
|
-
} catch {
|
|
2989
|
-
return null;
|
|
2990
|
-
}
|
|
3071
|
+
const url = gitConfig(cwd, "remote.origin.url");
|
|
3072
|
+
const m = url?.match(/github\.com[:/]+([^/]+)\//);
|
|
3073
|
+
if (!m) return null;
|
|
3074
|
+
const segment = m[1];
|
|
3075
|
+
const self = [gitConfig(cwd, "github.user"), gitConfig(cwd, "user.name")].filter((v) => v !== null);
|
|
3076
|
+
return { owner: `@${segment}`, confirmed: self.some((v) => v.toLowerCase() === segment.toLowerCase()) };
|
|
2991
3077
|
}
|
|
2992
3078
|
var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
|
|
2993
3079
|
# without this file \u2014 everything here is an override, so an empty file changes nothing.
|
|
@@ -3033,7 +3119,7 @@ var WORKFLOW_CONTENT = `name: tamperward
|
|
|
3033
3119
|
|
|
3034
3120
|
# The CI authority for main: the same engine as the agent hook and pre-commit, run over
|
|
3035
3121
|
# the PR's commit range. A block fails the check and clears ONLY via the out-of-band
|
|
3036
|
-
# label \`tamperward:allow:<rule>@<head-sha>\` applied by someone with
|
|
3122
|
+
# label \`tamperward:allow:<rule>@<head-sha>\` applied by someone with triage access or higher \u2014
|
|
3037
3123
|
# never a file the PR itself can commit. The verify step reads the same labels:
|
|
3038
3124
|
# \`tamperward:allow:verify@<head-sha>\` accepts a masked failure a reviewer has judged.
|
|
3039
3125
|
#
|
|
@@ -3125,9 +3211,31 @@ function missingTools(matcher2) {
|
|
|
3125
3211
|
if (have === null) return [];
|
|
3126
3212
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
3127
3213
|
}
|
|
3214
|
+
function hooksShapeError(hooks) {
|
|
3215
|
+
if (hooks === void 0 || hooks === null) return null;
|
|
3216
|
+
if (!isPlainObject(hooks)) return "hooks is not an object";
|
|
3217
|
+
for (const [event, arr] of Object.entries(hooks)) {
|
|
3218
|
+
if (arr === void 0 || arr === null) continue;
|
|
3219
|
+
if (!Array.isArray(arr)) return `hooks.${event} is not a list`;
|
|
3220
|
+
for (const m of arr) {
|
|
3221
|
+
if (!isPlainObject(m)) return `hooks.${event} contains an entry that is not an object`;
|
|
3222
|
+
if (m.hooks !== void 0 && m.hooks !== null && !(Array.isArray(m.hooks) && m.hooks.every(isPlainObject))) {
|
|
3223
|
+
return `hooks.${event} has a "hooks" value that is not a list of { type, command }`;
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
return null;
|
|
3228
|
+
}
|
|
3128
3229
|
function planPolicy(cwd) {
|
|
3129
3230
|
const path = join8(cwd, POLICY_FILE);
|
|
3130
|
-
if (existsSync7(path))
|
|
3231
|
+
if (existsSync7(path)) {
|
|
3232
|
+
try {
|
|
3233
|
+
loadPolicy(cwd);
|
|
3234
|
+
} catch (e) {
|
|
3235
|
+
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)` };
|
|
3236
|
+
}
|
|
3237
|
+
return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present and loads \u2014 left untouched" };
|
|
3238
|
+
}
|
|
3131
3239
|
return {
|
|
3132
3240
|
item: "policy",
|
|
3133
3241
|
path: POLICY_FILE,
|
|
@@ -3157,6 +3265,10 @@ function planClaudeHooks(cwd) {
|
|
|
3157
3265
|
}
|
|
3158
3266
|
settings = parsed;
|
|
3159
3267
|
}
|
|
3268
|
+
const shapeError = hooksShapeError(settings.hooks);
|
|
3269
|
+
if (shapeError) {
|
|
3270
|
+
return { item: "agent", path: rel, status: "error", detail: `${shapeError} \u2014 fix it, then re-run init (refusing to overwrite)` };
|
|
3271
|
+
}
|
|
3160
3272
|
const hooks = settings.hooks ??= {};
|
|
3161
3273
|
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
|
|
3162
3274
|
const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
|
|
@@ -3202,47 +3314,170 @@ function planClaudeHooks(cwd) {
|
|
|
3202
3314
|
}
|
|
3203
3315
|
};
|
|
3204
3316
|
}
|
|
3317
|
+
var MANAGED_HOOKS = [
|
|
3318
|
+
{
|
|
3319
|
+
name: "lefthook",
|
|
3320
|
+
configs: ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml", "lefthook-local.yml", "lefthook-local.yaml"],
|
|
3321
|
+
hookRe: /\blefthook\b/i,
|
|
3322
|
+
hint: `add this under \`pre-commit:\` \u2192 \`commands:\` in lefthook.yml:
|
|
3323
|
+
tamperward:
|
|
3324
|
+
run: ${PRECOMMIT_CMD}`
|
|
3325
|
+
},
|
|
3326
|
+
{
|
|
3327
|
+
name: "pre-commit",
|
|
3328
|
+
configs: [".pre-commit-config.yaml", ".pre-commit-config.yml"],
|
|
3329
|
+
hookRe: /pre-commit\.com|File generated by pre-commit/i,
|
|
3330
|
+
hint: `add this under \`repos:\` in .pre-commit-config.yaml:
|
|
3331
|
+
- repo: local
|
|
3332
|
+
hooks:
|
|
3333
|
+
- id: tamperward
|
|
3334
|
+
name: tamperward
|
|
3335
|
+
entry: ${PRECOMMIT_CMD}
|
|
3336
|
+
language: system
|
|
3337
|
+
pass_filenames: false
|
|
3338
|
+
always_run: true`
|
|
3339
|
+
}
|
|
3340
|
+
];
|
|
3341
|
+
function hooksDir(cwd) {
|
|
3342
|
+
try {
|
|
3343
|
+
const out2 = execFileSync4("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
3344
|
+
if (out2) return resolve2(cwd, out2);
|
|
3345
|
+
} catch {
|
|
3346
|
+
}
|
|
3347
|
+
return existsSync7(join8(cwd, ".git")) ? join8(cwd, ".git", "hooks") : null;
|
|
3348
|
+
}
|
|
3349
|
+
function display(cwd, path) {
|
|
3350
|
+
const rel = relative2(cwd, path);
|
|
3351
|
+
return rel && !rel.startsWith("..") && !isAbsolute4(rel) ? rel : path;
|
|
3352
|
+
}
|
|
3353
|
+
function unconditionalExitAt(lines) {
|
|
3354
|
+
let depth = 0;
|
|
3355
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3356
|
+
const t = lines[i].trim();
|
|
3357
|
+
if (!t || t.startsWith("#")) continue;
|
|
3358
|
+
if (depth === 0) {
|
|
3359
|
+
if (/^exit(\s|$|;)/.test(t)) return i;
|
|
3360
|
+
const exec = t.match(/^exec(?:\s+(.*))?$/);
|
|
3361
|
+
if (exec && exec[1] && !/^(\d*[<>]|&>)/.test(exec[1])) return i;
|
|
3362
|
+
}
|
|
3363
|
+
const opens = (t.match(/(?:^|[;(]\s*|&&\s*|\|\|\s*)(?:if|case|for|while|until)\s/g) ?? []).length + (/(?:^|\s)\{\s*$/.test(t) ? 1 : 0);
|
|
3364
|
+
const closes = (t.match(/(?:^|[;]\s*)(?:fi|esac|done)(?:\s|;|$)/g) ?? []).length + (/^\}/.test(t) ? 1 : 0);
|
|
3365
|
+
depth = Math.max(0, depth + opens - closes);
|
|
3366
|
+
}
|
|
3367
|
+
return -1;
|
|
3368
|
+
}
|
|
3205
3369
|
function planPreCommit(cwd) {
|
|
3206
|
-
const
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3370
|
+
const block = [MARKER, PRECOMMIT_CMD];
|
|
3371
|
+
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
3372
|
+
let path;
|
|
3373
|
+
let note;
|
|
3374
|
+
if (existsSync7(join8(cwd, ".husky"))) {
|
|
3375
|
+
path = join8(cwd, ".husky", "pre-commit");
|
|
3376
|
+
note = "husky";
|
|
3377
|
+
} else {
|
|
3378
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join8(cwd, c))));
|
|
3379
|
+
if (managed) {
|
|
3380
|
+
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}`);
|
|
3381
|
+
}
|
|
3382
|
+
const dir = hooksDir(cwd);
|
|
3383
|
+
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
3384
|
+
if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
|
|
3385
|
+
path = join8(dirname3(dir), "pre-commit");
|
|
3386
|
+
note = "husky";
|
|
3387
|
+
} else {
|
|
3388
|
+
path = join8(dir, "pre-commit");
|
|
3389
|
+
const rel2 = display(cwd, dir);
|
|
3390
|
+
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)`;
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
const rel = display(cwd, path);
|
|
3394
|
+
if (existsSync7(path) && !statSync5(path).isFile()) {
|
|
3395
|
+
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
3396
|
+
}
|
|
3214
3397
|
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
3215
|
-
if (existing
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3398
|
+
if (existing === null) {
|
|
3399
|
+
return {
|
|
3400
|
+
item: "pre-commit",
|
|
3401
|
+
path: rel,
|
|
3402
|
+
status: "create",
|
|
3403
|
+
detail: `create via ${note}`,
|
|
3404
|
+
apply: () => {
|
|
3405
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
3406
|
+
writeFileSync5(path, `#!/bin/sh
|
|
3407
|
+
${block.join("\n")}
|
|
3408
|
+
`);
|
|
3409
|
+
chmodSync(path, 493);
|
|
3410
|
+
}
|
|
3411
|
+
};
|
|
3412
|
+
}
|
|
3413
|
+
const owner = MANAGED_HOOKS.find((m) => m.hookRe.test(existing));
|
|
3414
|
+
if (owner) {
|
|
3415
|
+
return { item: "pre-commit", path: rel, status: "skip", detail: `generated by ${owner.name}, which overwrites it on install \u2014 ${owner.hint}` };
|
|
3416
|
+
}
|
|
3417
|
+
const lines = existing.split("\n");
|
|
3418
|
+
const dead = unconditionalExitAt(lines);
|
|
3419
|
+
const at = lines.findIndex((l) => PRECOMMIT_RE.test(l));
|
|
3420
|
+
const write = (out2) => {
|
|
3421
|
+
writeFileSync5(path, out2.join("\n"));
|
|
3422
|
+
chmodSync(path, 493);
|
|
3423
|
+
};
|
|
3424
|
+
if (at !== -1 && (dead === -1 || at < dead)) {
|
|
3425
|
+
if (!stalePin(lines[at])) {
|
|
3426
|
+
return { item: "pre-commit", path: rel, status: "ok", detail: "already runs the staged check" };
|
|
3220
3427
|
}
|
|
3221
|
-
const was = pinOf(lines[
|
|
3428
|
+
const was = pinOf(lines[at]) || "unpinned";
|
|
3222
3429
|
return {
|
|
3223
3430
|
item: "pre-commit",
|
|
3224
|
-
path:
|
|
3431
|
+
path: rel,
|
|
3225
3432
|
status: "update",
|
|
3226
3433
|
detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
|
|
3227
3434
|
apply: () => {
|
|
3228
|
-
lines[
|
|
3229
|
-
|
|
3230
|
-
|
|
3435
|
+
lines[at] = PRECOMMIT_CMD;
|
|
3436
|
+
write(lines);
|
|
3437
|
+
}
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
if (at !== -1) {
|
|
3441
|
+
const ours = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
|
|
3442
|
+
if (!ours) {
|
|
3443
|
+
return {
|
|
3444
|
+
item: "pre-commit",
|
|
3445
|
+
path: rel,
|
|
3446
|
+
status: "error",
|
|
3447
|
+
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`
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
return {
|
|
3451
|
+
item: "pre-commit",
|
|
3452
|
+
path: rel,
|
|
3453
|
+
status: "update",
|
|
3454
|
+
detail: `move the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ended the script before it ran`,
|
|
3455
|
+
apply: () => {
|
|
3456
|
+
const out2 = lines.filter((_, i) => i !== at && i !== at - 1);
|
|
3457
|
+
out2.splice(dead, 0, ...block);
|
|
3458
|
+
write(out2);
|
|
3459
|
+
}
|
|
3460
|
+
};
|
|
3461
|
+
}
|
|
3462
|
+
if (dead !== -1) {
|
|
3463
|
+
return {
|
|
3464
|
+
item: "pre-commit",
|
|
3465
|
+
path: rel,
|
|
3466
|
+
status: "update",
|
|
3467
|
+
detail: `insert the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ends the script \u2014 ${note}`,
|
|
3468
|
+
apply: () => {
|
|
3469
|
+
const out2 = [...lines];
|
|
3470
|
+
out2.splice(dead, 0, ...block);
|
|
3471
|
+
write(out2);
|
|
3231
3472
|
}
|
|
3232
3473
|
};
|
|
3233
3474
|
}
|
|
3234
3475
|
return {
|
|
3235
3476
|
item: "pre-commit",
|
|
3236
|
-
path:
|
|
3237
|
-
status:
|
|
3238
|
-
detail:
|
|
3239
|
-
apply: () =>
|
|
3240
|
-
mkdirSync5(dirname3(path), { recursive: true });
|
|
3241
|
-
const content = existing === null ? `#!/bin/sh
|
|
3242
|
-
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
3243
|
-
writeFileSync5(path, content);
|
|
3244
|
-
chmodSync(path, 493);
|
|
3245
|
-
}
|
|
3477
|
+
path: rel,
|
|
3478
|
+
status: "update",
|
|
3479
|
+
detail: `append the staged check (${note})`,
|
|
3480
|
+
apply: () => write([...existing.replace(/\n?$/, "\n").split("\n").slice(0, -1), ...block, ""])
|
|
3246
3481
|
};
|
|
3247
3482
|
}
|
|
3248
3483
|
function planCodeowners(cwd) {
|
|
@@ -3256,18 +3491,26 @@ function planCodeowners(cwd) {
|
|
|
3256
3491
|
if (missing.length === 0) {
|
|
3257
3492
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
3258
3493
|
}
|
|
3259
|
-
const
|
|
3494
|
+
const inferred = inferOwner(cwd);
|
|
3495
|
+
const owner = inferred?.owner ?? null;
|
|
3496
|
+
const unconfirmed = inferred !== null && !inferred.confirmed;
|
|
3260
3497
|
const block = `
|
|
3261
3498
|
${CODEOWNERS_MARK}
|
|
3262
3499
|
# A pull request that rewrites the workflow can keep the job name, replace the
|
|
3263
3500
|
# gate with \`true\`, and still report a green required check. Only a human
|
|
3264
3501
|
# requirement on these paths prevents that.
|
|
3265
|
-
` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" :
|
|
3502
|
+
` + (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
|
|
3503
|
+
# rejects a bare organisation as an owner, and a rule with an invalid owner protects
|
|
3504
|
+
# nothing. A user login is fine as written.
|
|
3505
|
+
` : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
|
|
3266
3506
|
return {
|
|
3267
3507
|
item: "codeowners",
|
|
3268
3508
|
path: existingRel ?? rel,
|
|
3269
3509
|
status: existing === null ? "create" : "update",
|
|
3270
|
-
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})`),
|
|
3510
|
+
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" : ""})`),
|
|
3511
|
+
...inferred && unconfirmed ? {
|
|
3512
|
+
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.`
|
|
3513
|
+
} : {},
|
|
3271
3514
|
apply: () => {
|
|
3272
3515
|
mkdirSync5(dirname3(path), { recursive: true });
|
|
3273
3516
|
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
@@ -3326,21 +3569,49 @@ function planWorkflow(cwd, force) {
|
|
|
3326
3569
|
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.`
|
|
3327
3570
|
};
|
|
3328
3571
|
}
|
|
3572
|
+
function planned(item, path, plan) {
|
|
3573
|
+
try {
|
|
3574
|
+
return plan();
|
|
3575
|
+
} catch (e) {
|
|
3576
|
+
return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText2(e)}` };
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3329
3579
|
function planInit(cwd, opts = {}) {
|
|
3330
|
-
return [
|
|
3580
|
+
return [
|
|
3581
|
+
planned("policy", POLICY_FILE, () => planPolicy(cwd)),
|
|
3582
|
+
planned("agent", ".claude/settings.json", () => planClaudeHooks(cwd)),
|
|
3583
|
+
planned("pre-commit", "(hooks)", () => planPreCommit(cwd)),
|
|
3584
|
+
planned("ci", ".github/workflows/tamperward.yml", () => planWorkflow(cwd, opts.forceWorkflow ?? false)),
|
|
3585
|
+
planned("codeowners", ".github/CODEOWNERS", () => planCodeowners(cwd))
|
|
3586
|
+
];
|
|
3331
3587
|
}
|
|
3332
3588
|
function runInit(opts) {
|
|
3333
3589
|
const cwd = opts.cwd ?? process.cwd();
|
|
3334
3590
|
const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
|
|
3335
3591
|
const w = process.stdout;
|
|
3592
|
+
let applied = 0;
|
|
3336
3593
|
for (const a of plan) {
|
|
3594
|
+
if (!opts.dryRun && a.apply) {
|
|
3595
|
+
try {
|
|
3596
|
+
a.apply();
|
|
3597
|
+
applied++;
|
|
3598
|
+
} catch (e) {
|
|
3599
|
+
a.status = "error";
|
|
3600
|
+
a.detail = `could not be written \u2014 ${errText2(e)}`;
|
|
3601
|
+
a.apply = void 0;
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3337
3604
|
const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
|
|
3338
3605
|
w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
|
|
3339
3606
|
`);
|
|
3340
|
-
|
|
3607
|
+
}
|
|
3608
|
+
for (const a of plan) {
|
|
3609
|
+
if (a.warning) w.write(`
|
|
3610
|
+
WARNING (${a.item}): ${a.warning}
|
|
3611
|
+
`);
|
|
3341
3612
|
}
|
|
3342
3613
|
const errors = plan.filter((a) => a.status === "error");
|
|
3343
|
-
const changed = plan.filter((a) => a.apply).length;
|
|
3614
|
+
const changed = opts.dryRun ? plan.filter((a) => a.apply).length : applied;
|
|
3344
3615
|
if (errors.length) {
|
|
3345
3616
|
w.write(`
|
|
3346
3617
|
tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
|
|
@@ -3363,15 +3634,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
3363
3634
|
// src/cli/verify.ts
|
|
3364
3635
|
import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
|
|
3365
3636
|
import { createHash as createHash8 } from "node:crypto";
|
|
3366
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as
|
|
3637
|
+
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";
|
|
3367
3638
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3368
|
-
import { dirname as dirname4, join as join11, resolve as
|
|
3639
|
+
import { dirname as dirname4, join as join11, resolve as resolve3 } from "node:path";
|
|
3369
3640
|
|
|
3370
3641
|
// src/git/trusted.ts
|
|
3371
3642
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3372
3643
|
import { createHash as createHash6 } from "node:crypto";
|
|
3373
3644
|
import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
|
|
3374
|
-
import { isAbsolute as
|
|
3645
|
+
import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
|
|
3375
3646
|
function trustedGitEnv() {
|
|
3376
3647
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3377
3648
|
}
|
|
@@ -3384,7 +3655,7 @@ function gitDirOf(cwd) {
|
|
|
3384
3655
|
env: trustedGitEnv()
|
|
3385
3656
|
}).trim();
|
|
3386
3657
|
if (!d) return null;
|
|
3387
|
-
return
|
|
3658
|
+
return isAbsolute5(d) ? d : join9(cwd, d);
|
|
3388
3659
|
} catch {
|
|
3389
3660
|
return null;
|
|
3390
3661
|
}
|
|
@@ -3422,7 +3693,7 @@ function objectRewriteState(cwd) {
|
|
|
3422
3693
|
// src/fingerprint.ts
|
|
3423
3694
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
3424
3695
|
import { createHash as createHash7 } from "node:crypto";
|
|
3425
|
-
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as
|
|
3696
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
3426
3697
|
import { join as join10 } from "node:path";
|
|
3427
3698
|
function treeFingerprint(cwd) {
|
|
3428
3699
|
const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
@@ -3461,7 +3732,7 @@ function depsFingerprint(cwd) {
|
|
|
3461
3732
|
h.update(p);
|
|
3462
3733
|
try {
|
|
3463
3734
|
h.update(readFileSync12(p));
|
|
3464
|
-
h.update(String(
|
|
3735
|
+
h.update(String(statSync6(p).mode));
|
|
3465
3736
|
} catch {
|
|
3466
3737
|
h.update("<unreadable>");
|
|
3467
3738
|
}
|
|
@@ -3476,7 +3747,7 @@ function depsFingerprint(cwd) {
|
|
|
3476
3747
|
}
|
|
3477
3748
|
};
|
|
3478
3749
|
try {
|
|
3479
|
-
if (!
|
|
3750
|
+
if (!statSync6(root).isDirectory()) return "none";
|
|
3480
3751
|
} catch {
|
|
3481
3752
|
return "none";
|
|
3482
3753
|
}
|
|
@@ -3534,7 +3805,7 @@ function materialize(cwd, dest) {
|
|
|
3534
3805
|
const src = join11(cwd, rel);
|
|
3535
3806
|
let st;
|
|
3536
3807
|
try {
|
|
3537
|
-
st =
|
|
3808
|
+
st = statSync7(src);
|
|
3538
3809
|
} catch {
|
|
3539
3810
|
continue;
|
|
3540
3811
|
}
|
|
@@ -3549,7 +3820,7 @@ function materialize(cwd, dest) {
|
|
|
3549
3820
|
}
|
|
3550
3821
|
chmodSync2(out2, st.mode);
|
|
3551
3822
|
}
|
|
3552
|
-
const nm =
|
|
3823
|
+
const nm = resolve3(cwd, "node_modules");
|
|
3553
3824
|
if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
|
|
3554
3825
|
}
|
|
3555
3826
|
function dropSymlink(p) {
|
|
@@ -3565,9 +3836,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
3565
3836
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
3566
3837
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
3567
3838
|
}
|
|
3568
|
-
const
|
|
3569
|
-
if (
|
|
3570
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
3839
|
+
const globs2 = policy.verify?.inputs;
|
|
3840
|
+
if (globs2 && globs2.length) {
|
|
3841
|
+
for (const rel of atBase) if (matchesAny(rel, globs2)) picked.add(rel);
|
|
3571
3842
|
}
|
|
3572
3843
|
return picked;
|
|
3573
3844
|
}
|
|
@@ -3839,7 +4110,7 @@ function parseVerify(args) {
|
|
|
3839
4110
|
// src/cli/run.ts
|
|
3840
4111
|
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
3841
4112
|
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
|
|
3842
|
-
import { resolve as
|
|
4113
|
+
import { resolve as resolve4 } from "node:path";
|
|
3843
4114
|
var out = (s) => process.stdout.write(s + "\n");
|
|
3844
4115
|
var err = (s) => process.stderr.write(s + "\n");
|
|
3845
4116
|
function git3(args, cwd) {
|
|
@@ -3886,7 +4157,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3886
4157
|
return out2;
|
|
3887
4158
|
}
|
|
3888
4159
|
function runEnvelope(opts) {
|
|
3889
|
-
const cwd =
|
|
4160
|
+
const cwd = resolve4(opts.cwd ?? process.cwd());
|
|
3890
4161
|
if (!opts.argv.length) {
|
|
3891
4162
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
3892
4163
|
return 2;
|
|
@@ -4023,7 +4294,7 @@ function parseRun(args) {
|
|
|
4023
4294
|
return o;
|
|
4024
4295
|
}
|
|
4025
4296
|
|
|
4026
|
-
// src/cli/
|
|
4297
|
+
// src/cli/main.ts
|
|
4027
4298
|
function parseAllow(args) {
|
|
4028
4299
|
const o = {};
|
|
4029
4300
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -4101,7 +4372,7 @@ Formats:
|
|
|
4101
4372
|
observe supported transient effects
|
|
4102
4373
|
tamperward verify [--base R] [--cmd C] pristine-suite re-execution: run the suite
|
|
4103
4374
|
[--budget S] [--json] [--keep] as-is AND with protected files restored
|
|
4104
|
-
|
|
4375
|
+
[--require-ancestor] [--cwd D] from the trusted base; a visible-green /
|
|
4105
4376
|
pristine-red result is a MASKED FAILURE
|
|
4106
4377
|
(exit 1, or 0 under an out-of-band
|
|
4107
4378
|
verify@<head-sha> approval); cannot-verify
|
|
@@ -4109,14 +4380,16 @@ Formats:
|
|
|
4109
4380
|
tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
|
|
4110
4381
|
[--base R] [--cmd C] base, run the agent, treat its exit as
|
|
4111
4382
|
[--budget S] [--allow-dirty] untrusted, then re-adjudicate the tree it
|
|
4112
|
-
|
|
4383
|
+
[--settle S] [--allow-dep-drift] left (policy over base...HEAD and the
|
|
4384
|
+
[--cwd D]
|
|
4113
4385
|
worktree, plus verify). Exit: agent's code
|
|
4114
4386
|
when clean; 1 on any blocking finding or
|
|
4115
4387
|
masked failure; 2 when it cannot
|
|
4116
4388
|
adjudicate (fails closed)
|
|
4117
4389
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
4390
|
+
[--file F] [--cwd D]
|
|
4118
4391
|
tamperward init [--dry-run] wire the policy file plus every
|
|
4119
|
-
[--force-workflow]
|
|
4392
|
+
[--force-workflow] [--cwd D] enforcement point: Claude Code hooks,
|
|
4120
4393
|
pre-commit, CI. Idempotent; never
|
|
4121
4394
|
overwrites your files. Re-running it
|
|
4122
4395
|
MIGRATES a CI workflow it generated and
|
|
@@ -4126,8 +4399,14 @@ Formats:
|
|
|
4126
4399
|
--force-workflow replaces a workflow it
|
|
4127
4400
|
did not write, or one you have edited.
|
|
4128
4401
|
|
|
4129
|
-
Exit
|
|
4130
|
-
|
|
4402
|
+
Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
|
|
4403
|
+
(verify), any blocking finding or masked failure (run) \xB7 2 cannot
|
|
4404
|
+
evaluate, failing closed: policy error, bad --diff range, no view,
|
|
4405
|
+
not a git repository, unresolvable base or revision, --require-ancestor
|
|
4406
|
+
refused, budget exceeded \u2014 always one line on stderr, never a stack
|
|
4407
|
+
trace. hook/sweep \u2192 0 with a deny emitted as JSON on stdout (exit 2
|
|
4408
|
+
would make Claude Code ignore the JSON); 2 only for an unsupported
|
|
4409
|
+
agent name.
|
|
4131
4410
|
`);
|
|
4132
4411
|
}
|
|
4133
4412
|
function main(argv2) {
|
|
@@ -4159,9 +4438,21 @@ function main(argv2) {
|
|
|
4159
4438
|
return 2;
|
|
4160
4439
|
}
|
|
4161
4440
|
}
|
|
4441
|
+
function guardedMain(argv2, stderr = process.stderr) {
|
|
4442
|
+
try {
|
|
4443
|
+
return main(argv2);
|
|
4444
|
+
} catch (e) {
|
|
4445
|
+
const first = (e instanceof Error ? e.message : String(e)).split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
|
4446
|
+
stderr.write(`tamperward: ${first || "unexpected failure"}
|
|
4447
|
+
`);
|
|
4448
|
+
return 2;
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
|
|
4452
|
+
// src/cli/index.ts
|
|
4162
4453
|
var argv = process.argv.slice(2);
|
|
4163
4454
|
if (argv[0] === "watch") {
|
|
4164
4455
|
runWatch(argv.slice(1));
|
|
4165
4456
|
} else {
|
|
4166
|
-
process.exit(
|
|
4457
|
+
process.exit(guardedMain(argv));
|
|
4167
4458
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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",
|