@fusionkit/plane 0.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.
Files changed (52) hide show
  1. package/dist/auth.d.ts +18 -0
  2. package/dist/auth.js +46 -0
  3. package/dist/claim-token-service.d.ts +23 -0
  4. package/dist/claim-token-service.js +54 -0
  5. package/dist/contract-service.d.ts +14 -0
  6. package/dist/contract-service.js +39 -0
  7. package/dist/domain-errors.d.ts +13 -0
  8. package/dist/domain-errors.js +31 -0
  9. package/dist/idp.d.ts +26 -0
  10. package/dist/idp.js +24 -0
  11. package/dist/index.d.ts +35 -0
  12. package/dist/index.js +21 -0
  13. package/dist/keys.d.ts +60 -0
  14. package/dist/keys.js +132 -0
  15. package/dist/logging.d.ts +21 -0
  16. package/dist/logging.js +42 -0
  17. package/dist/plane.d.ts +167 -0
  18. package/dist/plane.js +606 -0
  19. package/dist/policy.d.ts +23 -0
  20. package/dist/policy.js +92 -0
  21. package/dist/ratelimit.d.ts +40 -0
  22. package/dist/ratelimit.js +94 -0
  23. package/dist/receipt-service.d.ts +16 -0
  24. package/dist/receipt-service.js +17 -0
  25. package/dist/retention.d.ts +33 -0
  26. package/dist/retention.js +123 -0
  27. package/dist/run-lifecycle.d.ts +2 -0
  28. package/dist/run-lifecycle.js +19 -0
  29. package/dist/secrets.d.ts +25 -0
  30. package/dist/secrets.js +73 -0
  31. package/dist/server.d.ts +38 -0
  32. package/dist/server.js +418 -0
  33. package/dist/sqlite-store.d.ts +53 -0
  34. package/dist/sqlite-store.js +401 -0
  35. package/dist/store.d.ts +107 -0
  36. package/dist/store.js +9 -0
  37. package/dist/test/api.test.d.ts +1 -0
  38. package/dist/test/api.test.js +179 -0
  39. package/dist/test/hardening.test.d.ts +1 -0
  40. package/dist/test/hardening.test.js +259 -0
  41. package/dist/test/policy.test.d.ts +1 -0
  42. package/dist/test/policy.test.js +78 -0
  43. package/dist/test/server-hardening.test.d.ts +1 -0
  44. package/dist/test/server-hardening.test.js +192 -0
  45. package/dist/test/ui-parity.test.d.ts +1 -0
  46. package/dist/test/ui-parity.test.js +28 -0
  47. package/dist/validation.d.ts +326 -0
  48. package/dist/validation.js +178 -0
  49. package/package.json +34 -0
  50. package/ui/app.css +276 -0
  51. package/ui/app.js +483 -0
  52. package/ui/index.html +65 -0
