@metamynd/agentsafe-signer 0.13.0 → 0.15.1
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 -1
- package/daemon.mjs +38 -2
- package/package.json +2 -1
- package/payload-binding.mjs +121 -0
- package/policy-core.mjs +215 -9
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ tells you what to set. Opens the signing socket (always on) and the admin socket
|
|
|
28
28
|
## Status
|
|
29
29
|
|
|
30
30
|
**Implemented and tested:**
|
|
31
|
-
- The full
|
|
31
|
+
- The full nine-operation signing-socket protocol (`sign-authorize`, `sign-envelope`, `sign-payload`,
|
|
32
32
|
`sign-handshake-nonce`, `sign-key-control-challenge`, `sign-log-checkpoint`,
|
|
33
33
|
`sign-local-decision`, `get-identity`, `ping`) and the two-operation admin socket
|
|
34
34
|
(`generate-key`, `status`), matching the design doc's closed operation set exactly — no generic
|
|
@@ -146,6 +146,15 @@ tells you what to set. Opens the signing socket (always on) and the admin socket
|
|
|
146
146
|
same as `sign-authorize`/`sign-envelope` (a service-role daemon never evaluates a mandate
|
|
147
147
|
locally). `daemon-protocol.smoke.mjs`, `daemon-envelope-parity.smoke.mjs`.
|
|
148
148
|
|
|
149
|
+
- **`sign-payload` (0.15.0) — payload binding without giving up custody.** An agent-role daemon signs the
|
|
150
|
+
digest of the COMPLETE payload an agent will execute (MAGP 8.3.9), and — when given an `authorizationId` — the
|
|
151
|
+
late binding of a hold that already exists (8.3.11, a reviewer's MODIFY mints one with no digest). Like every op
|
|
152
|
+
here it builds the message itself from validated fields and never signs caller-supplied bytes: the digest must be
|
|
153
|
+
`sha256:` + 64 lower-case hex, the authorization id a UUID, and the message starts with a domain prefix
|
|
154
|
+
(`MAGP-PAYLOAD-v1` / `MAGP-PAYLOAD-REBIND-v1`) that nothing else this daemon signs starts with, so the signature
|
|
155
|
+
cannot be replayed as an authorize, envelope, handshake or challenge. Before this a daemon-backed guard asked to
|
|
156
|
+
bind a payload failed closed. `daemon-protocol.smoke.mjs`.
|
|
157
|
+
|
|
149
158
|
**Deliberately not yet implemented — real gaps, not oversights:**
|
|
150
159
|
- **The generated systemd unit has never started a daemon.** Running it for real on Ubuntu 24.04
|
|
151
160
|
LTS / systemd 255 (the first time this was tried on any Linux host) found five defects in a
|
package/daemon.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import { buildAuthMessage, buildCheckpointAnchorMessage, buildLocalDecisionMessage } from './policy-core.mjs';
|
|
10
10
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
11
|
+
import { buildPayloadBindingMessage, buildPayloadRebindMessage, isPayloadDigest } from './payload-binding.mjs';
|
|
11
12
|
import { KeyStore, KeyStoreError } from './keystore.mjs';
|
|
12
13
|
import { createSecurePipePool, createSecurePipeOnce } from './windows-secure-pipe.mjs';
|
|
13
14
|
import { takeCheckpoint } from './log-checkpoint.mjs';
|
|
@@ -17,7 +18,7 @@ export const MAX_LINE_BYTES = 64 * 1024;
|
|
|
17
18
|
export const FRESHNESS_MS = 5 * 60 * 1000;
|
|
18
19
|
export const CLOCK_SKEW_MS = 30 * 1000;
|
|
19
20
|
|
|
20
|
-
const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']);
|
|
21
|
+
const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']);
|
|
21
22
|
|
|
22
23
|
// sign-log-checkpoint is in BOTH role sets, unlike every other sign-* op: it authenticates the
|
|
23
24
|
// daemon's own log-tamper-evidence checkpoint (T11), which every daemon keeps regardless of
|
|
@@ -27,13 +28,15 @@ const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-handsh
|
|
|
27
28
|
// (agentsafe-guard.mjs's reportLocalDecision()); a service-role daemon never evaluates a
|
|
28
29
|
// mandate locally, so it has nothing to report.
|
|
29
30
|
const SIGNING_OPS_BY_ROLE = {
|
|
30
|
-
agent: new Set(['sign-authorize', 'sign-envelope', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']),
|
|
31
|
+
agent: new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']),
|
|
31
32
|
service: new Set(['sign-handshake-nonce', 'sign-log-checkpoint', 'get-identity', 'ping']),
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
const DEFAULT_RATE_LIMITS = {
|
|
35
36
|
'sign-authorize': { max: 20, windowMs: 10_000 },
|
|
36
37
|
'sign-envelope': { max: 20, windowMs: 10_000 },
|
|
38
|
+
// One per bound authorize, plus one per late binding (a modified hold): never more than sign-authorize itself.
|
|
39
|
+
'sign-payload': { max: 20, windowMs: 10_000 },
|
|
37
40
|
'sign-handshake-nonce': { max: 10, windowMs: 60_000 },
|
|
38
41
|
'sign-key-control-challenge': { max: 3, windowMs: 60 * 60 * 1000 },
|
|
39
42
|
// Local decisions are reported for every locally-decided block/escalate/non-value-allow, not
|
|
@@ -238,6 +241,8 @@ export class SignerDaemon {
|
|
|
238
241
|
return this.#handleSignAuthorize(params);
|
|
239
242
|
case 'sign-envelope':
|
|
240
243
|
return this.#handleSignEnvelope(params);
|
|
244
|
+
case 'sign-payload':
|
|
245
|
+
return this.#handleSignPayload(params);
|
|
241
246
|
case 'sign-handshake-nonce':
|
|
242
247
|
return this.#handleSignHandshakeNonce(params);
|
|
243
248
|
case 'sign-key-control-challenge':
|
|
@@ -309,6 +314,37 @@ export class SignerDaemon {
|
|
|
309
314
|
return { signature: this.#sign(Buffer.from(message, 'utf8')) };
|
|
310
315
|
}
|
|
311
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Payload binding (MAGP 8.3.9 / 8.3.11): signs the digest of the COMPLETE payload an agent will execute, bound to ONE
|
|
319
|
+
* authorization. Like every other op here the daemon builds the message ITSELF from structured, validated fields; it never
|
|
320
|
+
* signs bytes a caller hands it. The digest is checked to be `sha256:` + 64 lower-case hex, so the only free-form thing in the
|
|
321
|
+
* message is a fixed-shape hash under a fixed domain prefix (MAGP-PAYLOAD-v1 / MAGP-PAYLOAD-REBIND-v1) that no other message
|
|
322
|
+
* this daemon signs starts with — a signature made here cannot be replayed as an authorize, envelope, handshake or challenge.
|
|
323
|
+
*
|
|
324
|
+
* Without `authorizationId` it signs the authorize-time binding (named by the request's own nonce and issuedAt). With one it
|
|
325
|
+
* signs the LATE binding for a hold that already exists (a reviewer's MODIFY mints one with no digest), which names that
|
|
326
|
+
* authorization so the digest cannot be lifted onto another. The id must be a UUID, which is what the issuer mints.
|
|
327
|
+
*/
|
|
328
|
+
#handleSignPayload(params = {}) {
|
|
329
|
+
this.#assertRateLimit('sign-payload');
|
|
330
|
+
const { agentDid, action, nonce, issuedAt, payloadDigest, authorizationId } = params;
|
|
331
|
+
if (typeof agentDid !== 'string' || typeof action !== 'string' || typeof nonce !== 'string' || typeof issuedAt !== 'string') {
|
|
332
|
+
throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'missing required field');
|
|
333
|
+
}
|
|
334
|
+
if (action.length === 0 || action.includes('|')) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid action');
|
|
335
|
+
if (nonce.length === 0 || nonce.length > 128) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid nonce');
|
|
336
|
+
if (!isPayloadDigest(payloadDigest)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'payloadDigest must be sha256: followed by 64 lower-case hex');
|
|
337
|
+
if (authorizationId !== undefined && (typeof authorizationId !== 'string' || !UUID_RE.test(authorizationId))) {
|
|
338
|
+
throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'authorizationId must be a UUID');
|
|
339
|
+
}
|
|
340
|
+
this.#assertIdentity(agentDid);
|
|
341
|
+
if (!freshnessOk(issuedAt)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'issuedAt outside freshness window');
|
|
342
|
+
const message = authorizationId === undefined
|
|
343
|
+
? buildPayloadBindingMessage({ agentDid, action, nonce, issuedAt, payloadDigest })
|
|
344
|
+
: buildPayloadRebindMessage({ agentDid, action, authorizationId, nonce, issuedAt, payloadDigest });
|
|
345
|
+
return { signature: this.#sign(Buffer.from(message, 'utf8')) };
|
|
346
|
+
}
|
|
347
|
+
|
|
312
348
|
#handleSignEnvelope(params = {}) {
|
|
313
349
|
this.#assertRateLimit('sign-envelope');
|
|
314
350
|
this.#validateCoreFields(params);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-signer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Local signer daemon for AgentSafe agent/service keys \u2014 the key never enters the calling guard's own process. See docs/design/agent-key-custody-local-signer-daemon-plan.md.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./daemon.mjs",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"cli.mjs",
|
|
25
25
|
"policy-core.mjs",
|
|
26
26
|
"governance-envelope.mjs",
|
|
27
|
+
"payload-binding.mjs",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// GENERATED from backend/src/features/magp/payload-binding.ts - do not edit. Regenerate: npm run build:signer-core
|
|
2
|
+
|
|
3
|
+
// src/features/magp/payload-binding.ts
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// src/policy-core/canonical.ts
|
|
7
|
+
function escapeField(v) {
|
|
8
|
+
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/features/magp/payload-binding.ts
|
|
12
|
+
var PAYLOAD_BINDING_PREFIX = "MAGP-PAYLOAD-v1";
|
|
13
|
+
var PAYLOAD_REBIND_PREFIX = "MAGP-PAYLOAD-REBIND-v1";
|
|
14
|
+
var PAYLOAD_DIGEST_PREFIX = "sha256:";
|
|
15
|
+
var PAYLOAD_DIGEST_HEADER = "x-magp-payload-digest";
|
|
16
|
+
var MAX_CANONICAL_PAYLOAD_BYTES = 256 * 1024;
|
|
17
|
+
var MAX_DEPTH = 32;
|
|
18
|
+
var PayloadNotCanonicalizable = class extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "PayloadNotCanonicalizable";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
function hasLoneSurrogate(s) {
|
|
25
|
+
for (let i = 0; i < s.length; i++) {
|
|
26
|
+
const c = s.charCodeAt(i);
|
|
27
|
+
if (c >= 55296 && c <= 56319) {
|
|
28
|
+
const next = s.charCodeAt(i + 1);
|
|
29
|
+
if (!(next >= 56320 && next <= 57343)) return true;
|
|
30
|
+
i++;
|
|
31
|
+
} else if (c >= 56320 && c <= 57343) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function serialize(value, depth, path) {
|
|
38
|
+
if (depth > MAX_DEPTH) throw new PayloadNotCanonicalizable(`payload is nested deeper than ${MAX_DEPTH} levels at ${path}`);
|
|
39
|
+
if (value === null) return "null";
|
|
40
|
+
switch (typeof value) {
|
|
41
|
+
case "boolean":
|
|
42
|
+
return value ? "true" : "false";
|
|
43
|
+
case "number":
|
|
44
|
+
if (!Number.isFinite(value)) throw new PayloadNotCanonicalizable(`${path} is not a finite number`);
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
// ECMAScript Number::toString — what RFC 8785 specifies; -0 serialises as "0"
|
|
47
|
+
case "string":
|
|
48
|
+
if (hasLoneSurrogate(value)) throw new PayloadNotCanonicalizable(`${path} contains an unpaired surrogate`);
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
case "object": {
|
|
51
|
+
if (Array.isArray(value)) return `[${value.map((v, i) => serialize(v, depth + 1, `${path}[${i}]`)).join(",")}]`;
|
|
52
|
+
const proto = Object.getPrototypeOf(value);
|
|
53
|
+
if (proto !== Object.prototype && proto !== null) throw new PayloadNotCanonicalizable(`${path} is not a plain JSON object`);
|
|
54
|
+
const obj = value;
|
|
55
|
+
const keys = Object.keys(obj).sort();
|
|
56
|
+
const parts = keys.map((k) => {
|
|
57
|
+
if (hasLoneSurrogate(k)) throw new PayloadNotCanonicalizable(`${path} has a key with an unpaired surrogate`);
|
|
58
|
+
return `${JSON.stringify(k)}:${serialize(obj[k], depth + 1, `${path}.${k}`)}`;
|
|
59
|
+
});
|
|
60
|
+
return `{${parts.join(",")}}`;
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
throw new PayloadNotCanonicalizable(`${path} is a ${typeof value}, which JSON cannot represent`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function canonicalPayload(value) {
|
|
67
|
+
const text = serialize(value, 0, "$");
|
|
68
|
+
if (Buffer.byteLength(text, "utf8") > MAX_CANONICAL_PAYLOAD_BYTES) {
|
|
69
|
+
throw new PayloadNotCanonicalizable(`canonical payload exceeds ${MAX_CANONICAL_PAYLOAD_BYTES} bytes`);
|
|
70
|
+
}
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
function payloadDigestOf(value) {
|
|
74
|
+
return PAYLOAD_DIGEST_PREFIX + createHash("sha256").update(canonicalPayload(value), "utf8").digest("hex");
|
|
75
|
+
}
|
|
76
|
+
var DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
77
|
+
function isPayloadDigest(value) {
|
|
78
|
+
return typeof value === "string" && DIGEST_RE.test(value);
|
|
79
|
+
}
|
|
80
|
+
function toWireJson(value) {
|
|
81
|
+
const text = JSON.stringify(value);
|
|
82
|
+
if (text === void 0) throw new PayloadNotCanonicalizable("payload is not JSON-serialisable");
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
function buildPayloadBindingMessage(input) {
|
|
86
|
+
return [PAYLOAD_BINDING_PREFIX, input.agentDid, input.action, input.nonce, input.issuedAt, input.payloadDigest].map((f) => escapeField(String(f))).join("|");
|
|
87
|
+
}
|
|
88
|
+
function buildPayloadRebindMessage(input) {
|
|
89
|
+
return [PAYLOAD_REBIND_PREFIX, input.agentDid, input.action, input.authorizationId, input.nonce, input.issuedAt, input.payloadDigest].map((f) => escapeField(String(f))).join("|");
|
|
90
|
+
}
|
|
91
|
+
function decideClaimPayload(stored, presented) {
|
|
92
|
+
if (stored) {
|
|
93
|
+
if (!presented) return { ok: false, reasonCode: "PAYLOAD_DIGEST_REQUIRED" };
|
|
94
|
+
return presented === stored ? { ok: true } : { ok: false, reasonCode: "PAYLOAD_DIGEST_MISMATCH" };
|
|
95
|
+
}
|
|
96
|
+
return presented ? { ok: false, reasonCode: "PAYLOAD_NOT_BOUND_AT_AUTHORIZE" } : { ok: true };
|
|
97
|
+
}
|
|
98
|
+
function claimDigestField(payloadDigest) {
|
|
99
|
+
return `payload=${payloadDigest}`;
|
|
100
|
+
}
|
|
101
|
+
function claimEvidenceRefs(idempotencyRef, payloadDigest) {
|
|
102
|
+
const refs = [...idempotencyRef ? [idempotencyRef] : [], ...payloadDigest ? [`payload:${payloadDigest}`] : []];
|
|
103
|
+
return refs.length ? refs : null;
|
|
104
|
+
}
|
|
105
|
+
export {
|
|
106
|
+
MAX_CANONICAL_PAYLOAD_BYTES,
|
|
107
|
+
PAYLOAD_BINDING_PREFIX,
|
|
108
|
+
PAYLOAD_DIGEST_HEADER,
|
|
109
|
+
PAYLOAD_DIGEST_PREFIX,
|
|
110
|
+
PAYLOAD_REBIND_PREFIX,
|
|
111
|
+
PayloadNotCanonicalizable,
|
|
112
|
+
buildPayloadBindingMessage,
|
|
113
|
+
buildPayloadRebindMessage,
|
|
114
|
+
canonicalPayload,
|
|
115
|
+
claimDigestField,
|
|
116
|
+
claimEvidenceRefs,
|
|
117
|
+
decideClaimPayload,
|
|
118
|
+
isPayloadDigest,
|
|
119
|
+
payloadDigestOf,
|
|
120
|
+
toWireJson
|
|
121
|
+
};
|
package/policy-core.mjs
CHANGED
|
@@ -1,5 +1,138 @@
|
|
|
1
1
|
// GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:signer-core
|
|
2
2
|
|
|
3
|
+
// src/policy-core/provenance.ts
|
|
4
|
+
var PROVENANCE_LEVELS = ["agent_asserted", "agent_signed", "gateway_derived", "authoritative", "attested"];
|
|
5
|
+
var PROVENANCE_RANK = {
|
|
6
|
+
agent_asserted: 0,
|
|
7
|
+
agent_signed: 1,
|
|
8
|
+
gateway_derived: 2,
|
|
9
|
+
authoritative: 3,
|
|
10
|
+
attested: 4
|
|
11
|
+
};
|
|
12
|
+
function isProvenance(v) {
|
|
13
|
+
return typeof v === "string" && Object.prototype.hasOwnProperty.call(PROVENANCE_RANK, v);
|
|
14
|
+
}
|
|
15
|
+
var PROVENANCE_KEY = Symbol.for("magp.context.provenance");
|
|
16
|
+
function provenanceOf(ctx, field) {
|
|
17
|
+
const map = ctx?.[PROVENANCE_KEY];
|
|
18
|
+
const p = map && typeof map === "object" ? map[field] : void 0;
|
|
19
|
+
return isProvenance(p) ? p : "agent_asserted";
|
|
20
|
+
}
|
|
21
|
+
function meetsProvenance(actual, minimum) {
|
|
22
|
+
return PROVENANCE_RANK[actual] >= PROVENANCE_RANK[minimum];
|
|
23
|
+
}
|
|
24
|
+
var RISK_LEVELS = ["low", "medium", "high", "critical"];
|
|
25
|
+
var RISK_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
26
|
+
function normalizeRiskLevel(v) {
|
|
27
|
+
if (typeof v !== "string") return null;
|
|
28
|
+
const s = v.trim().toLowerCase();
|
|
29
|
+
return Object.prototype.hasOwnProperty.call(RISK_ORDER, s) ? s : null;
|
|
30
|
+
}
|
|
31
|
+
function maxRisk(...levels) {
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const l of levels) if (l && (best === null || RISK_ORDER[l] > RISK_ORDER[best])) best = l;
|
|
34
|
+
return best;
|
|
35
|
+
}
|
|
36
|
+
function riskFloorFor(mandate, target) {
|
|
37
|
+
if (!mandate) return null;
|
|
38
|
+
let floor = null;
|
|
39
|
+
for (const p of mandate.permission ?? []) {
|
|
40
|
+
if (!p || typeof p !== "object") continue;
|
|
41
|
+
if ((p.target ?? mandate.target) !== target) continue;
|
|
42
|
+
floor = maxRisk(floor, normalizeRiskLevel(p.riskTier));
|
|
43
|
+
}
|
|
44
|
+
return floor;
|
|
45
|
+
}
|
|
46
|
+
function requiresPayloadBindingFor(mandate, target) {
|
|
47
|
+
if (!mandate) return false;
|
|
48
|
+
for (const p of mandate.permission ?? []) {
|
|
49
|
+
if (!p || typeof p !== "object") continue;
|
|
50
|
+
if ((p.target ?? mandate.target) !== target) continue;
|
|
51
|
+
if (p.requirePayloadBinding === true) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
var FIELD_KINDS = {
|
|
56
|
+
riskLevel: "risk",
|
|
57
|
+
consent: "boolean",
|
|
58
|
+
piiPresent: "boolean",
|
|
59
|
+
amount: "number",
|
|
60
|
+
cumulativeSpend: "number",
|
|
61
|
+
callCount: "number",
|
|
62
|
+
evidenceConfidence: "number",
|
|
63
|
+
holTrustScore: "number",
|
|
64
|
+
dataSourceId: "string",
|
|
65
|
+
jurisdiction: "string",
|
|
66
|
+
dataResidency: "string",
|
|
67
|
+
model: "string",
|
|
68
|
+
tool: "string",
|
|
69
|
+
currency: "string",
|
|
70
|
+
action: "string",
|
|
71
|
+
prompt: "string",
|
|
72
|
+
output: "string",
|
|
73
|
+
evidenceTypes: "string[]"
|
|
74
|
+
};
|
|
75
|
+
function contextFieldProblem(ctx, field) {
|
|
76
|
+
const v = ctx?.[field];
|
|
77
|
+
if (v === void 0 || v === null) return "missing";
|
|
78
|
+
if (typeof v === "string" && v.trim() === "") return "missing";
|
|
79
|
+
switch (FIELD_KINDS[field]) {
|
|
80
|
+
case "risk":
|
|
81
|
+
return normalizeRiskLevel(v) === null ? "malformed" : null;
|
|
82
|
+
case "boolean":
|
|
83
|
+
return typeof v === "boolean" ? null : "malformed";
|
|
84
|
+
case "number":
|
|
85
|
+
return typeof v === "number" && Number.isFinite(v) ? null : "malformed";
|
|
86
|
+
case "string":
|
|
87
|
+
return typeof v === "string" ? null : "malformed";
|
|
88
|
+
case "string[]":
|
|
89
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? null : "malformed";
|
|
90
|
+
default:
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
var ATOM_DEFAULT_REQUIRED_CONTEXT = {
|
|
95
|
+
"risk-at-or-above": ["riskLevel"]
|
|
96
|
+
};
|
|
97
|
+
function buildRuleContext(src) {
|
|
98
|
+
const ctx = {};
|
|
99
|
+
const prov = /* @__PURE__ */ Object.create(null);
|
|
100
|
+
const put = (k, v, level) => {
|
|
101
|
+
Object.defineProperty(ctx, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
102
|
+
prov[k] = level;
|
|
103
|
+
};
|
|
104
|
+
const layers = [
|
|
105
|
+
[src.unsigned, "agent_asserted"],
|
|
106
|
+
[src.signed, "agent_signed"],
|
|
107
|
+
[src.gatewayDerived, "gateway_derived"],
|
|
108
|
+
[src.serverDerived, "authoritative"]
|
|
109
|
+
];
|
|
110
|
+
for (const [layer, level] of layers) {
|
|
111
|
+
for (const [k, v] of Object.entries(layer ?? {})) {
|
|
112
|
+
if (k === "riskLevel" && (level === "gateway_derived" || level === "authoritative") && normalizeRiskLevel(v) === null) continue;
|
|
113
|
+
put(k, v, level);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const floors = [];
|
|
117
|
+
const addFloor = (v, source) => {
|
|
118
|
+
const n = normalizeRiskLevel(v);
|
|
119
|
+
if (n) floors.push({ level: n, source });
|
|
120
|
+
};
|
|
121
|
+
addFloor(src.riskFloor, "authoritative");
|
|
122
|
+
addFloor(src.gatewayDerived?.riskLevel, "gateway_derived");
|
|
123
|
+
addFloor(src.serverDerived?.riskLevel, "authoritative");
|
|
124
|
+
const assertedUnsigned = normalizeRiskLevel(src.unsigned?.riskLevel);
|
|
125
|
+
const assertedSigned = normalizeRiskLevel(src.signed?.riskLevel);
|
|
126
|
+
const asserted = maxRisk(assertedUnsigned, assertedSigned);
|
|
127
|
+
if (floors.length > 0) {
|
|
128
|
+
put("riskLevel", maxRisk(asserted, ...floors.map((f) => f.level)), floors.reduce((best, f) => PROVENANCE_RANK[f.source] > PROVENANCE_RANK[best] ? f.source : best, "agent_asserted"));
|
|
129
|
+
} else if (asserted) {
|
|
130
|
+
put("riskLevel", asserted, assertedSigned ? "agent_signed" : "agent_asserted");
|
|
131
|
+
}
|
|
132
|
+
Object.defineProperty(ctx, PROVENANCE_KEY, { value: prov, enumerable: true, writable: false });
|
|
133
|
+
return ctx;
|
|
134
|
+
}
|
|
135
|
+
|
|
3
136
|
// src/policy-core/atom-registry.ts
|
|
4
137
|
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
138
|
function currencyOutOfScope(ctx, cfgCurrency) {
|
|
@@ -14,7 +147,8 @@ var ATOM_REGISTRY = {
|
|
|
14
147
|
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
15
148
|
"consent-missing": (c) => c.consent === false,
|
|
16
149
|
"risk-at-or-above": (c, cfg) => {
|
|
17
|
-
const
|
|
150
|
+
const haveLevel = normalizeRiskLevel(c.riskLevel);
|
|
151
|
+
const have = haveLevel === null ? void 0 : RISK_RANK[haveLevel];
|
|
18
152
|
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
19
153
|
return have !== void 0 && need !== void 0 && have >= need;
|
|
20
154
|
},
|
|
@@ -276,6 +410,7 @@ function requiredContextFor(predicates) {
|
|
|
276
410
|
}
|
|
277
411
|
|
|
278
412
|
// src/policy-core/standards-rules.ts
|
|
413
|
+
var CONTEXT_UNVERIFIABLE = "CONTEXT_UNVERIFIABLE";
|
|
279
414
|
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
280
415
|
function atomFires(atom, ctx) {
|
|
281
416
|
const pred = ATOM_REGISTRY[atom.predicate];
|
|
@@ -304,17 +439,49 @@ function moleculeFires(m, ctx) {
|
|
|
304
439
|
return false;
|
|
305
440
|
}
|
|
306
441
|
}
|
|
442
|
+
function requiredContextOf(m) {
|
|
443
|
+
const required = /* @__PURE__ */ new Map();
|
|
444
|
+
const need = (field, level) => {
|
|
445
|
+
const have = required.get(field);
|
|
446
|
+
if (!have || PROVENANCE_RANK[level] > PROVENANCE_RANK[have]) required.set(field, level);
|
|
447
|
+
};
|
|
448
|
+
for (const a of m.atoms ?? []) {
|
|
449
|
+
if (!Object.prototype.hasOwnProperty.call(ATOM_DEFAULT_REQUIRED_CONTEXT, a.predicate)) continue;
|
|
450
|
+
for (const f of ATOM_DEFAULT_REQUIRED_CONTEXT[a.predicate]) need(f, "agent_asserted");
|
|
451
|
+
}
|
|
452
|
+
for (const [f, level] of Object.entries(m.requireProvenance ?? {})) {
|
|
453
|
+
need(f, isProvenance(level) ? level : "attested");
|
|
454
|
+
}
|
|
455
|
+
return required;
|
|
456
|
+
}
|
|
457
|
+
function moleculeUnverifiable(m, ctx) {
|
|
458
|
+
const bad = [];
|
|
459
|
+
for (const [field, minimum] of requiredContextOf(m)) {
|
|
460
|
+
if (contextFieldProblem(ctx, field) !== null || !meetsProvenance(provenanceOf(ctx, field), minimum)) bad.push(field);
|
|
461
|
+
}
|
|
462
|
+
return bad.sort();
|
|
463
|
+
}
|
|
307
464
|
function evaluateStandardRules(molecules, ctx, standardKey = null) {
|
|
308
465
|
let best = null;
|
|
309
466
|
for (const m of molecules ?? []) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
467
|
+
const fired = moleculeFires(m, ctx);
|
|
468
|
+
const unverifiable = moleculeUnverifiable(m, ctx);
|
|
469
|
+
if (!fired && unverifiable.length === 0) continue;
|
|
470
|
+
let decision = fired ? m.decision : "escalate";
|
|
471
|
+
if (unverifiable.length > 0 && PRECEDENCE[decision] < PRECEDENCE.escalate) decision = "escalate";
|
|
472
|
+
const reasonCode = fired ? m.reasonCode : CONTEXT_UNVERIFIABLE;
|
|
473
|
+
if (!best || PRECEDENCE[decision] > PRECEDENCE[best.decision]) {
|
|
474
|
+
best = { decision, reasonCode, id: m.id, unverifiable: unverifiable.length > 0 ? unverifiable : void 0 };
|
|
314
475
|
}
|
|
315
476
|
}
|
|
316
477
|
if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
|
|
317
|
-
return {
|
|
478
|
+
return {
|
|
479
|
+
decision: best.decision,
|
|
480
|
+
reasonCode: best.reasonCode,
|
|
481
|
+
firedMoleculeId: best.id,
|
|
482
|
+
standardKey,
|
|
483
|
+
...best.unverifiable ? { unverifiableContext: best.unverifiable } : {}
|
|
484
|
+
};
|
|
318
485
|
}
|
|
319
486
|
function evaluateBoundStandards(standards, ctx) {
|
|
320
487
|
let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
|
|
@@ -369,6 +536,19 @@ function validateMolecules(molecules) {
|
|
|
369
536
|
if (!m.atoms || m.atoms.length === 0) {
|
|
370
537
|
issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
|
|
371
538
|
}
|
|
539
|
+
if (m.requireProvenance !== void 0) {
|
|
540
|
+
const rp = m.requireProvenance;
|
|
541
|
+
if (rp === null || typeof rp !== "object" || Array.isArray(rp)) {
|
|
542
|
+
issues.push({ moleculeId: m.id, message: "requireProvenance must be an object of { field: level }" });
|
|
543
|
+
} else {
|
|
544
|
+
for (const [field, level] of Object.entries(rp)) {
|
|
545
|
+
if (field.trim() === "") issues.push({ moleculeId: m.id, message: "requireProvenance has an empty field name" });
|
|
546
|
+
if (!isProvenance(level)) {
|
|
547
|
+
issues.push({ moleculeId: m.id, message: `requireProvenance '${field}' must be one of agent_asserted|agent_signed|gateway_derived|authoritative|attested` });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
372
552
|
for (const a of m.atoms ?? []) {
|
|
373
553
|
if (!ATOM_REGISTRY[a.predicate]) {
|
|
374
554
|
issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
|
|
@@ -406,9 +586,14 @@ var REASON_BY_OPERAND = {
|
|
|
406
586
|
"mm:route": "ROUTE_NOT_ALLOWED",
|
|
407
587
|
"mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
|
|
408
588
|
};
|
|
409
|
-
|
|
589
|
+
var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
|
|
590
|
+
function reasonFor(constraint, req) {
|
|
410
591
|
if (!constraint) return "CONSTRAINT_FAILED";
|
|
411
|
-
|
|
592
|
+
const { leftOperand } = constraint;
|
|
593
|
+
if (AMOUNT_OPERANDS.has(leftOperand) && !Object.prototype.hasOwnProperty.call(req.values, leftOperand)) {
|
|
594
|
+
return "AMOUNT_NOT_DETERMINABLE";
|
|
595
|
+
}
|
|
596
|
+
return REASON_BY_OPERAND[leftOperand] ?? `CONSTRAINT_FAILED:${leftOperand}`;
|
|
412
597
|
}
|
|
413
598
|
function constraintSatisfied(c, req, strict) {
|
|
414
599
|
const op = OPERATORS[c.operator];
|
|
@@ -464,7 +649,7 @@ function evaluateMandate(mandate, req) {
|
|
|
464
649
|
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
465
650
|
return {
|
|
466
651
|
decision: firstFail?.onFail ?? "block",
|
|
467
|
-
reasonCode: reasonFor(firstFail),
|
|
652
|
+
reasonCode: reasonFor(firstFail, req),
|
|
468
653
|
matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
|
|
469
654
|
};
|
|
470
655
|
}
|
|
@@ -516,6 +701,9 @@ function escapeField(v) {
|
|
|
516
701
|
function buildAuthMessage(f) {
|
|
517
702
|
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
518
703
|
}
|
|
704
|
+
function buildLegacyAuthMessageV1(f) {
|
|
705
|
+
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
706
|
+
}
|
|
519
707
|
function buildLocalDecisionMessage(f) {
|
|
520
708
|
return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
521
709
|
}
|
|
@@ -569,11 +757,17 @@ function operatingModeGate(mode, ctx) {
|
|
|
569
757
|
}
|
|
570
758
|
}
|
|
571
759
|
export {
|
|
760
|
+
ATOM_DEFAULT_REQUIRED_CONTEXT,
|
|
572
761
|
ATOM_REGISTRY,
|
|
573
762
|
ATOM_SPECS,
|
|
574
763
|
CATALOGUED_ATOMS,
|
|
764
|
+
CONTEXT_UNVERIFIABLE,
|
|
575
765
|
MODES_BY_RANK,
|
|
576
766
|
MODE_RANK,
|
|
767
|
+
PROVENANCE_KEY,
|
|
768
|
+
PROVENANCE_LEVELS,
|
|
769
|
+
PROVENANCE_RANK,
|
|
770
|
+
RISK_LEVELS,
|
|
577
771
|
SUPERVISED_AMOUNT_CAP,
|
|
578
772
|
applyCapture,
|
|
579
773
|
applyHold,
|
|
@@ -582,20 +776,32 @@ export {
|
|
|
582
776
|
authorityFailure,
|
|
583
777
|
buildAuthMessage,
|
|
584
778
|
buildCheckpointAnchorMessage,
|
|
779
|
+
buildLegacyAuthMessageV1,
|
|
585
780
|
buildLocalDecisionMessage,
|
|
781
|
+
buildRuleContext,
|
|
586
782
|
canAuthorize,
|
|
783
|
+
contextFieldProblem,
|
|
587
784
|
evaluate,
|
|
588
785
|
evaluateBoundStandards,
|
|
589
786
|
evaluateMandate,
|
|
590
787
|
evaluateStandardRules,
|
|
591
788
|
isAuthorityFailure,
|
|
592
789
|
isOperatingMode,
|
|
790
|
+
isProvenance,
|
|
791
|
+
maxRisk,
|
|
792
|
+
meetsProvenance,
|
|
593
793
|
moleculeFires,
|
|
794
|
+
moleculeUnverifiable,
|
|
594
795
|
moreRestrictive,
|
|
796
|
+
normalizeRiskLevel,
|
|
595
797
|
operatingModeGate,
|
|
798
|
+
provenanceOf,
|
|
596
799
|
releaseHold,
|
|
597
800
|
remainingBudget,
|
|
598
801
|
requiredContextFor,
|
|
802
|
+
requiredContextOf,
|
|
803
|
+
requiresPayloadBindingFor,
|
|
804
|
+
riskFloorFor,
|
|
599
805
|
sumEventField,
|
|
600
806
|
validateMolecules
|
|
601
807
|
};
|