@decionis/agent-safe-pipeline 0.4.1 → 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 +10 -0
- package/dist/Index.d.ts +6 -0
- package/dist/Index.d.ts.map +1 -1
- package/dist/Index.js +6 -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 +8 -0
- package/dist/execution/GovernedExecutionRunner.d.ts.map +1 -1
- package/dist/execution/GovernedExecutionRunner.js +28 -0
- package/dist/execution/GovernedExecutionRunner.js.map +1 -1
- package/dist/execution/NativeExecutor.d.ts +13 -0
- package/dist/execution/NativeExecutor.d.ts.map +1 -1
- package/dist/execution/NativeExecutor.js.map +1 -1
- package/dist/intent/ExecutionIntent.d.ts +11 -0
- package/dist/intent/ExecutionIntent.d.ts.map +1 -1
- package/dist/intent/ExecutionIntent.js +15 -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/IntentCapture.d.ts.map +1 -1
- package/dist/intent/IntentCapture.js +15 -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 +202 -2
- package/dist/profile/ApiProfile.d.ts.map +1 -1
- package/dist/profile/ApiProfile.js +397 -11
- package/dist/profile/ApiProfile.js.map +1 -1
- package/dist/profile/BuiltInApiProfiles.d.ts.map +1 -1
- package/dist/profile/BuiltInApiProfiles.js +199 -1
- package/dist/profile/BuiltInApiProfiles.js.map +1 -1
- 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 +152 -5
- package/dist/testing/LocalAuthority.d.ts.map +1 -1
- package/dist/testing/LocalAuthority.js +294 -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 +2 -2
|
@@ -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. */
|
|
@@ -316,6 +361,13 @@ export class LocalAuthority {
|
|
|
316
361
|
legacyVerifyReceipt;
|
|
317
362
|
verifyAttestation;
|
|
318
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;
|
|
319
371
|
managedApproverId;
|
|
320
372
|
trustedEffectObserverIds;
|
|
321
373
|
clock;
|
|
@@ -329,6 +381,13 @@ export class LocalAuthority {
|
|
|
329
381
|
* says the executor must refuse.
|
|
330
382
|
*/
|
|
331
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();
|
|
332
391
|
overrides = {
|
|
333
392
|
enforce: [],
|
|
334
393
|
status: [],
|
|
@@ -344,6 +403,11 @@ export class LocalAuthority {
|
|
|
344
403
|
escalationRouting;
|
|
345
404
|
server = null;
|
|
346
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();
|
|
347
411
|
constructor(options = {}) {
|
|
348
412
|
if (process.env.NODE_ENV === "production")
|
|
349
413
|
throw new Error("LOCAL_DOUBLE_FORBIDDEN");
|
|
@@ -354,6 +418,9 @@ export class LocalAuthority {
|
|
|
354
418
|
this.legacyVerifyReceipt = options.verifyReceipt;
|
|
355
419
|
this.verifyAttestation = options.verifyAttestation;
|
|
356
420
|
this.policy = options.policy ?? defaultPolicy;
|
|
421
|
+
this.materialSignalsOf = options.materialSignals ?? (() => []);
|
|
422
|
+
this.tenantKeys = options.tenantKeys ?? {};
|
|
423
|
+
this.addressableGrants = options.addressableGrants === true;
|
|
357
424
|
this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
|
|
358
425
|
this.trustedEffectObserverIds = options.trustedEffectObserverIds ?? [];
|
|
359
426
|
this.clock = options.clock ?? Date.now;
|
|
@@ -362,6 +429,7 @@ export class LocalAuthority {
|
|
|
362
429
|
privateKey: keys.privateKey,
|
|
363
430
|
publicJwk: keys.publicKey.export({ format: "jwk" }),
|
|
364
431
|
};
|
|
432
|
+
this.allowanceDrawsOf = options.allowanceDraws ?? (() => []);
|
|
365
433
|
}
|
|
366
434
|
/** The fixture's JWKS, as `GET /.well-known/decionis-execution-grant-jwks.json` serves it. */
|
|
367
435
|
get jwks() {
|
|
@@ -392,6 +460,9 @@ export class LocalAuthority {
|
|
|
392
460
|
...(grant.expectedEffectDigest === null
|
|
393
461
|
? {}
|
|
394
462
|
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
463
|
+
...(grant.expectedStateVersion === null
|
|
464
|
+
? {}
|
|
465
|
+
: { expected_state_version: grant.expectedStateVersion }),
|
|
395
466
|
execution_nonce: grant.nonce,
|
|
396
467
|
execution_correlation_id: grant.correlationId ?? "",
|
|
397
468
|
},
|
|
@@ -427,6 +498,51 @@ export class LocalAuthority {
|
|
|
427
498
|
purpose: input.purpose ?? "effect_receipt",
|
|
428
499
|
});
|
|
429
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
|
+
}
|
|
430
546
|
/** The key a signed signal's `kid` names, when it was registered to sign signals. */
|
|
431
547
|
signedSignalKey(kid) {
|
|
432
548
|
const key = this.providerKeys.get(kid);
|
|
@@ -540,6 +656,65 @@ export class LocalAuthority {
|
|
|
540
656
|
}
|
|
541
657
|
return record("EFFECT_RECEIPT_VERIFIED", described);
|
|
542
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
|
+
}
|
|
543
718
|
get baseUrl() {
|
|
544
719
|
return `${LOOPBACK_ORIGIN}:${this.port}`;
|
|
545
720
|
}
|
|
@@ -623,7 +798,14 @@ export class LocalAuthority {
|
|
|
623
798
|
next: {},
|
|
624
799
|
});
|
|
625
800
|
}
|
|
626
|
-
|
|
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) {
|
|
627
809
|
return this.send(res, record, 401, { error: "UNAUTHORIZED" });
|
|
628
810
|
}
|
|
629
811
|
const dossierLookup = url.pathname.match(/^\/v1\/protocol\/dossiers\/([^/]+)$/);
|
|
@@ -676,6 +858,13 @@ export class LocalAuthority {
|
|
|
676
858
|
return this.send(res, record, 404, { error: "NOT_FOUND" });
|
|
677
859
|
}
|
|
678
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
|
+
}
|
|
679
868
|
/** A grant resource addressed by an id that is not the presented token's grant: refused, as hosted. */
|
|
680
869
|
grantIdMismatch(record, grantId) {
|
|
681
870
|
const token = record.body?.execution_token;
|
|
@@ -749,13 +938,17 @@ export class LocalAuthority {
|
|
|
749
938
|
if (!parsed.success)
|
|
750
939
|
return { status: 400, body: { error: "REQUEST_INVALID" } };
|
|
751
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
|
+
}
|
|
752
945
|
if (record.headers["idempotency-key"] !== request.intent_id) {
|
|
753
946
|
return { status: 400, body: { error: "IDEMPOTENCY_KEY_MISMATCH" } };
|
|
754
947
|
}
|
|
755
948
|
const bindingError = this.assertBinding(request, record);
|
|
756
949
|
if (bindingError !== null)
|
|
757
950
|
return { status: 400, body: { error: bindingError } };
|
|
758
|
-
const evaluation = this.evaluate(request);
|
|
951
|
+
const evaluation = this.heldByBreach(request, this.evaluate(request));
|
|
759
952
|
if (evaluation.error !== undefined)
|
|
760
953
|
return { status: 409, body: { error: evaluation.error } };
|
|
761
954
|
this.decisions += 1;
|
|
@@ -766,10 +959,26 @@ export class LocalAuthority {
|
|
|
766
959
|
if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
|
|
767
960
|
return { status: 200, body: this.remember(request, decision) };
|
|
768
961
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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 };
|
|
773
982
|
}
|
|
774
983
|
/** Keeps a minted decision for its dossier route, with the envelope when links are on. */
|
|
775
984
|
remember(request, decision) {
|
|
@@ -840,7 +1049,7 @@ export class LocalAuthority {
|
|
|
840
1049
|
if ("refused" in signed) {
|
|
841
1050
|
return { status: "BLOCK", reasonCode: signed.refused, approval: null, error: signed.refused };
|
|
842
1051
|
}
|
|
843
|
-
const verdict = this.policy(
|
|
1052
|
+
const verdict = this.policy(this.policyInput(request, signed.facts));
|
|
844
1053
|
if (verdict === "BLOCK") {
|
|
845
1054
|
return { status: "BLOCK", reasonCode: "POLICY_HARD_LIMIT_EXCEEDED", approval: null };
|
|
846
1055
|
}
|
|
@@ -877,6 +1086,15 @@ export class LocalAuthority {
|
|
|
877
1086
|
}
|
|
878
1087
|
return { status: "ALLOW", reasonCode: "PRESENCE_RECEIPT_VERIFIED", approval: humanApproval };
|
|
879
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
|
+
}
|
|
880
1098
|
verifyEvidence(approval, request, requiredRole) {
|
|
881
1099
|
if (this.presence !== undefined) {
|
|
882
1100
|
return this.presence.verifyReceiptDetailed(approval, {
|
|
@@ -898,7 +1116,7 @@ export class LocalAuthority {
|
|
|
898
1116
|
should_execute: false,
|
|
899
1117
|
reason_codes: [...reasonCodes],
|
|
900
1118
|
action_hash: request.intent_hash,
|
|
901
|
-
policy_version:
|
|
1119
|
+
policy_version: this.policyVersion,
|
|
902
1120
|
mode: request.mode,
|
|
903
1121
|
execution_token: null,
|
|
904
1122
|
execution_token_expires_at: null,
|
|
@@ -931,9 +1149,18 @@ export class LocalAuthority {
|
|
|
931
1149
|
grantDecision(request, decision, approval) {
|
|
932
1150
|
const issuedAt = Math.floor(this.clock() / 1_000);
|
|
933
1151
|
const expiresAt = Math.min(Math.floor((this.clock() + GRANT_TTL_MS) / 1_000), Math.floor(Date.parse(request.expires_at) / 1_000));
|
|
934
|
-
const jti = `synthetic-jti-${randomUUID()}`;
|
|
935
|
-
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}`;
|
|
936
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}`);
|
|
937
1164
|
this.grants.set(token, {
|
|
938
1165
|
jti,
|
|
939
1166
|
tenantId: request.tenant_id,
|
|
@@ -949,7 +1176,11 @@ export class LocalAuthority {
|
|
|
949
1176
|
bindingDigest,
|
|
950
1177
|
payloadDigest: hashBinding(request.action.parameters),
|
|
951
1178
|
expectedEffectDigest: request.expected_effect_digest ?? null,
|
|
1179
|
+
expectedStateVersion: preconditionIn(request.context)?.version ?? null,
|
|
952
1180
|
receiptDossierId: approval?.receiptDossierId ?? null,
|
|
1181
|
+
policyVersion: this.policyVersion,
|
|
1182
|
+
materialSignals,
|
|
1183
|
+
scopes,
|
|
953
1184
|
claimed: false,
|
|
954
1185
|
claimToken: null,
|
|
955
1186
|
receipt: null,
|
|
@@ -1078,7 +1309,7 @@ export class LocalAuthority {
|
|
|
1078
1309
|
outcome = "ALLOW";
|
|
1079
1310
|
if (managed.finalDecision === null) {
|
|
1080
1311
|
this.decisions += 1;
|
|
1081
|
-
managed.finalDecision = this.
|
|
1312
|
+
managed.finalDecision = this.grantWithinAllowances(managed.request, this.baseDecision({ ...managed.request, mode: "ENFORCEMENT" }, "ALLOW", state.reasonCodes), null);
|
|
1082
1313
|
}
|
|
1083
1314
|
decision = managed.finalDecision;
|
|
1084
1315
|
}
|
|
@@ -1091,17 +1322,21 @@ export class LocalAuthority {
|
|
|
1091
1322
|
else if (state.status === "FAILED") {
|
|
1092
1323
|
outcome = "ERROR";
|
|
1093
1324
|
}
|
|
1325
|
+
// A reauthorization whose allowance cannot be reserved ends BLOCKED, as hosted.
|
|
1326
|
+
const refused = decision?.status === "BLOCK" ? decision : null;
|
|
1094
1327
|
return {
|
|
1095
1328
|
status: 200,
|
|
1096
1329
|
body: {
|
|
1097
1330
|
escalation_id: managed.escalationId,
|
|
1098
1331
|
intent_id: managed.request.intent_id,
|
|
1099
1332
|
action_hash: managed.request.intent_hash,
|
|
1100
|
-
status: state.status,
|
|
1101
|
-
outcome,
|
|
1333
|
+
status: refused === null ? state.status : "BLOCKED",
|
|
1334
|
+
outcome: refused === null ? outcome : "BLOCK",
|
|
1102
1335
|
expires_at: managed.request.expires_at,
|
|
1103
|
-
reason_codes:
|
|
1104
|
-
|
|
1336
|
+
reason_codes: refused === null
|
|
1337
|
+
? state.reasonCodes
|
|
1338
|
+
: [...refused.reason_codes, "REAUTHORIZATION_BLOCKED"],
|
|
1339
|
+
decision: refused === null ? decision : null,
|
|
1105
1340
|
},
|
|
1106
1341
|
};
|
|
1107
1342
|
}
|
|
@@ -1176,9 +1411,7 @@ export class LocalAuthority {
|
|
|
1176
1411
|
return done("FAILED", ["PRESENCE_VERIFICATION_FAILED", verification.reasonCode]);
|
|
1177
1412
|
// PRESENCE_VERIFIED and REAUTHORIZING are passed through within one lookup, as Decionis does.
|
|
1178
1413
|
const signed = this.signedSignalsOf(managed.request);
|
|
1179
|
-
const reevaluated = this.policy("refused" in signed
|
|
1180
|
-
? managed.request
|
|
1181
|
-
: { ...managed.request, signed_signals: signed.facts });
|
|
1414
|
+
const reevaluated = this.policy(this.policyInput(managed.request, "refused" in signed ? undefined : signed.facts));
|
|
1182
1415
|
if (reevaluated === "BLOCK")
|
|
1183
1416
|
return done("BLOCKED", ["REAUTHORIZATION_BLOCKED"]);
|
|
1184
1417
|
return done("GRANT_READY", ["PRESENCE_RECEIPT_VERIFIED"]);
|
|
@@ -1211,6 +1444,9 @@ export class LocalAuthority {
|
|
|
1211
1444
|
status,
|
|
1212
1445
|
body: { valid: false, reason_codes: [reasonCode], claims: null },
|
|
1213
1446
|
});
|
|
1447
|
+
const tenant = this.tenantOf(record);
|
|
1448
|
+
if (tenant !== null && tenant !== intent.data.tenant_id)
|
|
1449
|
+
return rejected("TENANT_MISMATCH", 403);
|
|
1214
1450
|
const bindingError = this.assertBinding({ ...intent.data, intent_hash: parsed.data.intent_hash }, record);
|
|
1215
1451
|
if (bindingError !== null)
|
|
1216
1452
|
return rejected(bindingError);
|
|
@@ -1232,8 +1468,32 @@ export class LocalAuthority {
|
|
|
1232
1468
|
parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
|
|
1233
1469
|
return rejected("PRESENCE_APPROVAL_STALE");
|
|
1234
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
|
+
}
|
|
1235
1484
|
if (grant.claimed)
|
|
1236
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");
|
|
1237
1497
|
grant.claimed = true;
|
|
1238
1498
|
grant.claimToken = randomBytes(32).toString("base64url");
|
|
1239
1499
|
grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
|
|
@@ -1276,6 +1536,9 @@ export class LocalAuthority {
|
|
|
1276
1536
|
...(grant.expectedEffectDigest === null
|
|
1277
1537
|
? {}
|
|
1278
1538
|
: { expected_effect_digest: grant.expectedEffectDigest }),
|
|
1539
|
+
...(grant.expectedStateVersion === null
|
|
1540
|
+
? {}
|
|
1541
|
+
: { expected_state_version: grant.expectedStateVersion }),
|
|
1279
1542
|
},
|
|
1280
1543
|
jti: grant.jti,
|
|
1281
1544
|
iat: grant.issuedAt,
|
|
@@ -1376,6 +1639,16 @@ export class LocalAuthority {
|
|
|
1376
1639
|
}
|
|
1377
1640
|
grant.finalized = parsed.data.outcome;
|
|
1378
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
|
+
});
|
|
1379
1652
|
// Byte-for-byte the hosted success body: the commit's own decision-chain
|
|
1380
1653
|
// evidence is queued, not yet recorded, so a successful finalization still
|
|
1381
1654
|
// carries `COMMIT_EVIDENCE_PENDING`.
|
|
@@ -1399,6 +1672,7 @@ export class LocalAuthority {
|
|
|
1399
1672
|
recorded: true,
|
|
1400
1673
|
},
|
|
1401
1674
|
}),
|
|
1675
|
+
...(assumptionBreach === null ? {} : { assumption_breach: assumptionBreach }),
|
|
1402
1676
|
reason_codes: ["COMMIT_EVIDENCE_PENDING"],
|
|
1403
1677
|
},
|
|
1404
1678
|
};
|
|
@@ -1429,6 +1703,8 @@ export class LocalAuthority {
|
|
|
1429
1703
|
return "INTENT_TIME_ORDER_INVALID";
|
|
1430
1704
|
if (expiresAt - capturedAt > 300_000)
|
|
1431
1705
|
return "INTENT_LIFETIME_TOO_LONG";
|
|
1706
|
+
if (preconditionIn(request.context) === undefined)
|
|
1707
|
+
return "INTENT_PRECONDITION_INVALID";
|
|
1432
1708
|
return null;
|
|
1433
1709
|
}
|
|
1434
1710
|
async respond(res, record, route, computed) {
|