tamperward 1.7.0 → 1.9.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/dist/cli/index.js +603 -35
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -463,6 +463,11 @@ function defaultPolicy(version = 1) {
|
|
|
463
463
|
// mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
|
|
464
464
|
// when intended output changes. WARN until the §7 evidence path earns block.
|
|
465
465
|
"snapshot-rewrite": { severity: "warn" },
|
|
466
|
+
// the watcher's transient class (modified-and-restored within a turn). WARN by
|
|
467
|
+
// default: a Stop block already forces restores, and a compliant restore reads
|
|
468
|
+
// as churn — block-by-default would deadlock the honest path. Harnesses that own
|
|
469
|
+
// that semantics raise it via TAMPERWARD_TRANSIENT=block.
|
|
470
|
+
"transient-protected-mutation": { severity: "warn" },
|
|
466
471
|
// the narrow graduation candidate: snapshot moved with NO accompanying change.
|
|
467
472
|
// ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
|
|
468
473
|
// (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
|
|
@@ -1009,6 +1014,24 @@ function policyWeakening(before, after) {
|
|
|
1009
1014
|
if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
|
|
1010
1015
|
reasons.push("sign-off no longer required for blocking findings");
|
|
1011
1016
|
}
|
|
1017
|
+
const bv = b.verify;
|
|
1018
|
+
const av = a.verify;
|
|
1019
|
+
if (bv?.command) {
|
|
1020
|
+
if (!av?.command) {
|
|
1021
|
+
reasons.push("verify block removed \u2014 pristine-suite re-execution disabled");
|
|
1022
|
+
} else {
|
|
1023
|
+
if (av.command !== bv.command) {
|
|
1024
|
+
reasons.push(
|
|
1025
|
+
`verify.command changed ("${bv.command}" \u2192 "${av.command}") \u2014 the re-executed suite is no longer the one that was configured`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
const bb = bv.budget ?? 300;
|
|
1029
|
+
const ab = av.budget ?? 300;
|
|
1030
|
+
if (ab < bb) {
|
|
1031
|
+
reasons.push(`verify.budget lowered ${bb}s \u2192 ${ab}s \u2014 starving the budget turns "cannot verify" into a pressure point`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1012
1035
|
return reasons;
|
|
1013
1036
|
}
|
|
1014
1037
|
|
|
@@ -1506,7 +1529,8 @@ function parsePolicy(raw) {
|
|
|
1506
1529
|
signoff: {
|
|
1507
1530
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1508
1531
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
1509
|
-
}
|
|
1532
|
+
},
|
|
1533
|
+
...r.verify?.command ? { verify: { command: r.verify.command, budget: r.verify.budget ?? 300 } } : {}
|
|
1510
1534
|
};
|
|
1511
1535
|
}
|
|
1512
1536
|
function parseOrThrow(src, where) {
|
|
@@ -1721,10 +1745,10 @@ function locationCell(f, env) {
|
|
|
1721
1745
|
const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
|
|
1722
1746
|
const server = env.GITHUB_SERVER_URL;
|
|
1723
1747
|
const repo = env.GITHUB_REPOSITORY;
|
|
1724
|
-
const
|
|
1725
|
-
if (!server || !repo || !
|
|
1748
|
+
const sha2 = env.GITHUB_SHA;
|
|
1749
|
+
if (!server || !repo || !sha2) return `\`${cell(label)}\``;
|
|
1726
1750
|
const frag = f.line ? `#L${f.line}` : "";
|
|
1727
|
-
return `[\`${cell(label)}\`](${server}/${repo}/blob/${
|
|
1751
|
+
return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha2}/${f.file}${frag})`;
|
|
1728
1752
|
}
|
|
1729
1753
|
function renderSummary(input, env = process.env) {
|
|
1730
1754
|
const findings = orderFindings(input.findings);
|
|
@@ -1870,7 +1894,8 @@ function runCheck(opts) {
|
|
|
1870
1894
|
}
|
|
1871
1895
|
|
|
1872
1896
|
// src/cli/hook.ts
|
|
1873
|
-
import { readFileSync as
|
|
1897
|
+
import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
|
|
1898
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1874
1899
|
|
|
1875
1900
|
// src/adapters/claude/changes.ts
|
|
1876
1901
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
@@ -1895,6 +1920,7 @@ function relForDisplay(path, cwd) {
|
|
|
1895
1920
|
}
|
|
1896
1921
|
function applyEdit(content, oldStr, newStr) {
|
|
1897
1922
|
if (content === null) return newStr;
|
|
1923
|
+
if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
|
|
1898
1924
|
return content.replace(oldStr, () => newStr);
|
|
1899
1925
|
}
|
|
1900
1926
|
function synthFileChange(displayPath, before, after) {
|
|
@@ -2025,10 +2051,261 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2025
2051
|
}
|
|
2026
2052
|
}
|
|
2027
2053
|
|
|
2054
|
+
// src/effect.ts
|
|
2055
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2056
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2057
|
+
import { join as join6 } from "node:path";
|
|
2058
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2059
|
+
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2060
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
2061
|
+
function gitDir2(cwd) {
|
|
2062
|
+
try {
|
|
2063
|
+
return execFileSync3("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf8" }).trim();
|
|
2064
|
+
} catch {
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
function ptreePath(cwd, sessionId) {
|
|
2069
|
+
if (!sessionId) return null;
|
|
2070
|
+
const gd = gitDir2(cwd);
|
|
2071
|
+
if (!gd) return null;
|
|
2072
|
+
const dir = join6(cwd, gd, "tamperward");
|
|
2073
|
+
return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
2074
|
+
}
|
|
2075
|
+
var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
|
|
2076
|
+
function snapshotProtected(cwd, policy, prev) {
|
|
2077
|
+
const out = {};
|
|
2078
|
+
const walk = (rel) => {
|
|
2079
|
+
const abs2 = rel ? join6(cwd, rel) : cwd;
|
|
2080
|
+
let names;
|
|
2081
|
+
try {
|
|
2082
|
+
names = readdirSync(abs2);
|
|
2083
|
+
} catch {
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
for (const name of names) {
|
|
2087
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
2088
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
2089
|
+
let st;
|
|
2090
|
+
try {
|
|
2091
|
+
st = statSync(join6(cwd, r));
|
|
2092
|
+
} catch {
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
if (st.isDirectory()) walk(r);
|
|
2096
|
+
else if (st.isFile() && isProtected(r, policy)) {
|
|
2097
|
+
const p = prev?.[r];
|
|
2098
|
+
if (p && p.mtimeMs === st.mtimeMs && p.size === st.size && p.mode === st.mode) {
|
|
2099
|
+
out[r] = p;
|
|
2100
|
+
} else {
|
|
2101
|
+
let hash = "";
|
|
2102
|
+
try {
|
|
2103
|
+
hash = sha(readFileSync6(join6(cwd, r)));
|
|
2104
|
+
} catch {
|
|
2105
|
+
hash = "unreadable";
|
|
2106
|
+
}
|
|
2107
|
+
out[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
walk("");
|
|
2113
|
+
return out;
|
|
2114
|
+
}
|
|
2115
|
+
function driftBetween(expected, current) {
|
|
2116
|
+
const changed = [];
|
|
2117
|
+
const deleted = [];
|
|
2118
|
+
for (const [path, e] of Object.entries(expected)) {
|
|
2119
|
+
const c = current[path];
|
|
2120
|
+
if (!c) deleted.push(path);
|
|
2121
|
+
else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
|
|
2122
|
+
}
|
|
2123
|
+
return { changed, deleted };
|
|
2124
|
+
}
|
|
2125
|
+
function loadPtree(cwd, sessionId) {
|
|
2126
|
+
const p = ptreePath(cwd, sessionId);
|
|
2127
|
+
if (!p || !existsSync5(p)) return null;
|
|
2128
|
+
try {
|
|
2129
|
+
return JSON.parse(readFileSync6(p, "utf8"));
|
|
2130
|
+
} catch {
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
function savePtree(cwd, sessionId, tree) {
|
|
2135
|
+
const p = ptreePath(cwd, sessionId);
|
|
2136
|
+
if (!p) return;
|
|
2137
|
+
try {
|
|
2138
|
+
mkdirSync3(join6(p, ".."), { recursive: true });
|
|
2139
|
+
writeFileSync3(p, JSON.stringify(tree));
|
|
2140
|
+
} catch {
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// src/cli/watch.ts
|
|
2145
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
|
|
2146
|
+
import { join as join7 } from "node:path";
|
|
2147
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
2148
|
+
var SKIP = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
2149
|
+
function snap(abs2) {
|
|
2150
|
+
try {
|
|
2151
|
+
const st = statSync2(abs2);
|
|
2152
|
+
if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
|
|
2153
|
+
const hash = createHash3("sha256").update(readFileSync7(abs2)).digest("hex").slice(0, 16);
|
|
2154
|
+
return { mode: st.mode, size: st.size, hash };
|
|
2155
|
+
} catch {
|
|
2156
|
+
return { mode: null, size: null, hash: null };
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
function defaultEventLog(cwd) {
|
|
2160
|
+
return process.env.TAMPERWARD_FSEVENTS ?? join7(cwd, ".git", "tamperward", "fsevents.jsonl");
|
|
2161
|
+
}
|
|
2162
|
+
function watchTree(dir, cb) {
|
|
2163
|
+
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
2164
|
+
try {
|
|
2165
|
+
const w = watch(dir, { recursive: true }, (kind, fname) => {
|
|
2166
|
+
if (fname) cb(kind, String(fname));
|
|
2167
|
+
});
|
|
2168
|
+
return { close: () => w.close() };
|
|
2169
|
+
} catch {
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
const watchers = /* @__PURE__ */ new Map();
|
|
2173
|
+
const addDir = (rel) => {
|
|
2174
|
+
if (watchers.has(rel) || SKIP.test(rel + "/")) return;
|
|
2175
|
+
let w;
|
|
2176
|
+
try {
|
|
2177
|
+
w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
|
|
2178
|
+
if (!fname) return;
|
|
2179
|
+
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
2180
|
+
cb(kind, child);
|
|
2181
|
+
try {
|
|
2182
|
+
if (statSync2(join7(dir, child)).isDirectory()) addDir(child);
|
|
2183
|
+
} catch {
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
2186
|
+
} catch {
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
watchers.set(rel, w);
|
|
2190
|
+
};
|
|
2191
|
+
const walk = (rel) => {
|
|
2192
|
+
addDir(rel);
|
|
2193
|
+
let names = [];
|
|
2194
|
+
try {
|
|
2195
|
+
names = readdirSync2(rel ? join7(dir, rel) : dir);
|
|
2196
|
+
} catch {
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
for (const name of names) {
|
|
2200
|
+
const child = rel ? `${rel}/${name}` : name;
|
|
2201
|
+
if (SKIP.test(child + "/")) continue;
|
|
2202
|
+
try {
|
|
2203
|
+
if (statSync2(join7(dir, child)).isDirectory()) walk(child);
|
|
2204
|
+
} catch {
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
};
|
|
2208
|
+
walk("");
|
|
2209
|
+
return { close: () => {
|
|
2210
|
+
for (const w of watchers.values()) w.close();
|
|
2211
|
+
} };
|
|
2212
|
+
}
|
|
2213
|
+
function startWatcher(dir, log, policy) {
|
|
2214
|
+
try {
|
|
2215
|
+
mkdirSync4(join7(log, ".."), { recursive: true });
|
|
2216
|
+
} catch {
|
|
2217
|
+
}
|
|
2218
|
+
const last = /* @__PURE__ */ new Map();
|
|
2219
|
+
return watchTree(dir, (kind, rel) => {
|
|
2220
|
+
if (SKIP.test(rel) || !isProtected(rel, policy)) return;
|
|
2221
|
+
const s = snap(join7(dir, rel));
|
|
2222
|
+
const key2 = `${s.hash}:${s.mode}`;
|
|
2223
|
+
if (last.get(rel) === key2) return;
|
|
2224
|
+
last.set(rel, key2);
|
|
2225
|
+
const ev = { ts: (/* @__PURE__ */ new Date()).toISOString(), path: rel, kind: kind === "rename" ? "rename" : "change", ...s };
|
|
2226
|
+
try {
|
|
2227
|
+
appendFileSync3(log, JSON.stringify(ev) + "\n");
|
|
2228
|
+
} catch {
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
function runWatch(args) {
|
|
2233
|
+
let dir = process.cwd();
|
|
2234
|
+
let log = null;
|
|
2235
|
+
for (let i = 0; i < args.length; i++) {
|
|
2236
|
+
if (args[i] === "--dir" && args[i + 1]) dir = args[++i];
|
|
2237
|
+
else if (args[i] === "--log" && args[i + 1]) log = args[++i];
|
|
2238
|
+
}
|
|
2239
|
+
const policy = loadPolicy(dir);
|
|
2240
|
+
const out = log ?? defaultEventLog(dir);
|
|
2241
|
+
startWatcher(dir, out, policy);
|
|
2242
|
+
process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${out}
|
|
2243
|
+
`);
|
|
2244
|
+
return -1;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// src/detectors/fs-events.ts
|
|
2248
|
+
import { readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
|
|
2249
|
+
var RULE11 = "transient-protected-mutation";
|
|
2250
|
+
function readEvents(log, offset) {
|
|
2251
|
+
let size = 0;
|
|
2252
|
+
try {
|
|
2253
|
+
size = statSync3(log).size;
|
|
2254
|
+
} catch {
|
|
2255
|
+
return { events: [], newOffset: offset };
|
|
2256
|
+
}
|
|
2257
|
+
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
2258
|
+
const raw = readFileSync8(log, "utf8").slice(offset);
|
|
2259
|
+
const events = [];
|
|
2260
|
+
for (const line of raw.split("\n")) {
|
|
2261
|
+
if (!line.trim()) continue;
|
|
2262
|
+
try {
|
|
2263
|
+
events.push(JSON.parse(line));
|
|
2264
|
+
} catch {
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return { events, newOffset: size };
|
|
2268
|
+
}
|
|
2269
|
+
function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
2270
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2271
|
+
for (const e of events) {
|
|
2272
|
+
if (!isProtected(e.path, policy)) continue;
|
|
2273
|
+
const arr = byPath.get(e.path) ?? [];
|
|
2274
|
+
arr.push(e);
|
|
2275
|
+
byPath.set(e.path, arr);
|
|
2276
|
+
}
|
|
2277
|
+
const out = [];
|
|
2278
|
+
const strict = process.env.TAMPERWARD_TRANSIENT === "block";
|
|
2279
|
+
for (const [path, evs] of byPath) {
|
|
2280
|
+
if (persistentPaths.has(path)) continue;
|
|
2281
|
+
const final = finalHash(path);
|
|
2282
|
+
const hashes = new Set(evs.map((e) => e.hash).filter((h) => h != null));
|
|
2283
|
+
if (final != null) hashes.add(final);
|
|
2284
|
+
const contentChurn = hashes.size > 1;
|
|
2285
|
+
const modes = new Set(evs.map((e) => e.mode).filter((m) => m != null));
|
|
2286
|
+
const modeChurn = modes.size > 1;
|
|
2287
|
+
if (!contentChurn && !modeChurn) continue;
|
|
2288
|
+
const what = contentChurn ? `content was modified and restored within the turn (${hashes.size} distinct states observed)` : `file mode changed during the turn (${[...modes].map((m) => (m & 4095).toString(8)).join(" \u2192 ")})`;
|
|
2289
|
+
const f = makeFinding(RULE11, policy, {
|
|
2290
|
+
file: path,
|
|
2291
|
+
message: `Transient protected mutation: ${what}, leaving no net diff.`,
|
|
2292
|
+
evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
|
|
2293
|
+
remediation: "Anything that ran against the mid-turn state ran against a weakened suite. Re-run the checks against the restored tree; if the mutation was an experiment, say so where a human will read it.",
|
|
2294
|
+
defaultSeverity: "warn"
|
|
2295
|
+
});
|
|
2296
|
+
if (strict && f.severity !== "block") {
|
|
2297
|
+
f.severity = "block";
|
|
2298
|
+
f.signoff.required = true;
|
|
2299
|
+
}
|
|
2300
|
+
out.push(f);
|
|
2301
|
+
}
|
|
2302
|
+
return out;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2028
2305
|
// src/cli/hook.ts
|
|
2029
2306
|
function readStdin() {
|
|
2030
2307
|
try {
|
|
2031
|
-
return
|
|
2308
|
+
return readFileSync9(0, "utf8");
|
|
2032
2309
|
} catch {
|
|
2033
2310
|
return "";
|
|
2034
2311
|
}
|
|
@@ -2045,7 +2322,7 @@ function recordDenylog(blocks) {
|
|
|
2045
2322
|
const log = process.env.TAMPERWARD_DENYLOG;
|
|
2046
2323
|
if (!log) return;
|
|
2047
2324
|
try {
|
|
2048
|
-
|
|
2325
|
+
appendFileSync4(log, blocks.map((b) => b.rule).join(",") + "\n");
|
|
2049
2326
|
} catch {
|
|
2050
2327
|
}
|
|
2051
2328
|
}
|
|
@@ -2077,30 +2354,141 @@ function verdict(blocks, kind) {
|
|
|
2077
2354
|
} : { decision: "block", reason };
|
|
2078
2355
|
return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
|
|
2079
2356
|
}
|
|
2357
|
+
function effectDriftBlocks(cwd, sessionId, policy) {
|
|
2358
|
+
if (!sessionId || !isGitRepo(cwd)) return null;
|
|
2359
|
+
const expected = loadPtree(cwd, sessionId);
|
|
2360
|
+
const current = snapshotProtected(cwd, policy, expected ?? void 0);
|
|
2361
|
+
if (!expected) {
|
|
2362
|
+
savePtree(cwd, sessionId, current);
|
|
2363
|
+
return null;
|
|
2364
|
+
}
|
|
2365
|
+
const d = driftBetween(expected, current);
|
|
2366
|
+
if (d.changed.length === 0 && d.deleted.length === 0) {
|
|
2367
|
+
savePtree(cwd, sessionId, current);
|
|
2368
|
+
return null;
|
|
2369
|
+
}
|
|
2370
|
+
const base = turnBaseline(cwd, sessionId);
|
|
2371
|
+
const all = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
|
|
2372
|
+
const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
|
|
2373
|
+
const scoped = all.filter(
|
|
2374
|
+
(c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
|
|
2375
|
+
);
|
|
2376
|
+
const blocks = evaluate(scoped, policy, void 0, "turn").filter((f) => f.severity === "block");
|
|
2377
|
+
if (blocks.length > 0) return blocks;
|
|
2378
|
+
savePtree(cwd, sessionId, current);
|
|
2379
|
+
return null;
|
|
2380
|
+
}
|
|
2381
|
+
function sanctionPredictedWrites(cwd, sessionId, policy, changes) {
|
|
2382
|
+
if (!sessionId) return;
|
|
2383
|
+
const tree = loadPtree(cwd, sessionId);
|
|
2384
|
+
if (!tree) return;
|
|
2385
|
+
let dirty = false;
|
|
2386
|
+
for (const c of changes) {
|
|
2387
|
+
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy)) continue;
|
|
2388
|
+
const hash = createHash4("sha256").update(c.after).digest("hex").slice(0, 16);
|
|
2389
|
+
const prev = tree[c.path];
|
|
2390
|
+
tree[c.path] = {
|
|
2391
|
+
hash,
|
|
2392
|
+
mode: prev?.mode ?? 33188,
|
|
2393
|
+
size: Buffer.byteLength(c.after),
|
|
2394
|
+
mtimeMs: -1
|
|
2395
|
+
// unknown until written; forces a re-hash next snapshot, which is correct
|
|
2396
|
+
};
|
|
2397
|
+
dirty = true;
|
|
2398
|
+
}
|
|
2399
|
+
if (dirty) savePtree(cwd, sessionId, tree);
|
|
2400
|
+
}
|
|
2080
2401
|
function preToolUseVerdict(input) {
|
|
2081
2402
|
try {
|
|
2082
2403
|
const cwd = input.cwd ?? process.cwd();
|
|
2083
2404
|
turnBaseline(cwd, input.session_id);
|
|
2405
|
+
const policy = loadPolicy(cwd);
|
|
2406
|
+
const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
|
|
2407
|
+
if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
|
|
2084
2408
|
const changes = changesFromClaudeHook(input, cwd);
|
|
2085
|
-
const blocks = evaluate(changes,
|
|
2409
|
+
const blocks = evaluate(changes, policy, void 0, "tool-call").filter((f) => f.severity === "block");
|
|
2410
|
+
if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
|
|
2086
2411
|
return verdict(blocks, "PreToolUse");
|
|
2087
2412
|
} catch (e) {
|
|
2088
2413
|
return failClosed("PreToolUse", errText(e));
|
|
2089
2414
|
}
|
|
2090
2415
|
}
|
|
2416
|
+
function cursorPath(cwd, sessionId) {
|
|
2417
|
+
const p = ptreePath(cwd, sessionId);
|
|
2418
|
+
return p ? p.replace(/ptree-/, "fscursor-") : null;
|
|
2419
|
+
}
|
|
2420
|
+
function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
2421
|
+
const none = { blocks: [], commit: () => {
|
|
2422
|
+
} };
|
|
2423
|
+
const log = defaultEventLog(cwd);
|
|
2424
|
+
const cp = cursorPath(cwd, sessionId);
|
|
2425
|
+
if (!existsSync6(log)) return none;
|
|
2426
|
+
let offset = 0;
|
|
2427
|
+
if (cp && existsSync6(cp)) {
|
|
2428
|
+
const n = Number(readFileSync9(cp, "utf8"));
|
|
2429
|
+
offset = Number.isFinite(n) ? n : 0;
|
|
2430
|
+
}
|
|
2431
|
+
const { events, newOffset } = readEvents(log, offset);
|
|
2432
|
+
if (events.length === 0) return { ...none, commit: () => {
|
|
2433
|
+
if (cp) try {
|
|
2434
|
+
writeFileSync4(cp, String(newOffset));
|
|
2435
|
+
} catch {
|
|
2436
|
+
}
|
|
2437
|
+
} };
|
|
2438
|
+
const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
|
|
2439
|
+
const finalHash = (path) => {
|
|
2440
|
+
try {
|
|
2441
|
+
if (!statSync4(`${cwd}/${path}`).isFile()) return null;
|
|
2442
|
+
return createHash4("sha256").update(readFileSync9(`${cwd}/${path}`)).digest("hex").slice(0, 16);
|
|
2443
|
+
} catch {
|
|
2444
|
+
return null;
|
|
2445
|
+
}
|
|
2446
|
+
};
|
|
2447
|
+
const findings = transientFindings(events, persistent, policy, finalHash);
|
|
2448
|
+
recordWarns(findings.filter((f) => f.severity !== "block"));
|
|
2449
|
+
return {
|
|
2450
|
+
blocks: findings.filter((f) => f.severity === "block"),
|
|
2451
|
+
commit: () => {
|
|
2452
|
+
if (cp) try {
|
|
2453
|
+
writeFileSync4(cp, String(newOffset));
|
|
2454
|
+
} catch {
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
function recordWarns(warns) {
|
|
2460
|
+
const log = process.env.TAMPERWARD_DENYLOG;
|
|
2461
|
+
if (!log || warns.length === 0) return;
|
|
2462
|
+
try {
|
|
2463
|
+
appendFileSync4(log, warns.map((w) => `warn:${w.rule}:${w.file ?? ""}`).join("\n") + "\n");
|
|
2464
|
+
} catch {
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2091
2467
|
function stopVerdict(input) {
|
|
2092
2468
|
if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
|
|
2093
2469
|
const cwd = input.cwd ?? process.cwd();
|
|
2094
2470
|
if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
|
|
2095
2471
|
let blocks;
|
|
2472
|
+
let commitCursor = () => {
|
|
2473
|
+
};
|
|
2096
2474
|
try {
|
|
2475
|
+
const policy = loadPolicy(cwd);
|
|
2097
2476
|
const base = turnBaseline(cwd, input.session_id);
|
|
2098
2477
|
const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
|
|
2099
|
-
blocks = evaluate(changes,
|
|
2478
|
+
blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block");
|
|
2479
|
+
const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
|
|
2480
|
+
blocks = blocks.concat(transient.blocks);
|
|
2481
|
+
commitCursor = transient.commit;
|
|
2482
|
+
if (blocks.length === 0 && input.session_id) {
|
|
2483
|
+
savePtree(cwd, input.session_id, snapshotProtected(cwd, policy, loadPtree(cwd, input.session_id) ?? void 0));
|
|
2484
|
+
}
|
|
2100
2485
|
} catch (e) {
|
|
2101
2486
|
return failClosed("Stop", errText(e));
|
|
2102
2487
|
}
|
|
2103
|
-
if (blocks.length === 0)
|
|
2488
|
+
if (blocks.length === 0) {
|
|
2489
|
+
advanceTurnBaseline(cwd, input.session_id);
|
|
2490
|
+
commitCursor();
|
|
2491
|
+
}
|
|
2104
2492
|
return verdict(blocks, "Stop");
|
|
2105
2493
|
}
|
|
2106
2494
|
function emit(r) {
|
|
@@ -2169,8 +2557,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2169
2557
|
}
|
|
2170
2558
|
|
|
2171
2559
|
// src/cli/init.ts
|
|
2172
|
-
import { existsSync as
|
|
2173
|
-
import { dirname as dirname3, join as
|
|
2560
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2561
|
+
import { dirname as dirname3, join as join8 } from "node:path";
|
|
2174
2562
|
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2175
2563
|
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2176
2564
|
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
@@ -2230,24 +2618,24 @@ jobs:
|
|
|
2230
2618
|
run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
|
|
2231
2619
|
`;
|
|
2232
2620
|
function planPolicy(cwd) {
|
|
2233
|
-
const path =
|
|
2234
|
-
if (
|
|
2621
|
+
const path = join8(cwd, POLICY_FILE);
|
|
2622
|
+
if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
|
|
2235
2623
|
return {
|
|
2236
2624
|
item: "policy",
|
|
2237
2625
|
path: POLICY_FILE,
|
|
2238
2626
|
status: "create",
|
|
2239
2627
|
detail: "baseline policy with commented overrides",
|
|
2240
|
-
apply: () =>
|
|
2628
|
+
apply: () => writeFileSync5(path, POLICY_CONTENT)
|
|
2241
2629
|
};
|
|
2242
2630
|
}
|
|
2243
2631
|
function planClaudeHooks(cwd) {
|
|
2244
2632
|
const rel = ".claude/settings.json";
|
|
2245
|
-
const path =
|
|
2633
|
+
const path = join8(cwd, rel);
|
|
2246
2634
|
let settings = {};
|
|
2247
|
-
if (
|
|
2635
|
+
if (existsSync7(path)) {
|
|
2248
2636
|
let parsed;
|
|
2249
2637
|
try {
|
|
2250
|
-
parsed = JSON.parse(
|
|
2638
|
+
parsed = JSON.parse(readFileSync10(path, "utf8"));
|
|
2251
2639
|
} catch {
|
|
2252
2640
|
return {
|
|
2253
2641
|
item: "agent",
|
|
@@ -2269,7 +2657,7 @@ function planClaudeHooks(cwd) {
|
|
|
2269
2657
|
return {
|
|
2270
2658
|
item: "agent",
|
|
2271
2659
|
path: rel,
|
|
2272
|
-
status:
|
|
2660
|
+
status: existsSync7(path) ? "update" : "create",
|
|
2273
2661
|
detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
|
|
2274
2662
|
apply: () => {
|
|
2275
2663
|
if (needPre) {
|
|
@@ -2281,8 +2669,8 @@ function planClaudeHooks(cwd) {
|
|
|
2281
2669
|
if (needStop) {
|
|
2282
2670
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
2283
2671
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2672
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2673
|
+
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
2286
2674
|
}
|
|
2287
2675
|
};
|
|
2288
2676
|
}
|
|
@@ -2290,12 +2678,12 @@ function planPreCommit(cwd) {
|
|
|
2290
2678
|
const line = `${MARKER}
|
|
2291
2679
|
${PRECOMMIT_CMD}
|
|
2292
2680
|
`;
|
|
2293
|
-
const husky =
|
|
2294
|
-
const
|
|
2295
|
-
const target =
|
|
2681
|
+
const husky = join8(cwd, ".husky");
|
|
2682
|
+
const gitDir3 = join8(cwd, ".git");
|
|
2683
|
+
const target = existsSync7(husky) ? { rel: ".husky/pre-commit", note: "husky" } : existsSync7(gitDir3) ? { rel: ".git/hooks/pre-commit", note: "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" } : null;
|
|
2296
2684
|
if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
|
|
2297
|
-
const path =
|
|
2298
|
-
const existing =
|
|
2685
|
+
const path = join8(cwd, target.rel);
|
|
2686
|
+
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
2299
2687
|
if (existing?.includes("tamperward check --staged")) {
|
|
2300
2688
|
return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
|
|
2301
2689
|
}
|
|
@@ -2305,26 +2693,26 @@ ${PRECOMMIT_CMD}
|
|
|
2305
2693
|
status: existing === null ? "create" : "update",
|
|
2306
2694
|
detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
|
|
2307
2695
|
apply: () => {
|
|
2308
|
-
|
|
2696
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2309
2697
|
const content = existing === null ? `#!/bin/sh
|
|
2310
2698
|
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
2311
|
-
|
|
2699
|
+
writeFileSync5(path, content);
|
|
2312
2700
|
chmodSync(path, 493);
|
|
2313
2701
|
}
|
|
2314
2702
|
};
|
|
2315
2703
|
}
|
|
2316
2704
|
function planWorkflow(cwd) {
|
|
2317
2705
|
const rel = ".github/workflows/tamperward.yml";
|
|
2318
|
-
const path =
|
|
2319
|
-
if (
|
|
2706
|
+
const path = join8(cwd, rel);
|
|
2707
|
+
if (existsSync7(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
|
|
2320
2708
|
return {
|
|
2321
2709
|
item: "ci",
|
|
2322
2710
|
path: rel,
|
|
2323
2711
|
status: "create",
|
|
2324
2712
|
detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
|
|
2325
2713
|
apply: () => {
|
|
2326
|
-
|
|
2327
|
-
|
|
2714
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2715
|
+
writeFileSync5(path, WORKFLOW_CONTENT);
|
|
2328
2716
|
}
|
|
2329
2717
|
};
|
|
2330
2718
|
}
|
|
@@ -2359,6 +2747,171 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
2359
2747
|
return 0;
|
|
2360
2748
|
}
|
|
2361
2749
|
|
|
2750
|
+
// src/cli/verify.ts
|
|
2751
|
+
import { execFileSync as execFileSync4, spawnSync } from "node:child_process";
|
|
2752
|
+
import { chmodSync as chmodSync2, cpSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "node:fs";
|
|
2753
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
2754
|
+
import { dirname as dirname4, join as join9 } from "node:path";
|
|
2755
|
+
var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
|
|
2756
|
+
function git2(args, cwd) {
|
|
2757
|
+
return execFileSync4("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
2758
|
+
}
|
|
2759
|
+
function resolveBase(base, cwd) {
|
|
2760
|
+
const rev = git2(["rev-parse", "--verify", `${base}^{commit}`], cwd).trim();
|
|
2761
|
+
try {
|
|
2762
|
+
return git2(["merge-base", rev, "HEAD"], cwd).trim();
|
|
2763
|
+
} catch {
|
|
2764
|
+
return rev;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
function materialize(cwd, dest) {
|
|
2768
|
+
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2769
|
+
for (const rel of listed) {
|
|
2770
|
+
const src = join9(cwd, rel);
|
|
2771
|
+
let st;
|
|
2772
|
+
try {
|
|
2773
|
+
st = statSync5(src);
|
|
2774
|
+
} catch {
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
2777
|
+
if (!st.isFile()) continue;
|
|
2778
|
+
const out = join9(dest, rel);
|
|
2779
|
+
mkdirSync6(dirname4(out), { recursive: true });
|
|
2780
|
+
cpSync(src, out);
|
|
2781
|
+
chmodSync2(out, st.mode);
|
|
2782
|
+
}
|
|
2783
|
+
const nm = join9(cwd, "node_modules");
|
|
2784
|
+
if (existsSync8(nm)) symlinkSync(nm, join9(dest, "node_modules"), "dir");
|
|
2785
|
+
}
|
|
2786
|
+
function overlayPristine(cwd, base, dest, policy) {
|
|
2787
|
+
const atBase = git2(["ls-tree", "-r", "--name-only", "-z", base], cwd).split("\0").filter(Boolean);
|
|
2788
|
+
const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c));
|
|
2789
|
+
let restored = 0;
|
|
2790
|
+
const baseProtected = /* @__PURE__ */ new Set();
|
|
2791
|
+
for (const rel of atBase) {
|
|
2792
|
+
if (!isOverlay(rel)) continue;
|
|
2793
|
+
baseProtected.add(rel);
|
|
2794
|
+
const content = execFileSync4("git", ["show", `${base}:${rel}`], { cwd, maxBuffer: 1 << 28 });
|
|
2795
|
+
const out = join9(dest, rel);
|
|
2796
|
+
mkdirSync6(dirname4(out), { recursive: true });
|
|
2797
|
+
writeFileSync6(out, content);
|
|
2798
|
+
restored++;
|
|
2799
|
+
}
|
|
2800
|
+
let kept = 0;
|
|
2801
|
+
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2802
|
+
for (const rel of inCopy) if (isOverlay(rel) && !baseProtected.has(rel)) kept++;
|
|
2803
|
+
return [restored, kept];
|
|
2804
|
+
}
|
|
2805
|
+
function runSuite(dir, cmd, budgetSecs) {
|
|
2806
|
+
const t0 = Date.now();
|
|
2807
|
+
const r = spawnSync("sh", ["-c", cmd], {
|
|
2808
|
+
cwd: dir,
|
|
2809
|
+
stdio: "ignore",
|
|
2810
|
+
timeout: budgetSecs * 1e3,
|
|
2811
|
+
killSignal: "SIGKILL"
|
|
2812
|
+
});
|
|
2813
|
+
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
2814
|
+
if (r.error && r.error.code === "ETIMEDOUT") return { exit: null, secs };
|
|
2815
|
+
if (r.status === null && r.signal) return { exit: null, secs };
|
|
2816
|
+
return { exit: r.status ?? 2, secs };
|
|
2817
|
+
}
|
|
2818
|
+
function runVerify(opts) {
|
|
2819
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2820
|
+
const out = (s) => void process.stdout.write(s + "\n");
|
|
2821
|
+
let policy;
|
|
2822
|
+
try {
|
|
2823
|
+
policy = loadPolicy(cwd);
|
|
2824
|
+
} catch (e) {
|
|
2825
|
+
out(`verify: cannot load policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
2826
|
+
return 2;
|
|
2827
|
+
}
|
|
2828
|
+
const cmd = opts.cmd ?? policy.verify?.command;
|
|
2829
|
+
if (!cmd) {
|
|
2830
|
+
out("verify: no suite command \u2014 set policy `verify: { command: ... }` or pass --cmd");
|
|
2831
|
+
return 2;
|
|
2832
|
+
}
|
|
2833
|
+
const budget = opts.budget ?? policy.verify?.budget ?? 300;
|
|
2834
|
+
let base;
|
|
2835
|
+
try {
|
|
2836
|
+
base = resolveBase(opts.base ?? "HEAD", cwd);
|
|
2837
|
+
} catch {
|
|
2838
|
+
out(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
2839
|
+
return 2;
|
|
2840
|
+
}
|
|
2841
|
+
const work = mkdtempSync2(join9(tmpdir2(), "tw-verify-"));
|
|
2842
|
+
const visDir = join9(work, "visible");
|
|
2843
|
+
const priDir = join9(work, "pristine");
|
|
2844
|
+
let restored = 0;
|
|
2845
|
+
let kept = 0;
|
|
2846
|
+
try {
|
|
2847
|
+
mkdirSync6(visDir);
|
|
2848
|
+
mkdirSync6(priDir);
|
|
2849
|
+
materialize(cwd, visDir);
|
|
2850
|
+
materialize(cwd, priDir);
|
|
2851
|
+
[restored, kept] = overlayPristine(cwd, base, priDir, policy);
|
|
2852
|
+
} catch (e) {
|
|
2853
|
+
if (!opts.keep) rmSync2(work, { recursive: true, force: true });
|
|
2854
|
+
out(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
2855
|
+
return 2;
|
|
2856
|
+
}
|
|
2857
|
+
const visible = runSuite(visDir, cmd, budget);
|
|
2858
|
+
const pristine = runSuite(priDir, cmd, budget);
|
|
2859
|
+
if (!opts.keep) rmSync2(work, { recursive: true, force: true });
|
|
2860
|
+
let verdict2;
|
|
2861
|
+
let code;
|
|
2862
|
+
if (visible.exit === null || pristine.exit === null) {
|
|
2863
|
+
verdict2 = "BUDGET_EXCEEDED";
|
|
2864
|
+
code = 2;
|
|
2865
|
+
} else if (visible.exit === 0 && pristine.exit === 0) {
|
|
2866
|
+
verdict2 = "VERIFIED";
|
|
2867
|
+
code = 0;
|
|
2868
|
+
} else if (visible.exit === 0) {
|
|
2869
|
+
verdict2 = "MASKED_FAILURE";
|
|
2870
|
+
code = 1;
|
|
2871
|
+
} else {
|
|
2872
|
+
verdict2 = "SUITE_RED";
|
|
2873
|
+
code = 1;
|
|
2874
|
+
}
|
|
2875
|
+
if (opts.json) {
|
|
2876
|
+
out(
|
|
2877
|
+
JSON.stringify({
|
|
2878
|
+
verdict: verdict2,
|
|
2879
|
+
base,
|
|
2880
|
+
command: cmd,
|
|
2881
|
+
budget_secs: budget,
|
|
2882
|
+
visible: { exit: visible.exit, secs: visible.secs },
|
|
2883
|
+
pristine: { exit: pristine.exit, secs: pristine.secs },
|
|
2884
|
+
protected_restored: restored,
|
|
2885
|
+
added_protected_kept: kept,
|
|
2886
|
+
...opts.keep ? { workdir: work } : {}
|
|
2887
|
+
})
|
|
2888
|
+
);
|
|
2889
|
+
} else {
|
|
2890
|
+
const lines = {
|
|
2891
|
+
VERIFIED: `verified: the suite passes, and still passes with all ${restored} protected files restored from ${base.slice(0, 10)}.`,
|
|
2892
|
+
MASKED_FAILURE: `MASKED FAILURE: the visible suite passes, but with the ${restored} protected files restored from ${base.slice(0, 10)} it FAILS (exit ${pristine.exit}). Something weakened the checks; the code does not pass the original suite.`,
|
|
2893
|
+
SUITE_RED: `suite red: the visible suite fails (exit ${visible.exit}) \u2014 fix the code first (pristine exit ${pristine.exit}).`,
|
|
2894
|
+
BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
|
|
2895
|
+
};
|
|
2896
|
+
out(`tamperward verify \u2014 ${lines[verdict2]}`);
|
|
2897
|
+
if (kept > 0) out(`(${kept} protected file(s) added since ${base.slice(0, 10)} were kept in the pristine run.)`);
|
|
2898
|
+
}
|
|
2899
|
+
return code;
|
|
2900
|
+
}
|
|
2901
|
+
function parseVerify(args) {
|
|
2902
|
+
const o = {};
|
|
2903
|
+
for (let i = 0; i < args.length; i++) {
|
|
2904
|
+
const a = args[i];
|
|
2905
|
+
if (a === "--base" && args[i + 1]) o.base = args[++i];
|
|
2906
|
+
else if (a === "--cmd" && args[i + 1]) o.cmd = args[++i];
|
|
2907
|
+
else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
|
|
2908
|
+
else if (a === "--json") o.json = true;
|
|
2909
|
+
else if (a === "--keep") o.keep = true;
|
|
2910
|
+
else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
|
|
2911
|
+
}
|
|
2912
|
+
return o;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2362
2915
|
// src/cli/index.ts
|
|
2363
2916
|
function parseAllow(args) {
|
|
2364
2917
|
const o = {};
|
|
@@ -2431,6 +2984,14 @@ Formats:
|
|
|
2431
2984
|
auto github when GITHUB_ACTIONS=true, otherwise text.
|
|
2432
2985
|
tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
|
|
2433
2986
|
tamperward sweep claude Stop sweep (re-scan the turn's working tree)
|
|
2987
|
+
tamperward watch [--dir D] [--log F] filesystem-event observer daemon: records
|
|
2988
|
+
protected-file events so the sweep can
|
|
2989
|
+
observe supported transient effects
|
|
2990
|
+
tamperward verify [--base R] [--cmd C] pristine-suite re-execution: run the suite
|
|
2991
|
+
[--budget S] [--json] [--keep] as-is AND with protected files restored
|
|
2992
|
+
from the trusted base; a visible-green /
|
|
2993
|
+
pristine-red result is a MASKED FAILURE
|
|
2994
|
+
(exit 1); cannot-verify fails closed (2)
|
|
2434
2995
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
2435
2996
|
tamperward init [--dry-run] wire the policy file plus every
|
|
2436
2997
|
enforcement point: Claude Code hooks,
|
|
@@ -2441,8 +3002,8 @@ Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a
|
|
|
2441
3002
|
emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
|
|
2442
3003
|
`);
|
|
2443
3004
|
}
|
|
2444
|
-
function main(
|
|
2445
|
-
const [cmd, ...rest] =
|
|
3005
|
+
function main(argv2) {
|
|
3006
|
+
const [cmd, ...rest] = argv2;
|
|
2446
3007
|
switch (cmd) {
|
|
2447
3008
|
case "check":
|
|
2448
3009
|
return runCheck(parseCheck(rest));
|
|
@@ -2454,6 +3015,8 @@ function main(argv) {
|
|
|
2454
3015
|
return runAllow(parseAllow(rest));
|
|
2455
3016
|
case "init":
|
|
2456
3017
|
return runInit(parseInit(rest));
|
|
3018
|
+
case "verify":
|
|
3019
|
+
return runVerify(parseVerify(rest));
|
|
2457
3020
|
case void 0:
|
|
2458
3021
|
case "-h":
|
|
2459
3022
|
case "--help":
|
|
@@ -2466,4 +3029,9 @@ function main(argv) {
|
|
|
2466
3029
|
return 2;
|
|
2467
3030
|
}
|
|
2468
3031
|
}
|
|
2469
|
-
process.
|
|
3032
|
+
var argv = process.argv.slice(2);
|
|
3033
|
+
if (argv[0] === "watch") {
|
|
3034
|
+
runWatch(argv.slice(1));
|
|
3035
|
+
} else {
|
|
3036
|
+
process.exit(main(argv));
|
|
3037
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|