tamperward 2.8.0 → 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 +282 -123
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1450,8 +1450,8 @@ function numericOf(e) {
|
|
|
1450
1450
|
}
|
|
1451
1451
|
return void 0;
|
|
1452
1452
|
}
|
|
1453
|
-
function metricsOf(expr,
|
|
1454
|
-
const obj =
|
|
1453
|
+
function metricsOf(expr, resolve6) {
|
|
1454
|
+
const obj = resolve6(expr);
|
|
1455
1455
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
1456
1456
|
const out2 = { values: {}, opaque: false };
|
|
1457
1457
|
let any = false;
|
|
@@ -1465,7 +1465,7 @@ function metricsOf(expr, resolve5) {
|
|
|
1465
1465
|
const k = keyName(p.name);
|
|
1466
1466
|
if (k === null || !isMetric(k)) continue;
|
|
1467
1467
|
any = true;
|
|
1468
|
-
const n = numericOf(
|
|
1468
|
+
const n = numericOf(resolve6(p.initializer));
|
|
1469
1469
|
if (n === void 0) out2.opaque = true;
|
|
1470
1470
|
else out2.values[k] = n;
|
|
1471
1471
|
}
|
|
@@ -1511,17 +1511,17 @@ function parseThresholds(src) {
|
|
|
1511
1511
|
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
1512
1512
|
try {
|
|
1513
1513
|
const sf = sourceOf(src);
|
|
1514
|
-
const
|
|
1514
|
+
const resolve6 = resolverFor(sf);
|
|
1515
1515
|
const visit = (node) => {
|
|
1516
1516
|
if (ts2.isPropertyAssignment(node)) {
|
|
1517
1517
|
const key2 = keyName(node.name);
|
|
1518
1518
|
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
1519
1519
|
res.present = true;
|
|
1520
|
-
const init =
|
|
1520
|
+
const init = resolve6(node.initializer);
|
|
1521
1521
|
if (!ts2.isObjectLiteralExpression(init)) {
|
|
1522
1522
|
res.opaque = true;
|
|
1523
1523
|
} else {
|
|
1524
|
-
const flat = metricsOf(init,
|
|
1524
|
+
const flat = metricsOf(init, resolve6);
|
|
1525
1525
|
if (flat) res.global = merge(res.global, flat);
|
|
1526
1526
|
for (const p of init.properties) {
|
|
1527
1527
|
if (ts2.isSpreadAssignment(p)) {
|
|
@@ -1535,7 +1535,7 @@ function parseThresholds(src) {
|
|
|
1535
1535
|
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
1536
1536
|
continue;
|
|
1537
1537
|
}
|
|
1538
|
-
const m = metricsOf(p.initializer,
|
|
1538
|
+
const m = metricsOf(p.initializer, resolve6);
|
|
1539
1539
|
if (!m) continue;
|
|
1540
1540
|
if (k === "global") res.global = merge(res.global, m);
|
|
1541
1541
|
else res.paths.set(norm(k), m);
|
|
@@ -1568,18 +1568,18 @@ function parseLists(src) {
|
|
|
1568
1568
|
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1569
1569
|
try {
|
|
1570
1570
|
const sf = sourceOf(src);
|
|
1571
|
-
const
|
|
1571
|
+
const resolve6 = resolverFor(sf);
|
|
1572
1572
|
const visit = (node) => {
|
|
1573
1573
|
if (ts2.isPropertyAssignment(node)) {
|
|
1574
1574
|
const k = keyName(node.name);
|
|
1575
1575
|
const vitestCoverage = ownerKey(node) === "coverage";
|
|
1576
1576
|
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
1577
|
-
const { items, opaque } = literals(
|
|
1577
|
+
const { items, opaque } = literals(resolve6(node.initializer));
|
|
1578
1578
|
res.collectFrom = items;
|
|
1579
1579
|
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
1580
1580
|
if (opaque) res.opaqueCollect = true;
|
|
1581
1581
|
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
1582
|
-
const { items, opaque } = literals(
|
|
1582
|
+
const { items, opaque } = literals(resolve6(node.initializer));
|
|
1583
1583
|
res.ignore.push(...items);
|
|
1584
1584
|
if (opaque) res.opaqueIgnore = true;
|
|
1585
1585
|
}
|
|
@@ -2660,12 +2660,13 @@ var ciTampering = {
|
|
|
2660
2660
|
};
|
|
2661
2661
|
|
|
2662
2662
|
// src/detectors/hook-tampering.ts
|
|
2663
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
2664
|
-
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";
|
|
2665
2665
|
|
|
2666
2666
|
// src/wiring.ts
|
|
2667
|
-
import { readFileSync } from "node:fs";
|
|
2668
|
-
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";
|
|
2669
2670
|
import { fileURLToPath } from "node:url";
|
|
2670
2671
|
function shippedVersion() {
|
|
2671
2672
|
try {
|
|
@@ -2689,7 +2690,7 @@ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
|
2689
2690
|
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
2690
2691
|
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
2691
2692
|
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2692
|
-
var PLAIN_SEMVER =
|
|
2693
|
+
var PLAIN_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
2693
2694
|
function compareVersions(a, b) {
|
|
2694
2695
|
if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
|
|
2695
2696
|
const pa = a.split(".").map(Number);
|
|
@@ -2707,8 +2708,41 @@ function initScriptPin(src) {
|
|
|
2707
2708
|
const m = src.match(INIT_SCRIPT);
|
|
2708
2709
|
return m ? m[1] ?? "" : null;
|
|
2709
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
|
+
}
|
|
2710
2719
|
var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
|
|
2711
|
-
|
|
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
|
+
}
|
|
2712
2746
|
|
|
2713
2747
|
// src/detectors/policy-diff.ts
|
|
2714
2748
|
import { parse } from "yaml";
|
|
@@ -2835,6 +2869,8 @@ function policyAddWeakening(after) {
|
|
|
2835
2869
|
}
|
|
2836
2870
|
|
|
2837
2871
|
// src/detectors/hook-wiring.ts
|
|
2872
|
+
import { existsSync } from "node:fs";
|
|
2873
|
+
import { isAbsolute as isAbsolute2, resolve } from "node:path";
|
|
2838
2874
|
import { parse as parseYaml } from "yaml";
|
|
2839
2875
|
function stripComment(line) {
|
|
2840
2876
|
let single = false;
|
|
@@ -3825,7 +3861,8 @@ var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$
|
|
|
3825
3861
|
var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
|
|
3826
3862
|
var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
|
|
3827
3863
|
var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
|
|
3828
|
-
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"];
|
|
3829
3866
|
function nameGlob(pattern) {
|
|
3830
3867
|
const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
3831
3868
|
return (n) => re.test(n);
|
|
@@ -3864,24 +3901,56 @@ function destinations(cmd, args, isDir) {
|
|
|
3864
3901
|
return [dest];
|
|
3865
3902
|
}
|
|
3866
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
|
+
}
|
|
3867
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();
|
|
3868
3913
|
const rel = (t) => {
|
|
3869
|
-
|
|
3870
|
-
if (
|
|
3871
|
-
|
|
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, "")));
|
|
3872
3933
|
};
|
|
3873
|
-
const
|
|
3874
|
-
const
|
|
3875
|
-
const
|
|
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;
|
|
3939
|
+
};
|
|
3940
|
+
const holds = (t) => !t.startsWith("-") && (whole(t) || userSettingsUnder(t) !== null || containsProtected(rel(t), policy, "hooks", ctx));
|
|
3876
3941
|
const hookish = (t) => hook(t) || holds(t);
|
|
3877
|
-
return { rel, hook, whole, holds, hookish };
|
|
3942
|
+
return { rel, hook, whole, holds, hookish, userSettingsUnder };
|
|
3878
3943
|
}
|
|
3879
3944
|
function shellHookTarget(seg, toks, policy, ctx) {
|
|
3880
|
-
const { rel, hook, whole, holds } = hookTests(policy, ctx);
|
|
3945
|
+
const { rel, hook, whole, holds, userSettingsUnder } = hookTests(policy, ctx);
|
|
3881
3946
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
3882
3947
|
if (hook(m[1])) return rel(m[1]);
|
|
3883
3948
|
}
|
|
3884
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
|
+
}
|
|
3885
3954
|
const fileLike = (p) => {
|
|
3886
3955
|
const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
3887
3956
|
return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
|
|
@@ -3889,6 +3958,8 @@ function shellHookTarget(seg, toks, policy, ctx) {
|
|
|
3889
3958
|
for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
|
|
3890
3959
|
for (const t of args) {
|
|
3891
3960
|
if (t.startsWith("-") || !holds(t)) continue;
|
|
3961
|
+
const user = userSettingsUnder(t);
|
|
3962
|
+
if (user !== null) return user;
|
|
3892
3963
|
const dir = rel(t).replace(/\/+$/, "");
|
|
3893
3964
|
const under = whole(t) ? "" : dir + "/";
|
|
3894
3965
|
const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
|
|
@@ -3912,7 +3983,7 @@ function shellWritesHook(seg, toks, policy, ctx) {
|
|
|
3912
3983
|
const anyHook = args.some(hook);
|
|
3913
3984
|
const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
|
|
3914
3985
|
const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
|
|
3915
|
-
const namesHookInText = () => scripts.some((a) => (a.match(
|
|
3986
|
+
const namesHookInText = () => scripts.some((a) => (a.match(SCRIPT_WORD) ?? []).some(hook));
|
|
3916
3987
|
switch (cmd) {
|
|
3917
3988
|
case "rm":
|
|
3918
3989
|
case "unlink":
|
|
@@ -3996,8 +4067,8 @@ function shellWritesHook(seg, toks, policy, ctx) {
|
|
|
3996
4067
|
function xargsWritesHook(toks, fed, policy, ctx) {
|
|
3997
4068
|
const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
|
|
3998
4069
|
if (at === -1) return null;
|
|
3999
|
-
const
|
|
4000
|
-
const fedHook = fed.some(
|
|
4070
|
+
const { hookish } = hookTests(policy, ctx);
|
|
4071
|
+
const fedHook = fed.some(hookish);
|
|
4001
4072
|
if (!fedHook) return null;
|
|
4002
4073
|
let i = at + 1;
|
|
4003
4074
|
while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
|
|
@@ -4029,11 +4100,23 @@ function eventEntries(s, event) {
|
|
|
4029
4100
|
return Array.isArray(arr) ? arr : null;
|
|
4030
4101
|
}
|
|
4031
4102
|
var declares = (s, event) => eventEntries(s, event) !== null;
|
|
4032
|
-
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;
|
|
4033
4106
|
var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
|
|
4034
4107
|
var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
|
|
4035
4108
|
var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
|
|
4036
|
-
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;
|
|
4037
4120
|
function analyseGateCommand(cmd, event) {
|
|
4038
4121
|
const want = event === "PreToolUse" ? "hook" : "sweep";
|
|
4039
4122
|
const problems = [];
|
|
@@ -4092,7 +4175,7 @@ function matcherSelection(m) {
|
|
|
4092
4175
|
if (m === "" || m === "*") return { tools: null, problem: null };
|
|
4093
4176
|
let problem = null;
|
|
4094
4177
|
if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
|
|
4095
|
-
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 };
|
|
4096
4179
|
let re;
|
|
4097
4180
|
try {
|
|
4098
4181
|
re = new RegExp(m);
|
|
@@ -4101,48 +4184,58 @@ function matcherSelection(m) {
|
|
|
4101
4184
|
}
|
|
4102
4185
|
return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
|
|
4103
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) ?? "");
|
|
4104
4213
|
function gateCandidates(s, event) {
|
|
4105
4214
|
const out2 = [];
|
|
4106
|
-
const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
4107
4215
|
for (const group of eventEntries(s, event) ?? []) {
|
|
4108
4216
|
if (!isObj(group)) {
|
|
4109
|
-
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 });
|
|
4110
4218
|
continue;
|
|
4111
4219
|
}
|
|
4112
4220
|
if (!Array.isArray(group.hooks)) {
|
|
4113
|
-
if (
|
|
4221
|
+
if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
|
|
4114
4222
|
continue;
|
|
4115
4223
|
}
|
|
4116
4224
|
for (const h of group.hooks) {
|
|
4117
4225
|
if (!isObj(h)) {
|
|
4118
|
-
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 });
|
|
4119
4227
|
continue;
|
|
4120
4228
|
}
|
|
4121
4229
|
const cmd = typeof h.command === "string" ? h.command : null;
|
|
4122
4230
|
if (cmd === null) {
|
|
4123
|
-
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 });
|
|
4124
4232
|
continue;
|
|
4125
4233
|
}
|
|
4126
4234
|
if (!MENTIONS_GATE.test(cmd)) continue;
|
|
4127
4235
|
const a = analyseGateCommand(cmd, event);
|
|
4128
|
-
const
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
4132
|
-
if (h.type !== void 0) runs = false;
|
|
4133
|
-
}
|
|
4134
|
-
for (const k of Object.keys(h)) {
|
|
4135
|
-
if (GATE_KEYS.has(k)) continue;
|
|
4136
|
-
problems.push(`carries \`${k}\``);
|
|
4137
|
-
runs = false;
|
|
4138
|
-
}
|
|
4139
|
-
if ("timeout" in h) {
|
|
4140
|
-
const t = h.timeout;
|
|
4141
|
-
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
4142
|
-
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
4143
|
-
runs = false;
|
|
4144
|
-
}
|
|
4145
|
-
}
|
|
4236
|
+
const shape = entryShape(h);
|
|
4237
|
+
const problems = [...a.problems, ...shape.problems];
|
|
4238
|
+
let runs = a.runs && shape.runs;
|
|
4146
4239
|
let tools = null;
|
|
4147
4240
|
if (event === "PreToolUse") {
|
|
4148
4241
|
const sel = matcherSelection(group.matcher);
|
|
@@ -4159,6 +4252,7 @@ function gateCandidates(s, event) {
|
|
|
4159
4252
|
}
|
|
4160
4253
|
return out2;
|
|
4161
4254
|
}
|
|
4255
|
+
var carriesGate = (s) => EVENTS.some((e) => gateCandidates(s, e).length > 0);
|
|
4162
4256
|
function otherCommands(s, event) {
|
|
4163
4257
|
const out2 = [];
|
|
4164
4258
|
for (const group of eventEntries(s, event) ?? []) {
|
|
@@ -4170,6 +4264,35 @@ function otherCommands(s, event) {
|
|
|
4170
4264
|
}
|
|
4171
4265
|
return out2;
|
|
4172
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
|
+
}
|
|
4173
4296
|
function coverage(cs) {
|
|
4174
4297
|
const live = cs.filter((c) => c.runs);
|
|
4175
4298
|
if (live.length === 0) return "none";
|
|
@@ -4220,16 +4343,26 @@ function duplicateJsonKeys(src) {
|
|
|
4220
4343
|
}
|
|
4221
4344
|
function settingsWeakening(before, after, local) {
|
|
4222
4345
|
const reasons = [];
|
|
4346
|
+
const gated = carriesGate(after);
|
|
4347
|
+
const gatedBefore = before !== null && carriesGate(before);
|
|
4223
4348
|
const dis = after.disableAllHooks;
|
|
4224
4349
|
if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
|
|
4225
4350
|
reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
|
|
4226
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
|
+
}
|
|
4227
4359
|
if (isObj(after.env)) {
|
|
4228
4360
|
const was = before !== null && isObj(before.env) ? before.env : {};
|
|
4229
4361
|
for (const [k, v] of Object.entries(after.env)) {
|
|
4230
|
-
if (
|
|
4231
|
-
|
|
4232
|
-
}
|
|
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\``);
|
|
4233
4366
|
}
|
|
4234
4367
|
}
|
|
4235
4368
|
for (const event of EVENTS) {
|
|
@@ -4257,15 +4390,36 @@ function settingsWeakening(before, after, local) {
|
|
|
4257
4390
|
if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
|
|
4258
4391
|
}
|
|
4259
4392
|
const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
|
|
4260
|
-
const
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
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)}`);
|
|
4264
4415
|
}
|
|
4265
4416
|
}
|
|
4266
|
-
|
|
4267
|
-
for (const
|
|
4268
|
-
|
|
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
|
+
}
|
|
4269
4423
|
}
|
|
4270
4424
|
}
|
|
4271
4425
|
return reasons;
|
|
@@ -4278,7 +4432,7 @@ function claudeSettingsFindings(c, policy) {
|
|
|
4278
4432
|
file: c.path,
|
|
4279
4433
|
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
4280
4434
|
evidence: reason,
|
|
4281
|
-
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."
|
|
4282
4436
|
});
|
|
4283
4437
|
if (c.before != null && before === null) return null;
|
|
4284
4438
|
if (after === null) {
|
|
@@ -4321,7 +4475,7 @@ function lefthookBase(c, changes, ctx) {
|
|
|
4321
4475
|
for (const n of names) {
|
|
4322
4476
|
try {
|
|
4323
4477
|
const p = join3(ctx.cwd, n);
|
|
4324
|
-
if (
|
|
4478
|
+
if (existsSync2(p)) return parseDoc(readFileSync2(p, "utf8"));
|
|
4325
4479
|
} catch {
|
|
4326
4480
|
}
|
|
4327
4481
|
}
|
|
@@ -4391,9 +4545,9 @@ function updateIndexHiding(toks, policy) {
|
|
|
4391
4545
|
return null;
|
|
4392
4546
|
}
|
|
4393
4547
|
function repoRelative(path, ctx) {
|
|
4394
|
-
if (!ctx?.cwd || !
|
|
4548
|
+
if (!ctx?.cwd || !isAbsolute3(path)) return path;
|
|
4395
4549
|
const rel = relative(ctx.cwd, path);
|
|
4396
|
-
return rel && !rel.startsWith("..") && !
|
|
4550
|
+
return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
|
|
4397
4551
|
}
|
|
4398
4552
|
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
4399
4553
|
var hookTampering = {
|
|
@@ -4405,7 +4559,7 @@ var hookTampering = {
|
|
|
4405
4559
|
for (const c of changes) {
|
|
4406
4560
|
let semantic;
|
|
4407
4561
|
if (c.kind === "file") {
|
|
4408
|
-
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);
|
|
4409
4563
|
if (!targetsHook) {
|
|
4410
4564
|
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
4411
4565
|
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
@@ -4449,7 +4603,7 @@ var hookTampering = {
|
|
|
4449
4603
|
remediation: "Renaming a hook out of place disables it. Restore the original path."
|
|
4450
4604
|
})
|
|
4451
4605
|
);
|
|
4452
|
-
} 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) {
|
|
4453
4607
|
out2.push(...semantic);
|
|
4454
4608
|
} else if (c.op === "rename") {
|
|
4455
4609
|
} else if (isCodeowners(c.path)) {
|
|
@@ -4589,10 +4743,12 @@ var hookTampering = {
|
|
|
4589
4743
|
const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
|
|
4590
4744
|
if (why) {
|
|
4591
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` : "";
|
|
4592
4748
|
out2.push(
|
|
4593
4749
|
makeFinding(RULE6, policy, {
|
|
4594
|
-
...
|
|
4595
|
-
message: `Hook tampering via shell: ${why}.`,
|
|
4750
|
+
...file !== void 0 ? { file } : {},
|
|
4751
|
+
message: `Hook tampering via shell: ${why}${outside}.`,
|
|
4596
4752
|
evidence: seg,
|
|
4597
4753
|
remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
|
|
4598
4754
|
})
|
|
@@ -5497,7 +5653,7 @@ var GUARDED_RULE = "hook-tampering";
|
|
|
5497
5653
|
var BASELINE_HOOKS = defaultPolicy().protected.hooks;
|
|
5498
5654
|
function isGuardedFinding(f) {
|
|
5499
5655
|
if (f.rule !== GUARDED_RULE || !f.file) return false;
|
|
5500
|
-
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) ||
|
|
5656
|
+
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || resolvesToClaudeSettings(f.file);
|
|
5501
5657
|
}
|
|
5502
5658
|
function key(f) {
|
|
5503
5659
|
return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
|
|
@@ -5556,8 +5712,8 @@ function hasBlocking(findings) {
|
|
|
5556
5712
|
}
|
|
5557
5713
|
|
|
5558
5714
|
// src/policy-load.ts
|
|
5559
|
-
import { readFileSync as readFileSync3, existsSync as
|
|
5560
|
-
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";
|
|
5561
5717
|
import { parse as parse2 } from "yaml";
|
|
5562
5718
|
var PolicyError = class extends Error {
|
|
5563
5719
|
};
|
|
@@ -5574,8 +5730,8 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
|
|
|
5574
5730
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5575
5731
|
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
5576
5732
|
function ledgerInsideRepo(ledger) {
|
|
5577
|
-
if (!ledger.trim() ||
|
|
5578
|
-
const norm2 =
|
|
5733
|
+
if (!ledger.trim() || isAbsolute4(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
5734
|
+
const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
|
|
5579
5735
|
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
5580
5736
|
}
|
|
5581
5737
|
function validate(r, where) {
|
|
@@ -5682,7 +5838,7 @@ function parseOrThrow(src, where) {
|
|
|
5682
5838
|
}
|
|
5683
5839
|
function loadPolicy(cwd = process.cwd()) {
|
|
5684
5840
|
const path = join4(cwd, POLICY_FILE);
|
|
5685
|
-
if (!
|
|
5841
|
+
if (!existsSync3(path)) return defaultPolicy();
|
|
5686
5842
|
return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
|
|
5687
5843
|
}
|
|
5688
5844
|
function loadPolicyAt(rev, cwd) {
|
|
@@ -5693,7 +5849,7 @@ function loadPolicyAt(rev, cwd) {
|
|
|
5693
5849
|
|
|
5694
5850
|
// src/signoff.ts
|
|
5695
5851
|
import { createHash } from "node:crypto";
|
|
5696
|
-
import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as
|
|
5852
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
|
|
5697
5853
|
import { dirname as dirname2, join as join5 } from "node:path";
|
|
5698
5854
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
5699
5855
|
function fingerprint(rule, file, evidence) {
|
|
@@ -5709,7 +5865,7 @@ function ledgerPath(cwd, policy) {
|
|
|
5709
5865
|
}
|
|
5710
5866
|
function readLedger(cwd, policy) {
|
|
5711
5867
|
const p = ledgerPath(cwd, policy);
|
|
5712
|
-
if (!
|
|
5868
|
+
if (!existsSync4(p)) return [];
|
|
5713
5869
|
const out2 = [];
|
|
5714
5870
|
for (const line of readFileSync4(p, "utf8").split("\n")) {
|
|
5715
5871
|
if (!line.trim()) continue;
|
|
@@ -6081,14 +6237,14 @@ function runCheck(opts) {
|
|
|
6081
6237
|
}
|
|
6082
6238
|
|
|
6083
6239
|
// src/cli/hook.ts
|
|
6084
|
-
import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as
|
|
6240
|
+
import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
|
|
6085
6241
|
import { createHash as createHash4 } from "node:crypto";
|
|
6086
6242
|
|
|
6087
6243
|
// src/adapters/claude/changes.ts
|
|
6088
6244
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
6089
6245
|
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
6090
6246
|
import { tmpdir } from "node:os";
|
|
6091
|
-
import { isAbsolute as
|
|
6247
|
+
import { isAbsolute as isAbsolute5, join as join6, relative as relative2, resolve as resolve2 } from "node:path";
|
|
6092
6248
|
function asStr(v) {
|
|
6093
6249
|
return typeof v === "string" ? v : "";
|
|
6094
6250
|
}
|
|
@@ -6096,11 +6252,11 @@ function readDisk(path) {
|
|
|
6096
6252
|
return textOf(inspectResolved(path));
|
|
6097
6253
|
}
|
|
6098
6254
|
function abs(path, cwd) {
|
|
6099
|
-
return
|
|
6255
|
+
return resolve2(cwd, path);
|
|
6100
6256
|
}
|
|
6101
6257
|
function relForDisplay(path, cwd) {
|
|
6102
6258
|
const rel = relative2(cwd, path);
|
|
6103
|
-
return rel && !rel.startsWith("..") && !
|
|
6259
|
+
return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
|
|
6104
6260
|
}
|
|
6105
6261
|
function applyEdit(content, oldStr, newStr) {
|
|
6106
6262
|
if (content === null) return newStr;
|
|
@@ -6202,7 +6358,7 @@ function formatDenial(blocks) {
|
|
|
6202
6358
|
}
|
|
6203
6359
|
|
|
6204
6360
|
// src/session.ts
|
|
6205
|
-
import { existsSync as
|
|
6361
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
|
|
6206
6362
|
import { dirname as dirname3, join as join7 } from "node:path";
|
|
6207
6363
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
6208
6364
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
@@ -6216,7 +6372,7 @@ function turnBaseline(cwd, sessionId) {
|
|
|
6216
6372
|
try {
|
|
6217
6373
|
const p = baselinePath(cwd, sessionId);
|
|
6218
6374
|
if (!p) return null;
|
|
6219
|
-
if (
|
|
6375
|
+
if (existsSync5(p)) {
|
|
6220
6376
|
const v = readFileSync5(p, "utf8").trim();
|
|
6221
6377
|
if (SHA.test(v)) return v;
|
|
6222
6378
|
}
|
|
@@ -6243,8 +6399,8 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
6243
6399
|
|
|
6244
6400
|
// src/effect.ts
|
|
6245
6401
|
import { createHash as createHash2 } from "node:crypto";
|
|
6246
|
-
import { existsSync as
|
|
6247
|
-
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";
|
|
6248
6404
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
6249
6405
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
6250
6406
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -6256,7 +6412,7 @@ function gitDir2(cwd) {
|
|
|
6256
6412
|
stdio: ["ignore", "pipe", "pipe"]
|
|
6257
6413
|
}).trim();
|
|
6258
6414
|
if (!d) return null;
|
|
6259
|
-
return
|
|
6415
|
+
return isAbsolute6(d) ? d : join8(cwd, d);
|
|
6260
6416
|
} catch {
|
|
6261
6417
|
return null;
|
|
6262
6418
|
}
|
|
@@ -6335,7 +6491,7 @@ function driftBetween(expected, current) {
|
|
|
6335
6491
|
return { changed, deleted, added };
|
|
6336
6492
|
}
|
|
6337
6493
|
function loadTree(p) {
|
|
6338
|
-
if (!p || !
|
|
6494
|
+
if (!p || !existsSync6(p)) return null;
|
|
6339
6495
|
try {
|
|
6340
6496
|
return JSON.parse(readFileSync6(p, "utf8"));
|
|
6341
6497
|
} catch {
|
|
@@ -6793,9 +6949,9 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
6793
6949
|
} };
|
|
6794
6950
|
const log = defaultEventLog(cwd);
|
|
6795
6951
|
const cp = cursorPath(cwd, sessionId);
|
|
6796
|
-
if (!
|
|
6952
|
+
if (!existsSync7(log)) return none;
|
|
6797
6953
|
let offset = 0;
|
|
6798
|
-
if (cp &&
|
|
6954
|
+
if (cp && existsSync7(cp)) {
|
|
6799
6955
|
const n = Number(readFileSync9(cp, "utf8"));
|
|
6800
6956
|
offset = Number.isFinite(n) ? n : 0;
|
|
6801
6957
|
}
|
|
@@ -6949,8 +7105,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
6949
7105
|
// src/cli/init.ts
|
|
6950
7106
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
6951
7107
|
import { createHash as createHash5 } from "node:crypto";
|
|
6952
|
-
import { existsSync as
|
|
6953
|
-
import { basename, dirname as dirname4, isAbsolute as
|
|
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";
|
|
6954
7110
|
var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
6955
7111
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6956
7112
|
function gitConfig(cwd, key2) {
|
|
@@ -7103,7 +7259,7 @@ function readWorkflowMark(src) {
|
|
|
7103
7259
|
function toolSet(matcher) {
|
|
7104
7260
|
const m = String(matcher ?? "").trim();
|
|
7105
7261
|
if (m === "" || m === "*") return null;
|
|
7106
|
-
return new Set(m.split(
|
|
7262
|
+
return new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean));
|
|
7107
7263
|
}
|
|
7108
7264
|
function missingTools(matcher) {
|
|
7109
7265
|
const have = toolSet(matcher);
|
|
@@ -7127,7 +7283,7 @@ function hooksShapeError(hooks) {
|
|
|
7127
7283
|
}
|
|
7128
7284
|
function planPolicy(cwd) {
|
|
7129
7285
|
const path = join10(cwd, POLICY_FILE);
|
|
7130
|
-
if (
|
|
7286
|
+
if (existsSync8(path)) {
|
|
7131
7287
|
try {
|
|
7132
7288
|
loadPolicy(cwd);
|
|
7133
7289
|
} catch (e) {
|
|
@@ -7147,7 +7303,7 @@ function planClaudeHooks(cwd) {
|
|
|
7147
7303
|
const rel = ".claude/settings.json";
|
|
7148
7304
|
const path = join10(cwd, rel);
|
|
7149
7305
|
let settings = {};
|
|
7150
|
-
if (
|
|
7306
|
+
if (existsSync8(path)) {
|
|
7151
7307
|
let parsed;
|
|
7152
7308
|
try {
|
|
7153
7309
|
parsed = JSON.parse(readFileSync10(path, "utf8"));
|
|
@@ -7176,7 +7332,8 @@ function planClaudeHooks(cwd) {
|
|
|
7176
7332
|
const needStop = stopEntries.length === 0;
|
|
7177
7333
|
const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
|
|
7178
7334
|
const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
|
|
7179
|
-
|
|
7335
|
+
const needDisableFalse = settings.disableAllHooks !== false;
|
|
7336
|
+
if (!needPre && !needStop && stale.length === 0 && repin.length === 0 && !needDisableFalse) {
|
|
7180
7337
|
return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
7181
7338
|
}
|
|
7182
7339
|
const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
|
|
@@ -7184,12 +7341,13 @@ function planClaudeHooks(cwd) {
|
|
|
7184
7341
|
return {
|
|
7185
7342
|
item: "agent",
|
|
7186
7343
|
path: rel,
|
|
7187
|
-
status:
|
|
7344
|
+
status: existsSync8(path) ? "update" : "create",
|
|
7188
7345
|
detail: [
|
|
7189
7346
|
needPre && "wire PreToolUse deny",
|
|
7190
7347
|
needStop && "wire Stop sweep",
|
|
7191
7348
|
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`,
|
|
7192
|
-
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)})`)
|
|
7193
7351
|
].filter(Boolean).join(" + "),
|
|
7194
7352
|
apply: () => {
|
|
7195
7353
|
for (const h of repin) {
|
|
@@ -7208,6 +7366,7 @@ function planClaudeHooks(cwd) {
|
|
|
7208
7366
|
if (needStop) {
|
|
7209
7367
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
7210
7368
|
}
|
|
7369
|
+
if (needDisableFalse) settings.disableAllHooks = false;
|
|
7211
7370
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
7212
7371
|
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
7213
7372
|
}
|
|
@@ -7240,14 +7399,14 @@ var MANAGED_HOOKS = [
|
|
|
7240
7399
|
function hooksDir(cwd) {
|
|
7241
7400
|
try {
|
|
7242
7401
|
const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
7243
|
-
if (out2) return
|
|
7402
|
+
if (out2) return resolve3(cwd, out2);
|
|
7244
7403
|
} catch {
|
|
7245
7404
|
}
|
|
7246
|
-
return
|
|
7405
|
+
return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
|
|
7247
7406
|
}
|
|
7248
7407
|
function display(cwd, path) {
|
|
7249
7408
|
const rel = relative3(cwd, path);
|
|
7250
|
-
return rel && !rel.startsWith("..") && !
|
|
7409
|
+
return rel && !rel.startsWith("..") && !isAbsolute7(rel) ? rel : path;
|
|
7251
7410
|
}
|
|
7252
7411
|
function unconditionalExitAt(lines) {
|
|
7253
7412
|
let depth = 0;
|
|
@@ -7270,17 +7429,17 @@ function planPreCommit(cwd) {
|
|
|
7270
7429
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
7271
7430
|
let path;
|
|
7272
7431
|
let note;
|
|
7273
|
-
if (
|
|
7432
|
+
if (existsSync8(join10(cwd, ".husky"))) {
|
|
7274
7433
|
path = join10(cwd, ".husky", "pre-commit");
|
|
7275
7434
|
note = "husky";
|
|
7276
7435
|
} else {
|
|
7277
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) =>
|
|
7436
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
|
|
7278
7437
|
if (managed) {
|
|
7279
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}`);
|
|
7280
7439
|
}
|
|
7281
7440
|
const dir = hooksDir(cwd);
|
|
7282
7441
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
7283
|
-
if (
|
|
7442
|
+
if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
|
|
7284
7443
|
path = join10(dirname4(dir), "pre-commit");
|
|
7285
7444
|
note = "husky";
|
|
7286
7445
|
} else {
|
|
@@ -7290,10 +7449,10 @@ function planPreCommit(cwd) {
|
|
|
7290
7449
|
}
|
|
7291
7450
|
}
|
|
7292
7451
|
const rel = display(cwd, path);
|
|
7293
|
-
if (
|
|
7452
|
+
if (existsSync8(path) && !statSync3(path).isFile()) {
|
|
7294
7453
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
7295
7454
|
}
|
|
7296
|
-
const existing =
|
|
7455
|
+
const existing = existsSync8(path) ? readFileSync10(path, "utf8") : null;
|
|
7297
7456
|
if (existing === null) {
|
|
7298
7457
|
return {
|
|
7299
7458
|
item: "pre-commit",
|
|
@@ -7382,7 +7541,7 @@ ${block.join("\n")}
|
|
|
7382
7541
|
function planCodeowners(cwd) {
|
|
7383
7542
|
const rel = ".github/CODEOWNERS";
|
|
7384
7543
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
7385
|
-
(r) =>
|
|
7544
|
+
(r) => existsSync8(join10(cwd, r))
|
|
7386
7545
|
);
|
|
7387
7546
|
const path = join10(cwd, existingRel ?? rel);
|
|
7388
7547
|
const existing = existingRel ? readFileSync10(path, "utf8") : null;
|
|
@@ -7423,7 +7582,7 @@ function planWorkflow(cwd, force) {
|
|
|
7423
7582
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
7424
7583
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
7425
7584
|
};
|
|
7426
|
-
if (!
|
|
7585
|
+
if (!existsSync8(path)) {
|
|
7427
7586
|
return {
|
|
7428
7587
|
item: "ci",
|
|
7429
7588
|
path: rel,
|
|
@@ -7533,15 +7692,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
7533
7692
|
// src/cli/verify.ts
|
|
7534
7693
|
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
7535
7694
|
import { createHash as createHash8 } from "node:crypto";
|
|
7536
|
-
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
|
|
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";
|
|
7537
7696
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
7538
|
-
import { dirname as dirname5, join as join13, resolve as
|
|
7697
|
+
import { dirname as dirname5, join as join13, resolve as resolve4 } from "node:path";
|
|
7539
7698
|
|
|
7540
7699
|
// src/git/trusted.ts
|
|
7541
7700
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
7542
7701
|
import { createHash as createHash6 } from "node:crypto";
|
|
7543
|
-
import { existsSync as
|
|
7544
|
-
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";
|
|
7545
7704
|
function trustedGitEnv() {
|
|
7546
7705
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
7547
7706
|
}
|
|
@@ -7554,7 +7713,7 @@ function gitDirOf(cwd) {
|
|
|
7554
7713
|
env: trustedGitEnv()
|
|
7555
7714
|
}).trim();
|
|
7556
7715
|
if (!d) return null;
|
|
7557
|
-
return
|
|
7716
|
+
return isAbsolute8(d) ? d : join11(cwd, d);
|
|
7558
7717
|
} catch {
|
|
7559
7718
|
return null;
|
|
7560
7719
|
}
|
|
@@ -7581,7 +7740,7 @@ function objectRewriteState(cwd) {
|
|
|
7581
7740
|
}
|
|
7582
7741
|
const p = join11(dir, rel);
|
|
7583
7742
|
try {
|
|
7584
|
-
parts.push(`${rel}:` + (
|
|
7743
|
+
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync11(p, "utf8").trim() : ""));
|
|
7585
7744
|
} catch {
|
|
7586
7745
|
parts.push(`${rel}:<unreadable>`);
|
|
7587
7746
|
}
|
|
@@ -7759,8 +7918,8 @@ function materialize(cwd, dest) {
|
|
|
7759
7918
|
}
|
|
7760
7919
|
chmodSync2(out2, st.mode);
|
|
7761
7920
|
}
|
|
7762
|
-
const nm =
|
|
7763
|
-
if (
|
|
7921
|
+
const nm = resolve4(cwd, "node_modules");
|
|
7922
|
+
if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
|
|
7764
7923
|
}
|
|
7765
7924
|
function dropSymlink(p) {
|
|
7766
7925
|
try {
|
|
@@ -8095,8 +8254,8 @@ function parseVerify(args) {
|
|
|
8095
8254
|
|
|
8096
8255
|
// src/cli/run.ts
|
|
8097
8256
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
8098
|
-
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync as
|
|
8099
|
-
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";
|
|
8100
8259
|
var out = (s) => process.stdout.write(s + "\n");
|
|
8101
8260
|
var err = (s) => process.stderr.write(s + "\n");
|
|
8102
8261
|
function git3(args, cwd) {
|
|
@@ -8113,7 +8272,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
8113
8272
|
const out2 = [];
|
|
8114
8273
|
let real;
|
|
8115
8274
|
try {
|
|
8116
|
-
real =
|
|
8275
|
+
real = realpathSync3(cwd);
|
|
8117
8276
|
} catch {
|
|
8118
8277
|
return out2;
|
|
8119
8278
|
}
|
|
@@ -8128,7 +8287,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
8128
8287
|
if (n === process.pid) continue;
|
|
8129
8288
|
let held;
|
|
8130
8289
|
try {
|
|
8131
|
-
held =
|
|
8290
|
+
held = realpathSync3(`/proc/${pid}/cwd`);
|
|
8132
8291
|
} catch {
|
|
8133
8292
|
continue;
|
|
8134
8293
|
}
|
|
@@ -8143,7 +8302,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
8143
8302
|
return out2;
|
|
8144
8303
|
}
|
|
8145
8304
|
function runEnvelope(opts) {
|
|
8146
|
-
const cwd =
|
|
8305
|
+
const cwd = resolve5(opts.cwd ?? process.cwd());
|
|
8147
8306
|
if (!opts.argv.length) {
|
|
8148
8307
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
8149
8308
|
return 2;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "hexrift",
|