@codai/axiom-mcp 2.0.0 → 2.1.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/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import { GuardOptions } from "@codai/axiom-checks";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import "@modelcontextprotocol/sdk/types.js";
6
6
  //#region src/jsonschema.d.ts
7
- export declare const SCHEMA_KINDS: readonly ["Plan", "Manifest", "ManifestBundle", "CheckReport", "ApplyResult", "Profile", "Journal"];
7
+ export declare const SCHEMA_KINDS: readonly ["Plan", "Manifest", "ManifestBundle", "CheckReport", "ApplyResult", "Profile", "Journal", "RepoSnapshot"];
8
8
  type SchemaKind = (typeof SCHEMA_KINDS)[number];
9
9
  export declare function isSchemaKind(v: unknown): v is SchemaKind;
10
10
  /** Draft 2020-12 JSON Schema, byte-identical to `packages/schema/schemas/<kind>.schema.json`. */
package/dist/index.js CHANGED
@@ -1,14 +1,17 @@
1
1
  import { createRequire } from "node:module";
2
- import { ApplyResultSchema, AxiomError, CheckReportSchema, DigestRefSchema, ErrorCodeSchema, JournalPhaseSchema, JournalSchema, ManifestBodySchema, ManifestBundleSchema, PlanSchema, ProfileSchema } from "@codai/axiom-schema";
2
+ import { ApplyResultSchema, AxiomError, CheckReportSchema, DigestRefSchema, ErrorCodeSchema, JournalPhaseSchema, JournalSchema, ManifestBodySchema, ManifestBundleSchema, PlanSchema, ProfileSchema, RepoSnapshotSchema, TrustStateSchema, TrustStoreSchema, compareUtf8, isValidRelPath } from "@codai/axiom-schema";
3
3
  import { z } from "zod";
