@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.
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +46 -0
- package/dist/claim-token-service.d.ts +23 -0
- package/dist/claim-token-service.js +54 -0
- package/dist/contract-service.d.ts +14 -0
- package/dist/contract-service.js +39 -0
- package/dist/domain-errors.d.ts +13 -0
- package/dist/domain-errors.js +31 -0
- package/dist/idp.d.ts +26 -0
- package/dist/idp.js +24 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +21 -0
- package/dist/keys.d.ts +60 -0
- package/dist/keys.js +132 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +42 -0
- package/dist/plane.d.ts +167 -0
- package/dist/plane.js +606 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +92 -0
- package/dist/ratelimit.d.ts +40 -0
- package/dist/ratelimit.js +94 -0
- package/dist/receipt-service.d.ts +16 -0
- package/dist/receipt-service.js +17 -0
- package/dist/retention.d.ts +33 -0
- package/dist/retention.js +123 -0
- package/dist/run-lifecycle.d.ts +2 -0
- package/dist/run-lifecycle.js +19 -0
- package/dist/secrets.d.ts +25 -0
- package/dist/secrets.js +73 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.js +418 -0
- package/dist/sqlite-store.d.ts +53 -0
- package/dist/sqlite-store.js +401 -0
- package/dist/store.d.ts +107 -0
- package/dist/store.js +9 -0
- package/dist/test/api.test.d.ts +1 -0
- package/dist/test/api.test.js +179 -0
- package/dist/test/hardening.test.d.ts +1 -0
- package/dist/test/hardening.test.js +259 -0
- package/dist/test/policy.test.d.ts +1 -0
- package/dist/test/policy.test.js +78 -0
- package/dist/test/server-hardening.test.d.ts +1 -0
- package/dist/test/server-hardening.test.js +192 -0
- package/dist/test/ui-parity.test.d.ts +1 -0
- package/dist/test/ui-parity.test.js +28 -0
- package/dist/validation.d.ts +326 -0
- package/dist/validation.js +178 -0
- package/package.json +34 -0
- package/ui/app.css +276 -0
- package/ui/app.js +483 -0
- 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
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -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;
|