tamperward 2.11.3 → 2.13.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 +32 -7
- package/dist/cli/index.js +503 -138
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -199,6 +199,16 @@ are involved here, and they are not the same thing.
|
|
|
199
199
|
its policy, verification surface and verifier command and budget from that same
|
|
200
200
|
trusted base — so neither step's verdict is governed by the candidate.
|
|
201
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
|
+
|
|
202
212
|
Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
|
|
203
213
|
The workflow passes the head SHA to both steps through `TAMPERWARD_OOB_HEAD`, so an
|
|
204
214
|
approval is bound to the exact commit it was granted for and a new push invalidates it.
|
|
@@ -244,13 +254,26 @@ CI workflow that runs both the diff-time check and pristine verification, and a
|
|
|
244
254
|
`CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
|
|
245
255
|
It never overwrites anything you wrote; `--dry-run` prints the plan.
|
|
246
256
|
|
|
247
|
-
**`init` is not sufficient on its own, and it will tell you so.**
|
|
248
|
-
branch
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
**`init` is not sufficient on its own, and it will tell you so.** The protected
|
|
258
|
+
branch needs all three repository-authority controls:
|
|
259
|
+
|
|
260
|
+
1. require the **`tamperward`** status check;
|
|
261
|
+
2. enable **Require review from Code Owners**; and
|
|
262
|
+
3. enable **Dismiss stale pull request approvals when new commits are pushed**.
|
|
263
|
+
|
|
264
|
+
The third control is load-bearing: an approval for an older gate-critical diff must
|
|
265
|
+
not authorize a later push. GitHub's "require approval of the most recent reviewable
|
|
266
|
+
push" can be useful in addition, but it is not equivalent here because that fresh
|
|
267
|
+
approver is not necessarily the Code Owner for the gate path. A `pull_request`
|
|
268
|
+
workflow runs from the pull request's own head and a required check is matched by job
|
|
269
|
+
name, so without this human boundary a PR can keep the job name, replace the gate with
|
|
270
|
+
`true`, and present a green required check over a change the gate would have blocked.
|
|
271
|
+
That is reproduced on this project's own CI, not a theoretical concern.
|
|
272
|
+
|
|
273
|
+
After configuring GitHub, verify the boundary with
|
|
274
|
+
`tamperward doctor --github --repo OWNER/REPO --branch <default-branch>`.
|
|
275
|
+
Public rulesets can be read anonymously; set `GH_TOKEN` or `GITHUB_TOKEN` when
|
|
276
|
+
authentication is required.
|
|
254
277
|
|
|
255
278
|
A real deployment needs a verify command configured — the generated CI verify step
|
|
256
279
|
**fails closed (exit 2) without one** rather than passing quietly. In
|
|
@@ -321,6 +344,7 @@ commands ignore what they do not know.
|
|
|
321
344
|
| --- | --- |
|
|
322
345
|
| `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
|
|
323
346
|
| `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>` |
|
|
347
|
+
| `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 |
|
|
324
348
|
| `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...>` |
|
|
325
349
|
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
326
350
|
| `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
|
|
@@ -333,6 +357,7 @@ commands ignore what they do not know.
|
|
|
333
357
|
| --- | --- | --- | --- | --- |
|
|
334
358
|
| `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 | — |
|
|
335
359
|
| `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 | — |
|
|
360
|
+
| `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 | — |
|
|
336
361
|
| `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 |
|
|
337
362
|
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name | — |
|
|
338
363
|
| `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off | — |
|
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:
|
|
@@ -8185,23 +8221,329 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
8185
8221
|
`
|
|
8186
8222
|
);
|
|
8187
8223
|
w.write(
|
|
8188
|
-
'\nONE STEP LEFT, AND IT IS NOT OPTIONAL. In your branch-protection or ruleset\nsettings for the default branch, require the tamperward check
|
|
8224
|
+
'\nONE STEP LEFT, AND IT IS NOT OPTIONAL. In your branch-protection or ruleset\nsettings for the default branch, configure ALL THREE repository-authority controls:\n 1. require the tamperward check;\n 2. enable "Require review from Code Owners"; and\n 3. enable "Dismiss stale pull request approvals when new commits are pushed".\n\nThe freshness control is part of the security boundary: an approval for an older\ngate-critical diff must not authorize a later push. "Require approval of the most\nrecent reviewable push" can be useful in addition, but is not a substitute here\nbecause the fresh approver is not necessarily the Code Owner for the gate path.\n\nUntil all three are enforced, the CI gate is advisory. A pull request runs the\nworkflow from its OWN head, and a required check is matched by job name \u2014 so a\npull request can keep the job called `tamperward`, replace the gate with `true`,\nand present a green required check over a change the gate would have blocked.\nCODEOWNERS puts a human in front of gate-critical paths; branch protection or an\nactive ruleset makes that review fresh and binding. Verify with: tamperward doctor\n--github --repo OWNER/REPO --branch <default-branch>.\n'
|
|
8225
|
+
);
|
|
8226
|
+
return 0;
|
|
8227
|
+
}
|
|
8228
|
+
|
|
8229
|
+
// src/cli/doctor.ts
|
|
8230
|
+
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
8231
|
+
import { existsSync as existsSync10, readFileSync as readFileSync13, readdirSync as readdirSync3 } from "node:fs";
|
|
8232
|
+
import { join as join13, resolve as resolve4 } from "node:path";
|
|
8233
|
+
import { parse as parse3 } from "yaml";
|
|
8234
|
+
var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
|
|
8235
|
+
function err(message) {
|
|
8236
|
+
process.stderr.write(`tamperward doctor: ${message}
|
|
8237
|
+
`);
|
|
8238
|
+
return 2;
|
|
8239
|
+
}
|
|
8240
|
+
function policyFor(opts, cwd) {
|
|
8241
|
+
if (!opts.base) return loadPolicy(cwd);
|
|
8242
|
+
const atBase = loadPolicyAt(opts.base, cwd);
|
|
8243
|
+
return atBase ?? defaultPolicy();
|
|
8244
|
+
}
|
|
8245
|
+
function asMapping(value) {
|
|
8246
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
8247
|
+
}
|
|
8248
|
+
function evaluateGitHubProtection(snapshot2, requiredCheck = "tamperward") {
|
|
8249
|
+
let hasRequiredCheck = false;
|
|
8250
|
+
let hasCodeOwnerReview = false;
|
|
8251
|
+
let dismissesStaleReviews = false;
|
|
8252
|
+
if (Array.isArray(snapshot2.rules)) {
|
|
8253
|
+
for (const raw of snapshot2.rules) {
|
|
8254
|
+
const rule = asMapping(raw);
|
|
8255
|
+
const params = asMapping(rule?.parameters);
|
|
8256
|
+
if (!rule || !params) continue;
|
|
8257
|
+
if (rule.type === "pull_request") {
|
|
8258
|
+
if (params.require_code_owner_review === true) hasCodeOwnerReview = true;
|
|
8259
|
+
if (params.dismiss_stale_reviews_on_push === true) dismissesStaleReviews = true;
|
|
8260
|
+
}
|
|
8261
|
+
if (rule.type === "required_status_checks") {
|
|
8262
|
+
const checks = params.required_status_checks;
|
|
8263
|
+
if (Array.isArray(checks)) {
|
|
8264
|
+
hasRequiredCheck ||= checks.some((rawCheck) => {
|
|
8265
|
+
const check2 = asMapping(rawCheck);
|
|
8266
|
+
return check2?.context === requiredCheck;
|
|
8267
|
+
});
|
|
8268
|
+
}
|
|
8269
|
+
}
|
|
8270
|
+
}
|
|
8271
|
+
}
|
|
8272
|
+
const classic = asMapping(snapshot2.branchProtection);
|
|
8273
|
+
const reviews = asMapping(classic?.required_pull_request_reviews);
|
|
8274
|
+
if (reviews?.require_code_owner_reviews === true) hasCodeOwnerReview = true;
|
|
8275
|
+
if (reviews?.dismiss_stale_reviews === true) dismissesStaleReviews = true;
|
|
8276
|
+
const status = asMapping(classic?.required_status_checks);
|
|
8277
|
+
if (status) {
|
|
8278
|
+
const contexts = status.contexts;
|
|
8279
|
+
if (Array.isArray(contexts)) {
|
|
8280
|
+
hasRequiredCheck ||= contexts.some((x) => x === requiredCheck);
|
|
8281
|
+
}
|
|
8282
|
+
const checks = status.checks;
|
|
8283
|
+
if (Array.isArray(checks)) {
|
|
8284
|
+
hasRequiredCheck ||= checks.some((rawCheck) => {
|
|
8285
|
+
const check2 = asMapping(rawCheck);
|
|
8286
|
+
return check2?.context === requiredCheck;
|
|
8287
|
+
});
|
|
8288
|
+
}
|
|
8289
|
+
}
|
|
8290
|
+
const findings = [];
|
|
8291
|
+
if (!hasRequiredCheck) findings.push("require the tamperward status check");
|
|
8292
|
+
if (!hasCodeOwnerReview) findings.push("require Code Owner review");
|
|
8293
|
+
if (!dismissesStaleReviews) findings.push("dismiss stale pull request approvals on new pushes");
|
|
8294
|
+
return findings;
|
|
8295
|
+
}
|
|
8296
|
+
function gitText(cwd, args) {
|
|
8297
|
+
try {
|
|
8298
|
+
return execFileSync7("git", args, {
|
|
8299
|
+
cwd,
|
|
8300
|
+
encoding: "utf8",
|
|
8301
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
8302
|
+
}).trim();
|
|
8303
|
+
} catch {
|
|
8304
|
+
return null;
|
|
8305
|
+
}
|
|
8306
|
+
}
|
|
8307
|
+
function githubRepoFromRemote(remote) {
|
|
8308
|
+
const m = remote.match(/github\.com(?::|\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
|
|
8309
|
+
return m ? m[1] + "/" + m[2].replace(/\.git$/i, "") : null;
|
|
8310
|
+
}
|
|
8311
|
+
function inferGitHubRepo(cwd) {
|
|
8312
|
+
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
8313
|
+
if (envRepo && /^[^/\s]+\/[^/\s]+$/.test(envRepo)) return envRepo;
|
|
8314
|
+
const remote = gitText(cwd, ["config", "--get", "remote.origin.url"]);
|
|
8315
|
+
return remote ? githubRepoFromRemote(remote) : null;
|
|
8316
|
+
}
|
|
8317
|
+
var GITHUB_API_SCRIPT = [
|
|
8318
|
+
"const endpoint = process.argv[1];",
|
|
8319
|
+
"const token = process.env.TAMPERWARD_GITHUB_TOKEN || '';",
|
|
8320
|
+
"const headers = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10', 'User-Agent': 'tamperward-doctor' };",
|
|
8321
|
+
"if (token) headers.Authorization = 'Bearer ' + token;",
|
|
8322
|
+
"fetch('https://api.github.com/' + endpoint, { headers }).then(async (r) => {",
|
|
8323
|
+
" const body = await r.text();",
|
|
8324
|
+
" if (!r.ok) { process.stderr.write('HTTP ' + r.status + ': ' + body); process.exit(22); }",
|
|
8325
|
+
" process.stdout.write(body);",
|
|
8326
|
+
"}).catch((e) => { process.stderr.write(String(e)); process.exit(23); });"
|
|
8327
|
+
].join("\n");
|
|
8328
|
+
function githubApiInvocation(endpoint, token) {
|
|
8329
|
+
return {
|
|
8330
|
+
executable: process.execPath,
|
|
8331
|
+
args: ["-e", GITHUB_API_SCRIPT, endpoint],
|
|
8332
|
+
env: {
|
|
8333
|
+
TAMPERWARD_GITHUB_TOKEN: token,
|
|
8334
|
+
LANG: "C",
|
|
8335
|
+
LC_ALL: "C"
|
|
8336
|
+
}
|
|
8337
|
+
};
|
|
8338
|
+
}
|
|
8339
|
+
function githubApi(cwd, endpoint) {
|
|
8340
|
+
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? "";
|
|
8341
|
+
const invocation = githubApiInvocation(endpoint, token);
|
|
8342
|
+
try {
|
|
8343
|
+
const stdout = execFileSync7(
|
|
8344
|
+
invocation.executable,
|
|
8345
|
+
invocation.args,
|
|
8346
|
+
{
|
|
8347
|
+
cwd,
|
|
8348
|
+
encoding: "utf8",
|
|
8349
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
8350
|
+
env: invocation.env,
|
|
8351
|
+
timeout: 15e3
|
|
8352
|
+
}
|
|
8353
|
+
);
|
|
8354
|
+
return JSON.parse(stdout);
|
|
8355
|
+
} catch (e) {
|
|
8356
|
+
const x = e;
|
|
8357
|
+
const detail = x.stderr ? String(x.stderr).replace(/\s+/g, " ").trim() : x.message;
|
|
8358
|
+
throw new Error(
|
|
8359
|
+
"GitHub API " + endpoint + " failed: " + (detail || "unknown error")
|
|
8360
|
+
);
|
|
8361
|
+
}
|
|
8362
|
+
}
|
|
8363
|
+
function githubAuthority(opts, cwd) {
|
|
8364
|
+
const repo = opts.repo ?? inferGitHubRepo(cwd);
|
|
8365
|
+
if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
|
|
8366
|
+
throw new Error(
|
|
8367
|
+
"cannot determine GitHub repository; pass --repo OWNER/REPO or configure a github.com origin"
|
|
8368
|
+
);
|
|
8369
|
+
}
|
|
8370
|
+
let branch = opts.branch ?? process.env.GITHUB_BASE_REF ?? "";
|
|
8371
|
+
if (!branch) {
|
|
8372
|
+
const meta2 = asMapping(githubApi(cwd, "repos/" + repo));
|
|
8373
|
+
if (typeof meta2?.default_branch === "string") branch = meta2.default_branch;
|
|
8374
|
+
}
|
|
8375
|
+
if (!branch) throw new Error("cannot determine protected branch; pass --branch BRANCH");
|
|
8376
|
+
let rules;
|
|
8377
|
+
let rulesError = null;
|
|
8378
|
+
try {
|
|
8379
|
+
rules = githubApi(cwd, "repos/" + repo + "/rules/branches/" + encodeURIComponent(branch));
|
|
8380
|
+
} catch (e) {
|
|
8381
|
+
rulesError = e instanceof Error ? e : new Error(String(e));
|
|
8382
|
+
}
|
|
8383
|
+
let findings = evaluateGitHubProtection({ rules });
|
|
8384
|
+
if (findings.length === 0) return { repo, branch, findings };
|
|
8385
|
+
let branchProtection;
|
|
8386
|
+
let classicError = null;
|
|
8387
|
+
try {
|
|
8388
|
+
branchProtection = githubApi(
|
|
8389
|
+
cwd,
|
|
8390
|
+
"repos/" + repo + "/branches/" + encodeURIComponent(branch) + "/protection"
|
|
8391
|
+
);
|
|
8392
|
+
} catch (e) {
|
|
8393
|
+
classicError = e instanceof Error ? e : new Error(String(e));
|
|
8394
|
+
}
|
|
8395
|
+
findings = evaluateGitHubProtection({ rules, branchProtection });
|
|
8396
|
+
if (findings.length > 0 && rules === void 0 && branchProtection === void 0) {
|
|
8397
|
+
throw new Error(
|
|
8398
|
+
"cannot inspect GitHub repository authority. Rules API: " + (rulesError?.message ?? "unavailable") + ". Classic branch protection API: " + (classicError?.message ?? "unavailable") + ". Authenticate gh with metadata read; classic-protection fallback may also need Administration read."
|
|
8399
|
+
);
|
|
8400
|
+
}
|
|
8401
|
+
if (findings.length > 0 && branchProtection === void 0 && classicError) {
|
|
8402
|
+
findings.push(
|
|
8403
|
+
"classic branch protection could not be inspected; if it supplies a missing requirement, use GH_TOKEN/GITHUB_TOKEN with Administration read"
|
|
8404
|
+
);
|
|
8405
|
+
}
|
|
8406
|
+
return { repo, branch, findings };
|
|
8407
|
+
}
|
|
8408
|
+
function verifyJobs(doc) {
|
|
8409
|
+
const root = asMapping(doc);
|
|
8410
|
+
const jobs = asMapping(root?.jobs);
|
|
8411
|
+
if (!jobs) return [];
|
|
8412
|
+
const found = [];
|
|
8413
|
+
for (const [name, raw] of Object.entries(jobs)) {
|
|
8414
|
+
const job = asMapping(raw);
|
|
8415
|
+
if (!job || !Array.isArray(job.steps)) continue;
|
|
8416
|
+
const hasVerify = job.steps.some((step) => {
|
|
8417
|
+
const item = asMapping(step);
|
|
8418
|
+
return typeof item?.run === "string" && VERIFY_COMMAND.test(item.run);
|
|
8419
|
+
});
|
|
8420
|
+
if (hasVerify) found.push({ name, job });
|
|
8421
|
+
}
|
|
8422
|
+
return found;
|
|
8423
|
+
}
|
|
8424
|
+
function runDoctor(opts = {}) {
|
|
8425
|
+
const cwd = resolve4(opts.cwd ?? process.cwd());
|
|
8426
|
+
let policy;
|
|
8427
|
+
try {
|
|
8428
|
+
policy = policyFor(opts, cwd);
|
|
8429
|
+
} catch (e) {
|
|
8430
|
+
return err(e instanceof PolicyError || e instanceof Error ? e.message : String(e));
|
|
8431
|
+
}
|
|
8432
|
+
if (!policy.verify?.command) {
|
|
8433
|
+
return err("trusted policy has no verify.command; generated CI cannot verify this repository");
|
|
8434
|
+
}
|
|
8435
|
+
const workflowRels = [];
|
|
8436
|
+
if (opts.workflow) {
|
|
8437
|
+
workflowRels.push(opts.workflow);
|
|
8438
|
+
} else {
|
|
8439
|
+
const workflowDirRel = ".github/workflows";
|
|
8440
|
+
const workflowDir = resolve4(cwd, workflowDirRel);
|
|
8441
|
+
if (!existsSync10(workflowDir)) {
|
|
8442
|
+
return err(`${workflowDirRel}: workflow directory does not exist`);
|
|
8443
|
+
}
|
|
8444
|
+
let entries;
|
|
8445
|
+
try {
|
|
8446
|
+
entries = readdirSync3(workflowDir).filter((name) => /\.ya?ml$/i.test(name)).sort();
|
|
8447
|
+
} catch (e) {
|
|
8448
|
+
return err(
|
|
8449
|
+
`${workflowDirRel}: could not enumerate workflows (${e instanceof Error ? e.message : String(e)})`
|
|
8450
|
+
);
|
|
8451
|
+
}
|
|
8452
|
+
for (const name of entries) workflowRels.push(join13(workflowDirRel, name));
|
|
8453
|
+
}
|
|
8454
|
+
const requiredSecs = requiredVerifierAuthoritySeconds(policy.verify.budget);
|
|
8455
|
+
const requiredMinutes = Math.ceil(requiredSecs / 60);
|
|
8456
|
+
let verifyJobCount = 0;
|
|
8457
|
+
for (const workflowRel of workflowRels) {
|
|
8458
|
+
const workflowPath = resolve4(cwd, workflowRel);
|
|
8459
|
+
if (!existsSync10(workflowPath)) {
|
|
8460
|
+
return err(`${workflowRel}: workflow does not exist`);
|
|
8461
|
+
}
|
|
8462
|
+
let doc;
|
|
8463
|
+
try {
|
|
8464
|
+
doc = parse3(readFileSync13(workflowPath, "utf8"));
|
|
8465
|
+
} catch (e) {
|
|
8466
|
+
return err(
|
|
8467
|
+
`${workflowRel}: workflow is not valid YAML (${e instanceof Error ? e.message : String(e)})`
|
|
8468
|
+
);
|
|
8469
|
+
}
|
|
8470
|
+
const jobs = verifyJobs(doc);
|
|
8471
|
+
verifyJobCount += jobs.length;
|
|
8472
|
+
for (const { name, job } of jobs) {
|
|
8473
|
+
const timeout = job["timeout-minutes"];
|
|
8474
|
+
if (timeout === void 0 || timeout === null) {
|
|
8475
|
+
return err(
|
|
8476
|
+
`${workflowRel} job "${name}": timeout-minutes is missing; requires at least ${requiredMinutes} minutes`
|
|
8477
|
+
);
|
|
8478
|
+
}
|
|
8479
|
+
if (typeof timeout === "string" && timeout.includes("${{")) {
|
|
8480
|
+
return err(
|
|
8481
|
+
`${workflowRel} job "${name}": timeout-minutes must be a static numeric value; requires at least ${requiredMinutes} minutes`
|
|
8482
|
+
);
|
|
8483
|
+
}
|
|
8484
|
+
if (typeof timeout !== "number" || !Number.isFinite(timeout)) {
|
|
8485
|
+
return err(
|
|
8486
|
+
`${workflowRel} job "${name}": timeout-minutes must be numeric; got ${JSON.stringify(timeout)}`
|
|
8487
|
+
);
|
|
8488
|
+
}
|
|
8489
|
+
if (timeout <= 0) {
|
|
8490
|
+
return err(
|
|
8491
|
+
`${workflowRel} job "${name}": timeout-minutes must be positive; got ${timeout}`
|
|
8492
|
+
);
|
|
8493
|
+
}
|
|
8494
|
+
if (!Number.isInteger(timeout)) {
|
|
8495
|
+
return err(
|
|
8496
|
+
`${workflowRel} job "${name}": timeout-minutes must be a whole number; got ${timeout}`
|
|
8497
|
+
);
|
|
8498
|
+
}
|
|
8499
|
+
if (timeout < requiredMinutes) {
|
|
8500
|
+
return err(
|
|
8501
|
+
`${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`
|
|
8502
|
+
);
|
|
8503
|
+
}
|
|
8504
|
+
}
|
|
8505
|
+
}
|
|
8506
|
+
if (verifyJobCount === 0) {
|
|
8507
|
+
const scope = opts.workflow ?? ".github/workflows/*.yml|*.yaml";
|
|
8508
|
+
return err(`${scope}: no job contains a tamperward verify step`);
|
|
8509
|
+
}
|
|
8510
|
+
let github = null;
|
|
8511
|
+
if (opts.github) {
|
|
8512
|
+
try {
|
|
8513
|
+
github = githubAuthority(opts, cwd);
|
|
8514
|
+
} catch (e) {
|
|
8515
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
8516
|
+
}
|
|
8517
|
+
if (github.findings.length > 0) {
|
|
8518
|
+
return err(
|
|
8519
|
+
"GitHub repository authority for " + github.repo + "#" + github.branch + " is incomplete: " + github.findings.join("; ")
|
|
8520
|
+
);
|
|
8521
|
+
}
|
|
8522
|
+
}
|
|
8523
|
+
process.stdout.write(
|
|
8524
|
+
`tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.
|
|
8525
|
+
`
|
|
8189
8526
|
);
|
|
8527
|
+
if (github) {
|
|
8528
|
+
process.stdout.write(
|
|
8529
|
+
"tamperward doctor: GitHub repository authority OK \u2014 " + github.repo + "#" + github.branch + " requires tamperward, Code Owner review, and stale-review dismissal on new pushes.\n"
|
|
8530
|
+
);
|
|
8531
|
+
}
|
|
8190
8532
|
return 0;
|
|
8191
8533
|
}
|
|
8192
8534
|
|
|
8193
8535
|
// src/cli/verify.ts
|
|
8194
|
-
import { execFileSync as
|
|
8536
|
+
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
8195
8537
|
import { createHash as createHash10 } from "node:crypto";
|
|
8196
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
8538
|
+
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
8539
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
8198
|
-
import { dirname as dirname7, isAbsolute as isAbsolute10, join as
|
|
8540
|
+
import { dirname as dirname7, isAbsolute as isAbsolute10, join as join16, relative as relative5, resolve as resolve7, sep as sep2 } from "node:path";
|
|
8199
8541
|
|
|
8200
8542
|
// src/fingerprint.ts
|
|
8201
|
-
import { execFileSync as
|
|
8543
|
+
import { execFileSync as execFileSync8 } from "node:child_process";
|
|
8202
8544
|
import { createHash as createHash7 } from "node:crypto";
|
|
8203
8545
|
function treeFingerprint(cwd, keepIgnored) {
|
|
8204
|
-
const listed =
|
|
8546
|
+
const listed = execFileSync8("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
8205
8547
|
cwd,
|
|
8206
8548
|
encoding: "utf8",
|
|
8207
8549
|
maxBuffer: 1 << 28,
|
|
@@ -8226,10 +8568,10 @@ function treeFingerprint(cwd, keepIgnored) {
|
|
|
8226
8568
|
// src/dependency-env.ts
|
|
8227
8569
|
import { createHash as createHash8 } from "node:crypto";
|
|
8228
8570
|
import {
|
|
8229
|
-
existsSync as
|
|
8571
|
+
existsSync as existsSync11,
|
|
8230
8572
|
lstatSync as lstatSync3,
|
|
8231
|
-
readdirSync as
|
|
8232
|
-
readFileSync as
|
|
8573
|
+
readdirSync as readdirSync4,
|
|
8574
|
+
readFileSync as readFileSync14,
|
|
8233
8575
|
readlinkSync as readlinkSync2,
|
|
8234
8576
|
realpathSync as realpathSync3,
|
|
8235
8577
|
statSync as statSync4
|
|
@@ -8239,15 +8581,15 @@ import {
|
|
|
8239
8581
|
delimiter,
|
|
8240
8582
|
dirname as dirname5,
|
|
8241
8583
|
isAbsolute as isAbsolute9,
|
|
8242
|
-
join as
|
|
8584
|
+
join as join14,
|
|
8243
8585
|
relative as relative4,
|
|
8244
|
-
resolve as
|
|
8586
|
+
resolve as resolve5,
|
|
8245
8587
|
sep
|
|
8246
8588
|
} from "node:path";
|
|
8247
8589
|
var Unattestable = class extends Error {
|
|
8248
8590
|
};
|
|
8249
8591
|
function inside(root, candidate) {
|
|
8250
|
-
const rel = relative4(
|
|
8592
|
+
const rel = relative4(resolve5(root), resolve5(candidate));
|
|
8251
8593
|
return rel === "" || rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute9(rel);
|
|
8252
8594
|
}
|
|
8253
8595
|
function modeKind(path) {
|
|
@@ -8286,7 +8628,7 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
8286
8628
|
st = statSync4(path);
|
|
8287
8629
|
if (!st.isFile()) throw new Error("not a regular file");
|
|
8288
8630
|
h.update(label + "\0external-file\0" + path + "\0" + String(st.mode) + "\0");
|
|
8289
|
-
h.update(
|
|
8631
|
+
h.update(readFileSync14(path));
|
|
8290
8632
|
h.update("\0");
|
|
8291
8633
|
} catch (e) {
|
|
8292
8634
|
throw new Unattestable(
|
|
@@ -8300,14 +8642,14 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
8300
8642
|
visited.add(realDir);
|
|
8301
8643
|
let entries;
|
|
8302
8644
|
try {
|
|
8303
|
-
entries =
|
|
8645
|
+
entries = readdirSync4(dir, { withFileTypes: true });
|
|
8304
8646
|
} catch (e) {
|
|
8305
8647
|
throw new Unattestable(
|
|
8306
8648
|
"dependency directory " + JSON.stringify(dir) + " cannot be read (" + (e instanceof Error ? e.message : String(e)) + ")"
|
|
8307
8649
|
);
|
|
8308
8650
|
}
|
|
8309
8651
|
for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
8310
|
-
const path =
|
|
8652
|
+
const path = join14(dir, entry.name);
|
|
8311
8653
|
const rel = namespace ? namespace + "/" + entry.name : entry.name;
|
|
8312
8654
|
let ident;
|
|
8313
8655
|
try {
|
|
@@ -8320,7 +8662,7 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
8320
8662
|
h.update(rel + "\0" + ident.kind + "\0" + String(ident.mode) + "\0");
|
|
8321
8663
|
if (ident.kind === "file") {
|
|
8322
8664
|
try {
|
|
8323
|
-
h.update(
|
|
8665
|
+
h.update(readFileSync14(path));
|
|
8324
8666
|
h.update("\0");
|
|
8325
8667
|
} catch (e) {
|
|
8326
8668
|
throw new Unattestable(
|
|
@@ -8379,7 +8721,7 @@ function snapshot(roots, probes, cwd) {
|
|
|
8379
8721
|
const h = createHash8("sha256");
|
|
8380
8722
|
for (const probe of probes) {
|
|
8381
8723
|
h.update(
|
|
8382
|
-
"probe\0" + probe.kind + "\0" + probe.path + "\0" + (
|
|
8724
|
+
"probe\0" + probe.kind + "\0" + probe.path + "\0" + (existsSync11(probe.path) ? "present" : "absent") + "\0"
|
|
8383
8725
|
);
|
|
8384
8726
|
}
|
|
8385
8727
|
for (const root of roots) fingerprintRoot(root, cwd, h);
|
|
@@ -8390,7 +8732,7 @@ function freezeRoot(kind, path) {
|
|
|
8390
8732
|
if (!statSync4(path).isDirectory()) {
|
|
8391
8733
|
throw new Error("not a directory");
|
|
8392
8734
|
}
|
|
8393
|
-
return { kind, path:
|
|
8735
|
+
return { kind, path: resolve5(path), realPath: realpathSync3(path) };
|
|
8394
8736
|
} catch (e) {
|
|
8395
8737
|
throw new Unattestable(
|
|
8396
8738
|
kind + " root " + JSON.stringify(path) + " is not resolvable (" + (e instanceof Error ? e.message : String(e)) + ")"
|
|
@@ -8426,17 +8768,17 @@ function firstExecutable(command) {
|
|
|
8426
8768
|
function resolveExecutable(token, cwd, env) {
|
|
8427
8769
|
if (!token) return null;
|
|
8428
8770
|
if (token.includes("/") || token.includes("\\")) {
|
|
8429
|
-
const path = isAbsolute9(token) ? token :
|
|
8771
|
+
const path = isAbsolute9(token) ? token : resolve5(cwd, token);
|
|
8430
8772
|
try {
|
|
8431
|
-
return
|
|
8773
|
+
return existsSync11(path) ? realpathSync3(path) : null;
|
|
8432
8774
|
} catch {
|
|
8433
8775
|
return null;
|
|
8434
8776
|
}
|
|
8435
8777
|
}
|
|
8436
8778
|
for (const entry of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
8437
|
-
const candidate =
|
|
8779
|
+
const candidate = resolve5(entry, token);
|
|
8438
8780
|
try {
|
|
8439
|
-
if (
|
|
8781
|
+
if (existsSync11(candidate) && statSync4(candidate).isFile()) return realpathSync3(candidate);
|
|
8440
8782
|
} catch {
|
|
8441
8783
|
}
|
|
8442
8784
|
}
|
|
@@ -8453,13 +8795,13 @@ function looksPython(command, executable, token) {
|
|
|
8453
8795
|
}
|
|
8454
8796
|
function inferredVenvFromToken(token, cwd) {
|
|
8455
8797
|
if (!token || !token.includes("/") && !token.includes("\\")) return null;
|
|
8456
|
-
const path = isAbsolute9(token) ? token :
|
|
8798
|
+
const path = isAbsolute9(token) ? token : resolve5(cwd, token);
|
|
8457
8799
|
if (!pythonName(basename3(path))) return null;
|
|
8458
8800
|
const parent = dirname5(path);
|
|
8459
8801
|
const parentName = basename3(parent).toLowerCase();
|
|
8460
8802
|
if (parentName !== "bin" && parentName !== "scripts") return null;
|
|
8461
8803
|
const root = dirname5(parent);
|
|
8462
|
-
return
|
|
8804
|
+
return existsSync11(join14(root, "pyvenv.cfg")) ? root : null;
|
|
8463
8805
|
}
|
|
8464
8806
|
function unsupportedEcosystem(command, token) {
|
|
8465
8807
|
const name = token ? basename3(token).toLowerCase() : "";
|
|
@@ -8475,13 +8817,13 @@ function inferredVenv(executable) {
|
|
|
8475
8817
|
const parentName = basename3(parent).toLowerCase();
|
|
8476
8818
|
if (parentName !== "bin" && parentName !== "scripts") return null;
|
|
8477
8819
|
const root = dirname5(parent);
|
|
8478
|
-
return
|
|
8820
|
+
return existsSync11(join14(root, "pyvenv.cfg")) ? root : null;
|
|
8479
8821
|
}
|
|
8480
8822
|
function venvProblem(root) {
|
|
8481
|
-
const cfg =
|
|
8482
|
-
if (!
|
|
8823
|
+
const cfg = join14(root, "pyvenv.cfg");
|
|
8824
|
+
if (!existsSync11(cfg)) return "VIRTUAL_ENV " + JSON.stringify(root) + " has no pyvenv.cfg";
|
|
8483
8825
|
try {
|
|
8484
|
-
const body =
|
|
8826
|
+
const body = readFileSync14(cfg, "utf8");
|
|
8485
8827
|
if (/^\s*include-system-site-packages\s*=\s*true\s*$/im.test(body)) {
|
|
8486
8828
|
return "Python environment " + JSON.stringify(root) + " enables system site-packages; those external package roots are not bounded";
|
|
8487
8829
|
}
|
|
@@ -8491,7 +8833,7 @@ function venvProblem(root) {
|
|
|
8491
8833
|
return null;
|
|
8492
8834
|
}
|
|
8493
8835
|
function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
|
|
8494
|
-
const cwd =
|
|
8836
|
+
const cwd = resolve5(cwdInput);
|
|
8495
8837
|
const roots = [];
|
|
8496
8838
|
const probes = [];
|
|
8497
8839
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -8504,8 +8846,8 @@ function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
|
|
|
8504
8846
|
}
|
|
8505
8847
|
};
|
|
8506
8848
|
try {
|
|
8507
|
-
const nodeModules =
|
|
8508
|
-
if (
|
|
8849
|
+
const nodeModules = join14(cwd, "node_modules");
|
|
8850
|
+
if (existsSync11(nodeModules)) addRoot("node_modules", nodeModules);
|
|
8509
8851
|
else probes.push({ kind: "node_modules", path: nodeModules });
|
|
8510
8852
|
const token = firstExecutable(command);
|
|
8511
8853
|
const executable = resolveExecutable(token, cwd, env);
|
|
@@ -8514,11 +8856,11 @@ function discoverDependencyEnvironment(cwdInput, command, env = process.env) {
|
|
|
8514
8856
|
if (!isAbsolute9(env.VIRTUAL_ENV)) {
|
|
8515
8857
|
throw new Unattestable("VIRTUAL_ENV is relative (" + JSON.stringify(env.VIRTUAL_ENV) + ")");
|
|
8516
8858
|
}
|
|
8517
|
-
venv =
|
|
8859
|
+
venv = resolve5(env.VIRTUAL_ENV);
|
|
8518
8860
|
} else {
|
|
8519
8861
|
venv = inferredVenv(executable) ?? inferredVenvFromToken(token, cwd);
|
|
8520
|
-
if (!venv && /(?:^|\s)uv\s+run(?:\s|$)/.test(command) &&
|
|
8521
|
-
venv =
|
|
8862
|
+
if (!venv && /(?:^|\s)uv\s+run(?:\s|$)/.test(command) && existsSync11(join14(cwd, ".venv", "pyvenv.cfg"))) {
|
|
8863
|
+
venv = join14(cwd, ".venv");
|
|
8522
8864
|
}
|
|
8523
8865
|
}
|
|
8524
8866
|
if (venv) {
|
|
@@ -8550,7 +8892,7 @@ function checkDependencyEnvironment(cwdInput, descriptor) {
|
|
|
8550
8892
|
return { ok: false, reason: descriptor.reason ?? "dependency environment is not attestable" };
|
|
8551
8893
|
}
|
|
8552
8894
|
try {
|
|
8553
|
-
const fingerprint2 = snapshot(descriptor.roots, descriptor.probes,
|
|
8895
|
+
const fingerprint2 = snapshot(descriptor.roots, descriptor.probes, resolve5(cwdInput));
|
|
8554
8896
|
if (fingerprint2 !== descriptor.fingerprint) {
|
|
8555
8897
|
return { ok: false, fingerprint: fingerprint2, reason: "dependency environment fingerprint changed" };
|
|
8556
8898
|
}
|
|
@@ -8579,8 +8921,8 @@ function dependencyEnvironmentSummary(descriptor) {
|
|
|
8579
8921
|
// src/verifier-backend.ts
|
|
8580
8922
|
import { createHash as createHash9, randomUUID } from "node:crypto";
|
|
8581
8923
|
import { spawnSync } from "node:child_process";
|
|
8582
|
-
import { accessSync, constants as constants2, readFileSync as
|
|
8583
|
-
import { delimiter as delimiter2, dirname as dirname6, join as
|
|
8924
|
+
import { accessSync, constants as constants2, readFileSync as readFileSync15, realpathSync as realpathSync4, statSync as statSync5 } from "node:fs";
|
|
8925
|
+
import { delimiter as delimiter2, dirname as dirname6, join as join15, resolve as resolve6 } from "node:path";
|
|
8584
8926
|
var DEFAULT_CONTAINER_RESOURCE_LIMITS = Object.freeze({
|
|
8585
8927
|
memoryBytes: 2 * 1024 * 1024 * 1024,
|
|
8586
8928
|
// Docker interprets memory-swap == memory as no additional swap allowance.
|
|
@@ -8609,7 +8951,7 @@ function writableByCaller(path) {
|
|
|
8609
8951
|
}
|
|
8610
8952
|
}
|
|
8611
8953
|
function replaceableByCaller(path) {
|
|
8612
|
-
let current =
|
|
8954
|
+
let current = resolve6(path);
|
|
8613
8955
|
if (writableByCaller(current)) return true;
|
|
8614
8956
|
current = dirname6(current);
|
|
8615
8957
|
while (true) {
|
|
@@ -8623,7 +8965,7 @@ function replaceableByCaller(path) {
|
|
|
8623
8965
|
function findExecutable(name) {
|
|
8624
8966
|
for (const raw of (process.env.PATH ?? "").split(delimiter2)) {
|
|
8625
8967
|
if (!raw) continue;
|
|
8626
|
-
const candidate =
|
|
8968
|
+
const candidate = join15(raw, name);
|
|
8627
8969
|
try {
|
|
8628
8970
|
accessSync(candidate, constants2.X_OK);
|
|
8629
8971
|
return realpathSync4(candidate);
|
|
@@ -8633,7 +8975,7 @@ function findExecutable(name) {
|
|
|
8633
8975
|
return null;
|
|
8634
8976
|
}
|
|
8635
8977
|
function fileSha256(path) {
|
|
8636
|
-
return createHash9("sha256").update(
|
|
8978
|
+
return createHash9("sha256").update(readFileSync15(path)).digest("hex");
|
|
8637
8979
|
}
|
|
8638
8980
|
function engineClientEnv() {
|
|
8639
8981
|
return {
|
|
@@ -8955,7 +9297,7 @@ function runContainerStage(backend, dir, command, budgetSecs) {
|
|
|
8955
9297
|
const args = containerRunArgs({
|
|
8956
9298
|
image: backend.image,
|
|
8957
9299
|
name,
|
|
8958
|
-
workspace:
|
|
9300
|
+
workspace: resolve6(dir),
|
|
8959
9301
|
command,
|
|
8960
9302
|
uid,
|
|
8961
9303
|
gid,
|
|
@@ -9108,7 +9450,7 @@ var VERIFICATION_SURFACE = [
|
|
|
9108
9450
|
"**/*.runsettings"
|
|
9109
9451
|
];
|
|
9110
9452
|
function git2(args, cwd) {
|
|
9111
|
-
return
|
|
9453
|
+
return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
9112
9454
|
}
|
|
9113
9455
|
function resolveBase(base, cwd) {
|
|
9114
9456
|
assertRev(base);
|
|
@@ -9124,7 +9466,7 @@ function baseIsAncestorOfHead(base, cwd) {
|
|
|
9124
9466
|
return r.status === 0;
|
|
9125
9467
|
}
|
|
9126
9468
|
function inside2(root, path) {
|
|
9127
|
-
const rel = relative5(
|
|
9469
|
+
const rel = relative5(resolve7(root), resolve7(path));
|
|
9128
9470
|
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute10(rel);
|
|
9129
9471
|
}
|
|
9130
9472
|
function linkParts(path) {
|
|
@@ -9141,7 +9483,7 @@ function rootedLinkTarget(target) {
|
|
|
9141
9483
|
return isAbsolute10(target) || driveRelativeLinkTarget(target);
|
|
9142
9484
|
}
|
|
9143
9485
|
function safeSymlink(target, out2, root, label) {
|
|
9144
|
-
if (rootedLinkTarget(target) || !inside2(root,
|
|
9486
|
+
if (rootedLinkTarget(target) || !inside2(root, resolve7(dirname7(out2), target))) {
|
|
9145
9487
|
throw linkEscape(label, target);
|
|
9146
9488
|
}
|
|
9147
9489
|
rmSync3(out2, { force: true });
|
|
@@ -9167,7 +9509,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
9167
9509
|
continue;
|
|
9168
9510
|
}
|
|
9169
9511
|
const base = domain === "tree" ? root : dependencyRoot;
|
|
9170
|
-
const current =
|
|
9512
|
+
const current = join16(base, ...stack);
|
|
9171
9513
|
let st;
|
|
9172
9514
|
try {
|
|
9173
9515
|
st = lstatSync4(current);
|
|
@@ -9193,7 +9535,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
9193
9535
|
throw linkEscape(label, target);
|
|
9194
9536
|
}
|
|
9195
9537
|
stack = [];
|
|
9196
|
-
pending.unshift(...linkParts(relative5(dependencyRoot,
|
|
9538
|
+
pending.unshift(...linkParts(relative5(dependencyRoot, resolve7(next))));
|
|
9197
9539
|
} else {
|
|
9198
9540
|
pending.unshift(...linkParts(next));
|
|
9199
9541
|
}
|
|
@@ -9203,7 +9545,7 @@ function rejectLinkedParent(cwd, rel) {
|
|
|
9203
9545
|
const parts = rel.split("/").slice(0, -1);
|
|
9204
9546
|
let current = cwd;
|
|
9205
9547
|
for (const part of parts) {
|
|
9206
|
-
current =
|
|
9548
|
+
current = join16(current, part);
|
|
9207
9549
|
let st;
|
|
9208
9550
|
try {
|
|
9209
9551
|
st = lstatSync4(current);
|
|
@@ -9219,7 +9561,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
9219
9561
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
9220
9562
|
const links = [];
|
|
9221
9563
|
for (const rel of listed) {
|
|
9222
|
-
const src =
|
|
9564
|
+
const src = join16(cwd, rel);
|
|
9223
9565
|
rejectLinkedParent(cwd, rel);
|
|
9224
9566
|
let st;
|
|
9225
9567
|
try {
|
|
@@ -9227,7 +9569,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
9227
9569
|
} catch {
|
|
9228
9570
|
continue;
|
|
9229
9571
|
}
|
|
9230
|
-
const out2 =
|
|
9572
|
+
const out2 = join16(dest, rel);
|
|
9231
9573
|
mkdirSync6(dirname7(out2), { recursive: true });
|
|
9232
9574
|
dropSymlink(out2);
|
|
9233
9575
|
if (st.isSymbolicLink()) {
|
|
@@ -9244,7 +9586,7 @@ function materialize(cwd, dest, dependencyRoot) {
|
|
|
9244
9586
|
for (const link of links) {
|
|
9245
9587
|
validateSymlinkGraph(link.target, link.out, dest, dependencyRoot, link.rel);
|
|
9246
9588
|
}
|
|
9247
|
-
if (dependencyRoot) symlinkSync(dependencyRoot,
|
|
9589
|
+
if (dependencyRoot) symlinkSync(dependencyRoot, join16(dest, "node_modules"), "dir");
|
|
9248
9590
|
}
|
|
9249
9591
|
function dropSymlink(p) {
|
|
9250
9592
|
try {
|
|
@@ -9277,11 +9619,11 @@ function overlayDigest(dest, paths) {
|
|
|
9277
9619
|
for (const rel of [...paths].sort()) {
|
|
9278
9620
|
h.update(rel);
|
|
9279
9621
|
h.update("\0");
|
|
9280
|
-
const p =
|
|
9622
|
+
const p = join16(dest, rel);
|
|
9281
9623
|
try {
|
|
9282
9624
|
const st = lstatSync4(p);
|
|
9283
9625
|
h.update(String(st.mode));
|
|
9284
|
-
h.update(st.isSymbolicLink() ? readlinkSync3(p) :
|
|
9626
|
+
h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync16(p));
|
|
9285
9627
|
} catch {
|
|
9286
9628
|
h.update("<gone>");
|
|
9287
9629
|
}
|
|
@@ -9304,8 +9646,8 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
|
|
|
9304
9646
|
if (e.type === "commit") {
|
|
9305
9647
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
9306
9648
|
}
|
|
9307
|
-
const content =
|
|
9308
|
-
const out2 =
|
|
9649
|
+
const content = execFileSync9("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
9650
|
+
const out2 = join16(dest, e.path);
|
|
9309
9651
|
mkdirSync6(dirname7(out2), { recursive: true });
|
|
9310
9652
|
rmSync3(out2, { force: true });
|
|
9311
9653
|
if (e.mode === "120000") {
|
|
@@ -9322,7 +9664,7 @@ function overlayPristine(cwd, base, dest, policy, cmd, dependencyRoot) {
|
|
|
9322
9664
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
9323
9665
|
for (const rel of inCopy) {
|
|
9324
9666
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
9325
|
-
rmSync3(
|
|
9667
|
+
rmSync3(join16(dest, rel), { force: true });
|
|
9326
9668
|
removed++;
|
|
9327
9669
|
}
|
|
9328
9670
|
for (const link of restoredLinks) {
|
|
@@ -9367,8 +9709,8 @@ function suiteEnv(scratch) {
|
|
|
9367
9709
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
9368
9710
|
env[k] = v;
|
|
9369
9711
|
}
|
|
9370
|
-
const userRc =
|
|
9371
|
-
const globalRc =
|
|
9712
|
+
const userRc = join16(scratch, "npmrc-user");
|
|
9713
|
+
const globalRc = join16(scratch, "npmrc-global");
|
|
9372
9714
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
9373
9715
|
env.npm_config_userconfig = userRc;
|
|
9374
9716
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -9378,8 +9720,8 @@ function suiteEnv(scratch) {
|
|
|
9378
9720
|
}
|
|
9379
9721
|
function runLocalSuite(dir, cmd, budgetSecs) {
|
|
9380
9722
|
const t0 = Date.now();
|
|
9381
|
-
const outDir = mkdtempSync2(
|
|
9382
|
-
const outFile =
|
|
9723
|
+
const outDir = mkdtempSync2(join16(tmpdir2(), "tw-verify-run-"));
|
|
9724
|
+
const outFile = join16(outDir, "result.json");
|
|
9383
9725
|
try {
|
|
9384
9726
|
spawnSync2(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
9385
9727
|
cwd: dir,
|
|
@@ -9392,7 +9734,7 @@ function runLocalSuite(dir, cmd, budgetSecs) {
|
|
|
9392
9734
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
9393
9735
|
let r;
|
|
9394
9736
|
try {
|
|
9395
|
-
r = JSON.parse(
|
|
9737
|
+
r = JSON.parse(readFileSync16(outFile, "utf8"));
|
|
9396
9738
|
} catch {
|
|
9397
9739
|
return { exit: null, secs };
|
|
9398
9740
|
}
|
|
@@ -9484,8 +9826,8 @@ function runVerify(opts) {
|
|
|
9484
9826
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
9485
9827
|
return 2;
|
|
9486
9828
|
}
|
|
9487
|
-
const visRoot = mkdtempSync2(
|
|
9488
|
-
const visDir =
|
|
9829
|
+
const visRoot = mkdtempSync2(join16(tmpdir2(), "tw-verify-vis-"));
|
|
9830
|
+
const visDir = join16(visRoot, "t");
|
|
9489
9831
|
const cleanup = (dirs) => {
|
|
9490
9832
|
if (opts.keep) return;
|
|
9491
9833
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
@@ -9542,8 +9884,8 @@ function runVerify(opts) {
|
|
|
9542
9884
|
out2((dependencyAfterVisible.reason ?? "dependency identity changed") + ". Failing closed, not open.");
|
|
9543
9885
|
return 2;
|
|
9544
9886
|
}
|
|
9545
|
-
const priRoot = mkdtempSync2(
|
|
9546
|
-
const priDir =
|
|
9887
|
+
const priRoot = mkdtempSync2(join16(tmpdir2(), "tw-verify-pri-"));
|
|
9888
|
+
const priDir = join16(priRoot, "t");
|
|
9547
9889
|
let restored = [];
|
|
9548
9890
|
let removedAdded = 0;
|
|
9549
9891
|
try {
|
|
@@ -9693,12 +10035,12 @@ function parseVerify(args) {
|
|
|
9693
10035
|
}
|
|
9694
10036
|
|
|
9695
10037
|
// src/cli/run.ts
|
|
9696
|
-
import { execFileSync as
|
|
9697
|
-
import { mkdtempSync as mkdtempSync3, readdirSync as
|
|
10038
|
+
import { execFileSync as execFileSync10, spawnSync as spawnSync3 } from "node:child_process";
|
|
10039
|
+
import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as readFileSync17, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
|
|
9698
10040
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
9699
|
-
import { join as
|
|
10041
|
+
import { join as join17, resolve as resolve8 } from "node:path";
|
|
9700
10042
|
var out = (s) => process.stdout.write(s + "\n");
|
|
9701
|
-
var
|
|
10043
|
+
var err2 = (s) => process.stderr.write(s + "\n");
|
|
9702
10044
|
var AGENT_SUPERVISOR = String.raw`
|
|
9703
10045
|
const { spawn, spawnSync } = require('node:child_process');
|
|
9704
10046
|
const fs = require('node:fs');
|
|
@@ -9809,14 +10151,14 @@ function linuxDescendantPids(rootPid) {
|
|
|
9809
10151
|
const byParent = /* @__PURE__ */ new Map();
|
|
9810
10152
|
let names;
|
|
9811
10153
|
try {
|
|
9812
|
-
names =
|
|
10154
|
+
names = readdirSync5("/proc").filter((x) => /^\d+$/.test(x));
|
|
9813
10155
|
} catch {
|
|
9814
10156
|
return [];
|
|
9815
10157
|
}
|
|
9816
10158
|
for (const name of names) {
|
|
9817
10159
|
const pid = Number(name);
|
|
9818
10160
|
try {
|
|
9819
|
-
const stat =
|
|
10161
|
+
const stat = readFileSync17(`/proc/${name}/stat`, "utf8");
|
|
9820
10162
|
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
9821
10163
|
const ppid = Number(fields[1]);
|
|
9822
10164
|
if (!Number.isFinite(ppid)) continue;
|
|
@@ -9866,8 +10208,8 @@ function killAgentTree(pid) {
|
|
|
9866
10208
|
}
|
|
9867
10209
|
}
|
|
9868
10210
|
function runAgentWithBudget(argv2, cwd, budgetSecs) {
|
|
9869
|
-
const stateDir = mkdtempSync3(
|
|
9870
|
-
const resultFile =
|
|
10211
|
+
const stateDir = mkdtempSync3(join17(tmpdir3(), "tw-agent-supervisor-"));
|
|
10212
|
+
const resultFile = join17(stateDir, "result.json");
|
|
9871
10213
|
let supervisorTimedOut = false;
|
|
9872
10214
|
try {
|
|
9873
10215
|
const supervisor = spawnSync3(
|
|
@@ -9885,7 +10227,7 @@ function runAgentWithBudget(argv2, cwd, budgetSecs) {
|
|
|
9885
10227
|
supervisorTimedOut = Boolean(supervisor.error) && supervisor.error.code === "ETIMEDOUT";
|
|
9886
10228
|
let state = {};
|
|
9887
10229
|
try {
|
|
9888
|
-
state = JSON.parse(
|
|
10230
|
+
state = JSON.parse(readFileSync17(resultFile, "utf8"));
|
|
9889
10231
|
} catch {
|
|
9890
10232
|
}
|
|
9891
10233
|
if (supervisorTimedOut || state.timedOut) {
|
|
@@ -9916,11 +10258,11 @@ function runAgentWithBudget(argv2, cwd, budgetSecs) {
|
|
|
9916
10258
|
}
|
|
9917
10259
|
}
|
|
9918
10260
|
function git3(args, cwd) {
|
|
9919
|
-
return
|
|
10261
|
+
return execFileSync10("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
9920
10262
|
}
|
|
9921
10263
|
function nowTicks() {
|
|
9922
10264
|
try {
|
|
9923
|
-
return Math.floor(parseFloat(
|
|
10265
|
+
return Math.floor(parseFloat(readFileSync17("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
9924
10266
|
} catch {
|
|
9925
10267
|
return Number.POSITIVE_INFINITY;
|
|
9926
10268
|
}
|
|
@@ -9935,7 +10277,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9935
10277
|
}
|
|
9936
10278
|
let pids;
|
|
9937
10279
|
try {
|
|
9938
|
-
pids =
|
|
10280
|
+
pids = readdirSync5("/proc").filter((d) => /^\d+$/.test(d));
|
|
9939
10281
|
} catch {
|
|
9940
10282
|
return out2;
|
|
9941
10283
|
}
|
|
@@ -9943,7 +10285,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9943
10285
|
const n = Number(pid);
|
|
9944
10286
|
if (n === process.pid) continue;
|
|
9945
10287
|
try {
|
|
9946
|
-
const stat =
|
|
10288
|
+
const stat = readFileSync17(`/proc/${pid}/stat`, "utf8");
|
|
9947
10289
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
9948
10290
|
if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
|
|
9949
10291
|
} catch {
|
|
@@ -9951,14 +10293,14 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9951
10293
|
}
|
|
9952
10294
|
const procLinks = [`/proc/${pid}/cwd`, `/proc/${pid}/exe`];
|
|
9953
10295
|
try {
|
|
9954
|
-
for (const fd of
|
|
10296
|
+
for (const fd of readdirSync5(`/proc/${pid}/fd`)) procLinks.push(`/proc/${pid}/fd/${fd}`);
|
|
9955
10297
|
} catch {
|
|
9956
10298
|
}
|
|
9957
10299
|
let holds = false;
|
|
9958
10300
|
for (const link of procLinks) {
|
|
9959
10301
|
try {
|
|
9960
10302
|
const raw = readlinkSync4(link).replace(/ \(deleted\)$/, "");
|
|
9961
|
-
const held = raw.startsWith("/") ?
|
|
10303
|
+
const held = raw.startsWith("/") ? resolve8(raw) : raw;
|
|
9962
10304
|
if (held === real || held.startsWith(real + "/")) {
|
|
9963
10305
|
holds = true;
|
|
9964
10306
|
break;
|
|
@@ -9971,13 +10313,13 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
9971
10313
|
return out2;
|
|
9972
10314
|
}
|
|
9973
10315
|
function runEnvelope(opts) {
|
|
9974
|
-
const cwd =
|
|
10316
|
+
const cwd = resolve8(opts.cwd ?? process.cwd());
|
|
9975
10317
|
if (!opts.argv.length) {
|
|
9976
|
-
|
|
10318
|
+
err2("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
9977
10319
|
return 2;
|
|
9978
10320
|
}
|
|
9979
10321
|
if (opts.agentBudget !== void 0 && (!Number.isFinite(opts.agentBudget) || opts.agentBudget <= 0)) {
|
|
9980
|
-
|
|
10322
|
+
err2(
|
|
9981
10323
|
`tamperward run: --agent-budget needs a positive number of seconds (got ${JSON.stringify(opts.agentBudget)}) \u2014 failing closed.`
|
|
9982
10324
|
);
|
|
9983
10325
|
return 2;
|
|
@@ -9986,7 +10328,7 @@ function runEnvelope(opts) {
|
|
|
9986
10328
|
try {
|
|
9987
10329
|
base = git3(["rev-parse", "--verify", `${opts.base ?? "HEAD"}^{commit}`], cwd).trim();
|
|
9988
10330
|
} catch {
|
|
9989
|
-
|
|
10331
|
+
err2(`tamperward run: cannot resolve trusted base ${opts.base ?? "HEAD"} \u2014 failing closed.`);
|
|
9990
10332
|
return 2;
|
|
9991
10333
|
}
|
|
9992
10334
|
let frozenPolicy;
|
|
@@ -9997,19 +10339,19 @@ function runEnvelope(opts) {
|
|
|
9997
10339
|
frozenCmd = opts.cmd ?? frozenPolicy.verify?.command;
|
|
9998
10340
|
frozenBudget = opts.budget ?? frozenPolicy.verify?.budget;
|
|
9999
10341
|
} catch (e) {
|
|
10000
|
-
|
|
10342
|
+
err2(`tamperward run: cannot load the entry-time policy (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed.`);
|
|
10001
10343
|
return 2;
|
|
10002
10344
|
}
|
|
10003
10345
|
if (!frozenCmd) {
|
|
10004
|
-
|
|
10005
|
-
|
|
10346
|
+
err2("tamperward run: no suite command at entry (policy `verify: { command: ... }` or --cmd) \u2014");
|
|
10347
|
+
err2("refusing to start: a verifier that appears after the agent runs would be agent-authored.");
|
|
10006
10348
|
return 2;
|
|
10007
10349
|
}
|
|
10008
10350
|
if (!opts.allowDirty) {
|
|
10009
10351
|
const dirty = git3(["status", "--porcelain"], cwd).trim();
|
|
10010
10352
|
if (dirty) {
|
|
10011
|
-
|
|
10012
|
-
|
|
10353
|
+
err2("tamperward run: working tree is dirty before the agent starts \u2014 the envelope cannot");
|
|
10354
|
+
err2("attribute changes. Commit or stash first, or pass --allow-dirty to own the risk.");
|
|
10013
10355
|
return 2;
|
|
10014
10356
|
}
|
|
10015
10357
|
}
|
|
@@ -10017,25 +10359,25 @@ function runEnvelope(opts) {
|
|
|
10017
10359
|
out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
|
|
10018
10360
|
out(`tamperward run \u2014 verifier backend: ${verifierBackendSummary(verifierBackend)}`);
|
|
10019
10361
|
if (!verifierBackend.available) {
|
|
10020
|
-
|
|
10021
|
-
|
|
10362
|
+
err2("tamperward run: the requested verifier backend cannot be established \u2014");
|
|
10363
|
+
err2(`${verifierBackend.reason ?? "unknown backend failure"}. Failing closed before the agent starts.`);
|
|
10022
10364
|
return 2;
|
|
10023
10365
|
}
|
|
10024
10366
|
if (verifierBackend.kind === "container") {
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
|
|
10367
|
+
err2("tamperward run: container verification requires a frozen candidate handoff from an");
|
|
10368
|
+
err2("agent domain that cannot control the verifier engine. `run` executes the agent under");
|
|
10369
|
+
err2("this host identity, so it cannot honestly provide that separation. Failing closed");
|
|
10370
|
+
err2("before the agent starts; use standalone `tamperward verify` in trusted CI or after");
|
|
10371
|
+
err2("an externally isolated agent produces the frozen candidate artifact.");
|
|
10030
10372
|
return 2;
|
|
10031
10373
|
}
|
|
10032
10374
|
const dependencyEnvironment = verifierBackend.kind === "local" ? discoverDependencyEnvironment(cwd, frozenCmd) : null;
|
|
10033
10375
|
if (dependencyEnvironment) {
|
|
10034
10376
|
out(`tamperward run \u2014 dependency environment: ${dependencyEnvironmentSummary(dependencyEnvironment)}`);
|
|
10035
10377
|
if (dependencyEnvironment.status === "unattestable" && !opts.allowDepDrift) {
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10378
|
+
err2("tamperward run: the verifier dependency environment cannot be attested \u2014");
|
|
10379
|
+
err2(`${dependencyEnvironment.reason ?? "unknown dependency environment"}. Failing closed before the agent starts.`);
|
|
10380
|
+
err2("(Pass --allow-dep-drift only if you explicitly own this dependency-integrity risk.)");
|
|
10039
10381
|
return 2;
|
|
10040
10382
|
}
|
|
10041
10383
|
} else {
|
|
@@ -10055,37 +10397,37 @@ function runEnvelope(opts) {
|
|
|
10055
10397
|
const agentExit = agentRun.exit;
|
|
10056
10398
|
const agentTimedOut = agentRun.timedOut;
|
|
10057
10399
|
if (agentRun.failure && !agentTimedOut) {
|
|
10058
|
-
|
|
10400
|
+
err2(`tamperward run: agent runtime failed to start/report cleanly (${agentRun.failure}) \u2014 continuing to adjudicate the released tree.`);
|
|
10059
10401
|
}
|
|
10060
10402
|
if (agentTimedOut) {
|
|
10061
|
-
|
|
10403
|
+
err2(
|
|
10062
10404
|
`tamperward run: AGENT_TIMEOUT \u2014 wrapped agent exceeded ${opts.agentBudget}s; its owned process tree was terminated. Post-timeout adjudication continues.`
|
|
10063
10405
|
);
|
|
10064
10406
|
}
|
|
10065
10407
|
if (objectRewriteState(cwd) !== rewritesBefore) {
|
|
10066
|
-
|
|
10067
|
-
|
|
10068
|
-
|
|
10408
|
+
err2("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
|
|
10409
|
+
err2("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
|
|
10410
|
+
err2("read against the anchor we started from. That is the finding.");
|
|
10069
10411
|
out(`
|
|
10070
10412
|
tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
10071
10413
|
return 1;
|
|
10072
10414
|
}
|
|
10073
10415
|
const dependencyBeforeAdjudication = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
|
|
10074
10416
|
if (!dependencyBeforeAdjudication.ok) {
|
|
10075
|
-
|
|
10076
|
-
|
|
10417
|
+
err2("tamperward run: the frozen dependency environment changed before adjudication began \u2014");
|
|
10418
|
+
err2(`${dependencyBeforeAdjudication.reason ?? "dependency identity changed"}.`);
|
|
10077
10419
|
if (!opts.allowDepDrift) {
|
|
10078
10420
|
out(`
|
|
10079
10421
|
tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEMENT_FAILED`);
|
|
10080
10422
|
return 1;
|
|
10081
10423
|
}
|
|
10082
|
-
|
|
10424
|
+
err2("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
|
|
10083
10425
|
}
|
|
10084
10426
|
const head = git3(["rev-parse", "HEAD"], cwd).trim();
|
|
10085
10427
|
const isAncestor = spawnSync3("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
|
|
10086
10428
|
if (isAncestor.status !== 0) {
|
|
10087
|
-
|
|
10088
|
-
|
|
10429
|
+
err2(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
|
|
10430
|
+
err2("the agent rewrote history out from under the anchor. That is the finding.");
|
|
10089
10431
|
out(`
|
|
10090
10432
|
tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMENT_FAILED`);
|
|
10091
10433
|
return 1;
|
|
@@ -10113,30 +10455,30 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
10113
10455
|
});
|
|
10114
10456
|
if (opts.settle && opts.settle > 0) {
|
|
10115
10457
|
try {
|
|
10116
|
-
|
|
10458
|
+
execFileSync10("sleep", [String(opts.settle)]);
|
|
10117
10459
|
} catch {
|
|
10118
10460
|
}
|
|
10119
10461
|
}
|
|
10120
10462
|
const mutatedDuringAdjudication = treeFingerprint(cwd, protectedIgnored) !== fpBefore;
|
|
10121
10463
|
if (mutatedDuringAdjudication) {
|
|
10122
|
-
|
|
10123
|
-
|
|
10464
|
+
err2("tamperward run: the working tree changed while it was being adjudicated \u2014");
|
|
10465
|
+
err2("the verdict below would describe a tree that no longer exists.");
|
|
10124
10466
|
}
|
|
10125
10467
|
const dependencyAfter = dependencyEnvironment ? checkDependencyEnvironment(cwd, dependencyEnvironment) : { ok: true };
|
|
10126
10468
|
const depsDrifted = Boolean(dependencyEnvironment) && !opts.allowDepDrift && !dependencyAfter.ok;
|
|
10127
10469
|
if (depsDrifted) {
|
|
10128
|
-
|
|
10129
|
-
|
|
10470
|
+
err2("tamperward run: the frozen dependency environment changed during this run \u2014");
|
|
10471
|
+
err2(`${dependencyAfter.reason ?? "dependency identity changed"}. Both verification runs depend on it.`);
|
|
10130
10472
|
}
|
|
10131
10473
|
const rewrote = objectRewriteState(cwd) !== rewritesBefore;
|
|
10132
10474
|
if (rewrote) {
|
|
10133
|
-
|
|
10134
|
-
|
|
10475
|
+
err2("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
|
|
10476
|
+
err2("the checks above read the trusted base through an object layer that has since moved.");
|
|
10135
10477
|
}
|
|
10136
10478
|
const survivors = survivorsHoldingTree(cwd, spawnTicks);
|
|
10137
10479
|
if (survivors.length) {
|
|
10138
|
-
|
|
10139
|
-
|
|
10480
|
+
err2(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
|
|
10481
|
+
err2(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
|
|
10140
10482
|
}
|
|
10141
10483
|
const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
|
|
10142
10484
|
const cannot = !notQuiescent && (diffCode === 2 || workCode === 2 || verifyCode === 2);
|
|
@@ -10202,6 +10544,19 @@ function parseInit(args) {
|
|
|
10202
10544
|
}
|
|
10203
10545
|
return o;
|
|
10204
10546
|
}
|
|
10547
|
+
function parseDoctor(args) {
|
|
10548
|
+
const o = {};
|
|
10549
|
+
for (let i = 0; i < args.length; i++) {
|
|
10550
|
+
const a = args[i];
|
|
10551
|
+
if (a === "--cwd") o.cwd = args[++i];
|
|
10552
|
+
else if (a === "--base") o.base = args[++i];
|
|
10553
|
+
else if (a === "--workflow") o.workflow = args[++i];
|
|
10554
|
+
else if (a === "--github") o.github = true;
|
|
10555
|
+
else if (a === "--repo") o.repo = args[++i];
|
|
10556
|
+
else if (a === "--branch") o.branch = args[++i];
|
|
10557
|
+
}
|
|
10558
|
+
return o;
|
|
10559
|
+
}
|
|
10205
10560
|
function parseCheck(args) {
|
|
10206
10561
|
const o = {};
|
|
10207
10562
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -10276,6 +10631,14 @@ Formats:
|
|
|
10276
10631
|
every tool the gate must see.
|
|
10277
10632
|
--force-workflow replaces a workflow it
|
|
10278
10633
|
did not write, or one you have edited.
|
|
10634
|
+
tamperward doctor [--base R] validate the CI verifier's outer-time
|
|
10635
|
+
[--workflow F] [--cwd D] envelope against the trusted policy.
|
|
10636
|
+
[--github] [--repo O/R] --github also validates repository
|
|
10637
|
+
[--branch B] authority: required tamperward status,
|
|
10638
|
+
Code Owner review, and stale-review
|
|
10639
|
+
dismissal after new pushes. Public
|
|
10640
|
+
rules can be read anonymously; set
|
|
10641
|
+
GH_TOKEN/GITHUB_TOKEN when needed.
|
|
10279
10642
|
|
|
10280
10643
|
Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
|
|
10281
10644
|
(verify), any blocking finding or masked failure (run) \xB7 2 cannot
|
|
@@ -10300,6 +10663,8 @@ function main(argv2) {
|
|
|
10300
10663
|
return runAllow(parseAllow(rest));
|
|
10301
10664
|
case "init":
|
|
10302
10665
|
return runInit(parseInit(rest));
|
|
10666
|
+
case "doctor":
|
|
10667
|
+
return runDoctor(parseDoctor(rest));
|
|
10303
10668
|
case "verify":
|
|
10304
10669
|
return runVerify(parseVerify(rest));
|
|
10305
10670
|
case "run":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.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",
|