@intentius/chant 0.90.0 → 0.91.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.
Files changed (164) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-tools.d.ts +6 -4
  8. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +31 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  12. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  13. package/dist/lifecycle/git.d.ts +40 -5
  14. package/dist/lifecycle/git.d.ts.map +1 -1
  15. package/dist/lifecycle/lease.d.ts +72 -16
  16. package/dist/lifecycle/lease.d.ts.map +1 -1
  17. package/dist/lifecycle/member-ledger.d.ts +3 -2
  18. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  20. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  21. package/dist/lifecycle/work-lease.d.ts +140 -0
  22. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  23. package/dist/op/activities/activity-contracts.d.ts +2 -2
  24. package/dist/op/builders.d.ts.map +1 -1
  25. package/dist/op/discover.d.ts +25 -0
  26. package/dist/op/discover.d.ts.map +1 -1
  27. package/dist/op/index.d.ts +9 -3
  28. package/dist/op/index.d.ts.map +1 -1
  29. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  30. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  31. package/dist/op/local-executor.d.ts +19 -0
  32. package/dist/op/local-executor.d.ts.map +1 -1
  33. package/dist/op/local-output.d.ts.map +1 -1
  34. package/dist/op/op-ir.d.ts +10 -1
  35. package/dist/op/op-ir.d.ts.map +1 -1
  36. package/dist/op/operator.d.ts +29 -0
  37. package/dist/op/operator.d.ts.map +1 -1
  38. package/dist/op/runtime.d.ts +9 -0
  39. package/dist/op/runtime.d.ts.map +1 -1
  40. package/dist/op/runtimes/local.d.ts.map +1 -1
  41. package/dist/op/step-output-ref.d.ts +2 -2
  42. package/dist/op/step-output-ref.d.ts.map +1 -1
  43. package/dist/op/steward.d.ts +140 -0
  44. package/dist/op/steward.d.ts.map +1 -0
  45. package/dist/op/types.d.ts +51 -0
  46. package/dist/op/types.d.ts.map +1 -1
  47. package/dist/op/work-lease-decl.d.ts +18 -0
  48. package/dist/op/work-lease-decl.d.ts.map +1 -0
  49. package/dist/op/work-lease-run.d.ts +173 -0
  50. package/dist/op/work-lease-run.d.ts.map +1 -0
  51. package/dist/workspace/box-isolation.d.ts +99 -0
  52. package/dist/workspace/box-isolation.d.ts.map +1 -0
  53. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  54. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  55. package/dist/workspace/checks/boxes.d.ts +71 -0
  56. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  57. package/dist/workspace/checks/records.d.ts +1 -0
  58. package/dist/workspace/checks/records.d.ts.map +1 -1
  59. package/dist/workspace/checks.d.ts +10 -1
  60. package/dist/workspace/checks.d.ts.map +1 -1
  61. package/dist/workspace/decide.d.ts +184 -0
  62. package/dist/workspace/decide.d.ts.map +1 -0
  63. package/dist/workspace/decision-points.schema.json +137 -0
  64. package/dist/workspace/declaration.d.ts +51 -0
  65. package/dist/workspace/declaration.d.ts.map +1 -1
  66. package/dist/workspace/declaration.schema.json +172 -0
  67. package/dist/workspace/declared-kinds.d.ts +12 -0
  68. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  69. package/dist/workspace/points-cli.d.ts +113 -0
  70. package/dist/workspace/points-cli.d.ts.map +1 -0
  71. package/dist/workspace/points.d.ts +320 -0
  72. package/dist/workspace/points.d.ts.map +1 -0
  73. package/dist/workspace/reason-codes.d.ts +26 -0
  74. package/dist/workspace/reason-codes.d.ts.map +1 -1
  75. package/dist/workspace/record-assets.d.ts.map +1 -1
  76. package/dist/workspace/records-cli.d.ts +12 -0
  77. package/dist/workspace/records-cli.d.ts.map +1 -1
  78. package/dist/workspace/records.d.ts +8 -2
  79. package/dist/workspace/records.d.ts.map +1 -1
  80. package/dist/workspace/status-stewards.d.ts +121 -0
  81. package/dist/workspace/status-stewards.d.ts.map +1 -0
  82. package/dist/workspace/status.d.ts +52 -1
  83. package/dist/workspace/status.d.ts.map +1 -1
  84. package/dist/workspace/work-cli.d.ts +78 -0
  85. package/dist/workspace/work-cli.d.ts.map +1 -0
  86. package/package.json +1 -1
  87. package/src/cli/handlers/components.test.ts +93 -0
  88. package/src/cli/handlers/components.ts +44 -3
  89. package/src/cli/handlers/operator.ts +107 -2
  90. package/src/cli/handlers/run.test.ts +19 -0
  91. package/src/cli/handlers/run.ts +53 -1
  92. package/src/cli/main.test.ts +40 -0
  93. package/src/cli/main.ts +78 -2
  94. package/src/cli/mcp/workspace-tools.test.ts +14 -1
  95. package/src/cli/mcp/workspace-tools.ts +49 -5
  96. package/src/cli/registry.ts +31 -0
  97. package/src/components/verbs/vuln-scan.test.ts +124 -1
  98. package/src/components/verbs/vuln-scan.ts +142 -1
  99. package/src/lifecycle/git.ts +65 -15
  100. package/src/lifecycle/lease.test.ts +22 -0
  101. package/src/lifecycle/lease.ts +133 -29
  102. package/src/lifecycle/member-ledger.ts +3 -2
  103. package/src/lifecycle/plan-ledger.test.ts +148 -0
  104. package/src/lifecycle/plan-ledger.ts +158 -0
  105. package/src/lifecycle/work-lease.test.ts +236 -0
  106. package/src/lifecycle/work-lease.ts +426 -0
  107. package/src/op/builders.ts +5 -0
  108. package/src/op/discover.ts +71 -0
  109. package/src/op/index.ts +16 -3
  110. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  111. package/src/op/lifecycle-receipt-store.ts +61 -0
  112. package/src/op/local-executor.ts +216 -18
  113. package/src/op/local-output.ts +13 -0
  114. package/src/op/op-ir.ts +14 -0
  115. package/src/op/operator.ts +75 -4
  116. package/src/op/runtime.ts +6 -0
  117. package/src/op/runtimes/local.ts +3 -0
  118. package/src/op/step-output-ref.ts +6 -2
  119. package/src/op/steward.test.ts +212 -0
  120. package/src/op/steward.ts +253 -0
  121. package/src/op/types.ts +53 -0
  122. package/src/op/work-lease-decl.ts +80 -0
  123. package/src/op/work-lease-run.test.ts +326 -0
  124. package/src/op/work-lease-run.ts +395 -0
  125. package/src/workspace/box-isolation.test.ts +261 -0
  126. package/src/workspace/box-isolation.ts +205 -0
  127. package/src/workspace/check-contract.test.ts +3 -1
  128. package/src/workspace/check.schema.json +15 -7
  129. package/src/workspace/checks/box-isolation.ts +68 -0
  130. package/src/workspace/checks/boxes.test.ts +197 -0
  131. package/src/workspace/checks/boxes.ts +307 -0
  132. package/src/workspace/checks/records.ts +25 -0
  133. package/src/workspace/checks.test.ts +7 -0
  134. package/src/workspace/checks.ts +19 -2
  135. package/src/workspace/decide.test.ts +224 -0
  136. package/src/workspace/decide.ts +576 -0
  137. package/src/workspace/decision-points.schema.json +137 -0
  138. package/src/workspace/declaration.schema.json +172 -0
  139. package/src/workspace/declaration.ts +137 -0
  140. package/src/workspace/declared-kinds.ts +25 -2
  141. package/src/workspace/intent.schema.json +4 -1
  142. package/src/workspace/point-answer.schema.json +95 -0
  143. package/src/workspace/points-cli.ts +273 -0
  144. package/src/workspace/points-write.schema.json +489 -0
  145. package/src/workspace/points.schema.json +710 -0
  146. package/src/workspace/points.test.ts +264 -0
  147. package/src/workspace/points.ts +564 -0
  148. package/src/workspace/read-contract.test.ts +18 -1
  149. package/src/workspace/reason-codes.test.ts +14 -1
  150. package/src/workspace/reason-codes.ts +33 -0
  151. package/src/workspace/record-assets.test.ts +3 -2
  152. package/src/workspace/record-assets.ts +4 -1
  153. package/src/workspace/records-cli.ts +15 -2
  154. package/src/workspace/records-contract.test.ts +3 -2
  155. package/src/workspace/records.schema.json +17 -0
  156. package/src/workspace/records.ts +33 -3
  157. package/src/workspace/status-contract.test.ts +178 -0
  158. package/src/workspace/status-stewards.ts +225 -0
  159. package/src/workspace/status.schema.json +230 -4
  160. package/src/workspace/status.ts +106 -5
  161. package/src/workspace/work-cli.test.ts +180 -0
  162. package/src/workspace/work-cli.ts +246 -0
  163. package/src/workspace/work-lease.schema.json +233 -0
  164. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -85,7 +85,7 @@ describe("which servers have the workspace tools", () => {
85
85
  const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
86
86
  expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
87
87
  }
88
- expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "records-new", "records-amend", "records-review", "records-close"]);
88
+ expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "workspace-points", "records-new", "records-amend", "records-review", "records-close", "points-answer"]);
89
89
  });
