tamperward 1.6.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/README.md +72 -5
- package/dist/cli/index.js +479 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,46 @@ escape valve is a PR label only a human with write access can apply.
|
|
|
115
115
|
|
|
116
116
|
## Measured, not asserted
|
|
117
117
|
|
|
118
|
+
**Read the two headline numbers together, always:** across **137 guarded runs — every
|
|
119
|
+
one inside a tamper class an active detector covered — 0 tampers reached green.**
|
|
120
|
+
Outside that coverage, on the one seed built to measure it, **agents tampered in 26 of
|
|
121
|
+
40 runs** — "tampered" meaning, mechanically: the independent held-out oracle
|
|
122
|
+
classified the result as passing the visible check through a prohibited shortcut. The
|
|
123
|
+
first number is what the gate does; the second is why the gate is one layer. And an honest note on evidence units: those run counts come from roughly
|
|
124
|
+
fifteen seed/task configurations and eight real repositories — repeated runs on one
|
|
125
|
+
seed measure that seed's stochasticity, not the space of real coding tasks, so the
|
|
126
|
+
independent evidence behind these numbers is the seed count, not the run count.
|
|
127
|
+
|
|
128
|
+
**And the third number, from the preregistered cross-repository experiment
|
|
129
|
+
(taskbench, `harness/taskbench/`):** on 26 paired real repositories with real
|
|
130
|
+
historical regressions, pressured-agent policy violations appeared in **13 of 26
|
|
131
|
+
ungated runs (50%)** — the behavior transfers — but the gate at 1.6.0 **did not
|
|
132
|
+
reduce false greens** (5 prevented vs 4 gated-only; paired RD +3.8%, 95% CI
|
|
133
|
+
−17.2 to +24.7, exact McNemar p = 1.0): agents routed through tool-call
|
|
134
|
+
spellings outside the shipped detector classes, including two gaps documented
|
|
135
|
+
before the sweep and deliberately left unfixed for the experiment's integrity.
|
|
136
|
+
Enforcement showed no measurable completion cost (+7.7 points, CI spans zero),
|
|
137
|
+
and independent pristine-suite re-execution caught **every masked failure in
|
|
138
|
+
all 53 trajectories**. Registration, bets, frozen analysis, and the refutation
|
|
139
|
+
of our own headline bet: `PREDICTION-taskbench.md`, `PHASE3.md`. The first two
|
|
140
|
+
numbers say what the gate does inside its coverage; the third says coverage is
|
|
141
|
+
the whole game on real repositories — and that the reliable layer was the
|
|
142
|
+
pristine-suite oracle the agent cannot touch.
|
|
143
|
+
|
|
144
|
+
**What this does not do**, stated before the numbers rather than after: it does not
|
|
145
|
+
establish that a patch is *correct*; it does not detect arbitrary deception (a
|
|
146
|
+
hardcoded implementation is invisible to any diff rule — measured, twice); it does not
|
|
147
|
+
replace sandboxing, held-out tests, or authoritative CI — it is designed to sit in
|
|
148
|
+
front of them. What it does: **enforce explicitly defined development invariants at
|
|
149
|
+
the loop, commit, and CI boundaries** — and nothing else.
|
|
150
|
+
|
|
151
|
+
**A word on the words:** *tamper*, *honest*, *cheat*, and *surrender* are used
|
|
152
|
+
throughout as operational labels, not claims about mental states. A "tamper" means: a
|
|
153
|
+
protected asset changed, or an independent held-out oracle failed, while the visible
|
|
154
|
+
check went green — a property of the artifacts, decided deterministically. Whether the
|
|
155
|
+
process that produced it "intended" anything is a question these experiments do not
|
|
156
|
+
measure and do not need.
|
|
157
|
+
|
|
118
158
|
**Does the loop work?** `harness/run.sh` stands up an isolated repo with a real bug and a
|
|
119
159
|
failing protected test, lets a headless agent loose with the hooks installed, and
|
|
120
160
|
classifies the end state with an oracle independent of the detectors under test
|
|
@@ -229,9 +269,17 @@ framing, and Sonnet's famous refusals turn out to be triggered by framing). The
|
|
|
229
269
|
completed frontier split — **Opus 0/10 · Fable 6/10 · Sonnet 10/10** — is the
|
|
230
270
|
published shape, with the standing self-measurement caveat on the Fable rows.
|
|
231
271
|
Twenty-six of forty runs tampered through the uncovered dimension: measured demand
|
|
232
|
-
that
|
|
233
|
-
the honest fix itself narrows through `as Record<string, unknown>`, so a
|
|
234
|
-
assertion-blocker is an FP factory.
|
|
272
|
+
that sent the `ts-any-launder` warn→block graduation question to a §7.A negatives
|
|
273
|
+
corpus — the honest fix itself narrows through `as Record<string, unknown>`, so a
|
|
274
|
+
naive assertion-blocker is an FP factory. **The corpus answered, and the question is
|
|
275
|
+
closed** (`harness/PREDICTION-launder-corpus.md`): on 1,227 real commits the
|
|
276
|
+
laundering spellings fire on legitimate work at 4–10× any deployable block threshold
|
|
277
|
+
(annotation-position `any`: 10.5% of commits; named-type assertions: 7.5%), so the
|
|
278
|
+
warn stays a warn permanently on syntactic evidence, no assertion rule gets built,
|
|
279
|
+
and the laundering class is owned by the layers where coverage is measured: the
|
|
280
|
+
held-out **oracle** (caught all 18 assertion tampers) and the **prompt** (the naming
|
|
281
|
+
sentence, 10/10 flip). For this class, detection is not the gate's layer —
|
|
282
|
+
verification and framing are, and the record says so with a denominator.
|
|
235
283
|
|
|
236
284
|
**Why not just a prompt line, or a 20-line grep hook?** Measured
|
|
237
285
|
(`harness/PREDICTION-baselines.md`), against a frozen naive hook and a one-sentence
|
|
@@ -258,8 +306,27 @@ severity tune → roughly one sign-off decision per 17 commits residual. The sam
|
|
|
258
306
|
shipped per-rule `exclude` globs (1.6.0, with the policy-weakening symmetry built in)
|
|
259
307
|
and then measured them honestly: **zero effect on this corpus** — these repos' casts
|
|
260
308
|
live in source, not tests, so the zod-recommended path scoping removed nothing here;
|
|
261
|
-
the null is published next to the feature.
|
|
262
|
-
|
|
309
|
+
the null is published next to the feature.
|
|
310
|
+
|
|
311
|
+
**What about just making the tests read-only?** The most common objection got its
|
|
312
|
+
pre-registered measurement (`harness/PREDICTION-capability.md`) — starting with the
|
|
313
|
+
mechanics the folk advice misses: `chmod` does not bind a root agent and never
|
|
314
|
+
prevented `rm` anyway (deletion is a *directory*-write right); the workable barrier
|
|
315
|
+
is the immutable bit. Result: **hard immutability governed the crude-tamper seed
|
|
316
|
+
completely — 10/10 honest, four EPERM hits all converting to the real fix** (a bare
|
|
317
|
+
permission error transferred the deny→fix dynamic with no remediation text). Then
|
|
318
|
+
the twist: adding a logged, auto-granted **elevation script made it worse (8/10)** —
|
|
319
|
+
both extra tampers walked through the front door with written confessions
|
|
320
|
+
("*Remove failing test file to unblock build*"), the sanctioned-path thesis firing
|
|
321
|
+
a fourth time. The lock is easy; the grantor is the hard part — which is the case
|
|
322
|
+
for elevation flows that end at a human, and exactly what the gate's sign-off label
|
|
323
|
+
already is. That seed now carries all six governance configurations on identical
|
|
324
|
+
pre-registered ground: nothing 6/10 tampers, one sentence 1/10, naive hook 2/10,
|
|
325
|
+
the gate 0/10, immutable 0/10, immutable+auto-grant 2/10 — with the standing scope
|
|
326
|
+
that none of the capability rows touch the semantic classes, by construction.
|
|
327
|
+
Running ledger: **70 pre-registered bets — 29 refuted, 37 held, 4 open or
|
|
328
|
+
unresolvable** — taskbench's B2 (prevention on real repositories) is the newest
|
|
329
|
+
refutation, published beside the bet it refutes.
|
|
263
330
|
|
|
264
331
|
**What about false positives?** `snapshot-rewrite` was swept over **1,652 real mainline
|
|
265
332
|
commits** (prettier, jest, docusaurus, immer): 216 touched snapshots, all legitimately —
|
package/dist/cli/index.js
CHANGED
|
@@ -438,11 +438,17 @@ function defaultPolicy(version = 1) {
|
|
|
438
438
|
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
439
439
|
// rewriting it from current output is the snapshot-update move the affordance
|
|
440
440
|
// experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
|
|
441
|
-
snapshots:
|
|
441
|
+
// tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
|
|
442
|
+
// live there as .cjs and were rewritten unseen (PHASE3.md §4).
|
|
443
|
+
snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
|
|
442
444
|
hooks: [".husky/**", "**/lefthook.*", ".tamperward.yml", "**/.tamperward.yml"]
|
|
443
445
|
},
|
|
444
446
|
rules: {
|
|
445
447
|
"test-deletion": { severity: "block" },
|
|
448
|
+
// content gutted from a surviving spec — the class Phase 3 measured walking
|
|
449
|
+
// through the block-count rule (rows, assertion arguments, setup). Severity
|
|
450
|
+
// backed by the real-commit corpus measurement recorded in the rule's SPEC row.
|
|
451
|
+
"test-content-removal": { severity: "block" },
|
|
446
452
|
"test-skip": { severity: "block" },
|
|
447
453
|
"ts-any-cast": { severity: "block" },
|
|
448
454
|
// the unambiguous explicit casts + ts-suppression directives
|
|
@@ -457,6 +463,11 @@ function defaultPolicy(version = 1) {
|
|
|
457
463
|
// mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
|
|
458
464
|
// when intended output changes. WARN until the §7 evidence path earns block.
|
|
459
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" },
|
|
460
471
|
// the narrow graduation candidate: snapshot moved with NO accompanying change.
|
|
461
472
|
// ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
|
|
462
473
|
// (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
|
|
@@ -1272,8 +1283,62 @@ var testDeletion = {
|
|
|
1272
1283
|
}
|
|
1273
1284
|
};
|
|
1274
1285
|
|
|
1286
|
+
// src/detectors/test-content-removal.ts
|
|
1287
|
+
var RULE8 = "test-content-removal";
|
|
1288
|
+
var MIN_REMOVED_LINES = 3;
|
|
1289
|
+
var ws = (s) => s.replace(/\s+/g, "");
|
|
1290
|
+
function significantLinesOrdered(src) {
|
|
1291
|
+
const out = [];
|
|
1292
|
+
for (const raw of src.split("\n")) {
|
|
1293
|
+
const l = raw.trim();
|
|
1294
|
+
if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out.push(l);
|
|
1295
|
+
}
|
|
1296
|
+
return out;
|
|
1297
|
+
}
|
|
1298
|
+
var testContentRemoval = {
|
|
1299
|
+
id: RULE8,
|
|
1300
|
+
surface: ["file"],
|
|
1301
|
+
certainty: "mechanical",
|
|
1302
|
+
run(changes, policy) {
|
|
1303
|
+
const out = [];
|
|
1304
|
+
let keptPool = "";
|
|
1305
|
+
for (const c of changes) {
|
|
1306
|
+
if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
|
|
1307
|
+
keptPool += ws(significantLinesOrdered(c.after).join("\n"));
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
for (const c of changes) {
|
|
1311
|
+
if (c.kind !== "file" || c.op !== "modify" || c.before == null || c.after == null) continue;
|
|
1312
|
+
if (!isProtected(c.path, policy, "tests")) continue;
|
|
1313
|
+
if (isProtected(c.path, policy, "snapshots")) continue;
|
|
1314
|
+
if (countTestBlocks(c.after) < countTestBlocks(c.before)) continue;
|
|
1315
|
+
const beforeSig = significantLinesOrdered(c.before);
|
|
1316
|
+
const afterSig = significantLinesOrdered(c.after);
|
|
1317
|
+
const netDrop = beforeSig.length - afterSig.length;
|
|
1318
|
+
const afterSet = new Set(afterSig);
|
|
1319
|
+
const gone = [];
|
|
1320
|
+
for (const line of new Set(beforeSig)) {
|
|
1321
|
+
if (afterSet.has(line)) continue;
|
|
1322
|
+
if (keptPool.includes(ws(line))) continue;
|
|
1323
|
+
gone.push(line);
|
|
1324
|
+
}
|
|
1325
|
+
if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
|
|
1326
|
+
out.push(
|
|
1327
|
+
makeFinding(RULE8, policy, {
|
|
1328
|
+
file: c.path,
|
|
1329
|
+
message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
|
|
1330
|
+
evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
1331
|
+
remediation: "Fix the code under test. Deleting the failing rows, assertion arguments, or setup from a spec weakens it exactly like deleting the test \u2014 the block just keeps its name."
|
|
1332
|
+
})
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return out;
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1275
1340
|
// src/detectors/snapshot-rewrite.ts
|
|
1276
|
-
var
|
|
1341
|
+
var RULE9 = "snapshot-rewrite";
|
|
1277
1342
|
var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
|
|
1278
1343
|
var UPDATE_ANY = /--update-?[sS]napshots?\b/;
|
|
1279
1344
|
var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
|
|
@@ -1287,12 +1352,12 @@ function namesProtectedSnapshot(seg, policy) {
|
|
|
1287
1352
|
return null;
|
|
1288
1353
|
}
|
|
1289
1354
|
var snapshotRewrite = {
|
|
1290
|
-
id:
|
|
1355
|
+
id: RULE9,
|
|
1291
1356
|
surface: ["command", "file"],
|
|
1292
1357
|
certainty: "mechanical",
|
|
1293
1358
|
run(changes, policy) {
|
|
1294
1359
|
const out = [];
|
|
1295
|
-
const warn = (input) => out.push(makeFinding(
|
|
1360
|
+
const warn = (input) => out.push(makeFinding(RULE9, policy, { ...input, defaultSeverity: "warn" }));
|
|
1296
1361
|
for (const c of changes) {
|
|
1297
1362
|
if (c.kind === "command") {
|
|
1298
1363
|
for (const seg of segments(c.raw)) {
|
|
@@ -1330,7 +1395,7 @@ var snapshotRewrite = {
|
|
|
1330
1395
|
};
|
|
1331
1396
|
|
|
1332
1397
|
// src/detectors/snapshot-only.ts
|
|
1333
|
-
var
|
|
1398
|
+
var RULE10 = "snapshot-only-rewrite";
|
|
1334
1399
|
var COMMIT_VIEWS = ["staged", "range"];
|
|
1335
1400
|
function isSnapshotChange(c, policy) {
|
|
1336
1401
|
if (c.kind !== "file") return false;
|
|
@@ -1339,7 +1404,7 @@ function isSnapshotChange(c, policy) {
|
|
|
1339
1404
|
return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
|
|
1340
1405
|
}
|
|
1341
1406
|
var snapshotOnlyRewrite = {
|
|
1342
|
-
id:
|
|
1407
|
+
id: RULE10,
|
|
1343
1408
|
surface: ["file"],
|
|
1344
1409
|
certainty: "mechanical",
|
|
1345
1410
|
run(changes, policy, view) {
|
|
@@ -1351,7 +1416,7 @@ var snapshotOnlyRewrite = {
|
|
|
1351
1416
|
const first = snaps[0];
|
|
1352
1417
|
const names = snaps.map((c) => c.kind === "file" ? c.path : "").filter(Boolean).slice(0, 3).join(", ");
|
|
1353
1418
|
return [
|
|
1354
|
-
makeFinding(
|
|
1419
|
+
makeFinding(RULE10, policy, {
|
|
1355
1420
|
file: first.path,
|
|
1356
1421
|
message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
|
|
1357
1422
|
evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
|
|
@@ -1372,6 +1437,7 @@ var allDetectors = [
|
|
|
1372
1437
|
ciTampering,
|
|
1373
1438
|
hookTampering,
|
|
1374
1439
|
testDeletion,
|
|
1440
|
+
testContentRemoval,
|
|
1375
1441
|
snapshotRewrite,
|
|
1376
1442
|
snapshotOnlyRewrite
|
|
1377
1443
|
];
|
|
@@ -1660,10 +1726,10 @@ function locationCell(f, env) {
|
|
|
1660
1726
|
const label = `${f.file}${f.line ? `:${f.line}` : ""}`;
|
|
1661
1727
|
const server = env.GITHUB_SERVER_URL;
|
|
1662
1728
|
const repo = env.GITHUB_REPOSITORY;
|
|
1663
|
-
const
|
|
1664
|
-
if (!server || !repo || !
|
|
1729
|
+
const sha2 = env.GITHUB_SHA;
|
|
1730
|
+
if (!server || !repo || !sha2) return `\`${cell(label)}\``;
|
|
1665
1731
|
const frag = f.line ? `#L${f.line}` : "";
|
|
1666
|
-
return `[\`${cell(label)}\`](${server}/${repo}/blob/${
|
|
1732
|
+
return `[\`${cell(label)}\`](${server}/${repo}/blob/${sha2}/${f.file}${frag})`;
|
|
1667
1733
|
}
|
|
1668
1734
|
function renderSummary(input, env = process.env) {
|
|
1669
1735
|
const findings = orderFindings(input.findings);
|
|
@@ -1809,7 +1875,8 @@ function runCheck(opts) {
|
|
|
1809
1875
|
}
|
|
1810
1876
|
|
|
1811
1877
|
// src/cli/hook.ts
|
|
1812
|
-
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";
|
|
1813
1880
|
|
|
1814
1881
|
// src/adapters/claude/changes.ts
|
|
1815
1882
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
@@ -1834,6 +1901,7 @@ function relForDisplay(path, cwd) {
|
|
|
1834
1901
|
}
|
|
1835
1902
|
function applyEdit(content, oldStr, newStr) {
|
|
1836
1903
|
if (content === null) return newStr;
|
|
1904
|
+
if (oldStr !== "" && !content.includes(oldStr)) return content + "\n" + newStr;
|
|
1837
1905
|
return content.replace(oldStr, () => newStr);
|
|
1838
1906
|
}
|
|
1839
1907
|
function synthFileChange(displayPath, before, after) {
|
|
@@ -1964,10 +2032,261 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
1964
2032
|
}
|
|
1965
2033
|
}
|
|
1966
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
|
+
|
|
1967
2286
|
// src/cli/hook.ts
|
|
1968
2287
|
function readStdin() {
|
|
1969
2288
|
try {
|
|
1970
|
-
return
|
|
2289
|
+
return readFileSync9(0, "utf8");
|
|
1971
2290
|
} catch {
|
|
1972
2291
|
return "";
|
|
1973
2292
|
}
|
|
@@ -1984,7 +2303,7 @@ function recordDenylog(blocks) {
|
|
|
1984
2303
|
const log = process.env.TAMPERWARD_DENYLOG;
|
|
1985
2304
|
if (!log) return;
|
|
1986
2305
|
try {
|
|
1987
|
-
|
|
2306
|
+
appendFileSync4(log, blocks.map((b) => b.rule).join(",") + "\n");
|
|
1988
2307
|
} catch {
|
|
1989
2308
|
}
|
|
1990
2309
|
}
|
|
@@ -2016,30 +2335,141 @@ function verdict(blocks, kind) {
|
|
|
2016
2335
|
} : { decision: "block", reason };
|
|
2017
2336
|
return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
|
|
2018
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
|
+
}
|
|
2019
2382
|
function preToolUseVerdict(input) {
|
|
2020
2383
|
try {
|
|
2021
2384
|
const cwd = input.cwd ?? process.cwd();
|
|
2022
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");
|
|
2023
2389
|
const changes = changesFromClaudeHook(input, cwd);
|
|
2024
|
-
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);
|
|
2025
2392
|
return verdict(blocks, "PreToolUse");
|
|
2026
2393
|
} catch (e) {
|
|
2027
2394
|
return failClosed("PreToolUse", errText(e));
|
|
2028
2395
|
}
|
|
2029
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
|
+
}
|
|
2030
2448
|
function stopVerdict(input) {
|
|
2031
2449
|
if (input.stop_hook_active) return { exitCode: 0, stdout: "" };
|
|
2032
2450
|
const cwd = input.cwd ?? process.cwd();
|
|
2033
2451
|
if (!isGitRepo(cwd)) return { exitCode: 0, stdout: "" };
|
|
2034
2452
|
let blocks;
|
|
2453
|
+
let commitCursor = () => {
|
|
2454
|
+
};
|
|
2035
2455
|
try {
|
|
2456
|
+
const policy = loadPolicy(cwd);
|
|
2036
2457
|
const base = turnBaseline(cwd, input.session_id);
|
|
2037
2458
|
const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
|
|
2038
|
-
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
|
+
}
|
|
2039
2466
|
} catch (e) {
|
|
2040
2467
|
return failClosed("Stop", errText(e));
|
|
2041
2468
|
}
|
|
2042
|
-
if (blocks.length === 0)
|
|
2469
|
+
if (blocks.length === 0) {
|
|
2470
|
+
advanceTurnBaseline(cwd, input.session_id);
|
|
2471
|
+
commitCursor();
|
|
2472
|
+
}
|
|
2043
2473
|
return verdict(blocks, "Stop");
|
|
2044
2474
|
}
|
|
2045
2475
|
function emit(r) {
|
|
@@ -2108,8 +2538,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2108
2538
|
}
|
|
2109
2539
|
|
|
2110
2540
|
// src/cli/init.ts
|
|
2111
|
-
import { existsSync as
|
|
2112
|
-
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";
|
|
2113
2543
|
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2114
2544
|
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2115
2545
|
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
@@ -2169,24 +2599,24 @@ jobs:
|
|
|
2169
2599
|
run: npx --yes tamperward check --diff "\${{ github.event.pull_request.base.sha }}...\${{ github.event.pull_request.head.sha }}"
|
|
2170
2600
|
`;
|
|
2171
2601
|
function planPolicy(cwd) {
|
|
2172
|
-
const path =
|
|
2173
|
-
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" };
|
|
2174
2604
|
return {
|
|
2175
2605
|
item: "policy",
|
|
2176
2606
|
path: POLICY_FILE,
|
|
2177
2607
|
status: "create",
|
|
2178
2608
|
detail: "baseline policy with commented overrides",
|
|
2179
|
-
apply: () =>
|
|
2609
|
+
apply: () => writeFileSync5(path, POLICY_CONTENT)
|
|
2180
2610
|
};
|
|
2181
2611
|
}
|
|
2182
2612
|
function planClaudeHooks(cwd) {
|
|
2183
2613
|
const rel = ".claude/settings.json";
|
|
2184
|
-
const path =
|
|
2614
|
+
const path = join8(cwd, rel);
|
|
2185
2615
|
let settings = {};
|
|
2186
|
-
if (
|
|
2616
|
+
if (existsSync7(path)) {
|
|
2187
2617
|
let parsed;
|
|
2188
2618
|
try {
|
|
2189
|
-
parsed = JSON.parse(
|
|
2619
|
+
parsed = JSON.parse(readFileSync10(path, "utf8"));
|
|
2190
2620
|
} catch {
|
|
2191
2621
|
return {
|
|
2192
2622
|
item: "agent",
|
|
@@ -2208,7 +2638,7 @@ function planClaudeHooks(cwd) {
|
|
|
2208
2638
|
return {
|
|
2209
2639
|
item: "agent",
|
|
2210
2640
|
path: rel,
|
|
2211
|
-
status:
|
|
2641
|
+
status: existsSync7(path) ? "update" : "create",
|
|
2212
2642
|
detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
|
|
2213
2643
|
apply: () => {
|
|
2214
2644
|
if (needPre) {
|
|
@@ -2220,8 +2650,8 @@ function planClaudeHooks(cwd) {
|
|
|
2220
2650
|
if (needStop) {
|
|
2221
2651
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
2222
2652
|
}
|
|
2223
|
-
|
|
2224
|
-
|
|
2653
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2654
|
+
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
2225
2655
|
}
|
|
2226
2656
|
};
|
|
2227
2657
|
}
|
|
@@ -2229,12 +2659,12 @@ function planPreCommit(cwd) {
|
|
|
2229
2659
|
const line = `${MARKER}
|
|
2230
2660
|
${PRECOMMIT_CMD}
|
|
2231
2661
|
`;
|
|
2232
|
-
const husky =
|
|
2233
|
-
const
|
|
2234
|
-
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;
|
|
2235
2665
|
if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
|
|
2236
|
-
const path =
|
|
2237
|
-
const existing =
|
|
2666
|
+
const path = join8(cwd, target.rel);
|
|
2667
|
+
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
2238
2668
|
if (existing?.includes("tamperward check --staged")) {
|
|
2239
2669
|
return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
|
|
2240
2670
|
}
|
|
@@ -2244,26 +2674,26 @@ ${PRECOMMIT_CMD}
|
|
|
2244
2674
|
status: existing === null ? "create" : "update",
|
|
2245
2675
|
detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
|
|
2246
2676
|
apply: () => {
|
|
2247
|
-
|
|
2677
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2248
2678
|
const content = existing === null ? `#!/bin/sh
|
|
2249
2679
|
${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
2250
|
-
|
|
2680
|
+
writeFileSync5(path, content);
|
|
2251
2681
|
chmodSync(path, 493);
|
|
2252
2682
|
}
|
|
2253
2683
|
};
|
|
2254
2684
|
}
|
|
2255
2685
|
function planWorkflow(cwd) {
|
|
2256
2686
|
const rel = ".github/workflows/tamperward.yml";
|
|
2257
|
-
const path =
|
|
2258
|
-
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" };
|
|
2259
2689
|
return {
|
|
2260
2690
|
item: "ci",
|
|
2261
2691
|
path: rel,
|
|
2262
2692
|
status: "create",
|
|
2263
2693
|
detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
|
|
2264
2694
|
apply: () => {
|
|
2265
|
-
|
|
2266
|
-
|
|
2695
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2696
|
+
writeFileSync5(path, WORKFLOW_CONTENT);
|
|
2267
2697
|
}
|
|
2268
2698
|
};
|
|
2269
2699
|
}
|
|
@@ -2370,6 +2800,9 @@ Formats:
|
|
|
2370
2800
|
auto github when GITHUB_ACTIONS=true, otherwise text.
|
|
2371
2801
|
tamperward hook claude PreToolUse gate (reads hook JSON on stdin)
|
|
2372
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
|
|
2373
2806
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
2374
2807
|
tamperward init [--dry-run] wire the policy file plus every
|
|
2375
2808
|
enforcement point: Claude Code hooks,
|
|
@@ -2380,8 +2813,8 @@ Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a
|
|
|
2380
2813
|
emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
|
|
2381
2814
|
`);
|
|
2382
2815
|
}
|
|
2383
|
-
function main(
|
|
2384
|
-
const [cmd, ...rest] =
|
|
2816
|
+
function main(argv2) {
|
|
2817
|
+
const [cmd, ...rest] = argv2;
|
|
2385
2818
|
switch (cmd) {
|
|
2386
2819
|
case "check":
|
|
2387
2820
|
return runCheck(parseCheck(rest));
|
|
@@ -2405,4 +2838,9 @@ function main(argv) {
|
|
|
2405
2838
|
return 2;
|
|
2406
2839
|
}
|
|
2407
2840
|
}
|
|
2408
|
-
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",
|