@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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns: the record an import writes when a copy of the workspace comes
|
|
3
|
+
* back (#2552, #2524 D10, ws-004, ws-073).
|
|
4
|
+
*
|
|
5
|
+
* `chant workspace import` writes `.chant/returns/<id>.json` at the workspace
|
|
6
|
+
* root, beside the lock. It names the export the copy started from, the
|
|
7
|
+
* copy's HEAD when the copy is a git repository of its own, and every path
|
|
8
|
+
* the import wrote or removed. For each written path whose bytes are the
|
|
9
|
+
* copy's own (no host value was switched back into it), it also carries the
|
|
10
|
+
* origin: the raw commit object that last changed the file in the copy, and
|
|
11
|
+
* the raw tree objects from that commit's root tree down to the file. Those
|
|
12
|
+
* objects hash to the ids that name them, so anyone can check offline that
|
|
13
|
+
* the commit, with its original signature, holds exactly the bytes the
|
|
14
|
+
* workspace now has. Nothing is re-signed: the signature is the one the
|
|
15
|
+
* author made where the copy lived.
|
|
16
|
+
*
|
|
17
|
+
* The commit is judged by the attestors of #2547 with the policy at base. A
|
|
18
|
+
* signer the base policy does not list makes it `attested-unverifiable-here`,
|
|
19
|
+
* not `unattested`: there is a signature, and nothing here can say whose key
|
|
20
|
+
* it is. An admin admits the signers by adding them to `.chant/trust.json`
|
|
21
|
+
* under `admitted`, naming the return (`chant workspace admit <id>`). That
|
|
22
|
+
* file is policy, read at base, and changing it is a protected write, so the
|
|
23
|
+
* admission is signed by the admin's own commit. From then on, the returned
|
|
24
|
+
* commits read as `attested`. An admitted key verifies only that return's
|
|
25
|
+
* commits and seals, never a commit made in the workspace's own repository.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { fileHash, LockError } from "./lineage-lock";
|
|
34
|
+
import type { WorkspaceTree } from "./tree";
|
|
35
|
+
import { attestRawCommit, type CommitAttestor } from "./trust/attestor";
|
|
36
|
+
import { returnedPolicy, TRUST_CONFIG_PATH, type TrustPolicy } from "./trust/policy";
|
|
37
|
+
import type { RecordProvenance } from "./trust/provenance";
|
|
38
|
+
import { splitSignedCommit, sshSignatureIntact, sshSignatureKey } from "./trust/ssh-commit";
|
|
39
|
+
|
|
40
|
+
/** Where return records live, relative to the workspace root. */
|
|
41
|
+
export const RETURNS_DIR = ".chant/returns";
|
|
42
|
+
|
|
43
|
+
const Sha256 = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
44
|
+
const CommitId = z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/);
|
|
45
|
+
const Base64 = z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/);
|
|
46
|
+
|
|
47
|
+
const OriginSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
/** The commit in the returned copy that last changed the file. */
|
|
50
|
+
commit: CommitId,
|
|
51
|
+
/** That commit object, raw, in base64: the signed payload and its signature. */
|
|
52
|
+
raw: Base64,
|
|
53
|
+
/** The raw tree objects from the commit's root tree down to the file's directory, in base64. */
|
|
54
|
+
trees: z.array(Base64).min(1),
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
export type ReturnOrigin = z.infer<typeof OriginSchema>;
|
|
58
|
+
|
|
59
|
+
const ReturnSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
schema: z.literal(1),
|
|
62
|
+
id: z.string().regex(/^ret-[0-9a-f]{12}$/),
|
|
63
|
+
/** The export the copy started from (`.chant/export.json`). */
|
|
64
|
+
export: z.object({ id: z.string().min(1), workspace: z.string().min(1), revision: CommitId.nullable() }).strict(),
|
|
65
|
+
/** The copy's HEAD, when it was a git repository of its own. */
|
|
66
|
+
head: CommitId.nullable(),
|
|
67
|
+
/** Paths written, relative to the workspace root, with the bytes' hash and, when it can be proved, the commit they come from. */
|
|
68
|
+
paths: z.record(z.string(), z.object({ sha256: Sha256, origin: OriginSchema.optional() }).strict()),
|
|
69
|
+
/** Paths removed. */
|
|
70
|
+
removed: z.array(z.string()),
|
|
71
|
+
/** Paths whose host-bound values the import switched back: their bytes are not the copy's, so they carry no origin. */
|
|
72
|
+
hostValues: z.array(z.string()),
|
|
73
|
+
/** The keys that signed the origin commits, for an admin to admit. `principal` is the committer's email, a suggestion only. */
|
|
74
|
+
signers: z.array(
|
|
75
|
+
z
|
|
76
|
+
.object({
|
|
77
|
+
key: z.string().min(1),
|
|
78
|
+
fingerprint: z.string(),
|
|
79
|
+
principal: z.string().min(1),
|
|
80
|
+
commits: z.array(CommitId),
|
|
81
|
+
})
|
|
82
|
+
.strict(),
|
|
83
|
+
),
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
export type ReturnRecord = z.infer<typeof ReturnSchema>;
|
|
87
|
+
|
|
88
|
+
// ── Git objects ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** The id git gives an object: the hash of `<type> <length>\0<data>`, SHA-1 or SHA-256 by the id length in use. */
|
|
91
|
+
export function gitObjectId(type: "blob" | "tree" | "commit", data: Buffer, algorithm: "sha1" | "sha256"): string {
|
|
92
|
+
return createHash(algorithm).update(`${type} ${data.length}\0`).update(data).digest("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The entries of a raw tree object: mode, name and id. */
|
|
96
|
+
export function parseTree(raw: Buffer, idBytes: number): { mode: string; name: string; id: string }[] {
|
|
97
|
+
const out: { mode: string; name: string; id: string }[] = [];
|
|
98
|
+
let i = 0;
|
|
99
|
+
while (i < raw.length) {
|
|
100
|
+
const sp = raw.indexOf(0x20, i);
|
|
101
|
+
const nul = raw.indexOf(0, sp);
|
|
102
|
+
if (sp < 0 || nul < 0 || nul + 1 + idBytes > raw.length) throw new Error("malformed tree object");
|
|
103
|
+
out.push({ mode: raw.subarray(i, sp).toString("latin1"), name: raw.subarray(sp + 1, nul).toString("utf-8"), id: raw.subarray(nul + 1, nul + 1 + idBytes).toString("hex") });
|
|
104
|
+
i = nul + 1 + idBytes;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function gitBuf(cwd: string, args: string[]): Buffer {
|
|
110
|
+
return execFileSync("git", args, { cwd, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The origin of `path` (relative to the copy's root) in the copy's own
|
|
115
|
+
* repository at `dir`: the last commit that changed it, and the tree objects
|
|
116
|
+
* down to it. Undefined when the file at that commit is not `content`, such
|
|
117
|
+
* as a file with uncommitted changes.
|
|
118
|
+
*/
|
|
119
|
+
export function buildOrigin(dir: string, path: string, content: Buffer): ReturnOrigin | undefined {
|
|
120
|
+
let commit: string;
|
|
121
|
+
try {
|
|
122
|
+
commit = gitBuf(dir, ["log", "-1", "--format=%H", "HEAD", "--", path]).toString("utf-8").trim();
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (!commit) return undefined;
|
|
127
|
+
const raw = gitBuf(dir, ["cat-file", "commit", commit]);
|
|
128
|
+
const proof: ReturnOrigin = { commit, raw: raw.toString("base64"), trees: [] };
|
|
129
|
+
let tree = /^tree ([0-9a-f]+)$/m.exec(raw.toString("latin1"))?.[1];
|
|
130
|
+
const parts = path.split("/");
|
|
131
|
+
for (let i = 0; i < parts.length && tree; i++) {
|
|
132
|
+
const t = gitBuf(dir, ["cat-file", "tree", tree]);
|
|
133
|
+
proof.trees.push(t.toString("base64"));
|
|
134
|
+
tree = parseTree(t, tree.length / 2).find((e) => e.name === parts[i])?.id;
|
|
135
|
+
}
|
|
136
|
+
return verifyOrigin(proof, path, content).ok ? proof : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Check that `origin`'s objects hash to their ids and lead from the commit to `content` at `path`. */
|
|
140
|
+
export function verifyOrigin(origin: ReturnOrigin, path: string, content: Buffer): { ok: true } | { ok: false; reason: string } {
|
|
141
|
+
const algorithm = origin.commit.length === 64 ? "sha256" : "sha1";
|
|
142
|
+
const raw = Buffer.from(origin.raw, "base64");
|
|
143
|
+
if (gitObjectId("commit", raw, algorithm) !== origin.commit) return { ok: false, reason: `the commit object does not hash to ${origin.commit.slice(0, 12)}` };
|
|
144
|
+
let id = /^tree ([0-9a-f]+)$/m.exec(raw.toString("latin1"))?.[1];
|
|
145
|
+
const parts = path.split("/");
|
|
146
|
+
if (origin.trees.length !== parts.length) return { ok: false, reason: `the proof has ${origin.trees.length} tree(s) for a path ${parts.length} deep` };
|
|
147
|
+
for (let i = 0; i < parts.length; i++) {
|
|
148
|
+
const t = Buffer.from(origin.trees[i], "base64");
|
|
149
|
+
if (!id || gitObjectId("tree", t, algorithm) !== id) return { ok: false, reason: `the tree for ${parts.slice(0, i).join("/") || "the root"} does not hash to its id` };
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = parseTree(t, id.length / 2);
|
|
153
|
+
} catch {
|
|
154
|
+
return { ok: false, reason: "a tree object in the proof is malformed" };
|
|
155
|
+
}
|
|
156
|
+
id = entries.find((e) => e.name === parts[i])?.id;
|
|
157
|
+
}
|
|
158
|
+
if (!id || gitObjectId("blob", content, algorithm) !== id) return { ok: false, reason: `the commit holds other bytes at ${path}` };
|
|
159
|
+
return { ok: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Signers ──────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** The committer's email in a raw commit object. */
|
|
165
|
+
function committerEmail(raw: Buffer): string | undefined {
|
|
166
|
+
return /^committer [^<]*<([^>]+)>/m.exec(raw.toString("utf-8"))?.[1];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function signatureHeader(origin: ReturnOrigin): string {
|
|
170
|
+
return origin.commit.length === 64 ? "gpgsig-sha256" : "gpgsig";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The keys that signed the origins, grouped by key, for an admin to admit. */
|
|
174
|
+
export function originSigners(origins: readonly ReturnOrigin[]): ReturnRecord["signers"] {
|
|
175
|
+
const byKey = new Map<string, ReturnRecord["signers"][number]>();
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
for (const o of origins) {
|
|
178
|
+
if (seen.has(o.commit)) continue;
|
|
179
|
+
seen.add(o.commit);
|
|
180
|
+
const raw = Buffer.from(o.raw, "base64");
|
|
181
|
+
const { payload, signature } = splitSignedCommit(raw, signatureHeader(o));
|
|
182
|
+
if (!signature) continue;
|
|
183
|
+
const key = sshSignatureKey(signature);
|
|
184
|
+
if (!key) continue;
|
|
185
|
+
const entry = byKey.get(key);
|
|
186
|
+
if (entry) {
|
|
187
|
+
entry.commits.push(o.commit);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const fingerprint = sshSignatureIntact(payload, signature, "git");
|
|
191
|
+
if (fingerprint === false) continue;
|
|
192
|
+
byKey.set(key, { key, fingerprint: fingerprint ?? "", principal: committerEmail(raw) ?? "unknown", commits: [o.commit] });
|
|
193
|
+
}
|
|
194
|
+
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Reading and writing ──────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export function parseReturn(text: string, label: string): ReturnRecord {
|
|
200
|
+
let raw: unknown;
|
|
201
|
+
try {
|
|
202
|
+
raw = JSON.parse(text);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw new LockError(`${label} is not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
205
|
+
}
|
|
206
|
+
const parsed = ReturnSchema.safeParse(raw);
|
|
207
|
+
if (!parsed.success) throw new LockError(`${label} is not a return record: ${parsed.error.issues.map((i) => `${i.path.join(".") || "/"}: ${i.message}`).join("; ")}`);
|
|
208
|
+
return parsed.data;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function renderReturn(r: ReturnRecord): string {
|
|
212
|
+
const paths: ReturnRecord["paths"] = {};
|
|
213
|
+
for (const p of Object.keys(r.paths).sort()) paths[p] = r.paths[p];
|
|
214
|
+
return JSON.stringify(ReturnSchema.parse({ ...r, paths, removed: [...r.removed].sort(), hostValues: [...r.hostValues].sort() }), null, 2) + "\n";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function writeReturn(root: string, r: ReturnRecord): string {
|
|
218
|
+
const rel = `${RETURNS_DIR}/${r.id}.json`;
|
|
219
|
+
mkdirSync(join(root, RETURNS_DIR), { recursive: true });
|
|
220
|
+
writeFileSync(join(root, rel), renderReturn(r));
|
|
221
|
+
return rel;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The return records in `tree` (the workspace root's), sorted by id. Unreadable ones are skipped and named in `problems`. */
|
|
225
|
+
export function readReturns(tree: WorkspaceTree): { returns: ReturnRecord[]; problems: string[] } {
|
|
226
|
+
const returns: ReturnRecord[] = [];
|
|
227
|
+
const problems: string[] = [];
|
|
228
|
+
for (const e of (tree.list(RETURNS_DIR) ?? []).filter((x) => x.type === "file" && x.name.endsWith(".json")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
229
|
+
const path = `${RETURNS_DIR}/${e.name}`;
|
|
230
|
+
try {
|
|
231
|
+
const r = parseReturn(tree.read(path), path);
|
|
232
|
+
if (`${r.id}.json` !== e.name) throw new LockError(`${path} holds return ${r.id}`);
|
|
233
|
+
returns.push(r);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (!(err instanceof LockError)) throw err;
|
|
236
|
+
problems.push(err.message);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { returns, problems };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Provenance ───────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The provenance of a returned file: its origin commit judged by the
|
|
246
|
+
* attestors with the policy at base plus the signers admitted for the
|
|
247
|
+
* return. Undefined when the return holds no origin for the path, or the
|
|
248
|
+
* workspace's bytes are not the ones returned, so the commit that holds the
|
|
249
|
+
* file here is what counts.
|
|
250
|
+
*/
|
|
251
|
+
export function returnedProvenance(
|
|
252
|
+
policy: TrustPolicy,
|
|
253
|
+
attestors: readonly CommitAttestor[],
|
|
254
|
+
ret: ReturnRecord,
|
|
255
|
+
path: string,
|
|
256
|
+
content: Buffer,
|
|
257
|
+
host: RecordProvenance,
|
|
258
|
+
): RecordProvenance | undefined {
|
|
259
|
+
const entry = ret.paths[path];
|
|
260
|
+
if (!entry || entry.sha256 !== fileHash(content)) return undefined;
|
|
261
|
+
const returned = { id: ret.id, importedIn: host.commit };
|
|
262
|
+
if (!entry.origin) return undefined;
|
|
263
|
+
const proof = verifyOrigin(entry.origin, path, content);
|
|
264
|
+
if (!proof.ok) {
|
|
265
|
+
return { level: "unattested", commit: host.commit, reason: `returned in ${ret.id}, and its origin does not check: ${proof.reason}`, returned: { ...returned, commit: entry.origin.commit } };
|
|
266
|
+
}
|
|
267
|
+
const raw = Buffer.from(entry.origin.raw, "base64");
|
|
268
|
+
const judged = returnedPolicy(policy, ret.id);
|
|
269
|
+
const a = attestRawCommit({ repo: "", policy: judged }, raw, attestors);
|
|
270
|
+
const at = `returned in ${ret.id} from commit ${entry.origin.commit.slice(0, 12)}`;
|
|
271
|
+
const base = { commit: host.commit, attestor: a.attestor, returned: { ...returned, commit: entry.origin.commit } };
|
|
272
|
+
if (a.level === "attested") {
|
|
273
|
+
const admitted = (policy.admitted[ret.id] ?? []).some((s) => s.principal === a.principal) && !policy.signers.some((s) => s.principal === a.principal);
|
|
274
|
+
return { ...base, level: "attested", ...(a.principal ? { principal: a.principal } : {}), ...(a.key ? { key: a.key } : {}), reason: `${at}, ${a.reason}${admitted ? `, a signer admitted for ${ret.id} at base` : ""}` };
|
|
275
|
+
}
|
|
276
|
+
if (a.level === "unattested") {
|
|
277
|
+
const { payload, signature } = splitSignedCommit(raw, signatureHeader(entry.origin));
|
|
278
|
+
const intact = signature?.startsWith("-----BEGIN SSH SIGNATURE-----") ? sshSignatureIntact(payload, signature, "git") : false;
|
|
279
|
+
if (intact) {
|
|
280
|
+
return {
|
|
281
|
+
...base,
|
|
282
|
+
level: "attested-unverifiable-here",
|
|
283
|
+
key: intact,
|
|
284
|
+
reason: `${at}, signed with ${intact}, a key neither the signers nor an admission at base lists; it reads as attested once an admin admits the signer (chant workspace admit ${ret.id})`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { ...base, level: a.level, ...(a.key ? { key: a.key } : {}), reason: `${at}: ${a.reason}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Admission ────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
export interface AdmitResult {
|
|
294
|
+
id: string;
|
|
295
|
+
/** Repository-relative path of the trust config. */
|
|
296
|
+
path: string;
|
|
297
|
+
/** The signers added. Empty when every one was already admitted. */
|
|
298
|
+
added: { principal: string; key: string }[];
|
|
299
|
+
written: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Add the signers of return `ret` to the `admitted` list of
|
|
304
|
+
* `.chant/trust.json` at the repository root `repo`, keeping everything else
|
|
305
|
+
* in it. The admission counts once an admin's signed commit merges it: the
|
|
306
|
+
* file is policy, read at base, and a protected write.
|
|
307
|
+
*/
|
|
308
|
+
export function admitReturn(repo: string, ret: ReturnRecord, opts: { note?: string; dryRun?: boolean } = {}): AdmitResult {
|
|
309
|
+
if (ret.signers.length === 0) throw new LockError(`return ${ret.id} names no signer: none of its origin commits carries an ssh signature`);
|
|
310
|
+
const abs = join(repo, TRUST_CONFIG_PATH);
|
|
311
|
+
let config: Record<string, unknown> = { schema: 1 };
|
|
312
|
+
if (existsSync(abs)) {
|
|
313
|
+
try {
|
|
314
|
+
config = JSON.parse(readFileSync(abs, "utf-8")) as Record<string, unknown>;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new LockError(`${TRUST_CONFIG_PATH} is not valid JSON, so the admission cannot be added to it: ${err instanceof Error ? err.message : String(err)}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const admitted = Array.isArray(config.admitted) ? (config.admitted as Array<{ return?: unknown; signers?: Array<{ key?: unknown }> }>) : [];
|
|
320
|
+
const have = new Set(admitted.filter((a) => a.return === ret.id).flatMap((a) => (a.signers ?? []).map((s) => s.key)));
|
|
321
|
+
const added = ret.signers.filter((s) => !have.has(s.key)).map((s) => ({ principal: s.principal, key: s.key }));
|
|
322
|
+
if (added.length > 0 && !opts.dryRun) {
|
|
323
|
+
config.admitted = [...admitted, { return: ret.id, signers: added, ...(opts.note ? { note: opts.note } : {}) }];
|
|
324
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
325
|
+
writeFileSync(abs, JSON.stringify(config, null, 2) + "\n");
|
|
326
|
+
}
|
|
327
|
+
return { id: ret.id, path: TRUST_CONFIG_PATH, added, written: added.length > 0 && !opts.dryRun };
|
|
328
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://intentius.io/chant/schemas/workspace/signers/v1/signers.schema.json",
|
|
4
|
+
"title": "chant workspace signers output",
|
|
5
|
+
"description": "What `chant workspace signers --json` prints (#2553): the signer set's versions along the base revision's first-parent line, oldest first, each signed by a threshold of the one before, and where the chain breaks when it does. A commit is judged by the version in effect where it entered that line. Version 1 of the read contract. Readers ignore fields they do not know; a field is only ever added within a version. The codes are a closed list, each in the one closed list of `reason-codes.ts`.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{
|
|
8
|
+
"$ref": "#/$defs/result"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"$ref": "#/$defs/failure"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"$defs": {
|
|
15
|
+
"result": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"description": "The history was read. It exits 1 when `broken` is not null.",
|
|
18
|
+
"required": [
|
|
19
|
+
"$schema",
|
|
20
|
+
"contract",
|
|
21
|
+
"chant",
|
|
22
|
+
"base",
|
|
23
|
+
"signersPath",
|
|
24
|
+
"versions",
|
|
25
|
+
"broken"
|
|
26
|
+
],
|
|
27
|
+
"properties": {
|
|
28
|
+
"$schema": {
|
|
29
|
+
"const": "https://intentius.io/chant/schemas/workspace/signers/v1/signers.schema.json"
|
|
30
|
+
},
|
|
31
|
+
"contract": {
|
|
32
|
+
"const": 1
|
|
33
|
+
},
|
|
34
|
+
"chant": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "The chant version that printed the document."
|
|
37
|
+
},
|
|
38
|
+
"base": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$",
|
|
41
|
+
"description": "The full commit id of the base revision the history is read along."
|
|
42
|
+
},
|
|
43
|
+
"signersPath": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "The signers file, from the repository root. Its rotation file is `<signersPath>.rotation.json`."
|
|
46
|
+
},
|
|
47
|
+
"versions": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"items": {
|
|
50
|
+
"$ref": "#/$defs/version"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"broken": {
|
|
54
|
+
"oneOf": [
|
|
55
|
+
{
|
|
56
|
+
"type": "null"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"$ref": "#/$defs/broken"
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"version": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"description": "One version of the signer set, made by one first-parent commit.",
|
|
68
|
+
"required": [
|
|
69
|
+
"version",
|
|
70
|
+
"commit",
|
|
71
|
+
"digest",
|
|
72
|
+
"threshold",
|
|
73
|
+
"principals",
|
|
74
|
+
"signedBy"
|
|
75
|
+
],
|
|
76
|
+
"properties": {
|
|
77
|
+
"version": {
|
|
78
|
+
"type": "integer",
|
|
79
|
+
"minimum": 0,
|
|
80
|
+
"description": "1 and up. 0 marks the commit that removed the signers file; nothing is in effect after it."
|
|
81
|
+
},
|
|
82
|
+
"commit": {
|
|
83
|
+
"oneOf": [
|
|
84
|
+
{
|
|
85
|
+
"type": "null"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"type": "string",
|
|
89
|
+
"pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$"
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"description": "The first-parent commit that made this version."
|
|
93
|
+
},
|
|
94
|
+
"digest": {
|
|
95
|
+
"oneOf": [
|
|
96
|
+
{
|
|
97
|
+
"type": "null"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"type": "string",
|
|
101
|
+
"pattern": "^sha256:[0-9a-f]{64}$"
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
"description": "sha256 of the signers file, line endings normalised. Null for version 0."
|
|
105
|
+
},
|
|
106
|
+
"threshold": {
|
|
107
|
+
"type": "integer",
|
|
108
|
+
"minimum": 0,
|
|
109
|
+
"description": "How many distinct signers of this version must sign the next one."
|
|
110
|
+
},
|
|
111
|
+
"principals": {
|
|
112
|
+
"type": "array",
|
|
113
|
+
"items": {
|
|
114
|
+
"type": "string"
|
|
115
|
+
},
|
|
116
|
+
"description": "The principals the version lists, sorted."
|
|
117
|
+
},
|
|
118
|
+
"signedBy": {
|
|
119
|
+
"type": "array",
|
|
120
|
+
"items": {
|
|
121
|
+
"type": "string"
|
|
122
|
+
},
|
|
123
|
+
"description": "The principals of the version before whose signatures counted. Empty for the first version."
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"broken": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"description": "The version that is not a valid rotation of the one before. Nothing verifies against this base.",
|
|
130
|
+
"required": [
|
|
131
|
+
"commit",
|
|
132
|
+
"code",
|
|
133
|
+
"message"
|
|
134
|
+
],
|
|
135
|
+
"properties": {
|
|
136
|
+
"commit": {
|
|
137
|
+
"oneOf": [
|
|
138
|
+
{
|
|
139
|
+
"type": "null"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"type": "string",
|
|
143
|
+
"pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$"
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"code": {
|
|
148
|
+
"enum": [
|
|
149
|
+
"signers-removed",
|
|
150
|
+
"rotation-unparseable",
|
|
151
|
+
"rotation-invalid",
|
|
152
|
+
"rotation-first-version",
|
|
153
|
+
"rotation-missing",
|
|
154
|
+
"rotation-version-skew",
|
|
155
|
+
"rotation-previous-mismatch",
|
|
156
|
+
"rotation-threshold-unsatisfiable",
|
|
157
|
+
"rotation-threshold-not-met"
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
"message": {
|
|
161
|
+
"type": "string"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"failure": {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"description": "No history could be read. Exits 1.",
|
|
168
|
+
"required": [
|
|
169
|
+
"$schema",
|
|
170
|
+
"contract",
|
|
171
|
+
"chant",
|
|
172
|
+
"error"
|
|
173
|
+
],
|
|
174
|
+
"properties": {
|
|
175
|
+
"$schema": {
|
|
176
|
+
"const": "https://intentius.io/chant/schemas/workspace/signers/v1/signers.schema.json"
|
|
177
|
+
},
|
|
178
|
+
"contract": {
|
|
179
|
+
"const": 1
|
|
180
|
+
},
|
|
181
|
+
"chant": {
|
|
182
|
+
"type": "string"
|
|
183
|
+
},
|
|
184
|
+
"error": {
|
|
185
|
+
"type": "object",
|
|
186
|
+
"required": [
|
|
187
|
+
"code",
|
|
188
|
+
"message"
|
|
189
|
+
],
|
|
190
|
+
"properties": {
|
|
191
|
+
"code": {
|
|
192
|
+
"enum": [
|
|
193
|
+
"not-a-git-repository",
|
|
194
|
+
"revision-unknown",
|
|
195
|
+
"signers-file-missing"
|
|
196
|
+
]
|
|
197
|
+
},
|
|
198
|
+
"message": {
|
|
199
|
+
"type": "string"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { samePlanDigest } from "../lifecycle/plan-digest";
|
|
22
23
|
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
23
24
|
import { isPendingGateExpired, latestPendingGate, latestResolutionForPlan, parseGateLedger } from "../lifecycle/gate-ledger";
|
|
24
25
|
import { tallyGateApprovals } from "../op/gate";
|
|
@@ -138,7 +139,7 @@ function decide(component: string, standing: PendingGateRecord, resolutions: Gat
|
|
|
138
139
|
} else {
|
|
139
140
|
const at = (r: GateResolutionRecord) => new Date(r.timestamp).getTime();
|
|
140
141
|
approvals = own
|
|
141
|
-
.filter((r) => r.gate === gate && at(r) >= new Date(since).getTime() && (planDigest === undefined || r.planDigest
|
|
142
|
+
.filter((r) => r.gate === gate && at(r) >= new Date(since).getTime() && (planDigest === undefined || samePlanDigest(r.planDigest, planDigest)))
|
|
142
143
|
.sort((a, b) => at(a) - at(b));
|
|
143
144
|
approved = approvals.length > 0;
|
|
144
145
|
mismatched = latestResolutionForPlan(own, gate, since, planDigest).mismatched;
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
* the merge base and the target version, so an unedited file carrying a value
|
|
27
27
|
* still has its recorded hash.
|
|
28
28
|
*
|
|
29
|
-
* `hostBound`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* A parameter marked `hostBound` holds a value that differs per host, such
|
|
30
|
+
* as the domain a hosted service serves the app on. Its value is recorded in
|
|
31
|
+
* `parameters` like any other, and the lineage also records, under
|
|
32
|
+
* `hostBound`, the files whose template text carries it, so `chant workspace
|
|
33
|
+
* export` can switch the value for the export and `import` can switch it
|
|
34
|
+
* back without fetching the template (#2552).
|
|
32
35
|
*/
|
|
33
36
|
|
|
34
37
|
import { posix } from "node:path";
|
|
@@ -54,7 +57,7 @@ const ParameterSchema = z
|
|
|
54
57
|
default: z.string().optional(),
|
|
55
58
|
/** A regular expression every value must match, checked before substitution. */
|
|
56
59
|
pattern: z.string().optional(),
|
|
57
|
-
/** The value differs per host (#2524 D9)
|
|
60
|
+
/** The value differs per host (#2524 D9): export and import switch it (#2552). */
|
|
58
61
|
hostBound: z.boolean().optional(),
|
|
59
62
|
description: z.string().optional(),
|
|
60
63
|
})
|
|
@@ -219,3 +222,18 @@ export function carryParameters(manifest: TemplateManifest | null, recorded: Rec
|
|
|
219
222
|
}
|
|
220
223
|
return resolveParameters(manifest, given);
|
|
221
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The host-bound parameters of a template (#2552), each with the listed files
|
|
228
|
+
* whose text carries its placeholder. Undefined when the template declares none.
|
|
229
|
+
*/
|
|
230
|
+
export function hostBoundFiles(files: Map<string, Buffer>, manifest: TemplateManifest | null): Record<string, string[]> | undefined {
|
|
231
|
+
if (!manifest) return undefined;
|
|
232
|
+
const names = Object.keys(manifest.parameters).filter((n) => manifest.parameters[n].hostBound === true).sort();
|
|
233
|
+
if (names.length === 0) return undefined;
|
|
234
|
+
const out: Record<string, string[]> = {};
|
|
235
|
+
for (const name of names) {
|
|
236
|
+
out[name] = manifest.files.filter((f) => files.get(f)?.toString("utf-8").includes(placeholder(name))).sort();
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
@@ -57,6 +57,12 @@ export interface CommitAttestor {
|
|
|
57
57
|
/** A stable name, such as `ssh-commit`. */
|
|
58
58
|
readonly name: string;
|
|
59
59
|
attestCommit(ctx: AttestorContext, commit: string): CommitAttestation;
|
|
60
|
+
/**
|
|
61
|
+
* Judge a commit object that is not in this repository, given its raw bytes
|
|
62
|
+
* (`git cat-file commit` output), such as one a return carries (#2552).
|
|
63
|
+
* Optional: an attestor without it is not asked.
|
|
64
|
+
*/
|
|
65
|
+
attestRaw?(ctx: AttestorContext, raw: Buffer): CommitAttestation;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
const registered: CommitAttestor[] = [];
|
|
@@ -86,6 +92,15 @@ export function attestCommit(ctx: AttestorContext, commit: string, attestors: re
|
|
|
86
92
|
return best ?? { level: "unattested", attestor: "none", reason: "no attestor is registered" };
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
/** {@link attestCommit} for a raw commit object, asking only the attestors that judge one. */
|
|
96
|
+
export function attestRawCommit(ctx: AttestorContext, raw: Buffer, attestors: readonly CommitAttestor[]): CommitAttestation {
|
|
97
|
+
return attestCommit(
|
|
98
|
+
ctx,
|
|
99
|
+
"",
|
|
100
|
+
attestors.filter((a) => a.attestRaw).map((a) => ({ name: a.name, attestCommit: (c: AttestorContext) => a.attestRaw!(c, raw) })),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
/** The attestors in use: the built-in ones first, then any registered. */
|
|
90
105
|
export async function activeAttestors(): Promise<CommitAttestor[]> {
|
|
91
106
|
const { sshCommitAttestor } = await import("./ssh-commit");
|