@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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outer workspace never writes inside a nested one (#2524 D2, ws-007,
|
|
3
|
+
* #2551). A member of kind `workspace` upgrades itself with its own
|
|
4
|
+
* `chant workspace upgrade`; the outer side only reads it
|
|
5
|
+
* (./nested-graph.ts).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readDeclaration, WorkspaceReadError } from "./declaration";
|
|
9
|
+
import { workingTree } from "./tree";
|
|
10
|
+
|
|
11
|
+
/** The nested workspaces `dir` declares, as `{ name, dir }` with `dir` relative to `dir`. Empty without a readable declaration. */
|
|
12
|
+
export function nestedWorkspaces(dir: string): { name: string; dir: string }[] {
|
|
13
|
+
try {
|
|
14
|
+
return readDeclaration(workingTree(dir))
|
|
15
|
+
.members.filter((m) => m.kind === "workspace")
|
|
16
|
+
.map((m) => ({ name: m.name, dir: m.dir }));
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err instanceof WorkspaceReadError) return [];
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The paths, of `paths` (relative to the repository root), that lie inside a
|
|
25
|
+
* nested workspace of the workspace at `dir`, whose own path from the
|
|
26
|
+
* repository root is `rel` ("" for the root).
|
|
27
|
+
*/
|
|
28
|
+
export function pathsInsideNested(dir: string, rel: string, paths: readonly string[]): { member: string; path: string }[] {
|
|
29
|
+
const nested = nestedWorkspaces(dir).map((n) => ({ member: n.name, prefix: `${[rel, n.dir].filter(Boolean).join("/")}/` }));
|
|
30
|
+
if (nested.length === 0) return [];
|
|
31
|
+
const out: { member: string; path: string }[] = [];
|
|
32
|
+
for (const path of paths) {
|
|
33
|
+
const hit = nested.find((n) => path.startsWith(n.prefix));
|
|
34
|
+
if (hit) out.push({ member: hit.member, path });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
5
|
+
import type { CommandContext } from "../cli/registry";
|
|
6
|
+
import { runWorkspacePin } from "./pin-cli";
|
|
7
|
+
import { integrityOf } from "./pin-integrity";
|
|
8
|
+
|
|
9
|
+
const cwd = process.cwd();
|
|
10
|
+
const scratch: string[] = [];
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
process.chdir(cwd);
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
for (const d of scratch.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function workspace(integrity?: string): string {
|
|
18
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-pin-cli-")));
|
|
19
|
+
scratch.push(root);
|
|
20
|
+
const files: Record<string, string> = {
|
|
21
|
+
"plugins/tf/package.json": JSON.stringify({ name: "tf" }),
|
|
22
|
+
"plugins/tf/k.json": "{}",
|
|
23
|
+
"chant.workspace.json": JSON.stringify({ name: "w", schema: 1, pins: [{ path: "plugins/tf", ...(integrity ? { integrity } : {}) }], members: [{ name: "w", dir: ".", kind: "other", because: "test" }] }),
|
|
24
|
+
};
|
|
25
|
+
for (const [p, t] of Object.entries(files)) {
|
|
26
|
+
mkdirSync(dirname(join(root, p)), { recursive: true });
|
|
27
|
+
writeFileSync(join(root, p), t);
|
|
28
|
+
}
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function pin(root: string, path: string | undefined, json = false) {
|
|
33
|
+
process.chdir(root);
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
const err: string[] = [];
|
|
36
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => void out.push(s));
|
|
37
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => void err.push(s));
|
|
38
|
+
const code = await runWorkspacePin({ args: { extraPositional: path, json } } as unknown as CommandContext);
|
|
39
|
+
return { code, out: out.join("\n"), err: err.join("\n") };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("chant workspace pin (#2547)", () => {
|
|
43
|
+
test("prints the integrity value, and says an unpinned path pin needs it", async () => {
|
|
44
|
+
const root = workspace();
|
|
45
|
+
const r = await pin(root, "plugins/tf");
|
|
46
|
+
expect(r.code).toBe(0);
|
|
47
|
+
expect(r.out).toBe(integrityOf(join(root, "plugins/tf")).integrity);
|
|
48
|
+
expect(r.err).toContain('is a path pin with no integrity; add "integrity"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("--json names the path, the value, the file count and whether the declared pin matches", async () => {
|
|
52
|
+
const root = workspace();
|
|
53
|
+
const { integrity } = integrityOf(join(root, "plugins/tf"));
|
|
54
|
+
const matching = workspace(integrity);
|
|
55
|
+
const r = await pin(matching, "plugins/tf", true);
|
|
56
|
+
expect(JSON.parse(r.out)).toEqual({ path: "plugins/tf", integrity: integrityOf(join(matching, "plugins/tf")).integrity, files: 2, pin: "matches" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("exits 1 when the declared pin no longer matches", async () => {
|
|
60
|
+
const root = workspace("sha256-AAAA");
|
|
61
|
+
const r = await pin(root, "plugins/tf");
|
|
62
|
+
expect(r.code).toBe(1);
|
|
63
|
+
expect(r.err).toContain("does not match the integrity its pin states (sha256-AAAA)");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("refuses a path outside the workspace, and a missing argument", async () => {
|
|
67
|
+
const root = workspace();
|
|
68
|
+
expect((await pin(root, "../elsewhere")).code).toBe(1);
|
|
69
|
+
expect((await pin(root, undefined)).code).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace pin <path> [--json]` (#2547, ws-065): print the `integrity`
|
|
3
|
+
* value that pins the plugin at `path`, a directory or file inside the
|
|
4
|
+
* workspace. What the hash covers is in ./pin-integrity.ts. The command reads
|
|
5
|
+
* files only, and says whether a path pin the declaration already has for
|
|
6
|
+
* that path matches.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { relative, resolve, sep } from "node:path";
|
|
10
|
+
import { formatError } from "../cli/format";
|
|
11
|
+
import type { CommandContext } from "../cli/registry";
|
|
12
|
+
import { readDeclaration, WorkspaceReadError } from "./declaration";
|
|
13
|
+
import { integrityOf, PinHashError } from "./pin-integrity";
|
|
14
|
+
import { locateWorkspace } from "./which-chant";
|
|
15
|
+
|
|
16
|
+
const USAGE = "chant workspace pin <path> [--json]";
|
|
17
|
+
|
|
18
|
+
export async function runWorkspacePin(ctx: CommandContext): Promise<number> {
|
|
19
|
+
const { args } = ctx;
|
|
20
|
+
const given = args.extraPositional;
|
|
21
|
+
if (!given) {
|
|
22
|
+
console.error(formatError({ message: "chant workspace pin needs the plugin's path", hint: USAGE }));
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const located = locateWorkspace(process.cwd());
|
|
27
|
+
const declaration = readDeclaration(located.tree, "", { rootChant: true });
|
|
28
|
+
const abs = resolve(process.cwd(), given);
|
|
29
|
+
const rel = relative(located.rootOnDisk, abs).split(sep).join("/");
|
|
30
|
+
if (rel === "" || rel.startsWith("../") || rel === "..") {
|
|
31
|
+
console.error(formatError({ message: `${given} is not inside the workspace`, hint: USAGE }));
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
const { integrity, files } = integrityOf(abs);
|
|
35
|
+
const declared = declaration.pins.find((p) => p.path === rel);
|
|
36
|
+
const state = declared === undefined ? "undeclared" : declared.integrity === null ? "declared-unpinned" : declared.integrity === integrity ? "matches" : "differs";
|
|
37
|
+
if (args.json) {
|
|
38
|
+
console.log(JSON.stringify({ path: rel, integrity, files, pin: state }, null, 2));
|
|
39
|
+
} else {
|
|
40
|
+
console.log(integrity);
|
|
41
|
+
const note = {
|
|
42
|
+
undeclared: `${rel} is not a path pin in ${declaration.file}; add { "path": "${rel}", "integrity": "${integrity}" } to pins`,
|
|
43
|
+
"declared-unpinned": `${rel} is a path pin with no integrity; add "integrity": "${integrity}" to it`,
|
|
44
|
+
matches: `${rel} matches the integrity its pin states`,
|
|
45
|
+
differs: `${rel} does not match the integrity its pin states (${declared?.integrity}); update the pin if the change is intended`,
|
|
46
|
+
}[state];
|
|
47
|
+
console.error(`${files} file${files === 1 ? "" : "s"} hashed. ${note}`);
|
|
48
|
+
}
|
|
49
|
+
return state === "differs" ? 1 : 0;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err instanceof WorkspaceReadError || err instanceof PinHashError) {
|
|
52
|
+
console.error(formatError({ message: `${given}: ${err.message}`, hint: USAGE }));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
6
|
+
import { loadKindRegistry } from "./kinds";
|
|
7
|
+
import { checkPinIntegrity, integrityOf, parseIntegrity, pinnedFiles } from "./pin-integrity";
|
|
8
|
+
|
|
9
|
+
const scratch: string[] = [];
|
|
10
|
+
afterAll(() => {
|
|
11
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
function dir(files: Record<string, string>): string {
|
|
14
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-pin-")));
|
|
15
|
+
scratch.push(root);
|
|
16
|
+
for (const [path, text] of Object.entries(files)) {
|
|
17
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
18
|
+
writeFileSync(join(root, path), text);
|
|
19
|
+
}
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tf = { name: "terraform", description: "a Terraform root module", precedence: 400, probe: { anyFile: ["main.tf"] } };
|
|
24
|
+
const plugin = (): Record<string, string> => ({
|
|
25
|
+
"plugins/tf/package.json": JSON.stringify({ name: "tf", exports: { "./workspace-kinds": "./k.json" } }),
|
|
26
|
+
"plugins/tf/k.json": JSON.stringify({ schema: 1, kinds: [tf] }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("integrityOf", () => {
|
|
30
|
+
test("a file is its bytes' SRI digest", () => {
|
|
31
|
+
const root = dir({ "a.txt": "hello\n" });
|
|
32
|
+
const expected = `sha256-${createHash("sha256").update("hello\n").digest("base64")}`;
|
|
33
|
+
expect(integrityOf(join(root, "a.txt"))).toEqual({ integrity: expected, files: 1 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a directory is the digest of a manifest of each file's own digest and path, sorted by path", () => {
|
|
37
|
+
const root = dir({ "d/b.txt": "B", "d/a/z.txt": "Z" });
|
|
38
|
+
const h = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
39
|
+
const manifest = `${h("Z")} a/z.txt\n${h("B")} b.txt\n`;
|
|
40
|
+
expect(integrityOf(join(root, "d"))).toEqual({
|
|
41
|
+
integrity: `sha256-${createHash("sha256").update(manifest).digest("base64")}`,
|
|
42
|
+
files: 2,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("it does not depend on the order files were written, and it follows a changed byte, a rename and an added file", () => {
|
|
47
|
+
const base = integrityOf(join(dir({ "d/a": "1", "d/b": "2" }), "d")).integrity;
|
|
48
|
+
expect(integrityOf(join(dir({ "d/b": "2", "d/a": "1" }), "d")).integrity).toBe(base);
|
|
49
|
+
expect(integrityOf(join(dir({ "d/a": "1", "d/b": "3" }), "d")).integrity).not.toBe(base);
|
|
50
|
+
expect(integrityOf(join(dir({ "d/a": "1", "d/c": "2" }), "d")).integrity).not.toBe(base);
|
|
51
|
+
expect(integrityOf(join(dir({ "d/a": "1", "d/b": "2", "d/e": "" }), "d")).integrity).not.toBe(base);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("node_modules and .git are left out, and a symbolic link is refused", () => {
|
|
55
|
+
const root = dir({ "d/a": "1", "d/node_modules/x/index.js": "x", "d/.git/HEAD": "ref" });
|
|
56
|
+
expect(pinnedFiles(join(root, "d"))).toEqual(["a"]);
|
|
57
|
+
symlinkSync("/etc/hosts", join(root, "d", "link"));
|
|
58
|
+
expect(() => integrityOf(join(root, "d"))).toThrow(/link is a symbolic link/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("sha384 and sha512 are taken with that algorithm", () => {
|
|
62
|
+
const root = dir({ "d/a": "1" });
|
|
63
|
+
expect(integrityOf(join(root, "d"), "sha512").integrity).toMatch(/^sha512-/);
|
|
64
|
+
expect(integrityOf(join(root, "d"), "sha384").integrity).toMatch(/^sha384-/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("parseIntegrity", () => {
|
|
69
|
+
test("takes the three SRI algorithms and nothing else", () => {
|
|
70
|
+
expect(parseIntegrity("sha256-AAAA")).toEqual({ algorithm: "sha256", digest: "AAAA" });
|
|
71
|
+
expect(parseIntegrity("md5-AAAA")).toBeUndefined();
|
|
72
|
+
expect(parseIntegrity("sha256:AAAA")).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("a path pin's integrity when kinds are loaded (#2547)", () => {
|
|
77
|
+
test("a matching integrity loads the plugin's kinds", () => {
|
|
78
|
+
const root = dir(plugin());
|
|
79
|
+
const { integrity } = integrityOf(join(root, "plugins/tf"));
|
|
80
|
+
const { registry, problems } = loadKindRegistry([{ package: null, version: null, path: "plugins/tf", integrity }], root);
|
|
81
|
+
expect(problems).toEqual([]);
|
|
82
|
+
expect(registry.get("terraform")?.source).toBe("plugins/tf");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a changed plugin is refused with the expected and actual hash, and none of its kinds load", () => {
|
|
86
|
+
const root = dir(plugin());
|
|
87
|
+
const { integrity } = integrityOf(join(root, "plugins/tf"));
|
|
88
|
+
writeFileSync(join(root, "plugins/tf/k.json"), JSON.stringify({ schema: 1, kinds: [{ ...tf, name: "evil" }] }));
|
|
89
|
+
const { registry, problems } = loadKindRegistry([{ package: null, version: null, path: "plugins/tf", integrity }], root);
|
|
90
|
+
expect(registry.get("evil")).toBeUndefined();
|
|
91
|
+
expect(registry.get("terraform")).toBeUndefined();
|
|
92
|
+
expect(problems).toHaveLength(1);
|
|
93
|
+
expect(problems[0].pin).toBe(0);
|
|
94
|
+
expect(problems[0].message).toContain("plugins/tf: integrity mismatch, so nothing is read from it");
|
|
95
|
+
expect(problems[0].message).toContain(`The pin says ${integrity} and the plugin hashes to sha256-`);
|
|
96
|
+
expect(problems[0].message).toContain("chant workspace pin plugins/tf");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an added file is a mismatch too", () => {
|
|
100
|
+
const root = dir(plugin());
|
|
101
|
+
const { integrity } = integrityOf(join(root, "plugins/tf"));
|
|
102
|
+
writeFileSync(join(root, "plugins/tf/extra.js"), "x");
|
|
103
|
+
const checked = checkPinIntegrity(join(root, "plugins/tf"), integrity, "plugins/tf");
|
|
104
|
+
expect(checked.ok).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a pin with no integrity loads as before", () => {
|
|
108
|
+
const root = dir(plugin());
|
|
109
|
+
const { registry, problems } = loadKindRegistry([{ package: null, version: null, path: "plugins/tf", integrity: null }], root);
|
|
110
|
+
expect(problems).toEqual([]);
|
|
111
|
+
expect(registry.get("terraform")).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a missing plugin and a malformed integrity are problems, not throws", () => {
|
|
115
|
+
const root = dir(plugin());
|
|
116
|
+
const missing = loadKindRegistry([{ package: null, version: null, path: "plugins/none", integrity: "sha256-AAAA" }], root);
|
|
117
|
+
expect(missing.problems[0].message).toMatch(/can't check its integrity, it does not exist/);
|
|
118
|
+
const bad = checkPinIntegrity(join(root, "plugins/tf"), "nope", "plugins/tf");
|
|
119
|
+
expect(bad).toEqual({ ok: false, message: expect.stringContaining("is not sha256-, sha384- or sha512-") });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content hashes for plugins a declaration loads by path (#2547, ws-065).
|
|
3
|
+
*
|
|
4
|
+
* A pin of `{ "path": "plugins/kinds", "integrity": "sha256-..." }` says the
|
|
5
|
+
* plugin directory must hash to that value before chant reads anything from
|
|
6
|
+
* it. `chant workspace pin <path>` prints the value to put there.
|
|
7
|
+
*
|
|
8
|
+
* `integrity` is a Subresource Integrity string: `sha256-`, `sha384-` or
|
|
9
|
+
* `sha512-`, then the base64 of the digest. A directory has no single byte
|
|
10
|
+
* stream to hash, so its digest is taken over a manifest that names every
|
|
11
|
+
* file by its path and its own digest, and a file path is hashed directly:
|
|
12
|
+
*
|
|
13
|
+
* - The files are every regular file under the directory, at any depth, except
|
|
14
|
+
* anything inside a `node_modules` or `.git` directory.
|
|
15
|
+
* - Each file's digest is the chosen algorithm over its bytes, as they are on
|
|
16
|
+
* disk: no line-ending change, so a checkout that rewrites line endings
|
|
17
|
+
* hashes differently from one that does not.
|
|
18
|
+
* - The manifest is one line per file, `<hex digest> <path>\n`, in order of
|
|
19
|
+
* the path's UTF-16 code units, with `/` as the separator and the path
|
|
20
|
+
* relative to the directory. The pin is the algorithm over those bytes.
|
|
21
|
+
* - A symbolic link anywhere in the directory is refused: it can point
|
|
22
|
+
* outside the plugin, and what it points at is not what was pinned.
|
|
23
|
+
*
|
|
24
|
+
* The hash covers the working tree. Build output and untracked files inside
|
|
25
|
+
* the directory change it, so pin a directory that holds only committed files.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
|
|
32
|
+
export type IntegrityAlgorithm = "sha256" | "sha384" | "sha512";
|
|
33
|
+
|
|
34
|
+
const INTEGRITY = /^(sha(?:256|384|512))-([A-Za-z0-9+/]+={0,2})$/;
|
|
35
|
+
|
|
36
|
+
/** The algorithm and base64 digest of an `integrity` value, or undefined when it is not one. */
|
|
37
|
+
export function parseIntegrity(value: string): { algorithm: IntegrityAlgorithm; digest: string } | undefined {
|
|
38
|
+
const m = INTEGRITY.exec(value);
|
|
39
|
+
return m ? { algorithm: m[1] as IntegrityAlgorithm, digest: m[2] } : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Directories a plugin hash never looks inside. */
|
|
43
|
+
const SKIPPED_DIRS = new Set(["node_modules", ".git"]);
|
|
44
|
+
|
|
45
|
+
/** Thrown when a path can't be hashed: it is missing, or holds a symbolic link. */
|
|
46
|
+
export class PinHashError extends Error {}
|
|
47
|
+
|
|
48
|
+
function walk(dir: string, prefix: string, out: string[]): void {
|
|
49
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
51
|
+
if (entry.isSymbolicLink()) throw new PinHashError(`${rel} is a symbolic link, which a pinned plugin may not contain`);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (!SKIPPED_DIRS.has(entry.name)) walk(join(dir, entry.name), rel, out);
|
|
54
|
+
} else if (entry.isFile()) {
|
|
55
|
+
out.push(rel);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The files a directory's hash covers, as paths relative to it, in hash order. */
|
|
61
|
+
export function pinnedFiles(dir: string): string[] {
|
|
62
|
+
const files: string[] = [];
|
|
63
|
+
walk(dir, "", files);
|
|
64
|
+
return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The `integrity` value for the plugin at `abs`: a directory by its manifest, a file by its bytes. */
|
|
68
|
+
export function integrityOf(abs: string, algorithm: IntegrityAlgorithm = "sha256"): { integrity: string; files: number } {
|
|
69
|
+
let stat;
|
|
70
|
+
try {
|
|
71
|
+
stat = lstatSync(abs);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new PinHashError("it does not exist");
|
|
74
|
+
}
|
|
75
|
+
if (stat.isSymbolicLink()) throw new PinHashError("it is a symbolic link, which a pinned plugin may not be");
|
|
76
|
+
if (stat.isFile()) {
|
|
77
|
+
return { integrity: `${algorithm}-${createHash(algorithm).update(readFileSync(abs)).digest("base64")}`, files: 1 };
|
|
78
|
+
}
|
|
79
|
+
const files = pinnedFiles(abs);
|
|
80
|
+
const manifest = files.map((f) => `${createHash(algorithm).update(readFileSync(join(abs, ...f.split("/")))).digest("hex")} ${f}\n`).join("");
|
|
81
|
+
return { integrity: `${algorithm}-${createHash(algorithm).update(manifest, "utf8").digest("base64")}`, files: files.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type IntegrityCheck = { ok: true } | { ok: false; message: string };
|
|
85
|
+
|
|
86
|
+
/** Compare the plugin at `abs` with the `integrity` a pin states. `label` names the plugin in the message. */
|
|
87
|
+
export function checkPinIntegrity(abs: string, integrity: string, label: string): IntegrityCheck {
|
|
88
|
+
const parsed = parseIntegrity(integrity);
|
|
89
|
+
if (!parsed) return { ok: false, message: `${label}: integrity ${JSON.stringify(integrity)} is not sha256-, sha384- or sha512- and a base64 digest` };
|
|
90
|
+
let actual: string;
|
|
91
|
+
try {
|
|
92
|
+
actual = integrityOf(abs, parsed.algorithm).integrity;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (!(err instanceof PinHashError)) throw err;
|
|
95
|
+
return { ok: false, message: `${label}: can't check its integrity, ${err.message}` };
|
|
96
|
+
}
|
|
97
|
+
if (actual === integrity) return { ok: true };
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
message:
|
|
101
|
+
`${label}: integrity mismatch, so nothing is read from it. The pin says ${integrity} and the plugin hashes to ${actual}. ` +
|
|
102
|
+
`If the change is intended, run \`chant workspace pin ${label}\` and put the value it prints in the pin; otherwise restore the plugin`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -110,6 +110,30 @@ describe("every schema against the reference workspace (#2543)", () => {
|
|
|
110
110
|
TIMEOUT,
|
|
111
111
|
);
|
|
112
112
|
|
|
113
|
+
test(
|
|
114
|
+
"graph from the chant repo expands the nested reference workspace read-only, with outer/inner/id ids (#2551)",
|
|
115
|
+
async () => {
|
|
116
|
+
const { expectValid } = contract(graphSchema);
|
|
117
|
+
const before = git(REPO, "status", "--porcelain", "--untracked-files=all", "--", "reference-workspace");
|
|
118
|
+
for (const at of [undefined, "HEAD"]) {
|
|
119
|
+
const { doc, failed } = await workspaceGraph({ cwd: REPO, at, members: ["reference-workspace"], reader });
|
|
120
|
+
expectValid(doc);
|
|
121
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
122
|
+
expect(failed, JSON.stringify(doc.members)).toBe(false);
|
|
123
|
+
const nested = doc.members.find((m) => m.name === "reference-workspace")!;
|
|
124
|
+
expect(nested).toMatchObject({ kind: "workspace", status: "composed", reason: null });
|
|
125
|
+
expect(nested.nested?.name).toBe("reference");
|
|
126
|
+
expect(nested.nested?.members.find((m) => m.name === "delivery")).toMatchObject({ status: "composed", dir: "delivery" });
|
|
127
|
+
expect(doc.nodes.length).toBeGreaterThan(0);
|
|
128
|
+
expect(doc.nodes.every((n) => n.id.startsWith("reference-workspace/delivery/") && n.member === "reference-workspace" && n.nested === "delivery")).toBe(true);
|
|
129
|
+
expect(doc.groups.byMember["reference-workspace"]).toEqual(doc.nodes.map((n) => n.id).sort());
|
|
130
|
+
}
|
|
131
|
+
// The outer read never writes inside the nested workspace.
|
|
132
|
+
expect(git(REPO, "status", "--porcelain", "--untracked-files=all", "--", "reference-workspace")).toBe(before);
|
|
133
|
+
},
|
|
134
|
+
TIMEOUT,
|
|
135
|
+
);
|
|
136
|
+
|
|
113
137
|
test("check, in the working tree and at HEAD", async () => {
|
|
114
138
|
const { expectValid } = contract(checkSchema);
|
|
115
139
|
for (const at of [undefined, "HEAD"]) {
|
|
@@ -35,6 +35,11 @@ import { ANSWER_WARNING_CODES } from "./points";
|
|
|
35
35
|
import { POINTS_ERROR_CODES, POINTS_SOURCE_REASON_CODES } from "./points-cli";
|
|
36
36
|
import { POINTS_WRITE_ERROR_CODES } from "./decide";
|
|
37
37
|
import { PATCH_ERROR_CODES } from "./patch";
|
|
38
|
+
import { AGENT_ERROR_CODES } from "./agent-cli";
|
|
39
|
+
import { WRITE_SCOPE_CODES } from "./write-scope";
|
|
40
|
+
import { ROTATION_REFUSAL_CODES } from "./trust/rotation";
|
|
41
|
+
import { SIGNERS_ERROR_CODES } from "./trust/signers-cli";
|
|
42
|
+
import { EVIDENCE_ERROR_CODES } from "./trust/evidence";
|
|
38
43
|
|
|
39
44
|
const HERE = import.meta.dirname;
|
|
40
45
|
|
|
@@ -84,6 +89,11 @@ const PER_COMMAND: Record<string, readonly string[]> = {
|
|
|
84
89
|
POINTS_SOURCE_REASON_CODES,
|
|
85
90
|
POINTS_WRITE_ERROR_CODES,
|
|
86
91
|
PATCH_ERROR_CODES,
|
|
92
|
+
AGENT_ERROR_CODES,
|
|
93
|
+
WRITE_SCOPE_CODES,
|
|
94
|
+
ROTATION_REFUSAL_CODES,
|
|
95
|
+
SIGNERS_ERROR_CODES,
|
|
96
|
+
EVIDENCE_ERROR_CODES,
|
|
87
97
|
};
|
|
88
98
|
|
|
89
99
|
/** Every string in an `enum` under a property named `code`, anywhere in a schema. */
|
|
@@ -70,8 +70,9 @@ export const REASONS = {
|
|
|
70
70
|
"record-supersedes-conflict": "A second closed record supersedes a record another one already superseded.",
|
|
71
71
|
"record-remediates-unknown": "A remediates link names an id no record has.",
|
|
72
72
|
"record-remediates-not-closed": "A remediates link names a record that isn't closed; a record still open is amended instead.",
|
|
73
|
+
"record-seal-mismatch": "A closed record's seal is not the whole-file seal of its text now: the record changed after it closed.",
|
|
73
74
|
// A review session that isn't valid (records, #2673).
|
|
74
|
-
"session-seal-mismatch": "A closed session's seal is not the
|
|
75
|
+
"session-seal-mismatch": "A closed session's seal is not the whole-file seal of its text now: the session changed after it closed, or was sealed by the rule before #2546.",
|
|
75
76
|
"session-verdict-unknown-record": "A session's verdict names a record that none of the session kind's subject records has.",
|
|
76
77
|
// A record that is valid but warned about (records, #2549).
|
|
77
78
|
"asset-drift": "A file the record pins by hash has changed: its bytes no longer hash to the pinned sha256.",
|
|
@@ -105,7 +106,7 @@ export const REASONS = {
|
|
|
105
106
|
"seal-missing": "The verdict, or the record, carries no seal.",
|
|
106
107
|
"seal-signer-unlisted": "The reviewer, or the record's author, has no key in the signers file at base, so the seal can't count.",
|
|
107
108
|
"seal-signature-invalid": "The seal is malformed, names a signer other than the reviewer or author, or its signature does not verify over the verdict or record.",
|
|
108
|
-
"seal-unverifiable": "Nothing here can say whose seal it is: there is no signers file at base,
|
|
109
|
+
"seal-unverifiable": "Nothing here can say whose seal it is: there is no signers file at base, ssh-keygen is not installed, or the record came back in a return sealed by a signer no admission lists yet.",
|
|
109
110
|
// A record whose author seal is not attested under a signers file at base (records, #2688). A warning: the record is still read.
|
|
110
111
|
"record-unattested": "A signers file is active at base, and the record names an author whose seal does not verify: it has none, the author has no key in the file, or the signature fails.",
|
|
111
112
|
// A records read that fails (records).
|
|
@@ -132,6 +133,10 @@ export const REASONS = {
|
|
|
132
133
|
"source-harvest-not-proposed": "A harvested record (source.via harvest) was written in a state other than the kind's first: a harvest proposes, and a person decides.",
|
|
133
134
|
"ratify-quorum-not-met": "The write puts a record in its kind's ratified state (reviews.ratified), and the record's quorum is not met: too few agreeing verdicts count.",
|
|
134
135
|
"record-sign-failed": "--sign was given and no author seal could be made: the record names no author, the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
|
|
136
|
+
// Write scope (#2548): a records write that is refused, and a commit check --changes reports, outside the writer's scope.
|
|
137
|
+
"write-scope-member": "The write is to a file, or to a record kind, of a member outside the writer's scope: an agent session writes only its own member, and writeScope.<class>.members leaves the member out.",
|
|
138
|
+
"write-scope-kind": "The write is to a record kind writeScope.<class>.records does not list, with a verb it does not list for the kind, or deletes a record.",
|
|
139
|
+
"agent-unknown": "CHANT_AGENT, or a commit's Chant-Agent trailer, names an agent session the declaration at base does not declare.",
|
|
135
140
|
// A review given in a session (records review --session, #2693).
|
|
136
141
|
"session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
|
|
137
142
|
"session-not-open": "--session names a session in a closed state, which takes no more verdicts.",
|
|
@@ -216,6 +221,28 @@ export const REASONS = {
|
|
|
216
221
|
// A box whose isolation fails (check, #2727). Each is also a WSP finding.
|
|
217
222
|
"box-isolation-collision": "Two boxes on one host resolve to the same port, state path or cookie name, or two ports in one box share an offset.",
|
|
218
223
|
"box-isolation-literal": "A host's stateRoot or a box's state entry is a literal machine path instead of one derived from an environment reference and the box's name.",
|
|
224
|
+
// The signer set's history (signers and verify, #2553): why a version is not a valid rotation of the one before. Nothing verifies past it.
|
|
225
|
+
"signers-file-missing": "There is no signers file at the base revision, so there is no signer history to read.",
|
|
226
|
+
"signers-removed": "The signers file was removed. Commits merged before the removal keep the set they were judged by; nothing after it verifies.",
|
|
227
|
+
"rotation-unparseable": "The rotation file beside the signers file is not JSON.",
|
|
228
|
+
"rotation-invalid": "The rotation file does not match its shape: schema 1, a version, the previous set's digest, a threshold and signatures.",
|
|
229
|
+
"rotation-first-version": "The first signer set's rotation file names a version other than 1, or a previous digest.",
|
|
230
|
+
"rotation-missing": "The signer set or its threshold changed and no rotation file signed by the set before it came with the change.",
|
|
231
|
+
"rotation-version-skew": "The rotation names a version other than the one after the set it replaces, as a replayed or skipped rotation does.",
|
|
232
|
+
"rotation-previous-mismatch": "The rotation names a previous digest other than the set it replaces, as a rollback to an older set does.",
|
|
233
|
+
"rotation-threshold-unsatisfiable": "The new threshold is more than the number of distinct signers in the new set, so no later rotation could meet it.",
|
|
234
|
+
"rotation-threshold-not-met": "Fewer distinct signers of the set before signed the rotation, in the chant-signers namespace, than that set's threshold.",
|
|
235
|
+
// Runner evidence (evidence sign and verify, #2553): why evidence is refused.
|
|
236
|
+
"trust-policy-unreadable": "The trust policy at base can't be read, or its signer history is broken, so no runner key is trusted.",
|
|
237
|
+
"envelope-unreadable": "The --envelope file can't be read, or is not JSON.",
|
|
238
|
+
"envelope-invalid": "The file is not a DSSE envelope: payloadType, payload in canonical base64, and signatures with keyid and sig.",
|
|
239
|
+
"envelope-untrusted": "No signature in the envelope verifies against a runner key the policy at base lists.",
|
|
240
|
+
"evidence-payload-type": "The envelope's payload type is not application/vnd.in-toto+json.",
|
|
241
|
+
"evidence-statement-invalid": "The payload is not an in-toto Statement v1 with chant's runner-evidence predicate, or has a field the predicate does not define.",
|
|
242
|
+
"evidence-runner-mismatch": "The statement names a runner other than the one whose key signed it.",
|
|
243
|
+
"runner-key-invalid": "The key given to evidence sign is not an Ed25519 private key in PEM.",
|
|
244
|
+
"runner-key-is-signer": "The key given to evidence sign is a person's key in the signers file. Evidence is signed by a service or CI identity.",
|
|
245
|
+
"runner-key-unlisted": "The policy at base lists no runner with the key given to evidence sign.",
|
|
219
246
|
// The lineage lock (check).
|
|
220
247
|
"lock-invalid": "The lineage lock can't be read.",
|
|
221
248
|
"manual-step-open": "A scope in the lineage lock has an open manual step.",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* `citedBy`. This module only reads; nothing here writes a seal into a file.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { recordSeal, type RecordEntry, type RecordFormat, type RecordKind } from "./records";
|
|
16
16
|
|
|
17
17
|
/** A subject record's review entry that names a session in its `session` field. */
|
|
18
18
|
export interface SessionCitation {
|
|
@@ -29,19 +29,15 @@ export interface SessionCitation {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* The seal of a session record: {@link
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* `awk 'NR==1&&/^---$/{f=1;print;next} f&&/^---$/{f=0} f&&/^closed_digest:/{next} {print}' S-0001.md | shasum -a 256`
|
|
39
|
-
* gives the same value for a file with LF line endings that ends in one.
|
|
40
|
-
* For a session kind of format json (ws-053), the seal member is removed by
|
|
41
|
-
* the JSON rule of {@link recordTextDigest} instead.
|
|
32
|
+
* The seal of a session record: {@link recordSeal} with the seal field, the
|
|
33
|
+
* whole-file JCS/SHA-256 every closed record of a sealed kind carries
|
|
34
|
+
* (#2546, ws-063). Until #2546 it was the SHA-256 of the file's text without
|
|
35
|
+
* the seal line; a session sealed by that rule holds a bare hex value and
|
|
36
|
+
* reads as `session-seal-mismatch` now. Null when the front matter can't be
|
|
37
|
+
* parsed.
|
|
42
38
|
*/
|
|
43
|
-
export function sessionSeal(text: string, field: string, format: RecordFormat = "markdown-front-matter"): string {
|
|
44
|
-
return
|
|
39
|
+
export function sessionSeal(text: string, field: string, format: RecordFormat = "markdown-front-matter"): string | null {
|
|
40
|
+
return recordSeal(text, field, format);
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
/**
|
|
@@ -86,7 +82,9 @@ export function joinSessions(kind: RecordKind, entries: RecordEntry[], texts: Ma
|
|
|
86
82
|
if (actual !== seal) {
|
|
87
83
|
e.reasons.push({
|
|
88
84
|
code: "session-seal-mismatch",
|
|
89
|
-
message:
|
|
85
|
+
message: /^[0-9a-f]{64}$/.test(seal)
|
|
86
|
+
? `${decl.seal} is ${seal}, a seal by the text rule chant used before #2546, and the session seals to ${actual} now: seal it again by hand, since a closed session is never rewritten by chant`
|
|
87
|
+
: `${decl.seal} is ${seal}, but the session seals to ${actual}: the session changed after it closed`,
|
|
90
88
|
});
|
|
91
89
|
}
|
|
92
90
|
}
|
|
@@ -176,6 +176,9 @@
|
|
|
176
176
|
"amend-supersede-instead",
|
|
177
177
|
"record-sign-failed",
|
|
178
178
|
"ratify-quorum-not-met",
|
|
179
|
+
"write-scope-member",
|
|
180
|
+
"write-scope-kind",
|
|
181
|
+
"agent-unknown",
|
|
179
182
|
"record-unparseable",
|
|
180
183
|
"record-schema-invalid",
|
|
181
184
|
"record-id-duplicate",
|
|
@@ -183,6 +186,7 @@
|
|
|
183
186
|
"record-supersedes-conflict",
|
|
184
187
|
"record-remediates-unknown",
|
|
185
188
|
"record-remediates-not-closed",
|
|
189
|
+
"record-seal-mismatch",
|
|
186
190
|
"session-seal-mismatch",
|
|
187
191
|
"session-verdict-unknown-record"
|
|
188
192
|
]
|