tamperward 1.7.0 → 1.8.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 +411 -34
- 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).
|
|
@@ -1721,10 +1726,10 @@ function locationCell(f, env) {
|
|
|
1721
1726
|
const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
|
|
1722
1727
|
const server = env.GITHUB_SERVER_URL;
|
|
1723
1728
|
const repo = env.GITHUB_REPOSITORY;
|
|
1724
|
-
const
|
|
1725
|
-
if (!server || !repo || !
|
|
1729
|
+
const sha2 = env.GITHUB_SHA;
|
|
1730
|
+
if (!server || !repo || !sha2) return `\`${cell(label)}\``;
|
|
1726
1731
|
const frag = f.line ? `#L${f.line}` : "";
|
|
1727
|
-
return `[\`${cell(label)}\`](${server}/${repo}/blob/${
|
|
1732
|
+
return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha2}/${f.file}${frag})`;
|
|
1728
1733
|
}
|
|
1729
1734
|
function renderSummary(input, env = process.env) {
|
|
1730
1735
|
const findings = orderFindings(input.findings);
|
|
@@ -1870,7 +1875,8 @@ function runCheck(opts) {
|
|
|
1870
1875
|
}
|
|
1871
1876
|
|
|
1872
1877
|
// src/cli/hook.ts
|
|
1873
|
-
import { readFileSync as
|
|
1878
|
+
import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
|
|
1879
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1874
1880
|
|
|
1875
1881
|
// src/adapters/claude/changes.ts
|
|
1876
1882
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
@@ -1895,6 +1901,7 @@ function relForDisplay(path, cwd) {
|
|
|
1895
1901
|
}
|
|
1896
1902
|
function applyEdit(content, oldStr, newStr) {
|
|
1897
1903
|
if (content === null) return newStr;
|
|
1904
|
+
if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
|
|
1898
1905
|
return content.replace(oldStr, () => newStr);
|
|
1899
1906
|
}
|
|
1900
1907
|
function synthFileChange(displayPath, before, after) {
|
|
@@ -2025,10 +2032,261 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2025
2032
|
}
|
|
2026
2033
|
}
|
|
2027
2034
|
|
|
2035
|
+
// src/effect.ts
|
|
2036
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2037
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2038
|
+
import { join as join6 } from "node:path";
|
|
2039
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2040
|
+
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2041
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
2042
|
+
function gitDir2(cwd) {
|
|
2043
|
+
try {
|
|
2044
|
+
return execFileSync3("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf8" }).trim();
|
|
2045
|
+
} catch {
|
|
2046
|
+
return null;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
function ptreePath(cwd, sessionId) {
|
|
2050
|
+
if (!sessionId) return null;
|
|
2051
|
+
const gd = gitDir2(cwd);
|
|
2052
|
+
if (!gd) return null;
|
|
2053
|
+
const dir = join6(cwd, gd, "tamperward");
|
|
2054
|
+
return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
2055
|
+
}
|
|
2056
|
+
var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
|
|
2057
|
+
function snapshotProtected(cwd, policy, prev) {
|
|
2058
|
+
const out = {};
|
|
2059
|
+
const walk = (rel) => {
|
|
2060
|
+
const abs2 = rel ? join6(cwd, rel) : cwd;
|
|
2061
|
+
let names;
|
|
2062
|
+
try {
|
|
2063
|
+
names = readdirSync(abs2);
|
|
2064
|
+
} catch {
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
for (const name of names) {
|
|
2068
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
2069
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
2070
|
+
let st;
|
|
2071
|
+
try {
|
|
2072
|
+
st = statSync(join6(cwd, r));
|
|
2073
|
+
} catch {
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
if (st.isDirectory()) walk(r);
|
|
2077
|
+
else if (st.isFile() && isProtected(r, policy)) {
|
|
2078
|
+
const p = prev?.[r];
|
|
2079
|
+
if (p && p.mtimeMs === st.mtimeMs && p.size === st.size && p.mode === st.mode) {
|
|
2080
|
+
out[r] = p;
|
|
2081
|
+
} else {
|
|
2082
|
+
let hash = "";
|
|
2083
|
+
try {
|
|
2084
|
+
hash = sha(readFileSync6(join6(cwd, r)));
|
|
2085
|
+
} catch {
|
|
2086
|
+
hash = "unreadable";
|
|
2087
|
+
}
|
|
2088
|
+
out[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
walk("");
|
|
2094
|
+
return out;
|
|
2095
|
+
}
|
|
2096
|
+
function driftBetween(expected, current) {
|
|
2097
|
+
const changed = [];
|
|
2098
|
+
const deleted = [];
|
|
2099
|
+
for (const [path, e] of Object.entries(expected)) {
|
|
2100
|
+
const c = current[path];
|
|
2101
|
+
if (!c) deleted.push(path);
|
|
2102
|
+
else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
|
|
2103
|
+
}
|
|
2104
|
+
return { changed, deleted };
|
|
2105
|
+
}
|
|
2106
|
+
function loadPtree(cwd, sessionId) {
|
|
2107
|
+
const p = ptreePath(cwd, sessionId);
|
|
2108
|
+
if (!p || !existsSync5(p)) return null;
|
|
2109
|
+
try {
|
|
2110
|
+
return JSON.parse(readFileSync6(p, "utf8"));
|
|
2111
|
+
} catch {
|
|
2112
|
+
return null;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
function savePtree(cwd, sessionId, tree) {
|
|
2116
|
+
const p = ptreePath(cwd, sessionId);
|
|
2117
|
+
if (!p) return;
|
|
2118
|
+
try {
|
|
2119
|
+
mkdirSync3(join6(p, ".."), { recursive: true });
|
|
2120
|
+
writeFileSync3(p, JSON.stringify(tree));
|
|
2121
|
+
} catch {
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// src/cli/watch.ts
|
|
2126
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
|
|
2127
|
+
import { join as join7 } from "node:path";
|
|
2128
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
2129
|
+
var SKIP = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
2130
|
+
function snap(abs2) {
|
|
2131
|
+
try {
|
|
2132
|
+
const st = statSync2(abs2);
|
|
2133
|
+
if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
|
|
2134
|
+
const hash = createHash3("sha256").update(readFileSync7(abs2)).digest("hex").slice(0, 16);
|
|
2135
|
+
return { mode: st.mode, size: st.size, hash };
|
|
2136
|
+
} catch {
|
|
2137
|
+
return { mode: null, size: null, hash: null };
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
function defaultEventLog(cwd) {
|
|
2141
|
+
return process.env.TAMPERWARD_FSEVENTS ?? join7(cwd, ".git", "tamperward", "fsevents.jsonl");
|
|
2142
|
+
}
|
|
2143
|
+
function watchTree(dir, cb) {
|
|
2144
|
+
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
2145
|
+
try {
|
|
2146
|
+
const w = watch(dir, { recursive: true }, (kind, fname) => {
|
|
2147
|
+
if (fname) cb(kind, String(fname));
|
|
2148
|
+
});
|
|
2149
|
+
return { close: () => w.close() };
|
|
2150
|
+
} catch {
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
const watchers = /* @__PURE__ */ new Map();
|
|
2154
|
+
const addDir = (rel) => {
|
|
2155
|
+
if (watchers.has(rel) || SKIP.test(rel + "/")) return;
|
|
2156
|
+
let w;
|
|
2157
|
+
try {
|
|
2158
|
+
w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
|
|
2159
|
+
if (!fname) return;
|
|
2160
|
+
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
2161
|
+
cb(kind, child);
|
|
2162
|
+
try {
|
|
2163
|
+
if (statSync2(join7(dir, child)).isDirectory()) addDir(child);
|
|
2164
|
+
} catch {
|
|
2165
|
+
}
|
|
2166
|
+
});
|
|
2167
|
+
} catch {
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
watchers.set(rel, w);
|
|
2171
|
+
};
|
|
2172
|
+
const walk = (rel) => {
|
|
2173
|
+
addDir(rel);
|
|
2174
|
+
let names = [];
|
|
2175
|
+
try {
|
|
2176
|
+
names = readdirSync2(rel ? join7(dir, rel) : dir);
|
|
2177
|
+
} catch {
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
for (const name of names) {
|
|
2181
|
+
const child = rel ? `${rel}/${name}` : name;
|
|
2182
|
+
if (SKIP.test(child + "/")) continue;
|
|
2183
|
+
try {
|
|
2184
|
+
if (statSync2(join7(dir, child)).isDirectory()) walk(child);
|
|
2185
|
+
} catch {
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
};
|
|
2189
|
+
walk("");
|
|
2190
|
+
return { close: () => {
|
|
2191
|
+
for (const w of watchers.values()) w.close();
|
|
2192
|
+
} };
|
|
2193
|
+
}
|
|
2194
|
+
function startWatcher(dir, log, policy) {
|
|
2195
|
+
try {
|
|
2196
|
+
mkdirSync4(join7(log, ".."), { recursive: true });
|
|
2197
|
+
} catch {
|
|
2198
|
+
}
|
|
2199
|
+
const last = /* @__PURE__ */ new Map();
|
|
2200
|
+
return watchTree(dir, (kind, rel) => {
|
|
2201
|
+
if (SKIP.test(rel) || !isProtected(rel, policy)) return;
|
|
2202
|
+
const s = snap(join7(dir, rel));
|
|
2203
|
+
const key2 = `${s.hash}:${s.mode}`;
|
|
2204
|
+
if (last.get(rel) === key2) return;
|
|
2205
|
+
last.set(rel, key2);
|
|
2206
|
+
const ev = { ts: (/* @__PURE__ */ new Date()).toISOString(), path: rel, kind: kind === "rename" ? "rename" : "change", ...s };
|
|
2207
|
+
try {
|
|
2208
|
+
appendFileSync3(log, JSON.stringify(ev) + "\n");
|
|
2209
|
+
} catch {
|
|
2210
|
+
}
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
function runWatch(args) {
|
|
2214
|
+
let dir = process.cwd();
|
|
2215
|
+
let log = null;
|
|
2216
|
+
for (let i = 0; i < args.length; i++) {
|
|
2217
|
+
if (args[i] === "--dir" && args[i + 1]) dir = args[++i];
|
|
2218
|
+
else if (args[i] === "--log" && args[i + 1]) log = args[++i];
|
|
2219
|
+
}
|
|
2220
|
+
const policy = loadPolicy(dir);
|
|
2221
|
+
const out = log ?? defaultEventLog(dir);
|
|
2222
|
+
startWatcher(dir, out, policy);
|
|
2223
|
+
process.stdout.write(`tamperward watch: recording protected-file events under ${dir} -> ${out}
|
|
2224
|
+
`);
|
|
2225
|
+
return -1;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// src/detectors/fs-events.ts
|
|
2229
|
+
import { readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
|
|
2230
|
+
var RULE11 = "transient-protected-mutation";
|
|
2231
|
+
function readEvents(log, offset) {
|
|
2232
|
+
let size = 0;
|
|
2233
|
+
try {
|
|
2234
|
+
size = statSync3(log).size;
|
|
2235
|
+
} catch {
|
|
2236
|
+
return { events: [], newOffset: offset };
|
|
2237
|
+
}
|
|
2238
|
+
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
2239
|
+
const raw = readFileSync8(log, "utf8").slice(offset);
|
|
2240
|
+
const events = [];
|
|
2241
|
+
for (const line of raw.split("\n")) {
|
|
2242
|
+
if (!line.trim()) continue;
|
|
2243
|
+
try {
|
|
2244
|
+
events.push(JSON.parse(line));
|
|
2245
|
+
} catch {
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
return { events, newOffset: size };
|
|
2249
|
+
}
|
|
2250
|
+
function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
2251
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2252
|
+
for (const e of events) {
|
|
2253
|
+
if (!isProtected(e.path, policy)) continue;
|
|
2254
|
+
const arr = byPath.get(e.path) ?? [];
|
|
2255
|
+
arr.push(e);
|
|
2256
|
+
byPath.set(e.path, arr);
|
|
2257
|
+
}
|
|
2258
|
+
const out = [];
|
|
2259
|
+
const strict = process.env.TAMPERWARD_TRANSIENT === "block";
|
|
2260
|
+
for (const [path, evs] of byPath) {
|
|
2261
|
+
if (persistentPaths.has(path)) continue;
|
|
2262
|
+
const final = finalHash(path);
|
|
2263
|
+
const hashes = new Set(evs.map((e) => e.hash).filter((h) => h != null));
|
|
2264
|
+
if (final != null) hashes.add(final);
|
|
2265
|
+
const contentChurn = hashes.size > 1;
|
|
2266
|
+
const modes = new Set(evs.map((e) => e.mode).filter((m) => m != null));
|
|
2267
|
+
const modeChurn = modes.size > 1;
|
|
2268
|
+
if (!contentChurn && !modeChurn) continue;
|
|
2269
|
+
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 ")})`;
|
|
2270
|
+
const f = makeFinding(RULE11, policy, {
|
|
2271
|
+
file: path,
|
|
2272
|
+
message: `Transient protected mutation: ${what}, leaving no net diff.`,
|
|
2273
|
+
evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
|
|
2274
|
+
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.",
|
|
2275
|
+
defaultSeverity: "warn"
|
|
2276
|
+
});
|
|
2277
|
+
if (strict && f.severity !== "block") {
|
|
2278
|
+
f.severity = "block";
|
|
2279
|
+
f.signoff.required = true;
|
|
2280
|
+
}
|
|
2281
|
+
out.push(f);
|
|
2282
|
+
}
|
|
2283
|
+
return out;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2028
2286
|
// src/cli/hook.ts
|
|
2029
2287
|
function readStdin() {
|
|
2030
2288
|
try {
|
|
2031
|
-
return
|
|
2289
|
+
return readFileSync9(0, "utf8");
|
|
2032
2290
|
} catch {
|
|
2033
2291
|
return "";
|
|
2034
2292
|
}
|
|
@@ -2045,7 +2303,7 @@ function recordDenylog(blocks) {
|
|
|
2045
2303
|
const log = process.env.TAMPERWARD_DENYLOG;
|
|
2046
2304
|
if (!log) return;
|
|
2047
2305
|
try {
|
|
2048
|
-
|
|
2306
|
+
appendFileSync4(log, blocks.map((b) => b.rule).join(",") + "\n");
|
|
2049
2307
|
} catch {
|
|
2050
2308
|
}
|
|
2051
2309
|
}
|
|
@@ -2077,30 +2335,141 @@ function verdict(blocks, kind) {
|
|
|
2077
2335
|
} : { decision: "block", reason };
|
|
2078
2336
|
return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
|
|
2079
2337
|
}
|
|
2338
|
+
function effectDriftBlocks(cwd, sessionId, policy) {
|
|
2339
|
+
if (!sessionId || !isGitRepo(cwd)) return null;
|
|
2340
|
+
const expected = loadPtree(cwd, sessionId);
|
|
2341
|
+
const current = snapshotProtected(cwd, policy, expected ?? void 0);
|
|
2342
|
+
if (!expected) {
|
|
2343
|
+
savePtree(cwd, sessionId, current);
|
|
2344
|
+
return null;
|
|
2345
|
+
}
|
|
2346
|
+
const d = driftBetween(expected, current);
|
|
2347
|
+
if (d.changed.length === 0 && d.deleted.length === 0) {
|
|
2348
|
+
savePtree(cwd, sessionId, current);
|
|
2349
|
+
return null;
|
|
2350
|
+
}
|
|
2351
|
+
const base = turnBaseline(cwd, sessionId);
|
|
2352
|
+
const all = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
|
|
2353
|
+
const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
|
|
2354
|
+
const scoped = all.filter(
|
|
2355
|
+
(c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
|
|
2356
|
+
);
|
|
2357
|
+
const blocks = evaluate(scoped, policy, void 0, "turn").filter((f) => f.severity === "block");
|
|
2358
|
+
if (blocks.length > 0) return blocks;
|
|
2359
|
+
savePtree(cwd, sessionId, current);
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
function sanctionPredictedWrites(cwd, sessionId, policy, changes) {
|
|
2363
|
+
if (!sessionId) return;
|
|
2364
|
+
const tree = loadPtree(cwd, sessionId);
|
|
2365
|
+
if (!tree) return;
|
|
2366
|
+
let dirty = false;
|
|
2367
|
+
for (const c of changes) {
|
|
2368
|
+
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy)) continue;
|
|
2369
|
+
const hash = createHash4("sha256").update(c.after).digest("hex").slice(0, 16);
|
|
2370
|
+
const prev = tree[c.path];
|
|
2371
|
+
tree[c.path] = {
|
|
2372
|
+
hash,
|
|
2373
|
+
mode: prev?.mode ?? 33188,
|
|
2374
|
+
size: Buffer.byteLength(c.after),
|
|
2375
|
+
mtimeMs: -1
|
|
2376
|
+
// unknown until written; forces a re-hash next snapshot, which is correct
|
|
2377
|
+
};
|
|
2378
|
+
dirty = true;
|
|
2379
|
+
}
|
|
2380
|
+
if (dirty) savePtree(cwd, sessionId, tree);
|
|
2381
|
+
}
|
|
2080
2382
|
function preToolUseVerdict(input) {
|
|
2081
2383
|
try {
|
|
2082
2384
|
const cwd = input.cwd ?? process.cwd();
|
|
2083
2385
|
turnBaseline(cwd, input.session_id);
|
|
2386
|
+
const policy = loadPolicy(cwd);
|
|
2387
|
+
const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
|
|
2388
|
+
if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
|
|
2084
2389
|
const changes = changesFromClaudeHook(input, cwd);
|
|
2085
|
-
const blocks = evaluate(changes,
|
|
2390
|
+
const blocks = evaluate(changes, policy, void 0, "tool-call").filter((f) => f.severity === "block");
|
|
2391
|
+
if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
|
|
2086
2392
|
return verdict(blocks, "PreToolUse");
|
|
2087
2393
|
} catch (e) {
|
|
2088
2394
|
return failClosed("PreToolUse", errText(e));
|
|
2089
2395
|
}
|
|
2090
2396
|
}
|
|
2397
|
+
function cursorPath(cwd, sessionId) {
|
|
2398
|
+
const p = ptreePath(cwd, sessionId);
|
|
2399
|
+
return p ? p.replace(/ptree-/, "fscursor-") : null;
|
|
2400
|
+
}
|
|
2401
|
+
function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
2402
|
+
const none = { blocks: [], commit: () => {
|
|
2403
|
+
} };
|
|
2404
|
+
const log = defaultEventLog(cwd);
|
|
2405
|
+
const cp = cursorPath(cwd, sessionId);
|
|
2406
|
+
if (!existsSync6(log)) return none;
|
|
2407
|
+
let offset = 0;
|
|
2408
|
+
if (cp && existsSync6(cp)) {
|
|
2409
|
+
const n = Number(readFileSync9(cp, "utf8"));
|
|
2410
|
+
offset = Number.isFinite(n) ? n : 0;
|
|
2411
|
+
}
|
|
2412
|
+
const { events, newOffset } = readEvents(log, offset);
|
|
2413
|
+
if (events.length === 0) return { ...none, commit: () => {
|
|
2414
|
+
if (cp) try {
|
|
2415
|
+
writeFileSync4(cp, String(newOffset));
|
|
2416
|
+
} catch {
|
|
2417
|
+
}
|
|
2418
|
+
} };
|
|
2419
|
+
const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
|
|
2420
|
+
const finalHash = (path) => {
|
|
2421
|
+
try {
|
|
2422
|
+
if (!statSync4(`${cwd}/${path}`).isFile()) return null;
|
|
2423
|
+
return createHash4("sha256").update(readFileSync9(`${cwd}/${path}`)).digest("hex").slice(0, 16);
|
|
2424
|
+
} catch {
|
|
2425
|
+
return null;
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
const findings = transientFindings(events, persistent, policy, finalHash);
|
|
2429
|
+
recordWarns(findings.filter((f) => f.severity !== "block"));
|
|
2430
|
+
return {
|
|
2431
|
+
blocks: findings.filter((f) => f.severity === "block"),
|
|
2432
|
+
commit: () => {
|
|
2433
|
+
if (cp) try {
|
|
2434
|
+
writeFileSync4(cp, String(newOffset));
|
|
2435
|
+
} catch {
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
function recordWarns(warns) {
|
|
2441
|
+
const log = process.env.TAMPERWARD_DENYLOG;
|
|
2442
|
+
if (!log || warns.length === 0) return;
|
|
2443
|
+
try {
|
|
2444
|
+
appendFileSync4(log, warns.map((w) => `warn:${w.rule}:${w.file ?? ""}`).join("\n") + "\n");
|
|
2445
|
+
} catch {
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2091
2448
|
function stopVerdict(input) {
|
|
2092
2449
|
if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
|
|
2093
2450
|
const cwd = input.cwd ?? process.cwd();
|
|
2094
2451
|
if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
|
|
2095
2452
|
let blocks;
|
|
2453
|
+
let commitCursor = () => {
|
|
2454
|
+
};
|
|
2096
2455
|
try {
|
|
2456
|
+
const policy = loadPolicy(cwd);
|
|
2097
2457
|
const base = turnBaseline(cwd, input.session_id);
|
|
2098
2458
|
const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
|
|
2099
|
-
blocks = evaluate(changes,
|
|
2459
|
+
blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block");
|
|
2460
|
+
const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
|
|
2461
|
+
blocks = blocks.concat(transient.blocks);
|
|
2462
|
+
commitCursor = transient.commit;
|
|
2463
|
+
if (blocks.length === 0 && input.session_id) {
|
|
2464
|
+
savePtree(cwd, input.session_id, snapshotProtected(cwd, policy, loadPtree(cwd, input.session_id) ?? void 0));
|
|
2465
|
+
}
|
|
2100
2466
|
} catch (e) {
|
|
2101
2467
|
return failClosed("Stop", errText(e));
|
|
2102
2468
|
}
|
|
2103
|
-
if (blocks.length === 0)
|
|
2469
|
+
if (blocks.length === 0) {
|
|
2470
|
+
advanceTurnBaseline(cwd, input.session_id);
|
|
2471
|
+
commitCursor();
|
|
2472
|
+
}
|
|
2104
2473
|
return verdict(blocks, "Stop");
|
|
2105
2474
|
}
|
|
2106
2475
|
function emit(r) {
|
|
@@ -2169,8 +2538,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2169
2538
|
}
|
|
2170
2539
|
|
|
2171
2540
|
// src/cli/init.ts
|
|
2172
|
-
import { existsSync as
|
|
2173
|
-
import { dirname as dirname3, join as
|
|
2541
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2542
|
+
import { dirname as dirname3, join as join8 } from "node:path";
|
|
2174
2543
|
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2175
2544
|
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2176
2545
|
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
@@ -2230,24 +2599,24 @@ jobs:
|
|
|
2230
2599
|
run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
|
|
2231
2600
|
`;
|
|
2232
2601
|
function planPolicy(cwd) {
|
|
2233
|
-
const path =
|
|
2234
|
-
if (
|
|
2602
|
+
const path = join8(cwd, POLICY_FILE);
|
|
2603
|
+
if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
|
|
2235
2604
|
return {
|
|
2236
2605
|
item: "policy",
|
|
2237
2606
|
path: POLICY_FILE,
|
|
2238
2607
|
status: "create",
|
|
2239
2608
|
detail: "baseline policy with commented overrides",
|
|
2240
|
-
apply: () =>
|
|
2609
|
+
apply: () => writeFileSync5(path, POLICY_CONTENT)
|
|
2241
2610
|
};
|
|
2242
2611
|
}
|
|
2243
2612
|
function planClaudeHooks(cwd) {
|
|
2244
2613
|
const rel = ".claude/settings.json";
|
|
2245
|
-
const path =
|
|
2614
|
+
const path = join8(cwd, rel);
|
|
2246
2615
|
let settings = {};
|
|
2247
|
-
if (
|
|
2616
|
+
if (existsSync7(path)) {
|
|
2248
2617
|
let parsed;
|
|
2249
2618
|
try {
|
|
2250
|
-
parsed = JSON.parse(
|
|
2619
|
+
parsed = JSON.parse(readFileSync10(path, "utf8"));
|
|
2251
2620
|
} catch {
|
|
2252
2621
|
return {
|
|
2253
2622
|
item: "agent",
|
|
@@ -2269,7 +2638,7 @@ function planClaudeHooks(cwd) {
|
|
|
2269
2638
|
return {
|
|
2270
2639
|
item: "agent",
|
|
2271
2640
|
path: rel,
|
|
2272
|
-
status:
|
|
2641
|
+
status: existsSync7(path) ? "update" : "create",
|
|
2273
2642
|
detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
|
|
2274
2643
|
apply: () => {
|
|
2275
2644
|
if (needPre) {
|
|
@@ -2281,8 +2650,8 @@ function planClaudeHooks(cwd) {
|
|
|
2281
2650
|
if (needStop) {
|
|
2282
2651
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
2283
2652
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2653
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2654
|
+
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
2286
2655
|
}
|
|
2287
2656
|
};
|
|
2288
2657
|
}
|
|
@@ -2290,12 +2659,12 @@ function planPreCommit(cwd) {
|
|
|
2290
2659
|
const line = `${MARKER}
|
|
2291
2660
|
${PRECOMMIT_CMD}
|
|
2292
2661
|
`;
|
|
2293
|
-
const husky =
|
|
2294
|
-
const
|
|
2295
|
-
const target =
|
|
2662
|
+
const husky = join8(cwd, ".husky");
|
|
2663
|
+
const gitDir3 = join8(cwd, ".git");
|
|
2664
|
+
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
2665
|
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 =
|
|
2666
|
+
const path = join8(cwd, target.rel);
|
|
2667
|
+
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
2299
2668
|
if (existing?.includes("tamperward check --staged")) {
|
|
2300
2669
|
return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
|
|
2301
2670
|
}
|
|
@@ -2305,26 +2674,26 @@ ${PRECOMMIT_CMD}
|
|
|
2305
2674
|
status: existing === null ? "create" : "update",
|
|
2306
2675
|
detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
|
|
2307
2676
|
apply: () => {
|
|
2308
|
-
|
|
2677
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2309
2678
|
const content = existing === null ? `#!/bin/sh
|
|
2310
2679
|
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
2311
|
-
|
|
2680
|
+
writeFileSync5(path, content);
|
|
2312
2681
|
chmodSync(path, 493);
|
|
2313
2682
|
}
|
|
2314
2683
|
};
|
|
2315
2684
|
}
|
|
2316
2685
|
function planWorkflow(cwd) {
|
|
2317
2686
|
const rel = ".github/workflows/tamperward.yml";
|
|
2318
|
-
const path =
|
|
2319
|
-
if (
|
|
2687
|
+
const path = join8(cwd, rel);
|
|
2688
|
+
if (existsSync7(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
|
|
2320
2689
|
return {
|
|
2321
2690
|
item: "ci",
|
|
2322
2691
|
path: rel,
|
|
2323
2692
|
status: "create",
|
|
2324
2693
|
detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
|
|
2325
2694
|
apply: () => {
|
|
2326
|
-
|
|
2327
|
-
|
|
2695
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2696
|
+
writeFileSync5(path, WORKFLOW_CONTENT);
|
|
2328
2697
|
}
|
|
2329
2698
|
};
|
|
2330
2699
|
}
|
|
@@ -2431,6 +2800,9 @@ Formats:
|
|
|
2431
2800
|
auto github when GITHUB_ACTIONS=true, otherwise text.
|
|
2432
2801
|
tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
|
|
2433
2802
|
tamperward sweep claude Stop sweep (re-scan the turn's working tree)
|
|
2803
|
+
tamperward watch [--dir D] [--log F] filesystem-event observer daemon: records
|
|
2804
|
+
protected-file events so the sweep can
|
|
2805
|
+
observe supported transient effects
|
|
2434
2806
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
2435
2807
|
tamperward init [--dry-run] wire the policy file plus every
|
|
2436
2808
|
enforcement point: Claude Code hooks,
|
|
@@ -2441,8 +2813,8 @@ Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a
|
|
|
2441
2813
|
emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
|
|
2442
2814
|
`);
|
|
2443
2815
|
}
|
|
2444
|
-
function main(
|
|
2445
|
-
const [cmd, ...rest] =
|
|
2816
|
+
function main(argv2) {
|
|
2817
|
+
const [cmd, ...rest] = argv2;
|
|
2446
2818
|
switch (cmd) {
|
|
2447
2819
|
case "check":
|
|
2448
2820
|
return runCheck(parseCheck(rest));
|
|
@@ -2466,4 +2838,9 @@ function main(argv) {
|
|
|
2466
2838
|
return 2;
|
|
2467
2839
|
}
|
|
2468
2840
|
}
|
|
2469
|
-
process.
|
|
2841
|
+
var argv = process.argv.slice(2);
|
|
2842
|
+
if (argv[0] === "watch") {
|
|
2843
|
+
runWatch(argv.slice(1));
|
|
2844
|
+
} else {
|
|
2845
|
+
process.exit(main(argv));
|
|
2846
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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",
|