@codai/axiom-mcp 2.0.0 → 2.2.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.js CHANGED
@@ -1,14 +1,18 @@
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, PlanArtifactSchema, 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";
9
- import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { TRUST_FILE_DEFAULT, TRUST_STATE_FILE, TRUST_STATE_KEY_FILE, loadProfile, runChecks, trustStateMac, trustStateMacOk, verifyBundleSignatures } from "@codai/axiom-checks";
9
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
10
+ import "@modelcontextprotocol/server/stdio";
11
+ import { webEmitter } from "@codai/axiom-emitters-web";
12
+ import { compilePlan, createEmitterRegistry, diffManifests, verifyBundle } from "@codai/axiom-plan";
10
13
  import { appliedPath, apply, rollback } from "@codai/axiom-apply";
11
- import { compilePlan, diffManifests, verifyBundle } from "@codai/axiom-plan";
14
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
15
+ import { canonicalDigestRef } from "@codai/axiom-canon";
12
16
  //#region src/jsonschema.ts
13
17
  const SCHEMA_KINDS = [
14
18
  "Plan",
@@ -17,7 +21,8 @@ const SCHEMA_KINDS = [
17
21
  "CheckReport",
18
22
  "ApplyResult",
19
23
  "Profile",
20
- "Journal"
24
+ "Journal",
25
+ "RepoSnapshot"
21
26
  ];
22
27
  const BY_KIND = {
23
28
  Plan: PlanSchema,
@@ -26,7 +31,8 @@ const BY_KIND = {
26
31
  CheckReport: CheckReportSchema,
27
32
  ApplyResult: ApplyResultSchema,
28
33
  Profile: ProfileSchema,
29
- Journal: JournalSchema
34
+ Journal: JournalSchema,
35
+ RepoSnapshot: RepoSnapshotSchema
30
36
  };
31
37
  function isSchemaKind(v) {
32
38
  return typeof v === "string" && SCHEMA_KINDS.includes(v);
@@ -162,6 +168,72 @@ async function resolveRoot(policy, requested) {
162
168
  } });
163
169
  }
164
170
  //#endregion
171
+ //#region src/adapter.ts
172
+ /**
173
+ * MCP SDK adapter (v2-architecture §5.4, D-19, S-405).
174
+ *
175
+ * The ONLY module under `packages/mcp/src` allowed to import `@modelcontextprotocol/*`
176
+ * (guard: `check-sdk-adapter`). Everything AXIOM needs from the SDK — server construction,
177
+ * the two serving entries, the result types — is re-exported here under names that do not
178
+ * change when the SDK does, so the next SDK major is a one-file diff.
179
+ *
180
+ * SDK 2.0.0 (2026-07-27) facts this seam encodes:
181
+ * - `@modelcontextprotocol/sdk` is split; the server surface is `@modelcontextprotocol/server`
182
+ * and stdio lives on its `./stdio` subpath (the root barrel is runtime-neutral).
183
+ * - A hand-connected `McpServer` + `StdioServerTransport` speaks ONLY the 2025 era. Serving the
184
+ * 2026-07-28 revision goes through the factory entries `serveStdio` / `createMcpHandler`,
185
+ * which pin one instance per connection (stdio) or build one per request (HTTP) and pass the
186
+ * factory the `era` they are about to serve.
187
+ * - `ttlMs` / `cacheScope` (SEP-2549) are stamped by the SDK on 2026-era cacheable results from
188
+ * `ServerOptions.cacheHints`; 2025-era responses never carry them.
189
+ */
190
+ /**
191
+ * Cache policy AXIOM advertises on 2026-era `tools/list` & co. The tool catalogue is static
192
+ * for the life of a process, so a shared 5-minute TTL is safe; `resources/read` is content
193
+ * addressed (`axiom://manifest/<sha>`) and therefore immutable → public, 1 day.
194
+ */
195
+ const CACHE_HINTS = {
196
+ "tools/list": {
197
+ ttlMs: 3e5,
198
+ cacheScope: "public"
199
+ },
200
+ "resources/templates/list": {
201
+ ttlMs: 3e5,
202
+ cacheScope: "public"
203
+ },
204
+ "server/discover": {
205
+ ttlMs: 3e5,
206
+ cacheScope: "public"
207
+ },
208
+ "resources/list": {
209
+ ttlMs: 1e4,
210
+ cacheScope: "private"
211
+ },
212
+ "resources/read": {
213
+ ttlMs: 864e5,
214
+ cacheScope: "public"
215
+ }
216
+ };
217
+ //#endregion
218
+ //#region src/emitters.ts
219
+ /** Template emitters available to `axiom_plan_compile` / `axiom compile` (D-13: optional sugar). */
220
+ const EMITTERS = createEmitterRegistry([webEmitter]);
221
+ /** Flat, sorted `emitter@version: template — description` rows for the CLI and the resource. */
222
+ function emitterCatalogue(registry = EMITTERS) {
223
+ const rows = [];
224
+ for (const id of registry.list()) {
225
+ const e = registry.get(id);
226
+ if (e === void 0) continue;
227
+ for (const template of Object.keys(e.templates).sort()) rows.push({
228
+ emitter: e.id,
229
+ version: e.version,
230
+ template,
231
+ description: e.templates[template]?.description ?? ""
232
+ });
233
+ }
234
+ return rows;
235
+ }
236
+ //#endregion
165
237
  //#region src/store.ts
166
238
  /** `<root>/.axiom/manifests/<hex>.json` and `<root>/.axiom/reports/<hex>.json`. */
