@clossys/launcher 0.3.1 → 0.5.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 +1373 -60
- package/contracts/conversation-contract.md +2 -2
- package/contracts/product-ci-workflow.yml +74 -0
- package/contracts/repository-inventory.json +53 -0
- package/dist/admission-fixture.d.ts +168 -0
- package/dist/admission-fixture.d.ts.map +1 -0
- package/dist/admission-fixture.js +467 -0
- package/dist/admission-fixture.js.map +1 -0
- package/dist/admission.d.ts +124 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +804 -0
- package/dist/admission.js.map +1 -0
- package/dist/agents-guide.d.ts +9 -0
- package/dist/agents-guide.d.ts.map +1 -0
- package/dist/agents-guide.js +26 -0
- package/dist/agents-guide.js.map +1 -0
- package/dist/apply-command-options.check.d.ts +12 -0
- package/dist/apply-command-options.check.d.ts.map +1 -0
- package/dist/apply-command-options.check.js +20 -0
- package/dist/apply-command-options.check.js.map +1 -0
- package/dist/apply-plan-cli.d.ts +39 -1
- package/dist/apply-plan-cli.d.ts.map +1 -1
- package/dist/apply-plan-cli.js +432 -15
- package/dist/apply-plan-cli.js.map +1 -1
- package/dist/apply-plan.d.ts +46 -59
- package/dist/apply-plan.d.ts.map +1 -1
- package/dist/apply-plan.js +112 -97
- package/dist/apply-plan.js.map +1 -1
- package/dist/apply-step-fixture.d.ts +87 -0
- package/dist/apply-step-fixture.d.ts.map +1 -0
- package/dist/apply-step-fixture.js +199 -0
- package/dist/apply-step-fixture.js.map +1 -0
- package/dist/apply-store.d.ts +93 -0
- package/dist/apply-store.d.ts.map +1 -0
- package/dist/apply-store.js +625 -0
- package/dist/apply-store.js.map +1 -0
- package/dist/approval-sheet.d.ts +21 -0
- package/dist/approval-sheet.d.ts.map +1 -0
- package/dist/approval-sheet.js +163 -0
- package/dist/approval-sheet.js.map +1 -0
- package/dist/body-command.d.ts +42 -0
- package/dist/body-command.d.ts.map +1 -0
- package/dist/body-command.js +143 -0
- package/dist/body-command.js.map +1 -0
- package/dist/change-set-contract.d.ts +403 -0
- package/dist/change-set-contract.d.ts.map +1 -0
- package/dist/change-set-contract.js +781 -0
- package/dist/change-set-contract.js.map +1 -0
- package/dist/change-set-digest.d.ts +28 -0
- package/dist/change-set-digest.d.ts.map +1 -0
- package/dist/change-set-digest.js +65 -0
- package/dist/change-set-digest.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +14 -3
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +17 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +84 -23
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +79 -22
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +843 -268
- package/dist/core.js.map +1 -1
- package/dist/dry-materialize.d.ts +63 -0
- package/dist/dry-materialize.d.ts.map +1 -0
- package/dist/dry-materialize.js +330 -0
- package/dist/dry-materialize.js.map +1 -0
- package/dist/existing-declaration-adoption.check.d.ts +2 -0
- package/dist/existing-declaration-adoption.check.d.ts.map +1 -0
- package/dist/existing-declaration-adoption.check.js +10 -0
- package/dist/existing-declaration-adoption.check.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/generated/package-scope.generated.d.ts +6 -0
- package/dist/generated/package-scope.generated.d.ts.map +1 -0
- package/dist/generated/package-scope.generated.js +10 -0
- package/dist/generated/package-scope.generated.js.map +1 -0
- package/dist/generated/plan-contracts.generated.d.ts +3 -0
- package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
- package/dist/generated/plan-contracts.generated.js +3101 -0
- package/dist/generated/plan-contracts.generated.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +11 -0
- package/dist/host.js.map +1 -1
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +48 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +35 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +24 -5
- package/dist/inventory-adoption.d.ts.map +1 -1
- package/dist/inventory-adoption.js +70 -25
- package/dist/inventory-adoption.js.map +1 -1
- package/dist/inventory-choice.d.ts +40 -0
- package/dist/inventory-choice.d.ts.map +1 -0
- package/dist/inventory-choice.js +156 -0
- package/dist/inventory-choice.js.map +1 -0
- package/dist/inventory-contract.d.ts +89 -0
- package/dist/inventory-contract.d.ts.map +1 -0
- package/dist/inventory-contract.js +121 -0
- package/dist/inventory-contract.js.map +1 -0
- package/dist/key-editor.d.ts +30 -0
- package/dist/key-editor.d.ts.map +1 -0
- package/dist/key-editor.js +445 -0
- package/dist/key-editor.js.map +1 -0
- package/dist/ledger-contract.d.ts +190 -0
- package/dist/ledger-contract.d.ts.map +1 -0
- package/dist/ledger-contract.js +555 -0
- package/dist/ledger-contract.js.map +1 -0
- package/dist/ledger-trust.d.ts +90 -0
- package/dist/ledger-trust.d.ts.map +1 -0
- package/dist/ledger-trust.js +203 -0
- package/dist/ledger-trust.js.map +1 -0
- package/dist/lockfile-invariants.d.ts +48 -0
- package/dist/lockfile-invariants.d.ts.map +1 -0
- package/dist/lockfile-invariants.js +375 -0
- package/dist/lockfile-invariants.js.map +1 -0
- package/dist/lockfile-readers.d.ts +72 -0
- package/dist/lockfile-readers.d.ts.map +1 -0
- package/dist/lockfile-readers.js +713 -0
- package/dist/lockfile-readers.js.map +1 -0
- package/dist/lockfile-regen.d.ts +106 -0
- package/dist/lockfile-regen.d.ts.map +1 -0
- package/dist/lockfile-regen.js +760 -0
- package/dist/lockfile-regen.js.map +1 -0
- package/dist/lockfile-tool-env.d.ts +29 -0
- package/dist/lockfile-tool-env.d.ts.map +1 -0
- package/dist/lockfile-tool-env.js +111 -0
- package/dist/lockfile-tool-env.js.map +1 -0
- package/dist/materialize.d.ts +113 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +881 -0
- package/dist/materialize.js.map +1 -0
- package/dist/observe-repository.d.ts +90 -0
- package/dist/observe-repository.d.ts.map +1 -0
- package/dist/observe-repository.js +1367 -0
- package/dist/observe-repository.js.map +1 -0
- package/dist/plan-bundle-setup-fixture.d.ts +68 -0
- package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
- package/dist/plan-bundle-setup-fixture.js +167 -0
- package/dist/plan-bundle-setup-fixture.js.map +1 -0
- package/dist/plan-bundle.d.ts +256 -0
- package/dist/plan-bundle.d.ts.map +1 -0
- package/dist/plan-bundle.js +882 -0
- package/dist/plan-bundle.js.map +1 -0
- package/dist/plan-command.d.ts +29 -0
- package/dist/plan-command.d.ts.map +1 -0
- package/dist/plan-command.js +523 -0
- package/dist/plan-command.js.map +1 -0
- package/dist/plan-contract.d.ts +153 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +61 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +25 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +106 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +23 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +177 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/planned-bundle.d.ts +20 -0
- package/dist/planned-bundle.d.ts.map +1 -0
- package/dist/planned-bundle.js +191 -0
- package/dist/planned-bundle.js.map +1 -0
- package/dist/product-repository.d.ts +4 -0
- package/dist/product-repository.d.ts.map +1 -1
- package/dist/product-repository.js +9 -1
- package/dist/product-repository.js.map +1 -1
- package/dist/provenance-gate.d.ts +48 -0
- package/dist/provenance-gate.d.ts.map +1 -0
- package/dist/provenance-gate.js +324 -0
- package/dist/provenance-gate.js.map +1 -0
- package/dist/pull-request-body.d.ts +45 -0
- package/dist/pull-request-body.d.ts.map +1 -0
- package/dist/pull-request-body.js +232 -0
- package/dist/pull-request-body.js.map +1 -0
- package/dist/registry-snapshot.d.ts +141 -0
- package/dist/registry-snapshot.d.ts.map +1 -0
- package/dist/registry-snapshot.js +483 -0
- package/dist/registry-snapshot.js.map +1 -0
- package/dist/release-age-edit.d.ts +52 -0
- package/dist/release-age-edit.d.ts.map +1 -0
- package/dist/release-age-edit.js +413 -0
- package/dist/release-age-edit.js.map +1 -0
- package/dist/root-entries.d.ts +36 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +80 -0
- package/dist/root-entries.js.map +1 -0
- package/dist/setup-template-scripts.d.ts +36 -0
- package/dist/setup-template-scripts.d.ts.map +1 -0
- package/dist/setup-template-scripts.js +568 -0
- package/dist/setup-template-scripts.js.map +1 -0
- package/dist/setup-templates.d.ts +55 -0
- package/dist/setup-templates.d.ts.map +1 -0
- package/dist/setup-templates.js +438 -0
- package/dist/setup-templates.js.map +1 -0
- package/dist/skills.d.ts +34 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +129 -17
- package/dist/skills.js.map +1 -1
- package/dist/status.d.ts +63 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +539 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +151 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/skeleton/README.md +14 -9
- package/skeleton/package.json +2 -1
- package/skill/SKILL.md +23 -7
- package/skill-catalogue/advisor/SKILL.md +59 -6
- package/skill-catalogue/architect/SKILL.md +2 -2
- package/skill-catalogue/bouncer/SKILL.md +2 -2
- package/skill-catalogue/builder/SKILL.md +2 -2
- package/skill-catalogue/butler/SKILL.md +2 -2
- package/skill-catalogue/controller/SKILL.md +2 -2
- package/skill-catalogue/customer/SKILL.md +2 -2
- package/skill-catalogue/designer/SKILL.md +4 -2
- package/skill-catalogue/giver/SKILL.md +2 -2
- package/skill-catalogue/influencer/SKILL.md +2 -2
- package/skill-catalogue/inspector/SKILL.md +2 -2
- package/skill-catalogue/integrator/SKILL.md +2 -2
- package/skill-catalogue/keeper/SKILL.md +2 -2
- package/skill-catalogue/launcher/SKILL.md +23 -7
- package/skill-catalogue/locksmith/SKILL.md +2 -2
- package/skill-catalogue/messenger/SKILL.md +2 -2
- package/skill-catalogue/observer/SKILL.md +2 -2
- package/skill-catalogue/publisher/SKILL.md +2 -2
- package/skill-catalogue/starter/SKILL.md +3 -2
- package/skill-catalogue/strategist/SKILL.md +12 -4
- package/skill-catalogue/writer/SKILL.md +2 -2
- package/src/admission-fixture.ts +585 -0
- package/src/admission.ts +819 -0
- package/src/agents-guide.ts +29 -0
- package/src/apply-command-options.check.ts +27 -0
- package/src/apply-plan-cli.ts +454 -14
- package/src/apply-plan.ts +112 -124
- package/src/apply-step-fixture.ts +236 -0
- package/src/apply-store.ts +584 -0
- package/src/approval-sheet.ts +170 -0
- package/src/body-command.ts +162 -0
- package/src/change-set-contract.ts +987 -0
- package/src/change-set-digest.ts +70 -0
- package/src/check-cli.ts +14 -3
- package/src/cli.ts +90 -22
- package/src/core.ts +973 -275
- package/src/dry-materialize.ts +353 -0
- package/src/existing-declaration-adoption.check.ts +12 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/generated/package-scope.generated.ts +10 -0
- package/src/generated/plan-contracts.generated.ts +3101 -0
- package/src/host.ts +10 -0
- package/src/identity.ts +51 -0
- package/src/index.ts +74 -3
- package/src/inventory-adoption.ts +107 -29
- package/src/inventory-choice.ts +172 -0
- package/src/inventory-contract.ts +166 -0
- package/src/key-editor.ts +446 -0
- package/src/ledger-contract.ts +660 -0
- package/src/ledger-trust.ts +272 -0
- package/src/lockfile-invariants.ts +421 -0
- package/src/lockfile-readers.ts +749 -0
- package/src/lockfile-regen.ts +851 -0
- package/src/lockfile-tool-env.ts +131 -0
- package/src/materialize.ts +915 -0
- package/src/observe-repository.ts +1365 -0
- package/src/plan-bundle-setup-fixture.ts +200 -0
- package/src/plan-bundle.ts +1014 -0
- package/src/plan-command.ts +532 -0
- package/src/plan-contract.ts +179 -0
- package/src/plan-digest.ts +102 -0
- package/src/plan-rules.ts +188 -0
- package/src/planned-bundle.ts +211 -0
- package/src/product-repository.ts +10 -1
- package/src/provenance-gate.ts +352 -0
- package/src/pull-request-body.ts +261 -0
- package/src/registry-snapshot.ts +534 -0
- package/src/release-age-edit.ts +430 -0
- package/src/root-entries.ts +81 -0
- package/src/setup-template-scripts.ts +580 -0
- package/src/setup-templates.ts +479 -0
- package/src/skills.ts +161 -18
- package/src/status.ts +557 -0
- package/src/types.ts +148 -13
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Planned mode for `launcher-apply-plan plan` (issue #1708, RFC apply-approved-plan sections 4.2, 4.4 and 7).
|
|
2
|
+
//
|
|
3
|
+
// The planner and the dry tree produce a report: it claims no repository state and records no approval. This module turns
|
|
4
|
+
// that report into a planned bundle, but only when an approval binds exactly what was planned. Every ambiguity leaves the
|
|
5
|
+
// report as it is, or refuses; a rule is one of a fixed set of tokens and never carries tool output, a path, an id or a file
|
|
6
|
+
// name.
|
|
7
|
+
//
|
|
8
|
+
// P1. The result is planned only when the bundle says the plan is the committed one AND the hub's committed plan, read as a
|
|
9
|
+
// git object at an attached HEAD, is approved (readHubAuthority) for this very plan digest AND the bundle that approval
|
|
10
|
+
// names is one the hub holds, stored and verifying, for that plan digest (the same K2 read admission makes). An approval
|
|
11
|
+
// that names nothing the hub holds approves nothing this run could be bound by. Otherwise the result is returned
|
|
12
|
+
// untouched, so its bytes are what they were before this module existed.
|
|
13
|
+
// P2. A binding comes only from decideSetBinding, over the ledger this module reads at the set's base commit and trusts
|
|
14
|
+
// against the committed plan's acts (never the acts of the set being judged). Nothing here builds a binding by hand.
|
|
15
|
+
// P3. A repository that already carries a violated V3 (the planner's A4 flags: an authorization for another plan, or none)
|
|
16
|
+
// is not admitted, and no readiness check runs for it.
|
|
17
|
+
// P4. V3 is satisfied exactly when the set is bound. A refusal is V3 violated (exit 1) or indeterminate (exit 2), the rule is
|
|
18
|
+
// the refusal's fixed token, and the repository has no binding.
|
|
19
|
+
// P5. Planned mode reports V1, V2, V4, V5 and V7 as satisfied on every computed repository (a set that exists and validated
|
|
20
|
+
// has met them), and V9 as satisfied only for a set that changes no lockfile; the dry tree's V6 and V9 are kept.
|
|
21
|
+
// P6. A repository is `planned` when its verdict is satisfied and it has a binding (code rule A6). The bundle is validated
|
|
22
|
+
// again before it is returned. No set, digest or set byte moves: the bundle digest does not cover mode, state, binding
|
|
23
|
+
// or checks.
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { realpathSync } from "node:fs";
|
|
27
|
+
import { decideSetBinding, planPackagesFor, readHubAuthority } from "./admission.js";
|
|
28
|
+
import { readStoredApplyBundle } from "./apply-store.js";
|
|
29
|
+
import type { AdmissionRefusal, HubAuthority, ReadinessRunner } from "./admission.js";
|
|
30
|
+
import {
|
|
31
|
+
AUTHORIZATION_ABSENT,
|
|
32
|
+
AUTHORIZATION_PLAN_MISMATCH,
|
|
33
|
+
LEDGER_PATH,
|
|
34
|
+
canonicalOrder,
|
|
35
|
+
validateApplyBundle,
|
|
36
|
+
worstVerdict,
|
|
37
|
+
} from "./change-set-contract.js";
|
|
38
|
+
import type { ApplyBundle, ApplyBundleRepository, ApplyCheck, RepositoryChangeSet } from "./change-set-contract.js";
|
|
39
|
+
import type { InstalledLedger } from "./ledger-contract.js";
|
|
40
|
+
import { trustInstalledLedger } from "./ledger-trust.js";
|
|
41
|
+
import { derivedLockfile } from "./materialize.js";
|
|
42
|
+
import type { PlanApplyBundleResult } from "./plan-bundle.js";
|
|
43
|
+
|
|
44
|
+
export interface PlannedBundleOptions {
|
|
45
|
+
/** The hub's real path. */
|
|
46
|
+
readonly hub: string;
|
|
47
|
+
/** The local clone of a repository id. */
|
|
48
|
+
readonly cloneFor: (id: string) => string;
|
|
49
|
+
/** Every change set the hub holds: the ledger's history is trusted only against these. */
|
|
50
|
+
readonly heldChangeSets: readonly RepositoryChangeSet[];
|
|
51
|
+
readonly now: () => Date;
|
|
52
|
+
/** Runs advisor-execution-readiness; the hub's own bin by default. Not reachable from a CLI. */
|
|
53
|
+
readonly runReadiness?: ReadinessRunner;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
57
|
+
/** The V3 rule of a refusal whose token is not a fixed one, or is one the bundle may carry only for its own authorization condition. */
|
|
58
|
+
const AUTHORIZATION_NOT_CURRENT = "authorization-not-current";
|
|
59
|
+
/** The V3 rule of a base ledger that cannot be read as a regular file. */
|
|
60
|
+
const LEDGER_UNREADABLE = "ledger-unreadable";
|
|
61
|
+
/** The V3 rule of a base ledger whose generation is not the one the set was computed over. */
|
|
62
|
+
const LEDGER_MISMATCH = "ledger-mismatch";
|
|
63
|
+
|
|
64
|
+
/** The V9 rule of a set that changes a lockfile and whose dry tree left no provenance check. */
|
|
65
|
+
const PROVENANCE_NOT_RUN = "provenance-not-run";
|
|
66
|
+
|
|
67
|
+
/** Rules only the bundle's own authorization condition may name (code rule A4). */
|
|
68
|
+
const RESERVED_RULES: ReadonlySet<string> = new Set([AUTHORIZATION_PLAN_MISMATCH, AUTHORIZATION_ABSENT]);
|
|
69
|
+
|
|
70
|
+
const sortChecks = (checks: readonly ApplyCheck[]): ApplyCheck[] => canonicalOrder(checks, (check) => [check.check, check.rule ?? ""]);
|
|
71
|
+
const satisfied = (check: ApplyCheck["check"]): ApplyCheck => ({ check, verdict: "satisfied" });
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// the base ledger, from git objects only
|
|
75
|
+
|
|
76
|
+
const DROPPED_GIT_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_GRAFT_FILE"];
|
|
77
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
|
|
78
|
+
|
|
79
|
+
/** Runs git with no repository named by the environment, no replace refs and literal pathspecs. Never throws. */
|
|
80
|
+
function runGit(cwd: string, args: readonly string[]): { status: number | null; stdout: Buffer } {
|
|
81
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
82
|
+
for (const name of DROPPED_GIT_ENV) delete env[name];
|
|
83
|
+
env.GIT_LITERAL_PATHSPECS = "1";
|
|
84
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
85
|
+
env.GIT_OPTIONAL_LOCKS = "0";
|
|
86
|
+
const run = spawnSync("git", ["--no-replace-objects", ...args], { cwd, env, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024 });
|
|
87
|
+
if (run.error !== undefined || run.stdout === null || run.stdout === undefined) return { status: null, stdout: Buffer.alloc(0) };
|
|
88
|
+
return { status: run.status, stdout: run.stdout };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The base ledger's exact bytes at the set's base commit: null when the commit holds none, or when the commit cannot be
|
|
93
|
+
* read (materialize reads it the same way, and admission reads the base tree itself for every set that depends on it);
|
|
94
|
+
* "unreadable" when the path is there but is not one regular, non-executable blob, or the blob cannot be read.
|
|
95
|
+
*/
|
|
96
|
+
function readBaseLedger(clone: string, baseCommit: string): Buffer | null | "unreadable" {
|
|
97
|
+
if (!OBJECT_ID.test(baseCommit)) return null;
|
|
98
|
+
const listed = runGit(clone, ["ls-tree", "-z", "--full-tree", baseCommit, "--", LEDGER_PATH]);
|
|
99
|
+
if (listed.status !== 0) return null;
|
|
100
|
+
const records = listed.stdout.toString("utf8").split("\0").filter((record) => record !== "");
|
|
101
|
+
if (records.length === 0) return null;
|
|
102
|
+
if (records.length !== 1) return "unreadable";
|
|
103
|
+
const record = records[0]!;
|
|
104
|
+
const tab = record.indexOf("\t");
|
|
105
|
+
if (tab === -1) return "unreadable";
|
|
106
|
+
const [mode, type, oid, ...rest] = record.slice(0, tab).split(" ");
|
|
107
|
+
if (mode !== "100644" || type !== "blob" || oid === undefined || rest.length > 0 || !OBJECT_ID.test(oid) || record.slice(tab + 1) !== LEDGER_PATH) return "unreadable";
|
|
108
|
+
const blob = runGit(clone, ["cat-file", "blob", oid]);
|
|
109
|
+
return blob.status === 0 ? blob.stdout : "unreadable";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type BaseLedger = { readonly ledger: InstalledLedger | null; readonly bytes: Uint8Array | null } | { readonly refused: ApplyCheck };
|
|
113
|
+
|
|
114
|
+
/** The base ledger, trusted over the committed plan's acts (T9), and held to the generation the set was computed over. */
|
|
115
|
+
function trustedBaseLedger(clone: string, set: RepositoryChangeSet, held: readonly RepositoryChangeSet[], authority: HubAuthority): BaseLedger {
|
|
116
|
+
const bytes = readBaseLedger(clone, set.repository.baseCommit);
|
|
117
|
+
if (bytes === "unreadable") return { refused: { check: "V3", verdict: "indeterminate", rule: LEDGER_UNREADABLE } };
|
|
118
|
+
const trust = trustInstalledLedger(bytes === null || bytes.length === 0 ? null : bytes, { id: set.repository.id, nodeId: set.repository.nodeId }, held, {
|
|
119
|
+
planPackageActs: [{ planDigest: authority.planDigest, packages: planPackagesFor(authority, set.repository.id) }],
|
|
120
|
+
});
|
|
121
|
+
if (trust.state === "refused") return { refused: { check: "V3", verdict: "indeterminate", rule: TOKEN.test(trust.rule) ? trust.rule : LEDGER_UNREADABLE } };
|
|
122
|
+
if ((trust.ledger?.generation ?? 0) !== set.ledger.generation) return { refused: { check: "V3", verdict: "violated", rule: LEDGER_MISMATCH } };
|
|
123
|
+
return { ledger: trust.ledger, bytes: bytes === null || bytes.length === 0 ? null : bytes };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// V3 for one set
|
|
128
|
+
|
|
129
|
+
/** The V3 check of a refusal: exit 1 is violated, anything else indeterminate; the rule is its fixed detail, or its reason. */
|
|
130
|
+
function refusalCheck(refusal: AdmissionRefusal): ApplyCheck {
|
|
131
|
+
const token = refusal.detail ?? refusal.reason;
|
|
132
|
+
const rule = TOKEN.test(token) && !RESERVED_RULES.has(token) ? token : AUTHORIZATION_NOT_CURRENT;
|
|
133
|
+
return { check: "V3", verdict: refusal.exitCode === 1 ? "violated" : "indeterminate", rule };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function decideV3(entry: Extract<ApplyBundleRepository, { changeSet: string }>, set: RepositoryChangeSet, authority: HubAuthority, options: PlannedBundleOptions): Promise<{ readonly check: ApplyCheck; readonly binding?: NonNullable<Extract<ApplyBundleRepository, { changeSet: string }>["binding"]> }> {
|
|
137
|
+
let clone: string;
|
|
138
|
+
try {
|
|
139
|
+
clone = realpathSync(options.cloneFor(set.repository.id));
|
|
140
|
+
} catch {
|
|
141
|
+
return { check: { check: "V3", verdict: "indeterminate", rule: "clone-unreadable" } };
|
|
142
|
+
}
|
|
143
|
+
const base = trustedBaseLedger(clone, set, options.heldChangeSets, authority);
|
|
144
|
+
if ("refused" in base) return { check: base.refused };
|
|
145
|
+
const decided = await decideSetBinding({
|
|
146
|
+
hub: options.hub,
|
|
147
|
+
clone,
|
|
148
|
+
set,
|
|
149
|
+
authority,
|
|
150
|
+
baseLedger: base.ledger,
|
|
151
|
+
baseLedgerBytes: base.bytes,
|
|
152
|
+
now: options.now,
|
|
153
|
+
runReadiness: options.runReadiness,
|
|
154
|
+
});
|
|
155
|
+
if (decided.state !== "bound") return { check: refusalCheck(decided) };
|
|
156
|
+
return { check: satisfied("V3"), binding: decided.binding };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// the bundle
|
|
161
|
+
|
|
162
|
+
/** Whether the hub stores, verified, the bundle the approval names, and it is for the approved plan's digest. A store that cannot be read holds nothing. */
|
|
163
|
+
function holdsApprovedBundle(hub: string, authority: HubAuthority): boolean {
|
|
164
|
+
try {
|
|
165
|
+
const approved = readStoredApplyBundle(hub, authority.subject);
|
|
166
|
+
return approved !== null && approved.bundleDigest === authority.subject && approved.plan.digest === authority.planDigest;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const PRE_APPLY: readonly ApplyCheck["check"][] = ["V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9"];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The result as a planned bundle, or the same result when nothing approves it (P1). A repository with no change set keeps
|
|
176
|
+
* its entry. Throws when the planned bundle does not validate; nothing is written here.
|
|
177
|
+
*/
|
|
178
|
+
export async function plannedBundle(result: PlanApplyBundleResult, options: PlannedBundleOptions): Promise<PlanApplyBundleResult> {
|
|
179
|
+
if (!result.bundle.plan.committed) return result;
|
|
180
|
+
const authority = readHubAuthority(options.hub);
|
|
181
|
+
if ("state" in authority || authority.planDigest !== result.bundle.plan.digest) return result;
|
|
182
|
+
if (!holdsApprovedBundle(options.hub, authority)) return result;
|
|
183
|
+
|
|
184
|
+
const repositories: ApplyBundleRepository[] = [];
|
|
185
|
+
for (const entry of result.bundle.repositories) {
|
|
186
|
+
if (!("changeSet" in entry)) {
|
|
187
|
+
repositories.push(entry);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const set = result.changeSets.find((candidate) => candidate.changeSetDigest === entry.changeSet);
|
|
191
|
+
if (set === undefined) throw new Error("a repository names a change set the result does not hold");
|
|
192
|
+
const { state: _state, binding: _binding, ...rest } = entry;
|
|
193
|
+
// P3: the planner already refused this repository's authorization; no readiness runs and nothing is bound.
|
|
194
|
+
const flagged = rest.checks.some((check) => check.check === "V3");
|
|
195
|
+
const decided = flagged ? undefined : await decideV3(rest, set, authority, options);
|
|
196
|
+
const others = rest.checks.filter((check) => !(decided !== undefined && check.check === "V3"));
|
|
197
|
+
const added: ApplyCheck[] = [satisfied("V1"), satisfied("V2"), satisfied("V4"), satisfied("V5"), satisfied("V7")];
|
|
198
|
+
// P5: V9 is satisfied only for a set with no lockfile to regenerate; for any other set it stands as the dry tree left it, and is
|
|
199
|
+
// indeterminate when the dry tree ran no provenance check at all.
|
|
200
|
+
if (derivedLockfile(set) === null) added.push(satisfied("V9"));
|
|
201
|
+
else added.push({ check: "V9", verdict: "indeterminate", rule: PROVENANCE_NOT_RUN });
|
|
202
|
+
const checks = sortChecks([...others, ...(decided === undefined ? [] : [decided.check]), ...added.filter((check) => !others.some((existing) => existing.check === check.check))]);
|
|
203
|
+
const verdict = worstVerdict(checks.map((check) => check.verdict));
|
|
204
|
+
const binding = decided?.binding;
|
|
205
|
+
const planned = verdict === "satisfied" && binding !== undefined && PRE_APPLY.every((id) => checks.some((check) => check.check === id && check.verdict === "satisfied"));
|
|
206
|
+
repositories.push({ ...rest, verdict, checks, ...(binding === undefined ? {} : { binding }), ...(planned ? { state: "planned" as const } : {}) });
|
|
207
|
+
}
|
|
208
|
+
const bundle: ApplyBundle = { ...result.bundle, mode: "planned", repositories };
|
|
209
|
+
if (!validateApplyBundle(bundle).valid) throw new Error("the planned bundle does not validate");
|
|
210
|
+
return { bundle, changeSets: result.changeSets };
|
|
211
|
+
}
|
|
@@ -30,6 +30,10 @@ const HUB_MARKER_REL = join("clossys", ".state", "workspace.json");
|
|
|
30
30
|
* against `directory`. Never mutates anything, never runs `npm ci` itself
|
|
31
31
|
* -- it checks that the manifest and lockfile are BOTH present, which is
|
|
32
32
|
* the precondition a real `npm ci` needs, not a substitute for running it.
|
|
33
|
+
* An unsatisfied check is a readiness report, not a failure: a launcher run
|
|
34
|
+
* in the hub writes nothing into a product repository, whose `AGENTS.md`
|
|
35
|
+
* arrives only once it is staffed in an approved plan, with that plan's
|
|
36
|
+
* setup pull request.
|
|
33
37
|
*/
|
|
34
38
|
export function checkCloudSessionBootstrap(host: WorkspaceHost, directory: string): CloudBootstrapReport {
|
|
35
39
|
const hasManifest = host.exists(join(directory, PACKAGE_JSON_REL));
|
|
@@ -57,7 +61,12 @@ export function checkCloudSessionBootstrap(host: WorkspaceHost, directory: strin
|
|
|
57
61
|
satisfied: agentsPointsAtClossys,
|
|
58
62
|
...(agentsPointsAtClossys
|
|
59
63
|
? {}
|
|
60
|
-
: {
|
|
64
|
+
: {
|
|
65
|
+
note:
|
|
66
|
+
agentsRaw === null
|
|
67
|
+
? "AGENTS.md does not exist at the repository root yet; a launcher run in the hub writes nothing here, and once this repository is staffed in an approved plan, AGENTS.md arrives with that plan's setup pull request."
|
|
68
|
+
: "AGENTS.md exists but does not mention clossys/.",
|
|
69
|
+
}),
|
|
61
70
|
};
|
|
62
71
|
|
|
63
72
|
const hasHubMarker = host.exists(join(directory, HUB_MARKER_REL));
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Provenance gate for a change set (RFC apply-approved-plan: V9, D20, D23, T12).
|
|
2
|
+
//
|
|
3
|
+
// Every package version a set installs or pins must have registry provenance
|
|
4
|
+
// that Integrator verifies. The hub pins `@clossys/integrator` (D23), so this
|
|
5
|
+
// runs the hub's own `integrator-provenance-check` bin against the
|
|
6
|
+
// materialized tree, reads its JSON report strictly, and turns it into one V9
|
|
7
|
+
// verdict for the set. The bin is found only inside the hub's own
|
|
8
|
+
// node_modules, never through npx or PATH, and runs under the same literal
|
|
9
|
+
// environment allow-list the lockfile step uses (lockfile-tool-env.ts): no
|
|
10
|
+
// parent credential, proxy, CA-trust or NODE_OPTIONS value reaches it.
|
|
11
|
+
//
|
|
12
|
+
// Every ambiguity refuses. A report that does not parse to exactly the shape
|
|
13
|
+
// the bin prints, or that disagrees with its own exit code, is indeterminate,
|
|
14
|
+
// never trusted as far as it goes. A package version the bin does not verify is
|
|
15
|
+
// never passed by any exception: it stays violated or indeterminate exactly as
|
|
16
|
+
// Integrator reports it.
|
|
17
|
+
//
|
|
18
|
+
// D20's first-identity-publication exception is deliberately not implemented.
|
|
19
|
+
// It needs evidence that a version is a package's first publication, and the
|
|
20
|
+
// registry snapshot cannot give it: a snapshot taken to resolve a plan records
|
|
21
|
+
// only the one version `latest` names, whatever else the package has published,
|
|
22
|
+
// and Integrator reports "attestation failed" (violated) the same way as "no
|
|
23
|
+
// attestation". Any condition built on the snapshot would therefore also pass a
|
|
24
|
+
// package with earlier releases whose latest release fails verification. Until
|
|
25
|
+
// the snapshot contract records evidence of a first publication, an unattested
|
|
26
|
+
// first publication blocks the apply: fail closed. Nothing here fetches,
|
|
27
|
+
// repairs or retries.
|
|
28
|
+
//
|
|
29
|
+
// The RFC and the snapshot contract are in the public repository, not shipped
|
|
30
|
+
// in this package: docs/rfcs/apply-approved-plan.md and
|
|
31
|
+
// docs/contracts/registry-snapshot.json.
|
|
32
|
+
|
|
33
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
34
|
+
import { delimiter, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
35
|
+
import { compareCodeUnits } from "./change-set-contract.js";
|
|
36
|
+
import type { ApplyCheck, ChangeSetItem } from "./change-set-contract.js";
|
|
37
|
+
import { readContractDocument } from "./generated/contract-schema.generated.js";
|
|
38
|
+
import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
|
|
39
|
+
import { spawnLockfileTool } from "./lockfile-regen.js";
|
|
40
|
+
import type { LockfileSpawn, LockfileSpawnResult } from "./lockfile-regen.js";
|
|
41
|
+
import { lockfileToolEnv, prepareLockfileScratch } from "./lockfile-tool-env.js";
|
|
42
|
+
import type { LockfileScratch } from "./lockfile-tool-env.js";
|
|
43
|
+
import { canonicalDigest } from "./plan-digest.js";
|
|
44
|
+
import { canonicalRegistrySnapshot, registrySnapshotViolations } from "./registry-snapshot.js";
|
|
45
|
+
import type { RegistrySnapshot } from "./registry-snapshot.js";
|
|
46
|
+
|
|
47
|
+
/** The bin Integrator installs, found under the hub's node_modules/.bin. */
|
|
48
|
+
export const PROVENANCE_CHECK_BIN = "integrator-provenance-check";
|
|
49
|
+
/** How long one run of the bin may take. */
|
|
50
|
+
export const PROVENANCE_CHECK_TIMEOUT_MS = 120_000;
|
|
51
|
+
/** Bytes kept of the bin's stdout; a report at or past this is treated as truncated. */
|
|
52
|
+
export const PROVENANCE_CHECK_MAX_BUFFER = 1_000_000;
|
|
53
|
+
|
|
54
|
+
export interface ProvenanceGateInput {
|
|
55
|
+
/** Absolute, real (realpath-equal) directory holding the set's materialized tree. */
|
|
56
|
+
readonly tree: string;
|
|
57
|
+
/** Absolute, real (realpath-equal) hub root, whose node_modules holds Integrator. */
|
|
58
|
+
readonly hubRoot: string;
|
|
59
|
+
/** The change set's items. */
|
|
60
|
+
readonly items: readonly ChangeSetItem[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Ports. Not reachable from a CLI. */
|
|
64
|
+
export interface ProvenanceGatePorts {
|
|
65
|
+
readonly spawn?: LockfileSpawn;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// The snapshot digest
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The snapshot's digest, as docs/contracts/registry-snapshot.json defines it
|
|
74
|
+
* (in the public repository, not shipped in this package): `sha256:` and the
|
|
75
|
+
* SHA-256 of the canonical JSON of `{registry, packages}`, packages sorted by
|
|
76
|
+
* name, each reduced to name, status, latest and versions, versions sorted by
|
|
77
|
+
* version and kept whole. fetchedAt, fetchedBy, responseSha256, schemaVersion
|
|
78
|
+
* and kind are left out. Throws TypeError for a snapshot that does not validate
|
|
79
|
+
* against the contract: an invalid snapshot has no digest. The gate itself
|
|
80
|
+
* does not read a snapshot; this is exported for the plan binding that a later
|
|
81
|
+
* change wires in.
|
|
82
|
+
*/
|
|
83
|
+
export function registrySnapshotDigest(snapshot: RegistrySnapshot): string {
|
|
84
|
+
if (registrySnapshotViolations(snapshot).length > 0) throw new TypeError("a snapshot that does not validate against the registry snapshot contract has no digest");
|
|
85
|
+
const canonical = canonicalRegistrySnapshot(snapshot);
|
|
86
|
+
return canonicalDigest({
|
|
87
|
+
registry: canonical.registry,
|
|
88
|
+
packages: canonical.packages.map((entry) => ({ name: entry.name, status: entry.status, latest: entry.latest, versions: entry.versions })),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Verdict plumbing
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const indeterminate = (rule: string): ApplyCheck => ({ check: "V9", verdict: "indeterminate", rule });
|
|
97
|
+
const violated = (rule: string): ApplyCheck => ({ check: "V9", verdict: "violated", rule });
|
|
98
|
+
|
|
99
|
+
/** The rules behind a failed V9, one entry per distinct rule, in code-unit order. */
|
|
100
|
+
function distinctRules(verdict: "indeterminate" | "violated", rules: ReadonlySet<string>): ApplyCheck[] {
|
|
101
|
+
return [...rules].sort(compareCodeUnits).map((rule) => (verdict === "indeterminate" ? indeterminate(rule) : violated(rule)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// The gated set
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
interface GatedPackage {
|
|
109
|
+
readonly name: string;
|
|
110
|
+
readonly version: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The packages this set installs or pins that its base does not already hold,
|
|
115
|
+
* by exact name, each once. Only a `satisfiedInBase` of exactly `true` skips an
|
|
116
|
+
* item: a value other than `true` (`false`, missing, `null`, a string or a
|
|
117
|
+
* number) is gated. Undefined when one name is gated at two versions: the set
|
|
118
|
+
* then asks for something no single report can answer.
|
|
119
|
+
*/
|
|
120
|
+
function gatedPackages(items: readonly ChangeSetItem[]): GatedPackage[] | undefined {
|
|
121
|
+
const versions = new Map<string, string>();
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
if ((item.act !== "install" && item.act !== "pin-starter") || item.satisfiedInBase === true) continue;
|
|
124
|
+
const earlier = versions.get(item.package.name);
|
|
125
|
+
if (earlier !== undefined && earlier !== item.package.version) return undefined;
|
|
126
|
+
versions.set(item.package.name, item.package.version);
|
|
127
|
+
}
|
|
128
|
+
return [...versions].map(([name, version]) => ({ name, version }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Roots and the bin
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** An absolute path that is its own realpath and names a directory. */
|
|
136
|
+
async function isCanonicalDirectory(path: string): Promise<boolean> {
|
|
137
|
+
if (typeof path !== "string" || !isAbsolute(path)) return false;
|
|
138
|
+
try {
|
|
139
|
+
if ((await realpath(path)) !== path) return false;
|
|
140
|
+
return (await stat(path)).isDirectory();
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether `child` lies strictly inside `parent`; both are already real paths. */
|
|
147
|
+
function isStrictlyInside(parent: string, child: string): boolean {
|
|
148
|
+
const path = relative(parent, child);
|
|
149
|
+
return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The verified real path of the hub's Integrator bin, or undefined. The
|
|
154
|
+
* `.bin` entry must resolve to a regular file strictly inside the real path of
|
|
155
|
+
* `node_modules/@clossys/integrator`, and that directory's real path must
|
|
156
|
+
* itself lie strictly inside `<hubRoot>/node_modules`, so a link that leaves
|
|
157
|
+
* the hub, leaves node_modules, or points at some other package's file is
|
|
158
|
+
* refused. `hubRoot` is already its own realpath.
|
|
159
|
+
*
|
|
160
|
+
* The caller spawns the returned real path, not the `.bin` entry: the launch
|
|
161
|
+
* then goes to the very file that was checked, and a link swapped in after the
|
|
162
|
+
* check cannot redirect it.
|
|
163
|
+
*/
|
|
164
|
+
async function verifiedBin(hubRoot: string): Promise<string | undefined> {
|
|
165
|
+
const nodeModules = join(hubRoot, "node_modules");
|
|
166
|
+
try {
|
|
167
|
+
const realBin = await realpath(join(nodeModules, ".bin", PROVENANCE_CHECK_BIN));
|
|
168
|
+
const realIntegrator = await realpath(join(nodeModules, "@clossys", "integrator"));
|
|
169
|
+
if (!(await lstat(realBin)).isFile() || !(await lstat(realIntegrator)).isDirectory()) return undefined;
|
|
170
|
+
if (!isStrictlyInside(nodeModules, realIntegrator) || !isStrictlyInside(realIntegrator, realBin)) return undefined;
|
|
171
|
+
return realBin;
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// The report
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
type PackageState = "verified" | "violated" | "indeterminate";
|
|
182
|
+
|
|
183
|
+
interface ReportedPackage {
|
|
184
|
+
readonly name: string;
|
|
185
|
+
readonly installedVersion: string;
|
|
186
|
+
readonly state: PackageState;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface ProvenanceReport {
|
|
190
|
+
readonly state: PackageState;
|
|
191
|
+
readonly packages: readonly ReportedPackage[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const REPORT_KEYS: ReadonlySet<string> = new Set(["state", "registryBaseUrl", "packages"]);
|
|
195
|
+
const PACKAGE_KEYS: ReadonlySet<string> = new Set(["name", "installedVersion", "latestVersion", "currencyDistance", "state", "reasons"]);
|
|
196
|
+
|
|
197
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
198
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
199
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
200
|
+
return prototype === Object.prototype || prototype === null;
|
|
201
|
+
};
|
|
202
|
+
const isState = (value: unknown): value is PackageState => value === "verified" || value === "violated" || value === "indeterminate";
|
|
203
|
+
const isString = (value: unknown): value is string => typeof value === "string";
|
|
204
|
+
|
|
205
|
+
/** Whether `value` is an own-keyed plain object whose keys all come from `allowed`. */
|
|
206
|
+
function hasOnlyKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
|
|
207
|
+
return Object.keys(value).every((key) => allowed.has(key) && Object.hasOwn(value, key));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Reads the bin's stdout as the closed report it prints, or undefined. Output
|
|
212
|
+
* at or past the capture limit is refused as truncated. The text is read with
|
|
213
|
+
* the same strict reader the contracts use, so a repeated key or a byte order
|
|
214
|
+
* mark is refused as well as anything JSON.parse refuses; no key outside the
|
|
215
|
+
* closed sets is accepted, and each field must have the type the bin gives it.
|
|
216
|
+
* The report's reasons are checked for shape and never used.
|
|
217
|
+
*/
|
|
218
|
+
function readReport(stdout: string): ProvenanceReport | undefined {
|
|
219
|
+
if (Buffer.byteLength(stdout, "utf8") >= PROVENANCE_CHECK_MAX_BUFFER) return undefined;
|
|
220
|
+
let value: unknown;
|
|
221
|
+
try {
|
|
222
|
+
value = readContractDocument(Buffer.from(stdout, "utf8"));
|
|
223
|
+
} catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
if (!isPlainObject(value) || !hasOnlyKeys(value, REPORT_KEYS) || Object.keys(value).length !== REPORT_KEYS.size) return undefined;
|
|
227
|
+
const { state, registryBaseUrl, packages } = value;
|
|
228
|
+
if (!isState(state) || registryBaseUrl !== PACKAGE_SCOPE.registry || !Array.isArray(packages)) return undefined;
|
|
229
|
+
const reported: ReportedPackage[] = [];
|
|
230
|
+
for (const entry of packages as unknown[]) {
|
|
231
|
+
if (!isPlainObject(entry) || !hasOnlyKeys(entry, PACKAGE_KEYS)) return undefined;
|
|
232
|
+
const { name, installedVersion, latestVersion, currencyDistance, state: packageState, reasons } = entry;
|
|
233
|
+
if (!isString(name) || !isString(installedVersion) || !isState(packageState)) return undefined;
|
|
234
|
+
if (!Array.isArray(reasons) || !(reasons as unknown[]).every(isString)) return undefined;
|
|
235
|
+
if (Object.hasOwn(entry, "latestVersion") && !isString(latestVersion)) return undefined;
|
|
236
|
+
if (Object.hasOwn(entry, "currencyDistance") && !isString(currencyDistance)) return undefined;
|
|
237
|
+
reported.push({ name, installedVersion, state: packageState });
|
|
238
|
+
}
|
|
239
|
+
return { state, packages: reported };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Whether the report says what its exit code says: exit 0 all verified, exit 1 a violation and nothing indeterminate. */
|
|
243
|
+
function agreesWithExit(status: 0 | 1, report: ProvenanceReport): boolean {
|
|
244
|
+
if (status === 0) return report.state === "verified" && report.packages.length > 0 && report.packages.every((entry) => entry.state === "verified");
|
|
245
|
+
return report.state === "violated" && report.packages.some((entry) => entry.state === "violated") && report.packages.every((entry) => entry.state !== "indeterminate");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The rule an unsuccessful launch maps to, or undefined when the bin ran to exit 0 or 1 with no failure flag. */
|
|
249
|
+
function launchFailure(result: LockfileSpawnResult): string | undefined {
|
|
250
|
+
if (result.failure === "not-found") return "engine-missing-bin";
|
|
251
|
+
if (result.failure === "timeout") return "engine-timeout";
|
|
252
|
+
if (result.failure !== undefined) return "engine-failed";
|
|
253
|
+
if (result.status === 2) return "engine-indeterminate";
|
|
254
|
+
if (result.status !== 0 && result.status !== 1) return "engine-failed";
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// The gate
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* V9 for one change set. Satisfied without touching anything when the set
|
|
264
|
+
* installs or pins nothing its base lacks. Otherwise runs the hub's
|
|
265
|
+
* `integrator-provenance-check` against `tree` and judges each gated package
|
|
266
|
+
* by exact name and exact version: satisfied only when every one is verified
|
|
267
|
+
* at that version, with no exception for an unverified one. Other `@clossys`
|
|
268
|
+
* packages in the report never gate: an unrelated violated legacy pin passes
|
|
269
|
+
* when every gated package is verified. Indeterminate wins over violated; the
|
|
270
|
+
* result lists each distinct rule once, in code-unit order, and never mixes a
|
|
271
|
+
* satisfied entry with a refusal. Never throws for a bad input; everything it
|
|
272
|
+
* cannot rely on is an indeterminate entry. The input carries no ledger or
|
|
273
|
+
* snapshot: nothing here reads one.
|
|
274
|
+
*/
|
|
275
|
+
export async function checkSetProvenance(input: ProvenanceGateInput, ports: ProvenanceGatePorts = {}): Promise<readonly ApplyCheck[]> {
|
|
276
|
+
// 1. The gated set.
|
|
277
|
+
const gated = gatedPackages(input.items);
|
|
278
|
+
if (gated === undefined) return [indeterminate("gated-package-conflict")];
|
|
279
|
+
if (gated.length === 0) return [{ check: "V9", verdict: "satisfied" }];
|
|
280
|
+
|
|
281
|
+
// 2 and 3. Roots, then the bin.
|
|
282
|
+
if (!(await isCanonicalDirectory(input.tree)) || !(await isCanonicalDirectory(input.hubRoot))) return [indeterminate("root-not-canonical")];
|
|
283
|
+
const command = await verifiedBin(input.hubRoot);
|
|
284
|
+
if (command === undefined) return [indeterminate("engine-missing-bin")];
|
|
285
|
+
|
|
286
|
+
// 4. Launch, in a scratch working directory, under the allow-list environment.
|
|
287
|
+
const run = ports.spawn ?? spawnLockfileTool;
|
|
288
|
+
let scratch: LockfileScratch;
|
|
289
|
+
try {
|
|
290
|
+
scratch = await prepareLockfileScratch();
|
|
291
|
+
} catch {
|
|
292
|
+
return [indeterminate("engine-failed")];
|
|
293
|
+
}
|
|
294
|
+
let result: LockfileSpawnResult | undefined;
|
|
295
|
+
try {
|
|
296
|
+
const env = lockfileToolEnv({ tool: "npm", scratch: scratch.path, registry: PACKAGE_SCOPE.registry, corepack: false, parent: process.env });
|
|
297
|
+
// The bin's `#!/usr/bin/env node` must find the node that is running this code.
|
|
298
|
+
env.PATH = `${dirname(process.execPath)}${delimiter}${env.PATH ?? ""}`;
|
|
299
|
+
result = await run({
|
|
300
|
+
command,
|
|
301
|
+
args: ["--cwd", input.tree, "--registry", PACKAGE_SCOPE.registry],
|
|
302
|
+
cwd: scratch.path,
|
|
303
|
+
env,
|
|
304
|
+
timeoutMs: PROVENANCE_CHECK_TIMEOUT_MS,
|
|
305
|
+
maxBuffer: PROVENANCE_CHECK_MAX_BUFFER,
|
|
306
|
+
});
|
|
307
|
+
} catch {
|
|
308
|
+
result = undefined;
|
|
309
|
+
} finally {
|
|
310
|
+
try {
|
|
311
|
+
await scratch.remove();
|
|
312
|
+
} catch {
|
|
313
|
+
// The verdict does not depend on the scratch's removal, and it is under the OS temp root.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (result === undefined) return [indeterminate("engine-failed")];
|
|
317
|
+
const failure = launchFailure(result);
|
|
318
|
+
if (failure !== undefined) return [indeterminate(failure)];
|
|
319
|
+
|
|
320
|
+
// 5 and 6. The report, and whether it agrees with its exit code.
|
|
321
|
+
const report = typeof result.stdout === "string" ? readReport(result.stdout) : undefined;
|
|
322
|
+
if (report === undefined) return [indeterminate("report-unreadable")];
|
|
323
|
+
if (!agreesWithExit(result.status as 0 | 1, report)) return [indeterminate("report-contradicts-exit")];
|
|
324
|
+
|
|
325
|
+
// 7 and 8. Each gated package, by exact name and exact version.
|
|
326
|
+
const indeterminateRules = new Set<string>();
|
|
327
|
+
const violatedRules = new Set<string>();
|
|
328
|
+
for (const pkg of gated) {
|
|
329
|
+
const entries = report.packages.filter((entry) => entry.name === pkg.name);
|
|
330
|
+
if (entries.length === 0) {
|
|
331
|
+
indeterminateRules.add("package-not-in-report");
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (entries.some((entry) => entry.installedVersion !== pkg.version)) {
|
|
335
|
+
violatedRules.add("version-mismatch");
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// The report already agrees with its exit code, so no entry is indeterminate; this is the same refusal kept local.
|
|
339
|
+
if (entries.some((entry) => entry.state === "indeterminate")) {
|
|
340
|
+
indeterminateRules.add("package-indeterminate");
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (entries.every((entry) => entry.state === "verified")) continue;
|
|
344
|
+
// Unverified at the act's own version: violated, with no exception.
|
|
345
|
+
violatedRules.add("provenance-unverified");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 9. Indeterminate wins over violated; a satisfied entry never sits beside a refusal.
|
|
349
|
+
if (indeterminateRules.size > 0) return distinctRules("indeterminate", indeterminateRules);
|
|
350
|
+
if (violatedRules.size > 0) return distinctRules("violated", violatedRules);
|
|
351
|
+
return [{ check: "V9", verdict: "satisfied" }];
|
|
352
|
+
}
|