90
90
 
91
91
  test("a server outside any workspace, or given none, does not", async () => {
@@ -115,6 +115,19 @@ describe("reads", () => {
115
115
  expect(recordsOf(only.structuredContent).map((r) => r.id)).toEqual(["fix-001"]);
116
116
  }, 120_000);
117
117
 
118
+ test("workspace-points returns the document chant workspace points --json prints, and points-answer the write's (ws-058)", async () => {
119
+ const s = server();
120
+ const res = await call(s, "workspace-points", { open: true });
121
+ expect(res.isError).toBeUndefined();
122
+ expect(res.structuredContent).toEqual(cli(["workspace", "points", "--open", "--json"]));
123
+ expect(res.structuredContent).toMatchObject({ open: true, sources: [], points: [], questions: [] });
124
+ // The generated workspace declares no answer kind, so there is nothing to answer.
125
+ const answered = await call(s, "points-answer", { id: "slice-tier-000000000000", answer: "small", by: ["alice"] });
126
+ expect(answered.structuredContent).toMatchObject({ verb: "answer", error: { code: "points-undeclared" } });
127
+ const bad = await call(s, "points-answer", { id: "slice-tier-000000000000", answer: "small", by: [] });
128
+ expect(bad.isError).toBe(true);
129
+ }, 120_000);
130
+
118
131
  test("an error document is returned as a document, and a flag-shaped value is refused before chant runs", async () => {
119
132
  const s = server();
120
133
  const missing = await call(s, "workspace-records", { kind: "nowhere/none.kind.mjs" });
@@ -6,15 +6,17 @@
6
6
  * is a thin call into the code the CLI runs:
7
7
  *
8
8
  * - The reads (`workspace-ls`, `workspace-status`, `workspace-graph`,
9
- * `workspace-records`) run `chant workspace <command> ... --json` with the
10
- * chant this server runs as, in the server's directory, and return the
9
+ * `workspace-records`, `workspace-points`) run `chant workspace <command>
10
+ * ... --json` with the chant this server runs as, in the server's
11
+ * directory, and return the
11
12
  * document it printed, unchanged, with its reason codes (#2536). Running the
12
13
  * command, rather than calling into it, keeps every rule it has, handing the
13
14
  * read to the workspace root's pinned chant included (ws-021), and keeps
14
15
  * anything a command prints away from the protocol on stdout.
15
16
  * - The writes (`records-new`, `records-amend`, `records-review`,
16
- * `records-close`) call the functions `chant workspace records new|amend|
17
- * review|close` call, and return the same JSON result. They keep every rule
17
+ * `records-close`, `points-answer`) call the functions `chant workspace
18
+ * records new|amend|review|close` and `points answer` call, and return the
19
+ * same JSON result. They keep every rule
18
20
  * the CLI keeps: the kind's schema, a closed record never changing, an
19
21
  * approved one changing only as its approval rule allows, a dissent needing
20
22
  * a note, and `sign` using this host's configured key or refusing with the
@@ -75,7 +77,7 @@ export const workspaceReadTools: ToolDefinition[] = [
75
77
  {
76
78
  name: "workspace-status",
77
79
  description:
78
- "What each member has released to an environment, from the lifecycle ledgers: chant workspace status <env> --json. Returns that document unchanged (status.schema.json).",
80
+ "What each member has released to an environment, its gates, and the steward that runs it with each Op's last run, from the declaration and the lifecycle ledgers: chant workspace status <env> --json. Returns that document unchanged (status.schema.json).",
79
81
  inputSchema: {
80
82
  type: "object",
81
83
  properties: {
@@ -119,6 +121,19 @@ export const workspaceReadTools: ToolDefinition[] = [
119
121
  },
120
122
  },
121
123
  },
124
+ {
125
+ name: "workspace-points",
126
+ description:
127
+ "The workspace's decision points and the questions asked of them: chant workspace points --json (points.schema.json, ws-058). A question is open while it is escalated to people, or proposed by a model and not yet confirmed; each lists any model's answer with its confidence and threshold. Returns the document unchanged. chant calls no model to answer this.",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ open: { type: "boolean", description: "Only the open questions (--open)." },
132
+ kind: { type: "string", description: "One answer kind file, in place of the declared ones (--kind)." },
133
+ at: atProp,
134
+ },
135
+ },
136
+ },
122
137
  ];
123
138
 
124
139
  export const workspaceWriteTools: ToolDefinition[] = [
@@ -191,6 +206,23 @@ export const workspaceWriteTools: ToolDefinition[] = [
191
206
  required: ["id"],
192
207
  },
193
208
  },
209
+ {
210
+ name: "points-answer",
211
+ description:
212
+ "Record people's answer to an open decision point question, or confirm a model's proposal: chant workspace points answer. Refused unless the point's quorum is met: distinct people, none holding the agent role, each holding one of the quorum's roles when it names any. An answered question never changes. " +
213
+ PROTOCOL,
214
+ inputSchema: {
215
+ type: "object",
216
+ properties: {
217
+ id: { type: "string", description: "The question's id, as workspace-points lists it." },
218
+ answer: { type: ["string", "boolean"], description: "One of the question's candidates; for a noul, true or false." },
219
+ by: { type: "array", items: { type: "string" }, description: "Each person who answered (--by)." },
220
+ kind: { type: "string", description: "The answer kind file. Without it, the declared answer kinds." },
221
+ dryRun: dryRunProp,
222
+ },
223
+ required: ["id", "answer", "by"],
224
+ },
225
+ },
194
226
  ];
195
227
 
196
228
  /** A tool call that can't be made as given: the client gets it as an error result, and nothing runs. */
@@ -249,6 +281,10 @@ export function readArgv(tool: string, params: Record<string, unknown>): string[
249
281
  if (intent !== undefined) return ["workspace", "graph", "--intent", intent, ...kindArgs, ...atArgs, "--json"];
250
282
  return ["workspace", "graph", ...kindArgs, ...atArgs, "--json"];
251
283
  }
284
+ case "workspace-points": {
285
+ const kind = str(params, "kind");
286
+ return ["workspace", "points", ...(bool(params, "open") ? ["--open"] : []), ...(kind !== undefined ? ["--kind", kind] : []), ...atArgs, "--json"];
287
+ }
252
288
  case "workspace-records": {
253
289
  const kind = str(params, "kind");
254
290
  const since = str(params, "since");
@@ -389,6 +425,14 @@ export function createWorkspaceTools(options: WorkspaceToolsOptions): WorkspaceT
389
425
  cwd,
390
426
  });