167
239
  function manifestsDir(root) {
@@ -179,13 +251,13 @@ function toDigestRef(shaOrRef) {
179
251
  if (!parsed.success) throw new AxiomError("ERR_NOT_FOUND", `not a sha256 digest: ${shaOrRef}`);
180
252
  return parsed.data;
181
253
  }
182
- async function writeJsonAtomic(file, value) {
254
+ async function writeJsonAtomic$1(file, value) {
183
255
  await mkdir(path.dirname(file), { recursive: true });
184
256
  const tmp = `${file}.tmp-${process.pid}`;
185
257
  await writeFile(tmp, JSON.stringify(value), "utf8");
186
258
  await rename(tmp, file);
187
259
  }
188
- async function readJsonOrUndefined(file) {
260
+ async function readJsonOrUndefined$1(file) {
189
261
  try {
190
262
  return JSON.parse(await readFile(file, "utf8"));
191
263
  } catch (err) {
@@ -195,30 +267,30 @@ async function readJsonOrUndefined(file) {
195
267
  }
196
268
  async function saveManifest(root, bundle) {
197
269
  const file = path.join(manifestsDir(root), `${hexOf(bundle.manifestDigest)}.json`);
198
- await writeJsonAtomic(file, bundle);
270
+ await writeJsonAtomic$1(file, bundle);
199
271
  return file;
200
272
  }
201
273
  async function saveReport(root, report) {
202
274
  const file = path.join(reportsDir(root), `${hexOf(report.manifestDigest)}.json`);
203
- await writeJsonAtomic(file, report);
275
+ await writeJsonAtomic$1(file, report);
204
276
  return file;
205
277
  }
206
278
  /** Search every root (allowlisted + seen) for a stored bundle. */
207
279
  async function loadManifest(roots, ref) {
208
280
  for (const root of roots) {
209
- const raw = await readJsonOrUndefined(path.join(manifestsDir(root), `${hexOf(ref)}.json`));
281
+ const raw = await readJsonOrUndefined$1(path.join(manifestsDir(root), `${hexOf(ref)}.json`));
210
282
  if (raw !== void 0) return ManifestBundleSchema.parse(raw);
211
283
  }
212
284
  }
213
285
  async function loadReport(roots, ref) {
214
286
  for (const root of roots) {
215
- const raw = await readJsonOrUndefined(path.join(reportsDir(root), `${hexOf(ref)}.json`));
287
+ const raw = await readJsonOrUndefined$1(path.join(reportsDir(root), `${hexOf(ref)}.json`));
216
288
  if (raw !== void 0) return CheckReportSchema.parse(raw);
217
289
  }
218
290
  }
219
291
  async function loadApplied(roots, ref) {
220
292
  for (const root of roots) {
221
- const raw = await readJsonOrUndefined(appliedPath(root, ref));
293
+ const raw = await readJsonOrUndefined$1(appliedPath(root, ref));
222
294
  if (raw !== void 0) return ApplyResultSchema.parse(raw);
223
295
  }
224
296
  }
@@ -242,6 +314,584 @@ async function listStored(roots, sub) {
242
314
  return out;
243
315
  }
244
316
  //#endregion
317
+ //#region src/tasks.ts
318
+ /**
319
+ * S-406 / D-24 — tool-level tasks and chunked plan sessions.
320
+ *
321
+ * MCP SDK v2 ships the `io.modelcontextprotocol/tasks` wire vocabulary but no runtime (the
322
+ * `tasks/*` methods are excluded from `setRequestHandler`, and the 2026-07-28 `tools/call` codec
323
+ * rejects a `CreateTaskResult`). AXIOM therefore models long-running work as ordinary tools:
324
+ * `axiom_check_start` → `{ taskId, status: "working", pollIntervalMs, ttlMs }`, then
325
+ * `axiom_task_get` until `status` is terminal, `axiom_task_cancel` to abort. This works on every
326
+ * client that can call a tool (Copilot, codai agent-core, SDK v1 and v2) and on both wire eras.
327
+ *
328
+ * State is per factory (shared by every server instance a `serverFactory` builds, like
329
+ * `seenRoots`), never persisted: a restarted server has no tasks, and a poll for an unknown id is
330
+ * `ERR_TASK_NOT_FOUND`. Terminal tasks are kept for `ttlMs` after finishing, then dropped.
331
+ *
332
+ * The same store hosts chunked plan sessions (`axiom_plan_begin` → `axiom_plan_add`* →
333
+ * `axiom_plan_seal`): artifacts accumulate server-side so a Plan whose JSON would exceed the
334
+ * per-call 4 MiB payload cap can still be compiled. Sealing feeds the assembled Plan to the very
335
+ * same `compilePlan`, so the digest is identical to a one-shot compile (property test).
336
+ */
337
+ const TASK_STATUSES = [
338
+ "working",
339
+ "completed",
340
+ "failed",
341
+ "cancelled"
342
+ ];
343
+ /** How long a finished task stays pollable. */
344
+ const TASK_TTL_MS = 6e5;
345
+ /** Advisory poll interval returned to clients. */
346
+ const TASK_POLL_INTERVAL_MS = 2e3;
347
+ /** Concurrent `working` tasks per server process; the (n+1)th `axiom_check_start` is refused. */
348
+ const TASK_MAX_WORKING = 8;
349
+ /** Idle sessions (no `add`/`seal`) are dropped after this. */
350
+ const PLAN_SESSION_TTL_MS = 18e5;
351
+ /** Artifacts a session may hold — same bound as `PlanSchema.artifacts.max`. */
352
+ const PLAN_SESSION_MAX_ARTIFACTS = 2e3;
353
+ /** Summed UTF-8 JSON bytes of every chunk a session accepts (64 MiB, the `default` profile's `maxTotalBytes`). */
354
+ const PLAN_SESSION_MAX_BYTES = 67108864;
355
+ const TaskStatusSchema = z.enum(TASK_STATUSES);
356
+ const TaskErrorSchema = z.object({
357
+ code: z.string(),
358
+ message: z.string(),
359
+ details: z.record(z.string(), z.unknown()).optional()
360
+ });
361
+ var TaskStore = class {
362
+ tasks = /* @__PURE__ */ new Map();
363
+ now;
364
+ ttlMs;
365
+ pollIntervalMs;
366
+ maxWorking;
367
+ constructor(opts = {}) {
368
+ this.now = opts.now ?? Date.now;
369
+ this.ttlMs = opts.ttlMs ?? 6e5;
370
+ this.pollIntervalMs = opts.pollIntervalMs ?? 2e3;
371
+ this.maxWorking = opts.maxWorking ?? 8;
372
+ }
373
+ /**
374
+ * Start `work` in the background. The returned record is `working` until the promise settles;
375
+ * a rejection becomes `failed` (AxiomError → its closed code, anything else → `ERR_INTERNAL`),
376
+ * a rejection after `cancel()` stays `cancelled`.
377
+ */
378
+ start(tool, work, meta = {}) {
379
+ this.sweep();
380
+ const working = [...this.tasks.values()].filter((t) => t.status === "working").length;
381
+ if (working >= this.maxWorking) throw new AxiomError("ERR_EBUSY", `too many running tasks (${working}/${this.maxWorking})`, { details: {
382
+ working,
383
+ max: this.maxWorking
384
+ } });
385
+ const controller = new AbortController();
386
+ const rec = {
387
+ taskId: randomUUID(),
388
+ tool,
389
+ status: "working",
390
+ createdAt: this.now(),
391
+ controller
392
+ };
393
+ if (meta.root !== void 0) rec.root = meta.root;
394
+ this.tasks.set(rec.taskId, rec);
395
+ work(controller.signal).then((result) => {
396
+ if (rec.status !== "working") return;
397
+ rec.status = "completed";
398
+ rec.result = result;
399
+ rec.finishedAt = this.now();
400
+ }, (err) => {
401
+ if (rec.status !== "working") return;
402
+ rec.status = controller.signal.aborted ? "cancelled" : "failed";
403
+ rec.error = toTaskError(err);
404
+ rec.finishedAt = this.now();
405
+ });
406
+ return rec;
407
+ }
408
+ get(taskId) {
409
+ this.sweep();
410
+ const rec = this.tasks.get(taskId);
411
+ if (rec === void 0) throw new AxiomError("ERR_TASK_NOT_FOUND", `no task ${taskId}`, { details: { taskId } });
412
+ return rec;
413
+ }
414
+ /** Abort a `working` task; terminal tasks are left as they are (idempotent). */
415
+ cancel(taskId) {
416
+ const rec = this.get(taskId);
417
+ if (rec.status === "working") {
418
+ rec.status = "cancelled";
419
+ rec.finishedAt = this.now();
420
+ rec.error = {
421
+ code: "ERR_TASK_CANCELLED",
422
+ message: "cancelled via axiom_task_cancel"
423
+ };
424
+ rec.controller.abort();
425
+ }
426
+ return rec;
427
+ }
428
+ describe(rec) {
429
+ return {
430
+ taskId: rec.taskId,
431
+ tool: rec.tool,
432
+ status: rec.status,
433
+ pollIntervalMs: this.pollIntervalMs,
434
+ ttlMs: this.ttlMs,
435
+ elapsedMs: Math.max(0, (rec.finishedAt ?? this.now()) - rec.createdAt)
436
+ };
437
+ }
438
+ /** Drop terminal tasks older than `ttlMs`. */
439
+ sweep() {
440
+ const cutoff = this.now() - this.ttlMs;
441
+ for (const [id, t] of this.tasks) if (t.finishedAt !== void 0 && t.finishedAt < cutoff) this.tasks.delete(id);
442
+ }
443
+ /** Abort everything still running (server shutdown). */
444
+ abortAll() {
445
+ for (const t of this.tasks.values()) if (t.status === "working") this.cancel(t.taskId);
446
+ }
447
+ get size() {
448
+ return this.tasks.size;
449
+ }
450
+ };
451
+ function toTaskError(err) {
452
+ if (err instanceof AxiomError) {
453
+ const j = err.toJSON();
454
+ const out = {
455
+ code: j.code,
456
+ message: j.message
457
+ };
458
+ if (j.details !== void 0) out.details = j.details;
459
+ return out;
460
+ }
461
+ return {
462
+ code: "ERR_INTERNAL",
463
+ message: err instanceof Error ? err.message : String(err)
464
+ };
465
+ }
466
+ var PlanSessionStore = class {
467
+ sessions = /* @__PURE__ */ new Map();
468
+ now;
469
+ ttlMs;
470
+ maxOpen;
471
+ maxArtifacts;
472
+ maxBytes;
473
+ constructor(opts = {}) {
474
+ this.now = opts.now ?? Date.now;
475
+ this.ttlMs = opts.ttlMs ?? 18e5;
476
+ this.maxOpen = opts.maxOpen ?? 16;
477
+ this.maxArtifacts = opts.maxArtifacts ?? 2e3;
478
+ this.maxBytes = opts.maxBytes ?? 67108864;
479
+ }
480
+ begin(header) {
481
+ this.sweep();
482
+ if (this.sessions.size >= this.maxOpen) throw new AxiomError("ERR_EBUSY", `too many open plan sessions (${this.sessions.size}/${this.maxOpen})`, { details: {
483
+ open: this.sessions.size,
484
+ max: this.maxOpen
485
+ } });
486
+ const t = this.now();
487
+ const s = {
488
+ sessionId: randomUUID(),
489
+ header,
490
+ artifacts: [],
491
+ paths: /* @__PURE__ */ new Set(),
492
+ bytes: 0,
493
+ createdAt: t,
494
+ touchedAt: t,
495
+ sealed: false
496
+ };
497
+ this.sessions.set(s.sessionId, s);
498
+ return s;
499
+ }
500
+ get(sessionId) {
501
+ this.sweep();
502
+ const s = this.sessions.get(sessionId);
503
+ if (s === void 0) throw new AxiomError("ERR_TASK_NOT_FOUND", `no plan session ${sessionId}`, { details: { sessionId } });
504
+ return s;
505
+ }
506
+ /** Append already-validated artifacts; duplicates (within or across chunks) are `ERR_INVALID_PLAN`. */
507
+ add(sessionId, artifacts, chunkBytes) {
508
+ const s = this.get(sessionId);
509
+ if (s.sealed) throw new AxiomError("ERR_PLAN_SESSION_STATE", "plan session is already sealed", { details: { sessionId } });
510
+ if (s.artifacts.length + artifacts.length > this.maxArtifacts) throw new AxiomError("ERR_PLAN_SESSION_STATE", `session would hold ${s.artifacts.length + artifacts.length} artifacts; max ${this.maxArtifacts}`, { details: {
511
+ sessionId,
512
+ have: s.artifacts.length,
513
+ adding: artifacts.length
514
+ } });
515
+ if (s.bytes + chunkBytes > this.maxBytes) throw new AxiomError("ERR_PLAN_SESSION_STATE", `session would hold ${s.bytes + chunkBytes} bytes; max ${this.maxBytes}`, { details: {
516
+ sessionId,
517
+ have: s.bytes,
518
+ adding: chunkBytes,
519
+ max: this.maxBytes
520
+ } });
521
+ for (const a of artifacts) if (s.paths.has(a.path)) throw new AxiomError("ERR_INVALID_PLAN", "duplicate artifact path across chunks", {
522
+ path: a.path,
523
+ details: { sessionId }
524
+ });
525
+ for (const a of artifacts) {
526
+ s.paths.add(a.path);
527
+ s.artifacts.push(a);
528
+ }
529
+ s.bytes += chunkBytes;
530
+ s.touchedAt = this.now();
531
+ return s;
532
+ }
533
+ /** Mark sealed and return the assembled Plan input; the session is dropped. */
534
+ seal(sessionId) {
535
+ const s = this.get(sessionId);
536
+ if (s.sealed) throw new AxiomError("ERR_PLAN_SESSION_STATE", "plan session is already sealed", { details: { sessionId } });
537
+ s.sealed = true;
538
+ this.sessions.delete(sessionId);
539
+ return {
540
+ session: s,
541
+ plan: {
542
+ apiVersion: "axiom.dev/v2",
543
+ kind: "Plan",
544
+ ...s.header,
545
+ artifacts: s.artifacts
546
+ }
547
+ };
548
+ }
549
+ /** Drop an unsealed session without compiling. Unknown ids are a no-op. */
550
+ abandon(sessionId) {
551
+ return this.sessions.delete(sessionId);
552
+ }
553
+ sweep() {
554
+ const cutoff = this.now() - this.ttlMs;
555
+ for (const [id, s] of this.sessions) if (s.touchedAt < cutoff) this.sessions.delete(id);
556
+ }
557
+ get size() {
558
+ return this.sessions.size;
559
+ }
560
+ };
561
+ //#endregion
562
+ //#region src/keys.ts
563
+ /**
564
+ * Key material and trust-store I/O for the CLI/MCP layer (D-16).
565
+ *
566
+ * Private keys are read ONLY from `AXIOM_SIGNING_KEY` (base64 PKCS#8 or raw seed) or
567
+ * `--key-file <path>`; they are never written to stdout and never stored under a root.
568
+ */
569
+ function trustFilePath(root, rel = TRUST_FILE_DEFAULT) {
570
+ return path.join(root, ...rel.split("/"));
571
+ }
572
+ function trustStatePath(root) {
573
+ return path.join(root, ...TRUST_STATE_FILE.split("/"));
574
+ }
575
+ function trustStateKeyPath(root) {
576
+ return path.join(root, ...TRUST_STATE_KEY_FILE.split("/"));
577
+ }
578
+ async function writeJsonAtomic(file, value, mode) {
579
+ await mkdir(path.dirname(file), { recursive: true });
580
+ const tmp = `${file}.tmp-${process.pid}`;
581
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
582
+ encoding: "utf8",
583
+ mode
584
+ });
585
+ if (mode !== void 0) await chmod(tmp, mode).catch(() => void 0);
586
+ await rename(tmp, file);
587
+ }
588
+ async function readJsonOrUndefined(file) {
589
+ try {
590
+ return JSON.parse(await readFile(file, "utf8"));
591
+ } catch (err) {
592
+ if (err.code === "ENOENT") return void 0;
593
+ throw err;
594
+ }
595
+ }
596
+ /** Verify a bundle's detached signatures against the root's trust store; `undefined` when no store. */
597
+ async function verifyBundleAgainstRoot(root, bundle, rel = TRUST_FILE_DEFAULT) {
598
+ const store = await loadTrustStore(root, rel);
599
+ if (store === void 0) return void 0;
600
+ const v = verifyBundleSignatures(bundle, store, bundle.manifest.counter);
601
+ const report = {
602
+ trustFile: rel,
603
+ keyids: v.keyids,
604
+ findings: v.findings.map((f) => ({
605
+ id: f.id,
606
+ message: f.message
607
+ })),
608
+ ok: v.keyids.length > 0 && v.findings.length === 0
609
+ };
610
+ if (!report.ok) report.code = (bundle.signatures ?? []).length === 0 ? "ERR_SIGNATURE_MISSING" : "ERR_SIGNATURE_INVALID";
611
+ return report;
612
+ }
613
+ async function loadTrustStore(root, rel = TRUST_FILE_DEFAULT) {
614
+ const raw = await readJsonOrUndefined(trustFilePath(root, rel));
615
+ if (raw === void 0) return void 0;
616
+ const parsed = TrustStoreSchema.safeParse(raw);
617
+ 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) } });
618
+ return parsed.data;
619
+ }
620
+ async function loadStateKey(root) {
621
+ let text;
622
+ try {
623
+ text = await readFile(trustStateKeyPath(root), "utf8");
624
+ } catch (err) {
625
+ if (err.code === "ENOENT") return void 0;
626
+ throw err;
627
+ }
628
+ const hex = text.trim();
629
+ if (!/^[0-9a-f]{64}$/.test(hex)) throw new AxiomError("ERR_TRUST_STATE_CORRUPT", `${TRUST_STATE_KEY_FILE} is not 32 hex bytes`);
630
+ return hex;
631
+ }
632
+ /** Create `.axiom/trust/state.key` (32 random bytes, hex, 0600) when absent; return the key. */
633
+ async function ensureStateKey(root) {
634
+ const existing = await loadStateKey(root);
635
+ if (existing !== void 0) return existing;
636
+ const hex = randomBytes(32).toString("hex");
637
+ const file = trustStateKeyPath(root);
638
+ await mkdir(path.dirname(file), { recursive: true });
639
+ const tmp = `${file}.tmp-${process.pid}`;
640
+ await writeFile(tmp, `${hex}\n`, {
641
+ encoding: "utf8",
642
+ mode: 384
643
+ });
644
+ await chmod(tmp, 384).catch(() => void 0);
645
+ await rename(tmp, file);
646
+ return hex;
647
+ }
648
+ async function loadTrustState(root) {
649
+ let raw;
650
+ try {
651
+ raw = await readJsonOrUndefined(trustStatePath(root));
652
+ } catch (err) {
653
+ if (err instanceof SyntaxError) throw new AxiomError("ERR_TRUST_STATE_CORRUPT", `${TRUST_STATE_FILE} is not valid JSON`, { cause: err });
654
+ throw err;
655
+ }
656
+ if (raw === void 0) return void 0;
657
+ const parsed = TrustStateSchema.safeParse(raw);
658
+ if (!parsed.success) throw new AxiomError("ERR_TRUST_STATE_CORRUPT", `${TRUST_STATE_FILE} is invalid`, { details: { issues: parsed.error.issues.slice(0, 5).map((i) => i.message) } });
659
+ const keyHex = await loadStateKey(root);
660
+ if (keyHex !== void 0 && !trustStateMacOk(parsed.data, keyHex)) throw new AxiomError("ERR_TRUST_STATE_CORRUPT", `${TRUST_STATE_FILE} failed its MAC (edited by hand, or ${TRUST_STATE_KEY_FILE} rotated)`, { details: { reason: parsed.data.mac === void 0 ? "NO_MAC" : "BAD_MAC" } });
661
+ return parsed.data;
662
+ }
663
+ /**
664
+ * Advance `lastCounter` to `bundle.manifest.counter` after a successful apply.
665
+ * Monotonic: never moves backwards; a no-op when the bundle has no counter.
666
+ * Write-temp + rename so a crash leaves either the old or the new state.
667
+ */
668
+ async function advanceTrustState(root, bundle) {
669
+ const counter = bundle.manifest.counter;
670
+ if (counter === void 0) return void 0;
671
+ const cur = await loadTrustState(root);
672
+ if (cur !== void 0 && cur.lastCounter >= counter) return cur;
673
+ const keyHex = await ensureStateKey(root);
674
+ const unsigned = {
675
+ version: 1,
676
+ lastCounter: counter,
677
+ manifestDigest: bundle.manifestDigest
678
+ };
679
+ const next = {
680
+ ...unsigned,
681
+ mac: trustStateMac(unsigned, keyHex)
682
+ };
683
+ await writeJsonAtomic(trustStatePath(root), next);
684
+ return next;
685
+ }
686
+ /** Does the resolved profile (plus plan checks) enable antiRollback on requireSigned? */
687
+ function profileWantsAntiRollback(checks) {
688
+ return checks.some((c) => c.predicate === "manifest.requireSigned" && typeof c.params === "object" && c.params !== null && c.params.antiRollback === true);
689
+ }
690
+ //#endregion
691
+ //#region src/snapshot.ts
692
+ /**
693
+ * `axiom_repo_snapshot` — deterministic, content-addressed inventory of a root (S-304).
694
+ *
695
+ * Successor of the v1 reverse-IR, which guessed "service types" from directory names and
696
+ * hashed nothing. A RepoSnapshot records what is actually there — relative path, size,
697
+ * sha256, mode, kind — sorted by `compareUtf8`, with no timestamps and no absolute paths, so
698
+ * the same tree yields the same `snapshotDigest` on every machine (invariant 1). Agents use
699
+ * it to build Plans against real pre-image digests and to diff two states of a tree.
700
+ *
701
+ * Read-only: never follows symlinks, never leaves the root, never spawns a process.
702
+ */
703
+ const SNAPSHOT_MAX_FILES_DEFAULT = 2e4;
704
+ const SNAPSHOT_MAX_FILES_CAP = 5e4;
705
+ const SNAPSHOT_MAX_BYTES_DEFAULT = 67108864;
706
+ /** Never inventoried, whatever `.gitignore` says. */
707
+ const ALWAYS_SKIP = /* @__PURE__ */ new Set([".git", ".axiom"]);
708
+ function globToRegExp(glob) {
709
+ let re = "^";
710
+ for (let i = 0; i < glob.length; i++) {
711
+ const c = glob[i];
712
+ if (c === "*") {
713
+ if (glob[i + 1] === "*") {
714
+ i++;
715
+ if (glob[i + 1] === "/") {
716
+ i++;
717
+ re += "(?:.*/)?";
718
+ } else re += ".*";
719
+ } else re += "[^/]*";
720
+ } else if (c === "?") re += "[^/]";
721
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
722
+ }
723
+ return new RegExp(`${re}$`);
724
+ }
725
+ function matcherOf(globs, whenEmpty) {
726
+ if (globs === void 0 || globs.length === 0) return () => whenEmpty;
727
+ const res = globs.map(globToRegExp);
728
+ return (rel) => res.some((r) => r.test(rel));
729
+ }
730
+ /** A glob must itself be a contained relative path once wildcards are removed (`..` → rejected). */
731
+ function validateGlob(g, label) {
732
+ const probe = g.replace(/\*+/g, "x").replace(/\?/g, "x").replace(/\/+$/, "");
733
+ if (probe.length === 0 || !isValidRelPath(probe)) throw new AxiomError("ERR_CONTAINMENT", `${label} glob must be a contained relative path: ${g}`, { details: { glob: g } });
734
+ }
735
+ /** Root `.gitignore` → matchers, same rough translation the repo facts use (negations dropped). */
736
+ async function gitignoreMatcher(root) {
737
+ let text;
738
+ try {
739
+ text = await readFile(path.join(root, ".gitignore"), "utf8");
740
+ } catch {
741
+ return () => false;
742
+ }
743
+ const globs = [];
744
+ for (const raw of text.split(/\r?\n/)) {
745
+ const l = raw.trim();
746
+ if (l.length === 0 || l.startsWith("#") || l.startsWith("!")) continue;
747
+ let pat = l.startsWith("/") ? l.slice(1) : l.includes("/") ? l : `**/${l}`;
748
+ if (pat.endsWith("/")) pat = pat.slice(0, -1);
749
+ globs.push(pat, `${pat}/**`);
750
+ }
751
+ return matcherOf(globs, false);
752
+ }
753
+ function sha256File(abs) {
754
+ return new Promise((resolve, reject) => {
755
+ const h = createHash("sha256");
756
+ createReadStream(abs).on("data", (chunk) => h.update(chunk)).on("error", reject).on("end", () => resolve(h.digest("hex")));
757
+ });
758
+ }
759
+ function modeOf(mode) {
760
+ return process.platform !== "win32" && (mode & 64) !== 0 ? "0755" : "0644";
761
+ }
762
+ /**
763
+ * Inventory `rootReal` (already realpath'd and authorised by the caller).
764
+ * The walk visits entries in `compareUtf8` order of their relative path (directories keyed with
765
+ * a trailing `/`), which is exactly the final sort order — so a truncated `files` is the first
766
+ * N paths of the full sorted inventory, deterministically.
767
+ */
768
+ async function snapshotRoot(root, opts = {}) {
769
+ if (opts.followSymlinks === true) throw new AxiomError("ERR_UNSUPPORTED_OP", "followSymlinks is not supported (symlinks are recorded, never followed)");
770
+ const rootReal = await realpath$1(root);
771
+ const maxFiles = Math.min(opts.maxFiles ?? 2e4, SNAPSHOT_MAX_FILES_CAP);
772
+ const maxBytes = opts.maxBytes ?? 67108864;
773
+ if (maxFiles < 1 || maxBytes < 0) throw new AxiomError("ERR_INVALID_PLAN", "maxFiles must be ≥ 1 and maxBytes ≥ 0", { details: {
774
+ maxFiles,
775
+ maxBytes
776
+ } });
777
+ for (const g of opts.include ?? []) validateGlob(g, "include");
778
+ for (const g of opts.exclude ?? []) validateGlob(g, "exclude");
779
+ const include = matcherOf(opts.include, true);
780
+ const exclude = matcherOf(opts.exclude, false);
781
+ const ignored = opts.respectGitignore === false ? () => false : await gitignoreMatcher(rootReal);
782
+ const withDigest = opts.withContentDigest !== false;
783
+ const files = [];
784
+ let bytes = 0;
785
+ let truncated = false;
786
+ const visit = async (dirAbs, dirRel) => {
787
+ let entries;
788
+ try {
789
+ const dir = await opendir(dirAbs);
790
+ entries = [];
791
+ for await (const e of dir) entries.push(e);
792
+ } catch {
793
+ return;
794
+ }
795
+ const sortKey = (d) => d.isDirectory() ? `${d.name}/` : d.name;
796
+ entries.sort((a, b) => compareUtf8(sortKey(a), sortKey(b)));
797
+ for (const e of entries) {
798
+ if (truncated) return;
799
+ const rel = dirRel === "" ? e.name : `${dirRel}/${e.name}`;
800
+ if (!isValidRelPath(rel)) continue;
801
+ if (e.isDirectory()) {
802
+ if (ALWAYS_SKIP.has(e.name) || ignored(rel) || exclude(rel)) continue;
803
+ await visit(path.join(dirAbs, e.name), rel);
804
+ continue;
805
+ }
806
+ if (ignored(rel) || !include(rel) || exclude(rel)) continue;
807
+ const abs = path.join(dirAbs, e.name);
808
+ let entry;
809
+ if (e.isSymbolicLink()) entry = await symlinkEntry(rootReal, abs, rel, withDigest);
810
+ else if (e.isFile()) entry = await fileEntry(abs, rel, withDigest);
811
+ if (entry === void 0) continue;
812
+ if (files.length >= maxFiles || bytes + entry.bytes > maxBytes) {
813
+ truncated = true;
814
+ return;
815
+ }
816
+ files.push(entry);
817
+ bytes += entry.bytes;
818
+ }
819
+ };
820
+ await visit(rootReal, "");
821
+ files.sort((a, b) => compareUtf8(a.path, b.path));
822
+ const body = {
823
+ files,
824
+ truncated,
825
+ counts: {
826
+ files: files.length,
827
+ bytes
828
+ }
829
+ };
830
+ return {
831
+ apiVersion: "axiom.dev/v2",
832
+ kind: "RepoSnapshot",
833
+ root: { kind: "relative" },
834
+ snapshotDigest: canonicalDigestRef(body),
835
+ body
836
+ };
837
+ }
838
+ async function fileEntry(abs, rel, withDigest) {
839
+ let st;
840
+ try {
841
+ st = await lstat(abs);
842
+ } catch {
843
+ return;
844
+ }
845
+ if (!st.isFile()) return void 0;
846
+ const entry = {
847
+ path: rel,
848
+ bytes: st.size,
849
+ mode: modeOf(st.mode),
850
+ kind: "file"
851
+ };
852
+ if (withDigest) try {
853
+ entry.sha256 = await sha256File(abs);
854
+ } catch {
855
+ return;
856
+ }
857
+ return entry;
858
+ }
859
+ /**
860
+ * A symlink is recorded as `kind: "symlink"`. Its target is hashed only when it resolves to a
861
+ * regular file *inside* the root; anything else (outside, dangling, directory) gets
862
+ * `bytes: 0` and no digest — the link is inventoried, its target is not disclosed.
863
+ */
864
+ async function symlinkEntry(rootReal, abs, rel, withDigest) {
865
+ const entry = {
866
+ path: rel,
867
+ bytes: 0,
868
+ mode: "0644",
869
+ kind: "symlink"
870
+ };
871
+ let target;
872
+ try {
873
+ target = await realpath$1(abs);
874
+ } catch {
875
+ return entry;
876
+ }
877
+ if (!isSameOrInside(rootReal, target)) return entry;
878
+ let st;
879
+ try {
880
+ st = await lstat(target);
881
+ } catch {
882
+ return entry;
883
+ }
884
+ if (!st.isFile()) return entry;
885
+ entry.bytes = st.size;
886
+ entry.mode = modeOf(st.mode);
887
+ if (withDigest) try {
888
+ entry.sha256 = await sha256File(target);
889
+ } catch {
890
+ entry.bytes = 0;
891
+ }
892
+ return entry;
893
+ }
894
+ //#endregion
245
895
  //#region src/tools.ts
