tamperward 2.10.9 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -5
- package/dist/cli/index.js +596 -74
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -207,8 +207,11 @@ request would need the verify label; the self-gate stays a diff-time gate.
|
|
|
207
207
|
|
|
208
208
|
This guarantee depends on a protected and immutable base, required status checks, a
|
|
209
209
|
pinned Tamperward version, and label permissions restricted to trusted humans.
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
The default local verifier is checkpointed same-host execution, not OS isolation.
|
|
211
|
+
An optional digest-pinned container backend isolates final verification with no network,
|
|
212
|
+
host dependency tree, HOME, temp, credential or socket sharing. Tamperward still is not a
|
|
213
|
+
semantic-correctness oracle, and `tamperward run` does not pretend that a same-identity
|
|
214
|
+
host agent is isolated from the Docker daemon.
|
|
212
215
|
|
|
213
216
|
Full assumptions and residual risks: [SPEC.md](./SPEC.md),
|
|
214
217
|
[SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md), and the
|
|
@@ -246,11 +249,26 @@ verify:
|
|
|
246
249
|
command: npm test
|
|
247
250
|
budget: 300
|
|
248
251
|
inputs: ['scripts/**'] # what the command DELEGATES to
|
|
252
|
+
# Optional stronger final-verification boundary:
|
|
253
|
+
# backend: container
|
|
254
|
+
# image: ghcr.io/acme/verifier@sha256:<64-hex-digest>
|
|
249
255
|
```
|
|
250
256
|
|
|
257
|
+
`backend: local` is the default and is reported as `checkpointed-local`. With
|
|
258
|
+
`backend: container`, the image must be digest-pinned and already present on the fixed
|
|
259
|
+
local Docker daemon; Tamperward never pulls during adjudication. The materialised
|
|
260
|
+
candidate/pristine tree is mounted read-only, the image owns runtime/dependencies,
|
|
261
|
+
network is disabled, HOME/tmp are private, and optional suite output belongs in
|
|
262
|
+
`$TAMPERWARD_OUTPUT_DIR` (`/workspace-out`). Images that declare Dockerfile
|
|
263
|
+
`VOLUME` paths are refused: Docker mounts those paths writable even with
|
|
264
|
+
`--read-only`, which would undermine the immutable verifier-image boundary. Image
|
|
265
|
+
`ENTRYPOINT` is also overridden; the pinned image supplies the runtime/dependencies,
|
|
266
|
+
while the trusted policy's `verify.command` remains the command that is adjudicated.
|
|
267
|
+
|
|
251
268
|
That block is itself a guarded surface: changing the command, lowering the budget,
|
|
252
|
-
narrowing `inputs`,
|
|
253
|
-
agent can
|
|
269
|
+
narrowing `inputs`, removing `backend: container`, or changing its pinned image is
|
|
270
|
+
flagged as policy weakening — a verifier an agent can redirect is no verification at
|
|
271
|
+
all.
|
|
254
272
|
|
|
255
273
|
`inputs` names the files the command *executes*, so the pristine run gets the
|
|
256
274
|
base's copy of them too. A command token that names a file present at the base is
|
|
@@ -266,9 +284,14 @@ The four primitives:
|
|
|
266
284
|
npx tamperward check --staged # pre-commit view
|
|
267
285
|
npx tamperward check --diff "main...HEAD" # CI view over the PR's commit range
|
|
268
286
|
npx tamperward verify --base main # pristine-suite re-execution
|
|
269
|
-
npx tamperward run -- <agent command...> #
|
|
287
|
+
npx tamperward run -- <agent command...> # same-host envelope (local verifier only)
|
|
270
288
|
```
|
|
271
289
|
|
|
290
|
+
The isolated backend is a **frozen-artifact final verifier**. `tamperward run`
|
|
291
|
+
deliberately refuses `backend: container` before launching the agent because the agent
|
|
292
|
+
would share the host identity that controls Docker. Use isolated `tamperward verify`
|
|
293
|
+
from trusted CI, or after an externally isolated agent hands off the frozen candidate.
|
|
294
|
+
|
|
272
295
|
### CLI reference
|
|
273
296
|
|
|
274
297
|
Every flag below is what the command's parser actually reads (`src/cli/index.ts`,
|
package/dist/cli/index.js
CHANGED
|
@@ -1651,8 +1651,8 @@ function numericOf(e) {
|
|
|
1651
1651
|
}
|
|
1652
1652
|
return void 0;
|
|
1653
1653
|
}
|
|
1654
|
-
function metricsOf(expr,
|
|
1655
|
-
const obj =
|
|
1654
|
+
function metricsOf(expr, resolve8) {
|
|
1655
|
+
const obj = resolve8(expr);
|
|
1656
1656
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
1657
1657
|
const out2 = { values: {}, opaque: false };
|
|
1658
1658
|
let any = false;
|
|
@@ -1666,7 +1666,7 @@ function metricsOf(expr, resolve7) {
|
|
|
1666
1666
|
const k = keyName(p.name);
|
|
1667
1667
|
if (k === null || !isMetric(k)) continue;
|
|
1668
1668
|
any = true;
|
|
1669
|
-
const n = numericOf(
|
|
1669
|
+
const n = numericOf(resolve8(p.initializer));
|
|
1670
1670
|
if (n === void 0) out2.opaque = true;
|
|
1671
1671
|
else out2.values[k] = n;
|
|
1672
1672
|
}
|
|
@@ -1712,17 +1712,17 @@ function parseThresholds(src) {
|
|
|
1712
1712
|
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
1713
1713
|
try {
|
|
1714
1714
|
const sf = sourceOf(src);
|
|
1715
|
-
const
|
|
1715
|
+
const resolve8 = resolverFor(sf);
|
|
1716
1716
|
const visit = (node) => {
|
|
1717
1717
|
if (ts2.isPropertyAssignment(node)) {
|
|
1718
1718
|
const key2 = keyName(node.name);
|
|
1719
1719
|
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
1720
1720
|
res.present = true;
|
|
1721
|
-
const init =
|
|
1721
|
+
const init = resolve8(node.initializer);
|
|
1722
1722
|
if (!ts2.isObjectLiteralExpression(init)) {
|
|
1723
1723
|
res.opaque = true;
|
|
1724
1724
|
} else {
|
|
1725
|
-
const flat = metricsOf(init,
|
|
1725
|
+
const flat = metricsOf(init, resolve8);
|
|
1726
1726
|
if (flat) res.global = merge(res.global, flat);
|
|
1727
1727
|
for (const p of init.properties) {
|
|
1728
1728
|
if (ts2.isSpreadAssignment(p)) {
|
|
@@ -1736,7 +1736,7 @@ function parseThresholds(src) {
|
|
|
1736
1736
|
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
1737
1737
|
continue;
|
|
1738
1738
|
}
|
|
1739
|
-
const m = metricsOf(p.initializer,
|
|
1739
|
+
const m = metricsOf(p.initializer, resolve8);
|
|
1740
1740
|
if (!m) continue;
|
|
1741
1741
|
if (k === "global") res.global = merge(res.global, m);
|
|
1742
1742
|
else res.paths.set(norm(k), m);
|
|
@@ -1769,18 +1769,18 @@ function parseLists(src) {
|
|
|
1769
1769
|
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1770
1770
|
try {
|
|
1771
1771
|
const sf = sourceOf(src);
|
|
1772
|
-
const
|
|
1772
|
+
const resolve8 = resolverFor(sf);
|
|
1773
1773
|
const visit = (node) => {
|
|
1774
1774
|
if (ts2.isPropertyAssignment(node)) {
|
|
1775
1775
|
const k = keyName(node.name);
|
|
1776
1776
|
const vitestCoverage = ownerKey(node) === "coverage";
|
|
1777
1777
|
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
1778
|
-
const { items, opaque } = literals(
|
|
1778
|
+
const { items, opaque } = literals(resolve8(node.initializer));
|
|
1779
1779
|
res.collectFrom = items;
|
|
1780
1780
|
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
1781
1781
|
if (opaque) res.opaqueCollect = true;
|
|
1782
1782
|
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
1783
|
-
const { items, opaque } = literals(
|
|
1783
|
+
const { items, opaque } = literals(resolve8(node.initializer));
|
|
1784
1784
|
res.ignore.push(...items);
|
|
1785
1785
|
if (opaque) res.opaqueIgnore = true;
|
|
1786
1786
|
}
|
|
@@ -3064,6 +3064,18 @@ function policyWeakening(before, after) {
|
|
|
3064
3064
|
`verify.inputs narrowed (removed ${droppedInputs.join(", ")}) \u2014 the pristine run would execute the candidate's copy of those files`
|
|
3065
3065
|
);
|
|
3066
3066
|
}
|
|
3067
|
+
const beforeBackend = bv.backend ?? "local";
|
|
3068
|
+
const afterBackend = av.backend ?? "local";
|
|
3069
|
+
if (beforeBackend === "container" && afterBackend !== "container") {
|
|
3070
|
+
reasons.push(
|
|
3071
|
+
`verify.backend lowered container \u2192 ${afterBackend} \u2014 final verification would leave the isolated trust domain`
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
if (beforeBackend === "container" && afterBackend === "container" && av.image !== bv.image) {
|
|
3075
|
+
reasons.push(
|
|
3076
|
+
`verify.image changed (${bv.image ?? "<missing>"} \u2192 ${av.image ?? "<missing>"}) \u2014 the trusted verifier image identity changed`
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3067
3079
|
}
|
|
3068
3080
|
}
|
|
3069
3081
|
return reasons;
|
|
@@ -6342,6 +6354,21 @@ function validate(r, where) {
|
|
|
6342
6354
|
bad(`verify.budget must be a positive number of seconds, got ${show(v.budget)}`);
|
|
6343
6355
|
}
|
|
6344
6356
|
if (v.inputs !== void 0 && !isStringList(v.inputs)) bad(`verify.inputs must be a list of globs, got ${show(v.inputs)}`);
|
|
6357
|
+
if (v.backend !== void 0 && v.backend !== "local" && v.backend !== "container") {
|
|
6358
|
+
bad(`verify.backend must be "local" or "container", got ${show(v.backend)}`);
|
|
6359
|
+
}
|
|
6360
|
+
if (v.image !== void 0 && typeof v.image !== "string") {
|
|
6361
|
+
bad(`verify.image must be an immutable container image reference, got ${show(v.image)}`);
|
|
6362
|
+
}
|
|
6363
|
+
const backend = v.backend ?? "local";
|
|
6364
|
+
if (backend === "container") {
|
|
6365
|
+
if (typeof v.image !== "string") bad('verify.image is required when verify.backend is "container"');
|
|
6366
|
+
if (!/^(?!-)[^\s@]+@sha256:[0-9a-f]{64}$/i.test(v.image)) {
|
|
6367
|
+
bad(`verify.image must be pinned by sha256 digest (name@sha256:<64 hex>), got ${show(v.image)}`);
|
|
6368
|
+
}
|
|
6369
|
+
} else if (v.image !== void 0) {
|
|
6370
|
+
bad('verify.image is only valid when verify.backend is "container"');
|
|
6371
|
+
}
|
|
6345
6372
|
}
|
|
6346
6373
|
}
|
|
6347
6374
|
function parsePolicy(raw, where = POLICY_FILE) {
|
|
@@ -6374,7 +6401,9 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
6374
6401
|
// Rebuilding the block field-by-field means a field added later and
|
|
6375
6402
|
// not listed here is SILENTLY DROPPED — which is how `inputs` first
|
|
6376
6403
|
// shipped as a no-op that parsed, validated, and restored nothing.
|
|
6377
|
-
...Array.isArray(r.verify.inputs) ? { inputs: r.verify.inputs.map(String) } : {}
|
|
6404
|
+
...Array.isArray(r.verify.inputs) ? { inputs: r.verify.inputs.map(String) } : {},
|
|
6405
|
+
...r.verify.backend ? { backend: r.verify.backend } : {},
|
|
6406
|
+
...r.verify.image ? { image: r.verify.image } : {}
|
|
6378
6407
|
}
|
|
6379
6408
|
} : {}
|
|
6380
6409
|
};
|
|
@@ -8162,11 +8191,11 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
8162
8191
|
}
|
|
8163
8192
|
|
|
8164
8193
|
// src/cli/verify.ts
|
|
8165
|
-
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
8166
|
-
import { createHash as
|
|
8167
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
8194
|
+
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
8195
|
+
import { createHash as createHash10 } from "node:crypto";
|
|
8196
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
8168
8197
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
8169
|
-
import { dirname as
|
|
8198
|
+
import { dirname as dirname7, isAbsolute as isAbsolute10, join as join15, relative as relative5, resolve as resolve6, sep as sep2 } from "node:path";
|
|
8170
8199
|
|
|
8171
8200
|
// src/fingerprint.ts
|
|
8172
8201
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
@@ -8547,6 +8576,420 @@ function dependencyEnvironmentSummary(descriptor) {
|
|
|
8547
8576
|
return "attested " + kinds + " (sha256 " + (descriptor.fingerprint?.slice(0, 12) ?? "unknown") + "...)";
|
|
8548
8577
|
}
|
|
8549
8578
|
|
|
8579
|
+
// src/verifier-backend.ts
|
|
8580
|
+
import { createHash as createHash9, randomUUID } from "node:crypto";
|
|
8581
|
+
import { spawnSync } from "node:child_process";
|
|
8582
|
+
import { accessSync, constants as constants2, readFileSync as readFileSync14, realpathSync as realpathSync4, statSync as statSync5 } from "node:fs";
|
|
8583
|
+
import { delimiter as delimiter2, dirname as dirname6, join as join14, resolve as resolve5 } from "node:path";
|
|
8584
|
+
var DIGEST_IMAGE = /^(?!-)[^\s@]+@sha256:[0-9a-f]{64}$/i;
|
|
8585
|
+
var DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock";
|
|
8586
|
+
function identity() {
|
|
8587
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
8588
|
+
const groups = typeof process.getgroups === "function" ? process.getgroups() : [];
|
|
8589
|
+
return { uid, groups };
|
|
8590
|
+
}
|
|
8591
|
+
function writableByCaller(path) {
|
|
8592
|
+
try {
|
|
8593
|
+
const st = statSync5(path);
|
|
8594
|
+
const { uid, groups } = identity();
|
|
8595
|
+
if (uid === 0) return true;
|
|
8596
|
+
if ((st.mode & 2) !== 0) return true;
|
|
8597
|
+
if (uid !== null && st.uid === uid && (st.mode & 128) !== 0) return true;
|
|
8598
|
+
if (groups.includes(st.gid) && (st.mode & 16) !== 0) return true;
|
|
8599
|
+
return false;
|
|
8600
|
+
} catch {
|
|
8601
|
+
return true;
|
|
8602
|
+
}
|
|
8603
|
+
}
|
|
8604
|
+
function replaceableByCaller(path) {
|
|
8605
|
+
let current = resolve5(path);
|
|
8606
|
+
if (writableByCaller(current)) return true;
|
|
8607
|
+
current = dirname6(current);
|
|
8608
|
+
while (true) {
|
|
8609
|
+
if (writableByCaller(current)) return true;
|
|
8610
|
+
const parent = dirname6(current);
|
|
8611
|
+
if (parent === current) break;
|
|
8612
|
+
current = parent;
|
|
8613
|
+
}
|
|
8614
|
+
return false;
|
|
8615
|
+
}
|
|
8616
|
+
function findExecutable(name) {
|
|
8617
|
+
for (const raw of (process.env.PATH ?? "").split(delimiter2)) {
|
|
8618
|
+
if (!raw) continue;
|
|
8619
|
+
const candidate = join14(raw, name);
|
|
8620
|
+
try {
|
|
8621
|
+
accessSync(candidate, constants2.X_OK);
|
|
8622
|
+
return realpathSync4(candidate);
|
|
8623
|
+
} catch {
|
|
8624
|
+
}
|
|
8625
|
+
}
|
|
8626
|
+
return null;
|
|
8627
|
+
}
|
|
8628
|
+
function fileSha256(path) {
|
|
8629
|
+
return createHash9("sha256").update(readFileSync14(path)).digest("hex");
|
|
8630
|
+
}
|
|
8631
|
+
function engineClientEnv() {
|
|
8632
|
+
return {
|
|
8633
|
+
PATH: "/usr/bin:/bin",
|
|
8634
|
+
HOME: "/nonexistent",
|
|
8635
|
+
LANG: "C",
|
|
8636
|
+
LC_ALL: "C"
|
|
8637
|
+
};
|
|
8638
|
+
}
|
|
8639
|
+
function dockerArgs(host, args) {
|
|
8640
|
+
return ["--host", host, ...args];
|
|
8641
|
+
}
|
|
8642
|
+
function dockerAvailable(enginePath, host) {
|
|
8643
|
+
try {
|
|
8644
|
+
const r = spawnSync(enginePath, dockerArgs(host, ["version"]), {
|
|
8645
|
+
stdio: "ignore",
|
|
8646
|
+
timeout: 5e3,
|
|
8647
|
+
killSignal: "SIGKILL",
|
|
8648
|
+
env: engineClientEnv()
|
|
8649
|
+
});
|
|
8650
|
+
return r.status === 0;
|
|
8651
|
+
} catch {
|
|
8652
|
+
return false;
|
|
8653
|
+
}
|
|
8654
|
+
}
|
|
8655
|
+
function declaredImageVolumePaths(metadata) {
|
|
8656
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
8657
|
+
throw new Error("image metadata must be a Docker image-inspect object");
|
|
8658
|
+
}
|
|
8659
|
+
const config = metadata.Config;
|
|
8660
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
8661
|
+
throw new Error("image metadata Config must be an object");
|
|
8662
|
+
}
|
|
8663
|
+
const volumes = config.Volumes;
|
|
8664
|
+
if (volumes === void 0 || volumes === null) return [];
|
|
8665
|
+
if (typeof volumes !== "object" || Array.isArray(volumes)) {
|
|
8666
|
+
throw new Error("image metadata Config.Volumes must be a mapping or null");
|
|
8667
|
+
}
|
|
8668
|
+
return Object.keys(volumes).sort();
|
|
8669
|
+
}
|
|
8670
|
+
function inspectImageMetadata(enginePath, host, image) {
|
|
8671
|
+
try {
|
|
8672
|
+
const r = spawnSync(enginePath, dockerArgs(host, ["image", "inspect", image]), {
|
|
8673
|
+
encoding: "utf8",
|
|
8674
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
8675
|
+
timeout: 1e4,
|
|
8676
|
+
killSignal: "SIGKILL",
|
|
8677
|
+
env: engineClientEnv()
|
|
8678
|
+
});
|
|
8679
|
+
if (r.error || r.status !== 0) {
|
|
8680
|
+
return {
|
|
8681
|
+
metadata: null,
|
|
8682
|
+
reason: "the digest-pinned verifier image is not present locally; TamperWard refuses to pull during adjudication"
|
|
8683
|
+
};
|
|
8684
|
+
}
|
|
8685
|
+
const parsed = JSON.parse(r.stdout ?? "");
|
|
8686
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
|
8687
|
+
return { metadata: null, reason: "Docker returned malformed verifier image metadata" };
|
|
8688
|
+
}
|
|
8689
|
+
return { metadata: parsed[0] };
|
|
8690
|
+
} catch {
|
|
8691
|
+
return { metadata: null, reason: "Docker returned unreadable verifier image metadata" };
|
|
8692
|
+
}
|
|
8693
|
+
}
|
|
8694
|
+
function engineStable(backend) {
|
|
8695
|
+
if (!backend.enginePath || !backend.engineSha256) return false;
|
|
8696
|
+
try {
|
|
8697
|
+
if (realpathSync4(backend.enginePath) !== backend.enginePath) return false;
|
|
8698
|
+
if (replaceableByCaller(backend.enginePath)) return false;
|
|
8699
|
+
return fileSha256(backend.enginePath) === backend.engineSha256;
|
|
8700
|
+
} catch {
|
|
8701
|
+
return false;
|
|
8702
|
+
}
|
|
8703
|
+
}
|
|
8704
|
+
function prepareVerifierBackend(verify) {
|
|
8705
|
+
const kind = verify?.backend ?? "local";
|
|
8706
|
+
if (kind === "local") {
|
|
8707
|
+
return { kind: "local", trust: "checkpointed-local", available: true };
|
|
8708
|
+
}
|
|
8709
|
+
const image = verify?.image;
|
|
8710
|
+
if (!image || !DIGEST_IMAGE.test(image)) {
|
|
8711
|
+
return {
|
|
8712
|
+
kind: "container",
|
|
8713
|
+
trust: "isolated-container",
|
|
8714
|
+
available: false,
|
|
8715
|
+
...image ? { image } : {},
|
|
8716
|
+
reason: "container verifier requires an immutable runtime-safe image reference name@sha256:<64 hex>"
|
|
8717
|
+
};
|
|
8718
|
+
}
|
|
8719
|
+
const forced = process.env.TAMPERWARD_CONTAINER_ENGINE;
|
|
8720
|
+
if (forced && forced !== "docker") {
|
|
8721
|
+
return {
|
|
8722
|
+
kind: "container",
|
|
8723
|
+
trust: "isolated-container",
|
|
8724
|
+
available: false,
|
|
8725
|
+
image,
|
|
8726
|
+
reason: `unsupported isolated verifier engine ${JSON.stringify(forced)}; Docker is the tested backend`
|
|
8727
|
+
};
|
|
8728
|
+
}
|
|
8729
|
+
const enginePath = findExecutable("docker");
|
|
8730
|
+
if (!enginePath) {
|
|
8731
|
+
return {
|
|
8732
|
+
kind: "container",
|
|
8733
|
+
trust: "isolated-container",
|
|
8734
|
+
available: false,
|
|
8735
|
+
image,
|
|
8736
|
+
reason: "Docker executable was not found on the operator PATH"
|
|
8737
|
+
};
|
|
8738
|
+
}
|
|
8739
|
+
if (replaceableByCaller(enginePath)) {
|
|
8740
|
+
return {
|
|
8741
|
+
kind: "container",
|
|
8742
|
+
trust: "isolated-container",
|
|
8743
|
+
available: false,
|
|
8744
|
+
image,
|
|
8745
|
+
engine: "docker",
|
|
8746
|
+
enginePath,
|
|
8747
|
+
reason: "Docker engine executable is writable/replaceable by the caller identity; it cannot be verifier authority"
|
|
8748
|
+
};
|
|
8749
|
+
}
|
|
8750
|
+
const daemonHost = DEFAULT_DOCKER_HOST;
|
|
8751
|
+
if (!dockerAvailable(enginePath, daemonHost)) {
|
|
8752
|
+
return {
|
|
8753
|
+
kind: "container",
|
|
8754
|
+
trust: "isolated-container",
|
|
8755
|
+
available: false,
|
|
8756
|
+
image,
|
|
8757
|
+
engine: "docker",
|
|
8758
|
+
enginePath,
|
|
8759
|
+
daemonHost,
|
|
8760
|
+
reason: `Docker is unavailable at the fixed verifier endpoint ${daemonHost}`
|
|
8761
|
+
};
|
|
8762
|
+
}
|
|
8763
|
+
const inspectedImage = inspectImageMetadata(enginePath, daemonHost, image);
|
|
8764
|
+
if (!inspectedImage.metadata) {
|
|
8765
|
+
return {
|
|
8766
|
+
kind: "container",
|
|
8767
|
+
trust: "isolated-container",
|
|
8768
|
+
available: false,
|
|
8769
|
+
image,
|
|
8770
|
+
engine: "docker",
|
|
8771
|
+
enginePath,
|
|
8772
|
+
daemonHost,
|
|
8773
|
+
reason: inspectedImage.reason ?? "Docker could not attest the verifier image metadata"
|
|
8774
|
+
};
|
|
8775
|
+
}
|
|
8776
|
+
let declaredVolumes;
|
|
8777
|
+
try {
|
|
8778
|
+
declaredVolumes = declaredImageVolumePaths(inspectedImage.metadata);
|
|
8779
|
+
} catch (e) {
|
|
8780
|
+
return {
|
|
8781
|
+
kind: "container",
|
|
8782
|
+
trust: "isolated-container",
|
|
8783
|
+
available: false,
|
|
8784
|
+
image,
|
|
8785
|
+
engine: "docker",
|
|
8786
|
+
enginePath,
|
|
8787
|
+
daemonHost,
|
|
8788
|
+
reason: `Docker verifier image metadata is invalid: ${e instanceof Error ? e.message : String(e)}`
|
|
8789
|
+
};
|
|
8790
|
+
}
|
|
8791
|
+
if (declaredVolumes.length > 0) {
|
|
8792
|
+
return {
|
|
8793
|
+
kind: "container",
|
|
8794
|
+
trust: "isolated-container",
|
|
8795
|
+
available: false,
|
|
8796
|
+
image,
|
|
8797
|
+
engine: "docker",
|
|
8798
|
+
enginePath,
|
|
8799
|
+
daemonHost,
|
|
8800
|
+
reason: `the verifier image declares writable Docker VOLUME path(s): ${declaredVolumes.join(", ")}; --read-only does not make image-declared volumes immutable`
|
|
8801
|
+
};
|
|
8802
|
+
}
|
|
8803
|
+
return {
|
|
8804
|
+
kind: "container",
|
|
8805
|
+
trust: "isolated-container",
|
|
8806
|
+
available: true,
|
|
8807
|
+
image,
|
|
8808
|
+
engine: "docker",
|
|
8809
|
+
enginePath,
|
|
8810
|
+
engineSha256: fileSha256(enginePath),
|
|
8811
|
+
daemonHost
|
|
8812
|
+
};
|
|
8813
|
+
}
|
|
8814
|
+
function verifierBackendReport(backend) {
|
|
8815
|
+
return {
|
|
8816
|
+
kind: backend.kind,
|
|
8817
|
+
trust: backend.trust,
|
|
8818
|
+
available: backend.available,
|
|
8819
|
+
...backend.image ? { image: backend.image } : {},
|
|
8820
|
+
...backend.engine ? { engine: backend.engine } : {},
|
|
8821
|
+
...backend.reason ? { reason: backend.reason } : {}
|
|
8822
|
+
};
|
|
8823
|
+
}
|
|
8824
|
+
function verifierBackendSummary(backend) {
|
|
8825
|
+
if (backend.kind === "local") {
|
|
8826
|
+
return "local (checkpointed same-host; weaker trust boundary)";
|
|
8827
|
+
}
|
|
8828
|
+
const identity2 = backend.image ?? "<missing image>";
|
|
8829
|
+
if (!backend.available) {
|
|
8830
|
+
return `container (isolated; unavailable: ${backend.reason ?? "unknown reason"}; image ${identity2})`;
|
|
8831
|
+
}
|
|
8832
|
+
return `container (isolated via Docker; image ${identity2})`;
|
|
8833
|
+
}
|
|
8834
|
+
function cleanupContainer(backend, name) {
|
|
8835
|
+
if (!backend.enginePath || !backend.daemonHost || !engineStable(backend)) return;
|
|
8836
|
+
try {
|
|
8837
|
+
spawnSync(backend.enginePath, dockerArgs(backend.daemonHost, ["rm", "-f", "-v", name]), {
|
|
8838
|
+
stdio: "ignore",
|
|
8839
|
+
timeout: 1e4,
|
|
8840
|
+
killSignal: "SIGKILL",
|
|
8841
|
+
env: engineClientEnv()
|
|
8842
|
+
});
|
|
8843
|
+
} catch {
|
|
8844
|
+
}
|
|
8845
|
+
}
|
|
8846
|
+
function containerRunArgs(input) {
|
|
8847
|
+
return [
|
|
8848
|
+
"run",
|
|
8849
|
+
"--name",
|
|
8850
|
+
input.name,
|
|
8851
|
+
// Keep the stopped container until the trusted host has inspected .State.
|
|
8852
|
+
// Docker CLI exit codes overlap candidate exit codes; container metadata is
|
|
8853
|
+
// the authority that tells us whether the suite actually ran.
|
|
8854
|
+
"--pull",
|
|
8855
|
+
"never",
|
|
8856
|
+
"--network",
|
|
8857
|
+
"none",
|
|
8858
|
+
"--read-only",
|
|
8859
|
+
"--cap-drop",
|
|
8860
|
+
"ALL",
|
|
8861
|
+
"--security-opt",
|
|
8862
|
+
"no-new-privileges",
|
|
8863
|
+
"--pids-limit",
|
|
8864
|
+
"256",
|
|
8865
|
+
"--user",
|
|
8866
|
+
`${input.uid}:${input.gid}`,
|
|
8867
|
+
// Frozen candidate/pristine input is immutable in the verifier. A suite
|
|
8868
|
+
// needing outputs writes to /workspace-out, HOME or /tmp instead.
|
|
8869
|
+
"--mount",
|
|
8870
|
+
`type=bind,src=${input.workspace},dst=/workspace,ro`,
|
|
8871
|
+
"--tmpfs",
|
|
8872
|
+
"/tmp:rw,nosuid,nodev,mode=1777",
|
|
8873
|
+
"--tmpfs",
|
|
8874
|
+
`/home/tamperward:rw,nosuid,nodev,mode=700,uid=${input.uid},gid=${input.gid}`,
|
|
8875
|
+
"--tmpfs",
|
|
8876
|
+
`/workspace-out:rw,nosuid,nodev,mode=700,uid=${input.uid},gid=${input.gid}`,
|
|
8877
|
+
"--workdir",
|
|
8878
|
+
"/workspace",
|
|
8879
|
+
"--env",
|
|
8880
|
+
"HOME=/home/tamperward",
|
|
8881
|
+
"--env",
|
|
8882
|
+
"TMPDIR=/tmp",
|
|
8883
|
+
"--env",
|
|
8884
|
+
"CI=1",
|
|
8885
|
+
"--env",
|
|
8886
|
+
"TAMPERWARD_OUTPUT_DIR=/workspace-out",
|
|
8887
|
+
"--env",
|
|
8888
|
+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
8889
|
+
// The image supplies immutable runtime/dependencies, not command policy.
|
|
8890
|
+
// Docker otherwise appends our command to an image ENTRYPOINT.
|
|
8891
|
+
"--entrypoint",
|
|
8892
|
+
"/bin/sh",
|
|
8893
|
+
input.image,
|
|
8894
|
+
"-c",
|
|
8895
|
+
input.command
|
|
8896
|
+
];
|
|
8897
|
+
}
|
|
8898
|
+
function runContainerStage(backend, dir, command, budgetSecs) {
|
|
8899
|
+
const t0 = Date.now();
|
|
8900
|
+
if (backend.kind !== "container" || !backend.available || !backend.image || !backend.enginePath || !backend.engineSha256 || !backend.daemonHost || !engineStable(backend)) {
|
|
8901
|
+
return {
|
|
8902
|
+
exit: null,
|
|
8903
|
+
secs: 0,
|
|
8904
|
+
failure: "backend",
|
|
8905
|
+
reason: "isolated verifier authority is incomplete or changed before stage execution"
|
|
8906
|
+
};
|
|
8907
|
+
}
|
|
8908
|
+
const name = `tamperward-verify-${process.pid}-${randomUUID().slice(0, 12)}`;
|
|
8909
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 65534;
|
|
8910
|
+
const gid = typeof process.getgid === "function" ? process.getgid() : 65534;
|
|
8911
|
+
const args = containerRunArgs({
|
|
8912
|
+
image: backend.image,
|
|
8913
|
+
name,
|
|
8914
|
+
workspace: resolve5(dir),
|
|
8915
|
+
command,
|
|
8916
|
+
uid,
|
|
8917
|
+
gid
|
|
8918
|
+
});
|
|
8919
|
+
try {
|
|
8920
|
+
const r = spawnSync(
|
|
8921
|
+
backend.enginePath,
|
|
8922
|
+
dockerArgs(backend.daemonHost, args),
|
|
8923
|
+
{
|
|
8924
|
+
stdio: "ignore",
|
|
8925
|
+
timeout: budgetSecs * 1e3,
|
|
8926
|
+
killSignal: "SIGKILL",
|
|
8927
|
+
env: engineClientEnv()
|
|
8928
|
+
}
|
|
8929
|
+
);
|
|
8930
|
+
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
8931
|
+
if (r.error) {
|
|
8932
|
+
cleanupContainer(backend, name);
|
|
8933
|
+
const code = r.error.code;
|
|
8934
|
+
return code === "ETIMEDOUT" ? { exit: null, secs, failure: "budget", reason: "verifier stage exceeded its budget" } : { exit: null, secs, failure: "backend", reason: `Docker client failed: ${r.error.message}` };
|
|
8935
|
+
}
|
|
8936
|
+
if (r.status === null) {
|
|
8937
|
+
cleanupContainer(backend, name);
|
|
8938
|
+
return {
|
|
8939
|
+
exit: null,
|
|
8940
|
+
secs,
|
|
8941
|
+
failure: "backend",
|
|
8942
|
+
reason: `Docker client did not return a status${r.signal ? ` (signal ${r.signal})` : ""}`
|
|
8943
|
+
};
|
|
8944
|
+
}
|
|
8945
|
+
if (!engineStable(backend)) {
|
|
8946
|
+
return {
|
|
8947
|
+
exit: null,
|
|
8948
|
+
secs,
|
|
8949
|
+
failure: "backend",
|
|
8950
|
+
reason: "Docker engine identity changed before verifier result inspection"
|
|
8951
|
+
};
|
|
8952
|
+
}
|
|
8953
|
+
const inspected = spawnSync(
|
|
8954
|
+
backend.enginePath,
|
|
8955
|
+
dockerArgs(backend.daemonHost, ["inspect", "--format", "{{json .State}}", name]),
|
|
8956
|
+
{
|
|
8957
|
+
encoding: "utf8",
|
|
8958
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
8959
|
+
timeout: 1e4,
|
|
8960
|
+
killSignal: "SIGKILL",
|
|
8961
|
+
env: engineClientEnv()
|
|
8962
|
+
}
|
|
8963
|
+
);
|
|
8964
|
+
let state = null;
|
|
8965
|
+
if (!inspected.error && inspected.status === 0) {
|
|
8966
|
+
try {
|
|
8967
|
+
state = JSON.parse((inspected.stdout ?? "").trim());
|
|
8968
|
+
} catch {
|
|
8969
|
+
state = null;
|
|
8970
|
+
}
|
|
8971
|
+
}
|
|
8972
|
+
cleanupContainer(backend, name);
|
|
8973
|
+
if (!state || state.Status !== "exited" || typeof state.ExitCode !== "number" || state.OOMKilled || (state.Error ?? "") !== "") {
|
|
8974
|
+
return {
|
|
8975
|
+
exit: null,
|
|
8976
|
+
secs,
|
|
8977
|
+
failure: "backend",
|
|
8978
|
+
reason: state?.OOMKilled ? "verifier container was OOM-killed" : state?.Error ? `verifier container runtime error: ${state.Error}` : "Docker did not provide a trustworthy exited-container state"
|
|
8979
|
+
};
|
|
8980
|
+
}
|
|
8981
|
+
return { exit: state.ExitCode, secs };
|
|
8982
|
+
} catch (e) {
|
|
8983
|
+
cleanupContainer(backend, name);
|
|
8984
|
+
return {
|
|
8985
|
+
exit: null,
|
|
8986
|
+
secs: Math.round((Date.now() - t0) / 1e3),
|
|
8987
|
+
failure: "backend",
|
|
8988
|
+
reason: `Docker verifier execution failed: ${e instanceof Error ? e.message : String(e)}`
|
|
8989
|
+
};
|
|
8990
|
+
}
|
|
8991
|
+
}
|
|
8992
|
+
|
|
8550
8993
|
// src/cli/verify.ts
|
|
8551
8994
|
var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
|
|
8552
8995
|
var VERIFICATION_SURFACE = [
|
|
@@ -8632,11 +9075,11 @@ function resolveBase(base, cwd) {
|
|
|
8632
9075
|
}
|
|
8633
9076
|
}
|
|
8634
9077
|
function baseIsAncestorOfHead(base, cwd) {
|
|
8635
|
-
const r =
|
|
9078
|
+
const r = spawnSync2("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd, env: trustedGitEnv() });
|
|
8636
9079
|
return r.status === 0;
|
|
8637
9080
|
}
|
|
8638
9081
|
function inside2(root, path) {
|
|
8639
|
-
const rel = relative5(
|
|
9082
|
+
const rel = relative5(resolve6(root), resolve6(path));
|
|
8640
9083
|
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute10(rel);
|
|
8641
9084
|
}
|
|
8642
9085
|
function linkParts(path) {
|
|
@@ -8653,7 +9096,7 @@ function rootedLinkTarget(target) {
|
|
|
8653
9096
|
return isAbsolute10(target) || driveRelativeLinkTarget(target);
|
|
8654
9097
|
}
|
|
8655
9098
|
function safeSymlink(target, out2, root, label) {
|
|
8656
|
-
if (rootedLinkTarget(target) || !inside2(root,
|
|
9099
|
+
if (rootedLinkTarget(target) || !inside2(root, resolve6(dirname7(out2), target))) {
|
|
8657
9100
|
throw linkEscape(label, target);
|
|
8658
9101
|
}
|
|
8659
9102
|
rmSync3(out2, { force: true });
|
|
@@ -8661,7 +9104,7 @@ function safeSymlink(target, out2, root, label) {
|
|
|
8661
9104
|
}
|
|
8662
9105
|
function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
8663
9106
|
let domain = "tree";
|
|
8664
|
-
let stack = linkParts(relative5(root,
|
|
9107
|
+
let stack = linkParts(relative5(root, dirname7(out2)));
|
|
8665
9108
|
const pending = linkParts(target);
|
|
8666
9109
|
const seen = /* @__PURE__ */ new Set();
|
|
8667
9110
|
let hops = 0;
|
|
@@ -8679,7 +9122,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
8679
9122
|
continue;
|
|
8680
9123
|
}
|
|
8681
9124
|
const base = domain === "tree" ? root : dependencyRoot;
|
|
8682
|
-
const current =
|
|
9125
|
+
const current = join15(base, ...stack);
|
|
8683
9126
|
let st;
|
|
8684
9127
|
try {
|
|
8685
9128
|
st = lstatSync4(current);
|
|
@@ -8705,7 +9148,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
8705
9148
|
throw linkEscape(label, target);
|
|
8706
9149
|
}
|
|
8707
9150
|
stack = [];
|
|
8708
|
-
pending.unshift(...linkParts(relative5(dependencyRoot,
|
|
9151
|
+
pending.unshift(...linkParts(relative5(dependencyRoot, resolve6(next))));
|
|
8709
9152
|
} else {
|
|
8710
9153
|
pending.unshift(...linkParts(next));
|
|
8711
9154
|
}
|
|
@@ -8715,7 +9158,7 @@ function rejectLinkedParent(cwd, rel) {
|
|
|
8715
9158
|
const parts = rel.split("/").slice(0, -1);
|
|
8716
9159
|
let current = cwd;
|
|
8717
9160
|
for (const part of parts) {
|
|
8718
|
-
current =
|
|
9161
|
+
current = join15(current, part);
|
|
8719
9162
|
let st;
|
|
8720
9163
|
try {
|
|
8721
9164
|
st = lstatSync4(current);
|
|
@@ -8731,7 +9174,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
8731
9174
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
8732
9175
|
const links = [];
|
|
8733
9176
|
for (const rel of listed) {
|
|
8734
|
-
const src =
|
|
9177
|
+
const src = join15(cwd, rel);
|
|
8735
9178
|
rejectLinkedParent(cwd, rel);
|
|
8736
9179
|
let st;
|
|
8737
9180
|
try {
|
|
@@ -8739,8 +9182,8 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
8739
9182
|
} catch {
|
|
8740
9183
|
continue;
|
|
8741
9184
|
}
|
|
8742
|
-
const out2 =
|
|
8743
|
-
mkdirSync6(
|
|
9185
|
+
const out2 = join15(dest, rel);
|
|
9186
|
+
mkdirSync6(dirname7(out2), { recursive: true });
|
|
8744
9187
|
dropSymlink(out2);
|
|
8745
9188
|
if (st.isSymbolicLink()) {
|
|
8746
9189
|
const target = readlinkSync3(src);
|
|
@@ -8756,7 +9199,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
8756
9199
|
for (const link of links) {
|
|
8757
9200
|
validateSymlinkGraph(link.target, link.out, dest, dependencyRoot, link.rel);
|
|
8758
9201
|
}
|
|
8759
|
-
if (dependencyRoot) symlinkSync(dependencyRoot,
|
|
9202
|
+
if (dependencyRoot) symlinkSync(dependencyRoot, join15(dest, "node_modules"), "dir");
|
|
8760
9203
|
}
|
|
8761
9204
|
function dropSymlink(p) {
|
|
8762
9205
|
try {
|
|
@@ -8785,15 +9228,15 @@ function baseEntries(base, cwd) {
|
|
|
8785
9228
|
});
|
|
8786
9229
|
}
|
|
8787
9230
|
function overlayDigest(dest, paths) {
|
|
8788
|
-
const h =
|
|
9231
|
+
const h = createHash10("sha256");
|
|
8789
9232
|
for (const rel of [...paths].sort()) {
|
|
8790
9233
|
h.update(rel);
|
|
8791
9234
|
h.update("\0");
|
|
8792
|
-
const p =
|
|
9235
|
+
const p = join15(dest, rel);
|
|
8793
9236
|
try {
|
|
8794
9237
|
const st = lstatSync4(p);
|
|
8795
9238
|
h.update(String(st.mode));
|
|
8796
|
-
h.update(st.isSymbolicLink() ? readlinkSync3(p) :
|
|
9239
|
+
h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync15(p));
|
|
8797
9240
|
} catch {
|
|
8798
9241
|
h.update("<gone>");
|
|
8799
9242
|
}
|
|
@@ -8817,8 +9260,8 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
|
|
|
8817
9260
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
8818
9261
|
}
|
|
8819
9262
|
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
8820
|
-
const out2 =
|
|
8821
|
-
mkdirSync6(
|
|
9263
|
+
const out2 = join15(dest, e.path);
|
|
9264
|
+
mkdirSync6(dirname7(out2), { recursive: true });
|
|
8822
9265
|
rmSync3(out2, { force: true });
|
|
8823
9266
|
if (e.mode === "120000") {
|
|
8824
9267
|
const target = content.toString("utf8");
|
|
@@ -8834,7 +9277,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
|
|
|
8834
9277
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
8835
9278
|
for (const rel of inCopy) {
|
|
8836
9279
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
8837
|
-
rmSync3(
|
|
9280
|
+
rmSync3(join15(dest, rel), { force: true });
|
|
8838
9281
|
removed++;
|
|
8839
9282
|
}
|
|
8840
9283
|
for (const link of restoredLinks) {
|
|
@@ -8879,8 +9322,8 @@ function suiteEnv(scratch) {
|
|
|
8879
9322
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
8880
9323
|
env[k] = v;
|
|
8881
9324
|
}
|
|
8882
|
-
const userRc =
|
|
8883
|
-
const globalRc =
|
|
9325
|
+
const userRc = join15(scratch, "npmrc-user");
|
|
9326
|
+
const globalRc = join15(scratch, "npmrc-global");
|
|
8884
9327
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
8885
9328
|
env.npm_config_userconfig = userRc;
|
|
8886
9329
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -8888,12 +9331,12 @@ function suiteEnv(scratch) {
|
|
|
8888
9331
|
env.YARN_IGNORE_PATH = "1";
|
|
8889
9332
|
return env;
|
|
8890
9333
|
}
|
|
8891
|
-
function
|
|
9334
|
+
function runLocalSuite(dir, cmd, budgetSecs) {
|
|
8892
9335
|
const t0 = Date.now();
|
|
8893
|
-
const outDir = mkdtempSync2(
|
|
8894
|
-
const outFile =
|
|
9336
|
+
const outDir = mkdtempSync2(join15(tmpdir2(), "tw-verify-run-"));
|
|
9337
|
+
const outFile = join15(outDir, "result.json");
|
|
8895
9338
|
try {
|
|
8896
|
-
|
|
9339
|
+
spawnSync2(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
8897
9340
|
cwd: dir,
|
|
8898
9341
|
env: suiteEnv(outDir),
|
|
8899
9342
|
stdio: "ignore",
|
|
@@ -8904,7 +9347,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
8904
9347
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
8905
9348
|
let r;
|
|
8906
9349
|
try {
|
|
8907
|
-
r = JSON.parse(
|
|
9350
|
+
r = JSON.parse(readFileSync15(outFile, "utf8"));
|
|
8908
9351
|
} catch {
|
|
8909
9352
|
return { exit: null, secs };
|
|
8910
9353
|
}
|
|
@@ -8948,13 +9391,34 @@ function runVerify(opts) {
|
|
|
8948
9391
|
return 2;
|
|
8949
9392
|
}
|
|
8950
9393
|
const budget = opts.budget ?? policy.verify?.budget ?? 300;
|
|
8951
|
-
const
|
|
8952
|
-
const
|
|
8953
|
-
if (
|
|
9394
|
+
const verifierBackend = opts.verifierBackend ?? prepareVerifierBackend(policy.verify);
|
|
9395
|
+
const backendReport = () => verifierBackendReport(verifierBackend);
|
|
9396
|
+
if (!verifierBackend.available) {
|
|
9397
|
+
if (opts.json) {
|
|
9398
|
+
out2(JSON.stringify({
|
|
9399
|
+
verdict: "CANNOT_VERIFY",
|
|
9400
|
+
reason: "VERIFIER_BACKEND_UNAVAILABLE",
|
|
9401
|
+
verifier_backend: backendReport()
|
|
9402
|
+
}));
|
|
9403
|
+
} else {
|
|
9404
|
+
out2("verify: isolated verifier backend is unavailable \u2014 failing closed");
|
|
9405
|
+
out2("verify: " + (verifierBackend.reason ?? "unknown backend failure"));
|
|
9406
|
+
}
|
|
9407
|
+
return 2;
|
|
9408
|
+
}
|
|
9409
|
+
const isolated = verifierBackend.kind === "container";
|
|
9410
|
+
const dependencyEnvironment = isolated ? null : opts.dependencyEnvironment ?? discoverDependencyEnvironment(cwd, cmd);
|
|
9411
|
+
const dependencyReport = () => dependencyEnvironment ? dependencyEnvironmentReport(dependencyEnvironment) : {
|
|
9412
|
+
status: "verifier-owned",
|
|
9413
|
+
roots: [],
|
|
9414
|
+
image: verifierBackend.image
|
|
9415
|
+
};
|
|
9416
|
+
if (dependencyEnvironment?.status === "unattestable" && !opts.allowDepDrift) {
|
|
8954
9417
|
if (opts.json) {
|
|
8955
9418
|
out2(JSON.stringify({
|
|
8956
9419
|
verdict: "CANNOT_VERIFY",
|
|
8957
9420
|
reason: "DEPENDENCY_ENVIRONMENT_UNATTESTABLE",
|
|
9421
|
+
verifier_backend: backendReport(),
|
|
8958
9422
|
dependency_environment: dependencyReport()
|
|
8959
9423
|
}));
|
|
8960
9424
|
} else {
|
|
@@ -8963,7 +9427,9 @@ function runVerify(opts) {
|
|
|
8963
9427
|
}
|
|
8964
9428
|
return 2;
|
|
8965
9429
|
}
|
|
8966
|
-
const frozenNodeModules = dependencyEnvironment.roots.find((root) => root.kind === "node_modules")?.realPath ?? null;
|
|
9430
|
+
const frozenNodeModules = dependencyEnvironment ? dependencyEnvironment.roots.find((root) => root.kind === "node_modules")?.realPath ?? null : null;
|
|
9431
|
+
const checkDeps = () => dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
|
|
9432
|
+
const runStage = (dir) => isolated ? runContainerStage(verifierBackend, dir, cmd, budget) : runLocalSuite(dir, cmd, budget);
|
|
8967
9433
|
let base;
|
|
8968
9434
|
try {
|
|
8969
9435
|
base = resolveBase(opts.base ?? "HEAD", cwd);
|
|
@@ -8971,15 +9437,15 @@ function runVerify(opts) {
|
|
|
8971
9437
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
8972
9438
|
return 2;
|
|
8973
9439
|
}
|
|
8974
|
-
const visRoot = mkdtempSync2(
|
|
8975
|
-
const visDir =
|
|
9440
|
+
const visRoot = mkdtempSync2(join15(tmpdir2(), "tw-verify-vis-"));
|
|
9441
|
+
const visDir = join15(visRoot, "t");
|
|
8976
9442
|
const cleanup = (dirs) => {
|
|
8977
9443
|
if (opts.keep) return;
|
|
8978
9444
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
8979
9445
|
};
|
|
8980
9446
|
const protectedIgnored = (rel) => isProtected(rel, policy);
|
|
8981
9447
|
const treeBefore = treeFingerprint(cwd, protectedIgnored);
|
|
8982
|
-
const dependencyAtEntry =
|
|
9448
|
+
const dependencyAtEntry = checkDeps();
|
|
8983
9449
|
if (!dependencyAtEntry.ok && !opts.allowDepDrift) {
|
|
8984
9450
|
cleanup([visRoot]);
|
|
8985
9451
|
out2("verify: the frozen dependency environment changed before the visible suite ran \u2014");
|
|
@@ -8994,22 +9460,38 @@ function runVerify(opts) {
|
|
|
8994
9460
|
out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
|
|
8995
9461
|
return 2;
|
|
8996
9462
|
}
|
|
8997
|
-
const visible =
|
|
9463
|
+
const visible = runStage(visDir);
|
|
9464
|
+
if (visible.failure === "backend") {
|
|
9465
|
+
cleanup([visRoot]);
|
|
9466
|
+
if (opts.json) {
|
|
9467
|
+
out2(JSON.stringify({
|
|
9468
|
+
verdict: "CANNOT_VERIFY",
|
|
9469
|
+
reason: "VERIFIER_BACKEND_RUNTIME_FAILURE",
|
|
9470
|
+
stage: "visible",
|
|
9471
|
+
detail: visible.reason,
|
|
9472
|
+
verifier_backend: backendReport()
|
|
9473
|
+
}));
|
|
9474
|
+
} else {
|
|
9475
|
+
out2("verify: isolated verifier backend failed while running the visible stage \u2014 failing closed");
|
|
9476
|
+
if (visible.reason) out2("verify: " + visible.reason);
|
|
9477
|
+
}
|
|
9478
|
+
return 2;
|
|
9479
|
+
}
|
|
8998
9480
|
if (treeFingerprint(cwd, protectedIgnored) !== treeBefore) {
|
|
8999
9481
|
cleanup([visRoot]);
|
|
9000
9482
|
out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
|
|
9001
9483
|
out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
|
|
9002
9484
|
return 2;
|
|
9003
9485
|
}
|
|
9004
|
-
const dependencyAfterVisible =
|
|
9486
|
+
const dependencyAfterVisible = checkDeps();
|
|
9005
9487
|
if (!dependencyAfterVisible.ok && !opts.allowDepDrift) {
|
|
9006
9488
|
cleanup([visRoot]);
|
|
9007
9489
|
out2("verify: the frozen dependency environment changed while the visible suite was running \u2014");
|
|
9008
9490
|
out2((dependencyAfterVisible.reason ?? "dependency identity changed") + ". Failing closed, not open.");
|
|
9009
9491
|
return 2;
|
|
9010
9492
|
}
|
|
9011
|
-
const priRoot = mkdtempSync2(
|
|
9012
|
-
const priDir =
|
|
9493
|
+
const priRoot = mkdtempSync2(join15(tmpdir2(), "tw-verify-pri-"));
|
|
9494
|
+
const priDir = join15(priRoot, "t");
|
|
9013
9495
|
let restored = [];
|
|
9014
9496
|
let removedAdded = 0;
|
|
9015
9497
|
try {
|
|
@@ -9029,10 +9511,26 @@ function runVerify(opts) {
|
|
|
9029
9511
|
return 2;
|
|
9030
9512
|
}
|
|
9031
9513
|
const overlayBefore = overlayDigest(priDir, restored);
|
|
9032
|
-
const pristine =
|
|
9514
|
+
const pristine = runStage(priDir);
|
|
9515
|
+
if (pristine.failure === "backend") {
|
|
9516
|
+
cleanup([visRoot, priRoot]);
|
|
9517
|
+
if (opts.json) {
|
|
9518
|
+
out2(JSON.stringify({
|
|
9519
|
+
verdict: "CANNOT_VERIFY",
|
|
9520
|
+
reason: "VERIFIER_BACKEND_RUNTIME_FAILURE",
|
|
9521
|
+
stage: "pristine",
|
|
9522
|
+
detail: pristine.reason,
|
|
9523
|
+
verifier_backend: backendReport()
|
|
9524
|
+
}));
|
|
9525
|
+
} else {
|
|
9526
|
+
out2("verify: isolated verifier backend failed while running the pristine stage \u2014 failing closed");
|
|
9527
|
+
if (pristine.reason) out2("verify: " + pristine.reason);
|
|
9528
|
+
}
|
|
9529
|
+
return 2;
|
|
9530
|
+
}
|
|
9033
9531
|
const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
|
|
9034
9532
|
const treeMoved = treeFingerprint(cwd, protectedIgnored) !== treeBefore;
|
|
9035
|
-
const dependencyAfterPristine =
|
|
9533
|
+
const dependencyAfterPristine = checkDeps();
|
|
9036
9534
|
const depsMoved = !dependencyAfterPristine.ok && !opts.allowDepDrift;
|
|
9037
9535
|
cleanup([visRoot, priRoot]);
|
|
9038
9536
|
if (overlayMoved || treeMoved || depsMoved) {
|
|
@@ -9072,6 +9570,7 @@ function runVerify(opts) {
|
|
|
9072
9570
|
pristine: { exit: pristine.exit, secs: pristine.secs },
|
|
9073
9571
|
protected_restored: restored.length,
|
|
9074
9572
|
added_protected_removed: removedAdded,
|
|
9573
|
+
verifier_backend: backendReport(),
|
|
9075
9574
|
dependency_environment: dependencyReport(),
|
|
9076
9575
|
...signedOff ? { oob_signoff: signedOff } : {},
|
|
9077
9576
|
...opts.keep ? { visible_dir: visDir, pristine_dir: priDir } : {}
|
|
@@ -9085,7 +9584,10 @@ function runVerify(opts) {
|
|
|
9085
9584
|
BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
|
|
9086
9585
|
};
|
|
9087
9586
|
out2(`tamperward verify \u2014 ${lines[verdict2]}`);
|
|
9088
|
-
out2(`
|
|
9587
|
+
out2(`verifier backend: ${verifierBackendSummary(verifierBackend)}`);
|
|
9588
|
+
out2(
|
|
9589
|
+
dependencyEnvironment ? `dependency environment: ${dependencyEnvironmentSummary(dependencyEnvironment)}` + (opts.allowDepDrift && dependencyEnvironment.status === "unattestable" ? " (operator override)" : "") : `dependency environment: verifier-owned by ${verifierBackend.image ?? "isolated image"}`
|
|
9590
|
+
);
|
|
9089
9591
|
if (signedOff)
|
|
9090
9592
|
out2(
|
|
9091
9593
|
`masked failure cleared by out-of-band approval (tamperward:allow:${signedOff}): a reviewer accepted that the original suite no longer applies to this change. Exit 0.`
|
|
@@ -9130,9 +9632,9 @@ function parseVerify(args) {
|
|
|
9130
9632
|
}
|
|
9131
9633
|
|
|
9132
9634
|
// src/cli/run.ts
|
|
9133
|
-
import { execFileSync as execFileSync9, spawnSync as
|
|
9134
|
-
import { readdirSync as readdirSync4, readFileSync as
|
|
9135
|
-
import { resolve as
|
|
9635
|
+
import { execFileSync as execFileSync9, spawnSync as spawnSync3 } from "node:child_process";
|
|
9636
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync16, readlinkSync as readlinkSync4, realpathSync as realpathSync6 } from "node:fs";
|
|
9637
|
+
import { resolve as resolve7 } from "node:path";
|
|
9136
9638
|
var out = (s) => process.stdout.write(s + "\n");
|
|
9137
9639
|
var err = (s) => process.stderr.write(s + "\n");
|
|
9138
9640
|
function git3(args, cwd) {
|
|
@@ -9140,7 +9642,7 @@ function git3(args, cwd) {
|
|
|
9140
9642
|
}
|
|
9141
9643
|
function nowTicks() {
|
|
9142
9644
|
try {
|
|
9143
|
-
return Math.floor(parseFloat(
|
|
9645
|
+
return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
9144
9646
|
} catch {
|
|
9145
9647
|
return Number.POSITIVE_INFINITY;
|
|
9146
9648
|
}
|
|
@@ -9149,7 +9651,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9149
9651
|
const out2 = [];
|
|
9150
9652
|
let real;
|
|
9151
9653
|
try {
|
|
9152
|
-
real =
|
|
9654
|
+
real = realpathSync6(cwd);
|
|
9153
9655
|
} catch {
|
|
9154
9656
|
return out2;
|
|
9155
9657
|
}
|
|
@@ -9163,7 +9665,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9163
9665
|
const n = Number(pid);
|
|
9164
9666
|
if (n === process.pid) continue;
|
|
9165
9667
|
try {
|
|
9166
|
-
const stat =
|
|
9668
|
+
const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
|
|
9167
9669
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
9168
9670
|
if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
|
|
9169
9671
|
} catch {
|
|
@@ -9178,7 +9680,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9178
9680
|
for (const link of procLinks) {
|
|
9179
9681
|
try {
|
|
9180
9682
|
const raw = readlinkSync4(link).replace(/ \(deleted\)$/, "");
|
|
9181
|
-
const held = raw.startsWith("/") ?
|
|
9683
|
+
const held = raw.startsWith("/") ? resolve7(raw) : raw;
|
|
9182
9684
|
if (held === real || held.startsWith(real + "/")) {
|
|
9183
9685
|
holds = true;
|
|
9184
9686
|
break;
|
|
@@ -9191,7 +9693,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9191
9693
|
return out2;
|
|
9192
9694
|
}
|
|
9193
9695
|
function runEnvelope(opts) {
|
|
9194
|
-
const cwd =
|
|
9696
|
+
const cwd = resolve7(opts.cwd ?? process.cwd());
|
|
9195
9697
|
if (!opts.argv.length) {
|
|
9196
9698
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
9197
9699
|
return 2;
|
|
@@ -9227,18 +9729,37 @@ function runEnvelope(opts) {
|
|
|
9227
9729
|
return 2;
|
|
9228
9730
|
}
|
|
9229
9731
|
}
|
|
9230
|
-
const
|
|
9732
|
+
const verifierBackend = prepareVerifierBackend(frozenPolicy.verify);
|
|
9231
9733
|
out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
|
|
9232
|
-
out(`tamperward run \u2014
|
|
9233
|
-
if (
|
|
9234
|
-
err("tamperward run: the verifier
|
|
9235
|
-
err(`${
|
|
9236
|
-
|
|
9734
|
+
out(`tamperward run \u2014 verifier backend: ${verifierBackendSummary(verifierBackend)}`);
|
|
9735
|
+
if (!verifierBackend.available) {
|
|
9736
|
+
err("tamperward run: the requested verifier backend cannot be established \u2014");
|
|
9737
|
+
err(`${verifierBackend.reason ?? "unknown backend failure"}. Failing closed before the agent starts.`);
|
|
9738
|
+
return 2;
|
|
9739
|
+
}
|
|
9740
|
+
if (verifierBackend.kind === "container") {
|
|
9741
|
+
err("tamperward run: container verification requires a frozen candidate handoff from an");
|
|
9742
|
+
err("agent domain that cannot control the verifier engine. `run` executes the agent under");
|
|
9743
|
+
err("this host identity, so it cannot honestly provide that separation. Failing closed");
|
|
9744
|
+
err("before the agent starts; use standalone `tamperward verify` in trusted CI or after");
|
|
9745
|
+
err("an externally isolated agent produces the frozen candidate artifact.");
|
|
9237
9746
|
return 2;
|
|
9238
9747
|
}
|
|
9748
|
+
const dependencyEnvironment = verifierBackend.kind === "local" ? discoverDependencyEnvironment(cwd, frozenCmd) : null;
|
|
9749
|
+
if (dependencyEnvironment) {
|
|
9750
|
+
out(`tamperward run \u2014 dependency environment: ${dependencyEnvironmentSummary(dependencyEnvironment)}`);
|
|
9751
|
+
if (dependencyEnvironment.status === "unattestable" && !opts.allowDepDrift) {
|
|
9752
|
+
err("tamperward run: the verifier dependency environment cannot be attested \u2014");
|
|
9753
|
+
err(`${dependencyEnvironment.reason ?? "unknown dependency environment"}. Failing closed before the agent starts.`);
|
|
9754
|
+
err("(Pass --allow-dep-drift only if you explicitly own this dependency-integrity risk.)");
|
|
9755
|
+
return 2;
|
|
9756
|
+
}
|
|
9757
|
+
} else {
|
|
9758
|
+
out(`tamperward run \u2014 dependency environment: verifier-owned by ${verifierBackend.image ?? "isolated image"}`);
|
|
9759
|
+
}
|
|
9239
9760
|
const rewritesBefore = objectRewriteState(cwd);
|
|
9240
9761
|
const spawnTicks = nowTicks();
|
|
9241
|
-
const agent =
|
|
9762
|
+
const agent = spawnSync3(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
|
|
9242
9763
|
const agentExit = agent.status ?? 1;
|
|
9243
9764
|
if (objectRewriteState(cwd) !== rewritesBefore) {
|
|
9244
9765
|
err("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
|
|
@@ -9248,7 +9769,7 @@ function runEnvelope(opts) {
|
|
|
9248
9769
|
tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
9249
9770
|
return 1;
|
|
9250
9771
|
}
|
|
9251
|
-
const dependencyBeforeAdjudication = checkDependencyEnvironment(cwd, dependencyEnvironment);
|
|
9772
|
+
const dependencyBeforeAdjudication = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
|
|
9252
9773
|
if (!dependencyBeforeAdjudication.ok) {
|
|
9253
9774
|
err("tamperward run: the frozen dependency environment changed before adjudication began \u2014");
|
|
9254
9775
|
err(`${dependencyBeforeAdjudication.reason ?? "dependency identity changed"}.`);
|
|
@@ -9260,7 +9781,7 @@ tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEME
|
|
|
9260
9781
|
err("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
|
|
9261
9782
|
}
|
|
9262
9783
|
const head = git3(["rev-parse", "HEAD"], cwd).trim();
|
|
9263
|
-
const isAncestor =
|
|
9784
|
+
const isAncestor = spawnSync3("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
|
|
9264
9785
|
if (isAncestor.status !== 0) {
|
|
9265
9786
|
err(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
|
|
9266
9787
|
err("the agent rewrote history out from under the anchor. That is the finding.");
|
|
@@ -9285,8 +9806,9 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
9285
9806
|
cmd: frozenCmd,
|
|
9286
9807
|
budget: frozenBudget,
|
|
9287
9808
|
policyOverride: frozenPolicy,
|
|
9288
|
-
dependencyEnvironment,
|
|
9289
|
-
allowDepDrift: opts.allowDepDrift
|
|
9809
|
+
...dependencyEnvironment ? { dependencyEnvironment } : {},
|
|
9810
|
+
allowDepDrift: opts.allowDepDrift,
|
|
9811
|
+
verifierBackend
|
|
9290
9812
|
});
|
|
9291
9813
|
if (opts.settle && opts.settle > 0) {
|
|
9292
9814
|
try {
|
|
@@ -9299,8 +9821,8 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
9299
9821
|
err("tamperward run: the working tree changed while it was being adjudicated \u2014");
|
|
9300
9822
|
err("the verdict below would describe a tree that no longer exists.");
|
|
9301
9823
|
}
|
|
9302
|
-
const dependencyAfter = checkDependencyEnvironment(cwd, dependencyEnvironment);
|
|
9303
|
-
const depsDrifted = !opts.allowDepDrift && !dependencyAfter.ok;
|
|
9824
|
+
const dependencyAfter = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
|
|
9825
|
+
const depsDrifted = Boolean(dependencyEnvironment) && !opts.allowDepDrift && !dependencyAfter.ok;
|
|
9304
9826
|
if (depsDrifted) {
|
|
9305
9827
|
err("tamperward run: the frozen dependency environment changed during this run \u2014");
|
|
9306
9828
|
err(`${dependencyAfter.reason ?? "dependency identity changed"}. Both verification runs depend on it.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "hexrift",
|