@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 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 eight-operation signing-socket protocol (`sign-authorize`, `sign-envelope`,
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.13.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 have = RISK_RANK[String(c.riskLevel)];
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
- if (moleculeFires(m, ctx)) {
311
- if (!best || PRECEDENCE[m.decision] > PRECEDENCE[best.decision]) {
312
- best = { decision: m.decision, reasonCode: m.reasonCode, id: m.id };
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 { decision: best.decision, reasonCode: best.reasonCode, firedMoleculeId: best.id, standardKey };
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
- function reasonFor(constraint) {
589
+ var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
590
+ function reasonFor(constraint, req) {
410
591
  if (!constraint) return "CONSTRAINT_FAILED";
411
- return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
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
  };