@decionis/agent-safe-pipeline 0.1.3 → 0.1.4

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 (68) hide show
  1. package/README.md +318 -16
  2. package/dist/Index.d.ts +1 -0
  3. package/dist/Index.d.ts.map +1 -1
  4. package/dist/Index.js +1 -0
  5. package/dist/Index.js.map +1 -1
  6. package/dist/approval/PresenceApprovalCoordinator.d.ts +25 -0
  7. package/dist/approval/PresenceApprovalCoordinator.d.ts.map +1 -1
  8. package/dist/approval/PresenceApprovalCoordinator.js +54 -6
  9. package/dist/approval/PresenceApprovalCoordinator.js.map +1 -1
  10. package/dist/audit/AuditRecorder.d.ts +122 -0
  11. package/dist/audit/AuditRecorder.d.ts.map +1 -0
  12. package/dist/audit/AuditRecorder.js +312 -0
  13. package/dist/audit/AuditRecorder.js.map +1 -0
  14. package/dist/decision/DecionisGate.d.ts +91 -2
  15. package/dist/decision/DecionisGate.d.ts.map +1 -1
  16. package/dist/decision/DecionisGate.js +793 -44
  17. package/dist/decision/DecionisGate.js.map +1 -1
  18. package/dist/decision/DecisionAuthority.d.ts +50 -1
  19. package/dist/decision/DecisionAuthority.d.ts.map +1 -1
  20. package/dist/decision/DecisionAuthority.js.map +1 -1
  21. package/dist/decision/FixtureDecisionAuthority.d.ts +27 -1
  22. package/dist/decision/FixtureDecisionAuthority.d.ts.map +1 -1
  23. package/dist/decision/FixtureDecisionAuthority.js +47 -0
  24. package/dist/decision/FixtureDecisionAuthority.js.map +1 -1
  25. package/dist/decision/ImmutableGateDecision.d.ts.map +1 -1
  26. package/dist/decision/ImmutableGateDecision.js +18 -0
  27. package/dist/decision/ImmutableGateDecision.js.map +1 -1
  28. package/dist/execution/ActionRegistry.d.ts +37 -0
  29. package/dist/execution/ActionRegistry.d.ts.map +1 -1
  30. package/dist/execution/ActionRegistry.js +75 -1
  31. package/dist/execution/ActionRegistry.js.map +1 -1
  32. package/dist/execution/AuthorizationVerifier.d.ts +29 -0
  33. package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
  34. package/dist/execution/AuthorizationVerifier.js +126 -25
  35. package/dist/execution/AuthorizationVerifier.js.map +1 -1
  36. package/dist/execution/SafeExecutor.d.ts +72 -7
  37. package/dist/execution/SafeExecutor.d.ts.map +1 -1
  38. package/dist/execution/SafeExecutor.js +253 -17
  39. package/dist/execution/SafeExecutor.js.map +1 -1
  40. package/dist/intent/CanonicalIntentHasher.d.ts +1 -1
  41. package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
  42. package/dist/intent/CanonicalIntentHasher.js +6 -2
  43. package/dist/intent/CanonicalIntentHasher.js.map +1 -1
  44. package/dist/intent/ExecutionIntent.d.ts +14 -2
  45. package/dist/intent/ExecutionIntent.d.ts.map +1 -1
  46. package/dist/intent/ExecutionIntent.js +7 -0
  47. package/dist/intent/ExecutionIntent.js.map +1 -1
  48. package/dist/intent/IntentCapture.d.ts +5 -0
  49. package/dist/intent/IntentCapture.d.ts.map +1 -1
  50. package/dist/intent/IntentCapture.js +12 -1
  51. package/dist/intent/IntentCapture.js.map +1 -1
  52. package/dist/shadow/ShadowPipeline.d.ts +75 -5
  53. package/dist/shadow/ShadowPipeline.d.ts.map +1 -1
  54. package/dist/shadow/ShadowPipeline.js +157 -7
  55. package/dist/shadow/ShadowPipeline.js.map +1 -1
  56. package/dist/testing/Index.d.ts +12 -0
  57. package/dist/testing/Index.d.ts.map +1 -0
  58. package/dist/testing/Index.js +12 -0
  59. package/dist/testing/Index.js.map +1 -0
  60. package/dist/testing/LocalAuthority.d.ts +203 -0
  61. package/dist/testing/LocalAuthority.d.ts.map +1 -0
  62. package/dist/testing/LocalAuthority.js +799 -0
  63. package/dist/testing/LocalAuthority.js.map +1 -0
  64. package/dist/testing/LocalPresence.d.ts +210 -0
  65. package/dist/testing/LocalPresence.d.ts.map +1 -0
  66. package/dist/testing/LocalPresence.js +473 -0
  67. package/dist/testing/LocalPresence.js.map +1 -0
  68. package/package.json +40 -4
