@intentius/chant 0.100.0 → 0.101.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/dist/build.d.ts +6 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/build-options.d.ts +2 -0
- package/dist/cli/build-options.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +8 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +24 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +26 -5
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lint/config.d.ts +4 -4
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +2 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/serializer.d.ts +8 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/telemetry-attribution.d.ts +77 -0
- package/dist/telemetry-attribution.d.ts.map +1 -0
- package/dist/workspace/agent-cli.d.ts +83 -0
- package/dist/workspace/agent-cli.d.ts.map +1 -0
- package/dist/workspace/changes-cli.d.ts.map +1 -1
- package/dist/workspace/changes.d.ts +8 -1
- package/dist/workspace/changes.d.ts.map +1 -1
- package/dist/workspace/checks/links.d.ts +1 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/live.d.ts +40 -0
- package/dist/workspace/checks/live.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +21 -2
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +63 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +1 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +32 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +138 -3
- package/dist/workspace/export-cli.d.ts +12 -0
- package/dist/workspace/export-cli.d.ts.map +1 -0
- package/dist/workspace/export.d.ts +145 -0
- package/dist/workspace/export.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/import.d.ts +73 -0
- package/dist/workspace/import.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +6 -2
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
- package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-adopt.d.ts +106 -0
- package/dist/workspace/lineage-adopt.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +9 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-cli.d.ts +6 -1
- package/dist/workspace/lineage-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-hash-index.d.ts +110 -0
- package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +10 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +147 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-migrations.d.ts +15 -3
- package/dist/workspace/lineage-migrations.d.ts.map +1 -1
- package/dist/workspace/lineage-provenance.d.ts +18 -0
- package/dist/workspace/lineage-provenance.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +30 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-versions.d.ts +116 -0
- package/dist/workspace/lineage-versions.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +31 -5
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/ls-generated.d.ts +37 -0
- package/dist/workspace/ls-generated.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +4 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/nested-graph.d.ts +56 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -0
- package/dist/workspace/nesting.d.ts +21 -0
- package/dist/workspace/nesting.d.ts.map +1 -0
- package/dist/workspace/pin-cli.d.ts +10 -0
- package/dist/workspace/pin-cli.d.ts.map +1 -0
- package/dist/workspace/pin-integrity.d.ts +51 -0
- package/dist/workspace/pin-integrity.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +7 -11
- package/dist/workspace/record-sessions.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +30 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +5 -2
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +22 -4
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +43 -5
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/returns.d.ts +129 -0
- package/dist/workspace/returns.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/template-manifest.d.ts +11 -3
- package/dist/workspace/template-manifest.d.ts.map +1 -1
- package/dist/workspace/trust/attestor.d.ts +8 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -1
- package/dist/workspace/trust/dsse.d.ts +58 -0
- package/dist/workspace/trust/dsse.d.ts.map +1 -0
- package/dist/workspace/trust/evidence-cli.d.ts +66 -0
- package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
- package/dist/workspace/trust/evidence.d.ts +93 -0
- package/dist/workspace/trust/evidence.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +54 -1
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/provenance.d.ts +21 -1
- package/dist/workspace/trust/provenance.d.ts.map +1 -1
- package/dist/workspace/trust/rotation.d.ts +132 -0
- package/dist/workspace/trust/rotation.d.ts.map +1 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/signers-cli.d.ts +49 -0
- package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +12 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/trust/test-repo.d.ts +13 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -1
- package/dist/workspace/trust/verify.d.ts +15 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +199 -0
- package/dist/workspace/write-scope.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/build.ts +8 -0
- package/src/cli/build-options.ts +4 -1
- package/src/cli/commands/build.ts +2 -0
- package/src/cli/commands/import-live.test.ts +69 -1
- package/src/cli/commands/import.ts +48 -22
- package/src/cli/handlers/fan-out.test.ts +6 -6
- package/src/cli/handlers/fan-out.ts +2 -1
- package/src/cli/handlers/graph.test.ts +42 -0
- package/src/cli/handlers/graph.ts +22 -0
- package/src/cli/handlers/operator.ts +1 -1
- package/src/cli/main.test.ts +32 -0
- package/src/cli/main.ts +103 -6
- package/src/cli/mcp/workspace-tools.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +33 -2
- package/src/cli/registry.ts +22 -0
- package/src/cli/serve-mcp-workspace.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +5 -1
- package/src/components/fan-out-output.test.ts +1 -1
- package/src/components/fan-out.test.ts +1 -1
- package/src/components/promote.test.ts +1 -1
- package/src/config.ts +12 -0
- package/src/content-digest.test.ts +2 -2
- package/src/lexicon.ts +25 -1
- package/src/lifecycle/gate-ledger.test.ts +14 -0
- package/src/lifecycle/gate-ledger.ts +2 -1
- package/src/lifecycle/plan-digest.test.ts +54 -3
- package/src/lifecycle/plan-digest.ts +38 -8
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/propose-upgrade.ts +8 -5
- package/src/op/gate-approval.test.ts +17 -0
- package/src/op/gate.ts +3 -3
- package/src/op/index.ts +1 -1
- package/src/serializer.ts +9 -0
- package/src/telemetry-attribution.test.ts +91 -0
- package/src/telemetry-attribution.ts +145 -0
- package/src/workspace/agent-cli.ts +134 -0
- package/src/workspace/agent.schema.json +356 -0
- package/src/workspace/behold-kinds.test.ts +1 -1
- package/src/workspace/changes-cli.ts +5 -0
- package/src/workspace/changes.schema.json +179 -1
- package/src/workspace/changes.ts +58 -4
- package/src/workspace/check-live.test.ts +192 -0
- package/src/workspace/check.schema.json +64 -0
- package/src/workspace/checks/links.ts +23 -2
- package/src/workspace/checks/live.ts +113 -0
- package/src/workspace/checks.test.ts +2 -2
- package/src/workspace/checks.ts +20 -3
- package/src/workspace/compose-graph.test.ts +47 -0
- package/src/workspace/compose-graph.ts +120 -3
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
- package/src/workspace/declaration.schema.json +138 -3
- package/src/workspace/declaration.ts +106 -1
- package/src/workspace/declared-kinds.test.ts +33 -0
- package/src/workspace/evidence.schema.json +279 -0
- package/src/workspace/export-cli.ts +180 -0
- package/src/workspace/export-import.test.ts +282 -0
- package/src/workspace/export.ts +486 -0
- package/src/workspace/graph-cli.ts +47 -1
- package/src/workspace/graph-contract.test.ts +89 -4
- package/src/workspace/graph.schema.json +206 -1
- package/src/workspace/import.ts +325 -0
- package/src/workspace/kinds.test.ts +5 -5
- package/src/workspace/kinds.ts +25 -3
- package/src/workspace/lineage-adopt-cli.ts +103 -0
- package/src/workspace/lineage-adopt.test.ts +552 -0
- package/src/workspace/lineage-adopt.ts +452 -0
- package/src/workspace/lineage-check.ts +26 -5
- package/src/workspace/lineage-cli.ts +12 -2
- package/src/workspace/lineage-hash-index.ts +305 -0
- package/src/workspace/lineage-init.test.ts +9 -0
- package/src/workspace/lineage-init.ts +29 -9
- package/src/workspace/lineage-lock.ts +54 -0
- package/src/workspace/lineage-migrations.test.ts +27 -0
- package/src/workspace/lineage-migrations.ts +44 -14
- package/src/workspace/lineage-provenance.ts +40 -0
- package/src/workspace/lineage-upgrade-cli.ts +9 -5
- package/src/workspace/lineage-upgrade.test.ts +92 -1
- package/src/workspace/lineage-upgrade.ts +111 -16
- package/src/workspace/lineage-versions.ts +348 -0
- package/src/workspace/links.test.ts +121 -2
- package/src/workspace/links.ts +97 -6
- package/src/workspace/ls-contract.test.ts +84 -1
- package/src/workspace/ls-generated.ts +111 -0
- package/src/workspace/ls.schema.json +19 -0
- package/src/workspace/ls.ts +8 -1
- package/src/workspace/member-commands.ts +3 -1
- package/src/workspace/nested-graph.test.ts +176 -0
- package/src/workspace/nested-graph.ts +169 -0
- package/src/workspace/nesting.ts +37 -0
- package/src/workspace/pin-cli.test.ts +71 -0
- package/src/workspace/pin-cli.ts +57 -0
- package/src/workspace/pin-integrity.test.ts +121 -0
- package/src/workspace/pin-integrity.ts +104 -0
- package/src/workspace/points-write.schema.json +1 -0
- package/src/workspace/read-contract.test.ts +24 -0
- package/src/workspace/reason-codes.test.ts +10 -0
- package/src/workspace/reason-codes.ts +29 -2
- package/src/workspace/record-sessions.ts +12 -14
- package/src/workspace/records-amend.schema.json +4 -0
- package/src/workspace/records-cli.ts +93 -12
- package/src/workspace/records-close.schema.json +6 -2
- package/src/workspace/records-close.ts +10 -2
- package/src/workspace/records-formats.test.ts +13 -5
- package/src/workspace/records-new.schema.json +4 -0
- package/src/workspace/records-review.schema.json +4 -0
- package/src/workspace/records-sessions.test.ts +23 -6
- package/src/workspace/records-write.test.ts +65 -8
- package/src/workspace/records-write.ts +70 -6
- package/src/workspace/records.schema.json +55 -3
- package/src/workspace/records.ts +96 -5
- package/src/workspace/returns.ts +328 -0
- package/src/workspace/signers.schema.json +206 -0
- package/src/workspace/status-gates.ts +2 -1
- package/src/workspace/template-manifest.ts +22 -4
- package/src/workspace/trust/attestor.ts +15 -0
- package/src/workspace/trust/dsse.ts +134 -0
- package/src/workspace/trust/evidence-cli.ts +195 -0
- package/src/workspace/trust/evidence.test.ts +241 -0
- package/src/workspace/trust/evidence.ts +207 -0
- package/src/workspace/trust/policy.ts +110 -3
- package/src/workspace/trust/provenance.ts +41 -4
- package/src/workspace/trust/record-seal.test.ts +1 -1
- package/src/workspace/trust/rotation.test.ts +258 -0
- package/src/workspace/trust/rotation.ts +336 -0
- package/src/workspace/trust/seal.ts +11 -0
- package/src/workspace/trust/signers-cli.ts +178 -0
- package/src/workspace/trust/ssh-commit.ts +53 -4
- package/src/workspace/trust/test-repo.ts +18 -0
- package/src/workspace/trust/trust.test.ts +8 -2
- package/src/workspace/trust/verify-cli.ts +1 -0
- package/src/workspace/trust/verify.ts +22 -0
- package/src/workspace/work-evidence.schema.json +4 -0
- package/src/workspace/write-scope.test.ts +340 -0
- package/src/workspace/write-scope.ts +448 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner evidence (#2553, #2524 D5 "Evidence").
|
|
3
|
+
*
|
|
4
|
+
* A runner, meaning a CI job or a service, states that a check ran over a
|
|
5
|
+
* set of records at one commit. The statement is an in-toto Statement v1:
|
|
6
|
+
* each record is a subject, named by its path and hashed, and the predicate
|
|
7
|
+
* binds the commit, its tree, the check, and the digests of the claim and of
|
|
8
|
+
* the environment when given. The runner signs it in a DSSE envelope with its
|
|
9
|
+
* own Ed25519 key.
|
|
10
|
+
*
|
|
11
|
+
* Verifying needs only the envelope, the repository's objects and the runner
|
|
12
|
+
* keys the policy at base lists, so it works offline. Evidence is never
|
|
13
|
+
* reused on trust: a verdict reports, subject by subject, whether the records
|
|
14
|
+
* still hash the same, and evidence for records that changed is `stale`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { canonicalJson } from "../../effect-receipt";
|
|
21
|
+
import type { ReasonCode } from "../reason-codes";
|
|
22
|
+
import { IN_TOTO_PAYLOAD_TYPE, signEnvelope, verifyEnvelope, type DsseEnvelope } from "./dsse";
|
|
23
|
+
import type { KeyObject } from "node:crypto";
|
|
24
|
+
import type { RunnerKey } from "./policy";
|
|
25
|
+
|
|
26
|
+
export const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
|
|
27
|
+
export const RUNNER_EVIDENCE_PREDICATE = "https://intentius.io/chant/runner-evidence/v1";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why evidence is refused, by `evidence sign` or `evidence verify` (#2553).
|
|
31
|
+
* Each is in `reason-codes.ts` and in `evidence.schema.json`. A sign or verify
|
|
32
|
+
* that can't read the record kind fails with the records read's own code.
|
|
33
|
+
*/
|
|
34
|
+
export const EVIDENCE_ERROR_CODES = [
|
|
35
|
+
"not-a-git-repository",
|
|
36
|
+
"revision-unknown",
|
|
37
|
+
"trust-policy-unreadable",
|
|
38
|
+
"envelope-unreadable",
|
|
39
|
+
"envelope-invalid",
|
|
40
|
+
"envelope-untrusted",
|
|
41
|
+
"evidence-payload-type",
|
|
42
|
+
"evidence-statement-invalid",
|
|
43
|
+
"evidence-runner-mismatch",
|
|
44
|
+
"runner-key-invalid",
|
|
45
|
+
"runner-key-is-signer",
|
|
46
|
+
"runner-key-unlisted",
|
|
47
|
+
"kind-unreadable",
|
|
48
|
+
"kind-invalid",
|
|
49
|
+
"schema-unreadable",
|
|
50
|
+
"schema-id-mismatch",
|
|
51
|
+
"schema-invalid",
|
|
52
|
+
"location-missing",
|
|
53
|
+
] as const satisfies readonly ReasonCode[];
|
|
54
|
+
export type EvidenceErrorCode = (typeof EVIDENCE_ERROR_CODES)[number];
|
|
55
|
+
|
|
56
|
+
const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/);
|
|
57
|
+
const fullCommit = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
|
|
58
|
+
|
|
59
|
+
/** The statement a runner signs. Strict, so a verifier never acts on a field it does not know. */
|
|
60
|
+
export const evidenceStatementSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
_type: z.literal(STATEMENT_TYPE),
|
|
63
|
+
subject: z
|
|
64
|
+
.array(
|
|
65
|
+
z
|
|
66
|
+
.object({
|
|
67
|
+
name: z
|
|
68
|
+
.string()
|
|
69
|
+
.min(1)
|
|
70
|
+
.refine((n) => !n.startsWith("/") && !n.split("/").includes(".."), "a path inside the repository"),
|
|
71
|
+
digest: z.object({ sha256: sha256Hex }).strict(),
|
|
72
|
+
})
|
|
73
|
+
.strict(),
|
|
74
|
+
),
|
|
75
|
+
predicateType: z.literal(RUNNER_EVIDENCE_PREDICATE),
|
|
76
|
+
predicate: z
|
|
77
|
+
.object({
|
|
78
|
+
runner: z.string().min(1),
|
|
79
|
+
commit: fullCommit,
|
|
80
|
+
tree: fullCommit,
|
|
81
|
+
check: z.string().min(1),
|
|
82
|
+
claim: z.object({ sha256: sha256Hex }).strict().optional(),
|
|
83
|
+
environment: z.object({ sha256: sha256Hex }).strict().optional(),
|
|
84
|
+
})
|
|
85
|
+
.strict(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
export type EvidenceStatement = z.infer<typeof evidenceStatementSchema>;
|
|
89
|
+
|
|
90
|
+
/** SHA-256 of a record's text, line endings normalised as seals do (#2524 D4). */
|
|
91
|
+
export function recordHash(text: string): string {
|
|
92
|
+
return createHash("sha256").update(text.replace(/\r\n?/g, "\n"), "utf8").digest("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sha256(bytes: Buffer | string): string {
|
|
96
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function git(repo: string, args: string[]): string {
|
|
100
|
+
return execFileSync("git", args, { cwd: repo, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function blobAt(repo: string, commit: string, path: string): string | undefined {
|
|
104
|
+
try {
|
|
105
|
+
return git(repo, ["cat-file", "blob", `${commit}:${path}`]);
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface EvidenceInput {
|
|
112
|
+
repo: string;
|
|
113
|
+
/** The full commit id the check ran at. Records are hashed as committed there. */
|
|
114
|
+
commit: string;
|
|
115
|
+
paths: string[];
|
|
116
|
+
runner: string;
|
|
117
|
+
check: string;
|
|
118
|
+
claim?: Buffer;
|
|
119
|
+
environment?: Buffer;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildStatement(input: EvidenceInput): EvidenceStatement {
|
|
123
|
+
const subject = [...input.paths].sort().map((name) => {
|
|
124
|
+
const text = blobAt(input.repo, input.commit, name);
|
|
125
|
+
if (text === undefined) throw new Error(`${name} is not in commit ${input.commit.slice(0, 8)}`);
|
|
126
|
+
return { name, digest: { sha256: recordHash(text) } };
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
_type: STATEMENT_TYPE,
|
|
130
|
+
subject,
|
|
131
|
+
predicateType: RUNNER_EVIDENCE_PREDICATE,
|
|
132
|
+
predicate: {
|
|
133
|
+
runner: input.runner,
|
|
134
|
+
commit: input.commit,
|
|
135
|
+
tree: git(input.repo, ["rev-parse", `${input.commit}^{tree}`]).trim(),
|
|
136
|
+
check: input.check,
|
|
137
|
+
...(input.claim ? { claim: { sha256: sha256(input.claim) } } : {}),
|
|
138
|
+
...(input.environment ? { environment: { sha256: sha256(input.environment) } } : {}),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function signEvidence(statement: EvidenceStatement, key: KeyObject, publicKey: string): DsseEnvelope {
|
|
144
|
+
return signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from(canonicalJson(statement), "utf8"), key, publicKey);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface SubjectVerdict {
|
|
148
|
+
name: string;
|
|
149
|
+
signed: string;
|
|
150
|
+
/** The hash at the revision checked, or null when the record is not there. */
|
|
151
|
+
now: string | null;
|
|
152
|
+
matches: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type EvidenceVerdict =
|
|
156
|
+
| {
|
|
157
|
+
ok: true;
|
|
158
|
+
/** `current` when every subject still hashes the same at the revision checked; otherwise `stale`. */
|
|
159
|
+
status: "current" | "stale";
|
|
160
|
+
runner: string;
|
|
161
|
+
class: RunnerKey["class"];
|
|
162
|
+
keyid: string;
|
|
163
|
+
statement: EvidenceStatement;
|
|
164
|
+
subjects: SubjectVerdict[];
|
|
165
|
+
}
|
|
166
|
+
| { ok: false; code: EvidenceErrorCode; reason: string };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Verify an envelope against the runner keys at base, then compare each
|
|
170
|
+
* subject with the record as committed at `at`.
|
|
171
|
+
*/
|
|
172
|
+
export function verifyEvidence(envelope: unknown, runners: readonly RunnerKey[], repo: string, at: string): EvidenceVerdict {
|
|
173
|
+
const v = verifyEnvelope(envelope, runners);
|
|
174
|
+
if (!v.ok) return v;
|
|
175
|
+
if (v.payloadType !== IN_TOTO_PAYLOAD_TYPE) return { ok: false, code: "evidence-payload-type", reason: `payload type ${v.payloadType} is not ${IN_TOTO_PAYLOAD_TYPE}` };
|
|
176
|
+
let raw: unknown;
|
|
177
|
+
try {
|
|
178
|
+
raw = JSON.parse(v.payload.toString("utf8"));
|
|
179
|
+
} catch {
|
|
180
|
+
return { ok: false, code: "evidence-statement-invalid", reason: "the payload is not JSON" };
|
|
181
|
+
}
|
|
182
|
+
const parsed = evidenceStatementSchema.safeParse(raw);
|
|
183
|
+
if (!parsed.success) {
|
|
184
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "/"}: ${i.message}`).join("; ");
|
|
185
|
+
return { ok: false, code: "evidence-statement-invalid", reason: `the payload is not a ${RUNNER_EVIDENCE_PREDICATE} statement: ${detail}` };
|
|
186
|
+
}
|
|
187
|
+
const statement = parsed.data;
|
|
188
|
+
// The runner named inside must be the one whose key signed: a runner cannot speak for another.
|
|
189
|
+
if (statement.predicate.runner !== v.principal) {
|
|
190
|
+
return { ok: false, code: "evidence-runner-mismatch", reason: `the statement names runner ${JSON.stringify(statement.predicate.runner)}, but ${v.principal}'s key signed it` };
|
|
191
|
+
}
|
|
192
|
+
const subjects = statement.subject.map((s) => {
|
|
193
|
+
const text = blobAt(repo, at, s.name);
|
|
194
|
+
const now = text === undefined ? null : recordHash(text);
|
|
195
|
+
return { name: s.name, signed: s.digest.sha256, now, matches: now !== null && now === s.digest.sha256 };
|
|
196
|
+
});
|
|
197
|
+
const runner = runners.find((r) => r.principal === v.principal)!;
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
status: subjects.every((s) => s.matches) ? "current" : "stale",
|
|
201
|
+
runner: v.principal,
|
|
202
|
+
class: runner.class,
|
|
203
|
+
keyid: v.keyid,
|
|
204
|
+
statement,
|
|
205
|
+
subjects,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -219,6 +219,49 @@ export const trustConfigSchema = z
|
|
|
219
219
|
* the commits `from..to`.
|
|
220
220
|
*/
|
|
221
221
|
adopted: z.array(z.object({ from: fullCommit.optional(), to: fullCommit, note: z.string().optional() }).strict()).optional(),
|
|
222
|
+
/**
|
|
223
|
+
* Keys that sign runner evidence (#2553). Each belongs to a service or a CI
|
|
224
|
+
* identity, never to a person: a key or principal the signers file lists
|
|
225
|
+
* is refused here.
|
|
226
|
+
*/
|
|
227
|
+
runners: z
|
|
228
|
+
.array(
|
|
229
|
+
z
|
|
230
|
+
.object({
|
|
231
|
+
principal: z.string().min(1),
|
|
232
|
+
class: z.enum(["runner", "service"]),
|
|
233
|
+
key: z.string().regex(/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/, "an ssh-ed25519 public key, with no comment"),
|
|
234
|
+
})
|
|
235
|
+
.strict(),
|
|
236
|
+
)
|
|
237
|
+
.optional(),
|
|
238
|
+
/**
|
|
239
|
+
* Signers admitted for the work one return brought back (#2552, D10,
|
|
240
|
+
* ws-004). Each entry names the return by its id (the file
|
|
241
|
+
* `.chant/returns/<id>.json` the import wrote) and the keys it admits.
|
|
242
|
+
* An admitted key verifies only the returned commits and seals of that
|
|
243
|
+
* return, never a commit made in this repository.
|
|
244
|
+
*/
|
|
245
|
+
admitted: z
|
|
246
|
+
.array(
|
|
247
|
+
z
|
|
248
|
+
.object({
|
|
249
|
+
return: z.string().regex(/^ret-[0-9a-f]{12}$/, "a return id, ret-<12 hex>"),
|
|
250
|
+
signers: z
|
|
251
|
+
.array(
|
|
252
|
+
z
|
|
253
|
+
.object({
|
|
254
|
+
principal: z.string().min(1),
|
|
255
|
+
key: z.string().regex(/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/]+={0,2}$/, "an ssh public key, with no comment"),
|
|
256
|
+
})
|
|
257
|
+
.strict(),
|
|
258
|
+
)
|
|
259
|
+
.min(1),
|
|
260
|
+
note: z.string().optional(),
|
|
261
|
+
})
|
|
262
|
+
.strict(),
|
|
263
|
+
)
|
|
264
|
+
.optional(),
|
|
222
265
|
})
|
|
223
266
|
.strict();
|
|
224
267
|
|
|
@@ -227,6 +270,14 @@ export type TrustConfig = z.infer<typeof trustConfigSchema>;
|
|
|
227
270
|
/** The role whose holders may change the policy. Without any grant of it, every signer may. */
|
|
228
271
|
export const ADMIN_ROLE = "admin";
|
|
229
272
|
|
|
273
|
+
/** A key that signs runner evidence: a service or CI identity. */
|
|
274
|
+
export interface RunnerKey {
|
|
275
|
+
principal: string;
|
|
276
|
+
class: "runner" | "service";
|
|
277
|
+
/** `ssh-ed25519 <base64>`. */
|
|
278
|
+
key: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
230
281
|
/** The policy a check applies, as read at base. */
|
|
231
282
|
export interface TrustPolicy {
|
|
232
283
|
/** The full commit id the policy was read at, or null when there is no base. */
|
|
@@ -239,13 +290,32 @@ export interface TrustPolicy {
|
|
|
239
290
|
excluded: ExcludedSigner[];
|
|
240
291
|
roles: Record<string, string[]>;
|
|
241
292
|
adopted: Array<{ from?: string; to: string }>;
|
|
293
|
+
/** Runner and service keys that may sign runner evidence (#2553). */
|
|
294
|
+
runners: RunnerKey[];
|
|
295
|
+
/** Runner entries that are not used, and why. */
|
|
296
|
+
excludedRunners: Array<{ principal: string; reason: string }>;
|
|
297
|
+
/** Signers admitted per return (#2552), keyed by return id. They verify that return's commits and seals only. */
|
|
298
|
+
admitted: Record<string, Signer[]>;
|
|
299
|
+
/**
|
|
300
|
+
* Set on a copy of the policy used for one returned record (#2552): the
|
|
301
|
+
* return it came back in. A seal by a principal the policy does not list
|
|
302
|
+
* then reads as unverifiable rather than unlisted, until an admin admits
|
|
303
|
+
* the signer. Never set on the policy read at base.
|
|
304
|
+
*/
|
|
305
|
+
returnedFrom?: string;
|
|
242
306
|
/** Why the policy could not be read, when it could not. Nothing verifies then. */
|
|
243
307
|
problems: string[];
|
|
308
|
+
/**
|
|
309
|
+
* The signer set in effect at a commit's position in the base's history
|
|
310
|
+
* (#2553). Absent, every commit is judged by `signers`.
|
|
311
|
+
*/
|
|
312
|
+
signersAt?: (commit: string) => { version: number; signers: Signer[] } | null;
|
|
244
313
|
}
|
|
245
314
|
|
|
246
|
-
/** Paths whose change is a protected write under `policy
|
|
315
|
+
/** Paths whose change is a protected write under `policy`: the config, the signers file and its rotation file (#2553). */
|
|
247
316
|
export function protectedPaths(policy: TrustPolicy): string[] {
|
|
248
|
-
|
|
317
|
+
const s = policy.signersPath;
|
|
318
|
+
return [...new Set([TRUST_CONFIG_PATH, s, posix.join(posix.dirname(s), `${posix.basename(s)}.rotation.json`)])].sort();
|
|
249
319
|
}
|
|
250
320
|
|
|
251
321
|
/** Principals allowed to change the policy: the admins when any are granted, otherwise every signer. */
|
|
@@ -257,7 +327,7 @@ export function policyWriters(policy: TrustPolicy): Set<string> {
|
|
|
257
327
|
|
|
258
328
|
/** A policy with nothing in it: attestation off. */
|
|
259
329
|
export function emptyPolicy(base: string | null, problems: string[] = []): TrustPolicy {
|
|
260
|
-
return { base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], excluded: [], roles: {}, adopted: [], problems };
|
|
330
|
+
return { base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], excluded: [], roles: {}, adopted: [], runners: [], excludedRunners: [], admitted: {}, problems };
|
|
261
331
|
}
|
|
262
332
|
|
|
263
333
|
/**
|
|
@@ -285,6 +355,22 @@ export function readTrustPolicy(source: RecordSource, base: string | null): Trus
|
|
|
285
355
|
const signersPath = config.signers ?? DEFAULT_SIGNERS_PATH;
|
|
286
356
|
const text = readOptional(source, signersPath);
|
|
287
357
|
const set = text === undefined ? { signers: [], excluded: [] } : parseAllowedSigners(text);
|
|
358
|
+
// A runner key is a machine's, never a person's (#2553).
|
|
359
|
+
const humanKeys = new Set(set.signers.map((s) => s.key));
|
|
360
|
+
const humanNames = new Set(set.signers.map((s) => s.principal));
|
|
361
|
+
const runners: RunnerKey[] = [];
|
|
362
|
+
const excludedRunners: Array<{ principal: string; reason: string }> = [];
|
|
363
|
+
const all = config.runners ?? [];
|
|
364
|
+
for (const r of all) {
|
|
365
|
+
if (humanKeys.has(r.key) || humanNames.has(r.principal)) {
|
|
366
|
+
excludedRunners.push({ principal: r.principal, reason: `${signersPath} lists this ${humanKeys.has(r.key) ? "key" : "principal"}; runner keys belong to a service or CI identity, never to a signer` });
|
|
367
|
+
} else if (all.filter((o) => o.key === r.key || o.principal === r.principal).length > 1) {
|
|
368
|
+
// One key under two names, or one name with two entries, would leave it unclear who signed.
|
|
369
|
+
excludedRunners.push({ principal: r.principal, reason: "another runner entry has the same key or principal; each runner is one principal with one key" });
|
|
370
|
+
} else {
|
|
371
|
+
runners.push(r);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
288
374
|
return {
|
|
289
375
|
base,
|
|
290
376
|
signersPath,
|
|
@@ -293,10 +379,31 @@ export function readTrustPolicy(source: RecordSource, base: string | null): Trus
|
|
|
293
379
|
excluded: set.excluded,
|
|
294
380
|
roles: config.roles ?? {},
|
|
295
381
|
adopted: config.adopted ?? [],
|
|
382
|
+
runners,
|
|
383
|
+
excludedRunners,
|
|
384
|
+
admitted: admittedOf(config),
|
|
296
385
|
problems: [],
|
|
297
386
|
};
|
|
298
387
|
}
|
|
299
388
|
|
|
389
|
+
/** The admitted signers, by return id. Two entries for one return add up. */
|
|
390
|
+
function admittedOf(config: TrustConfig): Record<string, Signer[]> {
|
|
391
|
+
const out: Record<string, Signer[]> = {};
|
|
392
|
+
for (const a of config.admitted ?? []) {
|
|
393
|
+
out[a.return] = [...(out[a.return] ?? []), ...a.signers.map((s) => ({ principal: s.principal, key: s.key, line: 0 }))];
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The policy for work that came back in return `id` (#2552): the signers at
|
|
400
|
+
* base plus the ones admitted for that return, marked with the return so a
|
|
401
|
+
* seal by an unknown signer reads as unverifiable.
|
|
402
|
+
*/
|
|
403
|
+
export function returnedPolicy(policy: TrustPolicy, id: string): TrustPolicy {
|
|
404
|
+
return { ...policy, signers: [...policy.signers, ...(policy.admitted[id] ?? [])], returnedFrom: id };
|
|
405
|
+
}
|
|
406
|
+
|
|
300
407
|
/** A file's text through `source`, or undefined when it is not there. */
|
|
301
408
|
export function readOptional(source: RecordSource, path: string): string | undefined {
|
|
302
409
|
const dir = posix.dirname(path);
|
|
@@ -15,6 +15,7 @@ import { execFileSync } from "node:child_process";
|
|
|
15
15
|
import { gitRevisionSource } from "../record-source";
|
|
16
16
|
import { attestCommit, type CommitAttestor, type ProvenanceLevel } from "./attestor";
|
|
17
17
|
import { emptyPolicy, readTrustPolicy, type TrustPolicy } from "./policy";
|
|
18
|
+
import { SignerPositions, signerHistory } from "./rotation";
|
|
18
19
|
|
|
19
20
|
/** What `records` reports for one record. */
|
|
20
21
|
export interface RecordProvenance {
|
|
@@ -25,6 +26,12 @@ export interface RecordProvenance {
|
|
|
25
26
|
principal?: string;
|
|
26
27
|
key?: string;
|
|
27
28
|
reason: string;
|
|
29
|
+
/**
|
|
30
|
+
* Set when the content came back in a return (#2552): the return's id, the
|
|
31
|
+
* commit in the returned copy that the level judges, and the commit here
|
|
32
|
+
* that holds the content. `commit` above is that commit here.
|
|
33
|
+
*/
|
|
34
|
+
returned?: { id: string; commit?: string; importedIn: string | null };
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
/** Where the base revision came from. */
|
|
@@ -70,10 +77,22 @@ export function resolveBase(repo: string, explicit?: string): ResolvedBase {
|
|
|
70
77
|
return { commit: null, from: null, problem: "no base revision: pass --base <rev> (there is no origin/HEAD, main or master)" };
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The policy at `base`, or an inactive one when there is no base. With a
|
|
82
|
+
* signers file, its history is verified too (#2553): every version must be
|
|
83
|
+
* signed by a threshold of the one before, or nothing verifies.
|
|
84
|
+
*/
|
|
74
85
|
export function policyAtBase(repo: string, base: ResolvedBase): TrustPolicy {
|
|
75
86
|
if (!base.commit) return emptyPolicy(null, base.problem ? [base.problem] : []);
|
|
76
|
-
|
|
87
|
+
const policy = readTrustPolicy(gitRevisionSource(repo, base.commit), base.commit);
|
|
88
|
+
if (!policy.active || policy.problems.length > 0) return policy;
|
|
89
|
+
const history = signerHistory(repo, base.commit, policy.signersPath);
|
|
90
|
+
if (history.broken) {
|
|
91
|
+
const at = history.broken.commit ? ` at ${history.broken.commit.slice(0, 8)}` : "";
|
|
92
|
+
return { ...policy, problems: [`the signer history of ${policy.signersPath} is broken${at}: ${history.broken.reason}`] };
|
|
93
|
+
}
|
|
94
|
+
const positions = new SignerPositions(repo, base.commit, history);
|
|
95
|
+
return { ...policy, signersAt: (commit) => positions.versionFor(commit) };
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
/**
|
|
@@ -126,7 +145,18 @@ export function isAdopted(repo: string, policy: TrustPolicy, commit: string): bo
|
|
|
126
145
|
|
|
127
146
|
/** A commit's provenance level under `policy`. */
|
|
128
147
|
export function commitProvenance(repo: string, policy: TrustPolicy, commit: string, attestors: readonly CommitAttestor[]): RecordProvenance {
|
|
129
|
-
|
|
148
|
+
// Judge the commit by the signer set in effect where it entered the base's history (#2553).
|
|
149
|
+
let judged = policy;
|
|
150
|
+
if (policy.signersAt) {
|
|
151
|
+
const v = policy.signersAt(commit);
|
|
152
|
+
judged = { ...policy, signers: v?.signers ?? [] };
|
|
153
|
+
if (!v || v.version === 0) {
|
|
154
|
+
const none = "no signer set was in effect where this commit entered the base's history";
|
|
155
|
+
if (isAdopted(repo, policy, commit)) return { level: "adopted", commit, reason: `in a commit range the policy at base adopts; ${none}` };
|
|
156
|
+
return { level: "unattested", commit, reason: none };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const a = attestCommit({ repo, policy: judged }, commit, attestors);
|
|
130
160
|
if (a.level === "unattested" && isAdopted(repo, policy, commit)) {
|
|
131
161
|
return { level: "adopted", commit, attestor: a.attestor, reason: `in a commit range the policy at base adopts; ${a.reason}` };
|
|
132
162
|
}
|
|
@@ -147,6 +177,12 @@ export interface ProvenanceQuery {
|
|
|
147
177
|
at: string | null;
|
|
148
178
|
paths: string[];
|
|
149
179
|
attestors: readonly CommitAttestor[];
|
|
180
|
+
/**
|
|
181
|
+
* A second look at each committed path, given the provenance of the commit
|
|
182
|
+
* that holds it here. Returned work (#2552) uses it to judge the commit the
|
|
183
|
+
* content was made in, which a return carries. Undefined keeps the answer.
|
|
184
|
+
*/
|
|
185
|
+
revisit?: (path: string, host: RecordProvenance) => RecordProvenance | undefined;
|
|
150
186
|
}
|
|
151
187
|
|
|
152
188
|
/**
|
|
@@ -175,7 +211,8 @@ export function recordProvenance(q: ProvenanceQuery): Map<string, RecordProvenan
|
|
|
175
211
|
out.set(p, { level: "unattested", commit: null, reason: "no commit holds this file" });
|
|
176
212
|
continue;
|
|
177
213
|
}
|
|
178
|
-
|
|
214
|
+
const host = commitProvenance(q.repo, q.policy, c, q.attestors);
|
|
215
|
+
out.set(p, q.revisit?.(p, host) ?? host);
|
|
179
216
|
}
|
|
180
217
|
return out;
|
|
181
218
|
}
|
|
@@ -221,7 +221,7 @@ describe.skipIf(!hasSshKeygen)("author seals under a signers file at base", () =
|
|
|
221
221
|
const digest = (await record(r)).digest;
|
|
222
222
|
const doc = await amend(r, { state: "superseded" });
|
|
223
223
|
if ("error" in doc) throw new Error(doc.error.message);
|
|
224
|
-
expect(doc.changed).toEqual(["state", "seal"]);
|
|
224
|
+
expect(doc.changed).toEqual(["state", "seal", "closed_digest"]);
|
|
225
225
|
expect(doc.sealDropped).toMatch(/moves its digest or its state/);
|
|
226
226
|
expect((await record(r)).digest).toBe(digest);
|
|
227
227
|
});
|