4
- import { realpath } from "node:fs";
5
- import { access, constants, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
4
+ import { createReadStream, realpath } from "node:fs";
5
+ import { access, chmod, constants, lstat, mkdir, opendir, readFile, readdir, realpath as realpath$1, rename, stat, writeFile } from "node:fs/promises";
6
6
  import * as path from "node:path";
7
7
  import { promisify } from "node:util";
8
- import { loadProfile, runChecks } from "@codai/axiom-checks";
8
+ import { TRUST_FILE_DEFAULT, TRUST_STATE_FILE, loadProfile, runChecks, verifyBundleSignatures } from "@codai/axiom-checks";
9
9
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { webEmitter } from "@codai/axiom-emitters-web";
11
+ import { compilePlan, createEmitterRegistry, diffManifests, verifyBundle } from "@codai/axiom-plan";
10
12
  import { appliedPath, apply, rollback } from "@codai/axiom-apply";
11
- import { compilePlan, diffManifests, verifyBundle } from "@codai/axiom-plan";
13
+ import { canonicalDigestRef } from "@codai/axiom-canon";
14
+ import { createHash } from "node:crypto";
12
15
  //#region src/jsonschema.ts
13
16
  const SCHEMA_KINDS = [
14
17
  "Plan",
@@ -17,7 +20,8 @@ const SCHEMA_KINDS = [
17
20
  "CheckReport",
18
21
  "ApplyResult",
19
22
  "Profile",
20
- "Journal"
23
+ "Journal",
24
+ "RepoSnapshot"
21
25
  ];
22
26
  const BY_KIND = {
23
27
  Plan: PlanSchema,
@@ -26,7 +30,8 @@ const BY_KIND = {
26
30
  CheckReport: CheckReportSchema,
27
31
  ApplyResult: ApplyResultSchema,
28
32
  Profile: ProfileSchema,
29
- Journal: JournalSchema
33
+ Journal: JournalSchema,
34
+ RepoSnapshot: RepoSnapshotSchema
30
35
  };
31
36
  function isSchemaKind(v) {
32
37
  return typeof v === "string" && SCHEMA_KINDS.includes(v);
@@ -162,6 +167,25 @@ async function resolveRoot(policy, requested) {
162
167
  } });
163
168
  }
164
169
  //#endregion
170
+ //#region src/emitters.ts
171
+ /** Template emitters available to `axiom_plan_compile` / `axiom compile` (D-13: optional sugar). */
172
+ const EMITTERS = createEmitterRegistry([webEmitter]);
173
+ /** Flat, sorted `emitter@version: template — description` rows for the CLI and the resource. */
174
+ function emitterCatalogue(registry = EMITTERS) {
175
+ const rows = [];
176
+ for (const id of registry.list()) {
177
+ const e = registry.get(id);
178
+ if (e === void 0) continue;
179
+ for (const template of Object.keys(e.templates).sort()) rows.push({
180
+ emitter: e.id,
181
+ version: e.version,
182
+ template,
183
+ description: e.templates[template]?.description ?? ""
184
+ });
185
+ }
186
+ return rows;
187
+ }
188
+ //#endregion
165
189
  //#region src/store.ts
166
190
  /** `<root>/.axiom/manifests/<hex>.json` and `<root>/.axiom/reports/<hex>.json`. */
167
191
  function manifestsDir(root) {
@@ -179,13 +203,13 @@ function toDigestRef(shaOrRef) {
179
203
  if (!parsed.success) throw new AxiomError("ERR_NOT_FOUND", `not a sha256 digest: ${shaOrRef}`);
180
204
  return parsed.data;
181
205
  }
182
- async function writeJsonAtomic(file, value) {
206
+ async function writeJsonAtomic$1(file, value) {
183
207
  await mkdir(path.dirname(file), { recursive: true });
184
208
  const tmp = `${file}.tmp-${process.pid}`;
185
209
  await writeFile(tmp, JSON.stringify(value), "utf8");
186
210
  await rename(tmp, file);
187
211
  }
188
- async function readJsonOrUndefined(file) {
212
+ async function readJsonOrUndefined$1(file) {
189
213
  try {
190
214
  return JSON.parse(await readFile(file, "utf8"));
191
215
  } catch (err) {
@@ -195,30 +219,30 @@ async function readJsonOrUndefined(file) {
195
219
  }
196
220
  async function saveManifest(root, bundle) {
197
221
  const file = path.join(manifestsDir(root), `${hexOf(bundle.manifestDigest)}.json`);
198
- await writeJsonAtomic(file, bundle);
222
+ await writeJsonAtomic$1(file, bundle);
199
223
  return file;
200
224
  }
201
225
  async function saveReport(root, report) {
202
226
  const file = path.join(reportsDir(root), `${hexOf(report.manifestDigest)}.json`);
203
- await writeJsonAtomic(file, report);
227
+ await writeJsonAtomic$1(file, report);
204
228
  return file;
205
229
  }
206
230
  /** Search every root (allowlisted + seen) for a stored bundle. */
207
231
  async function loadManifest(roots, ref) {
208
232
  for (const root of roots) {
209
- const raw = await readJsonOrUndefined(path.join(manifestsDir(root), `${hexOf(ref)}.json`));
233
+ const raw = await readJsonOrUndefined$1(path.join(manifestsDir(root), `${hexOf(ref)}.json`));
210
234
  if (raw !== void 0) return ManifestBundleSchema.parse(raw);
211
235
  }
212
236
  }
213
237
  async function loadReport(roots, ref) {
214
238
  for (const root of roots) {
215
- const raw = await readJsonOrUndefined(path.join(reportsDir(root), `${hexOf(ref)}.json`));
239
+ const raw = await readJsonOrUndefined$1(path.join(reportsDir(root), `${hexOf(ref)}.json`));
216
240
  if (raw !== void 0) return CheckReportSchema.parse(raw);
217
241
  }
218
242
  }
219
243
  async function loadApplied(roots, ref) {
220
244
  for (const root of roots) {
221
- const raw = await readJsonOrUndefined(appliedPath(root, ref));
245
+ const raw = await readJsonOrUndefined$1(appliedPath(root, ref));
222
246
  if (raw !== void 0) return ApplyResultSchema.parse(raw);
223
247
  }
224
248
  }
@@ -242,6 +266,293 @@ async function listStored(roots, sub) {
242
266
  return out;
243
267
  }
244
268
  //#endregion
269
+ //#region src/keys.ts
270
+ /**
271
+ * Key material and trust-store I/O for the CLI/MCP layer (D-16).
272
+ *
273
+ * Private keys are read ONLY from `AXIOM_SIGNING_KEY` (base64 PKCS#8 or raw seed) or
274
+ * `--key-file <path>`; they are never written to stdout and never stored under a root.
275
+ */
276
+ function trustFilePath(root, rel = TRUST_FILE_DEFAULT) {
277
+ return path.join(root, ...rel.split("/"));
278
+ }
279
+ function trustStatePath(root) {
280
+ return path.join(root, ...TRUST_STATE_FILE.split("/"));
281
+ }
282
+ async function writeJsonAtomic(file, value, mode) {
283
+ await mkdir(path.dirname(file), { recursive: true });
284
+ const tmp = `${file}.tmp-${process.pid}`;
285
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
286
+ encoding: "utf8",
287
+ mode
288
+ });
289
+ if (mode !== void 0) await chmod(tmp, mode).catch(() => void 0);
290
+ await rename(tmp, file);
291
+ }
292
+ async function readJsonOrUndefined(file) {
293
+ try {
294
+ return JSON.parse(await readFile(file, "utf8"));
295
+ } catch (err) {
296
+ if (err.code === "ENOENT") return void 0;
297
+ throw err;
298
+ }
299
+ }
300
+ /** Verify a bundle's detached signatures against the root's trust store; `undefined` when no store. */
301
+ async function verifyBundleAgainstRoot(root, bundle, rel = TRUST_FILE_DEFAULT) {
302
+ const store = await loadTrustStore(root, rel);
303
+ if (store === void 0) return void 0;
304
+ const v = verifyBundleSignatures(bundle, store, bundle.manifest.counter);
305
+ return {
306
+ trustFile: rel,
307
+ keyids: v.keyids,
308
+ findings: v.findings.map((f) => ({
309
+ id: f.id,
310
+ message: f.message
311
+ })),
312
+ ok: v.keyids.length > 0 && v.findings.length === 0
313
+ };
314
+ }
315
+ async function loadTrustStore(root, rel = TRUST_FILE_DEFAULT) {
316
+ const raw = await readJsonOrUndefined(trustFilePath(root, rel));
317
+ if (raw === void 0) return void 0;
318
+ const parsed = TrustStoreSchema.safeParse(raw);
319
+ if (!parsed.success) throw new AxiomError("ERR_INVALID_PROFILE", `trust store ${rel} is invalid`, { details: { issues: parsed.error.issues.slice(0, 10).map((i) => i.message) } });
320
+ return parsed.data;
321
+ }
322
+ async function loadTrustState(root) {
323
+ const raw = await readJsonOrUndefined(trustStatePath(root));
324
+ if (raw === void 0) return void 0;
325
+ const parsed = TrustStateSchema.safeParse(raw);
326
+ if (!parsed.success) throw new AxiomError("ERR_JOURNAL_CORRUPT", `${TRUST_STATE_FILE} is invalid`);
327
+ return parsed.data;
328
+ }
329
+ /**
330
+ * Advance `lastCounter` to `bundle.manifest.counter` after a successful apply.
331
+ * Monotonic: never moves backwards; a no-op when the bundle has no counter.
332
+ * Write-temp + rename so a crash leaves either the old or the new state.
333
+ */
334
+ async function advanceTrustState(root, bundle) {
335
+ const counter = bundle.manifest.counter;
336
+ if (counter === void 0) return void 0;
337
+ const cur = await loadTrustState(root);
338
+ if (cur !== void 0 && cur.lastCounter >= counter) return cur;
339
+ const next = {
340
+ version: 1,
341
+ lastCounter: counter,
342
+ manifestDigest: bundle.manifestDigest
343
+ };
344
+ await writeJsonAtomic(trustStatePath(root), next);
345
+ return next;
346
+ }
347
+ /** Does the resolved profile (plus plan checks) enable antiRollback on requireSigned? */
348
+ function profileWantsAntiRollback(checks) {
349
+ return checks.some((c) => c.predicate === "manifest.requireSigned" && typeof c.params === "object" && c.params !== null && c.params.antiRollback === true);
350
+ }
351
+ //#endregion
352
+ //#region src/snapshot.ts
353
+ /**
354
+ * `axiom_repo_snapshot` — deterministic, content-addressed inventory of a root (S-304).
355
+ *
356
+ * Successor of the v1 reverse-IR, which guessed "service types" from directory names and
357
+ * hashed nothing. A RepoSnapshot records what is actually there — relative path, size,
358
+ * sha256, mode, kind — sorted by `compareUtf8`, with no timestamps and no absolute paths, so
359
+ * the same tree yields the same `snapshotDigest` on every machine (invariant 1). Agents use
360
+ * it to build Plans against real pre-image digests and to diff two states of a tree.
361
+ *
362
+ * Read-only: never follows symlinks, never leaves the root, never spawns a process.
363
+ */
364
+ const SNAPSHOT_MAX_FILES_DEFAULT = 2e4;
365
+ const SNAPSHOT_MAX_FILES_CAP = 5e4;
366
+ const SNAPSHOT_MAX_BYTES_DEFAULT = 67108864;
367
+ /** Never inventoried, whatever `.gitignore` says. */
368
+ const ALWAYS_SKIP = /* @__PURE__ */ new Set([".git", ".axiom"]);
369
+ function globToRegExp(glob) {
370
+ let re = "^";
371
+ for (let i = 0; i < glob.length; i++) {
372
+ const c = glob[i];
373
+ if (c === "*") {
374
+ if (glob[i + 1] === "*") {
375
+ i++;
376
+ if (glob[i + 1] === "/") {
377
+ i++;
378
+ re += "(?:.*/)?";
379
+ } else re += ".*";
380
+ } else re += "[^/]*";
381
+ } else if (c === "?") re += "[^/]";
382
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
383
+ }
384
+ return new RegExp(`${re}$`);
385
+ }
386
+ function matcherOf(globs, whenEmpty) {
387
+ if (globs === void 0 || globs.length === 0) return () => whenEmpty;
388
+ const res = globs.map(globToRegExp);
389
+ return (rel) => res.some((r) => r.test(rel));
390
+ }
391
+ /** A glob must itself be a contained relative path once wildcards are removed (`..` → rejected). */
392
+ function validateGlob(g, label) {
393
+ const probe = g.replace(/\*+/g, "x").replace(/\?/g, "x").replace(/\/+$/, "");
394
+ if (probe.length === 0 || !isValidRelPath(probe)) throw new AxiomError("ERR_CONTAINMENT", `${label} glob must be a contained relative path: ${g}`, { details: { glob: g } });
395
+ }
396
+ /** Root `.gitignore` → matchers, same rough translation the repo facts use (negations dropped). */
397
+ async function gitignoreMatcher(root) {
398
+ let text;
399
+ try {
400
+ text = await readFile(path.join(root, ".gitignore"), "utf8");
401
+ } catch {
402
+ return () => false;
403
+ }
404
+ const globs = [];
405
+ for (const raw of text.split(/\r?\n/)) {
406
+ const l = raw.trim();
407
+ if (l.length === 0 || l.startsWith("#") || l.startsWith("!")) continue;
408
+ let pat = l.startsWith("/") ? l.slice(1) : l.includes("/") ? l : `**/${l}`;
409
+ if (pat.endsWith("/")) pat = pat.slice(0, -1);
410
+ globs.push(pat, `${pat}/**`);
411
+ }
412
+ return matcherOf(globs, false);
413
+ }
414
+ function sha256File(abs) {
415
+ return new Promise((resolve, reject) => {
416
+ const h = createHash("sha256");
417
+ createReadStream(abs).on("data", (chunk) => h.update(chunk)).on("error", reject).on("end", () => resolve(h.digest("hex")));
418
+ });
419
+ }
420
+ function modeOf(mode) {
421
+ return process.platform !== "win32" && (mode & 64) !== 0 ? "0755" : "0644";
422
+ }
423
+ /**
424
+ * Inventory `rootReal` (already realpath'd and authorised by the caller).
425
+ * The walk visits entries in `compareUtf8` order of their relative path (directories keyed with
426
+ * a trailing `/`), which is exactly the final sort order — so a truncated `files` is the first
427
+ * N paths of the full sorted inventory, deterministically.
428
+ */
429
+ async function snapshotRoot(root, opts = {}) {
430
+ if (opts.followSymlinks === true) throw new AxiomError("ERR_UNSUPPORTED_OP", "followSymlinks is not supported (symlinks are recorded, never followed)");
431
+ const rootReal = await realpath$1(root);
432
+ const maxFiles = Math.min(opts.maxFiles ?? 2e4, SNAPSHOT_MAX_FILES_CAP);
433
+ const maxBytes = opts.maxBytes ?? 67108864;
434
+ if (maxFiles < 1 || maxBytes < 0) throw new AxiomError("ERR_INVALID_PLAN", "maxFiles must be ≥ 1 and maxBytes ≥ 0", { details: {
435
+ maxFiles,
436
+ maxBytes
437
+ } });
438
+ for (const g of opts.include ?? []) validateGlob(g, "include");
439
+ for (const g of opts.exclude ?? []) validateGlob(g, "exclude");
440
+ const include = matcherOf(opts.include, true);
441
+ const exclude = matcherOf(opts.exclude, false);
442
+ const ignored = opts.respectGitignore === false ? () => false : await gitignoreMatcher(rootReal);
443
+ const withDigest = opts.withContentDigest !== false;
444
+ const files = [];
445
+ let bytes = 0;
446
+ let truncated = false;
447
+ const visit = async (dirAbs, dirRel) => {
448
+ let entries;
449
+ try {
450
+ const dir = await opendir(dirAbs);
451
+ entries = [];
452
+ for await (const e of dir) entries.push(e);
453
+ } catch {
454
+ return;
455
+ }
456
+ const sortKey = (d) => d.isDirectory() ? `${d.name}/` : d.name;
457
+ entries.sort((a, b) => compareUtf8(sortKey(a), sortKey(b)));
458
+ for (const e of entries) {
459
+ if (truncated) return;
460
+ const rel = dirRel === "" ? e.name : `${dirRel}/${e.name}`;
461
+ if (!isValidRelPath(rel)) continue;
462
+ if (e.isDirectory()) {
463
+ if (ALWAYS_SKIP.has(e.name) || ignored(rel) || exclude(rel)) continue;
464
+ await visit(path.join(dirAbs, e.name), rel);
465
+ continue;
466
+ }
467
+ if (ignored(rel) || !include(rel) || exclude(rel)) continue;
468
+ const abs = path.join(dirAbs, e.name);
469
+ let entry;
470
+ if (e.isSymbolicLink()) entry = await symlinkEntry(rootReal, abs, rel, withDigest);
471
+ else if (e.isFile()) entry = await fileEntry(abs, rel, withDigest);
472
+ if (entry === void 0) continue;
473
+ if (files.length >= maxFiles || bytes + entry.bytes > maxBytes) {
474
+ truncated = true;
475
+ return;
476
+ }
477
+ files.push(entry);
478
+ bytes += entry.bytes;
479
+ }
480
+ };
481
+ await visit(rootReal, "");
482
+ files.sort((a, b) => compareUtf8(a.path, b.path));
483
+ const body = {
484
+ files,
485
+ truncated,
486
+ counts: {
487
+ files: files.length,
488
+ bytes
489
+ }
490
+ };
491
+ return {
492
+ apiVersion: "axiom.dev/v2",
493
+ kind: "RepoSnapshot",
494
+ root: { kind: "relative" },
495
+ snapshotDigest: canonicalDigestRef(body),
496
+ body
497
+ };
498
+ }
499
+ async function fileEntry(abs, rel, withDigest) {
500
+ let st;
501
+ try {
502
+ st = await lstat(abs);
503
+ } catch {
504
+ return;
505
+ }
506
+ if (!st.isFile()) return void 0;
507
+ const entry = {
508
+ path: rel,
509
+ bytes: st.size,
510
+ mode: modeOf(st.mode),
511
+ kind: "file"
512
+ };
513
+ if (withDigest) try {
514
+ entry.sha256 = await sha256File(abs);
515
+ } catch {
516
+ return;
517
+ }
518
+ return entry;
519
+ }
520
+ /**
521
+ * A symlink is recorded as `kind: "symlink"`. Its target is hashed only when it resolves to a
522
+ * regular file *inside* the root; anything else (outside, dangling, directory) gets
523
+ * `bytes: 0` and no digest — the link is inventoried, its target is not disclosed.
524
+ */
525
+ async function symlinkEntry(rootReal, abs, rel, withDigest) {
526
+ const entry = {
527
+ path: rel,
528
+ bytes: 0,
529
+ mode: "0644",
530
+ kind: "symlink"
531
+ };
532
+ let target;
533
+ try {
534
+ target = await realpath$1(abs);
535
+ } catch {
536
+ return entry;
537
+ }
538
+ if (!isSameOrInside(rootReal, target)) return entry;
539
+ let st;
540
+ try {
541
+ st = await lstat(target);
542
+ } catch {
543
+ return entry;
544
+ }
545
+ if (!st.isFile()) return entry;
546
+ entry.bytes = st.size;
547
+ entry.mode = modeOf(st.mode);
548
+ if (withDigest) try {
549
+ entry.sha256 = await sha256File(target);
550
+ } catch {
551
+ entry.bytes = 0;
552
+ }
553
+ return entry;
554
+ }
555
+ //#endregion
245
556
  //#region src/tools.ts
246
557
  /** Hard cap on any single `bundle`/`plan` argument, measured as UTF-8 JSON bytes (§(f) payload size). */
247
558
  const BUNDLE_BYTES_MAX = 4194304;
@@ -353,7 +664,18 @@ const ManifestVerifyOutput = z.object({
353
664
  code: ErrorCodeSchema,
354
665
  message: z.string(),
355
666
  path: z.string().optional()
356
- }))
667
+ })),
668
+ /** Present only when a root with `.axiom/trust/keys.json` was available (D-16). */
669
+ signatures: z.object({
670
+ trustFile: z.string(),
671
+ /** Trusted keyids whose signature verified over this manifest. */
672
+ keyids: z.array(z.string()),
673
+ findings: z.array(z.object({
674
+ id: z.string(),
675
+ message: z.string()
676
+ })),
677
+ ok: z.boolean()
678
+ }).optional()
357
679
  });