@@ -0,0 +1,799 @@
1
+ /**
2
+ * Loopback stand-in for the Decionis execution-authority routes that
3
+ * `@decionis/agent-safe-pipeline` consumes: enforce-and-bind (including
4
+ * shadow mode and managed escalation), escalation status and cancellation,
5
+ * claim-token with its consume-token alias, and finalize-token.
6
+ *
7
+ * It validates every request against the published OpenAPI contract with its
8
+ * own canonicalizer, so a hash it recomputes is independent evidence that the
9
+ * package binds intents the way Decionis does. Direct Presence evidence is
10
+ * verified against a `LocalPresence`, and a managed escalation is orchestrated
11
+ * through that same `LocalPresence` the way Decionis orchestrates Presence:
12
+ * create a request bound to the exact intent hash, wait for the ceremony,
13
+ * verify the receipt, re-evaluate policy, then expose a normal grant.
14
+ *
15
+ * Grants, claims, escalations, and commit outcomes are synthetic in-memory
16
+ * state. Nothing here is a real policy, tenant, or credential.
17
+ */
18
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
19
+ import { createServer } from "node:http";
20
+ import { z } from "zod";
21
+ import { readBody } from "./LocalPresence.js";
22
+ export const LOCAL_AUTHORITY_API_KEY = "test-key";
23
+ export const AUTONOMOUS_LIMIT_MINOR = 10_000;
24
+ export const HUMAN_LIMIT_MINOR = 100_000;
25
+ const LOOPBACK_ORIGIN = "http://127.0.0.1";
26
+ const MAX_REQUEST_BYTES = 256 * 1024;
27
+ const MAX_RECORDED_BODY_CHARS = 4_096;
28
+ const SOCKET_TIMEOUT_MS = 10_000;
29
+ const GRANT_TTL_MS = 60_000;
30
+ const CLAIM_LEASE_MS = 30_000;
31
+ const boundedId = z.string().trim().min(1).max(200);
32
+ const sha256Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/);
33
+ const roleId = z.string().regex(/^[A-Z][A-Z0-9_]{1,63}$/);
34
+ /** `ExecutionIntentBinding` from the Decionis OpenAPI contract; extra properties are rejected. */
35
+ const IntentBindingSchema = z.strictObject({
36
+ protocol_version: z.literal("agent-safe.intent/1"),
37
+ tenant_id: z.uuid(),
38
+ intent_id: z.uuid(),
39
+ captured_at: z.iso.datetime(),
40
+ expires_at: z.iso.datetime(),
41
+ actor: z.strictObject({
42
+ id: boundedId,
43
+ type: boundedId,
44
+ runtime: boundedId.optional(),
45
+ trust_level: z.string().trim().min(1).max(80).optional(),
46
+ }),
47
+ action: z.strictObject({
48
+ type: z
49
+ .string()
50
+ .min(1)
51
+ .max(120)
52
+ .regex(/^[a-z][a-z0-9._:-]*$/),
53
+ resource: z.string().min(1).max(500),
54
+ parameters: z.record(z.string(), z.unknown()),
55
+ }),
56
+ context: z.record(z.string(), z.unknown()),
57
+ downstream_target: z.strictObject({
58
+ system: boundedId,
59
+ environment: boundedId.optional(),
60
+ operation: boundedId,
61
+ endpoint: z.string().min(1).max(500).optional(),
62
+ }),
63
+ });
64
+ const EvidenceSchema = z.strictObject({
65
+ humanApproval: z
66
+ .strictObject({
67
+ provider: z.literal("presence"),
68
+ requestId: boundedId,
69
+ receiptDossierId: boundedId,
70
+ })
71
+ .optional(),
72
+ });
73
+ const ManagedEscalationRequestSchema = z.strictObject({
74
+ mode: z.literal("MANAGED"),
75
+ approver: z
76
+ .strictObject({
77
+ principal_id: boundedId.optional(),
78
+ role_id: roleId.optional(),
79
+ })
80
+ .optional(),
81
+ verification_requirements: z
82
+ .strictObject({
83
+ methods: z
84
+ .array(z.enum(["WEBAUTHN", "ACTIVE_LIVENESS"]))
85
+ .min(1)
86
+ .max(3),
87
+ level: z.enum(["STANDARD", "HIGH_CONFIDENCE"]).optional(),
88
+ })
89
+ .optional(),
90
+ });
91
+ /** `ExecutionAuthorityRequest`: the binding plus hash, mode, evidence, and managed constraints. */
92
+ const AuthorityRequestSchema = z.strictObject({
93
+ ...IntentBindingSchema.shape,
94
+ intent_hash: sha256Digest,
95
+ mode: z.enum(["SHADOW", "ENFORCEMENT"]),
96
+ evidence: EvidenceSchema.optional(),
97
+ escalation: ManagedEscalationRequestSchema.optional(),
98
+ });
99
+ /** `ExecutionTokenRequest`. */
100
+ const TokenRequestSchema = z.strictObject({
101
+ execution_token: z.string().min(1).max(20_000),
102
+ intent_hash: sha256Digest,
103
+ intent: z.record(z.string(), z.unknown()),
104
+ evidence: EvidenceSchema.optional(),
105
+ consumed_by: boundedId.optional(),
106
+ commit_correlation_id: boundedId.optional(),
107
+ });
108
+ /** `ExecutionFinalizeRequest`. */
109
+ const FinalizeRequestSchema = z.strictObject({
110
+ execution_token: z.string().min(1).max(20_000),
111
+ claim_token: z.string().regex(/^[\w-]{43,128}$/),
112
+ outcome: z.enum(["COMMITTED", "FAILED", "INDETERMINATE"]),
113
+ commit_correlation_id: boundedId,
114
+ downstream_evidence: z.record(z.string(), z.unknown()).optional(),
115
+ });
116
+ const BINDING_KEYS = Object.keys(IntentBindingSchema.shape);
117
+ /**
118
+ * Independent canonicalization: keys sorted by UTF-16 code unit, values encoded
119
+ * with JSON.stringify semantics. This deliberately does not import the
120
+ * package's hasher so the two implementations check each other.
121
+ */
122
+ export function stableStringify(value) {
123
+ if (Array.isArray(value))
124
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
125
+ if (value === null || typeof value !== "object")
126
+ return JSON.stringify(value);
127
+ const record = value;
128
+ const entries = Object.keys(record)
129
+ .sort()
130
+ .filter((key) => record[key] !== undefined)
131
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
132
+ return `{${entries.join(",")}}`;
133
+ }
134
+ export function hashBinding(binding) {
135
+ return `sha256:${createHash("sha256").update(stableStringify(binding), "utf8").digest("hex")}`;
136
+ }
137
+ function bindingOf(request) {
138
+ const binding = {};
139
+ for (const key of BINDING_KEYS)
140
+ binding[key] = request[key];
141
+ return binding;
142
+ }
143
+ function truncate(text) {
144
+ return text.length > MAX_RECORDED_BODY_CHARS
145
+ ? `${text.slice(0, MAX_RECORDED_BODY_CHARS)}…[truncated ${text.length - MAX_RECORDED_BODY_CHARS} chars]`
146
+ : text;
147
+ }
148
+ function sleep(ms) {
149
+ return new Promise((resolve) => setTimeout(resolve, ms));
150
+ }
151
+ function defaultPolicy(request) {
152
+ const amount = Number(request.action.parameters.amountMinor ?? 0);
153
+ if (amount > HUMAN_LIMIT_MINOR)
154
+ return "BLOCK";
155
+ if (amount > AUTONOMOUS_LIMIT_MINOR)
156
+ return "ESCALATE";
157
+ return "ALLOW";
158
+ }
159
+ export class LocalAuthority {
160
+ /** Every request seen, oldest first, with bounded bodies and the response given. */
161
+ requests = [];
162
+ /** Issued grants keyed by execution token. */
163
+ grants = new Map();
164
+ /** Managed escalations keyed by their opaque identifier. */
165
+ escalations = new Map();
166
+ apiKey;
167
+ presence;
168
+ legacyVerifyReceipt;
169
+ policy;
170
+ managedApproverId;
171
+ clock;
172
+ overrides = {
173
+ enforce: [],
174
+ status: [],
175
+ claim: [],
176
+ finalize: [],
177
+ };
178
+ managedByIntent = new Map();
179
+ nextManagedLifecycle = null;
180
+ decisions = 0;
181
+ server = null;
182
+ port = 0;
183
+ constructor(options = {}) {
184
+ if (process.env.NODE_ENV === "production")
185
+ throw new Error("LOCAL_DOUBLE_FORBIDDEN");
186
+ this.apiKey = options.apiKey ?? LOCAL_AUTHORITY_API_KEY;
187
+ this.presence = options.presence;
188
+ this.legacyVerifyReceipt = options.verifyReceipt;
189
+ this.policy = options.policy ?? defaultPolicy;
190
+ this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
191
+ this.clock = options.clock ?? Date.now;
192
+ }
193
+ get baseUrl() {
194
+ return `${LOOPBACK_ORIGIN}:${this.port}`;
195
+ }
196
+ async start() {
197
+ const server = createServer((req, res) => {
198
+ void this.handle(req, res);
199
+ });
200
+ server.setTimeout(SOCKET_TIMEOUT_MS);
201
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
202
+ const address = server.address();
203
+ this.port = typeof address === "object" && address !== null ? address.port : 0;
204
+ this.server = server;
205
+ }
206
+ async stop() {
207
+ const server = this.server;
208
+ if (server === null)
209
+ return;
210
+ server.closeAllConnections();
211
+ await new Promise((resolve) => server.close(() => resolve()));
212
+ this.server = null;
213
+ }
214
+ /**
215
+ * Alters the next response on one route only. `transform` receives the
216
+ * computed body; `body` replaces it (an object or a raw string); `status`,
217
+ * `delayMs`, `truncateTo`, and `destroy` shape the transport behavior.
218
+ */
219
+ scriptOnce(route, override) {
220
+ this.overrides[route].push(override);
221
+ }
222
+ /** Statuses returned, in order, for the next managed escalation instead of orchestration. */
223
+ scriptNextManagedLifecycle(statuses) {
224
+ this.nextManagedLifecycle = [...statuses];
225
+ }
226
+ async handle(req, res) {
227
+ const url = new URL(req.url ?? "/", this.baseUrl);
228
+ const record = {
229
+ method: req.method ?? "GET",
230
+ path: url.pathname,
231
+ headers: {
232
+ authorization: headerString(req.headers.authorization),
233
+ "content-type": headerString(req.headers["content-type"]),
234
+ "idempotency-key": headerString(req.headers["idempotency-key"]),
235
+ },
236
+ body: null,
237
+ recomputedHash: null,
238
+ response: null,
239
+ at: new Date(this.clock()).toISOString(),
240
+ };
241
+ this.requests.push(record);
242
+ const raw = await readBody(req, MAX_REQUEST_BYTES);
243
+ if (raw === null)
244
+ return this.send(res, record, 413, { error: "REQUEST_TOO_LARGE" });
245
+ try {
246
+ record.body = raw.length === 0 ? null : JSON.parse(raw);
247
+ }
248
+ catch {
249
+ record.body = truncate(raw);
250
+ return this.send(res, record, 400, { error: "REQUEST_MALFORMED" });
251
+ }
252
+ if (req.headers.authorization !== `Bearer ${this.apiKey}`) {
253
+ return this.send(res, record, 401, { error: "UNAUTHORIZED" });
254
+ }
255
+ const escalationLookup = url.pathname.match(/^\/v1\/authority\/escalations\/([^/]+)$/);
256
+ if (req.method === "GET" && escalationLookup !== null) {
257
+ return this.respond(res, record, "status", this.managedStatus(decodeURIComponent(escalationLookup[1] ?? "")));
258
+ }
259
+ if (req.method === "DELETE" && escalationLookup !== null) {
260
+ return this.cancelManaged(res, record, decodeURIComponent(escalationLookup[1] ?? ""));
261
+ }
262
+ if (req.method !== "POST")
263
+ return this.send(res, record, 405, { error: "METHOD_NOT_ALLOWED" });
264
+ if (req.headers["content-type"] !== "application/json") {
265
+ return this.send(res, record, 415, { error: "UNSUPPORTED_MEDIA_TYPE" });
266
+ }
267
+ switch (url.pathname) {
268
+ case "/v1/authority/enforce-and-bind":
269
+ return this.respond(res, record, "enforce", this.enforceAndBind(record));
270
+ case "/v1/execution/claim-token":
271
+ case "/v1/execution/consume-token":
272
+ return this.respond(res, record, "claim", this.claim(record));
273
+ case "/v1/execution/finalize-token":
274
+ return this.respond(res, record, "finalize", this.finalize(record));
275
+ default:
276
+ return this.send(res, record, 404, { error: "NOT_FOUND" });
277
+ }
278
+ }
279
+ enforceAndBind(record) {
280
+ const parsed = AuthorityRequestSchema.safeParse(record.body);
281
+ if (!parsed.success)
282
+ return { status: 400, body: { error: "REQUEST_INVALID" } };
283
+ const request = parsed.data;
284
+ if (record.headers["idempotency-key"] !== request.intent_id) {
285
+ return { status: 400, body: { error: "IDEMPOTENCY_KEY_MISMATCH" } };
286
+ }
287
+ const bindingError = this.assertBinding(request, record);
288
+ if (bindingError !== null)
289
+ return { status: 400, body: { error: bindingError } };
290
+ const evaluation = this.evaluate(request);
291
+ if (evaluation.error !== undefined)
292
+ return { status: 409, body: { error: evaluation.error } };
293
+ this.decisions += 1;
294
+ const decision = this.baseDecision(request, evaluation.status, [evaluation.reasonCode]);
295
+ if (evaluation.status === "ESCALATE" && request.escalation?.mode === "MANAGED") {
296
+ return { status: 200, body: this.openManaged(request, decision) };
297
+ }
298
+ if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
299
+ return { status: 200, body: decision };
300
+ }
301
+ return { status: 200, body: this.grantDecision(request, decision, evaluation.approval) };
302
+ }
303
+ /** Policy plus evidence handling: a valid receipt turns an escalation into an allow. */
304
+ evaluate(request) {
305
+ const verdict = this.policy(request);
306
+ if (verdict === "BLOCK") {
307
+ return { status: "BLOCK", reasonCode: "POLICY_HARD_LIMIT_EXCEEDED", approval: null };
308
+ }
309
+ if (verdict === "ALLOW") {
310
+ return { status: "ALLOW", reasonCode: "POLICY_AUTONOMOUS_LIMIT", approval: null };
311
+ }
312
+ const humanApproval = request.evidence?.humanApproval;
313
+ if (humanApproval === undefined) {
314
+ return { status: "ESCALATE", reasonCode: "HUMAN_APPROVAL_REQUIRED", approval: null };
315
+ }
316
+ if (!this.verifyEvidence(humanApproval, request)) {
317
+ // Decionis verifies the receipt with Presence and refuses the request.
318
+ return {
319
+ status: "BLOCK",
320
+ reasonCode: "PRESENCE_RECEIPT_INVALID",
321
+ approval: null,
322
+ error: "PRESENCE_RECEIPT_INVALID",
323
+ };
324
+ }
325
+ return { status: "ALLOW", reasonCode: "PRESENCE_RECEIPT_VERIFIED", approval: humanApproval };
326
+ }
327
+ verifyEvidence(approval, request, requiredRole) {
328
+ if (this.presence !== undefined) {
329
+ return this.presence.verifyReceiptDetailed(approval, {
330
+ intentHash: request.intent_hash,
331
+ actionType: request.action.type,
332
+ resource: request.action.resource,
333
+ ...(requiredRole === undefined ? {} : { requiredRole }),
334
+ }).ok;
335
+ }
336
+ return this.legacyVerifyReceipt?.(approval, request.intent_hash) ?? false;
337
+ }
338
+ baseDecision(request, status, reasonCodes) {
339
+ const sequence = this.decisions;
340
+ const dossierId = `synthetic-dossier-${sequence}`;
341
+ return {
342
+ decision_id: `synthetic-decision-${sequence}`,
343
+ chain_id: "synthetic-chain-1",
344
+ status,
345
+ should_execute: false,
346
+ reason_codes: [...reasonCodes],
347
+ action_hash: request.intent_hash,
348
+ policy_version: "synthetic-policy-v1",
349
+ mode: request.mode,
350
+ execution_token: null,
351
+ execution_token_expires_at: null,
352
+ dossier_id: dossierId,
353
+ dossier_sha256: `sha256:${createHash("sha256").update(dossierId).digest("hex")}`,
354
+ dossier_url: `/v1/protocol/dossiers/${dossierId}`,
355
+ approval_request_id: status === "ESCALATE" ? `synthetic-approval-${sequence}` : null,
356
+ ledger_entry_id: `synthetic-ledger-${sequence}`,
357
+ authority_classification: request.mode === "ENFORCEMENT" ? "AUTHORITATIVE" : "OBSERVATIONAL",
358
+ execution_eligible: false,
359
+ execution_binding_digest: null,
360
+ execution_token_jti: null,
361
+ execution_token_key_id: null,
362
+ };
363
+ }
364
+ grantDecision(request, decision, approval) {
365
+ const issuedAt = Math.floor(this.clock() / 1_000);
366
+ const expiresAt = Math.min(Math.floor((this.clock() + GRANT_TTL_MS) / 1_000), Math.floor(Date.parse(request.expires_at) / 1_000));
367
+ const jti = `synthetic-jti-${randomUUID()}`;
368
+ const token = `synthetic-grant.${jti}`;
369
+ const bindingDigest = `sha256:${createHash("sha256").update(request.intent_hash).digest("hex")}`;
370
+ this.grants.set(token, {
371
+ jti,
372
+ tenantId: request.tenant_id,
373
+ actorId: request.actor.id,
374
+ action: request.action.type,
375
+ audience: `${request.downstream_target.system}:${request.downstream_target.operation}`,
376
+ decisionId: String(decision.decision_id),
377
+ dossierId: String(decision.dossier_id),
378
+ intentHash: request.intent_hash,
379
+ issuedAt,
380
+ expiresAt,
381
+ nonce: randomBytes(32).toString("base64url"),
382
+ bindingDigest,
383
+ receiptDossierId: approval?.receiptDossierId ?? null,
384
+ claimed: false,
385
+ claimToken: null,
386
+ correlationId: null,
387
+ consumedBy: null,
388
+ finalized: null,
389
+ });
390
+ return {
391
+ ...decision,
392
+ should_execute: true,
393
+ execution_token: token,
394
+ execution_token_expires_at: new Date(expiresAt * 1_000).toISOString(),
395
+ execution_eligible: true,
396
+ execution_binding_digest: bindingDigest,
397
+ execution_token_jti: jti,
398
+ execution_token_key_id: "synthetic-key-1",
399
+ };
400
+ }
401
+ /** Opens a managed escalation: scripted, orchestrated through LocalPresence, or default-scripted. */
402
+ openManaged(request, decision) {
403
+ const existing = this.managedByIntent.get(request.intent_id);
404
+ if (existing !== undefined)
405
+ return existing.initialDecision;
406
+ const escalationId = `synthetic-escalation-${randomUUID()}`;
407
+ const initialDecision = {
408
+ ...decision,
409
+ managed_escalation: {
410
+ outcome: "ESCALATE_PENDING",
411
+ escalation_id: escalationId,
412
+ intent_id: request.intent_id,
413
+ status: "PENDING_PRESENCE",
414
+ expires_at: request.expires_at,
415
+ reason_codes: ["PRESENCE_PENDING"],
416
+ },
417
+ };
418
+ let lifecycle = this.nextManagedLifecycle;
419
+ this.nextManagedLifecycle = null;
420
+ let presenceRequestId = null;
421
+ let status = "PENDING_PRESENCE";
422
+ let reasonCodes = ["PRESENCE_PENDING"];
423
+ if (lifecycle === null && this.presence !== undefined) {
424
+ const created = this.requestPresence(request, escalationId);
425
+ if (created === null) {
426
+ status = "FAILED";
427
+ reasonCodes = ["PRESENCE_REQUEST_FAILED"];
428
+ }
429
+ else {
430
+ presenceRequestId = created;
431
+ status = "AWAITING_APPROVER";
432
+ }
433
+ }
434
+ else if (lifecycle === null) {
435
+ lifecycle = ["AWAITING_APPROVER", "GRANT_READY"];
436
+ }
437
+ const managed = {
438
+ escalationId,
439
+ request,
440
+ presenceRequestId,
441
+ lifecycle,
442
+ lookup: 0,
443
+ status,
444
+ reasonCodes,
445
+ finalDecision: null,
446
+ initialDecision,
447
+ };
448
+ this.escalations.set(escalationId, managed);
449
+ this.managedByIntent.set(request.intent_id, managed);
450
+ return initialDecision;
451
+ }
452
+ /** Creates the Presence request the way Decionis does: bound structurally to the intent hash. */
453
+ requestPresence(request, escalationId) {
454
+ if (this.presence === undefined)
455
+ return null;
456
+ const requirements = request.escalation?.verification_requirements;
457
+ const amountMinor = request.action.parameters.amountMinor;
458
+ const currency = request.action.parameters.currency;
459
+ const remainingSeconds = Math.floor((Date.parse(request.expires_at) - this.clock()) / 1_000);
460
+ const created = this.presence.createRequest({
461
+ action_context: {
462
+ intent: request.action.type,
463
+ surface: "decionis_managed",
464
+ actor_id: request.escalation?.approver?.principal_id ?? this.managedApproverId,
465
+ target_resource_id: request.action.resource,
466
+ intent_hash: request.intent_hash,
467
+ ...(typeof amountMinor === "number" && typeof currency === "string"
468
+ ? { amount: amountMinor / 100, currency: currency.toUpperCase() }
469
+ : {}),
470
+ },
471
+ originator: {
472
+ organization_name: "Local Decionis",
473
+ actor_id: request.actor.id,
474
+ display_name: request.actor.id,
475
+ role: "AI execution authority requester",
476
+ },
477
+ presentation: {
478
+ locale: "en",
479
+ title: `Approve ${request.action.type}`,
480
+ description: `${request.action.resource} is held until human authority is verified.`,
481
+ display_fields: [
482
+ { key: "action", label: "Action", value: request.action.type },
483
+ { key: "target", label: "Target", value: request.action.resource },
484
+ { key: "intent_hash", label: "Intent hash", value: request.intent_hash },
485
+ ],
486
+ },
487
+ verification_requirements: {
488
+ level: requirements?.level ?? "STANDARD",
489
+ methods: requirements?.methods ?? ["WEBAUTHN"],
490
+ hardware_pki_required: false,
491
+ disallow_virtual_cameras: true,
492
+ },
493
+ ttl_seconds: Math.min(Math.max(remainingSeconds, 30), 600),
494
+ }, `managed-${createHash("sha256").update(escalationId).digest("hex").slice(0, 40)}`);
495
+ if (created.status !== 200)
496
+ return null;
497
+ const body = created.body;
498
+ return typeof body.request_id === "string" ? body.request_id : null;
499
+ }
500
+ managedStatus(escalationId) {
501
+ const managed = this.escalations.get(escalationId);
502
+ if (managed === undefined)
503
+ return { status: 404, body: { error: "NOT_FOUND" } };
504
+ const state = managed.lifecycle === null ? this.advance(managed) : this.scripted(managed);
505
+ let outcome = "ESCALATE_PENDING";
506
+ let decision = null;
507
+ if (state.status === "GRANT_READY") {
508
+ outcome = "ALLOW";
509
+ if (managed.finalDecision === null) {
510
+ this.decisions += 1;
511
+ managed.finalDecision = this.grantDecision(managed.request, this.baseDecision({ ...managed.request, mode: "ENFORCEMENT" }, "ALLOW", state.reasonCodes), null);
512
+ }
513
+ decision = managed.finalDecision;
514
+ }
515
+ else if (state.status === "EXPIRED" ||
516
+ state.status === "REJECTED" ||
517
+ state.status === "BLOCKED" ||
518
+ state.status === "CANCELLED") {
519
+ outcome = "BLOCK";
520
+ }
521
+ else if (state.status === "FAILED") {
522
+ outcome = "ERROR";
523
+ }
524
+ return {
525
+ status: 200,
526
+ body: {
527
+ escalation_id: managed.escalationId,
528
+ intent_id: managed.request.intent_id,
529
+ action_hash: managed.request.intent_hash,
530
+ status: state.status,
531
+ outcome,
532
+ expires_at: managed.request.expires_at,
533
+ reason_codes: state.reasonCodes,
534
+ decision,
535
+ },
536
+ };
537
+ }
538
+ scripted(managed) {
539
+ const lifecycle = managed.lifecycle ?? [];
540
+ const index = Math.min(managed.lookup, lifecycle.length - 1);
541
+ const entry = lifecycle[index] ?? "FAILED";
542
+ managed.lookup += 1;
543
+ const status = typeof entry === "string" ? entry : entry.status;
544
+ const scriptedReasonCodes = typeof entry === "string" ? null : (entry.reasonCodes ?? null);
545
+ const reasonCodes = scriptedReasonCodes ??
546
+ (status === "GRANT_READY"
547
+ ? ["PRESENCE_RECEIPT_VERIFIED"]
548
+ : status === "EXPIRED"
549
+ ? ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]
550
+ : status === "REJECTED"
551
+ ? ["PRESENCE_REJECTED"]
552
+ : status === "CANCELLED"
553
+ ? ["CANCELLED"]
554
+ : status === "BLOCKED"
555
+ ? ["REAUTHORIZATION_BLOCKED"]
556
+ : status === "FAILED"
557
+ ? ["PRESENCE_VERIFICATION_FAILED"]
558
+ : ["PRESENCE_PENDING"]);
559
+ managed.status = status;
560
+ managed.reasonCodes = reasonCodes;
561
+ return { status, reasonCodes };
562
+ }
563
+ /** Orchestrated state machine, mirroring Decionis's transitions against Presence. */
564
+ advance(managed) {
565
+ const done = (status, reasonCodes) => {
566
+ managed.status = status;
567
+ managed.reasonCodes = reasonCodes;
568
+ return { status, reasonCodes };
569
+ };
570
+ if (LocalAuthority.isTerminal(managed.status)) {
571
+ return { status: managed.status, reasonCodes: managed.reasonCodes };
572
+ }
573
+ if (Date.parse(managed.request.expires_at) <= this.clock()) {
574
+ return done("EXPIRED", ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]);
575
+ }
576
+ if (this.presence === undefined || managed.presenceRequestId === null) {
577
+ return done("FAILED", ["PRESENCE_REQUEST_FAILED"]);
578
+ }
579
+ const view = this.presence.poll(managed.presenceRequestId);
580
+ if (view === null)
581
+ return done("FAILED", ["PRESENCE_REQUEST_FAILED"]);
582
+ switch (view.status) {
583
+ case "PENDING":
584
+ case "DELIVERED":
585
+ return done("AWAITING_APPROVER", ["PRESENCE_PENDING"]);
586
+ case "BLOCKED":
587
+ return done("REJECTED", ["PRESENCE_REJECTED"]);
588
+ case "EXPIRED":
589
+ return done("EXPIRED", ["PRESENCE_EXPIRED", "RECAPTURE_REQUIRED"]);
590
+ case "CANCELLED":
591
+ return done("CANCELLED", ["MANAGED_ESCALATION_CANCELLED"]);
592
+ case "ALLOWED":
593
+ break;
594
+ }
595
+ if (view.receiptDossierId === null)
596
+ return done("FAILED", ["PRESENCE_VERIFICATION_FAILED"]);
597
+ const verification = this.presence.verifyReceiptDetailed({ requestId: view.requestId, receiptDossierId: view.receiptDossierId }, {
598
+ intentHash: managed.request.intent_hash,
599
+ actionType: managed.request.action.type,
600
+ resource: managed.request.action.resource,
601
+ ...(managed.request.escalation?.approver?.role_id === undefined
602
+ ? {}
603
+ : { requiredRole: managed.request.escalation.approver.role_id }),
604
+ });
605
+ if (!verification.ok)
606
+ return done("FAILED", ["PRESENCE_VERIFICATION_FAILED", verification.reasonCode]);
607
+ // PRESENCE_VERIFIED and REAUTHORIZING are passed through within one lookup, as Decionis does.
608
+ const reevaluated = this.policy(managed.request);
609
+ if (reevaluated === "BLOCK")
610
+ return done("BLOCKED", ["REAUTHORIZATION_BLOCKED"]);
611
+ return done("GRANT_READY", ["PRESENCE_RECEIPT_VERIFIED"]);
612
+ }
613
+ cancelManaged(res, record, escalationId) {
614
+ const managed = this.escalations.get(escalationId);
615
+ if (managed === undefined)
616
+ return this.send(res, record, 404, { error: "NOT_FOUND" });
617
+ if (managed.lifecycle !== null) {
618
+ managed.lifecycle = ["CANCELLED"];
619
+ managed.lookup = 0;
620
+ }
621
+ else if (!LocalAuthority.isTerminal(managed.status)) {
622
+ if (managed.presenceRequestId !== null)
623
+ this.presence?.cancel(managed.presenceRequestId);
624
+ managed.status = "CANCELLED";
625
+ managed.reasonCodes = ["MANAGED_ESCALATION_CANCELLED"];
626
+ }
627
+ const result = this.managedStatus(escalationId);
628
+ return this.send(res, record, result.status, result.body);
629
+ }
630
+ claim(record) {
631
+ const parsed = TokenRequestSchema.safeParse(record.body);
632
+ if (!parsed.success)
633
+ return { status: 400, body: { error: "REQUEST_INVALID" } };
634
+ const intent = IntentBindingSchema.safeParse(parsed.data.intent);
635
+ if (!intent.success)
636
+ return { status: 400, body: { error: "REQUEST_INVALID" } };
637
+ const rejected = (reasonCode, status = 409) => ({
638
+ status,
639
+ body: { valid: false, reason_codes: [reasonCode], claims: null },
640
+ });
641
+ const bindingError = this.assertBinding({ ...intent.data, intent_hash: parsed.data.intent_hash }, record);
642
+ if (bindingError !== null)
643
+ return rejected(bindingError);
644
+ const grant = this.grants.get(parsed.data.execution_token);
645
+ if (grant === undefined)
646
+ return rejected("GRANT_INVALID");
647
+ if (grant.expiresAt * 1_000 <= this.clock())
648
+ return rejected("GRANT_EXPIRED");
649
+ if (grant.intentHash !== parsed.data.intent_hash)
650
+ return rejected("GRANT_BINDING_MISMATCH");
651
+ if (grant.receiptDossierId !== null &&
652
+ parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
653
+ return rejected("PRESENCE_APPROVAL_STALE");
654
+ }
655
+ if (grant.claimed)
656
+ return rejected("NONCE_REPLAY_DETECTED");
657
+ grant.claimed = true;
658
+ grant.claimToken = randomBytes(32).toString("base64url");
659
+ grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
660
+ grant.consumedBy = parsed.data.consumed_by ?? null;
661
+ return {
662
+ status: 200,
663
+ body: {
664
+ valid: true,
665
+ should_execute: true,
666
+ would_block: false,
667
+ verdict: "ALLOW",
668
+ reason_codes: [],
669
+ claims: {
670
+ iss: "synthetic-authority",
671
+ sub: grant.actorId,
672
+ aud: grant.audience,
673
+ org_id: grant.tenantId,
674
+ dossier_id: grant.dossierId,
675
+ decision_id: grant.decisionId,
676
+ chain_id: "synthetic-chain-1",
677
+ action: grant.action,
678
+ decision: "allow",
679
+ scope: "execute",
680
+ binding: {
681
+ intent_hash: grant.intentHash,
682
+ execution_binding_digest: grant.bindingDigest,
683
+ execution_nonce: grant.nonce,
684
+ execution_correlation_id: grant.correlationId,
685
+ },
686
+ jti: grant.jti,
687
+ iat: grant.issuedAt,
688
+ nbf: grant.issuedAt,
689
+ exp: grant.expiresAt,
690
+ },
691
+ claim_token: grant.claimToken,
692
+ claim_lease_expires_at: new Date(this.clock() + CLAIM_LEASE_MS).toISOString(),
693
+ evidence: { nonce_claim_state: "CLAIMED", commit_correlation_id: grant.correlationId },
694
+ },
695
+ };
696
+ }
697
+ finalize(record) {
698
+ const parsed = FinalizeRequestSchema.safeParse(record.body);
699
+ if (!parsed.success)
700
+ return { status: 400, body: { error: "REQUEST_INVALID" } };
701
+ const rejected = (reasonCode) => ({
702
+ status: 409,
703
+ body: { finalized: false, reason_codes: [reasonCode] },
704
+ });
705
+ const grant = this.grants.get(parsed.data.execution_token);
706
+ if (grant === undefined)
707
+ return rejected("GRANT_INVALID");
708
+ if (!grant.claimed || grant.claimToken !== parsed.data.claim_token) {
709
+ return rejected("NONCE_REPLAY_DETECTED");
710
+ }
711
+ if (grant.correlationId !== parsed.data.commit_correlation_id) {
712
+ return rejected("EXECUTION_CORRELATION_MISMATCH");
713
+ }
714
+ if (grant.finalized !== null)
715
+ return rejected("NONCE_REPLAY_DETECTED");
716
+ grant.finalized = parsed.data.outcome;
717
+ return {
718
+ status: 200,
719
+ body: {
720
+ finalized: true,
721
+ outcome: parsed.data.outcome,
722
+ evidence_recorded: true,
723
+ effect_status: "NOT_OBSERVED",
724
+ reason_codes: [],
725
+ },
726
+ };
727
+ }
728
+ /** Recomputes the hash independently and applies the contract's time rules. */
729
+ assertBinding(request, record) {
730
+ const recomputed = hashBinding(bindingOf(request));
731
+ record.recomputedHash = recomputed;
732
+ if (recomputed !== request.intent_hash)
733
+ return "INTENT_HASH_MISMATCH";
734
+ const capturedAt = Date.parse(request.captured_at);
735
+ const expiresAt = Date.parse(request.expires_at);
736
+ const now = this.clock();
737
+ if (capturedAt > now + 5_000)
738
+ return "INTENT_CAPTURED_IN_FUTURE";
739
+ if (expiresAt <= now)
740
+ return "INTENT_EXPIRED";
741
+ if (expiresAt <= capturedAt)
742
+ return "INTENT_TIME_ORDER_INVALID";
743
+ if (expiresAt - capturedAt > 300_000)
744
+ return "INTENT_LIFETIME_TOO_LONG";
745
+ return null;
746
+ }
747
+ async respond(res, record, route, computed) {
748
+ const override = this.overrides[route].shift();
749
+ if (override === undefined)
750
+ return this.send(res, record, computed.status, computed.body);
751
+ if (override.delayMs !== undefined)
752
+ await sleep(override.delayMs);
753
+ if (override.destroy === true) {
754
+ record.response = { status: null, body: "[connection destroyed]" };
755
+ res.socket?.destroy();
756
+ return;
757
+ }
758
+ const status = override.status ?? computed.status;
759
+ const body = override.body !== undefined
760
+ ? override.body
761
+ : override.transform !== undefined
762
+ ? override.transform(computed.body)
763
+ : computed.body;
764
+ if (override.truncateTo !== undefined) {
765
+ const raw = typeof body === "string" ? body : JSON.stringify(body);
766
+ record.response = { status, body: `[truncated to ${override.truncateTo} bytes]` };
767
+ res.writeHead(status, {
768
+ "content-type": "application/json",
769
+ "content-length": String(Buffer.byteLength(raw, "utf8")),
770
+ });
771
+ res.write(raw.slice(0, override.truncateTo));
772
+ res.socket?.destroy();
773
+ return;
774
+ }
775
+ return this.send(res, record, status, body);
776
+ }
777
+ send(res, record, status, body) {
778
+ const raw = typeof body === "string" ? body : JSON.stringify(body);
779
+ record.response = { status, body: typeof body === "string" ? truncate(body) : body };
780
+ res.writeHead(status, { "content-type": "application/json" });
781
+ res.end(raw);
782
+ }
783
+ static isTerminal(status) {
784
+ return (status === "GRANT_READY" ||
785
+ status === "EXPIRED" ||
786
+ status === "REJECTED" ||
787
+ status === "BLOCKED" ||
788
+ status === "CANCELLED" ||
789
+ status === "FAILED");
790
+ }
791
+ }
792
+ function headerString(value) {
793
+ if (typeof value === "string")
794
+ return value;
795
+ if (Array.isArray(value))
796
+ return value[0] ?? null;
797
+ return null;
798
+ }
799
+ //# sourceMappingURL=LocalAuthority.js.map