246
896
  /** Hard cap on any single `bundle`/`plan` argument, measured as UTF-8 JSON bytes (§(f) payload size). */
247
897
  const BUNDLE_BYTES_MAX = 4194304;
@@ -305,18 +955,20 @@ async function profileFor(ctx, bundle, name, rootReal) {
305
955
  });
306
956
  return loadProfile(profileName, { searchDirs });
307
957
  }
308
- async function checkBundle(ctx, bundle, profileName, rootReal) {
958
+ async function checkBundle(ctx, bundle, profileName, rootReal, signal) {
309
959
  const opts = {
310
960
  bundle,
311
961
  profile: await profileFor(ctx, bundle, profileName, rootReal),
312
962
  checks: bundle.manifest.checks,
313
963
  ...ctx.guards
314
964
  };
965
+ if (signal !== void 0) opts.signal = signal;
315
966
  if (rootReal !== void 0) {
316
967
  opts.root = rootReal;
317
968
  opts.casDir = path.join(rootReal, ".axiom", "cas");
318
969
  }
319
970
  const report = await runChecks(opts);
971
+ if (signal?.aborted) throw new AxiomError("ERR_TASK_CANCELLED", "check cancelled", { details: { manifestDigest: bundle.manifestDigest } });
320
972
  if (rootReal !== void 0) {
321
973
  await saveReport(rootReal, report);
322
974
  ctx.seenRoots.add(rootReal);
@@ -353,7 +1005,20 @@ const ManifestVerifyOutput = z.object({
353
1005
  code: ErrorCodeSchema,
354
1006
  message: z.string(),
355
1007
  path: z.string().optional()
356
- }))
1008
+ })),
1009
+ /** Present only when a root with `.axiom/trust/keys.json` was available (D-16). */
1010
+ signatures: z.object({
1011
+ trustFile: z.string(),
1012
+ /** Trusted keyids whose signature verified over this manifest. */
1013
+ keyids: z.array(z.string()),
1014
+ findings: z.array(z.object({
1015
+ id: z.string(),
1016
+ message: z.string()
1017
+ })),
1018
+ ok: z.boolean(),
1019
+ /** `ERR_SIGNATURE_MISSING` (no signatures at all) or `ERR_SIGNATURE_INVALID`; absent when `ok`. */
1020
+ code: z.enum(["ERR_SIGNATURE_MISSING", "ERR_SIGNATURE_INVALID"]).optional()
1021
+ }).optional()
357
1022
  });