358
680
  const RollbackOutput = z.object({
359
681
  manifestDigest: DigestRefSchema,
@@ -417,7 +739,10 @@ const TOOL_DEFS = [
417
739
  })
418
740
  };
419
741
  try {
420
- const { bundle } = await compilePlan(parsed.data, { store: "inline" });
742
+ const { bundle } = await compilePlan(parsed.data, {
743
+ store: "inline",
744
+ emitters: EMITTERS
745
+ });
421
746
  return {
422
747
  ok: true,
423
748
  planDigest: bundle.manifest.planDigest,
@@ -440,7 +765,7 @@ const TOOL_DEFS = [
440
765
  defineTool({
441
766
  name: "axiom_plan_compile",
442
767
  title: "Compile a Plan into a ManifestBundle",
443
- description: "Compile a Plan into a content-addressed ManifestBundle (sorted artifacts, sha256 digests, in-toto planDigest). `store: cas` writes blobs under <root>/.axiom/cas instead of inlining them. When a root is given the bundle is stored under <root>/.axiom/manifests/<hex>.json so later tools can reference it by digest.",
768
+ description: "Compile a Plan into a content-addressed ManifestBundle (sorted artifacts, sha256 digests, in-toto planDigest). `store: cas` writes blobs under <root>/.axiom/cas instead of inlining them. When a root is given the bundle is stored under <root>/.axiom/manifests/<hex>.json so later tools can reference it by digest. `template` sources are rendered by the built-in `web` emitter (see `axiom emitters`); its version is recorded in toolchain.emitters.",
444
769
  inputSchema: {
445
770
  plan: LooseObject.describe("Plan document"),
446
771
  store: z.enum(["inline", "cas"]).optional().describe("Blob transport; default inline"),
@@ -451,7 +776,10 @@ const TOOL_DEFS = [
451
776
  async handler(ctx, { plan, store, root }) {
452
777
  guardPayloadSize("plan", plan);
453
778
  const rootReal = root !== void 0 || store === "cas" ? (await resolveRoot(ctx.policy, root)).rootReal : void 0;
454
- const opts = { store: store ?? "inline" };
779
+ const opts = {
780
+ store: store ?? "inline",
781
+ emitters: EMITTERS
782
+ };
455
783
  if (rootReal !== void 0) opts.root = rootReal;
456
784
  const { bundle } = await compilePlan(plan, opts);
457
785
  if (rootReal !== void 0) {
@@ -476,11 +804,14 @@ const TOOL_DEFS = [
476
804
  defineTool({
477
805
  name: "axiom_manifest_verify",
478
806
  title: "Verify a ManifestBundle",
479
- description: "Structural and content-address verification: schema, recomputed manifestDigest, every inline blob hashes to its key, attestation subject matches. Never writes.",
480
- inputSchema: { bundle: LooseObject.describe("ManifestBundle") },
807
+ description: "Structural and content-address verification: schema, recomputed manifestDigest, every inline blob hashes to its key, attestation subject matches. When a root with .axiom/trust/keys.json is available, detached DSSE signatures are verified and the trusted keyids are reported under `signatures`. Never writes.",
808
+ inputSchema: {
809
+ bundle: LooseObject.describe("ManifestBundle"),
810
+ root: RootArg
811
+ },
481
812
  outputSchema: ManifestVerifyOutput,
482
813
  annotations: READ,
483
- async handler(_ctx, { bundle }) {
814
+ async handler(ctx, { bundle, root }) {
484
815
  guardPayloadSize("bundle", bundle);
485
816
  const r = verifyBundle(bundle);
486
817
  const out = {
@@ -491,12 +822,25 @@ const TOOL_DEFS = [
491
822
  errors: r.errors
492
823
  };
493
824
  if (r.manifestDigest !== void 0) out.manifestDigest = r.manifestDigest;
825
+ if (r.ok) {
826
+ const rootReal = await optionalRoot(ctx, root);
827
+ if (rootReal !== void 0) {
828
+ const sig = await verifyBundleAgainstRoot(rootReal, parseBundle(bundle));
829
+ if (sig !== void 0) {
830
+ out.signatures = sig;
831
+ out.signed = sig.keyids.length > 0;
832
+ if (!sig.ok) out.ok = false;
833
+ }
834
+ }
835
+ }
494
836
  return out;
495
837
  },
496
838
  summarize: (o) => ({
497
839
  ok: o.ok,
498
840
  manifestDigest: o.manifestDigest,
499
841
  canonical: o.canonical,
842
+ signed: o.signed,
843
+ keyids: o.signatures?.keyids,
500
844
  missing: o.missing.length,
501
845
  errors: o.errors.slice(0, 20)
502
846
  })
@@ -562,6 +906,7 @@ const TOOL_DEFS = [
562
906
  manifestDigest: parsed.manifestDigest
563
907
  } });
564
908
  const { rootReal } = await resolveRoot(ctx.policy, root);
909
+ const profileDoc = await profileFor(ctx, parsed, profile, rootReal);
565
910
  const result = await apply({
566
911
  bundle: parsed,
567
912
  root: rootReal,
@@ -575,6 +920,7 @@ const TOOL_DEFS = [
575
920
  await saveManifest(rootReal, parsed);
576
921
  ctx.seenRoots.add(rootReal);
577
922
  }
923
+ if (result.status === "applied" && profileWantsAntiRollback([...profileDoc.checks, ...parsed.manifest.checks])) await advanceTrustState(rootReal, parsed);
578
924
  ctx.log.info("apply", {
579
925
  manifestDigest: parsed.manifestDigest,
580
926
  status: result.status,
@@ -677,6 +1023,46 @@ const TOOL_DEFS = [
677
1023
  return { roots };
678
1024
  },
679
1025
  summarize: (o) => o
1026
+ }),
1027
+ defineTool({
1028
+ name: "axiom_repo_snapshot",
1029
+ title: "Snapshot a root",
1030
+ description: "Deterministic, content-addressed inventory of a root: every regular file (and symlink) as { path, bytes, sha256, mode, kind }, sorted by code point, with snapshotDigest = sha256(JCS(body)). No timestamps, no absolute paths — the same tree gives the same digest on every machine. Honours the root .gitignore, always skips .git/ and .axiom/, never follows symlinks, never leaves the root. Use it to build Plans against real pre-image digests, or diff two snapshots with `axiom snapshot-diff`.",
1031
+ inputSchema: {
1032
+ root: RootArg,
1033
+ include: z.array(z.string().min(1)).optional().describe("Relative globs (*, **, ?) to keep; default everything"),
1034
+ exclude: z.array(z.string().min(1)).optional().describe("Relative globs to drop"),
1035
+ maxFiles: z.int().min(1).max(SNAPSHOT_MAX_FILES_CAP).default(SNAPSHOT_MAX_FILES_DEFAULT).describe(`Stop after this many files (cap ${SNAPSHOT_MAX_FILES_CAP}); sets truncated`),
1036
+ maxBytes: z.int().nonnegative().default(SNAPSHOT_MAX_BYTES_DEFAULT).describe("Stop once the summed size would exceed this; sets truncated"),
1037
+ followSymlinks: z.literal(false).default(false).describe("Always false; symlinks are recorded, never followed"),
1038
+ respectGitignore: z.boolean().default(true),
1039
+ withContentDigest: z.boolean().default(true).describe("false → sizes only, no sha256")
1040
+ },
1041
+ outputSchema: RepoSnapshotSchema,
1042
+ annotations: READ,
1043
+ async handler(ctx, input) {
1044
+ const { rootReal } = await resolveRoot(ctx.policy, input.root);
1045
+ const opts = {
1046
+ maxFiles: input.maxFiles,
1047
+ maxBytes: input.maxBytes,
1048
+ respectGitignore: input.respectGitignore,
1049
+ withContentDigest: input.withContentDigest
1050
+ };
1051
+ if (input.include !== void 0) opts.include = input.include;
1052
+ if (input.exclude !== void 0) opts.exclude = input.exclude;
1053
+ const snap = await snapshotRoot(rootReal, opts);
1054
+ ctx.log.debug("snapshot", {
1055
+ root: rootReal,
1056
+ files: snap.body.counts.files
1057
+ });
1058
+ return snap;
1059
+ },
1060
+ summarize: (o) => ({
1061
+ snapshotDigest: o.snapshotDigest,
1062
+ counts: o.body.counts,
1063
+ truncated: o.body.truncated,
1064
+ paths: o.body.files.slice(0, 20).map((f) => f.path)
1065
+ })
680
1066
  })
681
1067
  ];
682
1068
  async function resolveBundleOrRef(ctx, v, label) {
@@ -875,6 +1261,10 @@ function createServer(policy, opts = {}) {
875
1261
  text: JSON.stringify(jsonSchemaFor(k), null, 2)
876
1262
  }] };
877
1263
  });
1264
+ server.registerResource("emitters", "axiom://emitters", {
1265
+ title: "Template emitters available to axiom_plan_compile",
1266
+ mimeType: "application/json"
1267
+ }, async (uri) => json(uri.href, emitterCatalogue()));
878
1268
  log.info("server created", {
879
1269
  name: SERVER_NAME,
880
1270
  version: SERVER_VERSION,