@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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace signers [rotate [--threshold <n>] | sign --key <file>]`
|
|
3
|
+
* (#2553): show the signer history, and propose a new signer set.
|
|
4
|
+
*
|
|
5
|
+
* Rotating is three steps: edit the signers file, run `rotate` to write the
|
|
6
|
+
* rotation file for the next version, and have a threshold of the current
|
|
7
|
+
* signers each run `sign` with their own key. `chant workspace verify` then
|
|
8
|
+
* checks the result against the set at base.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { formatError, formatSuccess } from "../../cli/format";
|
|
15
|
+
import type { CommandContext } from "../../cli/registry";
|
|
16
|
+
import { readerVersion } from "../declaration";
|
|
17
|
+
import type { ReasonCode } from "../reason-codes";
|
|
18
|
+
import { gitRoot } from "../record-source";
|
|
19
|
+
import { parseAllowedSigners, readTrustPolicy } from "./policy";
|
|
20
|
+
import { resolveBase } from "./provenance";
|
|
21
|
+
import { gitRevisionSource } from "../record-source";
|
|
22
|
+
import {
|
|
23
|
+
distinctHolders,
|
|
24
|
+
rotationFileSchema,
|
|
25
|
+
rotationPath,
|
|
26
|
+
rotationStatement,
|
|
27
|
+
ROTATION_NAMESPACE,
|
|
28
|
+
signerHistory,
|
|
29
|
+
signersDigest,
|
|
30
|
+
type RotationFile,
|
|
31
|
+
type RotationRefusalCode,
|
|
32
|
+
type SignerHistory,
|
|
33
|
+
} from "./rotation";
|
|
34
|
+
import { verifySshSignature } from "./ssh-commit";
|
|
35
|
+
|
|
36
|
+
const USAGE = "chant workspace signers [--base <rev>] [--json] | signers rotate [--threshold <n>] | signers sign --key <ssh private key>";
|
|
37
|
+
|
|
38
|
+
export const SIGNERS_CONTRACT_VERSION = 1;
|
|
39
|
+
export const SIGNERS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/signers/v1/signers.schema.json";
|
|
40
|
+
|
|
41
|
+
/** Why `chant workspace signers` prints no history. Each is in `reason-codes.ts` and `signers.schema.json`. */
|
|
42
|
+
export const SIGNERS_ERROR_CODES = ["not-a-git-repository", "revision-unknown", "signers-file-missing"] as const satisfies readonly ReasonCode[];
|
|
43
|
+
export type SignersErrorCode = (typeof SIGNERS_ERROR_CODES)[number];
|
|
44
|
+
|
|
45
|
+
/** What `chant workspace signers --json` prints. */
|
|
46
|
+
export type SignersDocument =
|
|
47
|
+
| {
|
|
48
|
+
$schema: string;
|
|
49
|
+
contract: number;
|
|
50
|
+
chant: string;
|
|
51
|
+
base: string;
|
|
52
|
+
signersPath: string;
|
|
53
|
+
versions: Array<{ version: number; commit: string | null; digest: string | null; threshold: number; principals: string[]; signedBy: string[] }>;
|
|
54
|
+
broken: { commit: string | null; code: RotationRefusalCode; message: string } | null;
|
|
55
|
+
}
|
|
56
|
+
| { $schema: string; contract: number; chant: string; error: { code: SignersErrorCode; message: string } };
|
|
57
|
+
|
|
58
|
+
function head() {
|
|
59
|
+
return { $schema: SIGNERS_OUTPUT_SCHEMA_ID, contract: SIGNERS_CONTRACT_VERSION, chant: readerVersion() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The signer history at `base` as the `--json` document. */
|
|
63
|
+
export function signersDocument(repo: string, base: string, signersPath: string, history: SignerHistory): SignersDocument {
|
|
64
|
+
return {
|
|
65
|
+
...head(),
|
|
66
|
+
base,
|
|
67
|
+
signersPath,
|
|
68
|
+
versions: history.versions.map((v) => ({
|
|
69
|
+
version: v.version,
|
|
70
|
+
commit: v.commit,
|
|
71
|
+
digest: v.version === 0 ? null : v.digest,
|
|
72
|
+
threshold: v.threshold,
|
|
73
|
+
principals: [...new Set(v.signers.map((s) => s.principal))].sort(),
|
|
74
|
+
signedBy: v.signedBy,
|
|
75
|
+
})),
|
|
76
|
+
broken: history.broken ? { commit: history.broken.commit, code: history.broken.code, message: history.broken.reason } : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fail(message: string): number {
|
|
81
|
+
console.error(formatError({ message, hint: USAGE }));
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function runWorkspaceSigners(ctx: CommandContext): Promise<number> {
|
|
86
|
+
const { args } = ctx;
|
|
87
|
+
const sub = args.extraPositional;
|
|
88
|
+
const refuse = (code: SignersErrorCode, message: string): number => {
|
|
89
|
+
if (args.json && !sub) console.log(JSON.stringify({ ...head(), error: { code, message } }, null, 2));
|
|
90
|
+
else console.error(formatError({ message, hint: USAGE }));
|
|
91
|
+
return 1;
|
|
92
|
+
};
|
|
93
|
+
const repo = gitRoot(process.cwd());
|
|
94
|
+
if (!repo) return refuse("not-a-git-repository", "signer history is read from git, and this directory is not in a git repository");
|
|
95
|
+
const base = resolveBase(repo, args.base);
|
|
96
|
+
if (!base.commit) return refuse("revision-unknown", base.problem ?? "no base revision");
|
|
97
|
+
// The policy as written at base; its history is checked below, not taken on trust.
|
|
98
|
+
const policy = readTrustPolicy(gitRevisionSource(repo, base.commit), base.commit);
|
|
99
|
+
if (!policy.active) return refuse("signers-file-missing", `there is no signers file (${policy.signersPath}) at base ${base.commit.slice(0, 8)}`);
|
|
100
|
+
const history = signerHistory(repo, base.commit, policy.signersPath);
|
|
101
|
+
const latest = history.versions.at(-1);
|
|
102
|
+
|
|
103
|
+
if (!sub) {
|
|
104
|
+
if (args.json) {
|
|
105
|
+
console.log(JSON.stringify(signersDocument(repo, base.commit, policy.signersPath, history), null, 2));
|
|
106
|
+
return history.broken ? 1 : 0;
|
|
107
|
+
}
|
|
108
|
+
for (const v of history.versions) {
|
|
109
|
+
if (v.version === 0) {
|
|
110
|
+
console.log(` ${v.commit?.slice(0, 8)} signers file removed`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const by = v.signedBy.length ? `, signed by ${v.signedBy.join(", ")}` : ", the first version";
|
|
114
|
+
console.log(` ${v.commit?.slice(0, 8)} version ${v.version}: ${new Set(v.signers.map((s) => s.principal)).size} signers, threshold ${v.threshold}${by}`);
|
|
115
|
+
}
|
|
116
|
+
if (history.broken) return fail(`the history breaks at ${history.broken.commit?.slice(0, 8)} (${history.broken.code}): ${history.broken.reason}`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (history.broken || !latest || latest.version === 0) return fail("the signer history at base is not valid, so there is no version to rotate from");
|
|
120
|
+
|
|
121
|
+
const signersFile = join(repo, policy.signersPath);
|
|
122
|
+
const rotationFile = join(repo, rotationPath(policy.signersPath));
|
|
123
|
+
const text = readFileSync(signersFile, "utf-8");
|
|
124
|
+
|
|
125
|
+
if (sub === "rotate") {
|
|
126
|
+
const threshold = args.threshold !== undefined ? Number(args.threshold) : latest.threshold;
|
|
127
|
+
if (!Number.isInteger(threshold) || threshold < 1) return fail("--threshold takes a whole number of at least 1");
|
|
128
|
+
const holders = distinctHolders(parseAllowedSigners(text).signers);
|
|
129
|
+
if (threshold > holders) return fail(`--threshold ${threshold} is more than the ${holders} distinct signers in the new set`);
|
|
130
|
+
const rotation: RotationFile = { schema: 1, version: latest.version + 1, previous: latest.digest, threshold, signatures: [] };
|
|
131
|
+
writeFileSync(rotationFile, JSON.stringify(rotation, null, 2) + "\n");
|
|
132
|
+
console.log(
|
|
133
|
+
formatSuccess(
|
|
134
|
+
`wrote ${rotationPath(policy.signersPath)} for version ${rotation.version}; ${latest.threshold} of the version ${latest.version} signers must now run chant workspace signers sign --key <their key>`,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (sub === "sign") {
|
|
141
|
+
if (!args.key) return fail("--key <ssh private key> is required");
|
|
142
|
+
if (!existsSync(rotationFile)) return fail(`there is no ${rotationPath(policy.signersPath)}; run chant workspace signers rotate first`);
|
|
143
|
+
let raw: unknown;
|
|
144
|
+
try {
|
|
145
|
+
raw = JSON.parse(readFileSync(rotationFile, "utf-8"));
|
|
146
|
+
} catch {
|
|
147
|
+
return fail(`${rotationPath(policy.signersPath)} is not JSON`);
|
|
148
|
+
}
|
|
149
|
+
const parsed = rotationFileSchema.safeParse(raw);
|
|
150
|
+
if (!parsed.success) return fail(`${rotationPath(policy.signersPath)} is invalid`);
|
|
151
|
+
if (parsed.data.version !== latest.version + 1 || parsed.data.previous !== latest.digest) {
|
|
152
|
+
return fail(`${rotationPath(policy.signersPath)} is not the next version after version ${latest.version} at base; run chant workspace signers rotate again`);
|
|
153
|
+
}
|
|
154
|
+
const rotation = parsed.data;
|
|
155
|
+
const statement = rotationStatement(rotation, signersDigest(text));
|
|
156
|
+
let pub: string;
|
|
157
|
+
try {
|
|
158
|
+
pub = execFileSync("ssh-keygen", ["-y", "-f", args.key], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim().split(/\s+/).slice(0, 2).join(" ");
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return fail(`ssh-keygen could not read ${args.key}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
161
|
+
}
|
|
162
|
+
const me = latest.signers.find((s) => s.key === pub);
|
|
163
|
+
if (!me) return fail(`this key is not in version ${latest.version} of the signer set, so its signature would not count`);
|
|
164
|
+
let sig: string;
|
|
165
|
+
try {
|
|
166
|
+
sig = execFileSync("ssh-keygen", ["-q", "-Y", "sign", "-n", ROTATION_NAMESPACE, "-f", args.key], { input: statement, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return fail(`ssh-keygen could not sign with ${args.key}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
const check = verifySshSignature([me], statement, sig, ROTATION_NAMESPACE);
|
|
171
|
+
if (!check.ok) return fail(`the signature did not verify: ${check.reason}`);
|
|
172
|
+
rotation.signatures = [...rotation.signatures.filter((s) => s.principal !== me.principal), { principal: me.principal, signature: sig }];
|
|
173
|
+
writeFileSync(rotationFile, JSON.stringify(rotation, null, 2) + "\n");
|
|
174
|
+
console.log(formatSuccess(`signed version ${rotation.version} as ${me.principal} (${rotation.signatures.length} of ${latest.threshold} needed)`));
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
return fail(`Unknown signers subcommand: ${sub}`);
|
|
178
|
+
}
|
|
@@ -116,15 +116,19 @@ export const sshCommitAttestor: CommitAttestor = {
|
|
|
116
116
|
const memo = `${ctx.repo}\0${commit}\0${renderAllowedSigners(signers)}`;
|
|
117
117
|
const hit = cache.get(memo);
|
|
118
118
|
if (hit) return hit;
|
|
119
|
-
const
|
|
119
|
+
const raw = execFileSync("git", ["cat-file", "commit", commit], { cwd: ctx.repo, maxBuffer: 64 * 1024 * 1024 });
|
|
120
|
+
const answer = judgeRaw(raw, objectFormat(ctx.repo) === "sha256" ? "gpgsig-sha256" : "gpgsig", signers);
|
|
120
121
|
cache.set(memo, answer);
|
|
121
122
|
return answer;
|
|
122
123
|
},
|
|
124
|
+
attestRaw(ctx: AttestorContext, raw: Buffer): CommitAttestation {
|
|
125
|
+
// A commit from another repository: its object format shows in its tree line.
|
|
126
|
+
const tree = /^tree ([0-9a-f]+)$/m.exec(raw.subarray(0, 200).toString("latin1"))?.[1];
|
|
127
|
+
return judgeRaw(raw, tree?.length === 64 ? "gpgsig-sha256" : "gpgsig", ctx.policy.signers);
|
|
128
|
+
},
|
|
123
129
|
};
|
|
124
130
|
|
|
125
|
-
function
|
|
126
|
-
const raw = execFileSync("git", ["cat-file", "commit", commit], { cwd: repo, maxBuffer: 64 * 1024 * 1024 });
|
|
127
|
-
const header = objectFormat(repo) === "sha256" ? "gpgsig-sha256" : "gpgsig";
|
|
131
|
+
function judgeRaw(raw: Buffer, header: string, signers: Signer[]): CommitAttestation {
|
|
128
132
|
const { payload, signature } = splitSignedCommit(raw, header);
|
|
129
133
|
if (!signature) return { level: "unattested", attestor: NAME, reason: "the commit is not signed" };
|
|
130
134
|
if (!signature.startsWith("-----BEGIN SSH SIGNATURE-----")) {
|
|
@@ -136,3 +140,48 @@ function attest(repo: string, commit: string, signers: Signer[]): CommitAttestat
|
|
|
136
140
|
if (r.missing) return { level: "attested-unverifiable-here", attestor: NAME, reason: r.reason };
|
|
137
141
|
return { level: "unattested", attestor: NAME, reason: r.reason };
|
|
138
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The public key inside an armored ssh signature (the SSHSIG format), as
|
|
146
|
+
* `<type> <base64>`, or undefined when the text is not one. The key is what
|
|
147
|
+
* made the signature; whether anyone trusts it is a separate question.
|
|
148
|
+
*/
|
|
149
|
+
export function sshSignatureKey(signature: string): string | undefined {
|
|
150
|
+
const body = signature
|
|
151
|
+
.replace(/-----BEGIN SSH SIGNATURE-----/, "")
|
|
152
|
+
.replace(/-----END SSH SIGNATURE-----/, "")
|
|
153
|
+
.replace(/\s+/g, "");
|
|
154
|
+
let buf: Buffer;
|
|
155
|
+
try {
|
|
156
|
+
buf = Buffer.from(body, "base64");
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
if (buf.length < 14 || buf.subarray(0, 6).toString("latin1") !== "SSHSIG") return undefined;
|
|
161
|
+
const len = buf.readUInt32BE(10);
|
|
162
|
+
const blob = buf.subarray(14, 14 + len);
|
|
163
|
+
if (blob.length !== len || len < 4) return undefined;
|
|
164
|
+
const typeLen = blob.readUInt32BE(0);
|
|
165
|
+
const type = blob.subarray(4, 4 + typeLen).toString("latin1");
|
|
166
|
+
if (!/^[a-z0-9@.-]+$/.test(type)) return undefined;
|
|
167
|
+
return `${type} ${blob.toString("base64")}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether an ssh signature over `payload` in `namespace` is intact, whoever
|
|
172
|
+
* made it (`ssh-keygen -Y check-novalidate`). Returns the key's fingerprint,
|
|
173
|
+
* false when it does not check, or null when ssh-keygen is not installed.
|
|
174
|
+
*/
|
|
175
|
+
export function sshSignatureIntact(payload: Buffer, signature: string, namespace: string): string | false | null {
|
|
176
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-intact-"));
|
|
177
|
+
try {
|
|
178
|
+
const sigFile = join(dir, "signature");
|
|
179
|
+
writeFileSync(sigFile, signature);
|
|
180
|
+
const r = sshKeygen(["-Y", "check-novalidate", "-n", namespace, "-s", sigFile], payload);
|
|
181
|
+
if (r.missing) return null;
|
|
182
|
+
if (r.status !== 0) return false;
|
|
183
|
+
return /key (SHA256:[A-Za-z0-9+/=]+)/.exec(r.stdout + r.stderr)?.[1] ?? "";
|
|
184
|
+
} finally {
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -8,6 +8,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
|
|
8
8
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
|
+
import { ROTATION_NAMESPACE, rotationStatement, signersDigest } from "./rotation";
|
|
11
12
|
|
|
12
13
|
export const hasSshKeygen = spawnSync("ssh-keygen", ["-Y"], { stdio: "ignore" }).error === undefined;
|
|
13
14
|
|
|
@@ -109,6 +110,23 @@ export class TestRepo {
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Write `<signers>.rotation.json` for the signers file now in the working
|
|
115
|
+
* tree, as the next version after `previousText`, signed by `by` in the
|
|
116
|
+
* rotation namespace (#2553).
|
|
117
|
+
*/
|
|
118
|
+
export function writeRotation(
|
|
119
|
+
repo: TestRepo,
|
|
120
|
+
opts: { previousText: string; version: number; threshold?: number; by: Array<[string, Key]>; namespace?: string; signersPath?: string },
|
|
121
|
+
): void {
|
|
122
|
+
const path = opts.signersPath ?? ".chant/allowed_signers";
|
|
123
|
+
const text = readFileSync(join(repo.dir, path), "utf-8");
|
|
124
|
+
const rotation = { version: opts.version, previous: signersDigest(opts.previousText), threshold: opts.threshold ?? 1 };
|
|
125
|
+
const statement = rotationStatement(rotation, signersDigest(text));
|
|
126
|
+
const signatures = opts.by.map(([principal, key]) => ({ principal, signature: repo.sshSign(key, statement, opts.namespace ?? ROTATION_NAMESPACE) }));
|
|
127
|
+
repo.write(`${path}.rotation.json`, JSON.stringify({ schema: 1, ...rotation, signatures }, null, 2));
|
|
128
|
+
}
|
|
129
|
+
|
|
112
130
|
/** A record kind reading `records/*.md`, and its schema, written into `repo`. */
|
|
113
131
|
export function writeRecordKind(repo: TestRepo): string {
|
|
114
132
|
repo.write(
|
|
@@ -15,7 +15,7 @@ import { queryRecords, type RecordsDocument, type RecordView } from "../records-
|
|
|
15
15
|
import { activeAttestors } from "./attestor";
|
|
16
16
|
import { parseAllowedSigners } from "./policy";
|
|
17
17
|
import { verifyChange } from "./verify";
|
|
18
|
-
import { hasSshKeygen, note, TestRepo, writeRecordKind, type Key } from "./test-repo";
|
|
18
|
+
import { hasSshKeygen, note, TestRepo, writeRecordKind, writeRotation, type Key } from "./test-repo";
|
|
19
19
|
|
|
20
20
|
const repos: TestRepo[] = [];
|
|
21
21
|
function repo(label: string): TestRepo {
|
|
@@ -90,8 +90,11 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
|
|
|
90
90
|
const kind = writeRecordKind(r);
|
|
91
91
|
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob]));
|
|
92
92
|
if (opts.admin) r.write(".chant/trust.json", JSON.stringify({ schema: 1, roles: { admin: ["alice@example.test"] } }));
|
|
93
|
+
r.commit("policy", alice);
|
|
94
|
+
// A separate commit: the one that adds the signers file is judged by the
|
|
95
|
+
// set before it, which is none (#2553), so it attests nothing itself.
|
|
93
96
|
r.write("records/n1.md", note("n1"));
|
|
94
|
-
r.commit("
|
|
97
|
+
r.commit("first record", alice);
|
|
95
98
|
r.git(["checkout", "-q", "-b", "change"]);
|
|
96
99
|
return { r, alice, bob, mallory, kind };
|
|
97
100
|
}
|
|
@@ -181,7 +184,9 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
|
|
|
181
184
|
test("attack: a backdated commit by a key removed from the signers before base", async () => {
|
|
182
185
|
const { r, alice, bob, kind } = baseline("backdated-revoked");
|
|
183
186
|
r.git(["checkout", "-q", "main"]);
|
|
187
|
+
const v1 = r.git(["show", "main:.chant/allowed_signers"]);
|
|
184
188
|
r.write(".chant/allowed_signers", signers(["alice@example.test", alice]));
|
|
189
|
+
writeRotation(r, { previousText: v1, version: 2, by: [["alice@example.test", alice]] });
|
|
185
190
|
r.commit("remove bob", alice);
|
|
186
191
|
r.git(["checkout", "-q", "-b", "late"]);
|
|
187
192
|
r.write("records/n2.md", note("n2"));
|
|
@@ -299,6 +304,7 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
|
|
|
299
304
|
|
|
300
305
|
r.git(["reset", "-q", "--hard", "main"]);
|
|
301
306
|
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob], ["carol@example.test", r.key("carol2")]));
|
|
307
|
+
writeRotation(r, { previousText: r.git(["show", "main:.chant/allowed_signers"]), version: 2, by: [["alice@example.test", alice]] });
|
|
302
308
|
r.commit("add carol", alice);
|
|
303
309
|
report = await verify(r);
|
|
304
310
|
expect(report.ok).toBe(true);
|
|
@@ -49,6 +49,7 @@ function printReport(r: ChangeReport): void {
|
|
|
49
49
|
lines.push(` ${c.commit.slice(0, 8)} ${c.level}${who} ${c.subject}${note}`);
|
|
50
50
|
}
|
|
51
51
|
for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
|
|
52
|
+
if (r.rotation && "to" in r.rotation) lines.push(` signer set: version ${r.rotation.from} to ${r.rotation.to}, signed by ${r.rotation.signedBy.join(", ")}`);
|
|
52
53
|
for (const n of r.notes) lines.push(n);
|
|
53
54
|
console.log(lines.join("\n"));
|
|
54
55
|
if (r.ok) console.log(formatSuccess(`verified ${r.commits.length} commits against the policy at base`));
|
|
@@ -16,6 +16,7 @@ import { execFileSync } from "node:child_process";
|
|
|
16
16
|
import { type CommitAttestor, type ProvenanceLevel } from "./attestor";
|
|
17
17
|
import { policyAtBase, commitProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./provenance";
|
|
18
18
|
import { policyWriters, protectedPaths, type ExcludedSigner, type TrustPolicy } from "./policy";
|
|
19
|
+
import { fileAt, nextVersion, rotationPath, signerHistory, type RotationRefusalCode } from "./rotation";
|
|
19
20
|
|
|
20
21
|
export interface CommitVerdict extends RecordProvenance {
|
|
21
22
|
commit: string;
|
|
@@ -51,6 +52,12 @@ export interface ChangeReport {
|
|
|
51
52
|
/** Protected paths whose content differs between the merge base and head. */
|
|
52
53
|
protectedChanged: string[];
|
|
53
54
|
protectedWrites: ProtectedWrite[];
|
|
55
|
+
/**
|
|
56
|
+
* When the change edits the signer set (#2553): the version it proposes and
|
|
57
|
+
* who signed it, or, when the new set is refused, the version it had to
|
|
58
|
+
* follow and why.
|
|
59
|
+
*/
|
|
60
|
+
rotation: { from: number; to: number; signedBy: string[] } | { from: number; code: RotationRefusalCode; reason: string } | null;
|
|
54
61
|
notes: string[];
|
|
55
62
|
failures: string[];
|
|
56
63
|
ok: boolean;
|
|
@@ -111,6 +118,7 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
|
|
|
111
118
|
protectedPaths: protectedPaths(policy),
|
|
112
119
|
protectedChanged: [],
|
|
113
120
|
protectedWrites: [],
|
|
121
|
+
rotation: null,
|
|
114
122
|
notes: [],
|
|
115
123
|
failures: [],
|
|
116
124
|
ok: false,
|
|
@@ -183,6 +191,20 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
|
|
|
183
191
|
}
|
|
184
192
|
}
|
|
185
193
|
|
|
194
|
+
// A new signer set must be signed by a threshold of the set at base (#2553).
|
|
195
|
+
const rotation = rotationPath(policy.signersPath);
|
|
196
|
+
if (changedPaths.includes(policy.signersPath) || changedPaths.includes(rotation)) {
|
|
197
|
+
const next = { signers: fileAt(repo, head, policy.signersPath), rotation: fileAt(repo, head, rotation) };
|
|
198
|
+
const latest = signerHistory(repo, base.commit, policy.signersPath).versions.at(-1);
|
|
199
|
+
if (next.signers !== undefined && latest && latest.version > 0) {
|
|
200
|
+
const v = nextVersion(latest, next, null);
|
|
201
|
+
if ("code" in v) {
|
|
202
|
+
report.rotation = { from: latest.version, code: v.code, reason: v.reason };
|
|
203
|
+
report.failures.push(`the signer set at head is not a valid rotation of the set at base (${v.code}): ${v.reason}`);
|
|
204
|
+
} else if (v !== latest) report.rotation = { from: latest.version, to: v.version, signedBy: v.signedBy };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
186
208
|
if (opts.require) {
|
|
187
209
|
for (const v of report.commits) {
|
|
188
210
|
if (v.skipped || v.level === opts.require) continue;
|
|
@@ -182,11 +182,15 @@
|
|
|
182
182
|
"amend-supersede-instead",
|
|
183
183
|
"record-sign-failed",
|
|
184
184
|
"ratify-quorum-not-met",
|
|
185
|
+
"write-scope-member",
|
|
186
|
+
"write-scope-kind",
|
|
187
|
+
"agent-unknown",
|
|
185
188
|
"record-unparseable",
|
|
186
189
|
"record-schema-invalid",
|
|
187
190
|
"record-id-duplicate",
|
|
188
191
|
"record-supersedes-unknown",
|
|
189
192
|
"record-supersedes-conflict",
|
|
193
|
+
"record-seal-mismatch",
|
|
190
194
|
"session-seal-mismatch",
|
|
191
195
|
"session-verdict-unknown-record",
|
|
192
196
|
"work-kind-missing",
|