@intentius/chant 0.124.23 → 0.124.25
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/ci-jobs.d.ts +41 -1
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +48 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +14 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +9 -0
- package/dist/cli/handlers/emulator.d.ts +13 -2
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.js +63 -3
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.js +5 -4
- package/dist/cli/handlers/run.d.ts +4 -3
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +73 -9
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -11
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +45 -6
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +97 -22
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +25 -10
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +24 -2
- package/dist/lexicon-module.d.ts +7 -1
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +7 -2
- package/dist/lifecycle/git.d.ts +11 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/git.js +62 -6
- package/dist/lifecycle/no-record.d.ts +59 -0
- package/dist/lifecycle/no-record.d.ts.map +1 -0
- package/dist/lifecycle/no-record.js +88 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +30 -9
- package/dist/op/emulator-lifecycle.d.ts +23 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.js +39 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/local-executor.d.ts +10 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +71 -7
- package/dist/op/op-waves-notify.d.ts +2 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-notify.js +9 -3
- package/dist/op/op-waves-run.d.ts +8 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +36 -11
- package/dist/op/op-waves.d.ts +85 -1
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +153 -1
- package/dist/op/receipt-store.d.ts +5 -0
- package/dist/op/receipt-store.d.ts.map +1 -1
- package/dist/op/receipt-store.js +4 -3
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +6 -0
- package/dist/op/step-console.d.ts +65 -0
- package/dist/op/step-console.d.ts.map +1 -0
- package/dist/op/step-console.js +140 -0
- package/dist/op/step-output-ref.d.ts +26 -18
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/step-output-ref.js +86 -63
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/point-decision.js +3 -0
- package/dist/wave-runtime.mjs +239 -239
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/compose-graph.d.ts +22 -3
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +18 -5
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/graph.schema.json +68 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/nested-graph.d.ts +1 -1
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +5 -1
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- package/dist/workspace/runs.d.ts.map +1 -1
- package/dist/workspace/runs.js +4 -2
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/ci-jobs.ts +58 -1
- package/src/cli/commands/build.ts +18 -0
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +9 -0
- package/src/cli/handlers/emulator.test.ts +61 -1
- package/src/cli/handlers/emulator.ts +65 -4
- package/src/cli/handlers/promote.test.ts +7 -0
- package/src/cli/handlers/promote.ts +7 -1
- package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
- package/src/cli/handlers/run-wave.ts +5 -4
- package/src/cli/handlers/run.test.ts +126 -0
- package/src/cli/handlers/run.ts +69 -9
- package/src/cli/main.test.ts +34 -0
- package/src/cli/main.ts +38 -11
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/cli/parse-args.ts +21 -0
- package/src/cli/registry.ts +12 -0
- package/src/components/cli-support.test.ts +75 -0
- package/src/components/cli-support.ts +130 -25
- package/src/components/driver.test.ts +28 -0
- package/src/components/driver.ts +23 -10
- package/src/components/generate-disabled.test.ts +56 -0
- package/src/effect-receipt.ts +22 -0
- package/src/graph-ci-edges.test.ts +50 -0
- package/src/graph-detail.ts +21 -2
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/git.ts +61 -6
- package/src/lifecycle/no-record.test.ts +175 -0
- package/src/lifecycle/no-record.ts +108 -0
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/op/activities/shell.ts +28 -6
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/emulator-lifecycle.test.ts +24 -1
- package/src/op/emulator-lifecycle.ts +58 -2
- package/src/op/gate.ts +3 -0
- package/src/op/index.ts +1 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +88 -8
- package/src/op/op-waves-jobs.test.ts +213 -0
- package/src/op/op-waves-notify.ts +11 -5
- package/src/op/op-waves-run.ts +42 -11
- package/src/op/op-waves.ts +202 -2
- package/src/op/receipt-store.ts +13 -3
- package/src/op/runtimes/local.ts +6 -0
- package/src/op/step-console.test.ts +150 -0
- package/src/op/step-console.ts +155 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +3 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/compose-graph.test.ts +55 -0
- package/src/workspace/compose-graph.ts +28 -6
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/graph-contract.test.ts +26 -1
- package/src/workspace/graph.schema.json +68 -1
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/nested-graph.test.ts +14 -0
- package/src/workspace/nested-graph.ts +7 -2
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- package/src/workspace/runs.ts +4 -2
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { join } from "node:path";
|
|
11
12
|
import Ajv2020 from "ajv/dist/2020";
|
|
12
13
|
import schema from "../records.schema.json";
|
|
13
14
|
import { afterEach, describe, expect, test } from "vitest";
|
|
@@ -357,6 +358,110 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
|
|
|
357
358
|
});
|
|
358
359
|
});
|
|
359
360
|
|
|
361
|
+
describe.skipIf(!hasSshKeygen)("writeScope and agents in the declaration are protected (#3079)", () => {
|
|
362
|
+
const scoped = (over: Record<string, unknown> = {}) =>
|
|
363
|
+
JSON.stringify(
|
|
364
|
+
{
|
|
365
|
+
name: "ws",
|
|
366
|
+
schema: 1,
|
|
367
|
+
members: [{ name: "app", dir: "app", kind: "other", because: "plain files" }],
|
|
368
|
+
writeScope: { agent: { records: {} } },
|
|
369
|
+
agents: [{ name: "app-agent", member: "app" }],
|
|
370
|
+
...over,
|
|
371
|
+
},
|
|
372
|
+
null,
|
|
373
|
+
2,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
/** main: alice and bob sign, alice is admin when `admin`; the declaration at base restricts agents. */
|
|
377
|
+
function baseline(label: string, opts: { admin?: boolean; declaration?: string; dir?: string } = {}) {
|
|
378
|
+
const r = repo(label);
|
|
379
|
+
const alice = r.key("alice");
|
|
380
|
+
const bob = r.key("bob");
|
|
381
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob]));
|
|
382
|
+
if (opts.admin) r.write(".chant/trust.json", JSON.stringify({ schema: 1, roles: { admin: ["alice@example.test"] } }));
|
|
383
|
+
r.commit("policy", alice);
|
|
384
|
+
r.write(`${opts.dir ? `${opts.dir}/` : ""}chant.workspace.json`, opts.declaration ?? scoped());
|
|
385
|
+
r.commit("declaration", alice);
|
|
386
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
387
|
+
return { r, alice, bob };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
test("an unsigned commit that widens writeScope is a refused protected write", async () => {
|
|
391
|
+
const { r } = baseline("scope-unsigned");
|
|
392
|
+
r.write("chant.workspace.json", scoped({ writeScope: { agent: { records: { decision: ["new", "amend"] } } } }));
|
|
393
|
+
const c = r.commit("let agents amend");
|
|
394
|
+
const report = await verify(r);
|
|
395
|
+
expect(report.ok).toBe(false);
|
|
396
|
+
expect(report.protectedWrites).toEqual([expect.objectContaining({ commit: c, paths: ["chant.workspace.json"], allowed: false })]);
|
|
397
|
+
expect(report.failures.join("\n")).toMatch(/chant\.workspace\.json \(writeScope\) is protected/);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("a change to agents is a protected write, allowed when a signer at base signs it", async () => {
|
|
401
|
+
const { r, alice } = baseline("agents-signed");
|
|
402
|
+
r.write("chant.workspace.json", scoped({ agents: [{ name: "app-agent", member: "app" }, { name: "other", member: "app" }] }));
|
|
403
|
+
r.commit("add a session", alice);
|
|
404
|
+
const report = await verify(r);
|
|
405
|
+
expect(report.protectedWrites).toEqual([expect.objectContaining({ allowed: true, principal: "alice@example.test", blocks: ["agents"] })]);
|
|
406
|
+
expect(report.ok).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("with an admin role granted, a signer who is not an admin may not change writeScope", async () => {
|
|
410
|
+
const { r, bob } = baseline("scope-admin", { admin: true });
|
|
411
|
+
r.write("chant.workspace.json", scoped({ writeScope: {} }));
|
|
412
|
+
r.commit("drop the scope", bob);
|
|
413
|
+
const report = await verify(r);
|
|
414
|
+
expect(report.protectedWrites[0]).toMatchObject({ allowed: false, principal: "bob@example.test", blocks: ["writeScope"] });
|
|
415
|
+
expect(report.ok).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("deleting the declaration drops the scope, and is a protected write", async () => {
|
|
419
|
+
const { r } = baseline("scope-deleted");
|
|
420
|
+
r.git(["rm", "-q", "chant.workspace.json"]);
|
|
421
|
+
r.commit("no more workspace");
|
|
422
|
+
const report = await verify(r);
|
|
423
|
+
expect(report.protectedWrites[0]).toMatchObject({ allowed: false, blocks: ["writeScope", "agents"] });
|
|
424
|
+
expect(report.ok).toBe(false);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("other edits to the declaration are not protected", async () => {
|
|
428
|
+
const { r } = baseline("scope-other");
|
|
429
|
+
r.write("chant.workspace.json", scoped({ name: "renamed" }));
|
|
430
|
+
r.commit("rename");
|
|
431
|
+
const report = await verify(r);
|
|
432
|
+
expect(report.protectedWrites).toEqual([]);
|
|
433
|
+
expect(report.declaration).toEqual({ path: "chant.workspace.json", blocks: ["writeScope", "agents"], changed: [] });
|
|
434
|
+
expect(report.ok).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("a scope the change adds, where the declaration at base has none, is not protected", async () => {
|
|
438
|
+
const { r } = baseline("scope-none", { declaration: JSON.stringify({ name: "ws", schema: 1, members: [] }) });
|
|
439
|
+
r.write("chant.workspace.json", scoped());
|
|
440
|
+
r.commit("restrict agents");
|
|
441
|
+
const report = await verify(r);
|
|
442
|
+
expect(report.protectedWrites).toEqual([]);
|
|
443
|
+
expect(report.declaration).toBeNull();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("a change and its revert inside the range leave nothing to protect", async () => {
|
|
447
|
+
const { r } = baseline("scope-revert");
|
|
448
|
+
r.write("chant.workspace.json", scoped({ writeScope: {} }));
|
|
449
|
+
r.commit("drop");
|
|
450
|
+
r.write("chant.workspace.json", scoped());
|
|
451
|
+
r.commit("restore");
|
|
452
|
+
const report = await verify(r);
|
|
453
|
+
expect(report.protectedWrites).toEqual([]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("the declaration is found from cwd, as check --changes finds it", async () => {
|
|
457
|
+
const { r } = baseline("scope-nested", { dir: "ws" });
|
|
458
|
+
r.write("ws/chant.workspace.json", scoped({ writeScope: {} }));
|
|
459
|
+
r.commit("drop");
|
|
460
|
+
const report = await verifyChange({ repo: r.dir, cwd: join(r.dir, "ws"), base: "main", attestors: await activeAttestors() });
|
|
461
|
+
expect(report.protectedWrites).toEqual([expect.objectContaining({ paths: ["ws/chant.workspace.json"], allowed: false })]);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
360
465
|
describe("attestation is opt-in", () => {
|
|
361
466
|
test("without a signers file at base nothing is verified, and every record is unattested", async () => {
|
|
362
467
|
const r = repo("off");
|
|
@@ -26,6 +26,7 @@ export async function runWorkspaceVerify(ctx: CommandContext): Promise<number> {
|
|
|
26
26
|
}
|
|
27
27
|
const report = verifyChange({
|
|
28
28
|
repo,
|
|
29
|
+
cwd: process.cwd(),
|
|
29
30
|
base: args.base,
|
|
30
31
|
head: args.head,
|
|
31
32
|
require: args.require === "attested" || args.require === "attested-runs" ? args.require : undefined,
|
|
@@ -49,7 +50,7 @@ function printReport(r: ChangeReport): void {
|
|
|
49
50
|
lines.push(` ${c.commit.slice(0, 8)} ${c.level}${who} ${c.subject}${note}`);
|
|
50
51
|
}
|
|
51
52
|
for (const c of r.runs.commits) lines.push(` ${c.commit.slice(0, 8)} agent run ${c.runs.join(", ") || c.run}: ${c.attested ? "attested" : "not attested"}, ${c.reason}`);
|
|
52
|
-
for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
|
|
53
|
+
for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}${w.blocks ? ` (${w.blocks.join(", ")})` : ""}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
|
|
53
54
|
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(", ")}`);
|
|
54
55
|
for (const n of r.notes) lines.push(n);
|
|
55
56
|
console.log(lines.join("\n"));
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* that touches those files must be attested by a signer the base policy
|
|
9
9
|
* trusts, and by an admin when the base policy grants the admin role.
|
|
10
10
|
*
|
|
11
|
+
* The write scope is policy too (#3079): when the workspace declaration at
|
|
12
|
+
* base has a `writeScope` or `agents` block, a commit that changes either
|
|
13
|
+
* block, or removes the declaration, is a protected write on the same terms.
|
|
14
|
+
* Other edits to the declaration are not. The declaration is found from
|
|
15
|
+
* `cwd`, as `chant workspace check --changes` finds it.
|
|
16
|
+
*
|
|
11
17
|
* Attestation is opt-in (#2525 rule 6). Without a signers file at base the
|
|
12
18
|
* check passes and says so, unless `--require attested` asks for more.
|
|
13
19
|
*
|
|
@@ -22,6 +28,11 @@
|
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import { execFileSync } from "node:child_process";
|
|
31
|
+
import { posix } from "node:path";
|
|
32
|
+
import { isDeepStrictEqual } from "node:util";
|
|
33
|
+
import { DECLARATION_FILES, WorkspaceReadError } from "../declaration";
|
|
34
|
+
import { parseJsonText } from "../jsonc";
|
|
35
|
+
import { locateWorkspace } from "../which-chant";
|
|
25
36
|
import { type CommitAttestor, type ProvenanceLevel } from "./attestor";
|
|
26
37
|
import { policyAtBase, commitProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./provenance";
|
|
27
38
|
import { policyWriters, protectedPaths, type ExcludedSigner, type TrustPolicy } from "./policy";
|
|
@@ -41,6 +52,8 @@ export interface CommitVerdict extends RecordProvenance {
|
|
|
41
52
|
export interface ProtectedWrite {
|
|
42
53
|
commit: string;
|
|
43
54
|
paths: string[];
|
|
55
|
+
/** For a declaration write (#3079): the scope blocks the commit changes. */
|
|
56
|
+
blocks?: ScopeBlock[];
|
|
44
57
|
level: ProvenanceLevel;
|
|
45
58
|
principal?: string;
|
|
46
59
|
allowed: boolean;
|
|
@@ -67,6 +80,12 @@ export interface ChangeReport {
|
|
|
67
80
|
/** Protected paths whose content differs between the merge base and head. */
|
|
68
81
|
protectedChanged: string[];
|
|
69
82
|
protectedWrites: ProtectedWrite[];
|
|
83
|
+
/**
|
|
84
|
+
* The workspace declaration at base, when it has a scope block (#3079): its
|
|
85
|
+
* path from the repository root, the blocks it has, and the blocks that
|
|
86
|
+
* differ between the merge base and head.
|
|
87
|
+
*/
|
|
88
|
+
declaration: { path: string; blocks: ScopeBlock[]; changed: ScopeBlock[] } | null;
|
|
70
89
|
/**
|
|
71
90
|
* When the change edits the signer set (#2553): the version it proposes and
|
|
72
91
|
* who signed it, or, when the new set is refused, the version it had to
|
|
@@ -80,6 +99,8 @@ export interface ChangeReport {
|
|
|
80
99
|
|
|
81
100
|
export interface VerifyOptions {
|
|
82
101
|
repo: string;
|
|
102
|
+
/** Where to look for the workspace declaration, as `check --changes` does; the repository root by default. */
|
|
103
|
+
cwd?: string;
|
|
83
104
|
base?: string;
|
|
84
105
|
head?: string;
|
|
85
106
|
require?: VerifyRequirement;
|
|
@@ -106,6 +127,51 @@ export function commitsTouching(repo: string, range: string, paths: string[]): M
|
|
|
106
127
|
return out;
|
|
107
128
|
}
|
|
108
129
|
|
|
130
|
+
/** The declaration blocks whose change is a protected write (#3079). */
|
|
131
|
+
export type ScopeBlock = "writeScope" | "agents";
|
|
132
|
+
const SCOPE_BLOCKS: readonly ScopeBlock[] = ["writeScope", "agents"];
|
|
133
|
+
|
|
134
|
+
/** The scope blocks of the declaration in `dir` at `rev`: absent ones undefined, or the text when it can't be read. */
|
|
135
|
+
type ScopeState = { blocks: Partial<Record<ScopeBlock, unknown>> } | { unreadable: string };
|
|
136
|
+
|
|
137
|
+
function scopeState(repo: string, rev: string, dir: string): ScopeState {
|
|
138
|
+
const present = DECLARATION_FILES.map((name) => ({ name, text: fileAt(repo, rev, posix.join(dir, name)) })).filter((f) => f.text !== undefined);
|
|
139
|
+
if (present.length === 0) return { blocks: {} };
|
|
140
|
+
if (present.length > 1) return { unreadable: present.map((f) => f.text).join("\0") };
|
|
141
|
+
const parsed = parseJsonText(present[0].text!, { jsonc: present[0].name.endsWith(".jsonc") });
|
|
142
|
+
if (!parsed.ok || typeof parsed.value !== "object" || parsed.value === null || Array.isArray(parsed.value)) return { unreadable: present[0].text! };
|
|
143
|
+
const obj = parsed.value as Record<string, unknown>;
|
|
144
|
+
return { blocks: Object.fromEntries(SCOPE_BLOCKS.filter((b) => obj[b] !== undefined).map((b) => [b, obj[b]])) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The scope blocks that differ between two states; all of them when either can't be read and the texts differ. */
|
|
148
|
+
function changedBlocks(a: ScopeState, b: ScopeState): ScopeBlock[] {
|
|
149
|
+
if ("unreadable" in a || "unreadable" in b) {
|
|
150
|
+
return "unreadable" in a && "unreadable" in b && a.unreadable === b.unreadable ? [] : [...SCOPE_BLOCKS];
|
|
151
|
+
}
|
|
152
|
+
return SCOPE_BLOCKS.filter((k) => !isDeepStrictEqual(a.blocks[k], b.blocks[k]));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The directory, from the repository root, of the declaration found from
|
|
157
|
+
* `cwd` at `base`, with the scope blocks it has there. Null when there is no
|
|
158
|
+
* declaration, or one with neither block: then nothing in it is protected. A
|
|
159
|
+
* declaration that can't be read at base counts as having both.
|
|
160
|
+
*/
|
|
161
|
+
function scopedDeclarationAt(repo: string, cwd: string, base: string): { dir: string; blocks: ScopeBlock[] } | null {
|
|
162
|
+
let dir: string;
|
|
163
|
+
try {
|
|
164
|
+
const root = locateWorkspace(cwd, base).root;
|
|
165
|
+
dir = root === "." ? "" : root;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof WorkspaceReadError) return null;
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
const state = scopeState(repo, base, dir);
|
|
171
|
+
const blocks = "unreadable" in state ? [...SCOPE_BLOCKS] : SCOPE_BLOCKS.filter((b) => b in state.blocks);
|
|
172
|
+
return blocks.length > 0 ? { dir, blocks } : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
109
175
|
/** Whether a merge commit changes nothing beyond what its parents bring. */
|
|
110
176
|
function mergeWithoutOwnChanges(repo: string, commit: string, parents: number): boolean {
|
|
111
177
|
if (parents < 2) return false;
|
|
@@ -134,6 +200,7 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
|
|
|
134
200
|
protectedPaths: protectedPaths(policy),
|
|
135
201
|
protectedChanged: [],
|
|
136
202
|
protectedWrites: [],
|
|
203
|
+
declaration: null,
|
|
137
204
|
rotation: null,
|
|
138
205
|
notes: [],
|
|
139
206
|
failures: [],
|
|
@@ -157,7 +224,8 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
|
|
|
157
224
|
|
|
158
225
|
// Every commit in the change, oldest first, with its parents.
|
|
159
226
|
const listing = git(repo, ["rev-list", "--reverse", "--parents", `${base.commit}..${head}`]).split("\n").filter(Boolean);
|
|
160
|
-
const
|
|
227
|
+
const parentList = new Map(listing.map((l) => { const [c, ...p] = l.split(" "); return [c, p] as const; }));
|
|
228
|
+
const parentsOf = new Map([...parentList].map(([c, p]) => [c, p.length] as const));
|
|
161
229
|
report.runs = changeRuns(repo, [...parentsOf.keys()], policy.runners);
|
|
162
230
|
if (opts.require === "attested-runs") {
|
|
163
231
|
if (policy.runners.length === 0 && report.runs.commits.length > 0) {
|
|
@@ -204,23 +272,48 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
|
|
|
204
272
|
}
|
|
205
273
|
|
|
206
274
|
// Protected writes: every commit that touches the policy needs a writer's signature.
|
|
275
|
+
const writers = policyWriters(policy);
|
|
276
|
+
const judge = (commit: string, paths: string[], blocks?: ScopeBlock[]) => {
|
|
277
|
+
const v = verdicts.get(commit) ?? { ...commitProvenance(repo, policy, commit, opts.attestors), commit, subject: "" };
|
|
278
|
+
const allowed = v.level === "attested" && v.principal !== undefined && writers.has(v.principal);
|
|
279
|
+
const who = policy.roles.admin?.length ? "an admin the policy at base names" : "a signer the policy at base lists";
|
|
280
|
+
const what = blocks ? `${paths.join(", ")} (${blocks.join(", ")})` : paths.join(", ");
|
|
281
|
+
const reason = allowed
|
|
282
|
+
? `signed by ${v.principal}, ${who.replace(/^an? /, "")}`
|
|
283
|
+
: v.level === "attested"
|
|
284
|
+
? `signed by ${v.principal}, who is not ${who}`
|
|
285
|
+
: `${what} is protected and needs a signature by ${who}: ${v.reason}`;
|
|
286
|
+
report.protectedWrites.push({ commit, paths, ...(blocks ? { blocks } : {}), level: v.level, ...(v.principal ? { principal: v.principal } : {}), allowed, reason });
|
|
287
|
+
if (!allowed) report.failures.push(`${commit.slice(0, 8)} changes ${what}: ${reason}`);
|
|
288
|
+
};
|
|
207
289
|
if (changedPaths.length > 0) {
|
|
208
|
-
const writers = policyWriters(policy);
|
|
209
290
|
const touching = commitsTouching(repo, `${base.commit}..${head}`, report.protectedPaths);
|
|
210
291
|
if (touching.size === 0) {
|
|
211
292
|
report.failures.push(`the policy files ${changedPaths.join(", ")} differ from base, and no commit in the change accounts for it`);
|
|
212
293
|
}
|
|
213
|
-
for (const [commit, paths] of touching)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
294
|
+
for (const [commit, paths] of touching) judge(commit, paths);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The write scope in the declaration at base (#3079): a commit that changes writeScope or agents.
|
|
298
|
+
const scoped = scopedDeclarationAt(repo, opts.cwd ?? repo, base.commit);
|
|
299
|
+
if (scoped) {
|
|
300
|
+
const files = DECLARATION_FILES.map((name) => posix.join(scoped.dir, name));
|
|
301
|
+
const changed = changedBlocks(scopeState(repo, mergeBase, scoped.dir), scopeState(repo, head, scoped.dir));
|
|
302
|
+
report.declaration = { path: files.find((f) => fileAt(repo, base.commit!, f) !== undefined) ?? files[0], blocks: scoped.blocks, changed };
|
|
303
|
+
if (changed.length > 0) {
|
|
304
|
+
let accounted = false;
|
|
305
|
+
for (const [commit, paths] of commitsTouching(repo, `${base.commit}..${head}`, files)) {
|
|
306
|
+
const after = scopeState(repo, commit, scoped.dir);
|
|
307
|
+
// A merge changes a block only when it differs from every parent.
|
|
308
|
+
const per = (parentList.get(commit) ?? []).map((p) => new Set(changedBlocks(scopeState(repo, p, scoped.dir), after)));
|
|
309
|
+
const blocks = SCOPE_BLOCKS.filter((b) => per.length > 0 && per.every((s) => s.has(b)));
|
|
310
|
+
if (blocks.length === 0) continue;
|
|
311
|
+
accounted = true;
|
|
312
|
+
judge(commit, paths, blocks);
|
|
313
|
+
}
|
|
314
|
+
if (!accounted) {
|
|
315
|
+
report.failures.push(`${changed.join(" and ")} in ${report.declaration.path} differ from base, and no commit in the change accounts for it`);
|
|
316
|
+
}
|
|
224
317
|
}
|
|
225
318
|
}
|
|
226
319
|
|
|
@@ -371,6 +371,24 @@ describe("chant workspace agent: what a session reloads from (#2548)", () => {
|
|
|
371
371
|
agent.expectValid(doc);
|
|
372
372
|
expect(code(doc)).toBe("agent-unknown");
|
|
373
373
|
});
|
|
374
|
+
|
|
375
|
+
test("a declaration that fails validation is declaration-invalid, with the problem, not declaration-missing (#3433)", async () => {
|
|
376
|
+
const broken = JSON.stringify({ name: "studio", schema: 1, members: [{ name: "app", dir: "app" }], agents: AGENTS });
|
|
377
|
+
for (const committed of [false, true]) {
|
|
378
|
+
const dir = repo({ "chant.workspace.json": broken, "app/server.mjs": "export {};\n" }, committed);
|
|
379
|
+
if (committed) git(dir, "branch", "-M", "main");
|
|
380
|
+
const doc = await agentSession({ cwd: dir, name: "app-agent" });
|
|
381
|
+
agent.expectValid(doc);
|
|
382
|
+
expect(code(doc), committed ? "committed on main" : "uncommitted").toBe("declaration-invalid");
|
|
383
|
+
expect("error" in doc ? doc.error.message : "").toMatch(/kind/);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("no declaration at all is still declaration-missing", async () => {
|
|
388
|
+
const doc = await agentSession({ cwd: repo({ "app/server.mjs": "export {};\n" }), name: "app-agent" });
|
|
389
|
+
agent.expectValid(doc);
|
|
390
|
+
expect(code(doc)).toBe("declaration-missing");
|
|
391
|
+
});
|
|
374
392
|
});
|
|
375
393
|
|
|
376
394
|
describe("principal classes a plugin defines (#3080)", () => {
|
|
@@ -352,18 +352,28 @@ export interface ScopeSource {
|
|
|
352
352
|
policy: TrustPolicy;
|
|
353
353
|
/** The principal classes: the core four and those the declaration's pins supply, path pins read where the declaration was (#3080). */
|
|
354
354
|
classes: ClassRegistry;
|
|
355
|
+
/** Why the working tree's declaration couldn't be read when `declaration` is null: declaration-missing, or declaration-invalid with the problem (#3433). */
|
|
356
|
+
failure?: WorkspaceReadError;
|
|
355
357
|
}
|
|
356
358
|
|
|
357
|
-
|
|
359
|
+
type ReadAt = { declaration: Declaration; rootOnDisk: string; root: string; tree: WorkspaceTree };
|
|
360
|
+
|
|
361
|
+
/** The declaration at `at` (the working tree without it), or the read error that says why there is none. */
|
|
362
|
+
function readOrFailure(cwd: string, at?: string): ReadAt | WorkspaceReadError {
|
|
358
363
|
try {
|
|
359
364
|
const located = locateWorkspace(cwd, at);
|
|
360
365
|
return { declaration: readDeclaration(located.tree), rootOnDisk: located.rootOnDisk, root: located.root, tree: located.tree };
|
|
361
366
|
} catch (err) {
|
|
362
|
-
if (err instanceof WorkspaceReadError) return
|
|
367
|
+
if (err instanceof WorkspaceReadError) return err;
|
|
363
368
|
throw err;
|
|
364
369
|
}
|
|
365
370
|
}
|
|
366
371
|
|
|
372
|
+
function readAt(cwd: string, at?: string): ReadAt | null {
|
|
373
|
+
const read = readOrFailure(cwd, at);
|
|
374
|
+
return read instanceof WorkspaceReadError ? null : read;
|
|
375
|
+
}
|
|
376
|
+
|
|
367
377
|
/**
|
|
368
378
|
* The scope for a write from `cwd`: the declaration and the trust policy at
|
|
369
379
|
* the base revision (`origin/HEAD`, `main` or `master`), so a write can't
|
|
@@ -375,7 +385,7 @@ export function scopeSource(cwd: string, at?: string): ScopeSource {
|
|
|
375
385
|
const top = gitRoot(cwd);
|
|
376
386
|
const base = top ? resolveBase(top, at) : null;
|
|
377
387
|
const policy = top && base ? policyAtBase(top, base) : emptyPolicy(null);
|
|
378
|
-
const withClasses = (read:
|
|
388
|
+
const withClasses = (read: ReadAt) => {
|
|
379
389
|
const { tree, ...rest } = read;
|
|
380
390
|
return { ...rest, classes: loadClassRegistry(read.declaration.pins, read.rootOnDisk, { tree }).registry };
|
|
381
391
|
};
|
|
@@ -383,8 +393,9 @@ export function scopeSource(cwd: string, at?: string): ScopeSource {
|
|
|
383
393
|
const atBase = readAt(cwd, base.commit);
|
|
384
394
|
if (atBase) return { ...withClasses(atBase), from: "base", policy };
|
|
385
395
|
}
|
|
386
|
-
const here =
|
|
387
|
-
|
|
396
|
+
const here = readOrFailure(cwd);
|
|
397
|
+
if (here instanceof WorkspaceReadError) return { declaration: null, from: null, rootOnDisk: null, root: null, policy, classes: coreClassRegistry(), failure: here };
|
|
398
|
+
return { ...withClasses(here), from: "working-tree", policy };
|
|
388
399
|
}
|
|
389
400
|
|
|
390
401
|
const toPosix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"));
|