package/dist/plane.js ADDED
@@ -0,0 +1,606 @@
1
+ import { createPrivateKey, createPublicKey, randomBytes, randomUUID } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { appendEvent, contractHash, executionFromRunRequest, hashCanonical, keyIdFromPublicPem, PROTOCOL_VERSIONS, verifyChain } from "@fusionkit/protocol";
5
+ import { hashToken, principalCan, toPrincipal } from "./auth.js";
6
+ import { ClaimTokenService } from "./claim-token-service.js";
7
+ import { ContractService } from "./contract-service.js";
8
+ import { badRequest, conflict, notFound, unauthorized } from "./domain-errors.js";
9
+ import { createLogger, Metrics } from "./logging.js";
10
+ import { evaluatePolicy } from "./policy.js";
11
+ import { ReceiptService } from "./receipt-service.js";
12
+ import { RetentionSweeper } from "./retention.js";
13
+ import { assertRunTransition } from "./run-lifecycle.js";
14
+ import { SqliteStore } from "./sqlite-store.js";
15
+ export const DEFAULT_PLANE_TUNING = {
16
+ claimTokenTtlMs: 10 * 60 * 1000,
17
+ contractTtlMs: 60 * 60 * 1000,
18
+ nonceTtlMs: 24 * 60 * 60 * 1000,
19
+ enrollTokenTtlMs: 60 * 60 * 1000,
20
+ tokenBytes: 32,
21
+ sqliteFilename: "plane.db",
22
+ bootstrapAdminName: "admin",
23
+ bootstrapEnrollerName: "bootstrap-enroller"
24
+ };
25
+ /** Throw unless `pem` parses as an ed25519 public key. */
26
+ function assertEd25519PublicKey(pem) {
27
+ let key;
28
+ try {
29
+ key = createPublicKey(pem);
30
+ }
31
+ catch (error) {
32
+ throw new Error(`runner public key is not a valid PEM: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ if (key.asymmetricKeyType !== "ed25519") {
35
+ throw new Error(`runner public key must be ed25519, got ${key.asymmetricKeyType ?? "unknown"}`);
36
+ }
37
+ }
38
+ export class Plane {
39
+ config;
40
+ store;
41
+ policyHash;
42
+ receipts;
43
+ claimTokens;
44
+ contracts;
45
+ logger;
46
+ idp;
47
+ metrics;
48
+ sweeper;
49
+ tuning;
50
+ constructor(config) {
51
+ this.config = config;
52
+ this.tuning = { ...DEFAULT_PLANE_TUNING, ...config.tuning };
53
+ if (config.store) {
54
+ this.store = config.store;
55
+ }
56
+ else {
57
+ const dbPath = join(config.dataDir, this.tuning.sqliteFilename);
58
+ mkdirSync(dirname(dbPath), { recursive: true });
59
+ this.store = new SqliteStore(dbPath);
60
+ }
61
+ this.policyHash = hashCanonical(config.policy);
62
+ this.receipts = new ReceiptService({
63
+ planePrivateKeyPem: config.planePrivateKeyPem,
64
+ planePublicKeyPem: config.planePublicKeyPem
65
+ });
66
+ this.claimTokens = new ClaimTokenService({
67
+ planePrivateKeyPem: config.planePrivateKeyPem,
68
+ planePublicKeyPem: config.planePublicKeyPem,
69
+ claimTokenTtlMs: this.tuning.claimTokenTtlMs
70
+ });
71
+ this.contracts = new ContractService({
72
+ planePrivateKeyPem: config.planePrivateKeyPem,
73
+ planePublicKeyPem: config.planePublicKeyPem,
74
+ policyHash: this.policyHash,
75
+ contractTtlMs: this.tuning.contractTtlMs,
76
+ buildSecretClaims: (secretNames, pool) => this.buildSecretClaims(secretNames, pool)
77
+ });
78
+ this.logger = config.logger ?? createLogger();
79
+ this.metrics = config.metrics ?? new Metrics();
80
+ if (config.idp)
81
+ this.idp = config.idp;
82
+ this.seedBootstrapPrincipals();
83
+ this.sweeper = new RetentionSweeper(this.store, config.policy.retention, undefined, this.logger);
84
+ if (config.startRetention)
85
+ this.sweeper.start();
86
+ }
87
+ /** Ensure the bootstrap admin and enroller principals match the config. */
88
+ seedBootstrapPrincipals() {
89
+ this.upsertPrincipal(this.tuning.bootstrapAdminName, "admin", this.config.adminToken);
90
+ this.upsertPrincipal(this.tuning.bootstrapEnrollerName, "enroller", this.config.enrollToken);
91
+ }
92
+ /** Mint a fresh bearer token with the configured entropy. */
93
+ newToken() {
94
+ return randomBytes(this.tuning.tokenBytes).toString("base64url");
95
+ }
96
+ upsertPrincipal(name, role, token) {
97
+ const existing = this.store.getPrincipalByName(name);
98
+ const record = {
99
+ principalId: existing?.principalId ?? `prn_${randomUUID()}`,
100
+ name,
101
+ role,
102
+ tokenHash: hashToken(token),
103
+ createdAt: existing?.createdAt ?? new Date().toISOString()
104
+ };
105
+ this.store.savePrincipal(record);
106
+ }
107
+ close() {
108
+ this.sweeper.stop();
109
+ this.store.close();
110
+ }
111
+ get blobs() {
112
+ return this.store;
113
+ }
114
+ get policySnapshot() {
115
+ return { policy: this.config.policy, policyHash: this.policyHash };
116
+ }
117
+ get log() {
118
+ return this.logger;
119
+ }
120
+ /** Run one retention pass synchronously (also used by tests). */
121
+ sweepRetention() {
122
+ return this.sweeper.sweepOnce();
123
+ }
124
+ // ---- Authentication and principals ----
125
+ /** Resolve a bearer token to a principal, or undefined if invalid/revoked. */
126
+ authenticate(token) {
127
+ if (!token)
128
+ return undefined;
129
+ const record = this.store.getPrincipalByTokenHash(hashToken(token));
130
+ if (!record || record.revokedAt)
131
+ return undefined;
132
+ return toPrincipal(record);
133
+ }
134
+ authorize(token, capability) {
135
+ const principal = this.authenticate(token);
136
+ if (!principal || !principalCan(principal.role, capability))
137
+ return undefined;
138
+ return principal;
139
+ }
140
+ /** Backward-compatible admin check used by older callers. */
141
+ checkAdminToken(token) {
142
+ const principal = this.authenticate(token);
143
+ return principal?.role === "admin";
144
+ }
145
+ issuePrincipal(name, role) {
146
+ if (this.store.getPrincipalByName(name)) {
147
+ throw conflict(`principal "${name}" already exists`);
148
+ }
149
+ const token = this.newToken();
150
+ const record = {
151
+ principalId: `prn_${randomUUID()}`,
152
+ name,
153
+ role,
154
+ tokenHash: hashToken(token),
155
+ createdAt: new Date().toISOString()
156
+ };
157
+ this.store.savePrincipal(record);
158
+ this.metrics.inc("principals.issued");
159
+ return { principalId: record.principalId, name, role, token };
160
+ }
161
+ rotatePrincipal(name) {
162
+ const existing = this.store.getPrincipalByName(name);
163
+ if (!existing || existing.revokedAt) {
164
+ throw notFound(`principal "${name}" not found`);
165
+ }
166
+ const token = this.newToken();
167
+ this.store.savePrincipal({ ...existing, tokenHash: hashToken(token) });
168
+ this.metrics.inc("principals.rotated");
169
+ return { token };
170
+ }
171
+ revokePrincipal(name) {
172
+ const existing = this.store.getPrincipalByName(name);
173
+ if (!existing)
174
+ return false;
175
+ const ok = this.store.revokePrincipal(existing.principalId, new Date().toISOString());
176
+ if (ok)
177
+ this.metrics.inc("principals.revoked");
178
+ return ok;
179
+ }
180
+ listPrincipals() {
181
+ return this.store.listPrincipals().map((p) => ({
182
+ name: p.name,
183
+ role: p.role,
184
+ createdAt: p.createdAt,
185
+ revoked: p.revokedAt !== undefined
186
+ }));
187
+ }
188
+ /** Mint a single-use, expiring runner enrollment token. */
189
+ issueEnrollToken(options = {}) {
190
+ const token = this.newToken();
191
+ const now = Date.now();
192
+ const expiresAt = new Date(now + (options.ttlMs ?? this.tuning.enrollTokenTtlMs)).toISOString();
193
+ this.store.saveEnrollToken({
194
+ tokenHash: hashToken(token),
195
+ ...(options.pool ? { pool: options.pool } : {}),
196
+ createdAt: new Date(now).toISOString(),
197
+ expiresAt
198
+ });
199
+ return { token, expiresAt };
200
+ }
201
+ // ---- Runners ----
202
+ enrollRunner(input) {
203
+ const principal = this.authenticate(input.enrollToken);
204
+ const byPrincipal = principal !== undefined &&
205
+ (principal.role === "enroller" || principal.role === "admin");
206
+ let bySingleUse = false;
207
+ if (!byPrincipal) {
208
+ const consumed = this.store.consumeEnrollToken(hashToken(input.enrollToken), new Date().toISOString());
209
+ bySingleUse =
210
+ consumed !== undefined && (!consumed.pool || consumed.pool === input.pool);
211
+ }
212
+ if (!byPrincipal && !bySingleUse) {
213
+ this.metrics.inc("enroll.rejected");
214
+ throw badRequest("invalid enroll token");
215
+ }
216
+ // Validate the runner's public key parses as an ed25519 SPKI key before
217
+ // storing it; a malformed key would otherwise only fail later at receipt
218
+ // verification time.
219
+ assertEd25519PublicKey(input.publicKeyPem);
220
+ const runnerId = `rnr_${randomUUID()}`;
221
+ const runnerToken = this.newToken();
222
+ const record = {
223
+ runnerId,
224
+ pool: input.pool,
225
+ publicKeyPem: input.publicKeyPem,
226
+ tokenHash: hashToken(runnerToken),
227
+ enrolledAt: new Date().toISOString()
228
+ };
229
+ this.store.saveRunner(record);
230
+ this.metrics.inc("enroll.accepted");
231
+ return { runnerId, runnerToken };
232
+ }
233
+ listRunners() {
234
+ return this.store.listRunners().map((runner) => ({
235
+ runnerId: runner.runnerId,
236
+ pool: runner.pool,
237
+ keyId: keyIdFromPublicPem(runner.publicKeyPem),
238
+ enrolledAt: runner.enrolledAt
239
+ }));
240
+ }
241
+ listRuns() {
242
+ return this.store.listRuns().map((record) => ({
243
+ runId: record.id,
244
+ status: record.status,
245
+ agentKind: record.request.agentKind,
246
+ pool: record.request.pool,
247
+ prompt: record.request.prompt,
248
+ requestedBy: record.request.requestedBy,
249
+ createdAt: record.createdAt,
250
+ updatedAt: record.updatedAt,
251
+ consentRequirements: record.consentRequirements,
252
+ hasReceipt: this.store.getReceipt(record.id) !== undefined,
253
+ ...(record.request.continuation
254
+ ? { continuation: record.request.continuation }
255
+ : {})
256
+ }));
257
+ }
258
+ authRunner(runnerToken) {
259
+ const runner = this.store.getRunnerByTokenHash(hashToken(runnerToken));
260
+ if (!runner)
261
+ throw unauthorized("invalid runner token");
262
+ return runner;
263
+ }
264
+ buildSecretClaims(secretNames, pool) {
265
+ return secretNames.map((name) => {
266
+ const rule = this.config.policy.secrets.releasable.find((r) => r.name === name);
267
+ // When policy has no explicit rule, the claim is scoped to the run's
268
+ // pool using the same `pool:<name>` convention policy rules use.
269
+ return { name, scope: rule ? rule.scope : `pool:${pool}` };
270
+ });
271
+ }
272
+ evaluateRequest(request) {
273
+ // The type assertion narrows for evaluatePolicy's signature only; the
274
+ // runtime allow-list check happens inside evaluatePolicy, which denies
275
+ // any agentKind not present in policy.agents.allow.
276
+ return evaluatePolicy(this.config.policy, {
277
+ agentKind: request.agentKind,
278
+ pool: request.pool,
279
+ secretNames: request.secretNames,
280
+ allowHosts: request.network.allowHosts,
281
+ maxSpendUsd: request.budget.maxSpendUsd,
282
+ maxDurationMin: request.budget.maxDurationMin
283
+ });
284
+ }
285
+ dryRun(request) {
286
+ const decision = this.evaluateRequest(request);
287
+ return {
288
+ dryRun: true,
289
+ agent: { kind: request.agentKind, version: request.agentVersion },
290
+ pool: request.pool,
291
+ workspace: {
292
+ baseRef: request.workspace.baseRef,
293
+ bundleHash: request.workspace.bundleHash,
294
+ dirtyDiffHash: request.workspace.dirtyDiffHash,
295
+ untrackedPaths: request.workspace.untrackedFiles.map((f) => f.path),
296
+ deniedPaths: request.workspace.deniedPaths
297
+ },
298
+ secrets: this.buildSecretClaims(request.secretNames, request.pool),
299
+ network: request.network,
300
+ budget: request.budget,
301
+ disclosure: request.disclosure,
302
+ execution: executionFromRunRequest(request),
303
+ ...(request.isolation ? { isolation: request.isolation } : {}),
304
+ ...(request.continuation ? { continuation: request.continuation } : {}),
305
+ policyDecision: decision
306
+ };
307
+ }
308
+ requestRun(request) {
309
+ const decision = this.evaluateRequest(request);
310
+ const runId = `run_${randomUUID()}`;
311
+ const fullRequest = { ...request, runId };
312
+ const now = new Date().toISOString();
313
+ const record = {
314
+ id: runId,
315
+ status: decision.decision === "ask" ? "awaiting_approval" : "created",
316
+ createdAt: now,
317
+ updatedAt: now,
318
+ request: fullRequest,
319
+ consentRequirements: decision.consentRequirements,
320
+ approvals: []
321
+ };
322
+ if (decision.decision === "allow") {
323
+ record.contract = this.issueContract(fullRequest, []);
324
+ this.store.saveRun(record);
325
+ this.appendPlaneEvents(record, [
326
+ { type: "run.created" },
327
+ ...this.continuationEvents(fullRequest),
328
+ {
329
+ type: "policy.evaluated",
330
+ decision: decision.decision,
331
+ reason: decision.reason
332
+ }
333
+ ]);
334
+ }
335
+ else {
336
+ this.store.saveRun(record);
337
+ }
338
+ this.metrics.inc("runs.requested");
339
+ return record;
340
+ }
341
+ continuationEvents(request) {
342
+ if (!request.continuation)
343
+ return [];
344
+ return [
345
+ {
346
+ type: "checkpoint.created",
347
+ checkpointId: request.continuation.checkpointId,
348
+ tier: request.continuation.tier
349
+ }
350
+ ];
351
+ }
352
+ approve(runId, actor, verified) {
353
+ const record = this.mustGetRun(runId);
354
+ if (record.status !== "awaiting_approval") {
355
+ throw conflict(`run ${runId} is not awaiting approval`);
356
+ }
357
+ const approval = {
358
+ actor,
359
+ ts: new Date().toISOString(),
360
+ ...(verified
361
+ ? { idpSubject: verified.idpSubject, idpIssuer: verified.idpIssuer }
362
+ : {})
363
+ };
364
+ record.approvals.push(approval);
365
+ record.contract = this.issueContract(record.request, [actor]);
366
+ assertRunTransition(record.status, "created");
367
+ record.status = "created";
368
+ record.updatedAt = new Date().toISOString();
369
+ this.store.saveRun(record);
370
+ this.appendPlaneEvents(record, [
371
+ { type: "run.created" },
372
+ ...this.continuationEvents(record.request),
373
+ {
374
+ type: "policy.evaluated",
375
+ decision: "ask",
376
+ reason: `consent required: ${record.consentRequirements.join("; ")}`
377
+ },
378
+ ...record.consentRequirements.map((requirement) => ({
379
+ type: "consent.requested",
380
+ requirement
381
+ })),
382
+ { type: "consent.granted", actor }
383
+ ]);
384
+ this.metrics.inc("runs.approved");
385
+ return record;
386
+ }
387
+ cancel(runId, actor) {
388
+ const record = this.mustGetRun(runId);
389
+ if (record.status !== "created" && record.status !== "awaiting_approval") {
390
+ throw badRequest(`run ${runId} is ${record.status}; only unclaimed runs can be cancelled`);
391
+ }
392
+ assertRunTransition(record.status, "cancelled");
393
+ record.status = "cancelled";
394
+ record.updatedAt = new Date().toISOString();
395
+ this.store.saveRun(record);
396
+ if (record.contract) {
397
+ this.appendPlaneEvents(record, [{ type: "run.cancelled", actor }]);
398
+ }
399
+ this.metrics.inc("runs.cancelled");
400
+ return record;
401
+ }
402
+ issueContract(request, approvedBy) {
403
+ return this.contracts.issue(request, approvedBy);
404
+ }
405
+ appendPlaneEvents(record, events) {
406
+ if (!record.contract)
407
+ throw badRequest("cannot append events before contract");
408
+ const genesis = contractHash(record.contract);
409
+ const chain = this.store.getEvents(record.id);
410
+ const appended = [];
411
+ for (const event of events) {
412
+ appended.push(appendEvent(chain, event, genesis));
413
+ }
414
+ this.store.appendEvents(record.id, appended);
415
+ }
416
+ claim(input) {
417
+ const runner = this.authRunner(input.runnerToken);
418
+ if (runner.pool !== input.pool) {
419
+ throw unauthorized("runner is not enrolled in the requested pool");
420
+ }
421
+ const candidate = this.store.claimNextRun(input.pool, runner.runnerId, new Date().toISOString());
422
+ if (!candidate || !candidate.contract)
423
+ return undefined;
424
+ this.appendPlaneEvents(candidate, [
425
+ {
426
+ type: "run.claimed",
427
+ runnerId: runner.runnerId,
428
+ runnerKeyId: keyIdFromPublicPem(runner.publicKeyPem)
429
+ },
430
+ ...candidate.contract.secrets.map((claim) => ({
431
+ type: "secret.released",
432
+ name: claim.name,
433
+ scope: claim.scope
434
+ }))
435
+ ]);
436
+ this.metrics.inc("runs.claimed");
437
+ // The claim token is intentionally a plane-internal credential (base64url
438
+ // JSON + ed25519 detached signature), never verified by third parties, so
439
+ // a full JWS envelope would add surface without adding interoperability.
440
+ const claimToken = this.claimTokens.issue({
441
+ runId: candidate.id,
442
+ runnerId: runner.runnerId
443
+ });
444
+ const secrets = candidate.contract.secrets.length > 0
445
+ ? this.config.secretStore.release(candidate.contract.secrets.map((c) => c.name))
446
+ : [];
447
+ if (secrets.length > 0)
448
+ this.metrics.inc("secrets.released", secrets.length);
449
+ return {
450
+ runId: candidate.id,
451
+ contract: candidate.contract,
452
+ claimToken,
453
+ events: this.store.getEvents(candidate.id),
454
+ secrets
455
+ };
456
+ }
457
+ /**
458
+ * Single decoder for claim tokens: verifies the plane signature, validates
459
+ * every payload field is present and well-formed, and checks expiry.
460
+ * Throws on any defect; both public verifiers below build on this.
461
+ */
462
+ parseClaimToken(token) {
463
+ return this.claimTokens.parse(token);
464
+ }
465
+ /**
466
+ * Verify a claim token's plane signature, payload shape, and expiry, plus
467
+ * that the named run is actually claimed by the named runner. Used to
468
+ * authorize artifact blob uploads from a runner holding an active claim;
469
+ * unlike verifyClaimToken it does not require the caller to know the run
470
+ * id ahead of time, but it still enforces the token's own run binding.
471
+ */
472
+ verifyClaimTokenSignature(token) {
473
+ try {
474
+ const payload = this.parseClaimToken(token);
475
+ const record = this.store.getRun(payload.runId);
476
+ return record !== undefined && record.claimedBy === payload.runnerId;
477
+ }
478
+ catch {
479
+ return false;
480
+ }
481
+ }
482
+ verifyClaimToken(token, runId) {
483
+ const payload = this.parseClaimToken(token);
484
+ if (payload.runId !== runId)
485
+ throw unauthorized("claim token run mismatch");
486
+ const record = this.mustGetRun(runId);
487
+ if (record.claimedBy !== payload.runnerId) {
488
+ throw unauthorized("claim token runner mismatch");
489
+ }
490
+ return { runnerId: payload.runnerId, nonce: payload.nonce, expMs: payload.expMs };
491
+ }
492
+ appendRunnerEvents(runId, claimToken, events) {
493
+ this.verifyClaimToken(claimToken, runId);
494
+ const record = this.mustGetRun(runId);
495
+ if (!record.contract)
496
+ throw badRequest("run has no contract");
497
+ const existing = this.store.getEvents(runId);
498
+ const combined = [...existing, ...events];
499
+ const verification = verifyChain(combined, contractHash(record.contract));
500
+ if (!verification.ok) {
501
+ throw badRequest(`event chain rejected at seq ${verification.brokenAtSeq}: ${verification.reason}`);
502
+ }
503
+ this.store.appendEvents(runId, events);
504
+ if (record.status === "claimed") {
505
+ assertRunTransition(record.status, "running");
506
+ record.status = "running";
507
+ record.updatedAt = new Date().toISOString();
508
+ this.store.saveRun(record);
509
+ }
510
+ }
511
+ complete(runId, claimToken, receipt) {
512
+ const verified = this.verifyClaimToken(claimToken, runId);
513
+ // Durable replay protection: the nonce ledger survives restarts and is
514
+ // atomic, unlike the previous in-memory Set.
515
+ if (!this.store.recordClaimNonce(verified.nonce, verified.expMs + this.tuning.nonceTtlMs)) {
516
+ throw conflict("claim token already used for completion");
517
+ }
518
+ const record = this.mustGetRun(runId);
519
+ if (!record.contract)
520
+ throw badRequest("run has no contract");
521
+ const runner = this.store.getRunnerById(verified.runnerId);
522
+ if (!runner)
523
+ throw notFound("unknown runner");
524
+ this.receipts.verifyRunnerReceipt({
525
+ contract: record.contract,
526
+ receipt,
527
+ events: this.store.getEvents(runId),
528
+ runnerPublicKeyPem: runner.publicKeyPem
529
+ });
530
+ const countersigned = this.receipts.countersign(receipt);
531
+ this.store.saveReceipt(runId, countersigned);
532
+ assertRunTransition(record.status, receipt.status);
533
+ record.status = receipt.status;
534
+ record.updatedAt = new Date().toISOString();
535
+ this.store.saveRun(record);
536
+ this.metrics.inc(`runs.completed.${receipt.status}`);
537
+ return countersigned;
538
+ }
539
+ getRun(runId) {
540
+ return this.store.getRun(runId);
541
+ }
542
+ getEvents(runId) {
543
+ return this.store.getEvents(runId);
544
+ }
545
+ getBundle(runId) {
546
+ const record = this.store.getRun(runId);
547
+ const receipt = this.store.getReceipt(runId);
548
+ if (!record || !record.contract || !receipt || !record.claimedBy) {
549
+ return undefined;
550
+ }
551
+ const runner = this.store.getRunnerById(record.claimedBy);
552
+ if (!runner)
553
+ return undefined;
554
+ return {
555
+ version: PROTOCOL_VERSIONS.bundle,
556
+ contract: record.contract,
557
+ receipt,
558
+ events: this.store.getEvents(runId),
559
+ keys: {
560
+ planePublicKeyPem: this.config.planePublicKeyPem,
561
+ runnerPublicKeyPem: runner.publicKeyPem
562
+ }
563
+ };
564
+ }
565
+ exportJsonl(sinceIso) {
566
+ const since = sinceIso ? new Date(sinceIso).getTime() : 0;
567
+ const lines = this.store
568
+ .exportEvents(since)
569
+ .map(({ runId, event }) => JSON.stringify({ runId, ...event }));
570
+ return lines.join("\n") + (lines.length > 0 ? "\n" : "");
571
+ }
572
+ /**
573
+ * Readiness: store reachable and the signing keypair actually usable —
574
+ * the private key must parse and its public half must match the
575
+ * configured public key, so a plane with mismatched key material reports
576
+ * not-ready instead of issuing unverifiable contracts.
577
+ */
578
+ ready() {
579
+ try {
580
+ this.store.countBlobs();
581
+ const privateKey = createPrivateKey(this.config.planePrivateKeyPem);
582
+ const derivedPublicPem = createPublicKey(privateKey)
583
+ .export({ type: "spki", format: "pem" })
584
+ .toString();
585
+ return (keyIdFromPublicPem(derivedPublicPem) ===
586
+ keyIdFromPublicPem(this.config.planePublicKeyPem));
587
+ }
588
+ catch {
589
+ return false;
590
+ }
591
+ }
592
+ verifyIdpToken(token) {
593
+ if (!this.idp) {
594
+ return Promise.reject(badRequest("no IdP is configured for approvals"));
595
+ }
596
+ return this.idp
597
+ .verify(token)
598
+ .then((v) => ({ idpSubject: v.subject, idpIssuer: v.issuer }));
599
+ }
600
+ mustGetRun(runId) {
601
+ const record = this.store.getRun(runId);
602
+ if (!record)
603
+ throw notFound(`unknown run ${runId}`);
604
+ return record;
605
+ }
606
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentKind, Policy, PolicyDecision } from "@fusionkit/protocol";
2
+ export type PolicyRequest = {
3
+ agentKind: AgentKind;
4
+ pool: string;
5
+ secretNames: string[];
6
+ allowHosts: string[];
7
+ maxSpendUsd?: number;
8
+ maxDurationMin?: number;
9
+ };
10
+ export type { PolicyDecision };
11
+ /**
12
+ * Evaluate a run request against policy at contract time. Fail closed:
13
+ * anything not allowed throws PolicyDeniedError; anything allowed but
14
+ * matching a consent rule returns "ask" with the named requirements.
15
+ */
16
+ export declare function evaluatePolicy(policy: Policy, request: PolicyRequest): PolicyDecision;
17
+ /**
18
+ * Opinionated starter policy for `warrant init` and the in-process test/demo
19
+ * stacks. Production deployments edit `policy.json` (or supply their own
20
+ * Policy); these defaults are intentionally permissive-but-bounded for a
21
+ * single-node dev/demo setup, not a recommended production posture.
22
+ */
23
+ export declare function defaultPolicy(): Policy;