tamperward 2.7.1 → 2.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 +796 -466
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2,9 +2,376 @@
|
|
|
2
2
|
|
|
3
3
|
// src/git/build.ts
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
// src/disk.ts
|
|
7
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync } from "node:fs";
|
|
6
8
|
import { join } from "node:path";
|
|
7
9
|
|
|
10
|
+
// src/policy.ts
|
|
11
|
+
import picomatch from "picomatch";
|
|
12
|
+
var POLICY_FILE = ".tamperward.yml";
|
|
13
|
+
var BLOCK_SINCE = {};
|
|
14
|
+
function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
15
|
+
const out2 = { ...rules };
|
|
16
|
+
for (const [rule, since] of Object.entries(gates)) {
|
|
17
|
+
if (since > version && out2[rule]?.severity === "block") {
|
|
18
|
+
out2[rule] = { ...out2[rule], severity: "warn" };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out2;
|
|
22
|
+
}
|
|
23
|
+
function normalizeGlob(glob) {
|
|
24
|
+
return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
|
|
25
|
+
}
|
|
26
|
+
function mergeRules(base, user) {
|
|
27
|
+
const out2 = { ...base };
|
|
28
|
+
for (const [name, cfg] of Object.entries(user ?? {})) {
|
|
29
|
+
out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
|
|
30
|
+
}
|
|
31
|
+
return out2;
|
|
32
|
+
}
|
|
33
|
+
var CONTROL = /[\u0000-\u001f\u007f]/;
|
|
34
|
+
function hasControlChar(path) {
|
|
35
|
+
return CONTROL.test(path);
|
|
36
|
+
}
|
|
37
|
+
function escapeControl(path) {
|
|
38
|
+
return path.replace(/[\u0000-\u001f\u007f]/g, (ch) => {
|
|
39
|
+
const c = ch.charCodeAt(0);
|
|
40
|
+
if (c === 10) return "\\n";
|
|
41
|
+
if (c === 13) return "\\r";
|
|
42
|
+
if (c === 9) return "\\t";
|
|
43
|
+
return `\\x${c.toString(16).padStart(2, "0")}`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
var listCache = /* @__PURE__ */ new WeakMap();
|
|
47
|
+
function listMatcher(globs4) {
|
|
48
|
+
let re = listCache.get(globs4);
|
|
49
|
+
if (re === void 0) {
|
|
50
|
+
re = globs4.length === 0 ? null : new RegExp(globs4.map((g) => `(?:${picomatch.makeRe(g, { dot: true }).source})`).join("|"));
|
|
51
|
+
listCache.set(globs4, re);
|
|
52
|
+
}
|
|
53
|
+
return re;
|
|
54
|
+
}
|
|
55
|
+
function matchesAny(path, globs4) {
|
|
56
|
+
if (!globs4) return false;
|
|
57
|
+
const re = listMatcher(globs4);
|
|
58
|
+
return re !== null && re.test(path);
|
|
59
|
+
}
|
|
60
|
+
function protectedCategory(path, policy) {
|
|
61
|
+
const cats = Object.entries(policy.protected ?? {});
|
|
62
|
+
if (hasControlChar(path)) return cats[0]?.[0] ?? "tests";
|
|
63
|
+
for (const [cat, globs4] of cats) {
|
|
64
|
+
if (matchesAny(path, globs4)) return cat;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
var anyCache = /* @__PURE__ */ new WeakMap();
|
|
69
|
+
function allProtectedGlobs(policy) {
|
|
70
|
+
const p = policy.protected ?? {};
|
|
71
|
+
let all = anyCache.get(p);
|
|
72
|
+
if (!all) {
|
|
73
|
+
all = Object.values(p).flat();
|
|
74
|
+
anyCache.set(p, all);
|
|
75
|
+
}
|
|
76
|
+
return all;
|
|
77
|
+
}
|
|
78
|
+
function isProtected(path, policy, category) {
|
|
79
|
+
if (hasControlChar(path)) return true;
|
|
80
|
+
if (category) return matchesAny(path, policy.protected?.[category]);
|
|
81
|
+
return matchesAny(path, allProtectedGlobs(policy));
|
|
82
|
+
}
|
|
83
|
+
function isPolicyFile(path) {
|
|
84
|
+
return path === POLICY_FILE || path.endsWith("/" + POLICY_FILE);
|
|
85
|
+
}
|
|
86
|
+
function isIgnored(path, policy) {
|
|
87
|
+
return matchesAny(path, policy.ignore);
|
|
88
|
+
}
|
|
89
|
+
function mergeProtected(base, user) {
|
|
90
|
+
const out2 = { ...base };
|
|
91
|
+
for (const [cat, globs4] of Object.entries(user ?? {})) {
|
|
92
|
+
const have = new Set(out2[cat] ?? []);
|
|
93
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
|
|
94
|
+
}
|
|
95
|
+
return out2;
|
|
96
|
+
}
|
|
97
|
+
function defaultPolicy(version = 1) {
|
|
98
|
+
const p = {
|
|
99
|
+
version,
|
|
100
|
+
protected: {
|
|
101
|
+
// Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
|
|
102
|
+
// .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
|
|
103
|
+
// instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
|
|
104
|
+
// strengthening, not a narrowing. picomatch expands the brace.
|
|
105
|
+
// test-skip, test-deletion and test-content-removal all gate on this
|
|
106
|
+
// class, so a repo it does not match gets rules that can NEVER fire —
|
|
107
|
+
// protection that is silently absent rather than visibly off. The JS/TS
|
|
108
|
+
// list shipped alone; these are the conventional layouts of the other
|
|
109
|
+
// ecosystems `init` is run in. (P2-13, external review.)
|
|
110
|
+
tests: [
|
|
111
|
+
// JavaScript / TypeScript
|
|
112
|
+
"**/*.test.ts",
|
|
113
|
+
"**/*.spec.ts",
|
|
114
|
+
"**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}",
|
|
115
|
+
"**/__tests__/**",
|
|
116
|
+
// Python (pytest / unittest layouts)
|
|
117
|
+
"**/test_*.py",
|
|
118
|
+
"**/*_test.py",
|
|
119
|
+
"**/conftest.py",
|
|
120
|
+
// Go
|
|
121
|
+
"**/*_test.go",
|
|
122
|
+
// Rust (integration tests live in tests/, unit tests inline)
|
|
123
|
+
"**/tests/**/*.rs",
|
|
124
|
+
// Ruby (RSpec / minitest)
|
|
125
|
+
"**/*_spec.rb",
|
|
126
|
+
"**/*_test.rb",
|
|
127
|
+
// Java / Kotlin / Scala (Maven & Gradle convention)
|
|
128
|
+
"**/src/test/**",
|
|
129
|
+
// PHP (PHPUnit)
|
|
130
|
+
"**/*Test.php",
|
|
131
|
+
// C# (.NET)
|
|
132
|
+
"**/*Tests.cs",
|
|
133
|
+
"**/*Test.cs"
|
|
134
|
+
],
|
|
135
|
+
config: [
|
|
136
|
+
"**/jest.config.*",
|
|
137
|
+
"**/vitest.config.*",
|
|
138
|
+
// A `vitest.workspace.*` file REPLACES the root config's selection with the
|
|
139
|
+
// union of its projects, and a `vite.config.*` carrying a `test:` key is the
|
|
140
|
+
// vitest config when no vitest.config exists — both are where a spec can be
|
|
141
|
+
// dropped from the suite without a line of it changing. ADDITIVE, like every
|
|
142
|
+
// baseline widening (the policy-diff reads it as a strengthening).
|
|
143
|
+
"**/vitest.workspace.*",
|
|
144
|
+
"**/vite.config.*",
|
|
145
|
+
"**/tsconfig*.json",
|
|
146
|
+
"**/.eslintrc*",
|
|
147
|
+
"**/eslint.config.*",
|
|
148
|
+
"**/package.json",
|
|
149
|
+
// Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
|
|
150
|
+
// see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
|
|
151
|
+
// codecov `target` lived in files no glob protected, so lowering them fired
|
|
152
|
+
// nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
|
|
153
|
+
// as a strengthening). pyproject.toml carries [tool.coverage.report].
|
|
154
|
+
"**/.coveragerc",
|
|
155
|
+
"**/pyproject.toml",
|
|
156
|
+
"**/.nycrc",
|
|
157
|
+
"**/.nycrc.*",
|
|
158
|
+
"**/nyc.config.*",
|
|
159
|
+
"**/codecov.yml",
|
|
160
|
+
"**/.codecov.yml"
|
|
161
|
+
],
|
|
162
|
+
ci: [".github/workflows/**"],
|
|
163
|
+
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
164
|
+
// rewriting it from current output is the snapshot-update move the affordance
|
|
165
|
+
// experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
|
|
166
|
+
// tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
|
|
167
|
+
// live there as .cjs and were rewritten unseen (PHASE3.md §4).
|
|
168
|
+
snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
|
|
169
|
+
// The wiring IS the enforcement: settings.json carries the PreToolUse and
|
|
170
|
+
// Stop hooks, and a pre-commit config carries the commit-time gate. These
|
|
171
|
+
// were installed by `init` and protected by nothing, so editing them out
|
|
172
|
+
// fired no rule at all. (.git/hooks/** is outside every git view and so
|
|
173
|
+
// cannot be covered here — noted in SECURITY-ENVELOPE.md.) (P1-4, review.)
|
|
174
|
+
hooks: [
|
|
175
|
+
".husky/**",
|
|
176
|
+
"**/lefthook.*",
|
|
177
|
+
// lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
|
|
178
|
+
// there switches the gate off without touching the file init pointed at.
|
|
179
|
+
"**/lefthook-local.*",
|
|
180
|
+
".tamperward.yml",
|
|
181
|
+
"**/.tamperward.yml",
|
|
182
|
+
".claude/settings.json",
|
|
183
|
+
".claude/settings.local.json",
|
|
184
|
+
"**/.pre-commit-config.yaml",
|
|
185
|
+
// CODEOWNERS is enforcement wiring, not documentation: it is the only
|
|
186
|
+
// thing that can require a human on a change to the workflow that
|
|
187
|
+
// decides whether the gate runs at all. GitHub reads it from any of
|
|
188
|
+
// these three locations. (H2.)
|
|
189
|
+
".github/CODEOWNERS",
|
|
190
|
+
"CODEOWNERS",
|
|
191
|
+
"docs/CODEOWNERS"
|
|
192
|
+
]
|
|
193
|
+
},
|
|
194
|
+
rules: {
|
|
195
|
+
"test-deletion": { severity: "block" },
|
|
196
|
+
// content gutted from a surviving spec — the class Phase 3 measured walking
|
|
197
|
+
// through the block-count rule (rows, assertion arguments, setup). Severity
|
|
198
|
+
// backed by the real-commit corpus measurement recorded in the rule's SPEC row.
|
|
199
|
+
"test-content-removal": { severity: "block" },
|
|
200
|
+
"test-skip": { severity: "block" },
|
|
201
|
+
"ts-any-cast": { severity: "block" },
|
|
202
|
+
// the unambiguous explicit casts + ts-suppression directives
|
|
203
|
+
// broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
|
|
204
|
+
// block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
|
|
205
|
+
"ts-any-launder": { severity: "warn" },
|
|
206
|
+
"lint-suppression": { severity: "block" },
|
|
207
|
+
"coverage-lowering": { severity: "block" },
|
|
208
|
+
"ci-tampering": { severity: "block" },
|
|
209
|
+
"hook-tampering": { severity: "block" },
|
|
210
|
+
"no-verify": { severity: "block" },
|
|
211
|
+
// mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
|
|
212
|
+
// when intended output changes. WARN until the §7 evidence path earns block.
|
|
213
|
+
"snapshot-rewrite": { severity: "warn" },
|
|
214
|
+
// the watcher's transient class (modified-and-restored within a turn). WARN by
|
|
215
|
+
// default: a Stop block already forces restores, and a compliant restore reads
|
|
216
|
+
// as churn — block-by-default would deadlock the honest path. Harnesses that own
|
|
217
|
+
// that semantics raise it via TAMPERWARD_TRANSIENT=block.
|
|
218
|
+
"transient-protected-mutation": { severity: "warn" },
|
|
219
|
+
// the narrow graduation candidate: snapshot moved with NO accompanying change.
|
|
220
|
+
// ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
|
|
221
|
+
// (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
|
|
222
|
+
"snapshot-only-rewrite": { severity: "warn" },
|
|
223
|
+
// heuristic — warn until precision is measured (SPEC §7)
|
|
224
|
+
"assertion-weakening": { severity: "warn" },
|
|
225
|
+
"guard-removal": { severity: "warn" }
|
|
226
|
+
},
|
|
227
|
+
ignore: [],
|
|
228
|
+
signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
|
|
229
|
+
};
|
|
230
|
+
p.rules = applyVersionGates(p.rules, version);
|
|
231
|
+
return p;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/disk.ts
|
|
235
|
+
var READ_CAP = 64 * 1024 * 1024;
|
|
236
|
+
var NO_META = { mode: 0, size: 0, mtimeMs: 0 };
|
|
237
|
+
function errText(e) {
|
|
238
|
+
return e instanceof Error ? e.message : String(e);
|
|
239
|
+
}
|
|
240
|
+
function errCode(e) {
|
|
241
|
+
return String(e?.code ?? "");
|
|
242
|
+
}
|
|
243
|
+
function irregularName(st) {
|
|
244
|
+
if (st.isFIFO()) return "fifo";
|
|
245
|
+
if (st.isSocket()) return "socket";
|
|
246
|
+
if (st.isCharacterDevice()) return "character device";
|
|
247
|
+
if (st.isBlockDevice()) return "block device";
|
|
248
|
+
return "unknown file type";
|
|
249
|
+
}
|
|
250
|
+
function meta(st) {
|
|
251
|
+
return { mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
|
|
252
|
+
}
|
|
253
|
+
var O_READ = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
254
|
+
function readRegular(abs2, st) {
|
|
255
|
+
let fd;
|
|
256
|
+
try {
|
|
257
|
+
fd = openSync(abs2, O_READ);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (errCode(e) === "ELOOP") return inspectOnce(abs2);
|
|
260
|
+
return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const now = fstatSync(fd);
|
|
264
|
+
if (!now.isFile()) return { kind: "irregular", content: null, ...meta(now), detail: irregularName(now) };
|
|
265
|
+
if (now.size > READ_CAP) return { kind: "oversize", content: null, ...meta(now), detail: `${now.size} bytes` };
|
|
266
|
+
const buf = Buffer.allocUnsafe(now.size);
|
|
267
|
+
let off2 = 0;
|
|
268
|
+
while (off2 < now.size) {
|
|
269
|
+
const n = readSync(fd, buf, off2, now.size - off2, off2);
|
|
270
|
+
if (n === 0) break;
|
|
271
|
+
off2 += n;
|
|
272
|
+
}
|
|
273
|
+
return { kind: "file", content: off2 === now.size ? buf : buf.subarray(0, off2), mode: now.mode, size: off2, mtimeMs: now.mtimeMs, detail: "" };
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
|
|
276
|
+
} finally {
|
|
277
|
+
closeSync(fd);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function classify(abs2, st) {
|
|
281
|
+
if (st.isSymbolicLink()) {
|
|
282
|
+
try {
|
|
283
|
+
const target = readlinkSync(abs2);
|
|
284
|
+
return { kind: "symlink", content: Buffer.from(target, "utf8"), ...meta(st), detail: target };
|
|
285
|
+
} catch (e) {
|
|
286
|
+
return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (st.isDirectory()) return { kind: "directory", content: null, ...meta(st), detail: "directory" };
|
|
290
|
+
if (!st.isFile()) return { kind: "irregular", content: null, ...meta(st), detail: irregularName(st) };
|
|
291
|
+
if (st.size > READ_CAP) return { kind: "oversize", content: null, ...meta(st), detail: `${st.size} bytes` };
|
|
292
|
+
return readRegular(abs2, st);
|
|
293
|
+
}
|
|
294
|
+
function inspectOnce(abs2) {
|
|
295
|
+
let st;
|
|
296
|
+
try {
|
|
297
|
+
st = lstatSync(abs2);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const code = errCode(e);
|
|
300
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
|
|
301
|
+
return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
|
|
302
|
+
}
|
|
303
|
+
if (st.isSymbolicLink() || !st.isFile()) return classify(abs2, st);
|
|
304
|
+
return { kind: "unreadable", content: null, ...meta(st), detail: "the path changed shape while it was being read" };
|
|
305
|
+
}
|
|
306
|
+
function inspectPath(abs2) {
|
|
307
|
+
let st;
|
|
308
|
+
try {
|
|
309
|
+
st = lstatSync(abs2);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
const code = errCode(e);
|
|
312
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
|
|
313
|
+
return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
|
|
314
|
+
}
|
|
315
|
+
return classify(abs2, st);
|
|
316
|
+
}
|
|
317
|
+
function inspectRel(cwd, rel) {
|
|
318
|
+
return inspectPath(join(cwd, rel));
|
|
319
|
+
}
|
|
320
|
+
function inspectResolved(abs2) {
|
|
321
|
+
let real;
|
|
322
|
+
try {
|
|
323
|
+
real = realpathSync(abs2);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
const code = errCode(e);
|
|
326
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
|
|
327
|
+
return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
|
|
328
|
+
}
|
|
329
|
+
return inspectPath(real);
|
|
330
|
+
}
|
|
331
|
+
function textOf(e) {
|
|
332
|
+
return e.content == null ? null : e.content.toString("utf8");
|
|
333
|
+
}
|
|
334
|
+
function describe(e, shown) {
|
|
335
|
+
switch (e.kind) {
|
|
336
|
+
case "symlink":
|
|
337
|
+
return `Protected path ${shown} is a symbolic link to ${escapeControl(e.detail)}. The gate never follows a link, so what the runner will read there cannot be judged.`;
|
|
338
|
+
case "directory":
|
|
339
|
+
return `Protected path ${shown} is a directory where a file is expected, so it cannot be judged.`;
|
|
340
|
+
case "irregular":
|
|
341
|
+
return `Protected path ${shown} is a ${e.detail}, not a regular file, so it cannot be read to judge it.`;
|
|
342
|
+
case "oversize":
|
|
343
|
+
return `Protected file ${shown} is ${e.detail}, above the ${READ_CAP / (1024 * 1024)} MiB the gate reads, so it is judged by name.`;
|
|
344
|
+
default:
|
|
345
|
+
return `Protected file ${shown} cannot be read to judge it (${e.detail}).`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function unjudgeableFinding(cwd, rel) {
|
|
349
|
+
const e = inspectRel(cwd, rel);
|
|
350
|
+
if (e.kind === "file" || e.kind === "absent") return null;
|
|
351
|
+
const shown = escapeControl(rel);
|
|
352
|
+
return {
|
|
353
|
+
rule: "hidden-drift",
|
|
354
|
+
severity: "block",
|
|
355
|
+
file: rel,
|
|
356
|
+
message: describe(e, shown),
|
|
357
|
+
evidence: `${shown}: ${e.kind}${e.detail ? ` (${escapeControl(e.detail)})` : ""}`,
|
|
358
|
+
remediation: "Put a regular file the gate can read at the path \u2014 no symbolic link, FIFO, socket or device, and under 64 MiB \u2014 through a tool call the gate can see, or remove it.",
|
|
359
|
+
signoff: { required: true, command: `tamperward allow hidden-drift --file ${shown} --reason "..."` }
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function unjudgeableProtected(cwd, policy, changes) {
|
|
363
|
+
const out2 = [];
|
|
364
|
+
const seen = /* @__PURE__ */ new Set();
|
|
365
|
+
for (const c of changes) {
|
|
366
|
+
if (c.kind !== "file" || c.op === "delete" || seen.has(c.path)) continue;
|
|
367
|
+
seen.add(c.path);
|
|
368
|
+
if (!isProtected(c.path, policy)) continue;
|
|
369
|
+
const f = unjudgeableFinding(cwd, c.path);
|
|
370
|
+
if (f) out2.push(f);
|
|
371
|
+
}
|
|
372
|
+
return out2;
|
|
373
|
+
}
|
|
374
|
+
|
|
8
375
|
// src/diff/parse.ts
|
|
9
376
|
var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
10
377
|
var C_ESCAPES = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, "\\": 92, '"': 34 };
|
|
@@ -251,11 +618,7 @@ function blobAt(rev, path, cwd) {
|
|
|
251
618
|
}
|
|
252
619
|
}
|
|
253
620
|
function fromDisk(path, cwd) {
|
|
254
|
-
|
|
255
|
-
return readFileSync(join(cwd ?? process.cwd(), path), "utf8");
|
|
256
|
-
} catch {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
621
|
+
return textOf(inspectRel(cwd ?? process.cwd(), path));
|
|
259
622
|
}
|
|
260
623
|
function enrich(c, beforeReader, afterReader) {
|
|
261
624
|
if (c.kind !== "file") return c;
|
|
@@ -353,6 +716,9 @@ function underSkippedDir(rel) {
|
|
|
353
716
|
return rel.split("/").some((seg) => SKIP_DIRS.has(seg));
|
|
354
717
|
}
|
|
355
718
|
function ignoredPaths(opts = {}, keep) {
|
|
719
|
+
return ignoredTree(opts, keep).files;
|
|
720
|
+
}
|
|
721
|
+
function ignoredTree(opts = {}, keep) {
|
|
356
722
|
const files = [];
|
|
357
723
|
const dirs = [];
|
|
358
724
|
for (const entry of git([...IGNORED_LS, "--directory"], opts.cwd).split("\0").filter(Boolean)) {
|
|
@@ -364,7 +730,7 @@ function ignoredPaths(opts = {}, keep) {
|
|
|
364
730
|
const expanded = git([...IGNORED_LS, "--", ...dirs.map((d) => `:(literal)${d}`)], opts.cwd).split("\0").filter(Boolean);
|
|
365
731
|
for (const rel of expanded) if (!underSkippedDir(rel)) files.push(rel);
|
|
366
732
|
}
|
|
367
|
-
return files.filter(keep);
|
|
733
|
+
return { dirs, files: files.filter(keep) };
|
|
368
734
|
}
|
|
369
735
|
function ignoredAdds(opts = {}, keep, exclude) {
|
|
370
736
|
return ignoredPaths(opts, keep).filter((rel) => !exclude?.has(rel)).map((rel) => addOf(rel, opts.cwd));
|
|
@@ -554,234 +920,38 @@ var noVerify = {
|
|
|
554
920
|
why = `${SKIP_WHY} (set earlier in the same command)`;
|
|
555
921
|
evidence = `${carriedSkip}; ${seg}`;
|
|
556
922
|
}
|
|
557
|
-
if (!why && sub && HOOK_SUBCOMMANDS.has(sub)) {
|
|
558
|
-
const args = gitArgs(toks, sub);
|
|
559
|
-
if (args.some((t) => NO_VERIFY.test(t))) why = "--no-verify skips the pre-commit/pre-push hooks";
|
|
560
|
-
else if (sub === "commit" && args.some((t) => SHORT_N.test(t))) why = "git commit -n skips the pre-commit hook";
|
|
561
|
-
}
|
|
562
|
-
if (!why && toks.includes(NO_HOOKS)) why = "--no-hooks disables hook execution";
|
|
563
|
-
if (why) {
|
|
564
|
-
out2.push(
|
|
565
|
-
makeFinding(RULE, policy, {
|
|
566
|
-
message: `Command bypasses git hooks: ${why}.`,
|
|
567
|
-
evidence,
|
|
568
|
-
remediation: "Let the hooks run and fix what they catch. Skipping verification is exactly the move this gate blocks \u2014 editing the hook and --no-hooks are blocked too."
|
|
569
|
-
})
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
return out2;
|
|
575
|
-
}
|
|
576
|
-
};
|
|
577
|
-
|
|
578
|
-
// src/detectors/ts-any-cast.ts
|
|
579
|
-
import ts from "typescript";
|
|
580
|
-
|
|
581
|
-
// src/diff/select.ts
|
|
582
|
-
function addedLines(c) {
|
|
583
|
-
if (c.kind !== "file") return [];
|
|
584
|
-
return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "add"));
|
|
585
|
-
}
|
|
586
|
-
function removedLines(c) {
|
|
587
|
-
if (c.kind !== "file") return [];
|
|
588
|
-
return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "del"));
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// src/policy.ts
|
|
592
|
-
import picomatch from "picomatch";
|
|
593
|
-
var POLICY_FILE = ".tamperward.yml";
|
|
594
|
-
var BLOCK_SINCE = {};
|
|
595
|
-
function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
596
|
-
const out2 = { ...rules };
|
|
597
|
-
for (const [rule, since] of Object.entries(gates)) {
|
|
598
|
-
if (since > version && out2[rule]?.severity === "block") {
|
|
599
|
-
out2[rule] = { ...out2[rule], severity: "warn" };
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
return out2;
|
|
603
|
-
}
|
|
604
|
-
function normalizeGlob(glob) {
|
|
605
|
-
return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
|
|
606
|
-
}
|
|
607
|
-
function mergeRules(base, user) {
|
|
608
|
-
const out2 = { ...base };
|
|
609
|
-
for (const [name, cfg] of Object.entries(user ?? {})) {
|
|
610
|
-
out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
|
|
611
|
-
}
|
|
612
|
-
return out2;
|
|
613
|
-
}
|
|
614
|
-
var cache = /* @__PURE__ */ new Map();
|
|
615
|
-
function matcher(glob) {
|
|
616
|
-
let m = cache.get(glob);
|
|
617
|
-
if (!m) {
|
|
618
|
-
m = picomatch(glob, { dot: true });
|
|
619
|
-
cache.set(glob, m);
|
|
620
|
-
}
|
|
621
|
-
return m;
|
|
622
|
-
}
|
|
623
|
-
function matchesAny(path, globs4) {
|
|
624
|
-
return !!globs4 && globs4.some((g) => matcher(g)(path));
|
|
625
|
-
}
|
|
626
|
-
function protectedCategory(path, policy) {
|
|
627
|
-
for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
|
|
628
|
-
if (matchesAny(path, globs4)) return cat;
|
|
629
|
-
}
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
function isProtected(path, policy, category) {
|
|
633
|
-
if (category) return matchesAny(path, policy.protected?.[category]);
|
|
634
|
-
return protectedCategory(path, policy) !== null;
|
|
635
|
-
}
|
|
636
|
-
function isPolicyFile(path) {
|
|
637
|
-
return path === POLICY_FILE || path.endsWith("/" + POLICY_FILE);
|
|
638
|
-
}
|
|
639
|
-
function isIgnored(path, policy) {
|
|
640
|
-
return matchesAny(path, policy.ignore);
|
|
641
|
-
}
|
|
642
|
-
function mergeProtected(base, user) {
|
|
643
|
-
const out2 = { ...base };
|
|
644
|
-
for (const [cat, globs4] of Object.entries(user ?? {})) {
|
|
645
|
-
const have = new Set(out2[cat] ?? []);
|
|
646
|
-
out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
|
|
923
|
+
if (!why && sub && HOOK_SUBCOMMANDS.has(sub)) {
|
|
924
|
+
const args = gitArgs(toks, sub);
|
|
925
|
+
if (args.some((t) => NO_VERIFY.test(t))) why = "--no-verify skips the pre-commit/pre-push hooks";
|
|
926
|
+
else if (sub === "commit" && args.some((t) => SHORT_N.test(t))) why = "git commit -n skips the pre-commit hook";
|
|
927
|
+
}
|
|
928
|
+
if (!why && toks.includes(NO_HOOKS)) why = "--no-hooks disables hook execution";
|
|
929
|
+
if (why) {
|
|
930
|
+
out2.push(
|
|
931
|
+
makeFinding(RULE, policy, {
|
|
932
|
+
message: `Command bypasses git hooks: ${why}.`,
|
|
933
|
+
evidence,
|
|
934
|
+
remediation: "Let the hooks run and fix what they catch. Skipping verification is exactly the move this gate blocks \u2014 editing the hook and --no-hooks are blocked too."
|
|
935
|
+
})
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out2;
|
|
647
941
|
}
|
|
648
|
-
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// src/detectors/ts-any-cast.ts
|
|
945
|
+
import ts from "typescript";
|
|
946
|
+
|
|
947
|
+
// src/diff/select.ts
|
|
948
|
+
function addedLines(c) {
|
|
949
|
+
if (c.kind !== "file") return [];
|
|
950
|
+
return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "add"));
|
|
649
951
|
}
|
|
650
|
-
function
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
protected: {
|
|
654
|
-
// Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
|
|
655
|
-
// .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
|
|
656
|
-
// instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
|
|
657
|
-
// strengthening, not a narrowing. picomatch expands the brace.
|
|
658
|
-
// test-skip, test-deletion and test-content-removal all gate on this
|
|
659
|
-
// class, so a repo it does not match gets rules that can NEVER fire —
|
|
660
|
-
// protection that is silently absent rather than visibly off. The JS/TS
|
|
661
|
-
// list shipped alone; these are the conventional layouts of the other
|
|
662
|
-
// ecosystems `init` is run in. (P2-13, external review.)
|
|
663
|
-
tests: [
|
|
664
|
-
// JavaScript / TypeScript
|
|
665
|
-
"**/*.test.ts",
|
|
666
|
-
"**/*.spec.ts",
|
|
667
|
-
"**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}",
|
|
668
|
-
"**/__tests__/**",
|
|
669
|
-
// Python (pytest / unittest layouts)
|
|
670
|
-
"**/test_*.py",
|
|
671
|
-
"**/*_test.py",
|
|
672
|
-
"**/conftest.py",
|
|
673
|
-
// Go
|
|
674
|
-
"**/*_test.go",
|
|
675
|
-
// Rust (integration tests live in tests/, unit tests inline)
|
|
676
|
-
"**/tests/**/*.rs",
|
|
677
|
-
// Ruby (RSpec / minitest)
|
|
678
|
-
"**/*_spec.rb",
|
|
679
|
-
"**/*_test.rb",
|
|
680
|
-
// Java / Kotlin / Scala (Maven & Gradle convention)
|
|
681
|
-
"**/src/test/**",
|
|
682
|
-
// PHP (PHPUnit)
|
|
683
|
-
"**/*Test.php",
|
|
684
|
-
// C# (.NET)
|
|
685
|
-
"**/*Tests.cs",
|
|
686
|
-
"**/*Test.cs"
|
|
687
|
-
],
|
|
688
|
-
config: [
|
|
689
|
-
"**/jest.config.*",
|
|
690
|
-
"**/vitest.config.*",
|
|
691
|
-
// A `vitest.workspace.*` file REPLACES the root config's selection with the
|
|
692
|
-
// union of its projects, and a `vite.config.*` carrying a `test:` key is the
|
|
693
|
-
// vitest config when no vitest.config exists — both are where a spec can be
|
|
694
|
-
// dropped from the suite without a line of it changing. ADDITIVE, like every
|
|
695
|
-
// baseline widening (the policy-diff reads it as a strengthening).
|
|
696
|
-
"**/vitest.workspace.*",
|
|
697
|
-
"**/vite.config.*",
|
|
698
|
-
"**/tsconfig*.json",
|
|
699
|
-
"**/.eslintrc*",
|
|
700
|
-
"**/eslint.config.*",
|
|
701
|
-
"**/package.json",
|
|
702
|
-
// Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
|
|
703
|
-
// see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
|
|
704
|
-
// codecov `target` lived in files no glob protected, so lowering them fired
|
|
705
|
-
// nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
|
|
706
|
-
// as a strengthening). pyproject.toml carries [tool.coverage.report].
|
|
707
|
-
"**/.coveragerc",
|
|
708
|
-
"**/pyproject.toml",
|
|
709
|
-
"**/.nycrc",
|
|
710
|
-
"**/.nycrc.*",
|
|
711
|
-
"**/nyc.config.*",
|
|
712
|
-
"**/codecov.yml",
|
|
713
|
-
"**/.codecov.yml"
|
|
714
|
-
],
|
|
715
|
-
ci: [".github/workflows/**"],
|
|
716
|
-
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
717
|
-
// rewriting it from current output is the snapshot-update move the affordance
|
|
718
|
-
// experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
|
|
719
|
-
// tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
|
|
720
|
-
// live there as .cjs and were rewritten unseen (PHASE3.md §4).
|
|
721
|
-
snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
|
|
722
|
-
// The wiring IS the enforcement: settings.json carries the PreToolUse and
|
|
723
|
-
// Stop hooks, and a pre-commit config carries the commit-time gate. These
|
|
724
|
-
// were installed by `init` and protected by nothing, so editing them out
|
|
725
|
-
// fired no rule at all. (.git/hooks/** is outside every git view and so
|
|
726
|
-
// cannot be covered here — noted in SECURITY-ENVELOPE.md.) (P1-4, review.)
|
|
727
|
-
hooks: [
|
|
728
|
-
".husky/**",
|
|
729
|
-
"**/lefthook.*",
|
|
730
|
-
// lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
|
|
731
|
-
// there switches the gate off without touching the file init pointed at.
|
|
732
|
-
"**/lefthook-local.*",
|
|
733
|
-
".tamperward.yml",
|
|
734
|
-
"**/.tamperward.yml",
|
|
735
|
-
".claude/settings.json",
|
|
736
|
-
".claude/settings.local.json",
|
|
737
|
-
"**/.pre-commit-config.yaml",
|
|
738
|
-
// CODEOWNERS is enforcement wiring, not documentation: it is the only
|
|
739
|
-
// thing that can require a human on a change to the workflow that
|
|
740
|
-
// decides whether the gate runs at all. GitHub reads it from any of
|
|
741
|
-
// these three locations. (H2.)
|
|
742
|
-
".github/CODEOWNERS",
|
|
743
|
-
"CODEOWNERS",
|
|
744
|
-
"docs/CODEOWNERS"
|
|
745
|
-
]
|
|
746
|
-
},
|
|
747
|
-
rules: {
|
|
748
|
-
"test-deletion": { severity: "block" },
|
|
749
|
-
// content gutted from a surviving spec — the class Phase 3 measured walking
|
|
750
|
-
// through the block-count rule (rows, assertion arguments, setup). Severity
|
|
751
|
-
// backed by the real-commit corpus measurement recorded in the rule's SPEC row.
|
|
752
|
-
"test-content-removal": { severity: "block" },
|
|
753
|
-
"test-skip": { severity: "block" },
|
|
754
|
-
"ts-any-cast": { severity: "block" },
|
|
755
|
-
// the unambiguous explicit casts + ts-suppression directives
|
|
756
|
-
// broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
|
|
757
|
-
// block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
|
|
758
|
-
"ts-any-launder": { severity: "warn" },
|
|
759
|
-
"lint-suppression": { severity: "block" },
|
|
760
|
-
"coverage-lowering": { severity: "block" },
|
|
761
|
-
"ci-tampering": { severity: "block" },
|
|
762
|
-
"hook-tampering": { severity: "block" },
|
|
763
|
-
"no-verify": { severity: "block" },
|
|
764
|
-
// mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
|
|
765
|
-
// when intended output changes. WARN until the §7 evidence path earns block.
|
|
766
|
-
"snapshot-rewrite": { severity: "warn" },
|
|
767
|
-
// the watcher's transient class (modified-and-restored within a turn). WARN by
|
|
768
|
-
// default: a Stop block already forces restores, and a compliant restore reads
|
|
769
|
-
// as churn — block-by-default would deadlock the honest path. Harnesses that own
|
|
770
|
-
// that semantics raise it via TAMPERWARD_TRANSIENT=block.
|
|
771
|
-
"transient-protected-mutation": { severity: "warn" },
|
|
772
|
-
// the narrow graduation candidate: snapshot moved with NO accompanying change.
|
|
773
|
-
// ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
|
|
774
|
-
// (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
|
|
775
|
-
"snapshot-only-rewrite": { severity: "warn" },
|
|
776
|
-
// heuristic — warn until precision is measured (SPEC §7)
|
|
777
|
-
"assertion-weakening": { severity: "warn" },
|
|
778
|
-
"guard-removal": { severity: "warn" }
|
|
779
|
-
},
|
|
780
|
-
ignore: [],
|
|
781
|
-
signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
|
|
782
|
-
};
|
|
783
|
-
p.rules = applyVersionGates(p.rules, version);
|
|
784
|
-
return p;
|
|
952
|
+
function removedLines(c) {
|
|
953
|
+
if (c.kind !== "file") return [];
|
|
954
|
+
return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "del"));
|
|
785
955
|
}
|
|
786
956
|
|
|
787
957
|
// src/detectors/files.ts
|
|
@@ -1164,12 +1334,12 @@ import ts2 from "typescript";
|
|
|
1164
1334
|
|
|
1165
1335
|
// src/detectors/repo.ts
|
|
1166
1336
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1167
|
-
var
|
|
1337
|
+
var cache = /* @__PURE__ */ new Map();
|
|
1168
1338
|
function trackedFiles(ctx) {
|
|
1169
1339
|
if (!ctx) return null;
|
|
1170
1340
|
if (ctx.trackedFiles) return ctx.trackedFiles;
|
|
1171
1341
|
if (!ctx.cwd) return null;
|
|
1172
|
-
const hit =
|
|
1342
|
+
const hit = cache.get(ctx.cwd);
|
|
1173
1343
|
if (hit !== void 0) return hit;
|
|
1174
1344
|
let files = null;
|
|
1175
1345
|
try {
|
|
@@ -1182,7 +1352,7 @@ function trackedFiles(ctx) {
|
|
|
1182
1352
|
} catch {
|
|
1183
1353
|
files = null;
|
|
1184
1354
|
}
|
|
1185
|
-
|
|
1355
|
+
cache.set(ctx.cwd, files);
|
|
1186
1356
|
return files;
|
|
1187
1357
|
}
|
|
1188
1358
|
var branchCache = /* @__PURE__ */ new Map();
|
|
@@ -1280,8 +1450,8 @@ function numericOf(e) {
|
|
|
1280
1450
|
}
|
|
1281
1451
|
return void 0;
|
|
1282
1452
|
}
|
|
1283
|
-
function metricsOf(expr,
|
|
1284
|
-
const obj =
|
|
1453
|
+
function metricsOf(expr, resolve6) {
|
|
1454
|
+
const obj = resolve6(expr);
|
|
1285
1455
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
1286
1456
|
const out2 = { values: {}, opaque: false };
|
|
1287
1457
|
let any = false;
|
|
@@ -1295,7 +1465,7 @@ function metricsOf(expr, resolve5) {
|
|
|
1295
1465
|
const k = keyName(p.name);
|
|
1296
1466
|
if (k === null || !isMetric(k)) continue;
|
|
1297
1467
|
any = true;
|
|
1298
|
-
const n = numericOf(
|
|
1468
|
+
const n = numericOf(resolve6(p.initializer));
|
|
1299
1469
|
if (n === void 0) out2.opaque = true;
|
|
1300
1470
|
else out2.values[k] = n;
|
|
1301
1471
|
}
|
|
@@ -1341,17 +1511,17 @@ function parseThresholds(src) {
|
|
|
1341
1511
|
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
1342
1512
|
try {
|
|
1343
1513
|
const sf = sourceOf(src);
|
|
1344
|
-
const
|
|
1514
|
+
const resolve6 = resolverFor(sf);
|
|
1345
1515
|
const visit = (node) => {
|
|
1346
1516
|
if (ts2.isPropertyAssignment(node)) {
|
|
1347
1517
|
const key2 = keyName(node.name);
|
|
1348
1518
|
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
1349
1519
|
res.present = true;
|
|
1350
|
-
const init =
|
|
1520
|
+
const init = resolve6(node.initializer);
|
|
1351
1521
|
if (!ts2.isObjectLiteralExpression(init)) {
|
|
1352
1522
|
res.opaque = true;
|
|
1353
1523
|
} else {
|
|
1354
|
-
const flat = metricsOf(init,
|
|
1524
|
+
const flat = metricsOf(init, resolve6);
|
|
1355
1525
|
if (flat) res.global = merge(res.global, flat);
|
|
1356
1526
|
for (const p of init.properties) {
|
|
1357
1527
|
if (ts2.isSpreadAssignment(p)) {
|
|
@@ -1365,7 +1535,7 @@ function parseThresholds(src) {
|
|
|
1365
1535
|
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
1366
1536
|
continue;
|
|
1367
1537
|
}
|
|
1368
|
-
const m = metricsOf(p.initializer,
|
|
1538
|
+
const m = metricsOf(p.initializer, resolve6);
|
|
1369
1539
|
if (!m) continue;
|
|
1370
1540
|
if (k === "global") res.global = merge(res.global, m);
|
|
1371
1541
|
else res.paths.set(norm(k), m);
|
|
@@ -1398,18 +1568,18 @@ function parseLists(src) {
|
|
|
1398
1568
|
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1399
1569
|
try {
|
|
1400
1570
|
const sf = sourceOf(src);
|
|
1401
|
-
const
|
|
1571
|
+
const resolve6 = resolverFor(sf);
|
|
1402
1572
|
const visit = (node) => {
|
|
1403
1573
|
if (ts2.isPropertyAssignment(node)) {
|
|
1404
1574
|
const k = keyName(node.name);
|
|
1405
1575
|
const vitestCoverage = ownerKey(node) === "coverage";
|
|
1406
1576
|
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
1407
|
-
const { items, opaque } = literals(
|
|
1577
|
+
const { items, opaque } = literals(resolve6(node.initializer));
|
|
1408
1578
|
res.collectFrom = items;
|
|
1409
1579
|
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
1410
1580
|
if (opaque) res.opaqueCollect = true;
|
|
1411
1581
|
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
1412
|
-
const { items, opaque } = literals(
|
|
1582
|
+
const { items, opaque } = literals(resolve6(node.initializer));
|
|
1413
1583
|
res.ignore.push(...items);
|
|
1414
1584
|
if (opaque) res.opaqueIgnore = true;
|
|
1415
1585
|
}
|
|
@@ -2490,19 +2660,20 @@ var ciTampering = {
|
|
|
2490
2660
|
};
|
|
2491
2661
|
|
|
2492
2662
|
// src/detectors/hook-tampering.ts
|
|
2493
|
-
import { existsSync, readFileSync as
|
|
2494
|
-
import { isAbsolute, join as join3, relative } from "node:path";
|
|
2663
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
2664
|
+
import { isAbsolute as isAbsolute3, join as join3, relative } from "node:path";
|
|
2495
2665
|
|
|
2496
2666
|
// src/wiring.ts
|
|
2497
|
-
import { readFileSync as
|
|
2498
|
-
import {
|
|
2667
|
+
import { readFileSync, realpathSync as realpathSync2 } from "node:fs";
|
|
2668
|
+
import { homedir } from "node:os";
|
|
2669
|
+
import { basename, dirname, isAbsolute, join as join2, posix } from "node:path";
|
|
2499
2670
|
import { fileURLToPath } from "node:url";
|
|
2500
2671
|
function shippedVersion() {
|
|
2501
2672
|
try {
|
|
2502
2673
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2503
2674
|
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
2504
2675
|
try {
|
|
2505
|
-
const pkg = JSON.parse(
|
|
2676
|
+
const pkg = JSON.parse(readFileSync(join2(here, rel), "utf8"));
|
|
2506
2677
|
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
2507
2678
|
} catch {
|
|
2508
2679
|
}
|
|
@@ -2519,7 +2690,7 @@ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
|
2519
2690
|
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
2520
2691
|
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
2521
2692
|
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2522
|
-
var PLAIN_SEMVER =
|
|
2693
|
+
var PLAIN_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
2523
2694
|
function compareVersions(a, b) {
|
|
2524
2695
|
if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
|
|
2525
2696
|
const pa = a.split(".").map(Number);
|
|
@@ -2537,8 +2708,41 @@ function initScriptPin(src) {
|
|
|
2537
2708
|
const m = src.match(INIT_SCRIPT);
|
|
2538
2709
|
return m ? m[1] ?? "" : null;
|
|
2539
2710
|
}
|
|
2711
|
+
function homeDir(env = process.env) {
|
|
2712
|
+
const h = (env.HOME ?? env.USERPROFILE ?? "").trim() || homedir();
|
|
2713
|
+
return h.replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
|
|
2714
|
+
}
|
|
2715
|
+
function claudeConfigDir(env = process.env) {
|
|
2716
|
+
const dir = (env.CLAUDE_CONFIG_DIR ?? "").trim().replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
|
|
2717
|
+
return dir || `${homeDir(env)}/.claude`;
|
|
2718
|
+
}
|
|
2540
2719
|
var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
|
|
2541
|
-
|
|
2720
|
+
function isClaudeSettings(path, env = process.env) {
|
|
2721
|
+
const p = path.replace(/\\/g, "/");
|
|
2722
|
+
if (CLAUDE_SETTINGS.test(p)) return true;
|
|
2723
|
+
const dir = (env.CLAUDE_CONFIG_DIR ?? "").trim().replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
|
|
2724
|
+
return dir !== "" && (p === `${dir}/settings.json` || p === `${dir}/settings.local.json`);
|
|
2725
|
+
}
|
|
2726
|
+
function canonicalPath(path) {
|
|
2727
|
+
const tail = [];
|
|
2728
|
+
let cur = path;
|
|
2729
|
+
for (let i = 0; i < 256; i++) {
|
|
2730
|
+
try {
|
|
2731
|
+
const real = realpathSync2(cur).replace(/\\/g, "/");
|
|
2732
|
+
return tail.length ? posix.join(real, ...tail.reverse()) : real;
|
|
2733
|
+
} catch {
|
|
2734
|
+
}
|
|
2735
|
+
const parent = dirname(cur);
|
|
2736
|
+
if (parent === cur) break;
|
|
2737
|
+
tail.push(basename(cur));
|
|
2738
|
+
cur = parent;
|
|
2739
|
+
}
|
|
2740
|
+
return path;
|
|
2741
|
+
}
|
|
2742
|
+
function resolvesToClaudeSettings(path, env = process.env) {
|
|
2743
|
+
if (isClaudeSettings(path, env)) return true;
|
|
2744
|
+
return isAbsolute(path) && isClaudeSettings(canonicalPath(path), env);
|
|
2745
|
+
}
|
|
2542
2746
|
|
|
2543
2747
|
// src/detectors/policy-diff.ts
|
|
2544
2748
|
import { parse } from "yaml";
|
|
@@ -2665,6 +2869,8 @@ function policyAddWeakening(after) {
|
|
|
2665
2869
|
}
|
|
2666
2870
|
|
|
2667
2871
|
// src/detectors/hook-wiring.ts
|
|
2872
|
+
import { existsSync } from "node:fs";
|
|
2873
|
+
import { isAbsolute as isAbsolute2, resolve } from "node:path";
|
|
2668
2874
|
import { parse as parseYaml } from "yaml";
|
|
2669
2875
|
function stripComment(line) {
|
|
2670
2876
|
let single = false;
|
|
@@ -3655,7 +3861,8 @@ var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$
|
|
|
3655
3861
|
var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
|
|
3656
3862
|
var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
|
|
3657
3863
|
var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
|
|
3658
|
-
var
|
|
3864
|
+
var SCRIPT_WORD = /[\w.~/$%{}\\-]+/g;
|
|
3865
|
+
var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", "managed-settings.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
|
|
3659
3866
|
function nameGlob(pattern) {
|
|
3660
3867
|
const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
3661
3868
|
return (n) => re.test(n);
|
|
@@ -3694,24 +3901,56 @@ function destinations(cmd, args, isDir) {
|
|
|
3694
3901
|
return [dest];
|
|
3695
3902
|
}
|
|
3696
3903
|
var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
|
|
3904
|
+
function expandShellPath(t, env = process.env) {
|
|
3905
|
+
const home = homeDir(env);
|
|
3906
|
+
return t.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\$(?:\{HOME\}|HOME\b|ENV\{HOME\})/g, home).replace(/%USERPROFILE%/gi, home).replace(/\$(?:\{CLAUDE_CONFIG_DIR\}|CLAUDE_CONFIG_DIR\b)/g, claudeConfigDir(env));
|
|
3907
|
+
}
|
|
3697
3908
|
function hookTests(policy, ctx) {
|
|
3909
|
+
const cwd = ctx?.cwd;
|
|
3910
|
+
const onDisk = cwd !== void 0 && existsSync(cwd);
|
|
3911
|
+
const root = onDisk ? canonicalPath(cwd) : cwd;
|
|
3912
|
+
const memo = /* @__PURE__ */ new Map();
|
|
3698
3913
|
const rel = (t) => {
|
|
3699
|
-
|
|
3700
|
-
if (
|
|
3701
|
-
|
|
3914
|
+
const hit = memo.get(t);
|
|
3915
|
+
if (hit !== void 0) return hit;
|
|
3916
|
+
let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
|
|
3917
|
+
const pathy = isAbsolute2(p) || p.includes("/") && !/^[-:]/.test(p);
|
|
3918
|
+
if (pathy && (isAbsolute2(p) || onDisk)) {
|
|
3919
|
+
p = canonicalPath(isAbsolute2(p) ? p : resolve(cwd, p));
|
|
3920
|
+
if (root !== void 0) {
|
|
3921
|
+
if (p === root) p = ".";
|
|
3922
|
+
else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
if (cwd !== void 0 && p.startsWith(cwd + "/")) p = p.slice(cwd.length + 1);
|
|
3926
|
+
p = p.replace(/^\.\/(?=.)/, "");
|
|
3927
|
+
memo.set(t, p);
|
|
3928
|
+
return p;
|
|
3929
|
+
};
|
|
3930
|
+
const hook = (t) => {
|
|
3931
|
+
const p = rel(t);
|
|
3932
|
+
return isProtected(p, policy, "hooks") || isClaudeSettings(p) || isClaudeSettings(expandShellPath(t.replace(/^["']|["']$/g, "")));
|
|
3933
|
+
};
|
|
3934
|
+
const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || cwd !== void 0 && rel(t) === cwd;
|
|
3935
|
+
const userSettings = `${claudeConfigDir()}/settings.json`;
|
|
3936
|
+
const userSettingsUnder = (t) => {
|
|
3937
|
+
const dir = rel(t).replace(/\/+$/, "");
|
|
3938
|
+
return dir !== "" && dir !== "/" && userSettings.startsWith(dir + "/") ? userSettings : null;
|
|
3702
3939
|
};
|
|
3703
|
-
const
|
|
3704
|
-
const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
|
|
3705
|
-
const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
|
|
3940
|
+
const holds = (t) => !t.startsWith("-") && (whole(t) || userSettingsUnder(t) !== null || containsProtected(rel(t), policy, "hooks", ctx));
|
|
3706
3941
|
const hookish = (t) => hook(t) || holds(t);
|
|
3707
|
-
return { rel, hook, whole, holds, hookish };
|
|
3942
|
+
return { rel, hook, whole, holds, hookish, userSettingsUnder };
|
|
3708
3943
|
}
|
|
3709
3944
|
function shellHookTarget(seg, toks, policy, ctx) {
|
|
3710
|
-
const { rel, hook, whole, holds } = hookTests(policy, ctx);
|
|
3945
|
+
const { rel, hook, whole, holds, userSettingsUnder } = hookTests(policy, ctx);
|
|
3711
3946
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
3712
3947
|
if (hook(m[1])) return rel(m[1]);
|
|
3713
3948
|
}
|
|
3714
3949
|
const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
|
|
3950
|
+
for (let i = 1; i < args.length; i++) {
|
|
3951
|
+
if (!INLINE_FLAG.test(args[i - 1])) continue;
|
|
3952
|
+
for (const word of args[i].match(SCRIPT_WORD) ?? []) if (hook(word)) return rel(word);
|
|
3953
|
+
}
|
|
3715
3954
|
const fileLike = (p) => {
|
|
3716
3955
|
const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
3717
3956
|
return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
|
|
@@ -3719,6 +3958,8 @@ function shellHookTarget(seg, toks, policy, ctx) {
|
|
|
3719
3958
|
for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
|
|
3720
3959
|
for (const t of args) {
|
|
3721
3960
|
if (t.startsWith("-") || !holds(t)) continue;
|
|
3961
|
+
const user = userSettingsUnder(t);
|
|
3962
|
+
if (user !== null) return user;
|
|
3722
3963
|
const dir = rel(t).replace(/\/+$/, "");
|
|
3723
3964
|
const under = whole(t) ? "" : dir + "/";
|
|
3724
3965
|
const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
|
|
@@ -3742,7 +3983,7 @@ function shellWritesHook(seg, toks, policy, ctx) {
|
|
|
3742
3983
|
const anyHook = args.some(hook);
|
|
3743
3984
|
const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
|
|
3744
3985
|
const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
|
|
3745
|
-
const namesHookInText = () => scripts.some((a) => (a.match(
|
|
3986
|
+
const namesHookInText = () => scripts.some((a) => (a.match(SCRIPT_WORD) ?? []).some(hook));
|
|
3746
3987
|
switch (cmd) {
|
|
3747
3988
|
case "rm":
|
|
3748
3989
|
case "unlink":
|
|
@@ -3826,8 +4067,8 @@ function shellWritesHook(seg, toks, policy, ctx) {
|
|
|
3826
4067
|
function xargsWritesHook(toks, fed, policy, ctx) {
|
|
3827
4068
|
const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
|
|
3828
4069
|
if (at === -1) return null;
|
|
3829
|
-
const
|
|
3830
|
-
const fedHook = fed.some(
|
|
4070
|
+
const { hookish } = hookTests(policy, ctx);
|
|
4071
|
+
const fedHook = fed.some(hookish);
|
|
3831
4072
|
if (!fedHook) return null;
|
|
3832
4073
|
let i = at + 1;
|
|
3833
4074
|
while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
|
|
@@ -3859,11 +4100,23 @@ function eventEntries(s, event) {
|
|
|
3859
4100
|
return Array.isArray(arr) ? arr : null;
|
|
3860
4101
|
}
|
|
3861
4102
|
var declares = (s, event) => eventEntries(s, event) !== null;
|
|
3862
|
-
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
|
|
4103
|
+
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout", "statusMessage"]);
|
|
4104
|
+
var HOOK_TYPES = /* @__PURE__ */ new Set(["command", "prompt", "agent", "http"]);
|
|
4105
|
+
var SIBLING_KEYS = GATE_KEYS;
|
|
3863
4106
|
var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
|
|
3864
4107
|
var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
|
|
3865
4108
|
var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
|
|
3866
|
-
var
|
|
4109
|
+
var ENV_MECHANISM = [
|
|
4110
|
+
[/^PATH$/, "the gate's launcher is looked up through it"],
|
|
4111
|
+
[/^(?:NODE_OPTIONS|NODE_PATH)$/, "node loads what it names before the gate runs"],
|
|
4112
|
+
[/^HOME$/, "npx's cache and configuration live under it"],
|
|
4113
|
+
[/^npm_config_/i, "npx reads it as npm configuration, the registry the gate is fetched from included"],
|
|
4114
|
+
[/^(?:BASH_ENV|ENV)$/, "a non-interactive shell sources it before every hook command"],
|
|
4115
|
+
[/^BASH_FUNC_/, "bash imports it as a function that shadows the command it names"],
|
|
4116
|
+
[/^(?:LD|DYLD)_/, "the dynamic loader injects it into every process, the gate included"],
|
|
4117
|
+
[/^TAMPERWARD_/, "it configures the gate itself"]
|
|
4118
|
+
];
|
|
4119
|
+
var envMechanism = (k) => ENV_MECHANISM.find(([re]) => re.test(k))?.[1] ?? null;
|
|
3867
4120
|
function analyseGateCommand(cmd, event) {
|
|
3868
4121
|
const want = event === "PreToolUse" ? "hook" : "sweep";
|
|
3869
4122
|
const problems = [];
|
|
@@ -3922,7 +4175,7 @@ function matcherSelection(m) {
|
|
|
3922
4175
|
if (m === "" || m === "*") return { tools: null, problem: null };
|
|
3923
4176
|
let problem = null;
|
|
3924
4177
|
if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
|
|
3925
|
-
if (/^[A-Za-z0-9_
|
|
4178
|
+
if (/^[A-Za-z0-9_ ,|-]+$/.test(m)) return { tools: new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean)), problem };
|
|
3926
4179
|
let re;
|
|
3927
4180
|
try {
|
|
3928
4181
|
re = new RegExp(m);
|
|
@@ -3931,48 +4184,58 @@ function matcherSelection(m) {
|
|
|
3931
4184
|
}
|
|
3932
4185
|
return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
|
|
3933
4186
|
}
|
|
4187
|
+
function entryShape(h) {
|
|
4188
|
+
const problems = [];
|
|
4189
|
+
let runs = true;
|
|
4190
|
+
if (h.type !== "command") {
|
|
4191
|
+
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
4192
|
+
if (h.type !== void 0) runs = false;
|
|
4193
|
+
}
|
|
4194
|
+
for (const k of Object.keys(h)) {
|
|
4195
|
+
if (GATE_KEYS.has(k)) continue;
|
|
4196
|
+
problems.push(`carries \`${k}\``);
|
|
4197
|
+
runs = false;
|
|
4198
|
+
}
|
|
4199
|
+
if ("timeout" in h) {
|
|
4200
|
+
const t = h.timeout;
|
|
4201
|
+
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
4202
|
+
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
4203
|
+
runs = false;
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
if ("statusMessage" in h && typeof h.statusMessage !== "string") {
|
|
4207
|
+
problems.push(`\`statusMessage\` ${JSON.stringify(h.statusMessage)} is not a string`);
|
|
4208
|
+
runs = false;
|
|
4209
|
+
}
|
|
4210
|
+
return { problems, runs };
|
|
4211
|
+
}
|
|
4212
|
+
var mentionsGate = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
3934
4213
|
function gateCandidates(s, event) {
|
|
3935
4214
|
const out2 = [];
|
|
3936
|
-
const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
3937
4215
|
for (const group of eventEntries(s, event) ?? []) {
|
|
3938
4216
|
if (!isObj(group)) {
|
|
3939
|
-
if (
|
|
4217
|
+
if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
|
|
3940
4218
|
continue;
|
|
3941
4219
|
}
|
|
3942
4220
|
if (!Array.isArray(group.hooks)) {
|
|
3943
|
-
if (
|
|
4221
|
+
if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
|
|
3944
4222
|
continue;
|
|
3945
4223
|
}
|
|
3946
4224
|
for (const h of group.hooks) {
|
|
3947
4225
|
if (!isObj(h)) {
|
|
3948
|
-
if (
|
|
4226
|
+
if (mentionsGate(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
|
|
3949
4227
|
continue;
|
|
3950
4228
|
}
|
|
3951
4229
|
const cmd = typeof h.command === "string" ? h.command : null;
|
|
3952
4230
|
if (cmd === null) {
|
|
3953
|
-
if (
|
|
4231
|
+
if (mentionsGate(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
|
|
3954
4232
|
continue;
|
|
3955
4233
|
}
|
|
3956
4234
|
if (!MENTIONS_GATE.test(cmd)) continue;
|
|
3957
4235
|
const a = analyseGateCommand(cmd, event);
|
|
3958
|
-
const
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
3962
|
-
if (h.type !== void 0) runs = false;
|
|
3963
|
-
}
|
|
3964
|
-
for (const k of Object.keys(h)) {
|
|
3965
|
-
if (GATE_KEYS.has(k)) continue;
|
|
3966
|
-
problems.push(`carries \`${k}\``);
|
|
3967
|
-
runs = false;
|
|
3968
|
-
}
|
|
3969
|
-
if ("timeout" in h) {
|
|
3970
|
-
const t = h.timeout;
|
|
3971
|
-
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
3972
|
-
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
3973
|
-
runs = false;
|
|
3974
|
-
}
|
|
3975
|
-
}
|
|
4236
|
+
const shape = entryShape(h);
|
|
4237
|
+
const problems = [...a.problems, ...shape.problems];
|
|
4238
|
+
let runs = a.runs && shape.runs;
|
|
3976
4239
|
let tools = null;
|
|
3977
4240
|
if (event === "PreToolUse") {
|
|
3978
4241
|
const sel = matcherSelection(group.matcher);
|
|
@@ -3989,6 +4252,7 @@ function gateCandidates(s, event) {
|
|
|
3989
4252
|
}
|
|
3990
4253
|
return out2;
|
|
3991
4254
|
}
|
|
4255
|
+
var carriesGate = (s) => EVENTS.some((e) => gateCandidates(s, e).length > 0);
|
|
3992
4256
|
function otherCommands(s, event) {
|
|
3993
4257
|
const out2 = [];
|
|
3994
4258
|
for (const group of eventEntries(s, event) ?? []) {
|
|
@@ -4000,6 +4264,35 @@ function otherCommands(s, event) {
|
|
|
4000
4264
|
}
|
|
4001
4265
|
return out2;
|
|
4002
4266
|
}
|
|
4267
|
+
function allEntries(s) {
|
|
4268
|
+
const hooks = s.hooks;
|
|
4269
|
+
if (!isObj(hooks)) return [];
|
|
4270
|
+
const out2 = [];
|
|
4271
|
+
for (const [event, arr] of Object.entries(hooks)) {
|
|
4272
|
+
if (!Array.isArray(arr)) continue;
|
|
4273
|
+
for (const group of arr) {
|
|
4274
|
+
if (!isObj(group) || !Array.isArray(group.hooks)) continue;
|
|
4275
|
+
for (const h of group.hooks) out2.push({ event, key: JSON.stringify({ e: event, m: group.matcher, h }), group, h });
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
return out2;
|
|
4279
|
+
}
|
|
4280
|
+
function schemaProblem(group, h) {
|
|
4281
|
+
if (group.matcher !== void 0 && typeof group.matcher !== "string") return `the matcher ${JSON.stringify(group.matcher)} is not a string`;
|
|
4282
|
+
if (!isObj(h)) return `the hook entry ${JSON.stringify(h)} is not an object`;
|
|
4283
|
+
if (typeof h.type !== "string" || !HOOK_TYPES.has(h.type)) return `\`type\` ${JSON.stringify(h.type)} is not one the runtime accepts`;
|
|
4284
|
+
if (h.type === "command" && typeof h.command !== "string") return "`command` is not a string";
|
|
4285
|
+
if ("timeout" in h && (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0)) return `\`timeout\` ${JSON.stringify(h.timeout)} is not a positive number`;
|
|
4286
|
+
return null;
|
|
4287
|
+
}
|
|
4288
|
+
function siblingProblem(h) {
|
|
4289
|
+
const cmd = typeof h.command === "string" ? h.command : "";
|
|
4290
|
+
if (FORGERY.test(cmd)) return `emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`;
|
|
4291
|
+
if (h.type !== "command") return `is not a plain command entry (\`type\` is ${JSON.stringify(h.type)})`;
|
|
4292
|
+
const extra = Object.keys(h).filter((k) => !SIBLING_KEYS.has(k));
|
|
4293
|
+
if (extra.length) return `is not a plain command entry (carries \`${extra.join("`, `")}\`)`;
|
|
4294
|
+
return `was added (\`${cmd.slice(0, 60)}\`) \u2014 its output combines with the gate's verdict (an \`updatedInput\` after an allow, a \`continue: false\` over a block), and init writes no entry beside the gate`;
|
|
4295
|
+
}
|
|
4003
4296
|
function coverage(cs) {
|
|
4004
4297
|
const live = cs.filter((c) => c.runs);
|
|
4005
4298
|
if (live.length === 0) return "none";
|
|
@@ -4050,16 +4343,26 @@ function duplicateJsonKeys(src) {
|
|
|
4050
4343
|
}
|
|
4051
4344
|
function settingsWeakening(before, after, local) {
|
|
4052
4345
|
const reasons = [];
|
|
4346
|
+
const gated = carriesGate(after);
|
|
4347
|
+
const gatedBefore = before !== null && carriesGate(before);
|
|
4053
4348
|
const dis = after.disableAllHooks;
|
|
4054
4349
|
if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
|
|
4055
4350
|
reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
|
|
4056
4351
|
}
|
|
4352
|
+
if (gated && dis === void 0) {
|
|
4353
|
+
if (before !== null && before.disableAllHooks === false) {
|
|
4354
|
+
reasons.push("`disableAllHooks: false` was removed \u2014 init declares it so that a `true` in the user settings file cannot switch the gate off");
|
|
4355
|
+
} else if (!gatedBefore) {
|
|
4356
|
+
reasons.push("`disableAllHooks: false` is not declared \u2014 init declares it in the file it writes, so that a `true` in the user settings file cannot switch the gate off");
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4057
4359
|
if (isObj(after.env)) {
|
|
4058
4360
|
const was = before !== null && isObj(before.env) ? before.env : {};
|
|
4059
4361
|
for (const [k, v] of Object.entries(after.env)) {
|
|
4060
|
-
if (
|
|
4061
|
-
|
|
4062
|
-
}
|
|
4362
|
+
if (k in was && JSON.stringify(was[k]) === JSON.stringify(v)) continue;
|
|
4363
|
+
const how = envMechanism(k);
|
|
4364
|
+
if (how) reasons.push(`a top-level \`env\` sets ${k} \u2014 ${how}`);
|
|
4365
|
+
else if (gated) reasons.push(`a top-level \`env\` sets ${k} \u2014 every hook command runs with it, and init writes no \`env\``);
|
|
4063
4366
|
}
|
|
4064
4367
|
}
|
|
4065
4368
|
for (const event of EVENTS) {
|
|
@@ -4087,15 +4390,36 @@ function settingsWeakening(before, after, local) {
|
|
|
4087
4390
|
if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
|
|
4088
4391
|
}
|
|
4089
4392
|
const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
|
|
4090
|
-
const
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4393
|
+
const judging = PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null;
|
|
4394
|
+
const floor = pins[0] ?? (bc.length === 0 ? judging : null);
|
|
4395
|
+
for (const c of fresh) {
|
|
4396
|
+
if (c.pin === null || c.pin !== "" && !PLAIN_SEMVER.test(c.pin)) continue;
|
|
4397
|
+
if (floor !== null && !pinNotBelow(c.pin, floor)) {
|
|
4398
|
+
reasons.push(
|
|
4399
|
+
c.pin === "" ? `the ${event} gate is unpinned, below ${floor} \u2014 \`npx\` then runs whatever \`tamperward\` resolves to on this machine` : `the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`
|
|
4400
|
+
);
|
|
4401
|
+
} else if (judging !== null && c.pin !== "" && (compareVersions(c.pin, judging) ?? 0) > 0) {
|
|
4402
|
+
reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, above the ${judging} judging it \u2014 a pin npm cannot resolve fails the hook open; raise it by running \`tamperward init\` from the newer version, which raises the judging gate with it`);
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
}
|
|
4406
|
+
if (gated) {
|
|
4407
|
+
const had = new Set(before !== null ? allEntries(before).map((e) => e.key) : []);
|
|
4408
|
+
for (const e of allEntries(after)) {
|
|
4409
|
+
if (had.has(e.key) || mentionsGate(e.h)) continue;
|
|
4410
|
+
const schema = schemaProblem(e.group, e.h);
|
|
4411
|
+
if (schema) {
|
|
4412
|
+
reasons.push(`a ${e.event} hook entry carries a value the runtime's schema rejects (${schema}) \u2014 the runtime continues without the broken file, the gate included`);
|
|
4413
|
+
} else if (e.event === "PreToolUse" || e.event === "Stop") {
|
|
4414
|
+
reasons.push(`a ${e.event} hook entry beside the gate ${siblingProblem(e.h)}`);
|
|
4094
4415
|
}
|
|
4095
4416
|
}
|
|
4096
|
-
|
|
4097
|
-
for (const
|
|
4098
|
-
|
|
4417
|
+
} else {
|
|
4418
|
+
for (const event of EVENTS) {
|
|
4419
|
+
const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
|
|
4420
|
+
for (const cmd of otherCommands(after, event)) {
|
|
4421
|
+
if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
|
|
4422
|
+
}
|
|
4099
4423
|
}
|
|
4100
4424
|
}
|
|
4101
4425
|
return reasons;
|
|
@@ -4108,7 +4432,7 @@ function claudeSettingsFindings(c, policy) {
|
|
|
4108
4432
|
file: c.path,
|
|
4109
4433
|
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
4110
4434
|
evidence: reason,
|
|
4111
|
-
remediation: "Leave the PreToolUse and Stop entries that run tamperward,
|
|
4435
|
+
remediation: "Leave the PreToolUse and Stop entries that run tamperward, the matcher that covers every editing tool, and `disableAllHooks: false` exactly as `tamperward init` wrote them, with no `env` and no hook entry beside the gate. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
|
|
4112
4436
|
});
|
|
4113
4437
|
if (c.before != null && before === null) return null;
|
|
4114
4438
|
if (after === null) {
|
|
@@ -4151,7 +4475,7 @@ function lefthookBase(c, changes, ctx) {
|
|
|
4151
4475
|
for (const n of names) {
|
|
4152
4476
|
try {
|
|
4153
4477
|
const p = join3(ctx.cwd, n);
|
|
4154
|
-
if (
|
|
4478
|
+
if (existsSync2(p)) return parseDoc(readFileSync2(p, "utf8"));
|
|
4155
4479
|
} catch {
|
|
4156
4480
|
}
|
|
4157
4481
|
}
|
|
@@ -4221,9 +4545,9 @@ function updateIndexHiding(toks, policy) {
|
|
|
4221
4545
|
return null;
|
|
4222
4546
|
}
|
|
4223
4547
|
function repoRelative(path, ctx) {
|
|
4224
|
-
if (!ctx?.cwd || !
|
|
4548
|
+
if (!ctx?.cwd || !isAbsolute3(path)) return path;
|
|
4225
4549
|
const rel = relative(ctx.cwd, path);
|
|
4226
|
-
return rel && !rel.startsWith("..") && !
|
|
4550
|
+
return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
|
|
4227
4551
|
}
|
|
4228
4552
|
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
4229
4553
|
var hookTampering = {
|
|
@@ -4235,7 +4559,7 @@ var hookTampering = {
|
|
|
4235
4559
|
for (const c of changes) {
|
|
4236
4560
|
let semantic;
|
|
4237
4561
|
if (c.kind === "file") {
|
|
4238
|
-
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) ||
|
|
4562
|
+
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || resolvesToClaudeSettings(c.path);
|
|
4239
4563
|
if (!targetsHook) {
|
|
4240
4564
|
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
4241
4565
|
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
@@ -4279,7 +4603,7 @@ var hookTampering = {
|
|
|
4279
4603
|
remediation: "Renaming a hook out of place disables it. Restore the original path."
|
|
4280
4604
|
})
|
|
4281
4605
|
);
|
|
4282
|
-
} else if ((c.path.endsWith(POLICY_FILE) ||
|
|
4606
|
+
} else if ((c.path.endsWith(POLICY_FILE) || resolvesToClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
|
|
4283
4607
|
out2.push(...semantic);
|
|
4284
4608
|
} else if (c.op === "rename") {
|
|
4285
4609
|
} else if (isCodeowners(c.path)) {
|
|
@@ -4419,10 +4743,12 @@ var hookTampering = {
|
|
|
4419
4743
|
const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
|
|
4420
4744
|
if (why) {
|
|
4421
4745
|
const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
|
|
4746
|
+
const file = target ? repoRelative(target, ctx) : void 0;
|
|
4747
|
+
const outside = file !== void 0 && (isAbsolute3(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
|
|
4422
4748
|
out2.push(
|
|
4423
4749
|
makeFinding(RULE6, policy, {
|
|
4424
|
-
...
|
|
4425
|
-
message: `Hook tampering via shell: ${why}.`,
|
|
4750
|
+
...file !== void 0 ? { file } : {},
|
|
4751
|
+
message: `Hook tampering via shell: ${why}${outside}.`,
|
|
4426
4752
|
evidence: seg,
|
|
4427
4753
|
remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
|
|
4428
4754
|
})
|
|
@@ -5327,7 +5653,7 @@ var GUARDED_RULE = "hook-tampering";
|
|
|
5327
5653
|
var BASELINE_HOOKS = defaultPolicy().protected.hooks;
|
|
5328
5654
|
function isGuardedFinding(f) {
|
|
5329
5655
|
if (f.rule !== GUARDED_RULE || !f.file) return false;
|
|
5330
|
-
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) ||
|
|
5656
|
+
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || resolvesToClaudeSettings(f.file);
|
|
5331
5657
|
}
|
|
5332
5658
|
function key(f) {
|
|
5333
5659
|
return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
|
|
@@ -5386,8 +5712,8 @@ function hasBlocking(findings) {
|
|
|
5386
5712
|
}
|
|
5387
5713
|
|
|
5388
5714
|
// src/policy-load.ts
|
|
5389
|
-
import { readFileSync as
|
|
5390
|
-
import { isAbsolute as
|
|
5715
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
|
|
5716
|
+
import { isAbsolute as isAbsolute4, join as join4, posix as posix2 } from "node:path";
|
|
5391
5717
|
import { parse as parse2 } from "yaml";
|
|
5392
5718
|
var PolicyError = class extends Error {
|
|
5393
5719
|
};
|
|
@@ -5404,8 +5730,8 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
|
|
|
5404
5730
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5405
5731
|
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
5406
5732
|
function ledgerInsideRepo(ledger) {
|
|
5407
|
-
if (!ledger.trim() ||
|
|
5408
|
-
const norm2 =
|
|
5733
|
+
if (!ledger.trim() || isAbsolute4(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
5734
|
+
const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
|
|
5409
5735
|
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
5410
5736
|
}
|
|
5411
5737
|
function validate(r, where) {
|
|
@@ -5512,8 +5838,8 @@ function parseOrThrow(src, where) {
|
|
|
5512
5838
|
}
|
|
5513
5839
|
function loadPolicy(cwd = process.cwd()) {
|
|
5514
5840
|
const path = join4(cwd, POLICY_FILE);
|
|
5515
|
-
if (!
|
|
5516
|
-
return parseOrThrow(
|
|
5841
|
+
if (!existsSync3(path)) return defaultPolicy();
|
|
5842
|
+
return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
|
|
5517
5843
|
}
|
|
5518
5844
|
function loadPolicyAt(rev, cwd) {
|
|
5519
5845
|
const src = fileAt(rev, POLICY_FILE, { cwd });
|
|
@@ -5523,7 +5849,7 @@ function loadPolicyAt(rev, cwd) {
|
|
|
5523
5849
|
|
|
5524
5850
|
// src/signoff.ts
|
|
5525
5851
|
import { createHash } from "node:crypto";
|
|
5526
|
-
import { appendFileSync, mkdirSync, readFileSync as
|
|
5852
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
|
|
5527
5853
|
import { dirname as dirname2, join as join5 } from "node:path";
|
|
5528
5854
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
5529
5855
|
function fingerprint(rule, file, evidence) {
|
|
@@ -5539,9 +5865,9 @@ function ledgerPath(cwd, policy) {
|
|
|
5539
5865
|
}
|
|
5540
5866
|
function readLedger(cwd, policy) {
|
|
5541
5867
|
const p = ledgerPath(cwd, policy);
|
|
5542
|
-
if (!
|
|
5868
|
+
if (!existsSync4(p)) return [];
|
|
5543
5869
|
const out2 = [];
|
|
5544
|
-
for (const line of
|
|
5870
|
+
for (const line of readFileSync4(p, "utf8").split("\n")) {
|
|
5545
5871
|
if (!line.trim()) continue;
|
|
5546
5872
|
try {
|
|
5547
5873
|
const e = JSON.parse(line);
|
|
@@ -5694,7 +6020,7 @@ function renderText(input, opts) {
|
|
|
5694
6020
|
for (const f of findings) {
|
|
5695
6021
|
const isBlock = f.severity === "block";
|
|
5696
6022
|
const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
|
|
5697
|
-
const loc = f.file ? `${stripControl(f.file)}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
6023
|
+
const loc = f.file ? `${stripControl(escapeControl(f.file))}${f.line ? `:${f.line}` : ""}` : "(command)";
|
|
5698
6024
|
out2.push(` ${mark} ${paint(stripControl(f.rule), BOLD, c)} ${paint(loc, DIM, c)}`);
|
|
5699
6025
|
for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
|
|
5700
6026
|
const row = (key2, lines) => {
|
|
@@ -5886,6 +6212,7 @@ function check(opts) {
|
|
|
5886
6212
|
}
|
|
5887
6213
|
const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
|
|
5888
6214
|
let findings = evaluate(changes, policy, void 0, view, { cwd });
|
|
6215
|
+
if (view === "worktree") findings = findings.concat(unjudgeableProtected(cwd, policy, changes));
|
|
5889
6216
|
const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
|
|
5890
6217
|
findings = remaining;
|
|
5891
6218
|
if (cleared.length) {
|
|
@@ -5910,31 +6237,26 @@ function runCheck(opts) {
|
|
|
5910
6237
|
}
|
|
5911
6238
|
|
|
5912
6239
|
// src/cli/hook.ts
|
|
5913
|
-
import { readFileSync as
|
|
6240
|
+
import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
|
|
5914
6241
|
import { createHash as createHash4 } from "node:crypto";
|
|
5915
|
-
import { join as join10 } from "node:path";
|
|
5916
6242
|
|
|
5917
6243
|
// src/adapters/claude/changes.ts
|
|
5918
6244
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
5919
|
-
import {
|
|
6245
|
+
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
5920
6246
|
import { tmpdir } from "node:os";
|
|
5921
|
-
import { isAbsolute as
|
|
6247
|
+
import { isAbsolute as isAbsolute5, join as join6, relative as relative2, resolve as resolve2 } from "node:path";
|
|
5922
6248
|
function asStr(v) {
|
|
5923
6249
|
return typeof v === "string" ? v : "";
|
|
5924
6250
|
}
|
|
5925
6251
|
function readDisk(path) {
|
|
5926
|
-
|
|
5927
|
-
return existsSync4(path) ? readFileSync6(path, "utf8") : null;
|
|
5928
|
-
} catch {
|
|
5929
|
-
return null;
|
|
5930
|
-
}
|
|
6252
|
+
return textOf(inspectResolved(path));
|
|
5931
6253
|
}
|
|
5932
6254
|
function abs(path, cwd) {
|
|
5933
|
-
return
|
|
6255
|
+
return resolve2(cwd, path);
|
|
5934
6256
|
}
|
|
5935
6257
|
function relForDisplay(path, cwd) {
|
|
5936
6258
|
const rel = relative2(cwd, path);
|
|
5937
|
-
return rel && !rel.startsWith("..") && !
|
|
6259
|
+
return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
|
|
5938
6260
|
}
|
|
5939
6261
|
function applyEdit(content, oldStr, newStr) {
|
|
5940
6262
|
if (content === null) return newStr;
|
|
@@ -6021,7 +6343,7 @@ function formatDenial(blocks) {
|
|
|
6021
6343
|
const lead = blocks[0];
|
|
6022
6344
|
const lines = ["Tamperward blocked this change \u2014 it weakens a protected safety net to pass checks.", ""];
|
|
6023
6345
|
for (const f of blocks) {
|
|
6024
|
-
const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
|
|
6346
|
+
const loc = f.file ? ` (${escapeControl(f.file)}${f.line ? `:${f.line}` : ""})` : "";
|
|
6025
6347
|
lines.push(` \u2022 ${f.rule}${loc}: ${f.message}`);
|
|
6026
6348
|
}
|
|
6027
6349
|
lines.push("");
|
|
@@ -6036,7 +6358,7 @@ function formatDenial(blocks) {
|
|
|
6036
6358
|
}
|
|
6037
6359
|
|
|
6038
6360
|
// src/session.ts
|
|
6039
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as
|
|
6361
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
|
|
6040
6362
|
import { dirname as dirname3, join as join7 } from "node:path";
|
|
6041
6363
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
6042
6364
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
@@ -6051,7 +6373,7 @@ function turnBaseline(cwd, sessionId) {
|
|
|
6051
6373
|
const p = baselinePath(cwd, sessionId);
|
|
6052
6374
|
if (!p) return null;
|
|
6053
6375
|
if (existsSync5(p)) {
|
|
6054
|
-
const v =
|
|
6376
|
+
const v = readFileSync5(p, "utf8").trim();
|
|
6055
6377
|
if (SHA.test(v)) return v;
|
|
6056
6378
|
}
|
|
6057
6379
|
const head = headSha(cwd);
|
|
@@ -6077,8 +6399,8 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
6077
6399
|
|
|
6078
6400
|
// src/effect.ts
|
|
6079
6401
|
import { createHash as createHash2 } from "node:crypto";
|
|
6080
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as
|
|
6081
|
-
import { isAbsolute as
|
|
6402
|
+
import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
6403
|
+
import { isAbsolute as isAbsolute6, join as join8 } from "node:path";
|
|
6082
6404
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
6083
6405
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
6084
6406
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -6090,7 +6412,7 @@ function gitDir2(cwd) {
|
|
|
6090
6412
|
stdio: ["ignore", "pipe", "pipe"]
|
|
6091
6413
|
}).trim();
|
|
6092
6414
|
if (!d) return null;
|
|
6093
|
-
return
|
|
6415
|
+
return isAbsolute6(d) ? d : join8(cwd, d);
|
|
6094
6416
|
} catch {
|
|
6095
6417
|
return null;
|
|
6096
6418
|
}
|
|
@@ -6112,8 +6434,22 @@ var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
|
|
|
6112
6434
|
function contentHash(content) {
|
|
6113
6435
|
return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
|
|
6114
6436
|
}
|
|
6437
|
+
function entryOf(e) {
|
|
6438
|
+
const hash = e.content != null ? sha(e.content) : e.kind === "unreadable" ? "unreadable" : `${e.kind}:${e.detail}`;
|
|
6439
|
+
return { hash, mode: e.mode, size: e.size, mtimeMs: e.mtimeMs };
|
|
6440
|
+
}
|
|
6115
6441
|
function snapshotProtected(cwd, policy, prev) {
|
|
6442
|
+
void prev;
|
|
6116
6443
|
const out2 = {};
|
|
6444
|
+
const keep = (rel) => isProtected(rel, policy);
|
|
6445
|
+
let pruned = /* @__PURE__ */ new Set();
|
|
6446
|
+
let listed = [];
|
|
6447
|
+
try {
|
|
6448
|
+
const t = ignoredTree({ cwd }, keep);
|
|
6449
|
+
pruned = new Set(t.dirs.map((d) => d.replace(/\/$/, "")));
|
|
6450
|
+
listed = t.files;
|
|
6451
|
+
} catch {
|
|
6452
|
+
}
|
|
6117
6453
|
const walk2 = (rel) => {
|
|
6118
6454
|
const abs2 = rel ? join8(cwd, rel) : cwd;
|
|
6119
6455
|
let names;
|
|
@@ -6125,25 +6461,19 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
6125
6461
|
for (const name of names) {
|
|
6126
6462
|
if (SKIP_DIRS2.has(name)) continue;
|
|
6127
6463
|
const r = rel ? `${rel}/${name}` : name;
|
|
6464
|
+
if (pruned.has(r)) continue;
|
|
6128
6465
|
let st;
|
|
6129
6466
|
try {
|
|
6130
|
-
st =
|
|
6467
|
+
st = lstatSync2(join8(cwd, r));
|
|
6131
6468
|
} catch {
|
|
6132
6469
|
continue;
|
|
6133
6470
|
}
|
|
6134
6471
|
if (st.isDirectory()) walk2(r);
|
|
6135
|
-
else if (
|
|
6136
|
-
let hash = "";
|
|
6137
|
-
try {
|
|
6138
|
-
hash = sha(readFileSync8(join8(cwd, r)));
|
|
6139
|
-
} catch {
|
|
6140
|
-
hash = "unreadable";
|
|
6141
|
-
}
|
|
6142
|
-
out2[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
|
|
6143
|
-
}
|
|
6472
|
+
else if (keep(r)) out2[r] = entryOf(inspectRel(cwd, r));
|
|
6144
6473
|
}
|
|
6145
6474
|
};
|
|
6146
6475
|
walk2("");
|
|
6476
|
+
for (const rel of listed) if (out2[rel] === void 0) out2[rel] = entryOf(inspectRel(cwd, rel));
|
|
6147
6477
|
return out2;
|
|
6148
6478
|
}
|
|
6149
6479
|
function driftBetween(expected, current) {
|
|
@@ -6163,7 +6493,7 @@ function driftBetween(expected, current) {
|
|
|
6163
6493
|
function loadTree(p) {
|
|
6164
6494
|
if (!p || !existsSync6(p)) return null;
|
|
6165
6495
|
try {
|
|
6166
|
-
return JSON.parse(
|
|
6496
|
+
return JSON.parse(readFileSync6(p, "utf8"));
|
|
6167
6497
|
} catch {
|
|
6168
6498
|
return null;
|
|
6169
6499
|
}
|
|
@@ -6202,19 +6532,16 @@ function dropTurnTree(cwd, sessionId) {
|
|
|
6202
6532
|
}
|
|
6203
6533
|
|
|
6204
6534
|
// src/cli/watch.ts
|
|
6205
|
-
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2,
|
|
6535
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync, watch } from "node:fs";
|
|
6206
6536
|
import { join as join9 } from "node:path";
|
|
6207
6537
|
import { createHash as createHash3 } from "node:crypto";
|
|
6208
6538
|
var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
6209
6539
|
function snap(abs2) {
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
} catch {
|
|
6216
|
-
return { mode: null, size: null, hash: null };
|
|
6217
|
-
}
|
|
6540
|
+
const e = inspectPath(abs2);
|
|
6541
|
+
if (e.kind === "absent" || e.kind === "unreadable") return { mode: null, size: null, hash: null };
|
|
6542
|
+
if (e.kind !== "file" || e.content == null) return { mode: e.mode, size: null, hash: null };
|
|
6543
|
+
const hash = createHash3("sha256").update(e.content).digest("hex").slice(0, 16);
|
|
6544
|
+
return { mode: e.mode, size: e.size, hash };
|
|
6218
6545
|
}
|
|
6219
6546
|
function defaultEventLog(cwd) {
|
|
6220
6547
|
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
@@ -6240,7 +6567,7 @@ function watchTree(dir, cb) {
|
|
|
6240
6567
|
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
6241
6568
|
cb(kind, child);
|
|
6242
6569
|
try {
|
|
6243
|
-
if (
|
|
6570
|
+
if (statSync(join9(dir, child)).isDirectory()) addDir(child);
|
|
6244
6571
|
} catch {
|
|
6245
6572
|
}
|
|
6246
6573
|
});
|
|
@@ -6261,7 +6588,7 @@ function watchTree(dir, cb) {
|
|
|
6261
6588
|
const child = rel ? `${rel}/${name}` : name;
|
|
6262
6589
|
if (SKIP2.test(child + "/")) continue;
|
|
6263
6590
|
try {
|
|
6264
|
-
if (
|
|
6591
|
+
if (statSync(join9(dir, child)).isDirectory()) walk2(child);
|
|
6265
6592
|
} catch {
|
|
6266
6593
|
}
|
|
6267
6594
|
}
|
|
@@ -6306,17 +6633,17 @@ function runWatch(args) {
|
|
|
6306
6633
|
}
|
|
6307
6634
|
|
|
6308
6635
|
// src/detectors/fs-events.ts
|
|
6309
|
-
import { readFileSync as
|
|
6636
|
+
import { readFileSync as readFileSync8, statSync as statSync2 } from "node:fs";
|
|
6310
6637
|
var RULE11 = "transient-protected-mutation";
|
|
6311
6638
|
function readEvents(log, offset) {
|
|
6312
6639
|
let size = 0;
|
|
6313
6640
|
try {
|
|
6314
|
-
size =
|
|
6641
|
+
size = statSync2(log).size;
|
|
6315
6642
|
} catch {
|
|
6316
6643
|
return { events: [], newOffset: offset };
|
|
6317
6644
|
}
|
|
6318
6645
|
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
6319
|
-
const raw =
|
|
6646
|
+
const raw = readFileSync8(log, "utf8").slice(offset);
|
|
6320
6647
|
const events = [];
|
|
6321
6648
|
for (const line of raw.split("\n")) {
|
|
6322
6649
|
if (!line.trim()) continue;
|
|
@@ -6369,7 +6696,7 @@ var HookInputError = class extends Error {
|
|
|
6369
6696
|
};
|
|
6370
6697
|
function readStdin() {
|
|
6371
6698
|
try {
|
|
6372
|
-
return
|
|
6699
|
+
return readFileSync9(0, "utf8");
|
|
6373
6700
|
} catch (e) {
|
|
6374
6701
|
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
6375
6702
|
}
|
|
@@ -6395,7 +6722,7 @@ function recordDenylog(blocks) {
|
|
|
6395
6722
|
} catch {
|
|
6396
6723
|
}
|
|
6397
6724
|
}
|
|
6398
|
-
function
|
|
6725
|
+
function errText2(e) {
|
|
6399
6726
|
const m = e instanceof Error ? e.message : String(e);
|
|
6400
6727
|
return m.length > 200 ? m.slice(0, 200) + "\u2026" : m;
|
|
6401
6728
|
}
|
|
@@ -6427,11 +6754,8 @@ function fileChanges(changes) {
|
|
|
6427
6754
|
return changes.filter((c) => c.kind === "file");
|
|
6428
6755
|
}
|
|
6429
6756
|
function hashOnDisk(cwd, rel) {
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
} catch {
|
|
6433
|
-
return null;
|
|
6434
|
-
}
|
|
6757
|
+
const e = inspectRel(cwd, rel);
|
|
6758
|
+
return e.content == null ? null : contentHash(e.content);
|
|
6435
6759
|
}
|
|
6436
6760
|
function gitMode(mode) {
|
|
6437
6761
|
return (mode & 64) !== 0 ? "100755" : "100644";
|
|
@@ -6478,6 +6802,11 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
|
|
|
6478
6802
|
const candidates = base ? [base, "HEAD", ""] : ["HEAD", ""];
|
|
6479
6803
|
for (const rel of [...d.changed, ...d.deleted]) {
|
|
6480
6804
|
if (seen.has(rel)) continue;
|
|
6805
|
+
const unjudgeable = unjudgeableFinding(cwd, rel);
|
|
6806
|
+
if (unjudgeable) {
|
|
6807
|
+
blocks.push(unjudgeable);
|
|
6808
|
+
continue;
|
|
6809
|
+
}
|
|
6481
6810
|
const after = fileOnDisk(rel, { cwd });
|
|
6482
6811
|
const trustedBlob = fileAt(trusted, rel, { cwd });
|
|
6483
6812
|
const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
|
|
@@ -6526,6 +6855,11 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
|
|
|
6526
6855
|
}
|
|
6527
6856
|
for (const rel of d.added) {
|
|
6528
6857
|
if (seen.has(rel)) continue;
|
|
6858
|
+
const unjudgeable = unjudgeableFinding(cwd, rel);
|
|
6859
|
+
if (unjudgeable) {
|
|
6860
|
+
blocks.push(unjudgeable);
|
|
6861
|
+
continue;
|
|
6862
|
+
}
|
|
6529
6863
|
const after = fileOnDisk(rel, { cwd });
|
|
6530
6864
|
if (after == null) {
|
|
6531
6865
|
blocks.push({
|
|
@@ -6565,7 +6899,8 @@ function effectDriftBlocks(cwd, sessionId, policy) {
|
|
|
6565
6899
|
(c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
|
|
6566
6900
|
);
|
|
6567
6901
|
const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
|
|
6568
|
-
const
|
|
6902
|
+
const judged = [...scoped, ...gap.changes];
|
|
6903
|
+
const blocks = evaluate(judged, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks, unjudgeableProtected(cwd, policy, judged));
|
|
6569
6904
|
if (blocks.length > 0) return blocks;
|
|
6570
6905
|
savePtree(cwd, sessionId, current);
|
|
6571
6906
|
return null;
|
|
@@ -6602,7 +6937,7 @@ function preToolUseVerdict(input) {
|
|
|
6602
6937
|
if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
|
|
6603
6938
|
return verdict(blocks, "PreToolUse");
|
|
6604
6939
|
} catch (e) {
|
|
6605
|
-
return failClosed("PreToolUse",
|
|
6940
|
+
return failClosed("PreToolUse", errText2(e));
|
|
6606
6941
|
}
|
|
6607
6942
|
}
|
|
6608
6943
|
function cursorPath(cwd, sessionId) {
|
|
@@ -6617,7 +6952,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
6617
6952
|
if (!existsSync7(log)) return none;
|
|
6618
6953
|
let offset = 0;
|
|
6619
6954
|
if (cp && existsSync7(cp)) {
|
|
6620
|
-
const n = Number(
|
|
6955
|
+
const n = Number(readFileSync9(cp, "utf8"));
|
|
6621
6956
|
offset = Number.isFinite(n) ? n : 0;
|
|
6622
6957
|
}
|
|
6623
6958
|
const { events, newOffset } = readEvents(log, offset);
|
|
@@ -6629,12 +6964,8 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
6629
6964
|
} };
|
|
6630
6965
|
const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
|
|
6631
6966
|
const finalHash = (path) => {
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
|
|
6635
|
-
} catch {
|
|
6636
|
-
return null;
|
|
6637
|
-
}
|
|
6967
|
+
const e = inspectRel(cwd, path);
|
|
6968
|
+
return e.kind === "file" && e.content != null ? contentHash(e.content) : null;
|
|
6638
6969
|
};
|
|
6639
6970
|
const findings = transientFindings(events, persistent, policy, finalHash);
|
|
6640
6971
|
recordWarns(findings.filter((f) => f.severity !== "block"));
|
|
@@ -6675,7 +7006,7 @@ function stopVerdict(input) {
|
|
|
6675
7006
|
changes = changes.concat(gap.changes);
|
|
6676
7007
|
hiddenBlocks = gap.blocks;
|
|
6677
7008
|
}
|
|
6678
|
-
blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks);
|
|
7009
|
+
blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks, unjudgeableProtected(cwd, policy, changes));
|
|
6679
7010
|
const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
|
|
6680
7011
|
blocks = blocks.concat(transient.blocks);
|
|
6681
7012
|
commitCursor = transient.commit;
|
|
@@ -6684,7 +7015,7 @@ function stopVerdict(input) {
|
|
|
6684
7015
|
saveTurnTree(cwd, input.session_id, current);
|
|
6685
7016
|
}
|
|
6686
7017
|
} catch (e) {
|
|
6687
|
-
return failClosed("Stop",
|
|
7018
|
+
return failClosed("Stop", errText2(e));
|
|
6688
7019
|
}
|
|
6689
7020
|
if (blocks.length === 0) {
|
|
6690
7021
|
advanceTurnBaseline(cwd, input.session_id);
|
|
@@ -6700,28 +7031,28 @@ function preToolUseFromRaw(raw) {
|
|
|
6700
7031
|
try {
|
|
6701
7032
|
return preToolUseVerdict(parseInput(raw));
|
|
6702
7033
|
} catch (e) {
|
|
6703
|
-
return failClosed("PreToolUse",
|
|
7034
|
+
return failClosed("PreToolUse", errText2(e));
|
|
6704
7035
|
}
|
|
6705
7036
|
}
|
|
6706
7037
|
function stopFromRaw(raw) {
|
|
6707
7038
|
try {
|
|
6708
7039
|
return stopVerdict(parseInput(raw));
|
|
6709
7040
|
} catch (e) {
|
|
6710
|
-
return failClosed("Stop",
|
|
7041
|
+
return failClosed("Stop", errText2(e));
|
|
6711
7042
|
}
|
|
6712
7043
|
}
|
|
6713
7044
|
function runHookClaude() {
|
|
6714
7045
|
try {
|
|
6715
7046
|
return emit(preToolUseFromRaw(readStdin()));
|
|
6716
7047
|
} catch (e) {
|
|
6717
|
-
return emit(failClosed("PreToolUse",
|
|
7048
|
+
return emit(failClosed("PreToolUse", errText2(e)));
|
|
6718
7049
|
}
|
|
6719
7050
|
}
|
|
6720
7051
|
function runSweepClaude() {
|
|
6721
7052
|
try {
|
|
6722
7053
|
return emit(stopFromRaw(readStdin()));
|
|
6723
7054
|
} catch (e) {
|
|
6724
|
-
return emit(failClosed("Stop",
|
|
7055
|
+
return emit(failClosed("Stop", errText2(e)));
|
|
6725
7056
|
}
|
|
6726
7057
|
}
|
|
6727
7058
|
|
|
@@ -6774,9 +7105,9 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
6774
7105
|
// src/cli/init.ts
|
|
6775
7106
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
6776
7107
|
import { createHash as createHash5 } from "node:crypto";
|
|
6777
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as
|
|
6778
|
-
import { basename, dirname as dirname4, isAbsolute as
|
|
6779
|
-
var
|
|
7108
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
|
|
7109
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute7, join as join10, relative as relative3, resolve as resolve3 } from "node:path";
|
|
7110
|
+
var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
6780
7111
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6781
7112
|
function gitConfig(cwd, key2) {
|
|
6782
7113
|
try {
|
|
@@ -6925,13 +7256,13 @@ function readWorkflowMark(src) {
|
|
|
6925
7256
|
if (!m) return null;
|
|
6926
7257
|
return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
|
|
6927
7258
|
}
|
|
6928
|
-
function toolSet(
|
|
6929
|
-
const m = String(
|
|
7259
|
+
function toolSet(matcher) {
|
|
7260
|
+
const m = String(matcher ?? "").trim();
|
|
6930
7261
|
if (m === "" || m === "*") return null;
|
|
6931
|
-
return new Set(m.split(
|
|
7262
|
+
return new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean));
|
|
6932
7263
|
}
|
|
6933
|
-
function missingTools(
|
|
6934
|
-
const have = toolSet(
|
|
7264
|
+
function missingTools(matcher) {
|
|
7265
|
+
const have = toolSet(matcher);
|
|
6935
7266
|
if (have === null) return [];
|
|
6936
7267
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
6937
7268
|
}
|
|
@@ -6951,12 +7282,12 @@ function hooksShapeError(hooks) {
|
|
|
6951
7282
|
return null;
|
|
6952
7283
|
}
|
|
6953
7284
|
function planPolicy(cwd) {
|
|
6954
|
-
const path =
|
|
7285
|
+
const path = join10(cwd, POLICY_FILE);
|
|
6955
7286
|
if (existsSync8(path)) {
|
|
6956
7287
|
try {
|
|
6957
7288
|
loadPolicy(cwd);
|
|
6958
7289
|
} catch (e) {
|
|
6959
|
-
return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${
|
|
7290
|
+
return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${errText3(e)}; fix it, then re-run init (left untouched)` };
|
|
6960
7291
|
}
|
|
6961
7292
|
return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present and loads \u2014 left untouched" };
|
|
6962
7293
|
}
|
|
@@ -6970,12 +7301,12 @@ function planPolicy(cwd) {
|
|
|
6970
7301
|
}
|
|
6971
7302
|
function planClaudeHooks(cwd) {
|
|
6972
7303
|
const rel = ".claude/settings.json";
|
|
6973
|
-
const path =
|
|
7304
|
+
const path = join10(cwd, rel);
|
|
6974
7305
|
let settings = {};
|
|
6975
7306
|
if (existsSync8(path)) {
|
|
6976
7307
|
let parsed;
|
|
6977
7308
|
try {
|
|
6978
|
-
parsed = JSON.parse(
|
|
7309
|
+
parsed = JSON.parse(readFileSync10(path, "utf8"));
|
|
6979
7310
|
} catch {
|
|
6980
7311
|
return {
|
|
6981
7312
|
item: "agent",
|
|
@@ -7001,7 +7332,8 @@ function planClaudeHooks(cwd) {
|
|
|
7001
7332
|
const needStop = stopEntries.length === 0;
|
|
7002
7333
|
const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
|
|
7003
7334
|
const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
|
|
7004
|
-
|
|
7335
|
+
const needDisableFalse = settings.disableAllHooks !== false;
|
|
7336
|
+
if (!needPre && !needStop && stale.length === 0 && repin.length === 0 && !needDisableFalse) {
|
|
7005
7337
|
return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
7006
7338
|
}
|
|
7007
7339
|
const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
|
|
@@ -7014,7 +7346,8 @@ function planClaudeHooks(cwd) {
|
|
|
7014
7346
|
needPre && "wire PreToolUse deny",
|
|
7015
7347
|
needStop && "wire Stop sweep",
|
|
7016
7348
|
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`,
|
|
7017
|
-
repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})
|
|
7349
|
+
repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`,
|
|
7350
|
+
needDisableFalse && (settings.disableAllHooks === void 0 ? "declare disableAllHooks: false so the user settings file cannot switch the hooks off" : `set disableAllHooks: false (was ${JSON.stringify(settings.disableAllHooks)})`)
|
|
7018
7351
|
].filter(Boolean).join(" + "),
|
|
7019
7352
|
apply: () => {
|
|
7020
7353
|
for (const h of repin) {
|
|
@@ -7033,6 +7366,7 @@ function planClaudeHooks(cwd) {
|
|
|
7033
7366
|
if (needStop) {
|
|
7034
7367
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
7035
7368
|
}
|
|
7369
|
+
if (needDisableFalse) settings.disableAllHooks = false;
|
|
7036
7370
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
7037
7371
|
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
7038
7372
|
}
|
|
@@ -7065,14 +7399,14 @@ var MANAGED_HOOKS = [
|
|
|
7065
7399
|
function hooksDir(cwd) {
|
|
7066
7400
|
try {
|
|
7067
7401
|
const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
7068
|
-
if (out2) return
|
|
7402
|
+
if (out2) return resolve3(cwd, out2);
|
|
7069
7403
|
} catch {
|
|
7070
7404
|
}
|
|
7071
|
-
return existsSync8(
|
|
7405
|
+
return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
|
|
7072
7406
|
}
|
|
7073
7407
|
function display(cwd, path) {
|
|
7074
7408
|
const rel = relative3(cwd, path);
|
|
7075
|
-
return rel && !rel.startsWith("..") && !
|
|
7409
|
+
return rel && !rel.startsWith("..") && !isAbsolute7(rel) ? rel : path;
|
|
7076
7410
|
}
|
|
7077
7411
|
function unconditionalExitAt(lines) {
|
|
7078
7412
|
let depth = 0;
|
|
@@ -7095,30 +7429,30 @@ function planPreCommit(cwd) {
|
|
|
7095
7429
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
7096
7430
|
let path;
|
|
7097
7431
|
let note;
|
|
7098
|
-
if (existsSync8(
|
|
7099
|
-
path =
|
|
7432
|
+
if (existsSync8(join10(cwd, ".husky"))) {
|
|
7433
|
+
path = join10(cwd, ".husky", "pre-commit");
|
|
7100
7434
|
note = "husky";
|
|
7101
7435
|
} else {
|
|
7102
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(
|
|
7436
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
|
|
7103
7437
|
if (managed) {
|
|
7104
7438
|
return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
|
|
7105
7439
|
}
|
|
7106
7440
|
const dir = hooksDir(cwd);
|
|
7107
7441
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
7108
|
-
if (
|
|
7109
|
-
path =
|
|
7442
|
+
if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
|
|
7443
|
+
path = join10(dirname4(dir), "pre-commit");
|
|
7110
7444
|
note = "husky";
|
|
7111
7445
|
} else {
|
|
7112
|
-
path =
|
|
7446
|
+
path = join10(dir, "pre-commit");
|
|
7113
7447
|
const rel2 = display(cwd, dir);
|
|
7114
|
-
note = rel2 ===
|
|
7448
|
+
note = rel2 === join10(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
|
|
7115
7449
|
}
|
|
7116
7450
|
}
|
|
7117
7451
|
const rel = display(cwd, path);
|
|
7118
|
-
if (existsSync8(path) && !
|
|
7452
|
+
if (existsSync8(path) && !statSync3(path).isFile()) {
|
|
7119
7453
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
7120
7454
|
}
|
|
7121
|
-
const existing = existsSync8(path) ?
|
|
7455
|
+
const existing = existsSync8(path) ? readFileSync10(path, "utf8") : null;
|
|
7122
7456
|
if (existing === null) {
|
|
7123
7457
|
return {
|
|
7124
7458
|
item: "pre-commit",
|
|
@@ -7207,10 +7541,10 @@ ${block.join("\n")}
|
|
|
7207
7541
|
function planCodeowners(cwd) {
|
|
7208
7542
|
const rel = ".github/CODEOWNERS";
|
|
7209
7543
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
7210
|
-
(r) => existsSync8(
|
|
7544
|
+
(r) => existsSync8(join10(cwd, r))
|
|
7211
7545
|
);
|
|
7212
|
-
const path =
|
|
7213
|
-
const existing = existingRel ?
|
|
7546
|
+
const path = join10(cwd, existingRel ?? rel);
|
|
7547
|
+
const existing = existingRel ? readFileSync10(path, "utf8") : null;
|
|
7214
7548
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
7215
7549
|
if (missing.length === 0) {
|
|
7216
7550
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
@@ -7243,7 +7577,7 @@ ${CODEOWNERS_MARK}
|
|
|
7243
7577
|
}
|
|
7244
7578
|
function planWorkflow(cwd, force) {
|
|
7245
7579
|
const rel = ".github/workflows/tamperward.yml";
|
|
7246
|
-
const path =
|
|
7580
|
+
const path = join10(cwd, rel);
|
|
7247
7581
|
const write = () => {
|
|
7248
7582
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
7249
7583
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
@@ -7259,7 +7593,7 @@ function planWorkflow(cwd, force) {
|
|
|
7259
7593
|
}
|
|
7260
7594
|
let src;
|
|
7261
7595
|
try {
|
|
7262
|
-
src =
|
|
7596
|
+
src = readFileSync10(path, "utf8");
|
|
7263
7597
|
} catch {
|
|
7264
7598
|
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
7265
7599
|
}
|
|
@@ -7297,7 +7631,7 @@ function planned(item, path, plan) {
|
|
|
7297
7631
|
try {
|
|
7298
7632
|
return plan();
|
|
7299
7633
|
} catch (e) {
|
|
7300
|
-
return { item, path, status: "error", detail: `cannot plan this item \u2014 ${
|
|
7634
|
+
return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText3(e)}` };
|
|
7301
7635
|
}
|
|
7302
7636
|
}
|
|
7303
7637
|
function planInit(cwd, opts = {}) {
|
|
@@ -7321,7 +7655,7 @@ function runInit(opts) {
|
|
|
7321
7655
|
applied++;
|
|
7322
7656
|
} catch (e) {
|
|
7323
7657
|
a.status = "error";
|
|
7324
|
-
a.detail = `could not be written \u2014 ${
|
|
7658
|
+
a.detail = `could not be written \u2014 ${errText3(e)}`;
|
|
7325
7659
|
a.apply = void 0;
|
|
7326
7660
|
}
|
|
7327
7661
|
}
|
|
@@ -7358,15 +7692,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
7358
7692
|
// src/cli/verify.ts
|
|
7359
7693
|
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
7360
7694
|
import { createHash as createHash8 } from "node:crypto";
|
|
7361
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
7695
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync3, rmSync as rmSync3, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
|
|
7362
7696
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
7363
|
-
import { dirname as dirname5, join as
|
|
7697
|
+
import { dirname as dirname5, join as join13, resolve as resolve4 } from "node:path";
|
|
7364
7698
|
|
|
7365
7699
|
// src/git/trusted.ts
|
|
7366
7700
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
7367
7701
|
import { createHash as createHash6 } from "node:crypto";
|
|
7368
|
-
import { existsSync as existsSync9, readFileSync as
|
|
7369
|
-
import { isAbsolute as
|
|
7702
|
+
import { existsSync as existsSync9, readFileSync as readFileSync11 } from "node:fs";
|
|
7703
|
+
import { isAbsolute as isAbsolute8, join as join11 } from "node:path";
|
|
7370
7704
|
function trustedGitEnv() {
|
|
7371
7705
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
7372
7706
|
}
|
|
@@ -7379,7 +7713,7 @@ function gitDirOf(cwd) {
|
|
|
7379
7713
|
env: trustedGitEnv()
|
|
7380
7714
|
}).trim();
|
|
7381
7715
|
if (!d) return null;
|
|
7382
|
-
return
|
|
7716
|
+
return isAbsolute8(d) ? d : join11(cwd, d);
|
|
7383
7717
|
} catch {
|
|
7384
7718
|
return null;
|
|
7385
7719
|
}
|
|
@@ -7404,9 +7738,9 @@ function objectRewriteState(cwd) {
|
|
|
7404
7738
|
parts.push(`${rel}:<no-git-dir>`);
|
|
7405
7739
|
continue;
|
|
7406
7740
|
}
|
|
7407
|
-
const p =
|
|
7741
|
+
const p = join11(dir, rel);
|
|
7408
7742
|
try {
|
|
7409
|
-
parts.push(`${rel}:` + (existsSync9(p) ?
|
|
7743
|
+
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync11(p, "utf8").trim() : ""));
|
|
7410
7744
|
} catch {
|
|
7411
7745
|
parts.push(`${rel}:<unreadable>`);
|
|
7412
7746
|
}
|
|
@@ -7417,8 +7751,8 @@ function objectRewriteState(cwd) {
|
|
|
7417
7751
|
// src/fingerprint.ts
|
|
7418
7752
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
7419
7753
|
import { createHash as createHash7 } from "node:crypto";
|
|
7420
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
7421
|
-
import { join as
|
|
7754
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync as readlinkSync2, statSync as statSync4 } from "node:fs";
|
|
7755
|
+
import { join as join12 } from "node:path";
|
|
7422
7756
|
function treeFingerprint(cwd) {
|
|
7423
7757
|
const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
7424
7758
|
cwd,
|
|
@@ -7430,17 +7764,13 @@ function treeFingerprint(cwd) {
|
|
|
7430
7764
|
for (const rel of files) {
|
|
7431
7765
|
h.update(rel);
|
|
7432
7766
|
h.update("\0");
|
|
7433
|
-
|
|
7434
|
-
h.update(readFileSync14(join13(cwd, rel)));
|
|
7435
|
-
} catch {
|
|
7436
|
-
h.update("<unreadable>");
|
|
7437
|
-
}
|
|
7767
|
+
h.update(inspectRel(cwd, rel).content ?? "<unreadable>");
|
|
7438
7768
|
h.update("\0");
|
|
7439
7769
|
}
|
|
7440
7770
|
return h.digest("hex");
|
|
7441
7771
|
}
|
|
7442
7772
|
function depsFingerprint(cwd) {
|
|
7443
|
-
const root =
|
|
7773
|
+
const root = join12(cwd, "node_modules");
|
|
7444
7774
|
const h = createHash7("sha256");
|
|
7445
7775
|
const walk2 = (dir) => {
|
|
7446
7776
|
let entries;
|
|
@@ -7450,20 +7780,20 @@ function depsFingerprint(cwd) {
|
|
|
7450
7780
|
return;
|
|
7451
7781
|
}
|
|
7452
7782
|
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
7453
|
-
const p =
|
|
7783
|
+
const p = join12(dir, e.name);
|
|
7454
7784
|
if (e.isDirectory()) walk2(p);
|
|
7455
7785
|
else if (e.isFile()) {
|
|
7456
7786
|
h.update(p);
|
|
7457
7787
|
try {
|
|
7458
|
-
h.update(
|
|
7459
|
-
h.update(String(
|
|
7788
|
+
h.update(readFileSync12(p));
|
|
7789
|
+
h.update(String(statSync4(p).mode));
|
|
7460
7790
|
} catch {
|
|
7461
7791
|
h.update("<unreadable>");
|
|
7462
7792
|
}
|
|
7463
7793
|
} else if (e.isSymbolicLink()) {
|
|
7464
7794
|
h.update(p);
|
|
7465
7795
|
try {
|
|
7466
|
-
h.update(
|
|
7796
|
+
h.update(readlinkSync2(p));
|
|
7467
7797
|
} catch {
|
|
7468
7798
|
h.update("<unreadable-link>");
|
|
7469
7799
|
}
|
|
@@ -7471,7 +7801,7 @@ function depsFingerprint(cwd) {
|
|
|
7471
7801
|
}
|
|
7472
7802
|
};
|
|
7473
7803
|
try {
|
|
7474
|
-
if (!
|
|
7804
|
+
if (!statSync4(root).isDirectory()) return "none";
|
|
7475
7805
|
} catch {
|
|
7476
7806
|
return "none";
|
|
7477
7807
|
}
|
|
@@ -7570,30 +7900,30 @@ function baseIsAncestorOfHead(base, cwd) {
|
|
|
7570
7900
|
function materialize(cwd, dest) {
|
|
7571
7901
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
7572
7902
|
for (const rel of listed) {
|
|
7573
|
-
const src =
|
|
7903
|
+
const src = join13(cwd, rel);
|
|
7574
7904
|
let st;
|
|
7575
7905
|
try {
|
|
7576
|
-
st =
|
|
7906
|
+
st = statSync5(src);
|
|
7577
7907
|
} catch {
|
|
7578
7908
|
continue;
|
|
7579
7909
|
}
|
|
7580
7910
|
if (!st.isFile()) continue;
|
|
7581
|
-
const out2 =
|
|
7911
|
+
const out2 = join13(dest, rel);
|
|
7582
7912
|
mkdirSync6(dirname5(out2), { recursive: true });
|
|
7583
7913
|
dropSymlink(out2);
|
|
7584
|
-
if (
|
|
7585
|
-
writeFileSync6(out2,
|
|
7914
|
+
if (lstatSync3(src).isSymbolicLink()) {
|
|
7915
|
+
writeFileSync6(out2, readFileSync13(src));
|
|
7586
7916
|
} else {
|
|
7587
7917
|
cpSync(src, out2, { dereference: true });
|
|
7588
7918
|
}
|
|
7589
7919
|
chmodSync2(out2, st.mode);
|
|
7590
7920
|
}
|
|
7591
|
-
const nm =
|
|
7592
|
-
if (existsSync10(nm)) symlinkSync(nm,
|
|
7921
|
+
const nm = resolve4(cwd, "node_modules");
|
|
7922
|
+
if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
|
|
7593
7923
|
}
|
|
7594
7924
|
function dropSymlink(p) {
|
|
7595
7925
|
try {
|
|
7596
|
-
if (
|
|
7926
|
+
if (lstatSync3(p).isSymbolicLink()) rmSync3(p, { force: true });
|
|
7597
7927
|
} catch {
|
|
7598
7928
|
}
|
|
7599
7929
|
}
|
|
@@ -7622,11 +7952,11 @@ function overlayDigest(dest, paths) {
|
|
|
7622
7952
|
for (const rel of [...paths].sort()) {
|
|
7623
7953
|
h.update(rel);
|
|
7624
7954
|
h.update("\0");
|
|
7625
|
-
const p =
|
|
7955
|
+
const p = join13(dest, rel);
|
|
7626
7956
|
try {
|
|
7627
|
-
const st =
|
|
7957
|
+
const st = lstatSync3(p);
|
|
7628
7958
|
h.update(String(st.mode));
|
|
7629
|
-
h.update(st.isSymbolicLink() ?
|
|
7959
|
+
h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync13(p));
|
|
7630
7960
|
} catch {
|
|
7631
7961
|
h.update("<gone>");
|
|
7632
7962
|
}
|
|
@@ -7649,7 +7979,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
7649
7979
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
7650
7980
|
}
|
|
7651
7981
|
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
7652
|
-
const out2 =
|
|
7982
|
+
const out2 = join13(dest, e.path);
|
|
7653
7983
|
mkdirSync6(dirname5(out2), { recursive: true });
|
|
7654
7984
|
rmSync3(out2, { force: true });
|
|
7655
7985
|
if (e.mode === "120000") {
|
|
@@ -7664,7 +7994,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
7664
7994
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
7665
7995
|
for (const rel of inCopy) {
|
|
7666
7996
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
7667
|
-
rmSync3(
|
|
7997
|
+
rmSync3(join13(dest, rel), { force: true });
|
|
7668
7998
|
removed++;
|
|
7669
7999
|
}
|
|
7670
8000
|
return { restored, removed };
|
|
@@ -7706,8 +8036,8 @@ function suiteEnv(scratch) {
|
|
|
7706
8036
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
7707
8037
|
env[k] = v;
|
|
7708
8038
|
}
|
|
7709
|
-
const userRc =
|
|
7710
|
-
const globalRc =
|
|
8039
|
+
const userRc = join13(scratch, "npmrc-user");
|
|
8040
|
+
const globalRc = join13(scratch, "npmrc-global");
|
|
7711
8041
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
7712
8042
|
env.npm_config_userconfig = userRc;
|
|
7713
8043
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -7717,8 +8047,8 @@ function suiteEnv(scratch) {
|
|
|
7717
8047
|
}
|
|
7718
8048
|
function runSuite(dir, cmd, budgetSecs) {
|
|
7719
8049
|
const t0 = Date.now();
|
|
7720
|
-
const outDir = mkdtempSync2(
|
|
7721
|
-
const outFile =
|
|
8050
|
+
const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
|
|
8051
|
+
const outFile = join13(outDir, "result.json");
|
|
7722
8052
|
try {
|
|
7723
8053
|
spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
7724
8054
|
cwd: dir,
|
|
@@ -7731,7 +8061,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
7731
8061
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
7732
8062
|
let r;
|
|
7733
8063
|
try {
|
|
7734
|
-
r = JSON.parse(
|
|
8064
|
+
r = JSON.parse(readFileSync13(outFile, "utf8"));
|
|
7735
8065
|
} catch {
|
|
7736
8066
|
return { exit: null, secs };
|
|
7737
8067
|
}
|
|
@@ -7782,8 +8112,8 @@ function runVerify(opts) {
|
|
|
7782
8112
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
7783
8113
|
return 2;
|
|
7784
8114
|
}
|
|
7785
|
-
const visRoot = mkdtempSync2(
|
|
7786
|
-
const visDir =
|
|
8115
|
+
const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
|
|
8116
|
+
const visDir = join13(visRoot, "t");
|
|
7787
8117
|
const cleanup = (dirs) => {
|
|
7788
8118
|
if (opts.keep) return;
|
|
7789
8119
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
@@ -7811,8 +8141,8 @@ function runVerify(opts) {
|
|
|
7811
8141
|
out2("pristine run executes through it too. Failing closed, not open.");
|
|
7812
8142
|
return 2;
|
|
7813
8143
|
}
|
|
7814
|
-
const priRoot = mkdtempSync2(
|
|
7815
|
-
const priDir =
|
|
8144
|
+
const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
|
|
8145
|
+
const priDir = join13(priRoot, "t");
|
|
7816
8146
|
let restored = [];
|
|
7817
8147
|
let removedAdded = 0;
|
|
7818
8148
|
try {
|
|
@@ -7924,8 +8254,8 @@ function parseVerify(args) {
|
|
|
7924
8254
|
|
|
7925
8255
|
// src/cli/run.ts
|
|
7926
8256
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
7927
|
-
import { readdirSync as readdirSync4, readFileSync as
|
|
7928
|
-
import { resolve as
|
|
8257
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync as realpathSync3 } from "node:fs";
|
|
8258
|
+
import { resolve as resolve5 } from "node:path";
|
|
7929
8259
|
var out = (s) => process.stdout.write(s + "\n");
|
|
7930
8260
|
var err = (s) => process.stderr.write(s + "\n");
|
|
7931
8261
|
function git3(args, cwd) {
|
|
@@ -7933,7 +8263,7 @@ function git3(args, cwd) {
|
|
|
7933
8263
|
}
|
|
7934
8264
|
function nowTicks() {
|
|
7935
8265
|
try {
|
|
7936
|
-
return Math.floor(parseFloat(
|
|
8266
|
+
return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
7937
8267
|
} catch {
|
|
7938
8268
|
return Number.POSITIVE_INFINITY;
|
|
7939
8269
|
}
|
|
@@ -7942,7 +8272,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
7942
8272
|
const out2 = [];
|
|
7943
8273
|
let real;
|
|
7944
8274
|
try {
|
|
7945
|
-
real =
|
|
8275
|
+
real = realpathSync3(cwd);
|
|
7946
8276
|
} catch {
|
|
7947
8277
|
return out2;
|
|
7948
8278
|
}
|
|
@@ -7957,13 +8287,13 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
7957
8287
|
if (n === process.pid) continue;
|
|
7958
8288
|
let held;
|
|
7959
8289
|
try {
|
|
7960
|
-
held =
|
|
8290
|
+
held = realpathSync3(`/proc/${pid}/cwd`);
|
|
7961
8291
|
} catch {
|
|
7962
8292
|
continue;
|
|
7963
8293
|
}
|
|
7964
8294
|
if (held !== real && !held.startsWith(real + "/")) continue;
|
|
7965
8295
|
try {
|
|
7966
|
-
const stat =
|
|
8296
|
+
const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
|
|
7967
8297
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
7968
8298
|
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
7969
8299
|
} catch {
|
|
@@ -7972,7 +8302,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
7972
8302
|
return out2;
|
|
7973
8303
|
}
|
|
7974
8304
|
function runEnvelope(opts) {
|
|
7975
|
-
const cwd =
|
|
8305
|
+
const cwd = resolve5(opts.cwd ?? process.cwd());
|
|
7976
8306
|
if (!opts.argv.length) {
|
|
7977
8307
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
7978
8308
|
return 2;
|