tamperward 1.14.2 → 1.14.5
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 +126 -75
- package/dist/cli/index.js +440 -135
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
|
|
|
43
43
|
|
|
44
44
|
> **Scope.** The rows above are one model, one pressure prompt, one JS/TS
|
|
45
45
|
> ecosystem — evidence for that setting, not a universal claim. Round 2 tested
|
|
46
|
-
> the v1.9.0 stack; the current **v1.14.
|
|
46
|
+
> the v1.9.0 stack; the current **v1.14.3** adds post-study envelope hardening
|
|
47
47
|
> (externally reviewed, with findings tracked individually as REPRO or AUDIT in
|
|
48
48
|
> [SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md) and closed with regression and
|
|
49
49
|
> mutation checks — see [CHANGELOG](./CHANGELOG.md)). Rounds 3 and 3.1 are
|
|
@@ -53,74 +53,119 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
|
|
|
53
53
|
> and 3.1 all ran on a verifier carrying it. No evidence any agent used it; the
|
|
54
54
|
> layer's authority in those rounds was nonetheless weaker than described at the
|
|
55
55
|
> time. Round 4 is the fresh pool.
|
|
56
|
-
> No round-3 outcomes are included in the claims above.
|
|
57
56
|
|
|
58
57
|
## Architecture
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
starts; the in-loop hooks read the live tree and remain steering controls. The
|
|
62
|
-
final adjudication boundary sits outside the agent's normal lifecycle.
|
|
59
|
+
Tamperward separates **steering** from **adjudication**.
|
|
63
60
|
|
|
61
|
+
> **Core invariant:** the agent may author the candidate tree, but it must not
|
|
62
|
+
> choose the trusted baseline, the governing policy, the verifier, or the final
|
|
63
|
+
> verdict.
|
|
64
|
+
|
|
65
|
+
| plane | mechanism | role |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| **In-loop steering** | Claude Code `PreToolUse`, protected-tree drift checks, the `Stop` sweep, and optional filesystem-event observation | Immediate corrective feedback. Not the final authority. |
|
|
68
|
+
| **Local adjudication** | `tamperward run -- <agent…>` | Wraps the runtime and independently judges the tree it leaves behind. |
|
|
69
|
+
| **Commit backstop** | `tamperward check --staged` | Catches human changes, and agents without supported hooks. |
|
|
70
|
+
| **Repository authority** | Required PR checks plus branch protection | Decides what may reach the protected branch. |
|
|
71
|
+
|
|
72
|
+
| layer | the question it answers |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| **Change semantics** | Does a proposed or recorded change match a known weakening class? (PreToolUse deny, staged diff, range diff) |
|
|
75
|
+
| **Effect** | What did the protected state actually become, however the mutation arrived? (drift check, fs-event observer, Stop sweep) |
|
|
76
|
+
| **Outcome** | Does the candidate still pass when protected verification state from the base is restored? (pristine verification — no diff interpreted at all) |
|
|
77
|
+
| **Authority** | Where is the final verdict rendered, and what assumptions make it authoritative? (run envelope, CI, SHA-bound human sign-off) |
|
|
78
|
+
|
|
79
|
+
### The local enforcement envelope
|
|
80
|
+
|
|
81
|
+
```mermaid
|
|
82
|
+
flowchart TB
|
|
83
|
+
subgraph R["tamperward run — outer enforcement envelope"]
|
|
84
|
+
E["Capture entry state"]
|
|
85
|
+
subgraph U["Agent-controlled lifecycle"]
|
|
86
|
+
H["In-loop steering hooks"]
|
|
87
|
+
A["Agent runtime"]
|
|
88
|
+
W["Candidate HEAD and worktree"]
|
|
89
|
+
H -.-> A
|
|
90
|
+
A --> W
|
|
91
|
+
end
|
|
92
|
+
J["Post-exit adjudication"]
|
|
93
|
+
C["Committed and worktree checks"]
|
|
94
|
+
V["Visible and pristine verification"]
|
|
95
|
+
Q["Ancestry, dependency drift, quiescence"]
|
|
96
|
+
X["Final exit verdict"]
|
|
97
|
+
E --> A
|
|
98
|
+
E --> J
|
|
99
|
+
W --> J
|
|
100
|
+
J --> C
|
|
101
|
+
J --> V
|
|
102
|
+
J --> Q
|
|
103
|
+
C --> X
|
|
104
|
+
V --> X
|
|
105
|
+
Q --> X
|
|
106
|
+
end
|
|
64
107
|
```
|
|
65
|
-
RECORDED AT ENVELOPE ENTRY:
|
|
66
|
-
entry SHA · frozen policy · frozen verifier command/budget · dependency fingerprint
|
|
67
|
-
│
|
|
68
|
-
┌─────────────────────────────┴───────────────────────────────┐
|
|
69
|
-
│ agent loop (Claude Code hooks today) │
|
|
70
|
-
│ PreToolUse gate: every proposed Edit/Write/Bash judged; │
|
|
71
|
-
│ known weakening DENIED before the tool runs │
|
|
72
|
-
│ │ │
|
|
73
|
-
│ working tree ▼ │
|
|
74
|
-
│ effect observation: per-call protected-tree drift check │
|
|
75
|
-
│ + filesystem-event observer (`tamperward watch`) │
|
|
76
|
-
│ │ │
|
|
77
|
-
│ Stop sweep ▼ │
|
|
78
|
-
│ end-of-turn net diff re-checked; transient protected │
|
|
79
|
-
│ mutations judged from the event log │
|
|
80
|
-
│ │ │
|
|
81
|
-
│ pristine verification ▼ (`tamperward verify`) │
|
|
82
|
-
│ suite runs twice in separate copies: the candidate tree │
|
|
83
|
-
│ as-is, and with protected tests/snapshots/config │
|
|
84
|
-
│ restored from the trusted base; visible-green + │
|
|
85
|
-
│ pristine-red = MASKED FAILURE │
|
|
86
|
-
└─────────────────────────────┬───────────────────────────────┘
|
|
87
|
-
│ agent runtime exits — exit code NOT trusted
|
|
88
|
-
▼
|
|
89
|
-
`tamperward run -- <agent…>` — the outer envelope
|
|
90
|
-
policy + verifier resolved from the entry state, never the tree
|
|
91
|
-
the agent wrote · post-agent HEAD must descend from the entry
|
|
92
|
-
SHA · policy check over base...HEAD (committed weakening) and
|
|
93
|
-
over the worktree incl. untracked files (uncommitted) ·
|
|
94
|
-
dependency-tree fingerprint compared · quiescence guard ·
|
|
95
|
-
pristine verify against the entry base · exit 1 on any blocking
|
|
96
|
-
finding or masked failure · exit 2 (fail closed) when
|
|
97
|
-
adjudication is impossible
|
|
98
|
-
│
|
|
99
|
-
▼
|
|
100
|
-
pre-commit backstop: `tamperward check --staged`
|
|
101
|
-
│
|
|
102
|
-
▼
|
|
103
|
-
PR CI: `check --diff base...head` + `verify --require-ancestor`
|
|
104
|
-
policy read from the merge-base — a PR cannot govern its own verdict
|
|
105
|
-
│
|
|
106
|
-
▼
|
|
107
|
-
out-of-band human exception only: PR label
|
|
108
|
-
`tamperward:allow:<rule>@<head-sha>` — SHA-bound, applied by a human
|
|
109
|
-
with write access, never a file the branch can commit
|
|
110
|
-
│
|
|
111
|
-
▼
|
|
112
|
-
protected main
|
|
113
|
-
```
|
|
114
108
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
109
|
+
Before starting the agent, the envelope
|
|
110
|
+
|
|
111
|
+
1. records the exact entry commit;
|
|
112
|
+
2. loads the policy and the verifier command and budget from that commit;
|
|
113
|
+
3. records the installed `node_modules` content fingerprint, where one exists; and
|
|
114
|
+
4. refuses an already-dirty tree unless the operator explicitly accepts the risk.
|
|
115
|
+
|
|
116
|
+
The hooks then steer the agent while it works: they read live state, deny known
|
|
117
|
+
weakening operations, detect protected-tree drift, and sweep the turn's net
|
|
118
|
+
changes. The `Stop` hook runs that sweep — it does **not** invoke `verify`.
|
|
119
|
+
`tamperward watch` can additionally record transient mutations, but it is an
|
|
120
|
+
optional observer that `init` neither starts nor supervises, and it is not an
|
|
121
|
+
enforcement boundary.
|
|
122
|
+
|
|
123
|
+
After the runtime exits its exit code is treated as untrusted, and the envelope
|
|
124
|
+
checks that post-agent `HEAD` still descends from the entry commit; the committed
|
|
125
|
+
changes over `entry...HEAD`; staged, unstaged and untracked non-ignored worktree
|
|
126
|
+
changes; dependency drift and whether the tree stayed quiescent; and the
|
|
127
|
+
verification outcome.
|
|
128
|
+
|
|
129
|
+
`tamperward verify` materialises two temporary copies — they are **not
|
|
130
|
+
sandboxes**, and both execute agent-controlled code. The visible copy runs the
|
|
131
|
+
candidate as-is. In the pristine copy, files on the protected overlay classes and
|
|
132
|
+
on a separate enumerated **verification surface** (the configuration a test runner
|
|
133
|
+
auto-consults) are restored from the trusted base, and agent-added files on those
|
|
134
|
+
surfaces are removed. That surface list deliberately **bounds** runner-configuration
|
|
135
|
+
inheritance rather than eliminating it — see
|
|
136
|
+
[docs/THREAT-MODEL-pristine-run.md](./docs/THREAT-MODEL-pristine-run.md).
|
|
137
|
+
|
|
138
|
+
Visible green followed by pristine red is a `MASKED_FAILURE`. Two green runs
|
|
139
|
+
establish only that the candidate passes the base suite; they establish neither
|
|
140
|
+
semantic correctness nor a substitute for independent held-out tests.
|
|
141
|
+
|
|
142
|
+
| result | envelope behaviour |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| Enforcement clean and agent exits 0 | exit 0 |
|
|
145
|
+
| Blocking finding, masked failure, dependency drift, or non-quiescent tree | exit 1 |
|
|
146
|
+
| Required adjudication cannot be completed | exit 2, failing closed |
|
|
147
|
+
| Enforcement clean but the agent failed | the agent's non-zero exit is preserved |
|
|
148
|
+
|
|
149
|
+
### CI authority
|
|
150
|
+
|
|
151
|
+
Pre-commit is an early backstop; protected CI is the merge authority. CI runs, independently:
|
|
152
|
+
|
|
153
|
+
1. `check --diff base...head`, with policy from the trusted merge-base; and
|
|
154
|
+
2. `verify --require-ancestor --base <base-sha>`, which since **1.14.3** also reads
|
|
155
|
+
its policy, verification surface and verifier command and budget from that same
|
|
156
|
+
trusted base — so neither job's verdict is governed by the candidate.
|
|
157
|
+
|
|
158
|
+
Legitimate exceptions are out-of-band PR labels bound to the exact head SHA; a new
|
|
159
|
+
push invalidates the approval.
|
|
160
|
+
|
|
161
|
+
This guarantee depends on a protected and immutable base, required status checks, a
|
|
162
|
+
pinned Tamperward version, and label permissions restricted to trusted humans.
|
|
163
|
+
Tamperward provides no OS isolation, no network confinement, no secret isolation,
|
|
164
|
+
and no semantic-correctness oracle.
|
|
165
|
+
|
|
166
|
+
Full assumptions and residual risks: [SPEC.md](./SPEC.md),
|
|
167
|
+
[SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md), and the
|
|
168
|
+
[series limitations page](./docs/blog/limitations.md).
|
|
124
169
|
|
|
125
170
|
## Quick start
|
|
126
171
|
|
|
@@ -140,11 +185,20 @@ A real deployment needs a verify command configured — the generated CI verify
|
|
|
140
185
|
verify:
|
|
141
186
|
command: npm test
|
|
142
187
|
budget: 300
|
|
188
|
+
inputs: ['scripts/**'] # what the command DELEGATES to
|
|
143
189
|
```
|
|
144
190
|
|
|
145
191
|
That block is itself a guarded surface: changing the command, lowering the budget,
|
|
146
|
-
or removing it is flagged as policy weakening — a verifier an
|
|
147
|
-
`true` is no verification at all.
|
|
192
|
+
narrowing `inputs`, or removing it is flagged as policy weakening — a verifier an
|
|
193
|
+
agent can point at `true` is no verification at all.
|
|
194
|
+
|
|
195
|
+
`inputs` names the files the command *executes*, so the pristine run gets the
|
|
196
|
+
base's copy of them too. A command token that names a file present at the base is
|
|
197
|
+
picked up automatically (`node runner.js`), so most repositories need nothing
|
|
198
|
+
here. Delegation is what needs the list: `npm test` names no file, and the base's
|
|
199
|
+
restored `"test": "sh scripts/test.sh"` will happily call a script nothing
|
|
200
|
+
restored. It bounds the class rather than closing it — see
|
|
201
|
+
[the threat model](./docs/THREAT-MODEL-pristine-run.md).
|
|
148
202
|
|
|
149
203
|
The four primitives:
|
|
150
204
|
|
|
@@ -155,14 +209,7 @@ npx tamperward verify --base main # pristine-suite re-execution
|
|
|
155
209
|
npx tamperward run -- <agent command...> # the outer envelope around an agent
|
|
156
210
|
```
|
|
157
211
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
| layer | the question it answers |
|
|
161
|
-
| --- | --- |
|
|
162
|
-
| **Change semantics** | Does a proposed or recorded change match a known weakening class? (PreToolUse deny, staged diff, range diff) |
|
|
163
|
-
| **Effect** | What did the protected state actually become, however the mutation arrived? (drift check, fs-event observer, Stop sweep) |
|
|
164
|
-
| **Outcome** | Does the candidate still pass when protected verification state from the base is restored? (pristine verification — no diff interpreted at all) |
|
|
165
|
-
| **Authority** | Where is the final verdict rendered, and what assumptions make that verdict authoritative? (run envelope, CI, SHA-bound human sign-off) |
|
|
212
|
+
### The rules
|
|
166
213
|
|
|
167
214
|
Sixteen rules are specified and fourteen ship (see the table in
|
|
168
215
|
[SPEC.md](./SPEC.md)). The families: test protection (`test-deletion`,
|
|
@@ -225,8 +272,12 @@ and its totals.
|
|
|
225
272
|
| 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 |
|
|
226
273
|
| 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 |
|
|
227
274
|
| 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 |
|
|
275
|
+
| 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 |
|
|
276
|
+
| 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 |
|
|
277
|
+
| 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 |
|
|
278
|
+
| 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 |
|
|
228
279
|
| 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 |
|
|
229
|
-
| v1.10.1–v1.14.0, owner + two-pass external review | Frozen entry-time policy and verifier, entry-SHA ancestry enforcement, quiescence guard,
|
|
280
|
+
| v1.10.1–v1.14.0, owner + two-pass external review | Frozen entry-time policy and verifier, entry-SHA ancestry enforcement, quiescence guard, `node_modules` content fingerprint, a CI verify step in the generated workflow, the gate pinned to its own version in CI, and SHA-bound sign-off labels |
|
|
230
281
|
|
|
231
282
|
Each row's primary artifact: [CHANGELOG.md](./CHANGELOG.md), [SPEC.md](./SPEC.md),
|
|
232
283
|
and the posts in [docs/blog/](./docs/blog/index.md).
|
|
@@ -247,7 +298,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
|
|
|
247
298
|
|
|
248
299
|
```bash
|
|
249
300
|
npm install && npm run build # bundles the CLI to dist/cli/index.js
|
|
250
|
-
npm test #
|
|
301
|
+
npm test # 368 tests at v1.14.3 — parser, detectors, engine, policy, renderers
|
|
251
302
|
npm run typecheck
|
|
252
303
|
```
|
|
253
304
|
|
package/dist/cli/index.js
CHANGED
|
@@ -1114,6 +1114,13 @@ function policyWeakening(before, after) {
|
|
|
1114
1114
|
if (ab < bb) {
|
|
1115
1115
|
reasons.push(`verify.budget lowered ${bb}s \u2192 ${ab}s \u2014 starving the budget turns "cannot verify" into a pressure point`);
|
|
1116
1116
|
}
|
|
1117
|
+
const stillInput = new Set(av.inputs ?? []);
|
|
1118
|
+
const droppedInputs = (bv.inputs ?? []).filter((g) => !stillInput.has(g));
|
|
1119
|
+
if (droppedInputs.length) {
|
|
1120
|
+
reasons.push(
|
|
1121
|
+
`verify.inputs narrowed (removed ${droppedInputs.join(", ")}) \u2014 the pristine run would execute the candidate's copy of those files`
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1117
1124
|
}
|
|
1118
1125
|
}
|
|
1119
1126
|
return reasons;
|
|
@@ -1624,7 +1631,16 @@ function parsePolicy(raw) {
|
|
|
1624
1631
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
1625
1632
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
1626
1633
|
},
|
|
1627
|
-
...r.verify?.command ? {
|
|
1634
|
+
...r.verify?.command ? {
|
|
1635
|
+
verify: {
|
|
1636
|
+
command: r.verify.command,
|
|
1637
|
+
budget: r.verify.budget ?? 300,
|
|
1638
|
+
// Rebuilding the block field-by-field means a field added later and
|
|
1639
|
+
// not listed here is SILENTLY DROPPED — which is how `inputs` first
|
|
1640
|
+
// shipped as a no-op that parsed, validated, and restored nothing.
|
|
1641
|
+
...Array.isArray(r.verify.inputs) ? { inputs: r.verify.inputs.map(String) } : {}
|
|
1642
|
+
}
|
|
1643
|
+
} : {}
|
|
1628
1644
|
};
|
|
1629
1645
|
}
|
|
1630
1646
|
function parseOrThrow(src, where) {
|
|
@@ -2418,20 +2434,27 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
|
2418
2434
|
}
|
|
2419
2435
|
|
|
2420
2436
|
// src/cli/hook.ts
|
|
2437
|
+
var HookInputError = class extends Error {
|
|
2438
|
+
};
|
|
2421
2439
|
function readStdin() {
|
|
2422
2440
|
try {
|
|
2423
2441
|
return readFileSync9(0, "utf8");
|
|
2424
|
-
} catch {
|
|
2425
|
-
|
|
2442
|
+
} catch (e) {
|
|
2443
|
+
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
2426
2444
|
}
|
|
2427
2445
|
}
|
|
2428
2446
|
function parseInput(raw) {
|
|
2429
2447
|
if (!raw.trim()) return {};
|
|
2448
|
+
let parsed;
|
|
2430
2449
|
try {
|
|
2431
|
-
|
|
2432
|
-
} catch {
|
|
2433
|
-
|
|
2450
|
+
parsed = JSON.parse(raw);
|
|
2451
|
+
} catch (e) {
|
|
2452
|
+
throw new HookInputError(`the hook payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
2434
2453
|
}
|
|
2454
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2455
|
+
throw new HookInputError(`the hook payload is not a JSON object (got ${Array.isArray(parsed) ? "an array" : typeof parsed})`);
|
|
2456
|
+
}
|
|
2457
|
+
return parsed;
|
|
2435
2458
|
}
|
|
2436
2459
|
function recordDenylog(blocks) {
|
|
2437
2460
|
const log = process.env.TAMPERWARD_DENYLOG;
|
|
@@ -2610,16 +2633,30 @@ function emit(r) {
|
|
|
2610
2633
|
if (r.stdout) process.stdout.write(r.stdout);
|
|
2611
2634
|
return r.exitCode;
|
|
2612
2635
|
}
|
|
2636
|
+
function preToolUseFromRaw(raw) {
|
|
2637
|
+
try {
|
|
2638
|
+
return preToolUseVerdict(parseInput(raw));
|
|
2639
|
+
} catch (e) {
|
|
2640
|
+
return failClosed("PreToolUse", errText(e));
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
function stopFromRaw(raw) {
|
|
2644
|
+
try {
|
|
2645
|
+
return stopVerdict(parseInput(raw));
|
|
2646
|
+
} catch (e) {
|
|
2647
|
+
return failClosed("Stop", errText(e));
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2613
2650
|
function runHookClaude() {
|
|
2614
2651
|
try {
|
|
2615
|
-
return emit(
|
|
2652
|
+
return emit(preToolUseFromRaw(readStdin()));
|
|
2616
2653
|
} catch (e) {
|
|
2617
2654
|
return emit(failClosed("PreToolUse", errText(e)));
|
|
2618
2655
|
}
|
|
2619
2656
|
}
|
|
2620
2657
|
function runSweepClaude() {
|
|
2621
2658
|
try {
|
|
2622
|
-
return emit(
|
|
2659
|
+
return emit(stopFromRaw(readStdin()));
|
|
2623
2660
|
} catch (e) {
|
|
2624
2661
|
return emit(failClosed("Stop", errText(e)));
|
|
2625
2662
|
}
|
|
@@ -2672,10 +2709,12 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2672
2709
|
}
|
|
2673
2710
|
|
|
2674
2711
|
// src/cli/init.ts
|
|
2712
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
2675
2713
|
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2676
2714
|
import { dirname as dirname3, join as join8 } from "node:path";
|
|
2677
2715
|
import { fileURLToPath } from "node:url";
|
|
2678
2716
|
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2717
|
+
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
2679
2718
|
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2680
2719
|
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
2681
2720
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
@@ -2729,6 +2768,15 @@ jobs:
|
|
|
2729
2768
|
tamperward:
|
|
2730
2769
|
runs-on: ubuntu-latest
|
|
2731
2770
|
timeout-minutes: 10
|
|
2771
|
+
# The gate is fetched from the registry AT GATE TIME, from inside the
|
|
2772
|
+
# checked-out pull request \u2014 and \`npm\` reads \`.npmrc\` from the working
|
|
2773
|
+
# directory. A PR that commits one line, \`registry=https://\u2026\`, chooses where
|
|
2774
|
+
# the authority judging it is downloaded from; the version pin below pins the
|
|
2775
|
+
# STRING, not the SOURCE. An environment variable outranks a project
|
|
2776
|
+
# \`.npmrc\` in npm's config precedence, so this is what makes the pin mean
|
|
2777
|
+
# something.
|
|
2778
|
+
env:
|
|
2779
|
+
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
|
2732
2780
|
steps:
|
|
2733
2781
|
- uses: actions/checkout@v5
|
|
2734
2782
|
with:
|
|
@@ -2762,6 +2810,30 @@ jobs:
|
|
|
2762
2810
|
- name: Tamperward verify (pristine re-execution)
|
|
2763
2811
|
run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
|
|
2764
2812
|
`;
|
|
2813
|
+
var WORKFLOW_MARK = "# tamperward:generated";
|
|
2814
|
+
var WORKFLOW_MARK_RE = /^# tamperward:generated v(\S+) sha256:([0-9a-f]+)\n/m;
|
|
2815
|
+
function bodyHash(body) {
|
|
2816
|
+
return createHash5("sha256").update(body).digest("hex").slice(0, 16);
|
|
2817
|
+
}
|
|
2818
|
+
function workflowFile(body) {
|
|
2819
|
+
return `${WORKFLOW_MARK} v${TW_VERSION} sha256:${bodyHash(body)}
|
|
2820
|
+
${body}`;
|
|
2821
|
+
}
|
|
2822
|
+
function readWorkflowMark(src) {
|
|
2823
|
+
const m = src.match(WORKFLOW_MARK_RE);
|
|
2824
|
+
if (!m) return null;
|
|
2825
|
+
return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
|
|
2826
|
+
}
|
|
2827
|
+
function toolSet(matcher2) {
|
|
2828
|
+
const m = String(matcher2 ?? "").trim();
|
|
2829
|
+
if (m === "" || m === "*") return null;
|
|
2830
|
+
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
2831
|
+
}
|
|
2832
|
+
function missingTools(matcher2) {
|
|
2833
|
+
const have = toolSet(matcher2);
|
|
2834
|
+
if (have === null) return [];
|
|
2835
|
+
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
2836
|
+
}
|
|
2765
2837
|
function planPolicy(cwd) {
|
|
2766
2838
|
const path = join8(cwd, POLICY_FILE);
|
|
2767
2839
|
if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
|
|
@@ -2795,24 +2867,34 @@ function planClaudeHooks(cwd) {
|
|
|
2795
2867
|
settings = parsed;
|
|
2796
2868
|
}
|
|
2797
2869
|
const hooks = settings.hooks ??= {};
|
|
2798
|
-
const
|
|
2799
|
-
const
|
|
2800
|
-
const
|
|
2801
|
-
|
|
2870
|
+
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => String(h.command ?? "").includes(needle)));
|
|
2871
|
+
const preEntries = entriesWith(hooks.PreToolUse, "tamperward hook claude");
|
|
2872
|
+
const needPre = preEntries.length === 0;
|
|
2873
|
+
const needStop = entriesWith(hooks.Stop, "tamperward sweep claude").length === 0;
|
|
2874
|
+
const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
|
|
2875
|
+
if (!needPre && !needStop && stale.length === 0) {
|
|
2876
|
+
return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
2877
|
+
}
|
|
2878
|
+
const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
|
|
2802
2879
|
return {
|
|
2803
2880
|
item: "agent",
|
|
2804
2881
|
path: rel,
|
|
2805
2882
|
status: existsSync7(path) ? "update" : "create",
|
|
2806
|
-
detail:
|
|
2883
|
+
detail: [
|
|
2884
|
+
needPre && "wire PreToolUse deny",
|
|
2885
|
+
needStop && "wire Stop sweep",
|
|
2886
|
+
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`
|
|
2887
|
+
].filter(Boolean).join(" + "),
|
|
2807
2888
|
apply: () => {
|
|
2808
2889
|
if (needPre) {
|
|
2809
2890
|
(hooks.PreToolUse ??= []).push({
|
|
2810
|
-
|
|
2811
|
-
// that branch was unreachable in the wiring we install. (P2-12.)
|
|
2812
|
-
matcher: "Bash|Edit|Write|MultiEdit|NotebookEdit",
|
|
2891
|
+
matcher: PRE_MATCHER,
|
|
2813
2892
|
hooks: [{ type: "command", command: HOOK_CMD }]
|
|
2814
2893
|
});
|
|
2815
2894
|
}
|
|
2895
|
+
for (const m of stale) {
|
|
2896
|
+
m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
|
|
2897
|
+
}
|
|
2816
2898
|
if (needStop) {
|
|
2817
2899
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
2818
2900
|
}
|
|
@@ -2848,27 +2930,64 @@ ${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
|
2848
2930
|
}
|
|
2849
2931
|
};
|
|
2850
2932
|
}
|
|
2851
|
-
function planWorkflow(cwd) {
|
|
2933
|
+
function planWorkflow(cwd, force) {
|
|
2852
2934
|
const rel = ".github/workflows/tamperward.yml";
|
|
2853
2935
|
const path = join8(cwd, rel);
|
|
2854
|
-
|
|
2936
|
+
const write = () => {
|
|
2937
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
2938
|
+
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
2939
|
+
};
|
|
2940
|
+
if (!existsSync7(path)) {
|
|
2941
|
+
return {
|
|
2942
|
+
item: "ci",
|
|
2943
|
+
path: rel,
|
|
2944
|
+
status: "create",
|
|
2945
|
+
detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
|
|
2946
|
+
apply: write
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
let src;
|
|
2950
|
+
try {
|
|
2951
|
+
src = readFileSync10(path, "utf8");
|
|
2952
|
+
} catch {
|
|
2953
|
+
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
2954
|
+
}
|
|
2955
|
+
const current = bodyHash(WORKFLOW_CONTENT);
|
|
2956
|
+
const mark = readWorkflowMark(src);
|
|
2957
|
+
if (mark && bodyHash(mark.body) === current) {
|
|
2958
|
+
return { item: "ci", path: rel, status: "ok", detail: `generated by tamperward and current (v${mark.version})` };
|
|
2959
|
+
}
|
|
2960
|
+
if (mark && bodyHash(mark.body) === mark.hash) {
|
|
2961
|
+
return {
|
|
2962
|
+
item: "ci",
|
|
2963
|
+
path: rel,
|
|
2964
|
+
status: "update",
|
|
2965
|
+
detail: `generated by tamperward v${mark.version} and unmodified \u2014 migrating to the v${TW_VERSION} template`,
|
|
2966
|
+
apply: write
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
if (force) {
|
|
2970
|
+
return {
|
|
2971
|
+
item: "ci",
|
|
2972
|
+
path: rel,
|
|
2973
|
+
status: "update",
|
|
2974
|
+
detail: mark ? `edited since tamperward v${mark.version} generated it \u2014 OVERWRITING (--force-workflow)` : "not generated by tamperward \u2014 OVERWRITING (--force-workflow)",
|
|
2975
|
+
apply: write
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2855
2978
|
return {
|
|
2856
2979
|
item: "ci",
|
|
2857
2980
|
path: rel,
|
|
2858
|
-
status: "
|
|
2859
|
-
detail: "
|
|
2860
|
-
apply: () => {
|
|
2861
|
-
mkdirSync5(dirname3(path), { recursive: true });
|
|
2862
|
-
writeFileSync5(path, WORKFLOW_CONTENT);
|
|
2863
|
-
}
|
|
2981
|
+
status: "skip",
|
|
2982
|
+
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
2983
|
};
|
|
2865
2984
|
}
|
|
2866
|
-
function planInit(cwd) {
|
|
2867
|
-
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd)];
|
|
2985
|
+
function planInit(cwd, opts = {}) {
|
|
2986
|
+
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false)];
|
|
2868
2987
|
}
|
|
2869
2988
|
function runInit(opts) {
|
|
2870
2989
|
const cwd = opts.cwd ?? process.cwd();
|
|
2871
|
-
const plan = planInit(cwd);
|
|
2990
|
+
const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
|
|
2872
2991
|
const w = process.stdout;
|
|
2873
2992
|
for (const a of plan) {
|
|
2874
2993
|
const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
|
|
@@ -2895,10 +3014,130 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
2895
3014
|
}
|
|
2896
3015
|
|
|
2897
3016
|
// src/cli/verify.ts
|
|
2898
|
-
import { execFileSync as
|
|
2899
|
-
import {
|
|
3017
|
+
import { execFileSync as execFileSync6, spawnSync } from "node:child_process";
|
|
3018
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
3019
|
+
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
3020
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
2901
|
-
import { dirname as dirname4, join as
|
|
3021
|
+
import { dirname as dirname4, join as join11, resolve } from "node:path";
|
|
3022
|
+
|
|
3023
|
+
// src/git/trusted.ts
|
|
3024
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
3025
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
3026
|
+
import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
|
|
3027
|
+
import { isAbsolute as isAbsolute2, join as join9 } from "node:path";
|
|
3028
|
+
function trustedGitEnv() {
|
|
3029
|
+
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3030
|
+
}
|
|
3031
|
+
function gitDirOf(cwd) {
|
|
3032
|
+
try {
|
|
3033
|
+
const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
|
|
3034
|
+
cwd,
|
|
3035
|
+
encoding: "utf8",
|
|
3036
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3037
|
+
env: trustedGitEnv()
|
|
3038
|
+
}).trim();
|
|
3039
|
+
if (!d) return null;
|
|
3040
|
+
return isAbsolute2(d) ? d : join9(cwd, d);
|
|
3041
|
+
} catch {
|
|
3042
|
+
return null;
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
function objectRewriteState(cwd) {
|
|
3046
|
+
const parts = [];
|
|
3047
|
+
try {
|
|
3048
|
+
parts.push(
|
|
3049
|
+
"replace:" + execFileSync4("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
3050
|
+
cwd,
|
|
3051
|
+
encoding: "utf8",
|
|
3052
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3053
|
+
env: trustedGitEnv()
|
|
3054
|
+
}).trim()
|
|
3055
|
+
);
|
|
3056
|
+
} catch {
|
|
3057
|
+
parts.push("replace:<unreadable>");
|
|
3058
|
+
}
|
|
3059
|
+
const dir = gitDirOf(cwd);
|
|
3060
|
+
for (const rel of ["info/grafts", "shallow"]) {
|
|
3061
|
+
if (dir === null) {
|
|
3062
|
+
parts.push(`${rel}:<no-git-dir>`);
|
|
3063
|
+
continue;
|
|
3064
|
+
}
|
|
3065
|
+
const p = join9(dir, rel);
|
|
3066
|
+
try {
|
|
3067
|
+
parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
|
|
3068
|
+
} catch {
|
|
3069
|
+
parts.push(`${rel}:<unreadable>`);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
return createHash6("sha256").update(parts.join("\n")).digest("hex");
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// src/fingerprint.ts
|
|
3076
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3077
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
3078
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
|
|
3079
|
+
import { join as join10 } from "node:path";
|
|
3080
|
+
function treeFingerprint(cwd) {
|
|
3081
|
+
const files = execFileSync5("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
3082
|
+
cwd,
|
|
3083
|
+
encoding: "utf8",
|
|
3084
|
+
maxBuffer: 1 << 28,
|
|
3085
|
+
env: trustedGitEnv()
|
|
3086
|
+
}).split("\0").filter(Boolean).sort();
|
|
3087
|
+
const h = createHash7("sha256");
|
|
3088
|
+
for (const rel of files) {
|
|
3089
|
+
h.update(rel);
|
|
3090
|
+
h.update("\0");
|
|
3091
|
+
try {
|
|
3092
|
+
h.update(readFileSync12(join10(cwd, rel)));
|
|
3093
|
+
} catch {
|
|
3094
|
+
h.update("<unreadable>");
|
|
3095
|
+
}
|
|
3096
|
+
h.update("\0");
|
|
3097
|
+
}
|
|
3098
|
+
return h.digest("hex");
|
|
3099
|
+
}
|
|
3100
|
+
function depsFingerprint(cwd) {
|
|
3101
|
+
const root = join10(cwd, "node_modules");
|
|
3102
|
+
const h = createHash7("sha256");
|
|
3103
|
+
const walk = (dir) => {
|
|
3104
|
+
let entries;
|
|
3105
|
+
try {
|
|
3106
|
+
entries = readdirSync3(dir, { withFileTypes: true });
|
|
3107
|
+
} catch {
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3111
|
+
const p = join10(dir, e.name);
|
|
3112
|
+
if (e.isDirectory()) walk(p);
|
|
3113
|
+
else if (e.isFile()) {
|
|
3114
|
+
h.update(p);
|
|
3115
|
+
try {
|
|
3116
|
+
h.update(readFileSync12(p));
|
|
3117
|
+
h.update(String(statSync5(p).mode));
|
|
3118
|
+
} catch {
|
|
3119
|
+
h.update("<unreadable>");
|
|
3120
|
+
}
|
|
3121
|
+
} else if (e.isSymbolicLink()) {
|
|
3122
|
+
h.update(p);
|
|
3123
|
+
try {
|
|
3124
|
+
h.update(readlinkSync(p));
|
|
3125
|
+
} catch {
|
|
3126
|
+
h.update("<unreadable-link>");
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
};
|
|
3131
|
+
try {
|
|
3132
|
+
if (!statSync5(root).isDirectory()) return "none";
|
|
3133
|
+
} catch {
|
|
3134
|
+
return "none";
|
|
3135
|
+
}
|
|
3136
|
+
walk(root);
|
|
3137
|
+
return h.digest("hex");
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
// src/cli/verify.ts
|
|
2902
3141
|
var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
|
|
2903
3142
|
var VERIFICATION_SURFACE = [
|
|
2904
3143
|
// Python / pytest — read from the rootdir, and conftest at any depth
|
|
@@ -2927,7 +3166,7 @@ var VERIFICATION_SURFACE = [
|
|
|
2927
3166
|
"**/*.runsettings"
|
|
2928
3167
|
];
|
|
2929
3168
|
function git2(args, cwd) {
|
|
2930
|
-
return
|
|
3169
|
+
return execFileSync6("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
2931
3170
|
}
|
|
2932
3171
|
function resolveBase(base, cwd) {
|
|
2933
3172
|
assertRev(base);
|
|
@@ -2939,32 +3178,32 @@ function resolveBase(base, cwd) {
|
|
|
2939
3178
|
}
|
|
2940
3179
|
}
|
|
2941
3180
|
function baseIsAncestorOfHead(base, cwd) {
|
|
2942
|
-
const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd });
|
|
3181
|
+
const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd, env: trustedGitEnv() });
|
|
2943
3182
|
return r.status === 0;
|
|
2944
3183
|
}
|
|
2945
3184
|
function materialize(cwd, dest) {
|
|
2946
3185
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2947
3186
|
for (const rel of listed) {
|
|
2948
|
-
const src =
|
|
3187
|
+
const src = join11(cwd, rel);
|
|
2949
3188
|
let st;
|
|
2950
3189
|
try {
|
|
2951
|
-
st =
|
|
3190
|
+
st = statSync6(src);
|
|
2952
3191
|
} catch {
|
|
2953
3192
|
continue;
|
|
2954
3193
|
}
|
|
2955
3194
|
if (!st.isFile()) continue;
|
|
2956
|
-
const out2 =
|
|
3195
|
+
const out2 = join11(dest, rel);
|
|
2957
3196
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
2958
3197
|
dropSymlink(out2);
|
|
2959
3198
|
if (lstatSync(src).isSymbolicLink()) {
|
|
2960
|
-
writeFileSync6(out2,
|
|
3199
|
+
writeFileSync6(out2, readFileSync13(src));
|
|
2961
3200
|
} else {
|
|
2962
3201
|
cpSync(src, out2, { dereference: true });
|
|
2963
3202
|
}
|
|
2964
3203
|
chmodSync2(out2, st.mode);
|
|
2965
3204
|
}
|
|
2966
3205
|
const nm = resolve(cwd, "node_modules");
|
|
2967
|
-
if (
|
|
3206
|
+
if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
|
|
2968
3207
|
}
|
|
2969
3208
|
function dropSymlink(p) {
|
|
2970
3209
|
try {
|
|
@@ -2972,29 +3211,77 @@ function dropSymlink(p) {
|
|
|
2972
3211
|
} catch {
|
|
2973
3212
|
}
|
|
2974
3213
|
}
|
|
2975
|
-
function
|
|
2976
|
-
const
|
|
2977
|
-
const
|
|
2978
|
-
|
|
3214
|
+
function verifierInputs(cmd, atBase, policy) {
|
|
3215
|
+
const baseFiles = new Set(atBase);
|
|
3216
|
+
const picked = /* @__PURE__ */ new Set();
|
|
3217
|
+
for (const raw of cmd.split(/[\s;&|()<>]+/)) {
|
|
3218
|
+
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
3219
|
+
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
3220
|
+
}
|
|
3221
|
+
const globs = policy.verify?.inputs;
|
|
3222
|
+
if (globs && globs.length) {
|
|
3223
|
+
for (const rel of atBase) if (matchesAny(rel, globs)) picked.add(rel);
|
|
3224
|
+
}
|
|
3225
|
+
return picked;
|
|
3226
|
+
}
|
|
3227
|
+
function baseEntries(base, cwd) {
|
|
3228
|
+
return git2(["ls-tree", "-r", "-z", base], cwd).split("\0").filter(Boolean).map((rec) => {
|
|
3229
|
+
const tab = rec.indexOf(" ");
|
|
3230
|
+
const [mode, type] = rec.slice(0, tab).split(" ");
|
|
3231
|
+
return { mode, type, path: rec.slice(tab + 1) };
|
|
3232
|
+
});
|
|
3233
|
+
}
|
|
3234
|
+
function overlayDigest(dest, paths) {
|
|
3235
|
+
const h = createHash8("sha256");
|
|
3236
|
+
for (const rel of [...paths].sort()) {
|
|
3237
|
+
h.update(rel);
|
|
3238
|
+
h.update("\0");
|
|
3239
|
+
const p = join11(dest, rel);
|
|
3240
|
+
try {
|
|
3241
|
+
const st = lstatSync(p);
|
|
3242
|
+
h.update(String(st.mode));
|
|
3243
|
+
h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync13(p));
|
|
3244
|
+
} catch {
|
|
3245
|
+
h.update("<gone>");
|
|
3246
|
+
}
|
|
3247
|
+
h.update("\0");
|
|
3248
|
+
}
|
|
3249
|
+
return h.digest("hex");
|
|
3250
|
+
}
|
|
3251
|
+
function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
3252
|
+
const entries = baseEntries(base, cwd);
|
|
3253
|
+
const atBase = entries.map((e) => e.path);
|
|
3254
|
+
const verifierOwned = verifierInputs(cmd, atBase, policy);
|
|
3255
|
+
const verifierGlobs = policy.verify?.inputs ?? [];
|
|
3256
|
+
const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c)) || matchesAny(p, VERIFICATION_SURFACE) || verifierOwned.has(p) || verifierGlobs.length > 0 && matchesAny(p, verifierGlobs);
|
|
3257
|
+
const restored = [];
|
|
2979
3258
|
const baseProtected = /* @__PURE__ */ new Set();
|
|
2980
|
-
for (const
|
|
2981
|
-
if (!isOverlay(
|
|
2982
|
-
baseProtected.add(
|
|
2983
|
-
|
|
2984
|
-
|
|
3259
|
+
for (const e of entries) {
|
|
3260
|
+
if (!isOverlay(e.path)) continue;
|
|
3261
|
+
baseProtected.add(e.path);
|
|
3262
|
+
if (e.type === "commit") {
|
|
3263
|
+
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
3264
|
+
}
|
|
3265
|
+
const content = execFileSync6("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
3266
|
+
const out2 = join11(dest, e.path);
|
|
2985
3267
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3268
|
+
rmSync2(out2, { force: true });
|
|
3269
|
+
if (e.mode === "120000") {
|
|
3270
|
+
symlinkSync(content.toString("utf8"), out2);
|
|
3271
|
+
} else {
|
|
3272
|
+
writeFileSync6(out2, content);
|
|
3273
|
+
chmodSync2(out2, parseInt(e.mode.slice(-4), 8) & 511);
|
|
3274
|
+
}
|
|
3275
|
+
restored.push(e.path);
|
|
2989
3276
|
}
|
|
2990
3277
|
let removed = 0;
|
|
2991
3278
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
2992
3279
|
for (const rel of inCopy) {
|
|
2993
3280
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
2994
|
-
rmSync2(
|
|
3281
|
+
rmSync2(join11(dest, rel), { force: true });
|
|
2995
3282
|
removed++;
|
|
2996
3283
|
}
|
|
2997
|
-
return
|
|
3284
|
+
return { restored, removed };
|
|
2998
3285
|
}
|
|
2999
3286
|
function runSuite(dir, cmd, budgetSecs) {
|
|
3000
3287
|
const t0 = Date.now();
|
|
@@ -3014,7 +3301,9 @@ function runVerify(opts) {
|
|
|
3014
3301
|
const out2 = (s) => void process.stdout.write(s + "\n");
|
|
3015
3302
|
let policy;
|
|
3016
3303
|
try {
|
|
3017
|
-
policy = opts.policyOverride
|
|
3304
|
+
if (opts.policyOverride) policy = opts.policyOverride;
|
|
3305
|
+
else if (opts.base) policy = loadPolicyAt(resolveBase(opts.base, cwd), cwd) ?? defaultPolicy();
|
|
3306
|
+
else policy = loadPolicy(cwd);
|
|
3018
3307
|
} catch (e) {
|
|
3019
3308
|
out2(`verify: cannot load policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
3020
3309
|
return 2;
|
|
@@ -3030,7 +3319,9 @@ function runVerify(opts) {
|
|
|
3030
3319
|
}
|
|
3031
3320
|
const cmd = opts.cmd ?? policy.verify?.command;
|
|
3032
3321
|
if (!cmd) {
|
|
3033
|
-
out2(
|
|
3322
|
+
out2(
|
|
3323
|
+
opts.base ? "verify: no suite command in the policy at the trusted base \u2014 the base governs the verifier, so a `verify:` block added only on the candidate is not used. Add it at the base, or pass --cmd explicitly." : "verify: no suite command \u2014 set policy `verify: { command: ... }` or pass --cmd"
|
|
3324
|
+
);
|
|
3034
3325
|
return 2;
|
|
3035
3326
|
}
|
|
3036
3327
|
const budget = opts.budget ?? policy.verify?.budget ?? 300;
|
|
@@ -3041,25 +3332,63 @@ function runVerify(opts) {
|
|
|
3041
3332
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
3042
3333
|
return 2;
|
|
3043
3334
|
}
|
|
3044
|
-
const
|
|
3045
|
-
const visDir =
|
|
3046
|
-
const
|
|
3047
|
-
|
|
3048
|
-
|
|
3335
|
+
const visRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-vis-"));
|
|
3336
|
+
const visDir = join11(visRoot, "t");
|
|
3337
|
+
const cleanup = (dirs) => {
|
|
3338
|
+
if (opts.keep) return;
|
|
3339
|
+
for (const d of dirs) rmSync2(d, { recursive: true, force: true });
|
|
3340
|
+
};
|
|
3341
|
+
const treeBefore = treeFingerprint(cwd);
|
|
3342
|
+
const depsBefore = depsFingerprint(cwd);
|
|
3049
3343
|
try {
|
|
3050
3344
|
mkdirSync6(visDir);
|
|
3051
|
-
mkdirSync6(priDir);
|
|
3052
3345
|
materialize(cwd, visDir);
|
|
3053
|
-
materialize(cwd, priDir);
|
|
3054
|
-
[restored, removedAdded] = overlayPristine(cwd, base, priDir, policy);
|
|
3055
3346
|
} catch (e) {
|
|
3056
|
-
|
|
3347
|
+
cleanup([visRoot]);
|
|
3057
3348
|
out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
3058
3349
|
return 2;
|
|
3059
3350
|
}
|
|
3060
3351
|
const visible = runSuite(visDir, cmd, budget);
|
|
3352
|
+
if (treeFingerprint(cwd) !== treeBefore) {
|
|
3353
|
+
cleanup([visRoot]);
|
|
3354
|
+
out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
|
|
3355
|
+
out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
|
|
3356
|
+
return 2;
|
|
3357
|
+
}
|
|
3358
|
+
if (depsFingerprint(cwd) !== depsBefore) {
|
|
3359
|
+
cleanup([visRoot]);
|
|
3360
|
+
out2("verify: the installed dependency tree changed while the visible suite was running \u2014 the");
|
|
3361
|
+
out2("pristine run executes through it too. Failing closed, not open.");
|
|
3362
|
+
return 2;
|
|
3363
|
+
}
|
|
3364
|
+
const priRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-pri-"));
|
|
3365
|
+
const priDir = join11(priRoot, "t");
|
|
3366
|
+
let restored = [];
|
|
3367
|
+
let removedAdded = 0;
|
|
3368
|
+
try {
|
|
3369
|
+
mkdirSync6(priDir);
|
|
3370
|
+
materialize(cwd, priDir);
|
|
3371
|
+
({ restored, removed: removedAdded } = overlayPristine(cwd, base, priDir, policy, cmd));
|
|
3372
|
+
} catch (e) {
|
|
3373
|
+
cleanup([visRoot, priRoot]);
|
|
3374
|
+
out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
3375
|
+
return 2;
|
|
3376
|
+
}
|
|
3377
|
+
const overlayBefore = overlayDigest(priDir, restored);
|
|
3061
3378
|
const pristine = runSuite(priDir, cmd, budget);
|
|
3062
|
-
|
|
3379
|
+
const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
|
|
3380
|
+
const treeMoved = treeFingerprint(cwd) !== treeBefore;
|
|
3381
|
+
const depsMoved = depsFingerprint(cwd) !== depsBefore;
|
|
3382
|
+
cleanup([visRoot, priRoot]);
|
|
3383
|
+
if (overlayMoved || treeMoved || depsMoved) {
|
|
3384
|
+
out2(
|
|
3385
|
+
"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"
|
|
3386
|
+
);
|
|
3387
|
+
out2("the verdict would describe something other than what ran. Failing closed, not open.");
|
|
3388
|
+
out2("(A suite that rewrites its own snapshots or test files in place will trip this; run it in");
|
|
3389
|
+
out2("whatever mode your runner calls CI, so verification observes rather than updates.)");
|
|
3390
|
+
return 2;
|
|
3391
|
+
}
|
|
3063
3392
|
let verdict2;
|
|
3064
3393
|
let code;
|
|
3065
3394
|
if (visible.exit === null || pristine.exit === null) {
|
|
@@ -3084,15 +3413,15 @@ function runVerify(opts) {
|
|
|
3084
3413
|
budget_secs: budget,
|
|
3085
3414
|
visible: { exit: visible.exit, secs: visible.secs },
|
|
3086
3415
|
pristine: { exit: pristine.exit, secs: pristine.secs },
|
|
3087
|
-
protected_restored: restored,
|
|
3416
|
+
protected_restored: restored.length,
|
|
3088
3417
|
added_protected_removed: removedAdded,
|
|
3089
|
-
...opts.keep ? {
|
|
3418
|
+
...opts.keep ? { visible_dir: visDir, pristine_dir: priDir } : {}
|
|
3090
3419
|
})
|
|
3091
3420
|
);
|
|
3092
3421
|
} else {
|
|
3093
3422
|
const lines = {
|
|
3094
|
-
VERIFIED: `verified: the suite passes, and still passes with all ${restored} protected files restored from ${base.slice(0, 10)}.`,
|
|
3095
|
-
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.`,
|
|
3423
|
+
VERIFIED: `verified: the suite passes, and still passes with all ${restored.length} protected files restored from ${base.slice(0, 10)}.`,
|
|
3424
|
+
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.`,
|
|
3096
3425
|
SUITE_RED: `suite red: the visible suite fails (exit ${visible.exit}) \u2014 fix the code first (pristine exit ${pristine.exit}).`,
|
|
3097
3426
|
BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
|
|
3098
3427
|
};
|
|
@@ -3120,72 +3449,17 @@ function parseVerify(args) {
|
|
|
3120
3449
|
}
|
|
3121
3450
|
|
|
3122
3451
|
// src/cli/run.ts
|
|
3123
|
-
import { execFileSync as
|
|
3124
|
-
import {
|
|
3125
|
-
import {
|
|
3126
|
-
import { join as join10, resolve as resolve2 } from "node:path";
|
|
3452
|
+
import { execFileSync as execFileSync7, spawnSync as spawnSync2 } from "node:child_process";
|
|
3453
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
|
|
3454
|
+
import { resolve as resolve2 } from "node:path";
|
|
3127
3455
|
var out = (s) => process.stdout.write(s + "\n");
|
|
3128
3456
|
var err = (s) => process.stderr.write(s + "\n");
|
|
3129
3457
|
function git3(args, cwd) {
|
|
3130
|
-
return
|
|
3131
|
-
}
|
|
3132
|
-
function treeFingerprint(cwd) {
|
|
3133
|
-
const files = git3(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean).sort();
|
|
3134
|
-
const h = createHash5("sha256");
|
|
3135
|
-
for (const rel of files) {
|
|
3136
|
-
h.update(rel);
|
|
3137
|
-
h.update("\0");
|
|
3138
|
-
try {
|
|
3139
|
-
h.update(readFileSync12(join10(cwd, rel)));
|
|
3140
|
-
} catch {
|
|
3141
|
-
h.update("<unreadable>");
|
|
3142
|
-
}
|
|
3143
|
-
h.update("\0");
|
|
3144
|
-
}
|
|
3145
|
-
return h.digest("hex");
|
|
3146
|
-
}
|
|
3147
|
-
function depsFingerprint(cwd) {
|
|
3148
|
-
const root = join10(cwd, "node_modules");
|
|
3149
|
-
const h = createHash5("sha256");
|
|
3150
|
-
const walk = (dir) => {
|
|
3151
|
-
let entries;
|
|
3152
|
-
try {
|
|
3153
|
-
entries = readdirSync3(dir, { withFileTypes: true });
|
|
3154
|
-
} catch {
|
|
3155
|
-
return;
|
|
3156
|
-
}
|
|
3157
|
-
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
3158
|
-
const p = join10(dir, e.name);
|
|
3159
|
-
if (e.isDirectory()) walk(p);
|
|
3160
|
-
else if (e.isFile()) {
|
|
3161
|
-
h.update(p);
|
|
3162
|
-
try {
|
|
3163
|
-
h.update(readFileSync12(p));
|
|
3164
|
-
h.update(String(statSync6(p).mode));
|
|
3165
|
-
} catch {
|
|
3166
|
-
h.update("<unreadable>");
|
|
3167
|
-
}
|
|
3168
|
-
} else if (e.isSymbolicLink()) {
|
|
3169
|
-
h.update(p);
|
|
3170
|
-
try {
|
|
3171
|
-
h.update(readlinkSync(p));
|
|
3172
|
-
} catch {
|
|
3173
|
-
h.update("<unreadable-link>");
|
|
3174
|
-
}
|
|
3175
|
-
}
|
|
3176
|
-
}
|
|
3177
|
-
};
|
|
3178
|
-
try {
|
|
3179
|
-
if (!statSync6(root).isDirectory()) return "none";
|
|
3180
|
-
} catch {
|
|
3181
|
-
return "none";
|
|
3182
|
-
}
|
|
3183
|
-
walk(root);
|
|
3184
|
-
return h.digest("hex");
|
|
3458
|
+
return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
3185
3459
|
}
|
|
3186
3460
|
function nowTicks() {
|
|
3187
3461
|
try {
|
|
3188
|
-
return Math.floor(parseFloat(
|
|
3462
|
+
return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
3189
3463
|
} catch {
|
|
3190
3464
|
return Number.POSITIVE_INFINITY;
|
|
3191
3465
|
}
|
|
@@ -3200,7 +3474,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3200
3474
|
}
|
|
3201
3475
|
let pids;
|
|
3202
3476
|
try {
|
|
3203
|
-
pids =
|
|
3477
|
+
pids = readdirSync4("/proc").filter((d) => /^\d+$/.test(d));
|
|
3204
3478
|
} catch {
|
|
3205
3479
|
return out2;
|
|
3206
3480
|
}
|
|
@@ -3215,7 +3489,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3215
3489
|
}
|
|
3216
3490
|
if (held !== real && !held.startsWith(real + "/")) continue;
|
|
3217
3491
|
try {
|
|
3218
|
-
const stat =
|
|
3492
|
+
const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
|
|
3219
3493
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
3220
3494
|
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
3221
3495
|
} catch {
|
|
@@ -3262,11 +3536,30 @@ function runEnvelope(opts) {
|
|
|
3262
3536
|
}
|
|
3263
3537
|
out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
|
|
3264
3538
|
const depsBefore = depsFingerprint(cwd);
|
|
3539
|
+
const rewritesBefore = objectRewriteState(cwd);
|
|
3265
3540
|
const spawnTicks = nowTicks();
|
|
3266
3541
|
const agent = spawnSync2(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
|
|
3267
3542
|
const agentExit = agent.status ?? 1;
|
|
3543
|
+
if (objectRewriteState(cwd) !== rewritesBefore) {
|
|
3544
|
+
err("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
|
|
3545
|
+
err("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
|
|
3546
|
+
err("read against the anchor we started from. That is the finding.");
|
|
3547
|
+
out(`
|
|
3548
|
+
tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
3549
|
+
return 1;
|
|
3550
|
+
}
|
|
3551
|
+
if (depsFingerprint(cwd) !== depsBefore) {
|
|
3552
|
+
err("tamperward run: the installed dependency tree changed before adjudication began \u2014 the program");
|
|
3553
|
+
err("that runs the suite is not the one we started with, and both verification runs execute it.");
|
|
3554
|
+
if (!opts.allowDepDrift) {
|
|
3555
|
+
out(`
|
|
3556
|
+
tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEMENT_FAILED`);
|
|
3557
|
+
return 1;
|
|
3558
|
+
}
|
|
3559
|
+
err("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
|
|
3560
|
+
}
|
|
3268
3561
|
const head = git3(["rev-parse", "HEAD"], cwd).trim();
|
|
3269
|
-
const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd });
|
|
3562
|
+
const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
|
|
3270
3563
|
if (isAncestor.status !== 0) {
|
|
3271
3564
|
err(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
|
|
3272
3565
|
err("the agent rewrote history out from under the anchor. That is the finding.");
|
|
@@ -3287,7 +3580,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
3287
3580
|
const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
|
|
3288
3581
|
if (opts.settle && opts.settle > 0) {
|
|
3289
3582
|
try {
|
|
3290
|
-
|
|
3583
|
+
execFileSync7("sleep", [String(opts.settle)]);
|
|
3291
3584
|
} catch {
|
|
3292
3585
|
}
|
|
3293
3586
|
}
|
|
@@ -3301,16 +3594,21 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
3301
3594
|
err("tamperward run: the installed dependency tree changed during this run \u2014 the program that");
|
|
3302
3595
|
err("runs the suite is not the one we started with. Both verification runs execute it.");
|
|
3303
3596
|
}
|
|
3597
|
+
const rewrote = objectRewriteState(cwd) !== rewritesBefore;
|
|
3598
|
+
if (rewrote) {
|
|
3599
|
+
err("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
|
|
3600
|
+
err("the checks above read the trusted base through an object layer that has since moved.");
|
|
3601
|
+
}
|
|
3304
3602
|
const survivors = survivorsHoldingTree(cwd, spawnTicks);
|
|
3305
3603
|
if (survivors.length) {
|
|
3306
3604
|
err(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
|
|
3307
3605
|
err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
|
|
3308
3606
|
}
|
|
3309
|
-
const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted;
|
|
3607
|
+
const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
|
|
3310
3608
|
const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
|
|
3311
3609
|
const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
|
|
3312
3610
|
const enforcement = cannot ? 2 : blocked ? 1 : 0;
|
|
3313
|
-
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";
|
|
3611
|
+
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";
|
|
3314
3612
|
out(`
|
|
3315
3613
|
tamperward run \u2014 agent exit ${agentExit}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
|
|
3316
3614
|
return enforcement !== 0 ? enforcement : agentExit;
|
|
@@ -3364,6 +3662,7 @@ function parseInit(args) {
|
|
|
3364
3662
|
const a = args[i];
|
|
3365
3663
|
if (a === "--cwd") o.cwd = args[++i];
|
|
3366
3664
|
else if (a === "--dry-run") o.dryRun = true;
|
|
3665
|
+
else if (a === "--force-workflow") o.forceWorkflow = true;
|
|
3367
3666
|
}
|
|
3368
3667
|
return o;
|
|
3369
3668
|
}
|
|
@@ -3427,9 +3726,15 @@ Formats:
|
|
|
3427
3726
|
adjudicate (fails closed)
|
|
3428
3727
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
3429
3728
|
tamperward init [--dry-run] wire the policy file plus every
|
|
3430
|
-
|
|
3729
|
+
[--force-workflow] enforcement point: Claude Code hooks,
|
|
3431
3730
|
pre-commit, CI. Idempotent; never
|
|
3432
|
-
overwrites your files.
|
|
3731
|
+
overwrites your files. Re-running it
|
|
3732
|
+
MIGRATES a CI workflow it generated and
|
|
3733
|
+
you have not edited, and widens a
|
|
3734
|
+
PreToolUse matcher that no longer covers
|
|
3735
|
+
every tool the gate must see.
|
|
3736
|
+
--force-workflow replaces a workflow it
|
|
3737
|
+
did not write, or one you have edited.
|
|
3433
3738
|
|
|
3434
3739
|
Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
|
|
3435
3740
|
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.5",
|
|
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",
|