358
1023
  const RollbackOutput = z.object({
359
1024
  manifestDigest: DigestRefSchema,
@@ -393,6 +1058,43 @@ const AxmParseOutput = z.object({
393
1058
  }))
394
1059
  });
395
1060
  const BundleOrRef = z.union([DigestRefSchema, LooseObject]).describe("A ManifestBundle object, or `sha256:<hex>` of a bundle stored under <root>/.axiom/manifests");
1061
+ const TaskDescriptorOutput = z.object({
1062
+ taskId: z.string(),
1063
+ tool: z.string(),
1064
+ status: TaskStatusSchema,
1065
+ pollIntervalMs: z.int().positive(),
1066
+ ttlMs: z.int().positive(),
1067
+ elapsedMs: z.int().nonnegative()
1068
+ });
1069
+ /** `axiom_task_get`: the descriptor plus, once terminal, the result or the error. */
1070
+ const TaskGetOutput = TaskDescriptorOutput.extend({
1071
+ /** Present iff `status === "completed"`; for `axiom_check` tasks a `CheckReport`. */
1072
+ result: CheckReportSchema.optional(),
1073
+ /** Present iff `status` is `failed` or `cancelled`. */
1074
+ error: TaskErrorSchema.optional()
1075
+ });
1076
+ const PlanHeaderShape = {
1077
+ name: PlanSchema.shape.name,
1078
+ intent: PlanSchema.shape.intent,
1079
+ profile: PlanSchema.shape.profile.optional(),
1080
+ capabilities: PlanSchema.shape.capabilities.optional(),
1081
+ checks: PlanSchema.shape.checks.optional(),
1082
+ counter: PlanSchema.shape.counter,
1083
+ metadata: PlanSchema.shape.metadata.optional()
1084
+ };
1085
+ const PlanSessionOutput = z.object({
1086
+ sessionId: z.string(),
1087
+ artifacts: z.int().nonnegative(),
1088
+ bytes: z.int().nonnegative(),
1089
+ /** Remaining budget before `axiom_plan_add` refuses with `ERR_PLAN_SESSION_STATE`. */
1090
+ limits: z.object({
1091
+ maxArtifacts: z.int().positive(),
1092
+ maxBytes: z.int().positive()
1093
+ }),
1094
+ ttlMs: z.int().positive()
1095
+ });
1096
+ const SessionIdArg = z.string().min(1).describe("sessionId returned by axiom_plan_begin");
1097
+ const TaskIdArg = z.string().min(1).describe("taskId returned by axiom_check_start");
396
1098
  const TOOL_DEFS = [
397
1099
  defineTool({
398
1100
  name: "axiom_plan_validate",
@@ -417,7 +1119,10 @@ const TOOL_DEFS = [
417
1119
  })
418
1120
  };
419
1121
  try {
420
- const { bundle } = await compilePlan(parsed.data, { store: "inline" });
1122
+ const { bundle } = await compilePlan(parsed.data, {
1123
+ store: "inline",
1124
+ emitters: EMITTERS
1125
+ });
421
1126
  return {
422
1127
  ok: true,
423
1128
  planDigest: bundle.manifest.planDigest,
@@ -440,7 +1145,7 @@ const TOOL_DEFS = [
440
1145
  defineTool({
441
1146
  name: "axiom_plan_compile",
442
1147
  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.",
1148
+ 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
1149
  inputSchema: {
445
1150
  plan: LooseObject.describe("Plan document"),
446
1151
  store: z.enum(["inline", "cas"]).optional().describe("Blob transport; default inline"),
@@ -450,37 +1155,21 @@ const TOOL_DEFS = [
450
1155
  annotations: ACT,
451
1156
  async handler(ctx, { plan, store, root }) {
452
1157
  guardPayloadSize("plan", plan);
453
- const rootReal = root !== void 0 || store === "cas" ? (await resolveRoot(ctx.policy, root)).rootReal : void 0;
454
- const opts = { store: store ?? "inline" };
455
- if (rootReal !== void 0) opts.root = rootReal;
456
- const { bundle } = await compilePlan(plan, opts);
457
- if (rootReal !== void 0) {
458
- await saveManifest(rootReal, bundle);
459
- ctx.seenRoots.add(rootReal);
460
- }
461
- ctx.log.info("compiled", {
462
- manifestDigest: bundle.manifestDigest,
463
- artifacts: bundle.manifest.artifacts.length
464
- });
465
- return bundle;
1158
+ return compileToBundle(ctx, plan, store, root);
466
1159
  },
467
- summarize: (b) => ({
468
- manifestDigest: b.manifestDigest,
469
- planDigest: b.manifest.planDigest,
470
- name: b.manifest.name,
471
- profile: b.manifest.profile,
472
- artifacts: b.manifest.artifacts.length,
473
- blobs: Object.keys(b.blobs).length
474
- })
1160
+ summarize: summarizeBundle
475
1161
  }),
476
1162
  defineTool({
477
1163
  name: "axiom_manifest_verify",
478
1164
  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") },
1165
+ 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.",
1166
+ inputSchema: {
1167
+ bundle: LooseObject.describe("ManifestBundle"),
1168
+ root: RootArg
1169
+ },
481
1170
  outputSchema: ManifestVerifyOutput,
482
1171
  annotations: READ,
483
- async handler(_ctx, { bundle }) {
1172
+ async handler(ctx, { bundle, root }) {
484
1173
  guardPayloadSize("bundle", bundle);
485
1174
  const r = verifyBundle(bundle);
486
1175
  const out = {
@@ -491,12 +1180,25 @@ const TOOL_DEFS = [
491
1180
  errors: r.errors
492
1181
  };
493
1182
  if (r.manifestDigest !== void 0) out.manifestDigest = r.manifestDigest;
1183
+ if (r.ok) {
1184
+ const rootReal = await optionalRoot(ctx, root);
1185
+ if (rootReal !== void 0) {
1186
+ const sig = await verifyBundleAgainstRoot(rootReal, parseBundle(bundle));
1187
+ if (sig !== void 0) {
1188
+ out.signatures = sig;
1189
+ out.signed = sig.keyids.length > 0;
1190
+ if (!sig.ok) out.ok = false;
1191
+ }
1192
+ }
1193
+ }
494
1194
  return out;
495
1195
  },
496
1196
  summarize: (o) => ({
497
1197
  ok: o.ok,
498
1198
  manifestDigest: o.manifestDigest,
499
1199
  canonical: o.canonical,
1200
+ signed: o.signed,
1201
+ keyids: o.signatures?.keyids,
500
1202
  missing: o.missing.length,
501
1203
  errors: o.errors.slice(0, 20)
502
1204
  })
@@ -517,6 +1219,135 @@ const TOOL_DEFS = [
517
1219
  },
518
1220
  summarize: summarizeReport
519
1221
  }),
1222
+ defineTool({
1223
+ name: "axiom_check_start",
1224
+ title: "Start policy checks as a background task",
1225
+ description: "Same evaluation as axiom_check, but returned immediately as a task so long `guard.external` suites (up to 15 min per guard) outlive the client's per-call timeout. Poll axiom_task_get with the returned taskId every pollIntervalMs until status is completed|failed|cancelled; the CheckReport is in `result`. Finished tasks are pollable for ttlMs, then forgotten. Tasks live in this server process only (D-24 — tool-level tasks, not the io.modelcontextprotocol/tasks wire extension).",
1226
+ inputSchema: {
1227
+ bundle: LooseObject.describe("ManifestBundle"),
1228
+ profile: ProfileArg,
1229
+ root: RootArg
1230
+ },
1231
+ outputSchema: TaskDescriptorOutput,
1232
+ annotations: READ,
1233
+ async handler(ctx, { bundle, profile, root }) {
1234
+ const parsed = parseBundle(bundle);
1235
+ const rootReal = await optionalRoot(ctx, root);
1236
+ const meta = {};
1237
+ if (rootReal !== void 0) meta.root = rootReal;
1238
+ const rec = ctx.tasks.start("axiom_check", (signal) => checkBundle(ctx, parsed, profile, rootReal, signal), meta);
1239
+ ctx.log.info("task started", {
1240
+ taskId: rec.taskId,
1241
+ manifestDigest: parsed.manifestDigest,
1242
+ root: rootReal
1243
+ });
1244
+ return ctx.tasks.describe(rec);
1245
+ },
1246
+ summarize: (o) => o
1247
+ }),
1248
+ defineTool({
1249
+ name: "axiom_task_get",
1250
+ title: "Poll a task",
1251
+ description: "Status of a task created by axiom_check_start. While `working` only the descriptor is returned; once terminal, `result` (completed) or `error` (failed|cancelled) is attached. Unknown or expired taskId → ERR_TASK_NOT_FOUND.",
1252
+ inputSchema: { taskId: TaskIdArg },
1253
+ outputSchema: TaskGetOutput,
1254
+ annotations: READ,
1255
+ async handler(ctx, { taskId }) {
1256
+ const rec = ctx.tasks.get(taskId);
1257
+ const out = ctx.tasks.describe(rec);
1258
+ if (rec.status === "completed") out.result = CheckReportSchema.parse(rec.result);
1259
+ else if (rec.error !== void 0) out.error = rec.error;
1260
+ return out;
1261
+ },
1262
+ summarize: (o) => ({
1263
+ taskId: o.taskId,
1264
+ status: o.status,
1265
+ elapsedMs: o.elapsedMs,
1266
+ pollIntervalMs: o.pollIntervalMs,
1267
+ result: o.result === void 0 ? void 0 : summarizeReport(o.result),
1268
+ error: o.error
1269
+ })
1270
+ }),
1271
+ defineTool({
1272
+ name: "axiom_task_cancel",
1273
+ title: "Cancel a task",
1274
+ description: "Abort a `working` task: every running guard process tree is killed and the task ends `cancelled` with error ERR_TASK_CANCELLED. Idempotent — a terminal task is returned unchanged.",
1275
+ inputSchema: { taskId: TaskIdArg },
1276
+ outputSchema: TaskDescriptorOutput,
1277
+ annotations: ACT,
1278
+ async handler(ctx, { taskId }) {
1279
+ const rec = ctx.tasks.cancel(taskId);
1280
+ ctx.log.info("task cancelled", {
1281
+ taskId,
1282
+ status: rec.status
1283
+ });
1284
+ return ctx.tasks.describe(rec);
1285
+ },
1286
+ summarize: (o) => o
1287
+ }),
1288
+ defineTool({
1289
+ name: "axiom_plan_begin",
1290
+ title: "Open a chunked plan session",
1291
+ description: "Start assembling a Plan whose JSON would exceed the 4 MiB per-call cap: send the header here (everything except `artifacts`), append artifacts in chunks with axiom_plan_add, then axiom_plan_seal compiles the whole. The sealed bundle's manifestDigest is identical to a one-shot axiom_plan_compile of the same Plan. Sessions are in-memory, expire after 30 min idle, and hold at most 2000 artifacts / 64 MiB.",
1292
+ inputSchema: PlanHeaderShape,
1293
+ outputSchema: PlanSessionOutput,
1294
+ annotations: ACT,
1295
+ async handler(ctx, header) {
1296
+ const s = ctx.planSessions.begin(header);
1297
+ ctx.log.debug("plan session opened", {
1298
+ sessionId: s.sessionId,
1299
+ name: header.name
1300
+ });
1301
+ return describeSession(ctx.planSessions, s);
1302
+ },
1303
+ summarize: (o) => o
1304
+ }),
1305
+ defineTool({
1306
+ name: "axiom_plan_add",
1307
+ title: "Append artifacts to a plan session",
1308
+ description: "Add a chunk of Plan artifacts (each ≤ 4 MiB call, inline content ≤ 256 KiB per artifact as in a Plan) to an open session. Paths must be unique across every chunk (ERR_INVALID_PLAN otherwise); a sealed or over-budget session is ERR_PLAN_SESSION_STATE.",
1309
+ inputSchema: {
1310
+ sessionId: SessionIdArg,
1311
+ artifacts: z.array(LooseObject).min(1).describe("Plan artifacts, same shape as Plan.artifacts[]")
1312
+ },
1313
+ outputSchema: PlanSessionOutput,
1314
+ annotations: ACT,
1315
+ async handler(ctx, { sessionId, artifacts }) {
1316
+ guardPayloadSize("artifacts", artifacts);
1317
+ const parsed = z.array(PlanArtifactSchema).safeParse(artifacts);
1318
+ if (!parsed.success) throw new AxiomError("ERR_INVALID_PLAN", "artifacts do not match PlanArtifactSchema", { details: { issues: parsed.error.issues.slice(0, 20).map((i) => ({
1319
+ path: i.path.map(String).join("."),
1320
+ message: i.message
1321
+ })) } });
1322
+ const bytes = Buffer.byteLength(JSON.stringify(artifacts), "utf8");
1323
+ const s = ctx.planSessions.add(sessionId, parsed.data, bytes);
1324
+ return describeSession(ctx.planSessions, s);
1325
+ },
1326
+ summarize: (o) => o
1327
+ }),
1328
+ defineTool({
1329
+ name: "axiom_plan_seal",
1330
+ title: "Seal a plan session and compile it",
1331
+ description: "Assemble the session's header + every added artifact into one Plan and compile it exactly like axiom_plan_compile (same options, same digest). The session is consumed whether or not compilation succeeds. Bundles whose inline blobs exceed 4 MiB need `store: cas` (and therefore a root).",
1332
+ inputSchema: {
1333
+ sessionId: SessionIdArg,
1334
+ store: z.enum(["inline", "cas"]).optional().describe("Blob transport; default inline"),
1335
+ root: RootArg
1336
+ },
1337
+ outputSchema: ManifestBundleSchema,
1338
+ annotations: ACT,
1339
+ async handler(ctx, { sessionId, store, root }) {
1340
+ const { plan, session } = ctx.planSessions.seal(sessionId);
1341
+ const bundle = await compileToBundle(ctx, plan, store, root);
1342
+ ctx.log.info("plan session sealed", {
1343
+ sessionId,
1344
+ artifacts: session.artifacts.length,
1345
+ manifestDigest: bundle.manifestDigest
1346
+ });
1347
+ return bundle;
1348
+ },
1349
+ summarize: summarizeBundle
1350
+ }),
520
1351
  defineTool({
521
1352
  name: "axiom_apply_dry_run",
522
1353
  title: "Dry-run apply (stage + diff, no writes to the tree)",
@@ -562,6 +1393,7 @@ const TOOL_DEFS = [
562
1393
  manifestDigest: parsed.manifestDigest
563
1394
  } });
564
1395
  const { rootReal } = await resolveRoot(ctx.policy, root);
1396
+ const profileDoc = await profileFor(ctx, parsed, profile, rootReal);
565
1397
  const result = await apply({
566
1398
  bundle: parsed,
567
1399
  root: rootReal,
@@ -575,6 +1407,7 @@ const TOOL_DEFS = [
575
1407
  await saveManifest(rootReal, parsed);
576
1408
  ctx.seenRoots.add(rootReal);
577
1409
  }
1410
+ if (result.status === "applied" && profileWantsAntiRollback([...profileDoc.checks, ...parsed.manifest.checks])) await advanceTrustState(rootReal, parsed);
578
1411
  ctx.log.info("apply", {
579
1412
  manifestDigest: parsed.manifestDigest,
580
1413
  status: result.status,
@@ -677,6 +1510,46 @@ const TOOL_DEFS = [
677
1510
  return { roots };
678
1511
  },
679
1512
  summarize: (o) => o
1513
+ }),
1514
+ defineTool({
1515
+ name: "axiom_repo_snapshot",
1516
+ title: "Snapshot a root",
1517
+ 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`.",
1518
+ inputSchema: {
1519
+ root: RootArg,
1520
+ include: z.array(z.string().min(1)).optional().describe("Relative globs (*, **, ?) to keep; default everything"),
1521
+ exclude: z.array(z.string().min(1)).optional().describe("Relative globs to drop"),
1522
+ 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`),
1523
+ maxBytes: z.int().nonnegative().default(SNAPSHOT_MAX_BYTES_DEFAULT).describe("Stop once the summed size would exceed this; sets truncated"),
1524
+ followSymlinks: z.literal(false).default(false).describe("Always false; symlinks are recorded, never followed"),
1525
+ respectGitignore: z.boolean().default(true),
1526
+ withContentDigest: z.boolean().default(true).describe("false → sizes only, no sha256")
1527
+ },
1528
+ outputSchema: RepoSnapshotSchema,
1529
+ annotations: READ,
1530
+ async handler(ctx, input) {
1531
+ const { rootReal } = await resolveRoot(ctx.policy, input.root);
1532
+ const opts = {
1533
+ maxFiles: input.maxFiles,
1534
+ maxBytes: input.maxBytes,
1535
+ respectGitignore: input.respectGitignore,
1536
+ withContentDigest: input.withContentDigest
1537
+ };
1538
+ if (input.include !== void 0) opts.include = input.include;
1539
+ if (input.exclude !== void 0) opts.exclude = input.exclude;
1540
+ const snap = await snapshotRoot(rootReal, opts);
1541
+ ctx.log.debug("snapshot", {
1542
+ root: rootReal,
1543
+ files: snap.body.counts.files
1544
+ });
1545
+ return snap;
1546
+ },
1547
+ summarize: (o) => ({
1548
+ snapshotDigest: o.snapshotDigest,
1549
+ counts: o.body.counts,
1550
+ truncated: o.body.truncated,
1551
+ paths: o.body.files.slice(0, 20).map((f) => f.path)
1552
+ })
680
1553
  })
681
1554
  ];
682
1555
  async function resolveBundleOrRef(ctx, v, label) {
@@ -688,6 +1561,47 @@ async function resolveBundleOrRef(ctx, v, label) {
688
1561
  }
689
1562
  return parseBundle(v);
690
1563
  }
1564
+ /** Shared by `axiom_plan_compile` and `axiom_plan_seal` so both produce byte-identical bundles. */
1565
+ async function compileToBundle(ctx, plan, store, root) {
1566
+ const rootReal = root !== void 0 || store === "cas" ? (await resolveRoot(ctx.policy, root)).rootReal : void 0;
1567
+ const opts = {
1568
+ store: store ?? "inline",
1569
+ emitters: EMITTERS
1570
+ };
1571
+ if (rootReal !== void 0) opts.root = rootReal;
1572
+ const { bundle } = await compilePlan(plan, opts);
1573
+ if (rootReal !== void 0) {
1574
+ await saveManifest(rootReal, bundle);
1575
+ ctx.seenRoots.add(rootReal);
1576
+ }
1577
+ ctx.log.info("compiled", {
1578
+ manifestDigest: bundle.manifestDigest,
1579
+ artifacts: bundle.manifest.artifacts.length
1580
+ });
1581
+ return bundle;
1582
+ }
1583
+ function summarizeBundle(b) {
1584
+ return {
1585
+ manifestDigest: b.manifestDigest,
1586
+ planDigest: b.manifest.planDigest,
1587
+ name: b.manifest.name,
1588
+ profile: b.manifest.profile,
1589
+ artifacts: b.manifest.artifacts.length,
1590
+ blobs: Object.keys(b.blobs).length
1591
+ };
1592
+ }
1593
+ function describeSession(store, s) {
1594
+ return {
1595
+ sessionId: s.sessionId,
1596
+ artifacts: s.artifacts.length,
1597
+ bytes: s.bytes,
1598
+ limits: {
1599
+ maxArtifacts: store.maxArtifacts,
1600
+ maxBytes: store.maxBytes
1601
+ },
1602
+ ttlMs: store.ttlMs
1603
+ };
1604
+ }
691
1605
  function summarizeReport(r) {
692
1606
  return {
693
1607
  manifestDigest: r.manifestDigest,
@@ -792,20 +1706,25 @@ function createServer(policy, opts = {}) {
792
1706
  const ctx = {
793
1707
  policy,
794
1708
  log,
795
- seenRoots: /* @__PURE__ */ new Set()
1709
+ seenRoots: opts.seenRoots ?? /* @__PURE__ */ new Set(),
1710
+ tasks: opts.tasks ?? new TaskStore(),
1711
+ planSessions: opts.planSessions ?? new PlanSessionStore()
796
1712
  };
797
1713
  if (opts.guards !== void 0) ctx.guards = opts.guards;
798
1714
  const server = new McpServer({
799
1715
  name: SERVER_NAME,
800
1716
  version: SERVER_VERSION
801
- }, { capabilities: {
802
- tools: {},
803
- resources: {}
804
- } });
1717
+ }, {
1718
+ capabilities: {
1719
+ tools: { listChanged: false },
1720
+ resources: { listChanged: false }
1721
+ },
1722
+ cacheHints: CACHE_HINTS
1723
+ });
805
1724
  for (const def of opts.tools ?? TOOL_DEFS) server.registerTool(def.name, {
806
1725
  title: def.title,
807
1726
  description: def.description,
808
- inputSchema: def.inputSchema,
1727
+ inputSchema: z.object(def.inputSchema),
809
1728
  outputSchema: def.outputSchema,
810
1729
  annotations: {
811
1730
  title: def.title,
@@ -875,9 +1794,14 @@ function createServer(policy, opts = {}) {
875
1794
  text: JSON.stringify(jsonSchemaFor(k), null, 2)
876
1795
  }] };
877
1796
  });
1797
+ server.registerResource("emitters", "axiom://emitters", {
1798
+ title: "Template emitters available to axiom_plan_compile",
1799
+ mimeType: "application/json"
1800
+ }, async (uri) => json(uri.href, emitterCatalogue()));
878
1801
  log.info("server created", {
879
1802
  name: SERVER_NAME,
880
1803
  version: SERVER_VERSION,
1804
+ era: opts.era ?? "legacy",
881
1805
  roots: [...policy.roots]
882
1806
  });
883
1807
  return server;
@@ -899,6 +1823,6 @@ function renderToolsSpec() {
899
1823
  return `${JSON.stringify(buildToolsSpec(), null, 2)}\n`;
900
1824
  }
901
1825
  //#endregion
902
- export { BUNDLE_BYTES_MAX, LOG_LEVELS, SCHEMA_KINDS, SERVER_NAME, SERVER_VERSION, SUMMARY_LIST_MAX, TOOL_DEFS, buildToolsSpec, createLogger, createRootsPolicy, createServer, isLogLevel, isSameOrInside, isSchemaKind, jsonSchemaFor, renderToolsSpec, resolveRoot, riskClassOf, toStructuredError, toolByName };
1826
+ export { BUNDLE_BYTES_MAX, LOG_LEVELS, PLAN_SESSION_MAX_ARTIFACTS, PLAN_SESSION_MAX_BYTES, PLAN_SESSION_TTL_MS, PlanSessionStore, SCHEMA_KINDS, SERVER_NAME, SERVER_VERSION, SUMMARY_LIST_MAX, TASK_MAX_WORKING, TASK_POLL_INTERVAL_MS, TASK_STATUSES, TASK_TTL_MS, TOOL_DEFS, TaskStore, buildToolsSpec, createLogger, createRootsPolicy, createServer, isLogLevel, isSameOrInside, isSchemaKind, jsonSchemaFor, renderToolsSpec, resolveRoot, riskClassOf, toStructuredError, toolByName };
903
1827
 
904
1828
  //# sourceMappingURL=index.js.map