tamperward 2.1.1 → 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 +1 -1
- package/dist/cli/index.js +369 -102
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -283,7 +283,7 @@ commands ignore what they do not know.
|
|
|
283
283
|
|
|
284
284
|
| command | 0 | 1 | 2 |
|
|
285
285
|
| --- | --- | --- | --- |
|
|
286
|
-
| `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 |
|
|
287
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 |
|
|
288
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 |
|
|
289
289
|
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
|
package/dist/cli/index.js
CHANGED
|
@@ -443,6 +443,16 @@ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
|
443
443
|
}
|
|
444
444
|
return out2;
|
|
445
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
|
+
}
|
|
446
456
|
var cache = /* @__PURE__ */ new Map();
|
|
447
457
|
function matcher(glob) {
|
|
448
458
|
let m = cache.get(glob);
|
|
@@ -452,12 +462,12 @@ function matcher(glob) {
|
|
|
452
462
|
}
|
|
453
463
|
return m;
|
|
454
464
|
}
|
|
455
|
-
function matchesAny(path,
|
|
456
|
-
return !!
|
|
465
|
+
function matchesAny(path, globs2) {
|
|
466
|
+
return !!globs2 && globs2.some((g) => matcher(g)(path));
|
|
457
467
|
}
|
|
458
468
|
function protectedCategory(path, policy) {
|
|
459
|
-
for (const [cat,
|
|
460
|
-
if (matchesAny(path,
|
|
469
|
+
for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
|
|
470
|
+
if (matchesAny(path, globs2)) return cat;
|
|
461
471
|
}
|
|
462
472
|
return null;
|
|
463
473
|
}
|
|
@@ -473,9 +483,9 @@ function isIgnored(path, policy) {
|
|
|
473
483
|
}
|
|
474
484
|
function mergeProtected(base, user) {
|
|
475
485
|
const out2 = { ...base };
|
|
476
|
-
for (const [cat,
|
|
486
|
+
for (const [cat, globs2] of Object.entries(user ?? {})) {
|
|
477
487
|
const have = new Set(out2[cat] ?? []);
|
|
478
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
488
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
|
|
479
489
|
}
|
|
480
490
|
return out2;
|
|
481
491
|
}
|
|
@@ -1164,6 +1174,7 @@ var ciTampering = {
|
|
|
1164
1174
|
|
|
1165
1175
|
// src/detectors/policy-diff.ts
|
|
1166
1176
|
import { parse } from "yaml";
|
|
1177
|
+
var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
1167
1178
|
function safeParse(src) {
|
|
1168
1179
|
try {
|
|
1169
1180
|
const v = parse(src);
|
|
@@ -1177,12 +1188,19 @@ function effective(raw) {
|
|
|
1177
1188
|
const v = raw.version;
|
|
1178
1189
|
const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
|
|
1179
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;
|
|
1180
1197
|
return {
|
|
1181
1198
|
version,
|
|
1182
|
-
rules
|
|
1183
|
-
ignore: raw.ignore
|
|
1184
|
-
protected: mergeProtected(base.protected,
|
|
1185
|
-
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
|
|
1186
1204
|
};
|
|
1187
1205
|
}
|
|
1188
1206
|
function policyWeakening(before, after) {
|
|
@@ -1234,6 +1252,9 @@ function policyWeakening(before, after) {
|
|
|
1234
1252
|
if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
|
|
1235
1253
|
reasons.push("sign-off no longer required for blocking findings");
|
|
1236
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
|
+
}
|
|
1237
1258
|
const bv = b.verify;
|
|
1238
1259
|
const av = a.verify;
|
|
1239
1260
|
if (bv?.command) {
|
|
@@ -1789,7 +1810,7 @@ function hasBlocking(findings) {
|
|
|
1789
1810
|
|
|
1790
1811
|
// src/policy-load.ts
|
|
1791
1812
|
import { readFileSync as readFileSync2, existsSync } from "node:fs";
|
|
1792
|
-
import { join as join2 } from "node:path";
|
|
1813
|
+
import { isAbsolute, join as join2, posix } from "node:path";
|
|
1793
1814
|
import { parse as parse2 } from "yaml";
|
|
1794
1815
|
var PolicyError = class extends Error {
|
|
1795
1816
|
};
|
|
@@ -1804,11 +1825,21 @@ var SEVERITIES = ["block", "warn"];
|
|
|
1804
1825
|
var isSeverity = (v) => SEVERITIES.includes(v);
|
|
1805
1826
|
var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
1806
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
|
+
}
|
|
1807
1834
|
function validate(r, where) {
|
|
1808
1835
|
function bad(msg) {
|
|
1809
1836
|
throw new PolicyError(`${where}: ${msg}`);
|
|
1810
1837
|
}
|
|
1811
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
|
+
}
|
|
1812
1843
|
if (r.rules !== void 0) {
|
|
1813
1844
|
if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
|
|
1814
1845
|
for (const [name, cfg] of Object.entries(r.rules)) {
|
|
@@ -1827,8 +1858,8 @@ function validate(r, where) {
|
|
|
1827
1858
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
1828
1859
|
if (r.protected !== void 0) {
|
|
1829
1860
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
1830
|
-
for (const [cat,
|
|
1831
|
-
if (!isStringList(
|
|
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)}`);
|
|
1832
1863
|
}
|
|
1833
1864
|
}
|
|
1834
1865
|
if (r.signoff !== void 0) {
|
|
@@ -1841,6 +1872,9 @@ function validate(r, where) {
|
|
|
1841
1872
|
}
|
|
1842
1873
|
const ledger = r.signoff.ledger;
|
|
1843
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
|
+
}
|
|
1844
1878
|
}
|
|
1845
1879
|
if (r.verify !== void 0) {
|
|
1846
1880
|
if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
|
|
@@ -1857,14 +1891,20 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
1857
1891
|
const version = normalizeVersion(r.version, where);
|
|
1858
1892
|
validate(r, where);
|
|
1859
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;
|
|
1860
1899
|
return {
|
|
1861
1900
|
version,
|
|
1862
1901
|
// Merge with the baseline, never replace. For an integrity tool, a config that sets
|
|
1863
1902
|
// one rule's severity must NOT silently drop the other nine — nor may naming one
|
|
1864
|
-
// protected glob wipe out the rest of its category (see mergeProtected)
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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,
|
|
1868
1908
|
signoff: {
|
|
1869
1909
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1870
1910
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -1914,7 +1954,11 @@ function fingerprint(rule, file, evidence) {
|
|
|
1914
1954
|
}
|
|
1915
1955
|
var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
|
|
1916
1956
|
function ledgerPath(cwd, policy) {
|
|
1917
|
-
|
|
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);
|
|
1918
1962
|
}
|
|
1919
1963
|
function readLedger(cwd, policy) {
|
|
1920
1964
|
const p = ledgerPath(cwd, policy);
|
|
@@ -2008,7 +2052,7 @@ function paint(s, code, on) {
|
|
|
2008
2052
|
function wrap(text, width) {
|
|
2009
2053
|
const out2 = [];
|
|
2010
2054
|
let cur = "";
|
|
2011
|
-
for (const w of text.split(/\s+/).filter(Boolean)) {
|
|
2055
|
+
for (const w of stripControl(text).split(/\s+/).filter(Boolean)) {
|
|
2012
2056
|
if (cur === "") cur = w;
|
|
2013
2057
|
else if (cur.length + 1 + w.length <= width) cur += " " + w;
|
|
2014
2058
|
else {
|
|
@@ -2073,15 +2117,15 @@ function renderText(input, opts) {
|
|
|
2073
2117
|
for (const f of findings) {
|
|
2074
2118
|
const isBlock = f.severity === "block";
|
|
2075
2119
|
const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
|
|
2076
|
-
const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
2077
|
-
out2.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
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)}`);
|
|
2078
2122
|
for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
|
|
2079
2123
|
const row = (key2, lines) => {
|
|
2080
2124
|
lines.forEach((l, i) => out2.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
|
|
2081
2125
|
};
|
|
2082
2126
|
row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
|
|
2083
2127
|
row("instead", wrap(f.remediation, body));
|
|
2084
|
-
if (f.signoff.required) row("sign-off", [f.signoff.command]);
|
|
2128
|
+
if (f.signoff.required) row("sign-off", [stripControl(f.signoff.command)]);
|
|
2085
2129
|
out2.push("");
|
|
2086
2130
|
}
|
|
2087
2131
|
if (blocks.length > 0) {
|
|
@@ -2095,7 +2139,7 @@ function renderText(input, opts) {
|
|
|
2095
2139
|
// src/cli/render/github.ts
|
|
2096
2140
|
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
2097
2141
|
function escData(s) {
|
|
2098
|
-
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");
|
|
2099
2143
|
}
|
|
2100
2144
|
function escProp(s) {
|
|
2101
2145
|
return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
@@ -2114,7 +2158,7 @@ function annotations(findings) {
|
|
|
2114
2158
|
});
|
|
2115
2159
|
}
|
|
2116
2160
|
function cell(s) {
|
|
2117
|
-
return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
2161
|
+
return stripControl(s).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
2118
2162
|
}
|
|
2119
2163
|
function mdCode(s) {
|
|
2120
2164
|
return s.replace(/`/g, "'").replace(/[\r\n]/g, " ");
|
|
@@ -2222,8 +2266,13 @@ function report(input) {
|
|
|
2222
2266
|
|
|
2223
2267
|
// src/cli/check.ts
|
|
2224
2268
|
function check(opts) {
|
|
2225
|
-
let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
|
|
2226
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);
|
|
2227
2276
|
let changes;
|
|
2228
2277
|
let layer;
|
|
2229
2278
|
let view;
|
|
@@ -2236,13 +2285,20 @@ function check(opts) {
|
|
|
2236
2285
|
layer = opts.ciLayer ? "ci" : "local";
|
|
2237
2286
|
view = "worktree";
|
|
2238
2287
|
} else if (opts.diff) {
|
|
2239
|
-
const
|
|
2240
|
-
|
|
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(".")) {
|
|
2241
2291
|
process.stderr.write(`tamperward: invalid --diff range "${opts.diff}" (expected <base>...<head>)
|
|
2242
2292
|
`);
|
|
2243
2293
|
return 2;
|
|
2244
2294
|
}
|
|
2245
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
|
+
}
|
|
2246
2302
|
layer = "ci";
|
|
2247
2303
|
view = "range";
|
|
2248
2304
|
policy = opts.policyOverride ?? loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
|
|
@@ -2283,7 +2339,7 @@ import { createHash as createHash4 } from "node:crypto";
|
|
|
2283
2339
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2284
2340
|
import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
2285
2341
|
import { tmpdir } from "node:os";
|
|
2286
|
-
import { isAbsolute, join as join4, relative, resolve } from "node:path";
|
|
2342
|
+
import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
|
|
2287
2343
|
function asStr(v) {
|
|
2288
2344
|
return typeof v === "string" ? v : "";
|
|
2289
2345
|
}
|
|
@@ -2299,7 +2355,7 @@ function abs(path, cwd) {
|
|
|
2299
2355
|
}
|
|
2300
2356
|
function relForDisplay(path, cwd) {
|
|
2301
2357
|
const rel = relative(cwd, path);
|
|
2302
|
-
return rel && !rel.startsWith("..") && !
|
|
2358
|
+
return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
|
|
2303
2359
|
}
|
|
2304
2360
|
function applyEdit(content, oldStr, newStr) {
|
|
2305
2361
|
if (content === null) return newStr;
|
|
@@ -2443,7 +2499,7 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2443
2499
|
// src/effect.ts
|
|
2444
2500
|
import { createHash as createHash2 } from "node:crypto";
|
|
2445
2501
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2446
|
-
import { isAbsolute as
|
|
2502
|
+
import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
|
|
2447
2503
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2448
2504
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2449
2505
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -2455,7 +2511,7 @@ function gitDir2(cwd) {
|
|
|
2455
2511
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2456
2512
|
}).trim();
|
|
2457
2513
|
if (!d) return null;
|
|
2458
|
-
return
|
|
2514
|
+
return isAbsolute3(d) ? d : join6(cwd, d);
|
|
2459
2515
|
} catch {
|
|
2460
2516
|
return null;
|
|
2461
2517
|
}
|
|
@@ -2972,9 +3028,18 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2972
3028
|
// src/cli/init.ts
|
|
2973
3029
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
2974
3030
|
import { createHash as createHash5 } from "node:crypto";
|
|
2975
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2976
|
-
import { dirname as dirname3, join as join8 } from "node:path";
|
|
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";
|
|
2977
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
|
+
}
|
|
2978
3043
|
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
2979
3044
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2980
3045
|
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
@@ -2987,25 +3052,28 @@ function coveredBy(existing, critical) {
|
|
|
2987
3052
|
for (const line of existing.split("\n")) {
|
|
2988
3053
|
const t = line.trim();
|
|
2989
3054
|
if (!t || t.startsWith("#")) continue;
|
|
2990
|
-
const
|
|
2991
|
-
if (!t.slice(
|
|
2992
|
-
if (
|
|
2993
|
-
|
|
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
|
+
}
|
|
2994
3067
|
}
|
|
2995
3068
|
return false;
|
|
2996
3069
|
}
|
|
2997
3070
|
function inferOwner(cwd) {
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
const m = url.match(/github\.com[:/]+([^/]+)\//);
|
|
3005
|
-
return m ? `@${m[1]}` : null;
|
|
3006
|
-
} catch {
|
|
3007
|
-
return null;
|
|
3008
|
-
}
|
|
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()) };
|
|
3009
3077
|
}
|
|
3010
3078
|
var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
|
|
3011
3079
|
# without this file \u2014 everything here is an override, so an empty file changes nothing.
|
|
@@ -3143,9 +3211,31 @@ function missingTools(matcher2) {
|
|
|
3143
3211
|
if (have === null) return [];
|
|
3144
3212
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
3145
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
|
+
}
|
|
3146
3229
|
function planPolicy(cwd) {
|
|
3147
3230
|
const path = join8(cwd, POLICY_FILE);
|
|
3148
|
-
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
|
+
}
|
|
3149
3239
|
return {
|
|
3150
3240
|
item: "policy",
|
|
3151
3241
|
path: POLICY_FILE,
|
|
@@ -3175,6 +3265,10 @@ function planClaudeHooks(cwd) {
|
|
|
3175
3265
|
}
|
|
3176
3266
|
settings = parsed;
|
|
3177
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
|
+
}
|
|
3178
3272
|
const hooks = settings.hooks ??= {};
|
|
3179
3273
|
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
|
|
3180
3274
|
const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
|
|
@@ -3220,47 +3314,170 @@ function planClaudeHooks(cwd) {
|
|
|
3220
3314
|
}
|
|
3221
3315
|
};
|
|
3222
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
|
+
}
|
|
3223
3369
|
function planPreCommit(cwd) {
|
|
3224
|
-
const
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
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
|
+
}
|
|
3232
3397
|
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
3233
|
-
if (existing
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
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" };
|
|
3238
3427
|
}
|
|
3239
|
-
const was = pinOf(lines[
|
|
3428
|
+
const was = pinOf(lines[at]) || "unpinned";
|
|
3240
3429
|
return {
|
|
3241
3430
|
item: "pre-commit",
|
|
3242
|
-
path:
|
|
3431
|
+
path: rel,
|
|
3243
3432
|
status: "update",
|
|
3244
3433
|
detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
|
|
3245
3434
|
apply: () => {
|
|
3246
|
-
lines[
|
|
3247
|
-
|
|
3248
|
-
|
|
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);
|
|
3249
3472
|
}
|
|
3250
3473
|
};
|
|
3251
3474
|
}
|
|
3252
3475
|
return {
|
|
3253
3476
|
item: "pre-commit",
|
|
3254
|
-
path:
|
|
3255
|
-
status:
|
|
3256
|
-
detail:
|
|
3257
|
-
apply: () =>
|
|
3258
|
-
mkdirSync5(dirname3(path), { recursive: true });
|
|
3259
|
-
const content = existing === null ? `#!/bin/sh
|
|
3260
|
-
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
3261
|
-
writeFileSync5(path, content);
|
|
3262
|
-
chmodSync(path, 493);
|
|
3263
|
-
}
|
|
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, ""])
|
|
3264
3481
|
};
|
|
3265
3482
|
}
|
|
3266
3483
|
function planCodeowners(cwd) {
|
|
@@ -3274,18 +3491,26 @@ function planCodeowners(cwd) {
|
|
|
3274
3491
|
if (missing.length === 0) {
|
|
3275
3492
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
3276
3493
|
}
|
|
3277
|
-
const
|
|
3494
|
+
const inferred = inferOwner(cwd);
|
|
3495
|
+
const owner = inferred?.owner ?? null;
|
|
3496
|
+
const unconfirmed = inferred !== null && !inferred.confirmed;
|
|
3278
3497
|
const block = `
|
|
3279
3498
|
${CODEOWNERS_MARK}
|
|
3280
3499
|
# A pull request that rewrites the workflow can keep the job name, replace the
|
|
3281
3500
|
# gate with \`true\`, and still report a green required check. Only a human
|
|
3282
3501
|
# requirement on these paths prevents that.
|
|
3283
|
-
` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" :
|
|
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";
|
|
3284
3506
|
return {
|
|
3285
3507
|
item: "codeowners",
|
|
3286
3508
|
path: existingRel ?? rel,
|
|
3287
3509
|
status: existing === null ? "create" : "update",
|
|
3288
|
-
detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner})`),
|
|
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
|
+
} : {},
|
|
3289
3514
|
apply: () => {
|
|
3290
3515
|
mkdirSync5(dirname3(path), { recursive: true });
|
|
3291
3516
|
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
@@ -3344,21 +3569,49 @@ function planWorkflow(cwd, force) {
|
|
|
3344
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.`
|
|
3345
3570
|
};
|
|
3346
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
|
+
}
|
|
3347
3579
|
function planInit(cwd, opts = {}) {
|
|
3348
|
-
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
|
+
];
|
|
3349
3587
|
}
|
|
3350
3588
|
function runInit(opts) {
|
|
3351
3589
|
const cwd = opts.cwd ?? process.cwd();
|
|
3352
3590
|
const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
|
|
3353
3591
|
const w = process.stdout;
|
|
3592
|
+
let applied = 0;
|
|
3354
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
|
+
}
|
|
3355
3604
|
const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
|
|
3356
3605
|
w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
|
|
3357
3606
|
`);
|
|
3358
|
-
|
|
3607
|
+
}
|
|
3608
|
+
for (const a of plan) {
|
|
3609
|
+
if (a.warning) w.write(`
|
|
3610
|
+
WARNING (${a.item}): ${a.warning}
|
|
3611
|
+
`);
|
|
3359
3612
|
}
|
|
3360
3613
|
const errors = plan.filter((a) => a.status === "error");
|
|
3361
|
-
const changed = plan.filter((a) => a.apply).length;
|
|
3614
|
+
const changed = opts.dryRun ? plan.filter((a) => a.apply).length : applied;
|
|
3362
3615
|
if (errors.length) {
|
|
3363
3616
|
w.write(`
|
|
3364
3617
|
tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
|
|
@@ -3381,15 +3634,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
3381
3634
|
// src/cli/verify.ts
|
|
3382
3635
|
import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
|
|
3383
3636
|
import { createHash as createHash8 } from "node:crypto";
|
|
3384
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as
|
|
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";
|
|
3385
3638
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3386
|
-
import { dirname as dirname4, join as join11, resolve as
|
|
3639
|
+
import { dirname as dirname4, join as join11, resolve as resolve3 } from "node:path";
|
|
3387
3640
|
|
|
3388
3641
|
// src/git/trusted.ts
|
|
3389
3642
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3390
3643
|
import { createHash as createHash6 } from "node:crypto";
|
|
3391
3644
|
import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
|
|
3392
|
-
import { isAbsolute as
|
|
3645
|
+
import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
|
|
3393
3646
|
function trustedGitEnv() {
|
|
3394
3647
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3395
3648
|
}
|
|
@@ -3402,7 +3655,7 @@ function gitDirOf(cwd) {
|
|
|
3402
3655
|
env: trustedGitEnv()
|
|
3403
3656
|
}).trim();
|
|
3404
3657
|
if (!d) return null;
|
|
3405
|
-
return
|
|
3658
|
+
return isAbsolute5(d) ? d : join9(cwd, d);
|
|
3406
3659
|
} catch {
|
|
3407
3660
|
return null;
|
|
3408
3661
|
}
|
|
@@ -3440,7 +3693,7 @@ function objectRewriteState(cwd) {
|
|
|
3440
3693
|
// src/fingerprint.ts
|
|
3441
3694
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
3442
3695
|
import { createHash as createHash7 } from "node:crypto";
|
|
3443
|
-
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";
|
|
3444
3697
|
import { join as join10 } from "node:path";
|
|
3445
3698
|
function treeFingerprint(cwd) {
|
|
3446
3699
|
const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
@@ -3479,7 +3732,7 @@ function depsFingerprint(cwd) {
|
|
|
3479
3732
|
h.update(p);
|
|
3480
3733
|
try {
|
|
3481
3734
|
h.update(readFileSync12(p));
|
|
3482
|
-
h.update(String(
|
|
3735
|
+
h.update(String(statSync6(p).mode));
|
|
3483
3736
|
} catch {
|
|
3484
3737
|
h.update("<unreadable>");
|
|
3485
3738
|
}
|
|
@@ -3494,7 +3747,7 @@ function depsFingerprint(cwd) {
|
|
|
3494
3747
|
}
|
|
3495
3748
|
};
|
|
3496
3749
|
try {
|
|
3497
|
-
if (!
|
|
3750
|
+
if (!statSync6(root).isDirectory()) return "none";
|
|
3498
3751
|
} catch {
|
|
3499
3752
|
return "none";
|
|
3500
3753
|
}
|
|
@@ -3552,7 +3805,7 @@ function materialize(cwd, dest) {
|
|
|
3552
3805
|
const src = join11(cwd, rel);
|
|
3553
3806
|
let st;
|
|
3554
3807
|
try {
|
|
3555
|
-
st =
|
|
3808
|
+
st = statSync7(src);
|
|
3556
3809
|
} catch {
|
|
3557
3810
|
continue;
|
|
3558
3811
|
}
|
|
@@ -3567,7 +3820,7 @@ function materialize(cwd, dest) {
|
|
|
3567
3820
|
}
|
|
3568
3821
|
chmodSync2(out2, st.mode);
|
|
3569
3822
|
}
|
|
3570
|
-
const nm =
|
|
3823
|
+
const nm = resolve3(cwd, "node_modules");
|
|
3571
3824
|
if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
|
|
3572
3825
|
}
|
|
3573
3826
|
function dropSymlink(p) {
|
|
@@ -3583,9 +3836,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
3583
3836
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
3584
3837
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
3585
3838
|
}
|
|
3586
|
-
const
|
|
3587
|
-
if (
|
|
3588
|
-
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);
|
|
3589
3842
|
}
|
|
3590
3843
|
return picked;
|
|
3591
3844
|
}
|
|
@@ -3857,7 +4110,7 @@ function parseVerify(args) {
|
|
|
3857
4110
|
// src/cli/run.ts
|
|
3858
4111
|
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
3859
4112
|
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
|
|
3860
|
-
import { resolve as
|
|
4113
|
+
import { resolve as resolve4 } from "node:path";
|
|
3861
4114
|
var out = (s) => process.stdout.write(s + "\n");
|
|
3862
4115
|
var err = (s) => process.stderr.write(s + "\n");
|
|
3863
4116
|
function git3(args, cwd) {
|
|
@@ -3904,7 +4157,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3904
4157
|
return out2;
|
|
3905
4158
|
}
|
|
3906
4159
|
function runEnvelope(opts) {
|
|
3907
|
-
const cwd =
|
|
4160
|
+
const cwd = resolve4(opts.cwd ?? process.cwd());
|
|
3908
4161
|
if (!opts.argv.length) {
|
|
3909
4162
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
3910
4163
|
return 2;
|
|
@@ -4041,7 +4294,7 @@ function parseRun(args) {
|
|
|
4041
4294
|
return o;
|
|
4042
4295
|
}
|
|
4043
4296
|
|
|
4044
|
-
// src/cli/
|
|
4297
|
+
// src/cli/main.ts
|
|
4045
4298
|
function parseAllow(args) {
|
|
4046
4299
|
const o = {};
|
|
4047
4300
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -4149,9 +4402,11 @@ Formats:
|
|
|
4149
4402
|
Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
|
|
4150
4403
|
(verify), any blocking finding or masked failure (run) \xB7 2 cannot
|
|
4151
4404
|
evaluate, failing closed: policy error, bad --diff range, no view,
|
|
4152
|
-
unresolvable base, --require-ancestor
|
|
4153
|
-
|
|
4154
|
-
|
|
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.
|
|
4155
4410
|
`);
|
|
4156
4411
|
}
|
|
4157
4412
|
function main(argv2) {
|
|
@@ -4183,9 +4438,21 @@ function main(argv2) {
|
|
|
4183
4438
|
return 2;
|
|
4184
4439
|
}
|
|
4185
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
|
|
4186
4453
|
var argv = process.argv.slice(2);
|
|
4187
4454
|
if (argv[0] === "watch") {
|
|
4188
4455
|
runWatch(argv.slice(1));
|
|
4189
4456
|
} else {
|
|
4190
|
-
process.exit(
|
|
4457
|
+
process.exit(guardedMain(argv));
|
|
4191
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",
|