tamperward 2.11.2 → 2.12.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 +33 -12
- package/dist/cli/index.js +556 -133
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,7 +155,15 @@ After the runtime exits its exit code is treated as untrusted, and the envelope
|
|
|
155
155
|
checks that post-agent `HEAD` still descends from the entry commit; the committed
|
|
156
156
|
changes over `entry...HEAD`; staged, unstaged and untracked non-ignored worktree
|
|
157
157
|
changes; dependency drift and whether the tree stayed quiescent; and the
|
|
158
|
-
verification outcome.
|
|
158
|
+
verification outcome. For agent runtimes that may hang, `--agent-budget <seconds>`
|
|
159
|
+
adds an operator-owned wall-clock boundary around the **agent itself** (separate from
|
|
160
|
+
the verifier's `--budget`). On expiry TamperWard terminates the owned process tree,
|
|
161
|
+
then still performs the same post-timeout adjudication. A clean timeout is
|
|
162
|
+
`AGENT_TIMEOUT` / exit 124; any enforcement failure or cannot-adjudicate result
|
|
163
|
+
still outranks that runtime status. On POSIX the agent starts in its own process
|
|
164
|
+
group; on Linux the timeout also enumerates the agent's `/proc` descendant tree so
|
|
165
|
+
a child that escaped the group with `setsid()` is terminated before adjudication.
|
|
166
|
+
Windows uses `taskkill /T /F` as the explicit process-tree fallback.
|
|
159
167
|
|
|
160
168
|
`tamperward verify` materialises two temporary copies — they are **not
|
|
161
169
|
sandboxes**, and both execute agent-controlled code. The visible copy runs the
|
|
@@ -175,6 +183,7 @@ semantic correctness nor a substitute for independent held-out tests.
|
|
|
175
183
|
| Enforcement clean and agent exits 0 | exit 0 |
|
|
176
184
|
| Blocking finding, masked failure, dependency drift, or non-quiescent tree | exit 1 |
|
|
177
185
|
| Required adjudication cannot be completed | exit 2, failing closed |
|
|
186
|
+
| Enforcement clean but the agent exceeded `--agent-budget` | `AGENT_TIMEOUT`, exit 124 |
|
|
178
187
|
| Enforcement clean but the agent failed | the agent's non-zero exit is preserved |
|
|
179
188
|
|
|
180
189
|
### CI authority
|
|
@@ -190,6 +199,16 @@ are involved here, and they are not the same thing.
|
|
|
190
199
|
its policy, verification surface and verifier command and budget from that same
|
|
191
200
|
trusted base — so neither step's verdict is governed by the candidate.
|
|
192
201
|
|
|
202
|
+
Since **2.12.0**, the generated job uses GitHub Actions' 360-minute outer timeout and
|
|
203
|
+
runs `tamperward doctor` before verification. The doctor reads the **trusted base**
|
|
204
|
+
policy, finds the job(s) that actually invoke `tamperward verify`, and requires enough
|
|
205
|
+
static outer time for both independently budgeted stages plus a 60-minute authority
|
|
206
|
+
reserve. With the generated 360-minute job that means budgets up to 9,000 seconds per
|
|
207
|
+
stage fit. Larger positive finite policy budgets remain valid — there is no schema cap —
|
|
208
|
+
but this generated GitHub-hosted authority refuses deterministically and tells you how
|
|
209
|
+
much outer time is required, so a custom runner/workflow can provide it instead of
|
|
210
|
+
GitHub killing verification mid-verdict.
|
|
211
|
+
|
|
193
212
|
Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
|
|
194
213
|
The workflow passes the head SHA to both steps through `TAMPERWARD_OOB_HEAD`, so an
|
|
195
214
|
approval is bound to the exact commit it was granted for and a new push invalidates it.
|
|
@@ -294,7 +313,7 @@ The four primitives:
|
|
|
294
313
|
npx tamperward check --staged # pre-commit view
|
|
295
314
|
npx tamperward check --diff "main...HEAD" # CI view over the PR's commit range
|
|
296
315
|
npx tamperward verify --base main # pristine-suite re-execution
|
|
297
|
-
npx tamperward run -- <agent command...>
|
|
316
|
+
npx tamperward run --agent-budget 1800 -- <agent command...> # optional agent-runtime bound
|
|
298
317
|
```
|
|
299
318
|
|
|
300
319
|
The isolated backend is a **frozen-artifact final verifier**. `tamperward run`
|
|
@@ -312,7 +331,8 @@ commands ignore what they do not know.
|
|
|
312
331
|
| --- | --- |
|
|
313
332
|
| `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
|
|
314
333
|
| `verify` | `--base <rev>` (default `HEAD`) · `--cmd <suite command>` · `--budget <seconds>` · `--json` · `--keep` (keep the two materialised copies and report their paths) · `--require-ancestor` (refuse a base that is not an ancestor of `HEAD`) · `--cwd <dir>` |
|
|
315
|
-
| `
|
|
334
|
+
| `doctor` | `--base <rev>` (trusted policy revision) · `--workflow <path>` (default `.github/workflows/tamperward.yml`) · `--cwd <dir>` — validates every job that runs `tamperward verify` has a static outer timeout covering visible + pristine + authority reserve |
|
|
335
|
+
| `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` (per verifier suite) · `--agent-budget <seconds>` (optional wrapped-agent wall clock) · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
|
|
316
336
|
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
317
337
|
| `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
|
|
318
338
|
| `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
|
|
@@ -320,15 +340,16 @@ commands ignore what they do not know.
|
|
|
320
340
|
|
|
321
341
|
**Exit codes** — part of the public surface:
|
|
322
342
|
|
|
323
|
-
| command | 0 | 1 | 2 |
|
|
324
|
-
| --- | --- | --- | --- |
|
|
325
|
-
| `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, no view given, not a git repository, or an unresolvable revision — any failure the gate cannot recover from is one clean `tamperward: …` line on stderr at exit 2, never a stack trace at exit 1 |
|
|
326
|
-
| `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
|
|
327
|
-
| `
|
|
328
|
-
| `
|
|
329
|
-
| `
|
|
330
|
-
| `
|
|
331
|
-
|
|
|
343
|
+
| command | 0 | 1 | 2 | 124 |
|
|
344
|
+
| --- | --- | --- | --- | --- |
|
|
345
|
+
| `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, no view given, not a git repository, or an unresolvable revision — any failure the gate cannot recover from is one clean `tamperward: …` line on stderr at exit 2, never a stack trace at exit 1 | — |
|
|
346
|
+
| `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run | — |
|
|
347
|
+
| `doctor` | configured verify job(s) have sufficient static outer time for the trusted policy | — | missing/invalid workflow, no verify job, missing/malformed/insufficient timeout, or trusted policy cannot be loaded | — |
|
|
348
|
+
| `run` | enforcement clean and the agent exited 0 — another non-zero agent exit is passed through unchanged | any blocking finding or masked failure, including a non-quiescent process after timeout | cannot adjudicate: dirty start, policy error, verify cannot run | `AGENT_TIMEOUT`: `--agent-budget` expired and post-timeout enforcement was clean |
|
|
349
|
+
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name | — |
|
|
350
|
+
| `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off | — |
|
|
351
|
+
| `init` | wired, or already wired | — | an item needs attention | — |
|
|
352
|
+
| no or unknown command | help printed (no command) | — | unknown command, help printed | — |
|
|
332
353
|
|
|
333
354
|
### Environment variables
|
|
334
355
|
|
package/dist/cli/index.js
CHANGED
|
@@ -674,9 +674,9 @@ function git(args, cwd) {
|
|
|
674
674
|
env: trustedGitEnv()
|
|
675
675
|
});
|
|
676
676
|
} catch (e) {
|
|
677
|
-
const
|
|
678
|
-
const detail = String(
|
|
679
|
-
throw new Error(detail ? `git ${args[0]}: ${detail}` :
|
|
677
|
+
const err3 = e;
|
|
678
|
+
const detail = String(err3.stderr ?? "").trim();
|
|
679
|
+
throw new Error(detail ? `git ${args[0]}: ${detail}` : err3.message ?? `git ${args[0]} failed`);
|
|
680
680
|
}
|
|
681
681
|
}
|
|
682
682
|
function blobAt(rev, path, cwd) {
|
|
@@ -864,11 +864,11 @@ function synthFileChange(displayPath, before, after) {
|
|
|
864
864
|
maxBuffer: 256 * 1024 * 1024
|
|
865
865
|
});
|
|
866
866
|
} catch (e) {
|
|
867
|
-
const
|
|
868
|
-
if (
|
|
869
|
-
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${
|
|
867
|
+
const err3 = e;
|
|
868
|
+
if (err3.status !== 1) {
|
|
869
|
+
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err3.code ?? err3.message ?? String(e)}`);
|
|
870
870
|
}
|
|
871
|
-
raw =
|
|
871
|
+
raw = err3.stdout ? String(err3.stdout) : "";
|
|
872
872
|
}
|
|
873
873
|
} finally {
|
|
874
874
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -1585,7 +1585,7 @@ function branchExists(name, ctx) {
|
|
|
1585
1585
|
}
|
|
1586
1586
|
function revIsHead(rev, ctx) {
|
|
1587
1587
|
if (!ctx?.cwd || rev.startsWith("-")) return null;
|
|
1588
|
-
const
|
|
1588
|
+
const parse4 = (r) => {
|
|
1589
1589
|
try {
|
|
1590
1590
|
return execFileSync4("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
|
|
1591
1591
|
cwd: ctx.cwd,
|
|
@@ -1596,8 +1596,8 @@ function revIsHead(rev, ctx) {
|
|
|
1596
1596
|
return null;
|
|
1597
1597
|
}
|
|
1598
1598
|
};
|
|
1599
|
-
const head =
|
|
1600
|
-
const target =
|
|
1599
|
+
const head = parse4("HEAD");
|
|
1600
|
+
const target = parse4(rev);
|
|
1601
1601
|
if (!head || !target) return null;
|
|
1602
1602
|
return head === target;
|
|
1603
1603
|
}
|
|
@@ -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, resolve9) {
|
|
1655
|
+
const obj = resolve9(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, resolve8) {
|
|
|
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(resolve9(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 resolve9 = 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 = resolve9(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, resolve9);
|
|
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, resolve9);
|
|
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 resolve9 = 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(resolve9(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(resolve9(node.initializer));
|
|
1784
1784
|
res.ignore.push(...items);
|
|
1785
1785
|
if (opaque) res.opaqueIgnore = true;
|
|
1786
1786
|
}
|
|
@@ -7600,6 +7600,36 @@ import { execFileSync as execFileSync6 } from "node:child_process";
|
|
|
7600
7600
|
import { createHash as createHash6 } from "node:crypto";
|
|
7601
7601
|
import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
|
|
7602
7602
|
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join12, relative as relative3, resolve as resolve3 } from "node:path";
|
|
7603
|
+
|
|
7604
|
+
// src/verifier-limits.ts
|
|
7605
|
+
var VERIFIER_STAGE_COUNT = 2;
|
|
7606
|
+
var GENERATED_CI_TIMEOUT_MINUTES = 360;
|
|
7607
|
+
var GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS = 60 * 60;
|
|
7608
|
+
function assertEnvelopeConstants() {
|
|
7609
|
+
if (!Number.isInteger(VERIFIER_STAGE_COUNT) || VERIFIER_STAGE_COUNT < 1) {
|
|
7610
|
+
throw new Error("TamperWard verifier envelope: stage count must be a positive integer");
|
|
7611
|
+
}
|
|
7612
|
+
if (!Number.isFinite(GENERATED_CI_TIMEOUT_MINUTES) || GENERATED_CI_TIMEOUT_MINUTES <= 0) {
|
|
7613
|
+
throw new Error("TamperWard verifier envelope: generated CI timeout must be positive");
|
|
7614
|
+
}
|
|
7615
|
+
if (!Number.isFinite(GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS) || GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS < 0) {
|
|
7616
|
+
throw new Error("TamperWard verifier envelope: overhead reserve cannot be negative");
|
|
7617
|
+
}
|
|
7618
|
+
if (GENERATED_CI_TIMEOUT_MINUTES * 60 <= GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS) {
|
|
7619
|
+
throw new Error(
|
|
7620
|
+
"TamperWard verifier envelope: generated CI timeout leaves no time for verifier stages"
|
|
7621
|
+
);
|
|
7622
|
+
}
|
|
7623
|
+
}
|
|
7624
|
+
assertEnvelopeConstants();
|
|
7625
|
+
function requiredVerifierAuthoritySeconds(stageBudgetSecs) {
|
|
7626
|
+
if (!Number.isFinite(stageBudgetSecs) || stageBudgetSecs <= 0) {
|
|
7627
|
+
throw new Error("verifier stage budget must be a positive finite number");
|
|
7628
|
+
}
|
|
7629
|
+
return VERIFIER_STAGE_COUNT * stageBudgetSecs + GENERATED_CI_MIN_OVERHEAD_RESERVE_SECS;
|
|
7630
|
+
}
|
|
7631
|
+
|
|
7632
|
+
// src/cli/init.ts
|
|
7603
7633
|
var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
7604
7634
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7605
7635
|
function gitConfig(cwd, key2) {
|
|
@@ -7690,7 +7720,7 @@ permissions:
|
|
|
7690
7720
|
jobs:
|
|
7691
7721
|
tamperward:
|
|
7692
7722
|
runs-on: ubuntu-latest
|
|
7693
|
-
timeout-minutes:
|
|
7723
|
+
timeout-minutes: ${GENERATED_CI_TIMEOUT_MINUTES}
|
|
7694
7724
|
steps:
|
|
7695
7725
|
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
|
7696
7726
|
with:
|
|
@@ -7708,6 +7738,12 @@ jobs:
|
|
|
7708
7738
|
# Candidate code runs later in this job. Do not leave the checkout
|
|
7709
7739
|
# token in .git/config where a verifier command can read/reuse it.
|
|
7710
7740
|
persist-credentials: false
|
|
7741
|
+
- name: Tamperward doctor (CI verifier envelope)
|
|
7742
|
+
# Validate the outer GitHub job budget against the TRUSTED base policy
|
|
7743
|
+
# before starting visible/pristine execution. Large policy budgets remain
|
|
7744
|
+
# valid for custom runners; this generated authority refuses if its host
|
|
7745
|
+
# cannot accommodate both stages plus cleanup/reporting reserve.
|
|
7746
|
+
run: tamperward doctor --base "\${{ github.event.pull_request.base.sha }}" --workflow .github/workflows/tamperward.yml
|
|
7711
7747
|
- name: Resolve out-of-band sign-off from PR labels
|
|
7712
7748
|
id: oob
|
|
7713
7749
|
env:
|
|
@@ -8190,12 +8226,139 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
8190
8226
|
return 0;
|
|
8191
8227
|
}
|
|
8192
8228
|
|
|
8229
|
+
// src/cli/doctor.ts
|
|
8230
|
+
import { existsSync as existsSync10, readFileSync as readFileSync13, readdirSync as readdirSync3 } from "node:fs";
|
|
8231
|
+
import { join as join13, resolve as resolve4 } from "node:path";
|
|
8232
|
+
import { parse as parse3 } from "yaml";
|
|
8233
|
+
var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
|
|
8234
|
+
function err(message) {
|
|
8235
|
+
process.stderr.write(`tamperward doctor: ${message}
|
|
8236
|
+
`);
|
|
8237
|
+
return 2;
|
|
8238
|
+
}
|
|
8239
|
+
function policyFor(opts, cwd) {
|
|
8240
|
+
if (!opts.base) return loadPolicy(cwd);
|
|
8241
|
+
const atBase = loadPolicyAt(opts.base, cwd);
|
|
8242
|
+
return atBase ?? defaultPolicy();
|
|
8243
|
+
}
|
|
8244
|
+
function asMapping(value) {
|
|
8245
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
8246
|
+
}
|
|
8247
|
+
function verifyJobs(doc) {
|
|
8248
|
+
const root = asMapping(doc);
|
|
8249
|
+
const jobs = asMapping(root?.jobs);
|
|
8250
|
+
if (!jobs) return [];
|
|
8251
|
+
const found = [];
|
|
8252
|
+
for (const [name, raw] of Object.entries(jobs)) {
|
|
8253
|
+
const job = asMapping(raw);
|
|
8254
|
+
if (!job || !Array.isArray(job.steps)) continue;
|
|
8255
|
+
const hasVerify = job.steps.some((step) => {
|
|
8256
|
+
const item = asMapping(step);
|
|
8257
|
+
return typeof item?.run === "string" && VERIFY_COMMAND.test(item.run);
|
|
8258
|
+
});
|
|
8259
|
+
if (hasVerify) found.push({ name, job });
|
|
8260
|
+
}
|
|
8261
|
+
return found;
|
|
8262
|
+
}
|
|
8263
|
+
function runDoctor(opts = {}) {
|
|
8264
|
+
const cwd = resolve4(opts.cwd ?? process.cwd());
|
|
8265
|
+
let policy;
|
|
8266
|
+
try {
|
|
8267
|
+
policy = policyFor(opts, cwd);
|
|
8268
|
+
} catch (e) {
|
|
8269
|
+
return err(e instanceof PolicyError || e instanceof Error ? e.message : String(e));
|
|
8270
|
+
}
|
|
8271
|
+
if (!policy.verify?.command) {
|
|
8272
|
+
return err("trusted policy has no verify.command; generated CI cannot verify this repository");
|
|
8273
|
+
}
|
|
8274
|
+
const workflowRels = [];
|
|
8275
|
+
if (opts.workflow) {
|
|
8276
|
+
workflowRels.push(opts.workflow);
|
|
8277
|
+
} else {
|
|
8278
|
+
const workflowDirRel = ".github/workflows";
|
|
8279
|
+
const workflowDir = resolve4(cwd, workflowDirRel);
|
|
8280
|
+
if (!existsSync10(workflowDir)) {
|
|
8281
|
+
return err(`${workflowDirRel}: workflow directory does not exist`);
|
|
8282
|
+
}
|
|
8283
|
+
let entries;
|
|
8284
|
+
try {
|
|
8285
|
+
entries = readdirSync3(workflowDir).filter((name) => /\.ya?ml$/i.test(name)).sort();
|
|
8286
|
+
} catch (e) {
|
|
8287
|
+
return err(
|
|
8288
|
+
`${workflowDirRel}: could not enumerate workflows (${e instanceof Error ? e.message : String(e)})`
|
|
8289
|
+
);
|
|
8290
|
+
}
|
|
8291
|
+
for (const name of entries) workflowRels.push(join13(workflowDirRel, name));
|
|
8292
|
+
}
|
|
8293
|
+
const requiredSecs = requiredVerifierAuthoritySeconds(policy.verify.budget);
|
|
8294
|
+
const requiredMinutes = Math.ceil(requiredSecs / 60);
|
|
8295
|
+
let verifyJobCount = 0;
|
|
8296
|
+
for (const workflowRel of workflowRels) {
|
|
8297
|
+
const workflowPath = resolve4(cwd, workflowRel);
|
|
8298
|
+
if (!existsSync10(workflowPath)) {
|
|
8299
|
+
return err(`${workflowRel}: workflow does not exist`);
|
|
8300
|
+
}
|
|
8301
|
+
let doc;
|
|
8302
|
+
try {
|
|
8303
|
+
doc = parse3(readFileSync13(workflowPath, "utf8"));
|
|
8304
|
+
} catch (e) {
|
|
8305
|
+
return err(
|
|
8306
|
+
`${workflowRel}: workflow is not valid YAML (${e instanceof Error ? e.message : String(e)})`
|
|
8307
|
+
);
|
|
8308
|
+
}
|
|
8309
|
+
const jobs = verifyJobs(doc);
|
|
8310
|
+
verifyJobCount += jobs.length;
|
|
8311
|
+
for (const { name, job } of jobs) {
|
|
8312
|
+
const timeout = job["timeout-minutes"];
|
|
8313
|
+
if (timeout === void 0 || timeout === null) {
|
|
8314
|
+
return err(
|
|
8315
|
+
`${workflowRel} job "${name}": timeout-minutes is missing; requires at least ${requiredMinutes} minutes`
|
|
8316
|
+
);
|
|
8317
|
+
}
|
|
8318
|
+
if (typeof timeout === "string" && timeout.includes("${{")) {
|
|
8319
|
+
return err(
|
|
8320
|
+
`${workflowRel} job "${name}": timeout-minutes must be a static numeric value; requires at least ${requiredMinutes} minutes`
|
|
8321
|
+
);
|
|
8322
|
+
}
|
|
8323
|
+
if (typeof timeout !== "number" || !Number.isFinite(timeout)) {
|
|
8324
|
+
return err(
|
|
8325
|
+
`${workflowRel} job "${name}": timeout-minutes must be numeric; got ${JSON.stringify(timeout)}`
|
|
8326
|
+
);
|
|
8327
|
+
}
|
|
8328
|
+
if (timeout <= 0) {
|
|
8329
|
+
return err(
|
|
8330
|
+
`${workflowRel} job "${name}": timeout-minutes must be positive; got ${timeout}`
|
|
8331
|
+
);
|
|
8332
|
+
}
|
|
8333
|
+
if (!Number.isInteger(timeout)) {
|
|
8334
|
+
return err(
|
|
8335
|
+
`${workflowRel} job "${name}": timeout-minutes must be a whole number; got ${timeout}`
|
|
8336
|
+
);
|
|
8337
|
+
}
|
|
8338
|
+
if (timeout < requiredMinutes) {
|
|
8339
|
+
return err(
|
|
8340
|
+
`${workflowRel} job "${name}": timeout-minutes ${timeout} is too small; trusted verify.budget ${policy.verify.budget}s requires at least ${requiredMinutes} minutes for visible + pristine + authority overhead`
|
|
8341
|
+
);
|
|
8342
|
+
}
|
|
8343
|
+
}
|
|
8344
|
+
}
|
|
8345
|
+
if (verifyJobCount === 0) {
|
|
8346
|
+
const scope = opts.workflow ?? ".github/workflows/*.yml|*.yaml";
|
|
8347
|
+
return err(`${scope}: no job contains a tamperward verify step`);
|
|
8348
|
+
}
|
|
8349
|
+
process.stdout.write(
|
|
8350
|
+
`tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.
|
|
8351
|
+
`
|
|
8352
|
+
);
|
|
8353
|
+
return 0;
|
|
8354
|
+
}
|
|
8355
|
+
|
|
8193
8356
|
// src/cli/verify.ts
|
|
8194
8357
|
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
8195
8358
|
import { createHash as createHash10 } from "node:crypto";
|
|
8196
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
8359
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync16, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
8197
8360
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
8198
|
-
import { dirname as dirname7, isAbsolute as isAbsolute10, join as
|
|
8361
|
+
import { dirname as dirname7, isAbsolute as isAbsolute10, join as join16, relative as relative5, resolve as resolve7, sep as sep2 } from "node:path";
|
|
8199
8362
|
|
|
8200
8363
|
// src/fingerprint.ts
|
|
8201
8364
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
@@ -8226,10 +8389,10 @@ function treeFingerprint(cwd, keepIgnored) {
|
|
|
8226
8389
|
// src/dependency-env.ts
|
|
8227
8390
|
import { createHash as createHash8 } from "node:crypto";
|
|
8228
8391
|
import {
|
|
8229
|
-
existsSync as
|
|
8392
|
+
existsSync as existsSync11,
|
|
8230
8393
|
lstatSync as lstatSync3,
|
|
8231
|
-
readdirSync as
|
|
8232
|
-
readFileSync as
|
|
8394
|
+
readdirSync as readdirSync4,
|
|
8395
|
+
readFileSync as readFileSync14,
|
|
8233
8396
|
readlinkSync as readlinkSync2,
|
|
8234
8397
|
realpathSync as realpathSync3,
|
|
8235
8398
|
statSync as statSync4
|
|
@@ -8239,15 +8402,15 @@ import {
|
|
|
8239
8402
|
delimiter,
|
|
8240
8403
|
dirname as dirname5,
|
|
8241
8404
|
isAbsolute as isAbsolute9,
|
|
8242
|
-
join as
|
|
8405
|
+
join as join14,
|
|
8243
8406
|
relative as relative4,
|
|
8244
|
-
resolve as
|
|
8407
|
+
resolve as resolve5,
|
|
8245
8408
|
sep
|
|
8246
8409
|
} from "node:path";
|
|
8247
8410
|
var Unattestable = class extends Error {
|
|
8248
8411
|
};
|
|
8249
8412
|
function inside(root, candidate) {
|
|
8250
|
-
const rel = relative4(
|
|
8413
|
+
const rel = relative4(resolve5(root), resolve5(candidate));
|
|
8251
8414
|
return rel === "" || rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute9(rel);
|
|
8252
8415
|
}
|
|
8253
8416
|
function modeKind(path) {
|
|
@@ -8286,7 +8449,7 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
8286
8449
|
st = statSync4(path);
|
|
8287
8450
|
if (!st.isFile()) throw new Error("not a regular file");
|
|
8288
8451
|
h.update(label + "\0external-file\0" + path + "\0" + String(st.mode) + "\0");
|
|
8289
|
-
h.update(
|
|
8452
|
+
h.update(readFileSync14(path));
|
|
8290
8453
|
h.update("\0");
|
|
8291
8454
|
} catch (e) {
|
|
8292
8455
|
throw new Unattestable(
|
|
@@ -8300,14 +8463,14 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
8300
8463
|
visited.add(realDir);
|
|
8301
8464
|
let entries;
|
|
8302
8465
|
try {
|
|
8303
|
-
entries =
|
|
8466
|
+
entries = readdirSync4(dir, { withFileTypes: true });
|
|
8304
8467
|
} catch (e) {
|
|
8305
8468
|
throw new Unattestable(
|
|
8306
8469
|
"dependency directory " + JSON.stringify(dir) + " cannot be read (" + (e instanceof Error ? e.message : String(e)) + ")"
|
|
8307
8470
|
);
|
|
8308
8471
|
}
|
|
8309
8472
|
for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
8310
|
-
const path =
|
|
8473
|
+
const path = join14(dir, entry.name);
|
|
8311
8474
|
const rel = namespace ? namespace + "/" + entry.name : entry.name;
|
|
8312
8475
|
let ident;
|
|
8313
8476
|
try {
|
|
@@ -8320,7 +8483,7 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
8320
8483
|
h.update(rel + "\0" + ident.kind + "\0" + String(ident.mode) + "\0");
|
|
8321
8484
|
if (ident.kind === "file") {
|
|
8322
8485
|
try {
|
|
8323
|
-
h.update(
|
|
8486
|
+
h.update(readFileSync14(path));
|
|
8324
8487
|
h.update("\0");
|
|
8325
8488
|
} catch (e) {
|
|
8326
8489
|
throw new Unattestable(
|
|
@@ -8379,7 +8542,7 @@ function snapshot(roots, probes, cwd) {
|
|
|
8379
8542
|
const h = createHash8("sha256");
|
|
8380
8543
|
for (const probe of probes) {
|
|
8381
8544
|
h.update(
|
|
8382
|
-
"probe\0" + probe.kind + "\0" + probe.path + "\0" + (
|
|
8545
|
+
"probe\0" + probe.kind + "\0" + probe.path + "\0" + (existsSync11(probe.path) ? "present" : "absent") + "\0"
|
|
8383
8546
|
);
|
|
8384
8547
|
}
|
|
8385
8548
|
for (const root of roots) fingerprintRoot(root, cwd, h);
|
|
@@ -8390,7 +8553,7 @@ function freezeRoot(kind, path) {
|
|
|
8390
8553
|
if (!statSync4(path).isDirectory()) {
|
|
8391
8554
|
throw new Error("not a directory");
|
|
8392
8555
|
}
|
|
8393
|
-
return { kind, path:
|
|
8556
|
+
return { kind, path: resolve5(path), realPath: realpathSync3(path) };
|
|
8394
8557
|
} catch (e) {
|
|
8395
8558
|
throw new Unattestable(
|
|
8396
8559
|
kind + " root " + JSON.stringify(path) + " is not resolvable (" + (e instanceof Error ? e.message : String(e)) + ")"
|
|
@@ -8426,17 +8589,17 @@ function firstExecutable(command) {
|
|
|
8426
8589
|
function resolveExecutable(token, cwd, env) {
|
|
8427
8590
|
if (!token) return null;
|
|
8428
8591
|
if (token.includes("/") || token.includes("\\")) {
|
|
8429
|
-
const path = isAbsolute9(token) ? token :
|
|
8592
|
+
const path = isAbsolute9(token) ? token : resolve5(cwd, token);
|
|
8430
8593
|
try {
|
|
8431
|
-
return
|
|
8594
|
+
return existsSync11(path) ? realpathSync3(path) : null;
|
|
8432
8595
|
} catch {
|
|
8433
8596
|
return null;
|
|
8434
8597
|
}
|
|
8435
8598
|
}
|
|
8436
8599
|
for (const entry of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
8437
|
-
const candidate =
|
|
8600
|
+
const candidate = resolve5(entry, token);
|
|
8438
8601
|
try {
|
|
8439
|
-
if (
|
|
8602
|
+
if (existsSync11(candidate) && statSync4(candidate).isFile()) return realpathSync3(candidate);
|
|
8440
8603
|
} catch {
|
|
8441
8604
|
}
|
|
8442
8605
|
}
|
|
@@ -8453,13 +8616,13 @@ function looksPython(command, executable, token) {
|
|
|
8453
8616
|
}
|
|
8454
8617
|
function inferredVenvFromToken(token, cwd) {
|
|
8455
8618
|
if (!token || !token.includes("/") && !token.includes("\\")) return null;
|
|
8456
|
-
const path = isAbsolute9(token) ? token :
|
|
8619
|
+
const path = isAbsolute9(token) ? token : resolve5(cwd, token);
|
|
8457
8620
|
if (!pythonName(basename3(path))) return null;
|
|
8458
8621
|
const parent = dirname5(path);
|
|
8459
8622
|
const parentName = basename3(parent).toLowerCase();
|
|
8460
8623
|
if (parentName !== "bin" && parentName !== "scripts") return null;
|
|
8461
8624
|
const root = dirname5(parent);
|
|
8462
|
-
return
|
|
8625
|
+
return existsSync11(join14(root, "pyvenv.cfg")) ? root : null;
|
|
8463
8626
|
}
|
|
8464
8627
|
function unsupportedEcosystem(command, token) {
|
|
8465
8628
|
const name = token ? basename3(token).toLowerCase() : "";
|
|
@@ -8475,13 +8638,13 @@ function inferredVenv(executable) {
|
|
|
8475
8638
|
const parentName = basename3(parent).toLowerCase();
|
|
8476
8639
|
if (parentName !== "bin" && parentName !== "scripts") return null;
|
|
8477
8640
|
const root = dirname5(parent);
|
|
8478
|
-
return
|
|
8641
|
+
return existsSync11(join14(root, "pyvenv.cfg")) ? root : null;
|
|
8479
8642
|
}
|
|
8480
8643
|
function venvProblem(root) {
|
|
8481
|
-
const cfg =
|
|
8482
|
-
if (!
|
|
8644
|
+
const cfg = join14(root, "pyvenv.cfg");
|
|
8645
|
+
if (!existsSync11(cfg)) return "VIRTUAL_ENV " + JSON.stringify(root) + " has no pyvenv.cfg";
|
|
8483
8646
|
try {
|
|
8484
|
-
const body =
|
|
8647
|
+
const body = readFileSync14(cfg, "utf8");
|
|
8485
8648
|
if (/^\s*include-system-site-packages\s*=\s*true\s*$/im.test(body)) {
|
|
8486
8649
|
return "Python environment " + JSON.stringify(root) + " enables system site-packages; those external package roots are not bounded";
|
|
8487
8650
|
}
|
|
@@ -8491,7 +8654,7 @@ function venvProblem(root) {
|
|
|
8491
8654
|
return null;
|
|
8492
8655
|
}
|
|
8493
8656
|
function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
|
|
8494
|
-
const cwd =
|
|
8657
|
+
const cwd = resolve5(cwdInput);
|
|
8495
8658
|
const roots = [];
|
|
8496
8659
|
const probes = [];
|
|
8497
8660
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -8504,8 +8667,8 @@ function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
|
|
|
8504
8667
|
}
|
|
8505
8668
|
};
|
|
8506
8669
|
try {
|
|
8507
|
-
const nodeModules =
|
|
8508
|
-
if (
|
|
8670
|
+
const nodeModules = join14(cwd, "node_modules");
|
|
8671
|
+
if (existsSync11(nodeModules)) addRoot("node_modules", nodeModules);
|
|
8509
8672
|
else probes.push({ kind: "node_modules", path: nodeModules });
|
|
8510
8673
|
const token = firstExecutable(command);
|
|
8511
8674
|
const executable = resolveExecutable(token, cwd, env);
|
|
@@ -8514,11 +8677,11 @@ function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
|
|
|
8514
8677
|
if (!isAbsolute9(env.VIRTUAL_ENV)) {
|
|
8515
8678
|
throw new Unattestable("VIRTUAL_ENV is relative (" + JSON.stringify(env.VIRTUAL_ENV) + ")");
|
|
8516
8679
|
}
|
|
8517
|
-
venv =
|
|
8680
|
+
venv = resolve5(env.VIRTUAL_ENV);
|
|
8518
8681
|
} else {
|
|
8519
8682
|
venv = inferredVenv(executable) ?? inferredVenvFromToken(token, cwd);
|
|
8520
|
-
if (!venv && /(?:^|\s)uv\s+run(?:\s|$)/.test(command) &&
|
|
8521
|
-
venv =
|
|
8683
|
+
if (!venv && /(?:^|\s)uv\s+run(?:\s|$)/.test(command) && existsSync11(join14(cwd, ".venv", "pyvenv.cfg"))) {
|
|
8684
|
+
venv = join14(cwd, ".venv");
|
|
8522
8685
|
}
|
|
8523
8686
|
}
|
|
8524
8687
|
if (venv) {
|
|
@@ -8550,7 +8713,7 @@ function checkDependencyEnvironment(cwdInput, descriptor) {
|
|
|
8550
8713
|
return { ok: false, reason: descriptor.reason ?? "dependency environment is not attestable" };
|
|
8551
8714
|
}
|
|
8552
8715
|
try {
|
|
8553
|
-
const fingerprint2 = snapshot(descriptor.roots, descriptor.probes,
|
|
8716
|
+
const fingerprint2 = snapshot(descriptor.roots, descriptor.probes, resolve5(cwdInput));
|
|
8554
8717
|
if (fingerprint2 !== descriptor.fingerprint) {
|
|
8555
8718
|
return { ok: false, fingerprint: fingerprint2, reason: "dependency environment fingerprint changed" };
|
|
8556
8719
|
}
|
|
@@ -8579,8 +8742,8 @@ function dependencyEnvironmentSummary(descriptor) {
|
|
|
8579
8742
|
// src/verifier-backend.ts
|
|
8580
8743
|
import { createHash as createHash9, randomUUID } from "node:crypto";
|
|
8581
8744
|
import { spawnSync } from "node:child_process";
|
|
8582
|
-
import { accessSync, constants as constants2, readFileSync as
|
|
8583
|
-
import { delimiter as delimiter2, dirname as dirname6, join as
|
|
8745
|
+
import { accessSync, constants as constants2, readFileSync as readFileSync15, realpathSync as realpathSync4, statSync as statSync5 } from "node:fs";
|
|
8746
|
+
import { delimiter as delimiter2, dirname as dirname6, join as join15, resolve as resolve6 } from "node:path";
|
|
8584
8747
|
var DEFAULT_CONTAINER_RESOURCE_LIMITS = Object.freeze({
|
|
8585
8748
|
memoryBytes: 2 * 1024 * 1024 * 1024,
|
|
8586
8749
|
// Docker interprets memory-swap == memory as no additional swap allowance.
|
|
@@ -8609,7 +8772,7 @@ function writableByCaller(path) {
|
|
|
8609
8772
|
}
|
|
8610
8773
|
}
|
|
8611
8774
|
function replaceableByCaller(path) {
|
|
8612
|
-
let current =
|
|
8775
|
+
let current = resolve6(path);
|
|
8613
8776
|
if (writableByCaller(current)) return true;
|
|
8614
8777
|
current = dirname6(current);
|
|
8615
8778
|
while (true) {
|
|
@@ -8623,7 +8786,7 @@ function replaceableByCaller(path) {
|
|
|
8623
8786
|
function findExecutable(name) {
|
|
8624
8787
|
for (const raw of (process.env.PATH ?? "").split(delimiter2)) {
|
|
8625
8788
|
if (!raw) continue;
|
|
8626
|
-
const candidate =
|
|
8789
|
+
const candidate = join15(raw, name);
|
|
8627
8790
|
try {
|
|
8628
8791
|
accessSync(candidate, constants2.X_OK);
|
|
8629
8792
|
return realpathSync4(candidate);
|
|
@@ -8633,7 +8796,7 @@ function findExecutable(name) {
|
|
|
8633
8796
|
return null;
|
|
8634
8797
|
}
|
|
8635
8798
|
function fileSha256(path) {
|
|
8636
|
-
return createHash9("sha256").update(
|
|
8799
|
+
return createHash9("sha256").update(readFileSync15(path)).digest("hex");
|
|
8637
8800
|
}
|
|
8638
8801
|
function engineClientEnv() {
|
|
8639
8802
|
return {
|
|
@@ -8955,7 +9118,7 @@ function runContainerStage(backend, dir, command, budgetSecs) {
|
|
|
8955
9118
|
const args = containerRunArgs({
|
|
8956
9119
|
image: backend.image,
|
|
8957
9120
|
name,
|
|
8958
|
-
workspace:
|
|
9121
|
+
workspace: resolve6(dir),
|
|
8959
9122
|
command,
|
|
8960
9123
|
uid,
|
|
8961
9124
|
gid,
|
|
@@ -9124,7 +9287,7 @@ function baseIsAncestorOfHead(base, cwd) {
|
|
|
9124
9287
|
return r.status === 0;
|
|
9125
9288
|
}
|
|
9126
9289
|
function inside2(root, path) {
|
|
9127
|
-
const rel = relative5(
|
|
9290
|
+
const rel = relative5(resolve7(root), resolve7(path));
|
|
9128
9291
|
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute10(rel);
|
|
9129
9292
|
}
|
|
9130
9293
|
function linkParts(path) {
|
|
@@ -9141,7 +9304,7 @@ function rootedLinkTarget(target) {
|
|
|
9141
9304
|
return isAbsolute10(target) || driveRelativeLinkTarget(target);
|
|
9142
9305
|
}
|
|
9143
9306
|
function safeSymlink(target, out2, root, label) {
|
|
9144
|
-
if (rootedLinkTarget(target) || !inside2(root,
|
|
9307
|
+
if (rootedLinkTarget(target) || !inside2(root, resolve7(dirname7(out2), target))) {
|
|
9145
9308
|
throw linkEscape(label, target);
|
|
9146
9309
|
}
|
|
9147
9310
|
rmSync3(out2, { force: true });
|
|
@@ -9167,7 +9330,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
9167
9330
|
continue;
|
|
9168
9331
|
}
|
|
9169
9332
|
const base = domain === "tree" ? root : dependencyRoot;
|
|
9170
|
-
const current =
|
|
9333
|
+
const current = join16(base, ...stack);
|
|
9171
9334
|
let st;
|
|
9172
9335
|
try {
|
|
9173
9336
|
st = lstatSync4(current);
|
|
@@ -9193,7 +9356,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
9193
9356
|
throw linkEscape(label, target);
|
|
9194
9357
|
}
|
|
9195
9358
|
stack = [];
|
|
9196
|
-
pending.unshift(...linkParts(relative5(dependencyRoot,
|
|
9359
|
+
pending.unshift(...linkParts(relative5(dependencyRoot, resolve7(next))));
|
|
9197
9360
|
} else {
|
|
9198
9361
|
pending.unshift(...linkParts(next));
|
|
9199
9362
|
}
|
|
@@ -9203,7 +9366,7 @@ function rejectLinkedParent(cwd, rel) {
|
|
|
9203
9366
|
const parts = rel.split("/").slice(0, -1);
|
|
9204
9367
|
let current = cwd;
|
|
9205
9368
|
for (const part of parts) {
|
|
9206
|
-
current =
|
|
9369
|
+
current = join16(current, part);
|
|
9207
9370
|
let st;
|
|
9208
9371
|
try {
|
|
9209
9372
|
st = lstatSync4(current);
|
|
@@ -9219,7 +9382,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
9219
9382
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
9220
9383
|
const links = [];
|
|
9221
9384
|
for (const rel of listed) {
|
|
9222
|
-
const src =
|
|
9385
|
+
const src = join16(cwd, rel);
|
|
9223
9386
|
rejectLinkedParent(cwd, rel);
|
|
9224
9387
|
let st;
|
|
9225
9388
|
try {
|
|
@@ -9227,7 +9390,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
9227
9390
|
} catch {
|
|
9228
9391
|
continue;
|
|
9229
9392
|
}
|
|
9230
|
-
const out2 =
|
|
9393
|
+
const out2 = join16(dest, rel);
|
|
9231
9394
|
mkdirSync6(dirname7(out2), { recursive: true });
|
|
9232
9395
|
dropSymlink(out2);
|
|
9233
9396
|
if (st.isSymbolicLink()) {
|
|
@@ -9244,7 +9407,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
9244
9407
|
for (const link of links) {
|
|
9245
9408
|
validateSymlinkGraph(link.target, link.out, dest, dependencyRoot, link.rel);
|
|
9246
9409
|
}
|
|
9247
|
-
if (dependencyRoot) symlinkSync(dependencyRoot,
|
|
9410
|
+
if (dependencyRoot) symlinkSync(dependencyRoot, join16(dest, "node_modules"), "dir");
|
|
9248
9411
|
}
|
|
9249
9412
|
function dropSymlink(p) {
|
|
9250
9413
|
try {
|
|
@@ -9277,11 +9440,11 @@ function overlayDigest(dest, paths) {
|
|
|
9277
9440
|
for (const rel of [...paths].sort()) {
|
|
9278
9441
|
h.update(rel);
|
|
9279
9442
|
h.update("\0");
|
|
9280
|
-
const p =
|
|
9443
|
+
const p = join16(dest, rel);
|
|
9281
9444
|
try {
|
|
9282
9445
|
const st = lstatSync4(p);
|
|
9283
9446
|
h.update(String(st.mode));
|
|
9284
|
-
h.update(st.isSymbolicLink() ? readlinkSync3(p) :
|
|
9447
|
+
h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync16(p));
|
|
9285
9448
|
} catch {
|
|
9286
9449
|
h.update("<gone>");
|
|
9287
9450
|
}
|
|
@@ -9305,7 +9468,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
|
|
|
9305
9468
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
9306
9469
|
}
|
|
9307
9470
|
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
9308
|
-
const out2 =
|
|
9471
|
+
const out2 = join16(dest, e.path);
|
|
9309
9472
|
mkdirSync6(dirname7(out2), { recursive: true });
|
|
9310
9473
|
rmSync3(out2, { force: true });
|
|
9311
9474
|
if (e.mode === "120000") {
|
|
@@ -9322,7 +9485,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
|
|
|
9322
9485
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
9323
9486
|
for (const rel of inCopy) {
|
|
9324
9487
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
9325
|
-
rmSync3(
|
|
9488
|
+
rmSync3(join16(dest, rel), { force: true });
|
|
9326
9489
|
removed++;
|
|
9327
9490
|
}
|
|
9328
9491
|
for (const link of restoredLinks) {
|
|
@@ -9367,8 +9530,8 @@ function suiteEnv(scratch) {
|
|
|
9367
9530
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
9368
9531
|
env[k] = v;
|
|
9369
9532
|
}
|
|
9370
|
-
const userRc =
|
|
9371
|
-
const globalRc =
|
|
9533
|
+
const userRc = join16(scratch, "npmrc-user");
|
|
9534
|
+
const globalRc = join16(scratch, "npmrc-global");
|
|
9372
9535
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
9373
9536
|
env.npm_config_userconfig = userRc;
|
|
9374
9537
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -9378,8 +9541,8 @@ function suiteEnv(scratch) {
|
|
|
9378
9541
|
}
|
|
9379
9542
|
function runLocalSuite(dir, cmd, budgetSecs) {
|
|
9380
9543
|
const t0 = Date.now();
|
|
9381
|
-
const outDir = mkdtempSync2(
|
|
9382
|
-
const outFile =
|
|
9544
|
+
const outDir = mkdtempSync2(join16(tmpdir2(), "tw-verify-run-"));
|
|
9545
|
+
const outFile = join16(outDir, "result.json");
|
|
9383
9546
|
try {
|
|
9384
9547
|
spawnSync2(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
9385
9548
|
cwd: dir,
|
|
@@ -9392,7 +9555,7 @@ function runLocalSuite(dir, cmd, budgetSecs) {
|
|
|
9392
9555
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
9393
9556
|
let r;
|
|
9394
9557
|
try {
|
|
9395
|
-
r = JSON.parse(
|
|
9558
|
+
r = JSON.parse(readFileSync16(outFile, "utf8"));
|
|
9396
9559
|
} catch {
|
|
9397
9560
|
return { exit: null, secs };
|
|
9398
9561
|
}
|
|
@@ -9484,8 +9647,8 @@ function runVerify(opts) {
|
|
|
9484
9647
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
9485
9648
|
return 2;
|
|
9486
9649
|
}
|
|
9487
|
-
const visRoot = mkdtempSync2(
|
|
9488
|
-
const visDir =
|
|
9650
|
+
const visRoot = mkdtempSync2(join16(tmpdir2(), "tw-verify-vis-"));
|
|
9651
|
+
const visDir = join16(visRoot, "t");
|
|
9489
9652
|
const cleanup = (dirs) => {
|
|
9490
9653
|
if (opts.keep) return;
|
|
9491
9654
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
@@ -9542,8 +9705,8 @@ function runVerify(opts) {
|
|
|
9542
9705
|
out2((dependencyAfterVisible.reason ?? "dependency identity changed") + ". Failing closed, not open.");
|
|
9543
9706
|
return 2;
|
|
9544
9707
|
}
|
|
9545
|
-
const priRoot = mkdtempSync2(
|
|
9546
|
-
const priDir =
|
|
9708
|
+
const priRoot = mkdtempSync2(join16(tmpdir2(), "tw-verify-pri-"));
|
|
9709
|
+
const priDir = join16(priRoot, "t");
|
|
9547
9710
|
let restored = [];
|
|
9548
9711
|
let removedAdded = 0;
|
|
9549
9712
|
try {
|
|
@@ -9694,16 +9857,233 @@ function parseVerify(args) {
|
|
|
9694
9857
|
|
|
9695
9858
|
// src/cli/run.ts
|
|
9696
9859
|
import { execFileSync as execFileSync9, spawnSync as spawnSync3 } from "node:child_process";
|
|
9697
|
-
import { readdirSync as
|
|
9698
|
-
import {
|
|
9860
|
+
import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as readFileSync17, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
|
|
9861
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
9862
|
+
import { join as join17, resolve as resolve8 } from "node:path";
|
|
9699
9863
|
var out = (s) => process.stdout.write(s + "\n");
|
|
9700
|
-
var
|
|
9864
|
+
var err2 = (s) => process.stderr.write(s + "\n");
|
|
9865
|
+
var AGENT_SUPERVISOR = String.raw`
|
|
9866
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
9867
|
+
const fs = require('node:fs');
|
|
9868
|
+
|
|
9869
|
+
const [resultFile, budgetRaw, command, ...args] = process.argv.slice(1);
|
|
9870
|
+
const budgetMs = Number(budgetRaw);
|
|
9871
|
+
let child;
|
|
9872
|
+
let timedOut = false;
|
|
9873
|
+
let finished = false;
|
|
9874
|
+
|
|
9875
|
+
function writeResult(value) {
|
|
9876
|
+
try { fs.writeFileSync(resultFile, JSON.stringify(value)); } catch {}
|
|
9877
|
+
}
|
|
9878
|
+
|
|
9879
|
+
function linuxDescendants(rootPid) {
|
|
9880
|
+
if (process.platform !== 'linux') return [];
|
|
9881
|
+
const byParent = new Map();
|
|
9882
|
+
let names = [];
|
|
9883
|
+
try { names = fs.readdirSync('/proc').filter((x) => /^\d+$/.test(x)); } catch { return []; }
|
|
9884
|
+
for (const name of names) {
|
|
9885
|
+
const pid = Number(name);
|
|
9886
|
+
try {
|
|
9887
|
+
const stat = fs.readFileSync('/proc/' + name + '/stat', 'utf8');
|
|
9888
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
9889
|
+
const ppid = Number(fields[1]);
|
|
9890
|
+
if (!Number.isFinite(ppid)) continue;
|
|
9891
|
+
const kids = byParent.get(ppid) || [];
|
|
9892
|
+
kids.push(pid);
|
|
9893
|
+
byParent.set(ppid, kids);
|
|
9894
|
+
} catch {}
|
|
9895
|
+
}
|
|
9896
|
+
const out = [];
|
|
9897
|
+
const seen = new Set([rootPid]);
|
|
9898
|
+
const stack = [rootPid];
|
|
9899
|
+
while (stack.length) {
|
|
9900
|
+
const parent = stack.pop();
|
|
9901
|
+
for (const childPid of (byParent.get(parent) || [])) {
|
|
9902
|
+
if (seen.has(childPid)) continue;
|
|
9903
|
+
seen.add(childPid);
|
|
9904
|
+
out.push(childPid);
|
|
9905
|
+
stack.push(childPid);
|
|
9906
|
+
}
|
|
9907
|
+
}
|
|
9908
|
+
return out;
|
|
9909
|
+
}
|
|
9910
|
+
|
|
9911
|
+
function killTree(pid) {
|
|
9912
|
+
if (!pid) return;
|
|
9913
|
+
if (process.platform === 'win32') {
|
|
9914
|
+
try { spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
9915
|
+
return;
|
|
9916
|
+
}
|
|
9917
|
+
|
|
9918
|
+
// Snapshot Linux descendants BEFORE killing the process-group leader. A
|
|
9919
|
+
// descendant may have called setsid() and escaped the group while remaining
|
|
9920
|
+
// in the agent's parent/child tree; /proc still lets us identify it.
|
|
9921
|
+
const descendants = linuxDescendants(pid);
|
|
9922
|
+
try { process.kill(-pid, 'SIGKILL'); } catch {
|
|
9923
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
9924
|
+
}
|
|
9925
|
+
for (const childPid of descendants.reverse()) {
|
|
9926
|
+
try { process.kill(childPid, 'SIGKILL'); } catch {}
|
|
9927
|
+
}
|
|
9928
|
+
}
|
|
9929
|
+
|
|
9930
|
+
try {
|
|
9931
|
+
child = spawn(command, args, {
|
|
9932
|
+
stdio: 'inherit',
|
|
9933
|
+
detached: process.platform !== 'win32',
|
|
9934
|
+
});
|
|
9935
|
+
} catch (e) {
|
|
9936
|
+
writeResult({ exit: 1, timedOut: false, failure: String(e) });
|
|
9937
|
+
process.exit(0);
|
|
9938
|
+
}
|
|
9939
|
+
|
|
9940
|
+
writeResult({ pid: child.pid, started: true });
|
|
9941
|
+
|
|
9942
|
+
const timer = setTimeout(() => {
|
|
9943
|
+
timedOut = true;
|
|
9944
|
+
killTree(child.pid);
|
|
9945
|
+
}, budgetMs);
|
|
9946
|
+
timer.unref();
|
|
9947
|
+
|
|
9948
|
+
child.once('error', (e) => {
|
|
9949
|
+
if (finished) return;
|
|
9950
|
+
finished = true;
|
|
9951
|
+
clearTimeout(timer);
|
|
9952
|
+
writeResult({ exit: 1, timedOut, failure: String(e) });
|
|
9953
|
+
process.exit(0);
|
|
9954
|
+
});
|
|
9955
|
+
|
|
9956
|
+
child.once('exit', (code, signal) => {
|
|
9957
|
+
if (finished) return;
|
|
9958
|
+
finished = true;
|
|
9959
|
+
clearTimeout(timer);
|
|
9960
|
+
// A timeout owns the public status even when the killed process reports a
|
|
9961
|
+
// shell-specific signal/exit value. The envelope will still adjudicate.
|
|
9962
|
+
writeResult({
|
|
9963
|
+
exit: timedOut ? 124 : (code == null ? 1 : code),
|
|
9964
|
+
timedOut,
|
|
9965
|
+
signal: signal == null ? null : String(signal),
|
|
9966
|
+
});
|
|
9967
|
+
process.exit(0);
|
|
9968
|
+
});
|
|
9969
|
+
`;
|
|
9970
|
+
function linuxDescendantPids(rootPid) {
|
|
9971
|
+
if (process.platform !== "linux") return [];
|
|
9972
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
9973
|
+
let names;
|
|
9974
|
+
try {
|
|
9975
|
+
names = readdirSync5("/proc").filter((x) => /^\d+$/.test(x));
|
|
9976
|
+
} catch {
|
|
9977
|
+
return [];
|
|
9978
|
+
}
|
|
9979
|
+
for (const name of names) {
|
|
9980
|
+
const pid = Number(name);
|
|
9981
|
+
try {
|
|
9982
|
+
const stat = readFileSync17(`/proc/${name}/stat`, "utf8");
|
|
9983
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
9984
|
+
const ppid = Number(fields[1]);
|
|
9985
|
+
if (!Number.isFinite(ppid)) continue;
|
|
9986
|
+
const kids = byParent.get(ppid) ?? [];
|
|
9987
|
+
kids.push(pid);
|
|
9988
|
+
byParent.set(ppid, kids);
|
|
9989
|
+
} catch {
|
|
9990
|
+
}
|
|
9991
|
+
}
|
|
9992
|
+
const out2 = [];
|
|
9993
|
+
const seen = /* @__PURE__ */ new Set([rootPid]);
|
|
9994
|
+
const stack = [rootPid];
|
|
9995
|
+
while (stack.length) {
|
|
9996
|
+
const parent = stack.pop();
|
|
9997
|
+
for (const childPid of byParent.get(parent) ?? []) {
|
|
9998
|
+
if (seen.has(childPid)) continue;
|
|
9999
|
+
seen.add(childPid);
|
|
10000
|
+
out2.push(childPid);
|
|
10001
|
+
stack.push(childPid);
|
|
10002
|
+
}
|
|
10003
|
+
}
|
|
10004
|
+
return out2;
|
|
10005
|
+
}
|
|
10006
|
+
function killAgentTree(pid) {
|
|
10007
|
+
if (!Number.isInteger(pid) || pid <= 1) return;
|
|
10008
|
+
if (process.platform === "win32") {
|
|
10009
|
+
try {
|
|
10010
|
+
spawnSync3("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5e3 });
|
|
10011
|
+
} catch {
|
|
10012
|
+
}
|
|
10013
|
+
return;
|
|
10014
|
+
}
|
|
10015
|
+
const descendants = linuxDescendantPids(pid);
|
|
10016
|
+
try {
|
|
10017
|
+
process.kill(-pid, "SIGKILL");
|
|
10018
|
+
} catch {
|
|
10019
|
+
try {
|
|
10020
|
+
process.kill(pid, "SIGKILL");
|
|
10021
|
+
} catch {
|
|
10022
|
+
}
|
|
10023
|
+
}
|
|
10024
|
+
for (const childPid of descendants.reverse()) {
|
|
10025
|
+
try {
|
|
10026
|
+
process.kill(childPid, "SIGKILL");
|
|
10027
|
+
} catch {
|
|
10028
|
+
}
|
|
10029
|
+
}
|
|
10030
|
+
}
|
|
10031
|
+
function runAgentWithBudget(argv2, cwd, budgetSecs) {
|
|
10032
|
+
const stateDir = mkdtempSync3(join17(tmpdir3(), "tw-agent-supervisor-"));
|
|
10033
|
+
const resultFile = join17(stateDir, "result.json");
|
|
10034
|
+
let supervisorTimedOut = false;
|
|
10035
|
+
try {
|
|
10036
|
+
const supervisor = spawnSync3(
|
|
10037
|
+
process.execPath,
|
|
10038
|
+
["-e", AGENT_SUPERVISOR, resultFile, String(budgetSecs * 1e3), ...argv2],
|
|
10039
|
+
{
|
|
10040
|
+
cwd,
|
|
10041
|
+
stdio: "inherit",
|
|
10042
|
+
// Backstop only. The inner supervisor owns the intended budget and
|
|
10043
|
+
// process-tree kill. This prevents a broken supervisor hanging run.
|
|
10044
|
+
timeout: Math.ceil(budgetSecs * 1e3) + 1e4,
|
|
10045
|
+
killSignal: "SIGKILL"
|
|
10046
|
+
}
|
|
10047
|
+
);
|
|
10048
|
+
supervisorTimedOut = Boolean(supervisor.error) && supervisor.error.code === "ETIMEDOUT";
|
|
10049
|
+
let state = {};
|
|
10050
|
+
try {
|
|
10051
|
+
state = JSON.parse(readFileSync17(resultFile, "utf8"));
|
|
10052
|
+
} catch {
|
|
10053
|
+
}
|
|
10054
|
+
if (supervisorTimedOut || state.timedOut) {
|
|
10055
|
+
if (state.pid) killAgentTree(state.pid);
|
|
10056
|
+
return {
|
|
10057
|
+
exit: 124,
|
|
10058
|
+
timedOut: true,
|
|
10059
|
+
signal: state.signal ?? null,
|
|
10060
|
+
...state.failure ? { failure: state.failure } : {}
|
|
10061
|
+
};
|
|
10062
|
+
}
|
|
10063
|
+
if (typeof state.exit === "number") {
|
|
10064
|
+
return {
|
|
10065
|
+
exit: state.exit,
|
|
10066
|
+
timedOut: false,
|
|
10067
|
+
signal: state.signal ?? null,
|
|
10068
|
+
...state.failure ? { failure: state.failure } : {}
|
|
10069
|
+
};
|
|
10070
|
+
}
|
|
10071
|
+
if (state.pid) killAgentTree(state.pid);
|
|
10072
|
+
return {
|
|
10073
|
+
exit: 1,
|
|
10074
|
+
timedOut: false,
|
|
10075
|
+
failure: state.failure ?? "agent supervisor did not produce a final status"
|
|
10076
|
+
};
|
|
10077
|
+
} finally {
|
|
10078
|
+
rmSync4(stateDir, { recursive: true, force: true });
|
|
10079
|
+
}
|
|
10080
|
+
}
|
|
9701
10081
|
function git3(args, cwd) {
|
|
9702
10082
|
return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
9703
10083
|
}
|
|
9704
10084
|
function nowTicks() {
|
|
9705
10085
|
try {
|
|
9706
|
-
return Math.floor(parseFloat(
|
|
10086
|
+
return Math.floor(parseFloat(readFileSync17("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
9707
10087
|
} catch {
|
|
9708
10088
|
return Number.POSITIVE_INFINITY;
|
|
9709
10089
|
}
|
|
@@ -9718,7 +10098,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9718
10098
|
}
|
|
9719
10099
|
let pids;
|
|
9720
10100
|
try {
|
|
9721
|
-
pids =
|
|
10101
|
+
pids = readdirSync5("/proc").filter((d) => /^\d+$/.test(d));
|
|
9722
10102
|
} catch {
|
|
9723
10103
|
return out2;
|
|
9724
10104
|
}
|
|
@@ -9726,7 +10106,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9726
10106
|
const n = Number(pid);
|
|
9727
10107
|
if (n === process.pid) continue;
|
|
9728
10108
|
try {
|
|
9729
|
-
const stat =
|
|
10109
|
+
const stat = readFileSync17(`/proc/${pid}/stat`, "utf8");
|
|
9730
10110
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
9731
10111
|
if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
|
|
9732
10112
|
} catch {
|
|
@@ -9734,14 +10114,14 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9734
10114
|
}
|
|
9735
10115
|
const procLinks = [`/proc/${pid}/cwd`, `/proc/${pid}/exe`];
|
|
9736
10116
|
try {
|
|
9737
|
-
for (const fd of
|
|
10117
|
+
for (const fd of readdirSync5(`/proc/${pid}/fd`)) procLinks.push(`/proc/${pid}/fd/${fd}`);
|
|
9738
10118
|
} catch {
|
|
9739
10119
|
}
|
|
9740
10120
|
let holds = false;
|
|
9741
10121
|
for (const link of procLinks) {
|
|
9742
10122
|
try {
|
|
9743
10123
|
const raw = readlinkSync4(link).replace(/ \(deleted\)$/, "");
|
|
9744
|
-
const held = raw.startsWith("/") ?
|
|
10124
|
+
const held = raw.startsWith("/") ? resolve8(raw) : raw;
|
|
9745
10125
|
if (held === real || held.startsWith(real + "/")) {
|
|
9746
10126
|
holds = true;
|
|
9747
10127
|
break;
|
|
@@ -9754,16 +10134,22 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9754
10134
|
return out2;
|
|
9755
10135
|
}
|
|
9756
10136
|
function runEnvelope(opts) {
|
|
9757
|
-
const cwd =
|
|
10137
|
+
const cwd = resolve8(opts.cwd ?? process.cwd());
|
|
9758
10138
|
if (!opts.argv.length) {
|
|
9759
|
-
|
|
10139
|
+
err2("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
10140
|
+
return 2;
|
|
10141
|
+
}
|
|
10142
|
+
if (opts.agentBudget !== void 0 && (!Number.isFinite(opts.agentBudget) || opts.agentBudget <= 0)) {
|
|
10143
|
+
err2(
|
|
10144
|
+
`tamperward run: --agent-budget needs a positive number of seconds (got ${JSON.stringify(opts.agentBudget)}) \u2014 failing closed.`
|
|
10145
|
+
);
|
|
9760
10146
|
return 2;
|
|
9761
10147
|
}
|
|
9762
10148
|
let base;
|
|
9763
10149
|
try {
|
|
9764
10150
|
base = git3(["rev-parse", "--verify", `${opts.base ?? "HEAD"}^{commit}`], cwd).trim();
|
|
9765
10151
|
} catch {
|
|
9766
|
-
|
|
10152
|
+
err2(`tamperward run: cannot resolve trusted base ${opts.base ?? "HEAD"} \u2014 failing closed.`);
|
|
9767
10153
|
return 2;
|
|
9768
10154
|
}
|
|
9769
10155
|
let frozenPolicy;
|
|
@@ -9774,19 +10160,19 @@ function runEnvelope(opts) {
|
|
|
9774
10160
|
frozenCmd = opts.cmd ?? frozenPolicy.verify?.command;
|
|
9775
10161
|
frozenBudget = opts.budget ?? frozenPolicy.verify?.budget;
|
|
9776
10162
|
} catch (e) {
|
|
9777
|
-
|
|
10163
|
+
err2(`tamperward run: cannot load the entry-time policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed.`);
|
|
9778
10164
|
return 2;
|
|
9779
10165
|
}
|
|
9780
10166
|
if (!frozenCmd) {
|
|
9781
|
-
|
|
9782
|
-
|
|
10167
|
+
err2("tamperward run: no suite command at entry (policy `verify: { command: ... }` or --cmd) \u2014");
|
|
10168
|
+
err2("refusing to start: a verifier that appears after the agent runs would be agent-authored.");
|
|
9783
10169
|
return 2;
|
|
9784
10170
|
}
|
|
9785
10171
|
if (!opts.allowDirty) {
|
|
9786
10172
|
const dirty = git3(["status", "--porcelain"], cwd).trim();
|
|
9787
10173
|
if (dirty) {
|
|
9788
|
-
|
|
9789
|
-
|
|
10174
|
+
err2("tamperward run: working tree is dirty before the agent starts \u2014 the envelope cannot");
|
|
10175
|
+
err2("attribute changes. Commit or stash first, or pass --allow-dirty to own the risk.");
|
|
9790
10176
|
return 2;
|
|
9791
10177
|
}
|
|
9792
10178
|
}
|
|
@@ -9794,25 +10180,25 @@ function runEnvelope(opts) {
|
|
|
9794
10180
|
out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
|
|
9795
10181
|
out(`tamperward run \u2014 verifier backend: ${verifierBackendSummary(verifierBackend)}`);
|
|
9796
10182
|
if (!verifierBackend.available) {
|
|
9797
|
-
|
|
9798
|
-
|
|
10183
|
+
err2("tamperward run: the requested verifier backend cannot be established \u2014");
|
|
10184
|
+
err2(`${verifierBackend.reason ?? "unknown backend failure"}. Failing closed before the agent starts.`);
|
|
9799
10185
|
return 2;
|
|
9800
10186
|
}
|
|
9801
10187
|
if (verifierBackend.kind === "container") {
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
10188
|
+
err2("tamperward run: container verification requires a frozen candidate handoff from an");
|
|
10189
|
+
err2("agent domain that cannot control the verifier engine. `run` executes the agent under");
|
|
10190
|
+
err2("this host identity, so it cannot honestly provide that separation. Failing closed");
|
|
10191
|
+
err2("before the agent starts; use standalone `tamperward verify` in trusted CI or after");
|
|
10192
|
+
err2("an externally isolated agent produces the frozen candidate artifact.");
|
|
9807
10193
|
return 2;
|
|
9808
10194
|
}
|
|
9809
10195
|
const dependencyEnvironment = verifierBackend.kind === "local" ? discoverDependencyEnvironment(cwd, frozenCmd) : null;
|
|
9810
10196
|
if (dependencyEnvironment) {
|
|
9811
10197
|
out(`tamperward run \u2014 dependency environment: ${dependencyEnvironmentSummary(dependencyEnvironment)}`);
|
|
9812
10198
|
if (dependencyEnvironment.status === "unattestable" && !opts.allowDepDrift) {
|
|
9813
|
-
|
|
9814
|
-
|
|
9815
|
-
|
|
10199
|
+
err2("tamperward run: the verifier dependency environment cannot be attested \u2014");
|
|
10200
|
+
err2(`${dependencyEnvironment.reason ?? "unknown dependency environment"}. Failing closed before the agent starts.`);
|
|
10201
|
+
err2("(Pass --allow-dep-drift only if you explicitly own this dependency-integrity risk.)");
|
|
9816
10202
|
return 2;
|
|
9817
10203
|
}
|
|
9818
10204
|
} else {
|
|
@@ -9820,32 +10206,49 @@ function runEnvelope(opts) {
|
|
|
9820
10206
|
}
|
|
9821
10207
|
const rewritesBefore = objectRewriteState(cwd);
|
|
9822
10208
|
const spawnTicks = nowTicks();
|
|
9823
|
-
const
|
|
9824
|
-
|
|
10209
|
+
const agentRun = opts.agentBudget === void 0 ? (() => {
|
|
10210
|
+
const agent = spawnSync3(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
|
|
10211
|
+
return {
|
|
10212
|
+
exit: agent.status ?? 1,
|
|
10213
|
+
timedOut: false,
|
|
10214
|
+
signal: agent.signal ? String(agent.signal) : null,
|
|
10215
|
+
...agent.error ? { failure: agent.error.message } : {}
|
|
10216
|
+
};
|
|
10217
|
+
})() : runAgentWithBudget(opts.argv, cwd, opts.agentBudget);
|
|
10218
|
+
const agentExit = agentRun.exit;
|
|
10219
|
+
const agentTimedOut = agentRun.timedOut;
|
|
10220
|
+
if (agentRun.failure && !agentTimedOut) {
|
|
10221
|
+
err2(`tamperward run: agent runtime failed to start/report cleanly (${agentRun.failure}) \u2014 continuing to adjudicate the released tree.`);
|
|
10222
|
+
}
|
|
10223
|
+
if (agentTimedOut) {
|
|
10224
|
+
err2(
|
|
10225
|
+
`tamperward run: AGENT_TIMEOUT \u2014 wrapped agent exceeded ${opts.agentBudget}s; its owned process tree was terminated. Post-timeout adjudication continues.`
|
|
10226
|
+
);
|
|
10227
|
+
}
|
|
9825
10228
|
if (objectRewriteState(cwd) !== rewritesBefore) {
|
|
9826
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
10229
|
+
err2("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
|
|
10230
|
+
err2("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
|
|
10231
|
+
err2("read against the anchor we started from. That is the finding.");
|
|
9829
10232
|
out(`
|
|
9830
10233
|
tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
9831
10234
|
return 1;
|
|
9832
10235
|
}
|
|
9833
10236
|
const dependencyBeforeAdjudication = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
|
|
9834
10237
|
if (!dependencyBeforeAdjudication.ok) {
|
|
9835
|
-
|
|
9836
|
-
|
|
10238
|
+
err2("tamperward run: the frozen dependency environment changed before adjudication began \u2014");
|
|
10239
|
+
err2(`${dependencyBeforeAdjudication.reason ?? "dependency identity changed"}.`);
|
|
9837
10240
|
if (!opts.allowDepDrift) {
|
|
9838
10241
|
out(`
|
|
9839
10242
|
tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEMENT_FAILED`);
|
|
9840
10243
|
return 1;
|
|
9841
10244
|
}
|
|
9842
|
-
|
|
10245
|
+
err2("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
|
|
9843
10246
|
}
|
|
9844
10247
|
const head = git3(["rev-parse", "HEAD"], cwd).trim();
|
|
9845
10248
|
const isAncestor = spawnSync3("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
|
|
9846
10249
|
if (isAncestor.status !== 0) {
|
|
9847
|
-
|
|
9848
|
-
|
|
10250
|
+
err2(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
|
|
10251
|
+
err2("the agent rewrote history out from under the anchor. That is the finding.");
|
|
9849
10252
|
out(`
|
|
9850
10253
|
tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
9851
10254
|
return 1;
|
|
@@ -9879,33 +10282,34 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
9879
10282
|
}
|
|
9880
10283
|
const mutatedDuringAdjudication = treeFingerprint(cwd, protectedIgnored) !== fpBefore;
|
|
9881
10284
|
if (mutatedDuringAdjudication) {
|
|
9882
|
-
|
|
9883
|
-
|
|
10285
|
+
err2("tamperward run: the working tree changed while it was being adjudicated \u2014");
|
|
10286
|
+
err2("the verdict below would describe a tree that no longer exists.");
|
|
9884
10287
|
}
|
|
9885
10288
|
const dependencyAfter = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
|
|
9886
10289
|
const depsDrifted = Boolean(dependencyEnvironment) && !opts.allowDepDrift && !dependencyAfter.ok;
|
|
9887
10290
|
if (depsDrifted) {
|
|
9888
|
-
|
|
9889
|
-
|
|
10291
|
+
err2("tamperward run: the frozen dependency environment changed during this run \u2014");
|
|
10292
|
+
err2(`${dependencyAfter.reason ?? "dependency identity changed"}. Both verification runs depend on it.`);
|
|
9890
10293
|
}
|
|
9891
10294
|
const rewrote = objectRewriteState(cwd) !== rewritesBefore;
|
|
9892
10295
|
if (rewrote) {
|
|
9893
|
-
|
|
9894
|
-
|
|
10296
|
+
err2("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
|
|
10297
|
+
err2("the checks above read the trusted base through an object layer that has since moved.");
|
|
9895
10298
|
}
|
|
9896
10299
|
const survivors = survivorsHoldingTree(cwd, spawnTicks);
|
|
9897
10300
|
if (survivors.length) {
|
|
9898
|
-
|
|
9899
|
-
|
|
10301
|
+
err2(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
|
|
10302
|
+
err2(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
|
|
9900
10303
|
}
|
|
9901
10304
|
const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
|
|
9902
10305
|
const cannot = !notQuiescent && (diffCode === 2 || workCode === 2 || verifyCode === 2);
|
|
9903
10306
|
const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
|
|
9904
10307
|
const enforcement = cannot ? 2 : blocked ? 1 : 0;
|
|
9905
|
-
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";
|
|
10308
|
+
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" : agentTimedOut ? "AGENT_TIMEOUT \u2014 runtime budget expired; post-timeout enforcement clean" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
|
|
10309
|
+
const agentSummary = agentTimedOut ? `AGENT_TIMEOUT (${opts.agentBudget}s; exit 124)` : `agent exit ${agentExit}`;
|
|
9906
10310
|
out(`
|
|
9907
|
-
tamperward run \u2014
|
|
9908
|
-
return enforcement !== 0 ? enforcement : agentExit;
|
|
10311
|
+
tamperward run \u2014 ${agentSummary}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
|
|
10312
|
+
return enforcement !== 0 ? enforcement : agentTimedOut ? 124 : agentExit;
|
|
9909
10313
|
}
|
|
9910
10314
|
function parseRun(args) {
|
|
9911
10315
|
const o = { argv: [] };
|
|
@@ -9917,6 +10321,7 @@ function parseRun(args) {
|
|
|
9917
10321
|
} else if (a === "--base") o.base = args[++i];
|
|
9918
10322
|
else if (a === "--cmd") o.cmd = args[++i];
|
|
9919
10323
|
else if (a === "--budget") o.budget = Number(args[++i]);
|
|
10324
|
+
else if (a === "--agent-budget") o.agentBudget = Number(args[++i]);
|
|
9920
10325
|
else if (a === "--allow-dirty") o.allowDirty = true;
|
|
9921
10326
|
else if (a === "--settle") o.settle = Number(args[++i]);
|
|
9922
10327
|
else if (a === "--allow-dep-drift") o.allowDepDrift = true;
|
|
@@ -9960,6 +10365,16 @@ function parseInit(args) {
|
|
|
9960
10365
|
}
|
|
9961
10366
|
return o;
|
|
9962
10367
|
}
|
|
10368
|
+
function parseDoctor(args) {
|
|
10369
|
+
const o = {};
|
|
10370
|
+
for (let i = 0; i < args.length; i++) {
|
|
10371
|
+
const a = args[i];
|
|
10372
|
+
if (a === "--cwd") o.cwd = args[++i];
|
|
10373
|
+
else if (a === "--base") o.base = args[++i];
|
|
10374
|
+
else if (a === "--workflow") o.workflow = args[++i];
|
|
10375
|
+
}
|
|
10376
|
+
return o;
|
|
10377
|
+
}
|
|
9963
10378
|
function parseCheck(args) {
|
|
9964
10379
|
const o = {};
|
|
9965
10380
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -10014,13 +10429,14 @@ Formats:
|
|
|
10014
10429
|
fails closed (2)
|
|
10015
10430
|
tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
|
|
10016
10431
|
[--base R] [--cmd C] base, run the agent, treat its exit as
|
|
10017
|
-
[--budget S] [--
|
|
10018
|
-
[--
|
|
10019
|
-
[--cwd D]
|
|
10020
|
-
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10432
|
+
[--budget S] [--agent-budget S] untrusted, then re-adjudicate the tree it
|
|
10433
|
+
[--allow-dirty] [--settle S] left (policy over base...HEAD and the
|
|
10434
|
+
[--allow-dep-drift] [--cwd D] worktree, plus verify). --agent-budget
|
|
10435
|
+
bounds the agent runtime itself and still
|
|
10436
|
+
adjudicates after timeout. Exit: agent's
|
|
10437
|
+
code when clean; 124 on clean AGENT_TIMEOUT;
|
|
10438
|
+
1 on any blocking finding/masked failure;
|
|
10439
|
+
2 when it cannot adjudicate (fails closed)
|
|
10024
10440
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
10025
10441
|
[--file F] [--cwd D]
|
|
10026
10442
|
tamperward init [--dry-run] wire the policy file plus every
|
|
@@ -10033,6 +10449,11 @@ Formats:
|
|
|
10033
10449
|
every tool the gate must see.
|
|
10034
10450
|
--force-workflow replaces a workflow it
|
|
10035
10451
|
did not write, or one you have edited.
|
|
10452
|
+
tamperward doctor [--base R] validate the CI verifier's outer-time
|
|
10453
|
+
[--workflow F] [--cwd D] envelope against the trusted policy.
|
|
10454
|
+
Generated PR CI passes the base SHA so
|
|
10455
|
+
candidate policy edits cannot choose
|
|
10456
|
+
their own timing requirement.
|
|
10036
10457
|
|
|
10037
10458
|
Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
|
|
10038
10459
|
(verify), any blocking finding or masked failure (run) \xB7 2 cannot
|
|
@@ -10057,6 +10478,8 @@ function main(argv2) {
|
|
|
10057
10478
|
return runAllow(parseAllow(rest));
|
|
10058
10479
|
case "init":
|
|
10059
10480
|
return runInit(parseInit(rest));
|
|
10481
|
+
case "doctor":
|
|
10482
|
+
return runDoctor(parseDoctor(rest));
|
|
10060
10483
|
case "verify":
|
|
10061
10484
|
return runVerify(parseVerify(rest));
|
|
10062
10485
|
case "run":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.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",
|