391
427
  },
428
+ "points-answer": async (params) => {
429
+ const { answerPoint } = await import("../../workspace/decide");
430
+ const answer = params.answer;
431
+ if (typeof answer !== "string" && typeof answer !== "boolean") throw new ToolInputError("answer must be a string, or true or false");
432
+ const by = params.by;
433
+ if (!Array.isArray(by) || by.length === 0 || !by.every((b) => typeof b === "string" && b.trim() !== "")) throw new ToolInputError("by must list each person who answered");
434
+ return answerPoint({ cwd, id: str(params, "id", true)!, answer, by: by as string[], kind: str(params, "kind"), dryRun: bool(params, "dryRun") });
435
+ },
392
436
  "records-close": async (params) => {
393
437
  const w = await write();
394
438
  const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("../../workspace/records-close");
@@ -287,6 +287,18 @@ export interface ParsedArgs {
287
287
  kind?: string;
288
288
  /** Every `--kind` given, in order: `chant workspace graph --intent` reads each (#2651). */
289
289
  kinds?: string[];
290
+ /** Every `--by` given, in order: `chant workspace points answer` counts each person toward the quorum (#2739). */
291
+ bys?: string[];
292
+ /** `chant workspace points --open` (#2739): only the questions still open. */
293
+ open?: boolean;
294
+ /** `chant workspace points ask <point> --inputs <file|->` (#2739): the inputs, as a JSON object. */
295
+ inputs?: string;
296
+ /** `chant workspace points ask <point> --response <file>` (#2739): a POST /v1/systemone response the caller got from a backend. */
297
+ response?: string;
298
+ /** `chant workspace points ask <point> --subject <id>` (#2739): what the question is about. */
299
+ subject?: string;
300
+ /** `chant workspace points answer <id> --answer <value>` (#2739): the people's answer. */
301
+ answer?: string;
290
302
  /** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
291
303
  composites?: boolean;
292
304
  /** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
@@ -369,6 +381,8 @@ export interface ParsedArgs {
369
381
  component?: string;
370
382
  /** `chant components release record --digest <sha256:...>` (#568) — artifact digest to record, joining this release to the build archive/ledger. Also `chant components export --digest <manifestDigest>` (#929) — a build archive manifest digest to export directly, bypassing env/component resolution. */
371
383
  digest?: string;
384
+ /** `chant components release record --release-plan <file>` (ws-055, #2733) — path to a release plan JSON file, persisted content-addressed to `_plans/<digest>.json` on chant/lifecycle and read back through the read contract (`chant workspace status --json`). The plan's own `digest` field supplies `--digest` when it is omitted, and must match it when both are given. Distinct from `--plan` (#2300, below), the Op gate-approval plan digest. */
385
+ releasePlanFile?: string;
372
386
  /** Every `--digest` value, in order (#2602). `chant components promote --digest <component>=<sha256:...>` is repeatable, one per component; the other commands read the single {@link digest}. */
373
387
  digests?: string[];
374
388
  /** `chant components release record --git-sha <sha>` (#568) — git commit the deploy was built from. */
@@ -413,6 +427,23 @@ export interface ParsedArgs {
413
427
  interval?: string;
414
428
  /** `chant operator --lease-ttl <duration>` (#1485) — how long an acquired lease is valid before it's reclaimable by another operator. Default: 5m. */
415
429
  leaseTtl?: string;
430
+ /**
431
+ * `chant operator --steward [<name>]` (#2731) — run a declared steward's
432
+ * local form: its scheduled Ops on their crons, under the steward's own
433
+ * lease. `""` when the flag is given without a name, which picks the
434
+ * project's only steward.
435
+ */
436
+ steward?: string;
437
+ /** `chant workspace work claim|renew|release <id> --holder <name>` (#2732): who holds, or releases, the work lease. */
438
+ holder?: string;
439
+ /** `chant run <op> --work <id>` (#2748): the work item an Op with a work lease runs under. */
440
+ work?: string;
441
+ /** `chant workspace work claim|renew <id> --ttl <seconds|duration>` (#2732): how long the lease lasts unless renewed. */
442
+ ttl?: string;
443
+ /** `chant workspace work renew|release <id> --token <token>` (#2732): the fencing token the caller holds. */
444
+ token?: string;
445
+ /** `chant workspace work release <id> --outcome <text>` (#2732): how the work ended, such as done or not_done. */
446
+ outcome?: string;
416
447
  /** `chant operator --once` (#1485) — run a single round and exit, instead of looping until Ctrl-C. Also the offline test/cron-invoker story. */
417
448
  once?: boolean;
418
449
  /** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
@@ -5,7 +5,8 @@
5
5
  * `scan-vulnerabilities` capability over an injected scanner.
6
6
  */
7
7
 
8
- import { readFileSync } from "node:fs";
8
+ import { readFileSync, mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
9
10
  import { join } from "node:path";
10
11
  import { describe, test, expect } from "vitest";
11
12
  import {
@@ -15,8 +16,13 @@ import {
15
16
  createToolVulnScanner,
16
17
  createScanVulnerabilitiesCapability,
17
18
  autoDetectVulnScanner,
19
+ sbomPackages,
20
+ compareVersions,
21
+ scanWithDatabase,
22
+ createDatabaseVulnScanner,
18
23
  type VulnFinding,
19
24
  type VulnScanner,
25
+ type AdvisoryDatabase,
20
26
  } from "./vuln-scan";
21
27
  import { ToolNotAvailableError } from "./process-runner";
22
28
  import type { SbomDocument } from "./sbom-generator";
@@ -178,6 +184,123 @@ describe("exploitability parsing (#1463)", () => {
178
184
  });
179
185
  });
180
186
 
187
+ // ── offline advisory database (ws-056, INTENTIUS/chant#2735) ─────────────────
188
+ // Ported from chud's supply-chain.mjs tests: an SBOM's purl-derived packages
189
+ // matched against a local, offline advisory database — no scanner binary, no
190
+ // network.
191
+
192
+ const SPDX_SBOM: SbomDocument = {
193
+ format: "spdx",
194
+ mediaType: "application/spdx+json",
195
+ bytes: JSON.stringify({
196
+ packages: [
197
+ { name: "lodash", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: "pkg:npm/lodash@4.17.20" }] },
198
+ { name: "left-pad", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: "pkg:npm/left-pad@1.3.0" }] },
199
+ { name: "no-purl" },
200
+ ],
201
+ }),
202
+ generator: "lockfile",
203
+ };
204
+
205
+ const CYCLONEDX_SBOM: SbomDocument = {
206
+ format: "cyclonedx",
207
+ mediaType: "application/vnd.cyclonedx+json",
208
+ bytes: JSON.stringify({
209
+ components: [{ name: "lodash", purl: "pkg:npm/lodash@4.17.20" }, { name: "left-pad", purl: "pkg:npm/left-pad@1.3.0" }],
210
+ }),
211
+ generator: "lockfile",
212
+ };
213
+
214
+ const DB: AdvisoryDatabase = {
215
+ advisories: [
216
+ { id: "CVE-2024-0001", package: "lodash", severity: "critical", introduced: "4.0.0", fixed: "4.17.21" },
217
+ { id: "CVE-2024-0002", package: "left-pad", severity: "low" },
218
+ { id: "CVE-2024-0003", package: "lodash", ecosystem: "pypi", severity: "high" },
219
+ ],
220
+ };
221
+
222
+ describe("sbomPackages", () => {
223
+ test("reads purls from an SPDX document's externalRefs, skipping entries with none", () => {
224
+ const packages = sbomPackages(SPDX_SBOM);
225
+ expect(packages).toEqual([
226
+ { ecosystem: "npm", name: "lodash", version: "4.17.20" },
227
+ { ecosystem: "npm", name: "left-pad", version: "1.3.0" },
228
+ ]);
229
+ });
230
+
231
+ test("reads purls from a CycloneDX document's components", () => {
232
+ expect(sbomPackages(CYCLONEDX_SBOM)).toEqual([
233
+ { ecosystem: "npm", name: "lodash", version: "4.17.20" },
234
+ { ecosystem: "npm", name: "left-pad", version: "1.3.0" },
235
+ ]);
236
+ });
237
+
238
+ test("deduplicates identical ecosystem/name/version", () => {
239
+ const doc = { bytes: JSON.stringify({ components: [{ purl: "pkg:npm/x@1.0.0" }, { purl: "pkg:npm/x@1.0.0" }] }) };
240
+ expect(sbomPackages(doc)).toHaveLength(1);
241
+ });
242
+ });
243
+
244
+ describe("compareVersions", () => {
245
+ test("compares numerically, not lexically (1.9.0 < 1.10.0)", () => {
246
+ expect(compareVersions("1.9.0", "1.10.0")).toBe(-1);
247
+ expect(compareVersions("1.10.0", "1.9.0")).toBe(1);
248
+ expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
249
+ });
250
+
251
+ test("a pre-release sorts before its release", () => {
252
+ expect(compareVersions("1.2.0-rc.1", "1.2.0")).toBe(-1);
253
+ expect(compareVersions("1.2.0", "1.2.0-rc.1")).toBe(1);
254
+ });
255
+ });
256
+
257
+ describe("scanWithDatabase", () => {
258
+ test("matches an SBOM's packages against the database by ecosystem + name, introduced <= version < fixed", () => {
259
+ const findings = scanWithDatabase(SPDX_SBOM, DB);
260
+ expect(findings).toHaveLength(2);
261
+ const lodash = findings.find((f) => f.cveId === "CVE-2024-0001");
262
+ expect(lodash).toEqual({ cveId: "CVE-2024-0001", severity: "critical", package: "lodash", installedVersion: "4.17.20", fixedVersion: "4.17.21", fixable: true });
263
+ const leftPad = findings.find((f) => f.cveId === "CVE-2024-0002");
264
+ expect(leftPad).toMatchObject({ package: "left-pad", fixable: false });
265
+ expect(leftPad!.fixedVersion).toBeUndefined();
266
+ });
267
+
268
+ test("a version at or after `fixed` is not affected", () => {
269
+ const doc = { bytes: JSON.stringify({ components: [{ purl: "pkg:npm/lodash@4.17.21" }] }) };
270
+ expect(scanWithDatabase(doc, DB)).toHaveLength(0);
271
+ });
272
+
273
+ test("an ecosystem mismatch does not match (pypi advisory, npm package)", () => {
274
+ const findings = scanWithDatabase(SPDX_SBOM, DB);
275
+ expect(findings.some((f) => f.cveId === "CVE-2024-0003")).toBe(false);
276
+ });
277
+
278
+ test("accepts a bare advisories array as well as { advisories: [...] }", () => {
279
+ expect(scanWithDatabase(SPDX_SBOM, DB.advisories)).toHaveLength(2);
280
+ });
281
+ });
282
+
283
+ describe("createDatabaseVulnScanner", () => {
284
+ test("scans by reading the database file at scan time", async () => {
285
+ const dir = mkdtempSync(join(tmpdir(), "chant-vulndb-"));
286
+ const dbFile = join(dir, "advisories.json");
287
+ writeFileSync(dbFile, JSON.stringify(DB));
288
+ const scanner = createDatabaseVulnScanner(dbFile);
289
+ const findings = await scanner.scan({ sbom: SPDX_SBOM });
290
+ expect(findings).toHaveLength(2);
291
+ });
292
+
293
+ test("composes with scan-vulnerabilities like any other VulnScanner", async () => {
294
+ const dir = mkdtempSync(join(tmpdir(), "chant-vulndb-"));
295
+ const dbFile = join(dir, "advisories.json");
296
+ writeFileSync(dbFile, JSON.stringify(DB));
297
+ const cap = createScanVulnerabilitiesCapability(createDatabaseVulnScanner(dbFile));
298
+ const out = await cap.run(ctx, { sbom: SPDX_SBOM, digest: "sha256:abc" });
299
+ expect(out.findings).toHaveLength(2);
300
+ expect(out.digest).toBe("sha256:abc");
301
+ });
302
+ });
303
+
181
304
  describe("createToolVulnScanner (grype, via MockProcessRunner)", () => {
182
305
  test("scans the SBOM with `grype sbom:<file>` and parses the result", async () => {
183
306
  const mock = createMockProcessRunner({ tools: { grype: true }, responses: { "grype sbom:": GRYPE_JSON } });
@@ -13,9 +13,17 @@
13
13
  * inline fake `VulnScanner` (an object literal with a canned `scan()`, see
14
14
  * ./vuln-gate.test.ts) and never invoke a real scanner, network, or vuln DB.
15
15
  * The scanner's vuln DB currency is the tool's job, not chant's.
16
+ *
17
+ * `createDatabaseVulnScanner` (ws-056, INTENTIUS/chant#2735) is the one
18
+ * hermetic exception: a `VulnScanner` matched against a local, offline
19
+ * advisory-database file instead of a real scanner binary — no `grype`/
20
+ * `trivy` required, deterministic, at the cost of covering only the
21
+ * advisories the database names. Ported from chud's `supply-chain.mjs`
22
+ * (`scanWithDatabase`/`sbomPackages`/`compareVersions`), which built its own
23
+ * copy of this because `scan-vulnerabilities` had no offline backend.
16
24
  */
17
25
 
18
- import { writeFileSync } from "node:fs";
26
+ import { readFileSync, writeFileSync } from "node:fs";
19
27
  import { tmpdir } from "node:os";
20
28
  import { join } from "node:path";
21
29
  import { createHash } from "node:crypto";
@@ -174,6 +182,139 @@ export function parseTrivyOutput(stdout: string): VulnFinding[] {
174
182
  return out;
175
183
  }
176
184
 
185
+ // ── offline advisory database ────────────────────────────────────────────────
186
+
187
+ /** One package an SBOM names, by purl: the ecosystem, its name and its installed version. */
188
+ export interface SbomPackage {
189
+ ecosystem: string;
190
+ name: string;
191
+ version: string;
192
+ }
193
+
194
+ /**
195
+ * The packages an SPDX or CycloneDX document lists, by purl (SPDX's
196
+ * `externalRefs` with `referenceType: "purl"`, CycloneDX's `components[].purl`),
197
+ * deduplicated by ecosystem/name/version. Parses `doc.bytes` (a string, as
198
+ * every `SbomDocument` carries it) rather than requiring a pre-parsed object,
199
+ * so a caller can pass a `scan-vulnerabilities` input's `sbom` straight
200
+ * through.
201
+ */
202
+ export function sbomPackages(doc: Pick<SbomDocument, "bytes">): SbomPackage[] {
203
+ const parsed = JSON.parse(doc.bytes) as {
204
+ packages?: Array<{ externalRefs?: Array<{ referenceType?: string; referenceLocator?: string }> }>;
205
+ components?: Array<{ purl?: string }>;
206
+ };
207
+ const purls = [
208
+ ...(parsed.packages ?? []).flatMap((p) =>
209
+ (p.externalRefs ?? []).filter((r) => r.referenceType === "purl").map((r) => r.referenceLocator ?? ""),
210
+ ),
211
+ ...(parsed.components ?? []).map((c) => c.purl).filter((p): p is string => Boolean(p)),
212
+ ];
213
+ const seen = new Map<string, SbomPackage>();
214
+ for (const purl of purls) {
215
+ const m = /^pkg:([^/]+)\/(.+)@([^?#]+)/.exec(purl);
216
+ if (!m) continue;
217
+ const pkg: SbomPackage = { ecosystem: m[1], name: decodeURIComponent(m[2]), version: decodeURIComponent(m[3]) };
218
+ seen.set(`${pkg.ecosystem}:${pkg.name}@${pkg.version}`, pkg);
219
+ }
220
+ return [...seen.values()];
221
+ }
222
+
223
+ /**
224
+ * Compare two dotted version strings numerically, a pre-release sorting
225
+ * before its release (`1.2.0-rc.1` < `1.2.0`). Not full semver (no build
226
+ * metadata, no ordering between differing pre-release labels beyond "a
227
+ * pre-release exists") — just enough to place an installed version between
228
+ * an advisory's `introduced` and `fixed`.
229
+ */
230
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
231
+ const parse = (v: string) => {
232
+ const [core, pre] = String(v).replace(/^v/, "").split("-", 2);
233
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
234
+ };
235
+ const x = parse(a);
236
+ const y = parse(b);
237
+ for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
238
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
239
+ if (d) return Math.sign(d) as -1 | 1;
240
+ }
241
+ if (x.pre && !y.pre) return -1;
242
+ if (!x.pre && y.pre) return 1;
243
+ if (x.pre === y.pre) return 0;
244
+ return x.pre! < y.pre! ? -1 : 1;
245
+ }
246
+
247
+ /** One advisory in a local, offline advisory database (`AdvisoryDatabase.advisories`). */
248
+ export interface Advisory {
249
+ /** Advisory id, e.g. `"CVE-2024-12345"` or `"GHSA-…"`. */
250
+ id: string;
251
+ /** Ecosystem the advisory applies to (npm, pypi, …). Default: `"npm"`. */
252
+ ecosystem?: string;
253
+ /** Affected package name, matched against the SBOM's purl-derived package name. */
254
+ package: string;
255
+ severity?: string;
256
+ /** First affected version. Default: `"0"` (affects every version up to `fixed`). */
257
+ introduced?: string;
258
+ /** First version that fixes it. Omitted for a vulnerability with no fix yet. */
259
+ fixed?: string;
260
+ summary?: string;
261
+ }
262
+
263
+ /** A local, offline advisory database: `{ "advisories": [...] }` (or a bare array, for convenience). */
264
+ export interface AdvisoryDatabase {
265
+ advisories: Advisory[];
266
+ }
267
+
268
+ /**
269
+ * Match an SBOM's packages against a local, offline advisory database —
270
+ * no scanner binary, no network, deterministic. A package is affected when
271
+ * `introduced <= installedVersion < fixed` (or unconditionally affected when
272
+ * the advisory names no `fixed`). Findings are `VulnFinding`-shaped, so they
273
+ * feed `vuln-gate` exactly like `grype`/`trivy` output does; every
274
+ * exploitability field (`epss`, `inKev`, …) an offline database can't supply
275
+ * is left `undefined`, never defaulted to a false negative.
276
+ */
277
+ export function scanWithDatabase(doc: Pick<SbomDocument, "bytes">, db: AdvisoryDatabase | Advisory[]): VulnFinding[] {
278
+ const advisories = Array.isArray(db) ? db : (db.advisories ?? []);
279
+ const findings: VulnFinding[] = [];
280
+ for (const pkg of sbomPackages(doc)) {
281
+ for (const a of advisories) {
282
+ if ((a.ecosystem ?? "npm") !== pkg.ecosystem || a.package !== pkg.name) continue;
283
+ if (compareVersions(pkg.version, a.introduced ?? "0") < 0) continue;
284
+ if (a.fixed && compareVersions(pkg.version, a.fixed) >= 0) continue;
285
+ findings.push({
286
+ cveId: a.id,
287
+ severity: normalizeSeverity(a.severity),
288
+ package: pkg.name,
289
+ installedVersion: pkg.version,
290
+ ...(a.fixed ? { fixedVersion: a.fixed } : {}),
291
+ fixable: Boolean(a.fixed),
292
+ });
293
+ }
294
+ }
295
+ return findings;
296
+ }
297
+
298
+ /**
299
+ * A `VulnScanner` backed by a local, offline advisory database file (JSON:
300
+ * `{ "advisories": [...] }`, see `AdvisoryDatabase`) instead of a real
301
+ * scanner binary — the hermetic fallback `autoDetectVulnScanner`'s doc
302
+ * comment says a scan doesn't have ("there is no hermetic fallback, a scan
303
+ * needs a real vuln DB"): deterministic, offline, no `grype`/`trivy`
304
+ * install required, at the cost of covering only the advisories the
305
+ * database names. Reads the file once per call (a database pinned in the
306
+ * repo changes rarely; callers that scan repeatedly should cache the parsed
307
+ * `AdvisoryDatabase` and call `scanWithDatabase` directly instead).
308
+ */
309
+ export function createDatabaseVulnScanner(dbFile: string): VulnScanner {
310
+ return {
311
+ async scan(input) {
312
+ const db = JSON.parse(readFileSync(dbFile, "utf8")) as AdvisoryDatabase | Advisory[];
313
+ return scanWithDatabase(input.sbom, db);
314
+ },
315
+ };
316
+ }
317
+
177
318
  /**
178
319
  * A real `VulnScanner` that writes the SBOM to a temp file and scans it with
179
320
  * `grype` (default) or `trivy`, through the injectable `ProcessRunner`.
@@ -1100,34 +1100,70 @@ export async function readBlobBySha(sha: string, opts?: { cwd?: string }): Promi
1100
1100
  * Push one arbitrary ref (e.g. a lease ref) to the remote, guarded the same
1101
1101
  * way {@link pushLifecycle} guards the ledger branch: `--force-with-lease`
1102
1102
  * keyed to the remote SHA last observed locally, so a concurrent push from a
1103
- * second machine is rejected rather than silently clobbered. Plain `--force`
1104
- * underneath that lease — a lease ref's value is a bare blob SHA, not a
1105
- * commit descending from the previous one, so there is no "fast-forward" to
1106
- * preserve, only the CAS the lease guard already provides.
1103
+ * second machine is rejected rather than silently clobbered. The lease alone
1104
+ * lets a push replace a value that is not an ancestor — a lease ref's value is
1105
+ * a bare blob SHA, not a commit descending from the previous one — as long as
1106
+ * the remote still holds the expected value. No `--force` beside it: git lets
1107
+ * `--force` override `--force-with-lease`, which made every lease push
1108
+ * unconditional before #2732.
1107
1109
  *
1108
1110
  * Returns `false` (never throws) when no remote is configured — a
1109
1111
  * remote-less project's lease is local-only by construction (see
1110
1112
  * `./lease.ts`'s module doc), or when the push itself is rejected (the
1111
- * caller re-reads and retries; see `acquireLease`).
1113
+ * caller re-reads and retries; see `acquireLease`). {@link pushRefStatus}
1114
+ * tells those two apart.
1112
1115
  */
1113
- export async function pushRef(ref: string, opts?: { cwd?: string }): Promise<boolean> {
1116
+ export async function pushRef(ref: string, opts?: { cwd?: string; expect?: string | null }): Promise<boolean> {
1117
+ return (await pushRefStatus(ref, opts)) === "pushed";
1118
+ }
1119
+
1120
+ /** What {@link pushRefStatus} did: the remote took the ref, there is no remote, or the remote refused it. */
1121
+ export type PushRefStatus = "pushed" | "no-remote" | "rejected";
1122
+
1123
+ /**
1124
+ * {@link pushRef}, saying why a push did not land (#2732).
1125
+ *
1126
+ * `opts.expect` is the value the caller last saw the ref at on the remote
1127
+ * (`null`: absent there), and becomes the `--force-with-lease` expectation.
1128
+ * Without it the expectation is `refs/remotes/<remote>/<ref>`, which nothing
1129
+ * fetches a lease ref into, so it reads as "absent" and only a first push
1130
+ * lands. A lease passes the value of its own remote-tracking ref instead
1131
+ * (`./lease.ts`'s `readLease`).
1132
+ *
1133
+ * A ref that no longer exists locally is pushed as a deletion, under the same
1134
+ * expectation, so a released lease is released on the remote too. Deleting a
1135
+ * ref the caller expects to be absent already is a no-op that reports
1136
+ * `pushed`.
1137
+ */
1138
+ export async function pushRefStatus(ref: string, opts?: { cwd?: string; expect?: string | null }): Promise<PushRefStatus> {
1114
1139
  const rt = getRuntime();
1115
1140
  const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
1116
- if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
1141
+ if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return "no-remote";
1117
1142
  const remote = remoteResult.stdout.trim().split("\n")[0];
1118
1143
 
1119
- const remoteRef = `refs/remotes/${remote}/${ref.replace(/^refs\//, "")}`;
1120
- const expectedResult = await rt.spawn(["git", "rev-parse", "--verify", remoteRef], { cwd: opts?.cwd });
1121
- const expected = expectedResult.exitCode === 0 ? expectedResult.stdout.trim() : null;
1144
+ let expected: string | null;
1145
+ if (opts?.expect !== undefined) {
1146
+ expected = opts.expect;
1147
+ } else {
1148
+ const remoteRef = `refs/remotes/${remote}/${ref.replace(/^refs\//, "")}`;
1149
+ const expectedResult = await rt.spawn(["git", "rev-parse", "--verify", remoteRef], { cwd: opts?.cwd });
1150
+ expected = expectedResult.exitCode === 0 ? expectedResult.stdout.trim() : null;
1151
+ }
1122
1152
  const lease = `${ref}:${expected ?? ""}`;
1123
1153
 
1154
+ const local = await readRefSha(ref, opts);
1155
+ if (local === null && expected === null) return "pushed";
1156
+ const refspec = local === null ? `:${ref}` : `${ref}:${ref}`;
1124
1157
  const pushResult = await rt.spawn(
1125
- ["git", "push", "--force", `--force-with-lease=${lease}`, remote, `${ref}:${ref}`],
1158
+ ["git", "push", `--force-with-lease=${lease}`, remote, refspec],
1126
1159
  { cwd: opts?.cwd },
1127
1160
  );
1128
- return pushResult.exitCode === 0;
1161
+ return pushResult.exitCode === 0 ? "pushed" : "rejected";
1129
1162
  }
1130
1163
 
1164
+ /** What {@link fetchRefIntoStatus} found: the ref fetched, no remote, the remote answered without the ref, or the fetch failed. */
1165
+ export type FetchRefStatus = "fetched" | "no-remote" | "missing" | "failed";
1166
+
1131
1167
  /**
1132
1168
  * Fetch one arbitrary remote ref into a local ref of a possibly *different*
1133
1169
  * name (#1959 finding 3). `+` forces the update even when it isn't a
@@ -1145,12 +1181,26 @@ export async function fetchRefInto(
1145
1181
  localRef: string,
1146
1182
  opts?: { cwd?: string },
1147
1183
  ): Promise<boolean> {
1184
+ return (await fetchRefIntoStatus(remoteRef, localRef, opts)) === "fetched";
1185
+ }
1186
+
1187
+ /**
1188
+ * {@link fetchRefInto}, saying why nothing was fetched (#2732). `missing`
1189
+ * means the remote answered and does not carry the ref, which for a lease is
1190
+ * the news that it was released there.
1191
+ */
1192
+ export async function fetchRefIntoStatus(
1193
+ remoteRef: string,
1194
+ localRef: string,
1195
+ opts?: { cwd?: string },
1196
+ ): Promise<FetchRefStatus> {
1148
1197
  const rt = getRuntime();
1149
1198
  const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
1150
- if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
1199
+ if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return "no-remote";
1151
1200
  const remote = remoteResult.stdout.trim().split("\n")[0];
1152
- const fetchResult = await rt.spawn(["git", "fetch", remote, `+${remoteRef}:${localRef}`], { cwd: opts?.cwd });
1153
- return fetchResult.exitCode === 0;
1201
+ const fetchResult = await rt.spawn(["git", "fetch", remote, `+${remoteRef}:${localRef}`], { cwd: opts?.cwd, env: C_LOCALE_ENV });
1202
+ if (fetchResult.exitCode === 0) return "fetched";
1203
+ return NO_SUCH_REMOTE_REF_RE.test(fetchResult.stderr ?? "") ? "missing" : "failed";
1154
1204
  }
1155
1205
 
1156
1206
  /**
@@ -285,6 +285,28 @@ describe("lifecycle/lease", () => {
285
285
  }
286
286
  });
287
287
 
288
+ // #2732: pushRef passed `--force` beside `--force-with-lease`, and git
289
+ // lets `--force` win, so every lease push was unconditional. It now pushes
290
+ // under the lease alone, expecting the value last fetched, so a renewal
291
+ // lands and a release deletes the ref on the remote.
292
+ test("a renewal and a release reach the remote, under the lease the last fetch saw (#2732)", async () => {
293
+ const { clonePath, remotePath, cleanup } = await setupClonePair();
294
+ try {
295
+ const ref = leaseRef("fountain-converge");
296
+ const first = await acquireLease("fountain-converge", "operator-a", { cwd: clonePath, ttlMs: 60_000 });
297
+ expect(first.acquired).toBe(true);
298
+ const renewed = await acquireLease("fountain-converge", "operator-a", { cwd: clonePath, ttlMs: 120_000 });
299
+ expect(renewed.lease?.token).toBe(first.lease?.token);
300
+ const local = git(["rev-parse", ref], clonePath).stdout.trim();
301
+ expect(git(["rev-parse", ref], remotePath).stdout.trim()).toBe(local);
302
+
303
+ expect(await releaseLease("fountain-converge", "operator-a", renewed.lease!.token, { cwd: clonePath })).toBe(true);
304
+ expect(git(["rev-parse", "--verify", ref], remotePath).exitCode).not.toBe(0);
305
+ } finally {
306
+ await cleanup();
307
+ }
308
+ });
309
+
288
310
  // ── #1959 finding 3 ──────────────────────────────────────────────────
289
311
  //
290
312
  // `readLease` used to fetch the remote lease ref directly into the same