@decionis/agent-safe-pipeline 0.4.0 → 0.5.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/README.md +29 -0
- package/dist/Index.d.ts +9 -0
- package/dist/Index.d.ts.map +1 -1
- package/dist/Index.js +9 -0
- package/dist/Index.js.map +1 -1
- package/dist/execution/ActionRegistry.d.ts +12 -0
- package/dist/execution/ActionRegistry.d.ts.map +1 -1
- package/dist/execution/ActionRegistry.js +14 -0
- package/dist/execution/ActionRegistry.js.map +1 -1
- package/dist/execution/GovernedExecutionRunner.d.ts +14 -0
- package/dist/execution/GovernedExecutionRunner.d.ts.map +1 -1
- package/dist/execution/GovernedExecutionRunner.js +46 -1
- package/dist/execution/GovernedExecutionRunner.js.map +1 -1
- package/dist/execution/NativeExecutor.d.ts +20 -1
- package/dist/execution/NativeExecutor.d.ts.map +1 -1
- package/dist/execution/NativeExecutor.js.map +1 -1
- package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
- package/dist/intent/CanonicalIntentHasher.js +11 -0
- package/dist/intent/CanonicalIntentHasher.js.map +1 -1
- package/dist/intent/ExecutionIntent.d.ts +55 -4
- package/dist/intent/ExecutionIntent.d.ts.map +1 -1
- package/dist/intent/ExecutionIntent.js +40 -0
- package/dist/intent/ExecutionIntent.js.map +1 -1
- package/dist/intent/ExecutionSignals.d.ts +9 -1
- package/dist/intent/ExecutionSignals.d.ts.map +1 -1
- package/dist/intent/ExecutionSignals.js +9 -0
- package/dist/intent/ExecutionSignals.js.map +1 -1
- package/dist/intent/IntentBindingSchema.d.ts +6 -0
- package/dist/intent/IntentBindingSchema.d.ts.map +1 -1
- package/dist/intent/IntentBindingSchema.js +10 -0
- package/dist/intent/IntentBindingSchema.js.map +1 -1
- package/dist/intent/IntentCapture.d.ts.map +1 -1
- package/dist/intent/IntentCapture.js +18 -4
- package/dist/intent/IntentCapture.js.map +1 -1
- package/dist/intent/TargetPrecondition.d.ts +36 -0
- package/dist/intent/TargetPrecondition.d.ts.map +1 -0
- package/dist/intent/TargetPrecondition.js +57 -0
- package/dist/intent/TargetPrecondition.js.map +1 -0
- package/dist/manifest/ActionManifest.d.ts +112 -0
- package/dist/manifest/ActionManifest.d.ts.map +1 -0
- package/dist/manifest/ActionManifest.js +182 -0
- package/dist/manifest/ActionManifest.js.map +1 -0
- package/dist/plan/GovernedPlan.d.ts +109 -0
- package/dist/plan/GovernedPlan.d.ts.map +1 -0
- package/dist/plan/GovernedPlan.js +187 -0
- package/dist/plan/GovernedPlan.js.map +1 -0
- package/dist/plan/PlanState.d.ts +94 -0
- package/dist/plan/PlanState.d.ts.map +1 -0
- package/dist/plan/PlanState.js +240 -0
- package/dist/plan/PlanState.js.map +1 -0
- package/dist/plan/PlanStepReference.d.ts +16 -0
- package/dist/plan/PlanStepReference.d.ts.map +1 -0
- package/dist/plan/PlanStepReference.js +25 -0
- package/dist/plan/PlanStepReference.js.map +1 -0
- package/dist/profile/ApiProfile.d.ts +354 -0
- package/dist/profile/ApiProfile.d.ts.map +1 -0
- package/dist/profile/ApiProfile.js +755 -0
- package/dist/profile/ApiProfile.js.map +1 -0
- package/dist/profile/ApiProfiles.d.ts +16 -0
- package/dist/profile/ApiProfiles.d.ts.map +1 -0
- package/dist/profile/ApiProfiles.js +29 -0
- package/dist/profile/ApiProfiles.js.map +1 -0
- package/dist/profile/BuiltInApiProfiles.d.ts +9 -0
- package/dist/profile/BuiltInApiProfiles.d.ts.map +1 -0
- package/dist/profile/BuiltInApiProfiles.js +494 -0
- package/dist/profile/BuiltInApiProfiles.js.map +1 -0
- package/dist/profile/McpToolProfile.d.ts +129 -0
- package/dist/profile/McpToolProfile.d.ts.map +1 -0
- package/dist/profile/McpToolProfile.js +270 -0
- package/dist/profile/McpToolProfile.js.map +1 -0
- package/dist/testing/Index.d.ts +3 -1
- package/dist/testing/Index.d.ts.map +1 -1
- package/dist/testing/Index.js +2 -0
- package/dist/testing/Index.js.map +1 -1
- package/dist/testing/LocalAssumptionBreaches.d.ts +96 -0
- package/dist/testing/LocalAssumptionBreaches.d.ts.map +1 -0
- package/dist/testing/LocalAssumptionBreaches.js +109 -0
- package/dist/testing/LocalAssumptionBreaches.js.map +1 -0
- package/dist/testing/LocalAuthority.d.ts +158 -5
- package/dist/testing/LocalAuthority.d.ts.map +1 -1
- package/dist/testing/LocalAuthority.js +302 -18
- package/dist/testing/LocalAuthority.js.map +1 -1
- package/dist/testing/LocalPlans.d.ts +43 -0
- package/dist/testing/LocalPlans.d.ts.map +1 -0
- package/dist/testing/LocalPlans.js +124 -0
- package/dist/testing/LocalPlans.js.map +1 -0
- package/package.json +5 -4
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* create a request bound to the exact intent hash, wait for the ceremony,
|
|
13
13
|
* verify the receipt, re-evaluate policy, then expose a normal grant.
|
|
14
14
|
*
|
|
15
|
+
* A claim re-derives authority from current state before it consumes
|
|
16
|
+
* anything, as the hosted revalidator does: a policy released since the grant
|
|
17
|
+
* fails `POLICY_VERSION_STALE`, a material signal published since the grant
|
|
18
|
+
* fails `SIGNAL_VERSION_STALE`, and a concurrency scope another open claim
|
|
19
|
+
* holds fails `AUTHORIZATION_SCOPE_BUSY` (or `CURRENT_STATE_UNRESOLVED` while
|
|
20
|
+
* that claim's outcome is unknown). None of them burns the grant.
|
|
21
|
+
*
|
|
15
22
|
* Grants, claims, escalations, and commit outcomes are synthetic in-memory
|
|
16
23
|
* state. Nothing here is a real policy, tenant, or credential.
|
|
17
24
|
*/
|
|
@@ -19,6 +26,7 @@ import { createHash, createPublicKey, generateKeyPairSync, randomBytes, randomUU
|
|
|
19
26
|
import { createServer } from "node:http";
|
|
20
27
|
import { z } from "zod";
|
|
21
28
|
import { readBody } from "./LocalPresence.js";
|
|
29
|
+
import { LocalAssumptionBreaches } from "./LocalAssumptionBreaches.js";
|
|
22
30
|
import { verifySignedSignals, } from "./SignedSignals.js";
|
|
23
31
|
export const LOCAL_AUTHORITY_API_KEY = "test-key";
|
|
24
32
|
export const AUTONOMOUS_LIMIT_MINOR = 10_000;
|
|
@@ -29,6 +37,43 @@ const MAX_RECORDED_BODY_CHARS = 4_096;
|
|
|
29
37
|
const SOCKET_TIMEOUT_MS = 10_000;
|
|
30
38
|
const GRANT_TTL_MS = 60_000;
|
|
31
39
|
const CLAIM_LEASE_MS = 30_000;
|
|
40
|
+
/**
|
|
41
|
+
* `ExecutionTargetPrecondition`, the contract's, written here again rather
|
|
42
|
+
* than imported, so the fixture checks the package's precondition with an
|
|
43
|
+
* independent reading: what a runtime states in `context.agent_safe` about
|
|
44
|
+
* the target's state before asking.
|
|
45
|
+
*/
|
|
46
|
+
const TargetPreconditionContract = z
|
|
47
|
+
.object({
|
|
48
|
+
version: z.string().regex(/^[\x21-\x7e]{1,256}$/),
|
|
49
|
+
state: z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(200)
|
|
53
|
+
.regex(/^\P{Cc}+$/u)
|
|
54
|
+
.optional(),
|
|
55
|
+
source: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(200)
|
|
59
|
+
.regex(/^[a-z0-9][\w.:/#-]*$/i),
|
|
60
|
+
read_at: z.string().datetime(),
|
|
61
|
+
})
|
|
62
|
+
.strict();
|
|
63
|
+
/**
|
|
64
|
+
* The precondition an intent's context states: the parsed contract, null when
|
|
65
|
+
* it states none, or `undefined` when it states one the contract refuses.
|
|
66
|
+
*/
|
|
67
|
+
function preconditionIn(context) {
|
|
68
|
+
const namespace = context["agent_safe"];
|
|
69
|
+
if (namespace === null || typeof namespace !== "object" || Array.isArray(namespace))
|
|
70
|
+
return null;
|
|
71
|
+
const raw = namespace["precondition"];
|
|
72
|
+
if (raw === undefined)
|
|
73
|
+
return null;
|
|
74
|
+
const parsed = TargetPreconditionContract.safeParse(raw);
|
|
75
|
+
return parsed.success ? parsed.data : undefined;
|
|
76
|
+
}
|
|
32
77
|
/** The issuer the fixture's grants and attestations name; a provider double checks it. */
|
|
33
78
|
export const LOCAL_AUTHORITY_ISSUER = "synthetic-authority";
|
|
34
79
|
/** Where the fixture publishes its attestation key, at the path Decionis documents. */
|
|
@@ -71,6 +116,14 @@ const IntentBindingSchema = z.strictObject({
|
|
|
71
116
|
operation: boundedId,
|
|
72
117
|
endpoint: z.string().min(1).max(500).optional(),
|
|
73
118
|
}),
|
|
119
|
+
policy_projection: z
|
|
120
|
+
.strictObject({
|
|
121
|
+
decision_type: z.string().trim().min(1).max(120),
|
|
122
|
+
transaction_type: z.string().trim().min(1).max(120).nullable(),
|
|
123
|
+
workflow_key: z.string().trim().min(1).max(120).nullable(),
|
|
124
|
+
channel: z.string().trim().min(1).max(120).nullable(),
|
|
125
|
+
})
|
|
126
|
+
.optional(),
|
|
74
127
|
expected_effect_digest: sha256Digest.optional(),
|
|
75
128
|
});
|
|
76
129
|
const EvidenceSchema = z.strictObject({
|
|
@@ -308,6 +361,13 @@ export class LocalAuthority {
|
|
|
308
361
|
legacyVerifyReceipt;
|
|
309
362
|
verifyAttestation;
|
|
310
363
|
policy;
|
|
364
|
+
/** How many policy releases are active or behind; the first is `synthetic-policy-v1`. */
|
|
365
|
+
policyReleases = 1;
|
|
366
|
+
/** Every material signal ingested, by id: the reference and the value it carries. */
|
|
367
|
+
signals = new Map();
|
|
368
|
+
materialSignalsOf;
|
|
369
|
+
tenantKeys;
|
|
370
|
+
addressableGrants;
|
|
311
371
|
managedApproverId;
|
|
312
372
|
trustedEffectObserverIds;
|
|
313
373
|
clock;
|
|
@@ -321,6 +381,13 @@ export class LocalAuthority {
|
|
|
321
381
|
* says the executor must refuse.
|
|
322
382
|
*/
|
|
323
383
|
misbindNextPayloadDigest = false;
|
|
384
|
+
/**
|
|
385
|
+
* The hosted assumption breaches: a finalization whose receipt or
|
|
386
|
+
* observation contradicts its grant holds the grant's scope, and
|
|
387
|
+
* `assumptionBreaches.clear(citations)` is the governed release or
|
|
388
|
+
* recovery review that cites it.
|
|
389
|
+
*/
|
|
390
|
+
assumptionBreaches = new LocalAssumptionBreaches();
|
|
324
391
|
overrides = {
|
|
325
392
|
enforce: [],
|
|
326
393
|
status: [],
|
|
@@ -336,6 +403,11 @@ export class LocalAuthority {
|
|
|
336
403
|
escalationRouting;
|
|
337
404
|
server = null;
|
|
338
405
|
port = 0;
|
|
406
|
+
allowanceDrawsOf;
|
|
407
|
+
/** Each allowance's limit, by key, as the policy writer last set it. */
|
|
408
|
+
allowanceLimits = new Map();
|
|
409
|
+
/** What each issued grant drew, by jti. */
|
|
410
|
+
grantDraws = new Map();
|
|
339
411
|
constructor(options = {}) {
|
|
340
412
|
if (process.env.NODE_ENV === "production")
|
|
341
413
|
throw new Error("LOCAL_DOUBLE_FORBIDDEN");
|
|
@@ -346,6 +418,9 @@ export class LocalAuthority {
|
|
|
346
418
|
this.legacyVerifyReceipt = options.verifyReceipt;
|
|
347
419
|
this.verifyAttestation = options.verifyAttestation;
|
|
348
420
|
this.policy = options.policy ?? defaultPolicy;
|
|
421
|
+
this.materialSignalsOf = options.materialSignals ?? (() => []);
|
|
422
|
+
this.tenantKeys = options.tenantKeys ?? {};
|
|
423
|
+
this.addressableGrants = options.addressableGrants === true;
|
|
349
424
|
this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
|
|
350
425
|
this.trustedEffectObserverIds = options.trustedEffectObserverIds ?? [];
|
|
351
426
|
this.clock = options.clock ?? Date.now;
|
|
@@ -354,6 +429,7 @@ export class LocalAuthority {
|
|
|
354
429
|
privateKey: keys.privateKey,
|
|
355
430
|
publicJwk: keys.publicKey.export({ format: "jwk" }),
|
|
356
431
|
};
|
|
432
|
+
this.allowanceDrawsOf = options.allowanceDraws ?? (() => []);
|
|
357
433
|
}
|
|
358
434
|
/** The fixture's JWKS, as `GET /.well-known/decionis-execution-grant-jwks.json` serves it. */
|
|
359
435
|
get jwks() {
|
|
@@ -384,6 +460,9 @@ export class LocalAuthority {
|
|
|
384
460
|
...(grant.expectedEffectDigest === null
|
|
385
461
|
? {}
|
|
386
462
|
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
463
|
+
...(grant.expectedStateVersion === null
|
|
464
|
+
? {}
|
|
465
|
+
: { expected_state_version: grant.expectedStateVersion }),
|
|
387
466
|
execution_nonce: grant.nonce,
|
|
388
467
|
execution_correlation_id: grant.correlationId ?? "",
|
|
389
468
|
},
|
|
@@ -419,6 +498,51 @@ export class LocalAuthority {
|
|
|
419
498
|
purpose: input.purpose ?? "effect_receipt",
|
|
420
499
|
});
|
|
421
500
|
}
|
|
501
|
+
/** The policy release decisions are made under now, as `policy_version` names it. */
|
|
502
|
+
get policyVersion() {
|
|
503
|
+
return `synthetic-policy-v${this.policyReleases}`;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Activates the next policy release, with a new policy hook or the same
|
|
507
|
+
* one: the governance authority's own act, which no API key can perform
|
|
508
|
+
* here or hosted. Every grant decided under an earlier release then fails
|
|
509
|
+
* at claim with `POLICY_VERSION_STALE`, and a new decision is made under
|
|
510
|
+
* this one. Returns the release now active.
|
|
511
|
+
*/
|
|
512
|
+
releasePolicy(policy) {
|
|
513
|
+
if (policy !== undefined)
|
|
514
|
+
this.policy = policy;
|
|
515
|
+
this.policyReleases += 1;
|
|
516
|
+
return this.policyVersion;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Ingests a material signal's current value, as the hosted
|
|
520
|
+
* `POST /v1/protocol/signals/envelopes` does for a system of record. Each
|
|
521
|
+
* call is a new version, so a grant that read an earlier one fails at
|
|
522
|
+
* claim with `SIGNAL_VERSION_STALE`.
|
|
523
|
+
*/
|
|
524
|
+
publishSignal(signalId, value) {
|
|
525
|
+
const reference = {
|
|
526
|
+
signal_id: signalId,
|
|
527
|
+
version: String(Number(this.signals.get(signalId)?.reference.version ?? 0) + 1),
|
|
528
|
+
observed_at: new Date(this.clock()).toISOString(),
|
|
529
|
+
value_digest: hashBinding(value),
|
|
530
|
+
};
|
|
531
|
+
this.signals.set(signalId, { reference, value });
|
|
532
|
+
return reference;
|
|
533
|
+
}
|
|
534
|
+
/** The published material signals a request names, in id order. */
|
|
535
|
+
materialSignalsFor(request) {
|
|
536
|
+
const ids = [...new Set(this.materialSignalsOf(request))].sort();
|
|
537
|
+
const published = ids.flatMap((id) => {
|
|
538
|
+
const signal = this.signals.get(id);
|
|
539
|
+
return signal === undefined ? [] : [signal];
|
|
540
|
+
});
|
|
541
|
+
return {
|
|
542
|
+
references: published.map((signal) => signal.reference),
|
|
543
|
+
values: Object.fromEntries(published.map((signal) => [signal.reference.signal_id, signal.value])),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
422
546
|
/** The key a signed signal's `kid` names, when it was registered to sign signals. */
|
|
423
547
|
signedSignalKey(kid) {
|
|
424
548
|
const key = this.providerKeys.get(kid);
|
|
@@ -532,6 +656,65 @@ export class LocalAuthority {
|
|
|
532
656
|
}
|
|
533
657
|
return record("EFFECT_RECEIPT_VERIFIED", described);
|
|
534
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* Defines an allowance, or changes its limit: the policy writer's act
|
|
661
|
+
* (hosted, `PUT /v1/authority/allowances/{key}` with `policy:write`), which
|
|
662
|
+
* no decision key can perform here or hosted. Raising it is a top-up.
|
|
663
|
+
*/
|
|
664
|
+
setAllowance(key, limit) {
|
|
665
|
+
if (!Number.isSafeInteger(limit) || limit < 0)
|
|
666
|
+
throw new Error("ALLOWANCE_LIMIT_INVALID");
|
|
667
|
+
this.allowanceLimits.set(key, limit);
|
|
668
|
+
}
|
|
669
|
+
/** What an allowance holds now; null for a key no allowance is defined under. */
|
|
670
|
+
allowanceUsage(key) {
|
|
671
|
+
const limit = this.allowanceLimits.get(key);
|
|
672
|
+
if (limit === undefined)
|
|
673
|
+
return null;
|
|
674
|
+
let reserved = 0;
|
|
675
|
+
let held = 0;
|
|
676
|
+
let consumed = 0;
|
|
677
|
+
for (const grant of this.grants.values()) {
|
|
678
|
+
for (const draw of this.grantDraws.get(grant.jti) ?? []) {
|
|
679
|
+
if (draw.allowance_key !== key || draw.amount === null)
|
|
680
|
+
continue;
|
|
681
|
+
if (!grant.claimed) {
|
|
682
|
+
if (grant.expiresAt * 1_000 > this.clock())
|
|
683
|
+
reserved += draw.amount;
|
|
684
|
+
}
|
|
685
|
+
else if (grant.finalized === "COMMITTED") {
|
|
686
|
+
consumed += draw.amount;
|
|
687
|
+
}
|
|
688
|
+
else if (grant.finalized !== "FAILED") {
|
|
689
|
+
held += draw.amount;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { reserved, held, consumed, available: Math.max(0, limit - reserved - held - consumed) };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* An ALLOW's grant, once its draws are reserved, as hosted reserves them
|
|
697
|
+
* with the grant; or the same decision as `BLOCK` with the refusal code and
|
|
698
|
+
* no grant. Never ESCALATE: no approval creates capacity.
|
|
699
|
+
*/
|
|
700
|
+
grantWithinAllowances(request, decision, approval) {
|
|
701
|
+
const draws = this.allowanceDrawsOf(request);
|
|
702
|
+
const refused = draws.find((draw) => draw.amount === null) !== undefined
|
|
703
|
+
? "ALLOWANCE_AMOUNT_INVALID"
|
|
704
|
+
: draws.some((draw) => this.allowanceUsage(draw.allowance_key) === null)
|
|
705
|
+
? "ALLOWANCE_UNDEFINED"
|
|
706
|
+
: draws.some((draw) => draw.amount > this.allowanceUsage(draw.allowance_key).available)
|
|
707
|
+
? "ALLOWANCE_EXHAUSTED"
|
|
708
|
+
: null;
|
|
709
|
+
if (refused !== null) {
|
|
710
|
+
const reasons = Array.isArray(decision.reason_codes) ? decision.reason_codes : [];
|
|
711
|
+
return { ...decision, status: "BLOCK", reason_codes: [...reasons, refused] };
|
|
712
|
+
}
|
|
713
|
+
const granted = this.grantDecision(request, decision, approval);
|
|
714
|
+
if (draws.length > 0)
|
|
715
|
+
this.grantDraws.set(String(granted.execution_token_jti), draws);
|
|
716
|
+
return granted;
|
|
717
|
+
}
|
|
535
718
|
get baseUrl() {
|
|
536
719
|
return `${LOOPBACK_ORIGIN}:${this.port}`;
|
|
537
720
|
}
|
|
@@ -615,7 +798,14 @@ export class LocalAuthority {
|
|
|
615
798
|
next: {},
|
|
616
799
|
});
|
|
617
800
|
}
|
|
618
|
-
|
|
801
|
+
// The policy lifecycle takes an account session, never an API key, as the
|
|
802
|
+
// hosted contract says: no key, an agent's or an executor's or this
|
|
803
|
+
// double's own, can propose, accept or release a policy. This double
|
|
804
|
+
// holds no account sessions at all; `releasePolicy` is the only way in.
|
|
805
|
+
if (url.pathname === "/v1/policies" || url.pathname.startsWith("/v1/policies/")) {
|
|
806
|
+
return this.send(res, record, 401, { error: "ACCOUNT_SESSION_REQUIRED" });
|
|
807
|
+
}
|
|
808
|
+
if (req.headers.authorization !== `Bearer ${this.apiKey}` && this.tenantOf(record) === null) {
|
|
619
809
|
return this.send(res, record, 401, { error: "UNAUTHORIZED" });
|
|
620
810
|
}
|
|
621
811
|
const dossierLookup = url.pathname.match(/^\/v1\/protocol\/dossiers\/([^/]+)$/);
|
|
@@ -668,6 +858,13 @@ export class LocalAuthority {
|
|
|
668
858
|
return this.send(res, record, 404, { error: "NOT_FOUND" });
|
|
669
859
|
}
|
|
670
860
|
}
|
|
861
|
+
/** The organisation a request's key is bound to, or null for the unbound `apiKey` or no key. */
|
|
862
|
+
tenantOf(record) {
|
|
863
|
+
const presented = record.headers.authorization?.match(/^Bearer (.+)$/)?.[1];
|
|
864
|
+
return presented !== undefined && Object.hasOwn(this.tenantKeys, presented)
|
|
865
|
+
? (this.tenantKeys[presented] ?? null)
|
|
866
|
+
: null;
|
|
867
|
+
}
|
|
671
868
|
/** A grant resource addressed by an id that is not the presented token's grant: refused, as hosted. */
|
|
672
869
|
grantIdMismatch(record, grantId) {
|
|
673
870
|
const token = record.body?.execution_token;
|
|
@@ -741,13 +938,17 @@ export class LocalAuthority {
|
|
|
741
938
|
if (!parsed.success)
|
|
742
939
|
return { status: 400, body: { error: "REQUEST_INVALID" } };
|
|
743
940
|
const request = parsed.data;
|
|
941
|
+
const tenant = this.tenantOf(record);
|
|
942
|
+
if (tenant !== null && tenant !== request.tenant_id) {
|
|
943
|
+
return { status: 403, body: { error: "AUTHORITY_TENANT_MISMATCH" } };
|
|
944
|
+
}
|
|
744
945
|
if (record.headers["idempotency-key"] !== request.intent_id) {
|
|
745
946
|
return { status: 400, body: { error: "IDEMPOTENCY_KEY_MISMATCH" } };
|
|
746
947
|
}
|
|
747
948
|
const bindingError = this.assertBinding(request, record);
|
|
748
949
|
if (bindingError !== null)
|
|
749
950
|
return { status: 400, body: { error: bindingError } };
|
|
750
|
-
const evaluation = this.evaluate(request);
|
|
951
|
+
const evaluation = this.heldByBreach(request, this.evaluate(request));
|
|
751
952
|
if (evaluation.error !== undefined)
|
|
752
953
|
return { status: 409, body: { error: evaluation.error } };
|
|
753
954
|
this.decisions += 1;
|
|
@@ -758,10 +959,26 @@ export class LocalAuthority {
|
|
|
758
959
|
if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
|
|
759
960
|
return { status: 200, body: this.remember(request, decision) };
|
|
760
961
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
962
|
+
const granted = this.grantWithinAllowances(request, decision, evaluation.approval);
|
|
963
|
+
// Only a grant has a scope to hold: a refusal for want of an allowance has no token.
|
|
964
|
+
const token = granted.execution_token;
|
|
965
|
+
if (typeof token === "string") {
|
|
966
|
+
this.assumptionBreaches.granted(token, LocalAssumptionBreaches.scopeOf(request), evaluation.reasonCode === "POLICY_AUTONOMOUS_LIMIT");
|
|
967
|
+
}
|
|
968
|
+
return { status: 200, body: this.remember(request, granted) };
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* The starter policy's assumption-breach rule: an ALLOW the policy gave on
|
|
972
|
+
* its own becomes ESCALATE while a breach holds the scope. A person's
|
|
973
|
+
* approval is that person's decision, and stands.
|
|
974
|
+
*/
|
|
975
|
+
heldByBreach(request, evaluation) {
|
|
976
|
+
if (evaluation.reasonCode !== "POLICY_AUTONOMOUS_LIMIT")
|
|
977
|
+
return evaluation;
|
|
978
|
+
if (this.assumptionBreaches.openIn(LocalAssumptionBreaches.scopeOf(request)).length === 0) {
|
|
979
|
+
return evaluation;
|
|
980
|
+
}
|
|
981
|
+
return { status: "ESCALATE", reasonCode: "ASSUMPTION_BREACH_OPEN", approval: null };
|
|
765
982
|
}
|
|
766
983
|
/** Keeps a minted decision for its dossier route, with the envelope when links are on. */
|
|
767
984
|
remember(request, decision) {
|
|
@@ -832,7 +1049,7 @@ export class LocalAuthority {
|
|
|
832
1049
|
if ("refused" in signed) {
|
|
833
1050
|
return { status: "BLOCK", reasonCode: signed.refused, approval: null, error: signed.refused };
|
|
834
1051
|
}
|
|
835
|
-
const verdict = this.policy(
|
|
1052
|
+
const verdict = this.policy(this.policyInput(request, signed.facts));
|
|
836
1053
|
if (verdict === "BLOCK") {
|
|
837
1054
|
return { status: "BLOCK", reasonCode: "POLICY_HARD_LIMIT_EXCEEDED", approval: null };
|
|
838
1055
|
}
|
|
@@ -869,6 +1086,15 @@ export class LocalAuthority {
|
|
|
869
1086
|
}
|
|
870
1087
|
return { status: "ALLOW", reasonCode: "PRESENCE_RECEIPT_VERIFIED", approval: humanApproval };
|
|
871
1088
|
}
|
|
1089
|
+
/** The request as the policy hook reads it, with the signals that reach it. */
|
|
1090
|
+
policyInput(request, signedFacts) {
|
|
1091
|
+
const material = this.materialSignalsFor(request).values;
|
|
1092
|
+
return {
|
|
1093
|
+
...request,
|
|
1094
|
+
...(signedFacts === undefined ? {} : { signed_signals: signedFacts }),
|
|
1095
|
+
...(Object.keys(material).length === 0 ? {} : { material_signals: material }),
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
872
1098
|
verifyEvidence(approval, request, requiredRole) {
|
|
873
1099
|
if (this.presence !== undefined) {
|
|
874
1100
|
return this.presence.verifyReceiptDetailed(approval, {
|
|
@@ -890,7 +1116,7 @@ export class LocalAuthority {
|
|
|
890
1116
|
should_execute: false,
|
|
891
1117
|
reason_codes: [...reasonCodes],
|
|
892
1118
|
action_hash: request.intent_hash,
|
|
893
|
-
policy_version:
|
|
1119
|
+
policy_version: this.policyVersion,
|
|
894
1120
|
mode: request.mode,
|
|
895
1121
|
execution_token: null,
|
|
896
1122
|
execution_token_expires_at: null,
|
|
@@ -923,9 +1149,18 @@ export class LocalAuthority {
|
|
|
923
1149
|
grantDecision(request, decision, approval) {
|
|
924
1150
|
const issuedAt = Math.floor(this.clock() / 1_000);
|
|
925
1151
|
const expiresAt = Math.min(Math.floor((this.clock() + GRANT_TTL_MS) / 1_000), Math.floor(Date.parse(request.expires_at) / 1_000));
|
|
926
|
-
const jti = `synthetic-jti-${randomUUID()}`;
|
|
927
|
-
const
|
|
1152
|
+
const jti = this.addressableGrants ? randomUUID() : `synthetic-jti-${randomUUID()}`;
|
|
1153
|
+
const encode = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
1154
|
+
const token = this.addressableGrants
|
|
1155
|
+
? `${encode({ alg: "none", typ: "synthetic-grant" })}.${encode({ jti, org_id: request.tenant_id })}.unsigned`
|
|
1156
|
+
: `synthetic-grant.${jti}`;
|
|
928
1157
|
const bindingDigest = `sha256:${createHash("sha256").update(request.intent_hash).digest("hex")}`;
|
|
1158
|
+
const materialSignals = this.materialSignalsFor(request).references;
|
|
1159
|
+
// The hosted claim store's conflict domains, one per material signal the
|
|
1160
|
+
// grant binds. Hosted, a grant that binds none holds its execution target
|
|
1161
|
+
// instead; this double does not, so that flows written before it modelled
|
|
1162
|
+
// scopes, such as two open card claims on one card, keep their behaviour.
|
|
1163
|
+
const scopes = materialSignals.map((signal) => `signal:${signal.signal_id}`);
|
|
929
1164
|
this.grants.set(token, {
|
|
930
1165
|
jti,
|
|
931
1166
|
tenantId: request.tenant_id,
|
|
@@ -941,7 +1176,11 @@ export class LocalAuthority {
|
|
|
941
1176
|
bindingDigest,
|
|
942
1177
|
payloadDigest: hashBinding(request.action.parameters),
|
|
943
1178
|
expectedEffectDigest: request.expected_effect_digest ?? null,
|
|
1179
|
+
expectedStateVersion: preconditionIn(request.context)?.version ?? null,
|
|
944
1180
|
receiptDossierId: approval?.receiptDossierId ?? null,
|
|
1181
|
+
policyVersion: this.policyVersion,
|
|
1182
|
+
materialSignals,
|
|
1183
|
+
scopes,
|
|
945
1184
|
claimed: false,
|
|
946
1185
|
claimToken: null,
|
|
947
1186
|
receipt: null,
|
|
@@ -1070,7 +1309,7 @@ export class LocalAuthority {
|
|
|
1070
1309
|
outcome = "ALLOW";
|
|
1071
1310
|
if (managed.finalDecision === null) {
|
|
1072
1311
|
this.decisions += 1;
|
|
1073
|
-
managed.finalDecision = this.
|
|
1312
|
+
managed.finalDecision = this.grantWithinAllowances(managed.request, this.baseDecision({ ...managed.request, mode: "ENFORCEMENT" }, "ALLOW", state.reasonCodes), null);
|
|
1074
1313
|
}
|
|
1075
1314
|
decision = managed.finalDecision;
|
|
1076
1315
|
}
|
|
@@ -1083,17 +1322,21 @@ export class LocalAuthority {
|
|
|
1083
1322
|
else if (state.status === "FAILED") {
|
|
1084
1323
|
outcome = "ERROR";
|
|
1085
1324
|
}
|
|
1325
|
+
// A reauthorization whose allowance cannot be reserved ends BLOCKED, as hosted.
|
|
1326
|
+
const refused = decision?.status === "BLOCK" ? decision : null;
|
|
1086
1327
|
return {
|
|
1087
1328
|
status: 200,
|
|
1088
1329
|
body: {
|
|
1089
1330
|
escalation_id: managed.escalationId,
|
|
1090
1331
|
intent_id: managed.request.intent_id,
|
|
1091
1332
|
action_hash: managed.request.intent_hash,
|
|
1092
|
-
status: state.status,
|
|
1093
|
-
outcome,
|
|
1333
|
+
status: refused === null ? state.status : "BLOCKED",
|
|
1334
|
+
outcome: refused === null ? outcome : "BLOCK",
|
|
1094
1335
|
expires_at: managed.request.expires_at,
|
|
1095
|
-
reason_codes:
|
|
1096
|
-
|
|
1336
|
+
reason_codes: refused === null
|
|
1337
|
+
? state.reasonCodes
|
|
1338
|
+
: [...refused.reason_codes, "REAUTHORIZATION_BLOCKED"],
|
|
1339
|
+
decision: refused === null ? decision : null,
|
|
1097
1340
|
},
|
|
1098
1341
|
};
|
|
1099
1342
|
}
|
|
@@ -1168,9 +1411,7 @@ export class LocalAuthority {
|
|
|
1168
1411
|
return done("FAILED", ["PRESENCE_VERIFICATION_FAILED", verification.reasonCode]);
|
|
1169
1412
|
// PRESENCE_VERIFIED and REAUTHORIZING are passed through within one lookup, as Decionis does.
|
|
1170
1413
|
const signed = this.signedSignalsOf(managed.request);
|
|
1171
|
-
const reevaluated = this.policy("refused" in signed
|
|
1172
|
-
? managed.request
|
|
1173
|
-
: { ...managed.request, signed_signals: signed.facts });
|
|
1414
|
+
const reevaluated = this.policy(this.policyInput(managed.request, "refused" in signed ? undefined : signed.facts));
|
|
1174
1415
|
if (reevaluated === "BLOCK")
|
|
1175
1416
|
return done("BLOCKED", ["REAUTHORIZATION_BLOCKED"]);
|
|
1176
1417
|
return done("GRANT_READY", ["PRESENCE_RECEIPT_VERIFIED"]);
|
|
@@ -1203,6 +1444,9 @@ export class LocalAuthority {
|
|
|
1203
1444
|
status,
|
|
1204
1445
|
body: { valid: false, reason_codes: [reasonCode], claims: null },
|
|
1205
1446
|
});
|
|
1447
|
+
const tenant = this.tenantOf(record);
|
|
1448
|
+
if (tenant !== null && tenant !== intent.data.tenant_id)
|
|
1449
|
+
return rejected("TENANT_MISMATCH", 403);
|
|
1206
1450
|
const bindingError = this.assertBinding({ ...intent.data, intent_hash: parsed.data.intent_hash }, record);
|
|
1207
1451
|
if (bindingError !== null)
|
|
1208
1452
|
return rejected(bindingError);
|
|
@@ -1224,8 +1468,32 @@ export class LocalAuthority {
|
|
|
1224
1468
|
parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
|
|
1225
1469
|
return rejected("PRESENCE_APPROVAL_STALE");
|
|
1226
1470
|
}
|
|
1471
|
+
// Authority is re-derived from current state before anything is claimed,
|
|
1472
|
+
// in the hosted order, so a stale grant never burns its nonce: the policy
|
|
1473
|
+
// release first, then every material signal the decision read.
|
|
1474
|
+
if (grant.policyVersion !== this.policyVersion)
|
|
1475
|
+
return rejected("POLICY_VERSION_STALE");
|
|
1476
|
+
if (grant.materialSignals.some((bound) => this.signals.get(bound.signal_id)?.reference.version !== bound.version)) {
|
|
1477
|
+
return rejected("SIGNAL_VERSION_STALE");
|
|
1478
|
+
}
|
|
1479
|
+
// A breach opened since the grant was issued: the policy re-run no longer
|
|
1480
|
+
// approves it, after the release and the signals, as the hosted claim orders it.
|
|
1481
|
+
if (this.assumptionBreaches.holdsClaim(parsed.data.execution_token)) {
|
|
1482
|
+
return rejected("POLICY_STATE_CHANGED");
|
|
1483
|
+
}
|
|
1227
1484
|
if (grant.claimed)
|
|
1228
1485
|
return rejected("NONCE_REPLAY_DETECTED");
|
|
1486
|
+
// A scope is held from the claim until the outcome is known: a commit or
|
|
1487
|
+
// a failure releases it, an INDETERMINATE keeps it until reconciled.
|
|
1488
|
+
const holders = [...this.grants.values()].filter((other) => other.claimed &&
|
|
1489
|
+
other.tenantId === grant.tenantId &&
|
|
1490
|
+
(other.finalized === null || other.finalized === "INDETERMINATE") &&
|
|
1491
|
+
other.scopes.some((scope) => grant.scopes.includes(scope)));
|
|
1492
|
+
if (holders.some((other) => other.finalized === "INDETERMINATE")) {
|
|
1493
|
+
return rejected("CURRENT_STATE_UNRESOLVED");
|
|
1494
|
+
}
|
|
1495
|
+
if (holders.length > 0)
|
|
1496
|
+
return rejected("AUTHORIZATION_SCOPE_BUSY");
|
|
1229
1497
|
grant.claimed = true;
|
|
1230
1498
|
grant.claimToken = randomBytes(32).toString("base64url");
|
|
1231
1499
|
grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
|
|
@@ -1268,6 +1536,9 @@ export class LocalAuthority {
|
|
|
1268
1536
|
...(grant.expectedEffectDigest === null
|
|
1269
1537
|
? {}
|
|
1270
1538
|
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
1539
|
+
...(grant.expectedStateVersion === null
|
|
1540
|
+
? {}
|
|
1541
|
+
: { expected_state_version: grant.expectedStateVersion }),
|
|
1271
1542
|
},
|
|
1272
1543
|
jti: grant.jti,
|
|
1273
1544
|
iat: grant.issuedAt,
|
|
@@ -1368,6 +1639,16 @@ export class LocalAuthority {
|
|
|
1368
1639
|
}
|
|
1369
1640
|
grant.finalized = parsed.data.outcome;
|
|
1370
1641
|
grant.receipt = receipt;
|
|
1642
|
+
const assumptionBreach = this.assumptionBreaches.finalized({
|
|
1643
|
+
token: parsed.data.execution_token,
|
|
1644
|
+
grantJti: grant.jti,
|
|
1645
|
+
dossierId: grant.dossierId,
|
|
1646
|
+
outcome: parsed.data.outcome,
|
|
1647
|
+
expectedEffectDigest: grant.expectedEffectDigest,
|
|
1648
|
+
observedEffectDigest: evidence?.observed_effect_digest ?? null,
|
|
1649
|
+
evidenceDigest: evidence?.evidence_digest ?? null,
|
|
1650
|
+
receipt,
|
|
1651
|
+
});
|
|
1371
1652
|
// Byte-for-byte the hosted success body: the commit's own decision-chain
|
|
1372
1653
|
// evidence is queued, not yet recorded, so a successful finalization still
|
|
1373
1654
|
// carries `COMMIT_EVIDENCE_PENDING`.
|
|
@@ -1391,6 +1672,7 @@ export class LocalAuthority {
|
|
|
1391
1672
|
recorded: true,
|
|
1392
1673
|
},
|
|
1393
1674
|
}),
|
|
1675
|
+
...(assumptionBreach === null ? {} : { assumption_breach: assumptionBreach }),
|
|
1394
1676
|
reason_codes: ["COMMIT_EVIDENCE_PENDING"],
|
|
1395
1677
|
},
|
|
1396
1678
|
};
|
|
@@ -1421,6 +1703,8 @@ export class LocalAuthority {
|
|
|
1421
1703
|
return "INTENT_TIME_ORDER_INVALID";
|
|
1422
1704
|
if (expiresAt - capturedAt > 300_000)
|
|
1423
1705
|
return "INTENT_LIFETIME_TOO_LONG";
|
|
1706
|
+
if (preconditionIn(request.context) === undefined)
|
|
1707
|
+
return "INTENT_PRECONDITION_INVALID";
|
|
1424
1708
|
return null;
|
|
1425
1709
|
}
|
|
1426
1710
|
async respond(res, record, route, computed) {
|