tamperward 1.14.3 → 1.14.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -6
- package/dist/cli/index.js +501 -134
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -173,9 +173,18 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
|
|
|
173
173
|
npx tamperward init
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
One idempotent command wires the policy, the agent hooks, the pre-commit hook,
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
|
|
177
|
+
CI workflow that runs both the diff-time check and pristine verification, and a
|
|
178
|
+
`CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
|
|
179
|
+
It never overwrites anything you wrote; `--dry-run` prints the plan.
|
|
180
|
+
|
|
181
|
+
**`init` is not sufficient on its own, and it will tell you so.** In your
|
|
182
|
+
branch-protection or ruleset settings you must require the check *and* enable
|
|
183
|
+
"Require review from Code Owners". A `pull_request` workflow runs from the pull
|
|
184
|
+
request's own head and a required check is matched by job name, so without that
|
|
185
|
+
a PR can keep the job name, replace the gate with `true`, and present a green
|
|
186
|
+
required check over a change the gate would have blocked. That is reproduced on
|
|
187
|
+
this project's own CI, not a theoretical concern.
|
|
179
188
|
|
|
180
189
|
A real deployment needs a verify command configured — the generated CI verify step
|
|
181
190
|
**fails closed (exit 2) without one** rather than passing quietly. In
|
|
@@ -185,11 +194,20 @@ A real deployment needs a verify command configured — the generated CI verify
|
|
|
185
194
|
verify:
|
|
186
195
|
command: npm test
|
|
187
196
|
budget: 300
|
|
197
|
+
inputs: ['scripts/**'] # what the command DELEGATES to
|
|
188
198
|
```
|
|
189
199
|
|
|
190
200
|
That block is itself a guarded surface: changing the command, lowering the budget,
|
|
191
|
-
or removing it is flagged as policy weakening — a verifier an
|
|
192
|
-
`true` is no verification at all.
|
|
201
|
+
narrowing `inputs`, or removing it is flagged as policy weakening — a verifier an
|
|
202
|
+
agent can point at `true` is no verification at all.
|
|
203
|
+
|
|
204
|
+
`inputs` names the files the command *executes*, so the pristine run gets the
|
|
205
|
+
base's copy of them too. A command token that names a file present at the base is
|
|
206
|
+
picked up automatically (`node runner.js`), so most repositories need nothing
|
|
207
|
+
here. Delegation is what needs the list: `npm test` names no file, and the base's
|
|
208
|
+
restored `"test": "sh scripts/test.sh"` will happily call a script nothing
|
|
209
|
+
restored. It bounds the class rather than closing it — see
|
|
210
|
+
[the threat model](./docs/THREAT-MODEL-pristine-run.md).
|
|
193
211
|
|
|
194
212
|
The four primitives:
|
|
195
213
|
|
|
@@ -263,6 +281,9 @@ and its totals.
|
|
|
263
281
|
| v1.9.0 | `tamperward verify`: the round-1 pristine oracle productized — visible-green + pristine-red is a masked failure, and "could not verify" fails closed |
|
|
264
282
|
| Round 2, 9–0 | On 22 fresh repositories, v1.9.0 produced b=9/c=0 false-green discordance (p = 0.0039); four gated false greens remained, through two documented residual mechanisms |
|
|
265
283
|
| cssstyle lifecycle escape (round 2) | The `tamperward run` envelope: the gate denied 42 mutations and verify refused all 25 stop attempts, and the runtime completed the session anyway over a masked tree — hooks decide, runtimes terminate, so the adjudication boundary moved outside the runtime |
|
|
284
|
+
| v1.14.6, external audit | A pull request can neuter the gate that judges it: `pull_request` workflows run from the PR's own head and required checks match by job name, so a job that keeps its name and runs `true` reports green over anything. Reproduced on this repo's own CI — `gate` succeeded in three seconds over a PR that lowered a mechanical rule to warn. No detector can close it, because the detector is defined by the candidate; `init` now writes a CODEOWNERS requirement on the gate-critical paths and states, every run, that branch protection is the half it cannot set for you |
|
|
285
|
+
| v1.14.5, external audit | The pristine copy was a sibling of the visible copy and fully prepared before it ran, so the candidate's own code wrote `../pristine/<the failing test>` and both runs returned 0 — no race, no background process, no git. The pristine copy is now built only after the visible run exits, its restored files are digested either side of the pristine run, and the working and dependency trees are fingerprinted around both. Also: the overlay restored content but not MODE, so `chmod -x` alone hid a test from an exec-bit runner in both copies; a transient dependency substitution that restored itself on the way out left the entry-vs-exit fingerprint identical; and a committed `.npmrc` chose the registry the CI gate was downloaded from |
|
|
286
|
+
| v1.14.4, external audit | The overlay restored the tests and then ran the agent's *runner*: `verify.command` was frozen, but `runner.js` — and anything `npm test` delegates to — was in no protected class. And `git replace` redirected what the base resolved to for `ls-tree`, `show` and `merge-base` alike, with no ref moved and no file touched. Verifier inputs are now restored from the base; every trusted read sets `GIT_NO_REPLACE_OBJECTS=1` and the envelope convicts a rewrite installed during the run. `init` also gained workflow migration and matcher repair, and the Claude hook now denies a payload it cannot parse instead of allowing it |
|
|
266
287
|
| v1.14.3, external review | Standalone `verify` loaded its policy from the working tree, so the generated CI workflow let a pull request supply the `verify:` command for its own re-execution. `check --diff` flagged the edit as hook-tampering, so the workflow caught it as a pair — but only where both jobs are required, and `verify` alone had no protection. With a `--base`, policy now comes from that commit |
|
|
267
288
|
| v1.14.2, threat model | 1.14.1 removed agent-added files only inside the protected classes, whose `config` list is JS/TS-only; an added `pytest.ini`, `setup.cfg`, `tox.ini` or `pyproject.toml` still reached the pristine run. `verify` now owns a verification surface covering runner-consulted configuration |
|
|
268
289
|
| v1.14.1, article audit | `tamperward verify` kept agent-added protected files in the pristine run on the premise that they "only add strictness". An added `conftest.py` could deselect the restored base tests by node id, so a masked failure reported VERIFIED and the envelope printed GREEN MEANS GREEN over an unfixed bug. Added protected files are now removed; PoC and mutation-checked regression committed |
|
|
@@ -287,7 +308,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
|
|
|
287
308
|
|
|
288
309
|
```bash
|
|
289
310
|
npm install && npm run build # bundles the CLI to dist/cli/index.js
|
|
290
|
-
npm test #
|
|
311
|
+
npm test # 368 tests at v1.14.3 — parser, detectors, engine, policy, renderers
|
|
291
312
|
npm run typecheck
|
|
292
313
|
```
|
|
293
314
|
|
package/dist/cli/index.js
CHANGED
|
@@ -510,7 +510,14 @@ function defaultPolicy(version = 1) {
|
|
|
510
510
|
"**/.tamperward.yml",
|
|
511
511
|
".claude/settings.json",
|
|
512
512
|
".claude/settings.local.json",
|
|
513
|
-
"**/.pre-commit-config.yaml"
|
|
513
|
+
"**/.pre-commit-config.yaml",
|
|
514
|
+
// CODEOWNERS is enforcement wiring, not documentation: it is the only
|
|
515
|
+
// thing that can require a human on a change to the workflow that
|
|
516
|
+
// decides whether the gate runs at all. GitHub reads it from any of
|
|
517
|
+
// these three locations. (H2.)
|
|
518
|
+
".github/CODEOWNERS",
|
|
519
|
+
"CODEOWNERS",
|
|
520
|
+
"docs/CODEOWNERS"
|
|
514
521
|
]
|
|
515
522
|
},
|
|
516
523
|
rules: {
|
|
@@ -1114,6 +1121,13 @@ function policyWeakening(before, after) {
|
|
|
1114
1121
|
if (ab < bb) {
|
|
1115
1122
|
reasons.push(`verify.budget lowered ${bb}s \u2192 ${ab}s \u2014 starving the budget turns "cannot verify" into a pressure point`);
|
|
1116
1123
|
}
|
|
1124
|
+
const stillInput = new Set(av.inputs ?? []);
|
|
1125
|
+
const droppedInputs = (bv.inputs ?? []).filter((g) => !stillInput.has(g));
|
|
1126
|
+
if (droppedInputs.length) {
|
|
1127
|
+
reasons.push(
|
|
1128
|
+
`verify.inputs narrowed (removed ${droppedInputs.join(", ")}) \u2014 the pristine run would execute the candidate's copy of those files`
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1117
1131
|
}
|
|
1118
1132
|
}
|
|
1119
1133
|
return reasons;
|
|
@@ -1624,7 +1638,16 @@ function parsePolicy(raw) {
|
|
|
1624
1638
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1625
1639
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
1626
1640
|
},
|
|
1627
|
-
...r.verify?.command ? {
|
|
1641
|
+
...r.verify?.command ? {
|
|
1642
|
+
verify: {
|
|
1643
|
+
command: r.verify.command,
|
|
1644
|
+
budget: r.verify.budget ?? 300,
|
|
1645
|
+
// Rebuilding the block field-by-field means a field added later and
|
|
1646
|
+
// not listed here is SILENTLY DROPPED — which is how `inputs` first
|
|
1647
|
+
// shipped as a no-op that parsed, validated, and restored nothing.
|
|
1648
|
+
...Array.isArray(r.verify.inputs) ? { inputs: r.verify.inputs.map(String) } : {}
|
|
1649
|
+
}
|
|
1650
|
+
} : {}
|
|
1628
1651
|
};
|
|
1629
1652
|
}
|
|
1630
1653
|
function parseOrThrow(src, where) {
|
|
@@ -2418,20 +2441,27 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
|
2418
2441
|
}
|
|
2419
2442
|
|
|
2420
2443
|
// src/cli/hook.ts
|
|
2444
|
+
var HookInputError = class extends Error {
|
|
2445
|
+
};
|
|
2421
2446
|
function readStdin() {
|
|
2422
2447
|
try {
|
|
2423
2448
|
return readFileSync9(0, "utf8");
|
|
2424
|
-
} catch {
|
|
2425
|
-
|
|
2449
|
+
} catch (e) {
|
|
2450
|
+
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
2426
2451
|
}
|
|
2427
2452
|
}
|
|
2428
2453
|
function parseInput(raw) {
|
|
2429
2454
|
if (!raw.trim()) return {};
|
|
2455
|
+
let parsed;
|
|
2430
2456
|
try {
|
|
2431
|
-
|
|
2432
|
-
} catch {
|
|
2433
|
-
|
|
2457
|
+
parsed = JSON.parse(raw);
|
|
2458
|
+
} catch (e) {
|
|
2459
|
+
throw new HookInputError(`the hook payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
2460
|
+
}
|
|
2461
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2462
|
+
throw new HookInputError(`the hook payload is not a JSON object (got ${Array.isArray(parsed) ? "an array" : typeof parsed})`);
|
|
2434
2463
|
}
|
|
2464
|
+
return parsed;
|
|
2435
2465
|
}
|
|
2436
2466
|
function recordDenylog(blocks) {
|
|
2437
2467
|
const log = process.env.TAMPERWARD_DENYLOG;
|
|
@@ -2610,16 +2640,30 @@ function emit(r) {
|
|
|
2610
2640
|
if (r.stdout) process.stdout.write(r.stdout);
|
|
2611
2641
|
return r.exitCode;
|
|
2612
2642
|
}
|
|
2643
|
+
function preToolUseFromRaw(raw) {
|
|
2644
|
+
try {
|
|
2645
|
+
return preToolUseVerdict(parseInput(raw));
|
|
2646
|
+
} catch (e) {
|
|
2647
|
+
return failClosed("PreToolUse", errText(e));
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
function stopFromRaw(raw) {
|
|
2651
|
+
try {
|
|
2652
|
+
return stopVerdict(parseInput(raw));
|
|
2653
|
+
} catch (e) {
|
|
2654
|
+
return failClosed("Stop", errText(e));
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2613
2657
|
function runHookClaude() {
|
|
2614
2658
|
try {
|
|
2615
|
-
return emit(
|
|
2659
|
+
return emit(preToolUseFromRaw(readStdin()));
|
|
2616
2660
|
} catch (e) {
|
|
2617
2661
|
return emit(failClosed("PreToolUse", errText(e)));
|
|
2618
2662
|
}
|
|
2619
2663
|
}
|
|
2620
2664
|
function runSweepClaude() {
|
|
2621
2665
|
try {
|
|
2622
|
-
return emit(
|
|
2666
|
+
return emit(stopFromRaw(readStdin()));
|
|
2623
2667
|
} catch (e) {
|
|
2624
2668
|
return emit(failClosed("Stop", errText(e)));
|
|
2625
2669
|
}
|
|
@@ -2672,13 +2716,42 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2672
2716
|
}
|
|
2673
2717
|
|
|
2674
2718
|
// src/cli/init.ts
|
|
2719
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
2720
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
2675
2721
|
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2676
2722
|
import { dirname as dirname3, join as join8 } from "node:path";
|
|
2677
2723
|
import { fileURLToPath } from "node:url";
|
|
2678
2724
|
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2725
|
+
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
2679
2726
|
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2680
2727
|
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
2681
2728
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2729
|
+
var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
|
|
2730
|
+
var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
|
|
2731
|
+
function coveredBy(existing, critical) {
|
|
2732
|
+
for (const line of existing.split("\n")) {
|
|
2733
|
+
const t = line.trim();
|
|
2734
|
+
if (!t || t.startsWith("#")) continue;
|
|
2735
|
+
const pattern = t.split(/\s+/)[0];
|
|
2736
|
+
if (!t.slice(pattern.length).trim()) continue;
|
|
2737
|
+
if (pattern === "*" || pattern === critical) return true;
|
|
2738
|
+
if (pattern.endsWith("/") && critical.startsWith(pattern)) return true;
|
|
2739
|
+
}
|
|
2740
|
+
return false;
|
|
2741
|
+
}
|
|
2742
|
+
function inferOwner(cwd) {
|
|
2743
|
+
try {
|
|
2744
|
+
const url = execFileSync4("git", ["remote", "get-url", "origin"], {
|
|
2745
|
+
cwd,
|
|
2746
|
+
encoding: "utf8",
|
|
2747
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2748
|
+
}).trim();
|
|
2749
|
+
const m = url.match(/github\.com[:/]+([^/]+)\//);
|
|
2750
|
+
return m ? `@${m[1]}` : null;
|
|
2751
|
+
} catch {
|
|
2752
|
+
return null;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2682
2755
|
var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
|
|
2683
2756
|
# without this file \u2014 everything here is an override, so an empty file changes nothing.
|
|
2684
2757
|
# Docs: https://github.com/hexrift/tamperward#readme
|
|
@@ -2729,6 +2802,15 @@ jobs:
|
|
|
2729
2802
|
tamperward:
|
|
2730
2803
|
runs-on: ubuntu-latest
|
|
2731
2804
|
timeout-minutes: 10
|
|
2805
|
+
# The gate is fetched from the registry AT GATE TIME, from inside the
|
|
2806
|
+
# checked-out pull request \u2014 and \`npm\` reads \`.npmrc\` from the working
|
|
2807
|
+
# directory. A PR that commits one line, \`registry=https://\u2026\`, chooses where
|
|
2808
|
+
# the authority judging it is downloaded from; the version pin below pins the
|
|
2809
|
+
# STRING, not the SOURCE. An environment variable outranks a project
|
|
2810
|
+
# \`.npmrc\` in npm's config precedence, so this is what makes the pin mean
|
|
2811
|
+
# something.
|
|
2812
|
+
env:
|
|
2813
|
+
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
|
2732
2814
|
steps:
|
|
2733
2815
|
- uses: actions/checkout@v5
|
|
2734
2816
|
with:
|
|
@@ -2762,6 +2844,30 @@ jobs:
|
|
|
2762
2844
|
- name: Tamperward verify (pristine re-execution)
|
|
2763
2845
|
run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
|
|
2764
2846
|
`;
|
|
2847
|
+
var WORKFLOW_MARK = "# tamperward:generated";
|
|
2848
|
+
var WORKFLOW_MARK_RE = /^# tamperward:generated v(\S+) sha256:([0-9a-f]+)\n/m;
|
|
2849
|
+
function bodyHash(body) {
|
|
2850
|
+
return createHash5("sha256").update(body).digest("hex").slice(0, 16);
|
|
2851
|
+
}
|
|
2852
|
+
function workflowFile(body) {
|
|
2853
|
+
return `${WORKFLOW_MARK} v${TW_VERSION} sha256:${bodyHash(body)}
|
|
2854
|
+
${body}`;
|
|
2855
|
+
}
|
|
2856
|
+
function readWorkflowMark(src) {
|
|
2857
|
+
const m = src.match(WORKFLOW_MARK_RE);
|
|
2858
|
+
if (!m) return null;
|
|
2859
|
+
return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
|
|
2860
|
+
}
|
|
2861
|
+
function toolSet(matcher2) {
|
|
2862
|
+
const m = String(matcher2 ?? "").trim();
|
|
2863
|
+
if (m === "" || m === "*") return null;
|
|
2864
|
+
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
2865
|
+
}
|
|
2866
|
+
function missingTools(matcher2) {
|
|
2867
|
+
const have = toolSet(matcher2);
|
|
2868
|
+
if (have === null) return [];
|
|
2869
|
+
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
2870
|
+
}
|
|
2765
2871
|
function planPolicy(cwd) {
|
|
2766
2872
|
const path = join8(cwd, POLICY_FILE);
|
|
2767
2873
|
if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
|
|
@@ -2795,24 +2901,34 @@ function planClaudeHooks(cwd) {
|
|
|
2795
2901
|
settings = parsed;
|
|
2796
2902
|
}
|
|
2797
2903
|
const hooks = settings.hooks ??= {};
|
|
2798
|
-
const
|
|
2799
|
-
const
|
|
2800
|
-
const
|
|
2801
|
-
|
|
2904
|
+
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => String(h.command ?? "").includes(needle)));
|
|
2905
|
+
const preEntries = entriesWith(hooks.PreToolUse, "tamperward hook claude");
|
|
2906
|
+
const needPre = preEntries.length === 0;
|
|
2907
|
+
const needStop = entriesWith(hooks.Stop, "tamperward sweep claude").length === 0;
|
|
2908
|
+
const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
|
|
2909
|
+
if (!needPre && !needStop && stale.length === 0) {
|
|
2910
|
+
return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
2911
|
+
}
|
|
2912
|
+
const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
|
|
2802
2913
|
return {
|
|
2803
2914
|
item: "agent",
|
|
2804
2915
|
path: rel,
|
|
2805
2916
|
status: existsSync7(path) ? "update" : "create",
|
|
2806
|
-
detail:
|
|
2917
|
+
detail: [
|
|
2918
|
+
needPre && "wire PreToolUse deny",
|
|
2919
|
+
needStop && "wire Stop sweep",
|
|
2920
|
+
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`
|
|
2921
|
+
].filter(Boolean).join(" + "),
|
|
2807
2922
|
apply: () => {
|
|
2808
2923
|
if (needPre) {
|
|
2809
2924
|
(hooks.PreToolUse ??= []).push({
|
|
2810
|
-
|
|
2811
|
-
// that branch was unreachable in the wiring we install. (P2-12.)
|
|
2812
|
-
matcher: "Bash|Edit|Write|MultiEdit|NotebookEdit",
|
|
2925
|
+
matcher: PRE_MATCHER,
|
|
2813
2926
|
hooks: [{ type: "command", command: HOOK_CMD }]
|
|
2814
2927
|
});
|
|
2815
2928
|
}
|
|
2929
|
+
for (const m of stale) {
|
|
2930
|
+
m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
|
|
2931
|
+
}
|
|
2816
2932
|
if (needStop) {
|
|
2817
2933
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
2818
2934
|
}
|
|
@@ -2848,27 +2964,93 @@ ${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
|
2848
2964
|
}
|
|
2849
2965
|
};
|
|
2850
2966
|
}
|
|
2851
|
-
function
|
|
2967
|
+
function planCodeowners(cwd) {
|
|
2968
|
+
const rel = ".github/CODEOWNERS";
|
|
2969
|
+
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
2970
|
+
(r) => existsSync7(join8(cwd, r))
|
|
2971
|
+
);
|
|
2972
|
+
const path = join8(cwd, existingRel ?? rel);
|
|
2973
|
+
const existing = existingRel ? readFileSync10(path, "utf8") : null;
|
|
2974
|
+
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
2975
|
+
if (missing.length === 0) {
|
|
2976
|
+
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
2977
|
+
}
|
|
2978
|
+
const owner = inferOwner(cwd);
|
|
2979
|
+
const block = `
|
|
2980
|
+
${CODEOWNERS_MARK}
|
|
2981
|
+
# A pull request that rewrites the workflow can keep the job name, replace the
|
|
2982
|
+
# gate with \`true\`, and still report a green required check. Only a human
|
|
2983
|
+
# requirement on these paths prevents that.
|
|
2984
|
+
` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
|
|
2985
|
+
return {
|
|
2986
|
+
item: "codeowners",
|
|
2987
|
+
path: existingRel ?? rel,
|
|
2988
|
+
status: existing === null ? "create" : "update",
|
|
2989
|
+
detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner})`),
|
|
2990
|
+
apply: () => {
|
|
2991
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2992
|
+
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
2993
|
+
}
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
function planWorkflow(cwd, force) {
|
|
2852
2997
|
const rel = ".github/workflows/tamperward.yml";
|
|
2853
2998
|
const path = join8(cwd, rel);
|
|
2854
|
-
|
|
2999
|
+
const write = () => {
|
|
3000
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
3001
|
+
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
3002
|
+
};
|
|
3003
|
+
if (!existsSync7(path)) {
|
|
3004
|
+
return {
|
|
3005
|
+
item: "ci",
|
|
3006
|
+
path: rel,
|
|
3007
|
+
status: "create",
|
|
3008
|
+
detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
|
|
3009
|
+
apply: write
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
let src;
|
|
3013
|
+
try {
|
|
3014
|
+
src = readFileSync10(path, "utf8");
|
|
3015
|
+
} catch {
|
|
3016
|
+
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
3017
|
+
}
|
|
3018
|
+
const current = bodyHash(WORKFLOW_CONTENT);
|
|
3019
|
+
const mark = readWorkflowMark(src);
|
|
3020
|
+
if (mark && bodyHash(mark.body) === current) {
|
|
3021
|
+
return { item: "ci", path: rel, status: "ok", detail: `generated by tamperward and current (v${mark.version})` };
|
|
3022
|
+
}
|
|
3023
|
+
if (mark && bodyHash(mark.body) === mark.hash) {
|
|
3024
|
+
return {
|
|
3025
|
+
item: "ci",
|
|
3026
|
+
path: rel,
|
|
3027
|
+
status: "update",
|
|
3028
|
+
detail: `generated by tamperward v${mark.version} and unmodified \u2014 migrating to the v${TW_VERSION} template`,
|
|
3029
|
+
apply: write
|
|
3030
|
+
};
|
|
3031
|
+
}
|
|
3032
|
+
if (force) {
|
|
3033
|
+
return {
|
|
3034
|
+
item: "ci",
|
|
3035
|
+
path: rel,
|
|
3036
|
+
status: "update",
|
|
3037
|
+
detail: mark ? `edited since tamperward v${mark.version} generated it \u2014 OVERWRITING (--force-workflow)` : "not generated by tamperward \u2014 OVERWRITING (--force-workflow)",
|
|
3038
|
+
apply: write
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
2855
3041
|
return {
|
|
2856
3042
|
item: "ci",
|
|
2857
3043
|
path: rel,
|
|
2858
|
-
status: "
|
|
2859
|
-
detail: "
|
|
2860
|
-
apply: () => {
|
|
2861
|
-
mkdirSync5(dirname3(path), { recursive: true });
|
|
2862
|
-
writeFileSync5(path, WORKFLOW_CONTENT);
|
|
2863
|
-
}
|
|
3044
|
+
status: "skip",
|
|
3045
|
+
detail: (mark ? `edited since tamperward v${mark.version} generated it` : "present but not generated by tamperward") + ` \u2014 left untouched. Compare it against the v${TW_VERSION} template (an older one may lack the pristine-verify step) and re-run with --force-workflow to replace it.`
|
|
2864
3046
|
};
|
|
2865
3047
|
}
|
|
2866
|
-
function planInit(cwd) {
|
|
2867
|
-
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd)];
|
|
3048
|
+
function planInit(cwd, opts = {}) {
|
|
3049
|
+
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false), planCodeowners(cwd)];
|
|
2868
3050
|
}
|
|
2869
3051
|
function runInit(opts) {
|
|
2870
3052
|
const cwd = opts.cwd ?? process.cwd();
|
|
2871
|
-
const plan = planInit(cwd);
|
|
3053
|
+
const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
|
|
2872
3054
|
const w = process.stdout;
|
|
2873
3055
|
for (const a of plan) {
|
|
2874
3056
|
const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
|
|
@@ -2891,14 +3073,137 @@ tamperward init: ${changed} change(s) planned. Re-run without --dry-run to apply
|
|
|
2891
3073
|
tamperward init: ${changed} change(s) applied. Commit them so the gate travels with the repo.
|
|
2892
3074
|
`
|
|
2893
3075
|
);
|
|
3076
|
+
w.write(
|
|
3077
|
+
'\nONE STEP LEFT, AND IT IS NOT OPTIONAL. In your branch-protection or ruleset\nsettings for the default branch, require the tamperward check AND enable\n"Require review from Code Owners".\n\nUntil you do, the CI gate is advisory. A pull request runs the workflow from\nits OWN head, and a required check is matched by job name \u2014 so a pull request\ncan keep the job called `tamperward`, replace the gate with `true`, and present\na green required check over a change the gate would have blocked. That is\nreproduced, not theoretical. CODEOWNERS is what puts a human in front of it;\nbranch protection is what makes CODEOWNERS bind.\n'
|
|
3078
|
+
);
|
|
2894
3079
|
return 0;
|
|
2895
3080
|
}
|
|
2896
3081
|
|
|
2897
3082
|
// src/cli/verify.ts
|
|
2898
|
-
import { execFileSync as
|
|
2899
|
-
import {
|
|
3083
|
+
import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
|
|
3084
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
3085
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync6, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
|
|
2900
3086
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
2901
|
-
import { dirname as dirname4, join as
|
|
3087
|
+
import { dirname as dirname4, join as join11, resolve } from "node:path";
|
|
3088
|
+
|
|
3089
|
+
// src/git/trusted.ts
|
|
3090
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3091
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
3092
|
+
import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
|
|
3093
|
+
import { isAbsolute as isAbsolute2, join as join9 } from "node:path";
|
|
3094
|
+
function trustedGitEnv() {
|
|
3095
|
+
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3096
|
+
}
|
|
3097
|
+
function gitDirOf(cwd) {
|
|
3098
|
+
try {
|
|
3099
|
+
const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
|
|
3100
|
+
cwd,
|
|
3101
|
+
encoding: "utf8",
|
|
3102
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3103
|
+
env: trustedGitEnv()
|
|
3104
|
+
}).trim();
|
|
3105
|
+
if (!d) return null;
|
|
3106
|
+
return isAbsolute2(d) ? d : join9(cwd, d);
|
|
3107
|
+
} catch {
|
|
3108
|
+
return null;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
function objectRewriteState(cwd) {
|
|
3112
|
+
const parts = [];
|
|
3113
|
+
try {
|
|
3114
|
+
parts.push(
|
|
3115
|
+
"replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
3116
|
+
cwd,
|
|
3117
|
+
encoding: "utf8",
|
|
3118
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3119
|
+
env: trustedGitEnv()
|
|
3120
|
+
}).trim()
|
|
3121
|
+
);
|
|
3122
|
+
} catch {
|
|
3123
|
+
parts.push("replace:<unreadable>");
|
|
3124
|
+
}
|
|
3125
|
+
const dir = gitDirOf(cwd);
|
|
3126
|
+
for (const rel of ["info/grafts", "shallow"]) {
|
|
3127
|
+
if (dir === null) {
|
|
3128
|
+
parts.push(`${rel}:<no-git-dir>`);
|
|
3129
|
+
continue;
|
|
3130
|
+
}
|
|
3131
|
+
const p = join9(dir, rel);
|
|
3132
|
+
try {
|
|
3133
|
+
parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
|
|
3134
|
+
} catch {
|
|
3135
|
+
parts.push(`${rel}:<unreadable>`);
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
return createHash6("sha256").update(parts.join("\n")).digest("hex");
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
// src/fingerprint.ts
|
|
3142
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
3143
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
3144
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
|
|
3145
|
+
import { join as join10 } from "node:path";
|
|
3146
|
+
function treeFingerprint(cwd) {
|
|
3147
|
+
const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
3148
|
+
cwd,
|
|
3149
|
+
encoding: "utf8",
|
|
3150
|
+
maxBuffer: 1 << 28,
|
|
3151
|
+
env: trustedGitEnv()
|
|
3152
|
+
}).split("\0").filter(Boolean).sort();
|
|
3153
|
+
const h = createHash7("sha256");
|
|
3154
|
+
for (const rel of files) {
|
|
3155
|
+
h.update(rel);
|
|
3156
|
+
h.update("\0");
|
|
3157
|
+
try {
|
|
3158
|
+
h.update(readFileSync12(join10(cwd, rel)));
|
|
3159
|
+
} catch {
|
|
3160
|
+
h.update("<unreadable>");
|
|
3161
|
+
}
|
|
3162
|
+
h.update("\0");
|
|
3163
|
+
}
|
|
3164
|
+
return h.digest("hex");
|
|
3165
|
+
}
|
|
3166
|
+
function depsFingerprint(cwd) {
|
|
3167
|
+
const root = join10(cwd, "node_modules");
|
|
3168
|
+
const h = createHash7("sha256");
|
|
3169
|
+
const walk = (dir) => {
|
|
3170
|
+
let entries;
|
|
3171
|
+
try {
|
|
3172
|
+
entries = readdirSync3(dir, { withFileTypes: true });
|
|
3173
|
+
} catch {
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3177
|
+
const p = join10(dir, e.name);
|
|
3178
|
+
if (e.isDirectory()) walk(p);
|
|
3179
|
+
else if (e.isFile()) {
|
|
3180
|
+
h.update(p);
|
|
3181
|
+
try {
|
|
3182
|
+
h.update(readFileSync12(p));
|
|
3183
|
+
h.update(String(statSync5(p).mode));
|
|
3184
|
+
} catch {
|
|
3185
|
+
h.update("<unreadable>");
|
|
3186
|
+
}
|
|
3187
|
+
} else if (e.isSymbolicLink()) {
|
|
3188
|
+
h.update(p);
|
|
3189
|
+
try {
|
|
3190
|
+
h.update(readlinkSync(p));
|
|
3191
|
+
} catch {
|
|
3192
|
+
h.update("<unreadable-link>");
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
};
|
|
3197
|
+
try {
|
|
3198
|
+
if (!statSync5(root).isDirectory()) return "none";
|
|
3199
|
+
} catch {
|
|
3200
|
+
return "none";
|
|
3201
|
+
}
|
|
3202
|
+
walk(root);
|
|
3203
|
+
return h.digest("hex");
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
// src/cli/verify.ts
|
|
2902
3207
|
var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
|
|
2903
3208
|
var VERIFICATION_SURFACE = [
|
|
2904
3209
|
// Python / pytest — read from the rootdir, and conftest at any depth
|
|
@@ -2927,7 +3232,7 @@ var VERIFICATION_SURFACE = [
|
|
|
2927
3232
|
"**/*.runsettings"
|
|
2928
3233
|
];
|
|
2929
3234
|
function git2(args, cwd) {
|
|
2930
|
-
return
|
|
3235
|
+
return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
2931
3236
|
}
|
|
2932
3237
|
function resolveBase(base, cwd) {
|
|
2933
3238
|
assertRev(base);
|
|
@@ -2939,32 +3244,32 @@ function resolveBase(base, cwd) {
|
|
|
2939
3244
|
}
|
|
2940
3245
|
}
|
|
2941
3246
|
function baseIsAncestorOfHead(base, cwd) {
|
|
2942
|
-
const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd });
|
|
3247
|
+
const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd, env: trustedGitEnv() });
|
|
2943
3248
|
return r.status === 0;
|
|
2944
3249
|
}
|
|
2945
3250
|
function materialize(cwd, dest) {
|
|
2946
3251
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2947
3252
|
for (const rel of listed) {
|
|
2948
|
-
const src =
|
|
3253
|
+
const src = join11(cwd, rel);
|
|
2949
3254
|
let st;
|
|
2950
3255
|
try {
|
|
2951
|
-
st =
|
|
3256
|
+
st = statSync6(src);
|
|
2952
3257
|
} catch {
|
|
2953
3258
|
continue;
|
|
2954
3259
|
}
|
|
2955
3260
|
if (!st.isFile()) continue;
|
|
2956
|
-
const out2 =
|
|
3261
|
+
const out2 = join11(dest, rel);
|
|
2957
3262
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
2958
3263
|
dropSymlink(out2);
|
|
2959
3264
|
if (lstatSync(src).isSymbolicLink()) {
|
|
2960
|
-
writeFileSync6(out2,
|
|
3265
|
+
writeFileSync6(out2, readFileSync13(src));
|
|
2961
3266
|
} else {
|
|
2962
3267
|
cpSync(src, out2, { dereference: true });
|
|
2963
3268
|
}
|
|
2964
3269
|
chmodSync2(out2, st.mode);
|
|
2965
3270
|
}
|
|
2966
3271
|
const nm = resolve(cwd, "node_modules");
|
|
2967
|
-
if (
|
|
3272
|
+
if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
|
|
2968
3273
|
}
|
|
2969
3274
|
function dropSymlink(p) {
|
|
2970
3275
|
try {
|
|
@@ -2972,29 +3277,77 @@ function dropSymlink(p) {
|
|
|
2972
3277
|
} catch {
|
|
2973
3278
|
}
|
|
2974
3279
|
}
|
|
2975
|
-
function
|
|
2976
|
-
const
|
|
2977
|
-
const
|
|
2978
|
-
|
|
3280
|
+
function verifierInputs(cmd, atBase, policy) {
|
|
3281
|
+
const baseFiles = new Set(atBase);
|
|
3282
|
+
const picked = /* @__PURE__ */ new Set();
|
|
3283
|
+
for (const raw of cmd.split(/[\s;&|()<>]+/)) {
|
|
3284
|
+
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
3285
|
+
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
3286
|
+
}
|
|
3287
|
+
const globs = policy.verify?.inputs;
|
|
3288
|
+
if (globs && globs.length) {
|
|
3289
|
+
for (const rel of atBase) if (matchesAny(rel, globs)) picked.add(rel);
|
|
3290
|
+
}
|
|
3291
|
+
return picked;
|
|
3292
|
+
}
|
|
3293
|
+
function baseEntries(base, cwd) {
|
|
3294
|
+
return git2(["ls-tree", "-r", "-z", base], cwd).split("\0").filter(Boolean).map((rec) => {
|
|
3295
|
+
const tab = rec.indexOf(" ");
|
|
3296
|
+
const [mode, type] = rec.slice(0, tab).split(" ");
|
|
3297
|
+
return { mode, type, path: rec.slice(tab + 1) };
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
function overlayDigest(dest, paths) {
|
|
3301
|
+
const h = createHash8("sha256");
|
|
3302
|
+
for (const rel of [...paths].sort()) {
|
|
3303
|
+
h.update(rel);
|
|
3304
|
+
h.update("\0");
|
|
3305
|
+
const p = join11(dest, rel);
|
|
3306
|
+
try {
|
|
3307
|
+
const st = lstatSync(p);
|
|
3308
|
+
h.update(String(st.mode));
|
|
3309
|
+
h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync13(p));
|
|
3310
|
+
} catch {
|
|
3311
|
+
h.update("<gone>");
|
|
3312
|
+
}
|
|
3313
|
+
h.update("\0");
|
|
3314
|
+
}
|
|
3315
|
+
return h.digest("hex");
|
|
3316
|
+
}
|
|
3317
|
+
function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
3318
|
+
const entries = baseEntries(base, cwd);
|
|
3319
|
+
const atBase = entries.map((e) => e.path);
|
|
3320
|
+
const verifierOwned = verifierInputs(cmd, atBase, policy);
|
|
3321
|
+
const verifierGlobs = policy.verify?.inputs ?? [];
|
|
3322
|
+
const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c)) || matchesAny(p, VERIFICATION_SURFACE) || verifierOwned.has(p) || verifierGlobs.length > 0 && matchesAny(p, verifierGlobs);
|
|
3323
|
+
const restored = [];
|
|
2979
3324
|
const baseProtected = /* @__PURE__ */ new Set();
|
|
2980
|
-
for (const
|
|
2981
|
-
if (!isOverlay(
|
|
2982
|
-
baseProtected.add(
|
|
2983
|
-
|
|
2984
|
-
|
|
3325
|
+
for (const e of entries) {
|
|
3326
|
+
if (!isOverlay(e.path)) continue;
|
|
3327
|
+
baseProtected.add(e.path);
|
|
3328
|
+
if (e.type === "commit") {
|
|
3329
|
+
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
3330
|
+
}
|
|
3331
|
+
const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
3332
|
+
const out2 = join11(dest, e.path);
|
|
2985
3333
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3334
|
+
rmSync2(out2, { force: true });
|
|
3335
|
+
if (e.mode === "120000") {
|
|
3336
|
+
symlinkSync(content.toString("utf8"), out2);
|
|
3337
|
+
} else {
|
|
3338
|
+
writeFileSync6(out2, content);
|
|
3339
|
+
chmodSync2(out2, parseInt(e.mode.slice(-4), 8) & 511);
|
|
3340
|
+
}
|
|
3341
|
+
restored.push(e.path);
|
|
2989
3342
|
}
|
|
2990
3343
|
let removed = 0;
|
|
2991
3344
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2992
3345
|
for (const rel of inCopy) {
|
|
2993
3346
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
2994
|
-
rmSync2(
|
|
3347
|
+
rmSync2(join11(dest, rel), { force: true });
|
|
2995
3348
|
removed++;
|
|
2996
3349
|
}
|
|
2997
|
-
return
|
|
3350
|
+
return { restored, removed };
|
|
2998
3351
|
}
|
|
2999
3352
|
function runSuite(dir, cmd, budgetSecs) {
|
|
3000
3353
|
const t0 = Date.now();
|
|
@@ -3045,25 +3398,63 @@ function runVerify(opts) {
|
|
|
3045
3398
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
3046
3399
|
return 2;
|
|
3047
3400
|
}
|
|
3048
|
-
const
|
|
3049
|
-
const visDir =
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3052
|
-
|
|
3401
|
+
const visRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-vis-"));
|
|
3402
|
+
const visDir = join11(visRoot, "t");
|
|
3403
|
+
const cleanup = (dirs) => {
|
|
3404
|
+
if (opts.keep) return;
|
|
3405
|
+
for (const d of dirs) rmSync2(d, { recursive: true, force: true });
|
|
3406
|
+
};
|
|
3407
|
+
const treeBefore = treeFingerprint(cwd);
|
|
3408
|
+
const depsBefore = depsFingerprint(cwd);
|
|
3053
3409
|
try {
|
|
3054
3410
|
mkdirSync6(visDir);
|
|
3055
|
-
mkdirSync6(priDir);
|
|
3056
3411
|
materialize(cwd, visDir);
|
|
3057
|
-
materialize(cwd, priDir);
|
|
3058
|
-
[restored, removedAdded] = overlayPristine(cwd, base, priDir, policy);
|
|
3059
3412
|
} catch (e) {
|
|
3060
|
-
|
|
3413
|
+
cleanup([visRoot]);
|
|
3061
3414
|
out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
3062
3415
|
return 2;
|
|
3063
3416
|
}
|
|
3064
3417
|
const visible = runSuite(visDir, cmd, budget);
|
|
3418
|
+
if (treeFingerprint(cwd) !== treeBefore) {
|
|
3419
|
+
cleanup([visRoot]);
|
|
3420
|
+
out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
|
|
3421
|
+
out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
|
|
3422
|
+
return 2;
|
|
3423
|
+
}
|
|
3424
|
+
if (depsFingerprint(cwd) !== depsBefore) {
|
|
3425
|
+
cleanup([visRoot]);
|
|
3426
|
+
out2("verify: the installed dependency tree changed while the visible suite was running \u2014 the");
|
|
3427
|
+
out2("pristine run executes through it too. Failing closed, not open.");
|
|
3428
|
+
return 2;
|
|
3429
|
+
}
|
|
3430
|
+
const priRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-pri-"));
|
|
3431
|
+
const priDir = join11(priRoot, "t");
|
|
3432
|
+
let restored = [];
|
|
3433
|
+
let removedAdded = 0;
|
|
3434
|
+
try {
|
|
3435
|
+
mkdirSync6(priDir);
|
|
3436
|
+
materialize(cwd, priDir);
|
|
3437
|
+
({ restored, removed: removedAdded } = overlayPristine(cwd, base, priDir, policy, cmd));
|
|
3438
|
+
} catch (e) {
|
|
3439
|
+
cleanup([visRoot, priRoot]);
|
|
3440
|
+
out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
3441
|
+
return 2;
|
|
3442
|
+
}
|
|
3443
|
+
const overlayBefore = overlayDigest(priDir, restored);
|
|
3065
3444
|
const pristine = runSuite(priDir, cmd, budget);
|
|
3066
|
-
|
|
3445
|
+
const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
|
|
3446
|
+
const treeMoved = treeFingerprint(cwd) !== treeBefore;
|
|
3447
|
+
const depsMoved = depsFingerprint(cwd) !== depsBefore;
|
|
3448
|
+
cleanup([visRoot, priRoot]);
|
|
3449
|
+
if (overlayMoved || treeMoved || depsMoved) {
|
|
3450
|
+
out2(
|
|
3451
|
+
"verify: " + (overlayMoved ? "a restored file in the pristine copy changed while the pristine suite was running" : treeMoved ? "the working tree changed while the pristine suite was running" : "the installed dependency tree changed while the pristine suite was running") + " \u2014"
|
|
3452
|
+
);
|
|
3453
|
+
out2("the verdict would describe something other than what ran. Failing closed, not open.");
|
|
3454
|
+
out2("(A suite that rewrites its own snapshots or test files in place will trip this; run it in");
|
|
3455
|
+
out2("whatever mode your runner calls CI, so verification observes rather than updates.)");
|
|
3456
|
+
return 2;
|
|
3457
|
+
}
|
|
3067
3458
|
let verdict2;
|
|
3068
3459
|
let code;
|
|
3069
3460
|
if (visible.exit === null || pristine.exit === null) {
|
|
@@ -3088,15 +3479,15 @@ function runVerify(opts) {
|
|
|
3088
3479
|
budget_secs: budget,
|
|
3089
3480
|
visible: { exit: visible.exit, secs: visible.secs },
|
|
3090
3481
|
pristine: { exit: pristine.exit, secs: pristine.secs },
|
|
3091
|
-
protected_restored: restored,
|
|
3482
|
+
protected_restored: restored.length,
|
|
3092
3483
|
added_protected_removed: removedAdded,
|
|
3093
|
-
...opts.keep ? {
|
|
3484
|
+
...opts.keep ? { visible_dir: visDir, pristine_dir: priDir } : {}
|
|
3094
3485
|
})
|
|
3095
3486
|
);
|
|
3096
3487
|
} else {
|
|
3097
3488
|
const lines = {
|
|
3098
|
-
VERIFIED: `verified: the suite passes, and still passes with all ${restored} protected files restored from ${base.slice(0, 10)}.`,
|
|
3099
|
-
MASKED_FAILURE: `MASKED FAILURE: the visible suite passes, but with the ${restored} protected files restored from ${base.slice(0, 10)} it FAILS (exit ${pristine.exit}). Something weakened the checks; the code does not pass the original suite.`,
|
|
3489
|
+
VERIFIED: `verified: the suite passes, and still passes with all ${restored.length} protected files restored from ${base.slice(0, 10)}.`,
|
|
3490
|
+
MASKED_FAILURE: `MASKED FAILURE: the visible suite passes, but with the ${restored.length} protected files restored from ${base.slice(0, 10)} it FAILS (exit ${pristine.exit}). Something weakened the checks; the code does not pass the original suite.`,
|
|
3100
3491
|
SUITE_RED: `suite red: the visible suite fails (exit ${visible.exit}) \u2014 fix the code first (pristine exit ${pristine.exit}).`,
|
|
3101
3492
|
BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
|
|
3102
3493
|
};
|
|
@@ -3124,72 +3515,17 @@ function parseVerify(args) {
|
|
|
3124
3515
|
}
|
|
3125
3516
|
|
|
3126
3517
|
// src/cli/run.ts
|
|
3127
|
-
import { execFileSync as
|
|
3128
|
-
import {
|
|
3129
|
-
import {
|
|
3130
|
-
import { join as join10, resolve as resolve2 } from "node:path";
|
|
3518
|
+
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
3519
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
|
|
3520
|
+
import { resolve as resolve2 } from "node:path";
|
|
3131
3521
|
var out = (s) => process.stdout.write(s + "\n");
|
|
3132
3522
|
var err = (s) => process.stderr.write(s + "\n");
|
|
3133
3523
|
function git3(args, cwd) {
|
|
3134
|
-
return
|
|
3135
|
-
}
|
|
3136
|
-
function treeFingerprint(cwd) {
|
|
3137
|
-
const files = git3(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean).sort();
|
|
3138
|
-
const h = createHash5("sha256");
|
|
3139
|
-
for (const rel of files) {
|
|
3140
|
-
h.update(rel);
|
|
3141
|
-
h.update("\0");
|
|
3142
|
-
try {
|
|
3143
|
-
h.update(readFileSync12(join10(cwd, rel)));
|
|
3144
|
-
} catch {
|
|
3145
|
-
h.update("<unreadable>");
|
|
3146
|
-
}
|
|
3147
|
-
h.update("\0");
|
|
3148
|
-
}
|
|
3149
|
-
return h.digest("hex");
|
|
3150
|
-
}
|
|
3151
|
-
function depsFingerprint(cwd) {
|
|
3152
|
-
const root = join10(cwd, "node_modules");
|
|
3153
|
-
const h = createHash5("sha256");
|
|
3154
|
-
const walk = (dir) => {
|
|
3155
|
-
let entries;
|
|
3156
|
-
try {
|
|
3157
|
-
entries = readdirSync3(dir, { withFileTypes: true });
|
|
3158
|
-
} catch {
|
|
3159
|
-
return;
|
|
3160
|
-
}
|
|
3161
|
-
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3162
|
-
const p = join10(dir, e.name);
|
|
3163
|
-
if (e.isDirectory()) walk(p);
|
|
3164
|
-
else if (e.isFile()) {
|
|
3165
|
-
h.update(p);
|
|
3166
|
-
try {
|
|
3167
|
-
h.update(readFileSync12(p));
|
|
3168
|
-
h.update(String(statSync6(p).mode));
|
|
3169
|
-
} catch {
|
|
3170
|
-
h.update("<unreadable>");
|
|
3171
|
-
}
|
|
3172
|
-
} else if (e.isSymbolicLink()) {
|
|
3173
|
-
h.update(p);
|
|
3174
|
-
try {
|
|
3175
|
-
h.update(readlinkSync(p));
|
|
3176
|
-
} catch {
|
|
3177
|
-
h.update("<unreadable-link>");
|
|
3178
|
-
}
|
|
3179
|
-
}
|
|
3180
|
-
}
|
|
3181
|
-
};
|
|
3182
|
-
try {
|
|
3183
|
-
if (!statSync6(root).isDirectory()) return "none";
|
|
3184
|
-
} catch {
|
|
3185
|
-
return "none";
|
|
3186
|
-
}
|
|
3187
|
-
walk(root);
|
|
3188
|
-
return h.digest("hex");
|
|
3524
|
+
return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
3189
3525
|
}
|
|
3190
3526
|
function nowTicks() {
|
|
3191
3527
|
try {
|
|
3192
|
-
return Math.floor(parseFloat(
|
|
3528
|
+
return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
3193
3529
|
} catch {
|
|
3194
3530
|
return Number.POSITIVE_INFINITY;
|
|
3195
3531
|
}
|
|
@@ -3204,7 +3540,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3204
3540
|
}
|
|
3205
3541
|
let pids;
|
|
3206
3542
|
try {
|
|
3207
|
-
pids =
|
|
3543
|
+
pids = readdirSync4("/proc").filter((d) => /^\d+$/.test(d));
|
|
3208
3544
|
} catch {
|
|
3209
3545
|
return out2;
|
|
3210
3546
|
}
|
|
@@ -3219,7 +3555,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3219
3555
|
}
|
|
3220
3556
|
if (held !== real && !held.startsWith(real + "/")) continue;
|
|
3221
3557
|
try {
|
|
3222
|
-
const stat =
|
|
3558
|
+
const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
|
|
3223
3559
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
3224
3560
|
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
3225
3561
|
} catch {
|
|
@@ -3266,11 +3602,30 @@ function runEnvelope(opts) {
|
|
|
3266
3602
|
}
|
|
3267
3603
|
out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
|
|
3268
3604
|
const depsBefore = depsFingerprint(cwd);
|
|
3605
|
+
const rewritesBefore = objectRewriteState(cwd);
|
|
3269
3606
|
const spawnTicks = nowTicks();
|
|
3270
3607
|
const agent = spawnSync2(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
|
|
3271
3608
|
const agentExit = agent.status ?? 1;
|
|
3609
|
+
if (objectRewriteState(cwd) !== rewritesBefore) {
|
|
3610
|
+
err("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
|
|
3611
|
+
err("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
|
|
3612
|
+
err("read against the anchor we started from. That is the finding.");
|
|
3613
|
+
out(`
|
|
3614
|
+
tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
3615
|
+
return 1;
|
|
3616
|
+
}
|
|
3617
|
+
if (depsFingerprint(cwd) !== depsBefore) {
|
|
3618
|
+
err("tamperward run: the installed dependency tree changed before adjudication began \u2014 the program");
|
|
3619
|
+
err("that runs the suite is not the one we started with, and both verification runs execute it.");
|
|
3620
|
+
if (!opts.allowDepDrift) {
|
|
3621
|
+
out(`
|
|
3622
|
+
tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEMENT_FAILED`);
|
|
3623
|
+
return 1;
|
|
3624
|
+
}
|
|
3625
|
+
err("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
|
|
3626
|
+
}
|
|
3272
3627
|
const head = git3(["rev-parse", "HEAD"], cwd).trim();
|
|
3273
|
-
const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd });
|
|
3628
|
+
const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
|
|
3274
3629
|
if (isAncestor.status !== 0) {
|
|
3275
3630
|
err(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
|
|
3276
3631
|
err("the agent rewrote history out from under the anchor. That is the finding.");
|
|
@@ -3291,7 +3646,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
3291
3646
|
const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
|
|
3292
3647
|
if (opts.settle && opts.settle > 0) {
|
|
3293
3648
|
try {
|
|
3294
|
-
|
|
3649
|
+
execFileSync8("sleep", [String(opts.settle)]);
|
|
3295
3650
|
} catch {
|
|
3296
3651
|
}
|
|
3297
3652
|
}
|
|
@@ -3305,16 +3660,21 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
3305
3660
|
err("tamperward run: the installed dependency tree changed during this run \u2014 the program that");
|
|
3306
3661
|
err("runs the suite is not the one we started with. Both verification runs execute it.");
|
|
3307
3662
|
}
|
|
3663
|
+
const rewrote = objectRewriteState(cwd) !== rewritesBefore;
|
|
3664
|
+
if (rewrote) {
|
|
3665
|
+
err("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
|
|
3666
|
+
err("the checks above read the trusted base through an object layer that has since moved.");
|
|
3667
|
+
}
|
|
3308
3668
|
const survivors = survivorsHoldingTree(cwd, spawnTicks);
|
|
3309
3669
|
if (survivors.length) {
|
|
3310
3670
|
err(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
|
|
3311
3671
|
err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
|
|
3312
3672
|
}
|
|
3313
|
-
const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted;
|
|
3673
|
+
const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
|
|
3314
3674
|
const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
|
|
3315
3675
|
const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
|
|
3316
3676
|
const enforcement = cannot ? 2 : blocked ? 1 : 0;
|
|
3317
|
-
const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
|
|
3677
|
+
const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
|
|
3318
3678
|
out(`
|
|
3319
3679
|
tamperward run \u2014 agent exit ${agentExit}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
|
|
3320
3680
|
return enforcement !== 0 ? enforcement : agentExit;
|
|
@@ -3368,6 +3728,7 @@ function parseInit(args) {
|
|
|
3368
3728
|
const a = args[i];
|
|
3369
3729
|
if (a === "--cwd") o.cwd = args[++i];
|
|
3370
3730
|
else if (a === "--dry-run") o.dryRun = true;
|
|
3731
|
+
else if (a === "--force-workflow") o.forceWorkflow = true;
|
|
3371
3732
|
}
|
|
3372
3733
|
return o;
|
|
3373
3734
|
}
|
|
@@ -3431,9 +3792,15 @@ Formats:
|
|
|
3431
3792
|
adjudicate (fails closed)
|
|
3432
3793
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
3433
3794
|
tamperward init [--dry-run] wire the policy file plus every
|
|
3434
|
-
|
|
3795
|
+
[--force-workflow] enforcement point: Claude Code hooks,
|
|
3435
3796
|
pre-commit, CI. Idempotent; never
|
|
3436
|
-
overwrites your files.
|
|
3797
|
+
overwrites your files. Re-running it
|
|
3798
|
+
MIGRATES a CI workflow it generated and
|
|
3799
|
+
you have not edited, and widens a
|
|
3800
|
+
PreToolUse matcher that no longer covers
|
|
3801
|
+
every tool the gate must see.
|
|
3802
|
+
--force-workflow replaces a workflow it
|
|
3803
|
+
did not write, or one you have edited.
|
|
3437
3804
|
|
|
3438
3805
|
Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
|
|
3439
3806
|
emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.6",
|
|
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",
|