@metamynd/agentsafe-signer 0.13.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 +382 -0
- package/cli.mjs +213 -0
- package/daemon-client.mjs +97 -0
- package/daemon.mjs +558 -0
- package/governance-envelope.mjs +69 -0
- package/kek-backends.mjs +192 -0
- package/keystore.mjs +108 -0
- package/log-anchor.mjs +112 -0
- package/log-checkpoint.mjs +191 -0
- package/merkle.mjs +68 -0
- package/migrate.mjs +120 -0
- package/package.json +47 -0
- package/policy-core.mjs +601 -0
- package/secure-memory.mjs +38 -0
- package/service-installer.mjs +244 -0
- package/windows-secure-pipe.mjs +367 -0
package/policy-core.mjs
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
// GENERATED from backend/src/policy-core — do not edit. Regenerate: npm run build:signer-core
|
|
2
|
+
|
|
3
|
+
// src/policy-core/atom-registry.ts
|
|
4
|
+
var RISK_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
5
|
+
function currencyOutOfScope(ctx, cfgCurrency) {
|
|
6
|
+
if (cfgCurrency === void 0 || cfgCurrency === null) return false;
|
|
7
|
+
const allowed = Array.isArray(cfgCurrency) ? cfgCurrency : [cfgCurrency];
|
|
8
|
+
if (allowed.length === 0) return false;
|
|
9
|
+
const currency = ctx.currency;
|
|
10
|
+
const matches = typeof currency === "string" && allowed.some((u) => typeof u === "string" && u.toUpperCase() === currency.toUpperCase());
|
|
11
|
+
return !matches;
|
|
12
|
+
}
|
|
13
|
+
var ATOM_REGISTRY = {
|
|
14
|
+
"data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
|
|
15
|
+
"consent-missing": (c) => c.consent === false,
|
|
16
|
+
"risk-at-or-above": (c, cfg) => {
|
|
17
|
+
const have = RISK_RANK[String(c.riskLevel)];
|
|
18
|
+
const need = RISK_RANK[String(cfg?.level ?? "high")];
|
|
19
|
+
return have !== void 0 && need !== void 0 && have >= need;
|
|
20
|
+
},
|
|
21
|
+
"amount-over": (c, cfg) => {
|
|
22
|
+
if (typeof c.amount !== "number") return false;
|
|
23
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
24
|
+
return c.amount > Number(cfg?.limit ?? 0);
|
|
25
|
+
},
|
|
26
|
+
// Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
|
|
27
|
+
// evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
|
|
28
|
+
// the context carries no usable amount, or one that cannot be trusted for capping —
|
|
29
|
+
// the gate cannot tell how much value the call would move, so a spend cap authored
|
|
30
|
+
// next to it would silently never fire. `amount-over` only ever fires on `> limit`,
|
|
31
|
+
// so a negative amount clears every positive cap by construction, and on a system
|
|
32
|
+
// that tracks committed spend ADDITIVELY (reserved += amount), a negative claim can
|
|
33
|
+
// net-reduce what's already committed rather than add to it — the same "cap never
|
|
34
|
+
// fires" failure as a missing amount, reached from the other side of zero. Zero
|
|
35
|
+
// itself is NOT covered here: a genuine $0 action (a read, a no-op) is a valid,
|
|
36
|
+
// known amount, not an unknown one. Author this with BLOCK as the FIRST rule of a
|
|
37
|
+
// spend policy; the cap that follows then only ever judges a known, non-negative
|
|
38
|
+
// number. Opt-in: only a rule that keys it runs it, so actions that carry no amount
|
|
39
|
+
// by nature are unaffected. The public authorize endpoint's own schema already
|
|
40
|
+
// rejects a negative amount before it reaches this atom (defense in depth, not the
|
|
41
|
+
// only layer) — this is what closes the same gap for paths that schema doesn't
|
|
42
|
+
// cover: the local/harness evaluator and the platform's own MCP tool policies.
|
|
43
|
+
"amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
|
|
44
|
+
// Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
|
|
45
|
+
// shadowable by the agent's itinerary), so this compares already-spent + this amount.
|
|
46
|
+
// See `currencyOutOfScope` above: a configured currency scope that this request's
|
|
47
|
+
// currency doesn't match fires the cap outright, same fail-closed reasoning as `amount-over`.
|
|
48
|
+
"cumulative-over": (c, cfg) => {
|
|
49
|
+
if (currencyOutOfScope(c, cfg?.currency)) return true;
|
|
50
|
+
return Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0);
|
|
51
|
+
},
|
|
52
|
+
// Fires if any configured term appears in the prompt and/or output text.
|
|
53
|
+
// Used to govern agent responses on content (prohibited claims, sensitive advice).
|
|
54
|
+
"text-matches": (c, cfg) => {
|
|
55
|
+
const hay = `${c.prompt ?? ""}
|
|
56
|
+
${c.output ?? ""}`.toLowerCase();
|
|
57
|
+
const terms = (cfg?.terms ?? []).map((t) => String(t).toLowerCase());
|
|
58
|
+
return terms.some((t) => t.length > 0 && hay.includes(t));
|
|
59
|
+
},
|
|
60
|
+
// --- Compliance atoms. Allow-list atoms fire when the context field is PRESENT
|
|
61
|
+
// and NOT allowed (consistent with data-source-not-approved: a missing field
|
|
62
|
+
// does not fire — the atom's requiredContext documents what to supply). ---
|
|
63
|
+
"jurisdiction-not-allowed": (c, cfg) => notInAllowList(c.jurisdiction, cfg?.allowed),
|
|
64
|
+
"data-residency-violation": (c, cfg) => notInAllowList(c.dataResidency, cfg?.allowedRegions),
|
|
65
|
+
"model-not-allowed": (c, cfg) => notInAllowList(c.model, cfg?.allowed),
|
|
66
|
+
"tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
|
|
67
|
+
"pii-present": (c) => c.piiPresent === true,
|
|
68
|
+
"rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0),
|
|
69
|
+
// --- Evidence-quality atoms (SAFR §24). Unlike the allow-list atoms, these fire on ABSENCE:
|
|
70
|
+
// a REQUIRE semantic — "the action must be backed by this evidence; if it isn't, fire"
|
|
71
|
+
// (author with escalate/block). Opt-in: they only run when a rule keys them. ---
|
|
72
|
+
// Fires when any REQUIRED evidence type is not among the attested `evidenceTypes` (missing
|
|
73
|
+
// evidence — including none supplied at all → all required missing → fires).
|
|
74
|
+
"evidence-requirement": (c, cfg) => {
|
|
75
|
+
const required = (cfg?.required ?? []).map((t) => String(t).toLowerCase().trim()).filter(Boolean);
|
|
76
|
+
if (required.length === 0) return false;
|
|
77
|
+
const have = new Set((Array.isArray(c.evidenceTypes) ? c.evidenceTypes : []).map((t) => String(t).toLowerCase().trim()));
|
|
78
|
+
return required.some((r) => !have.has(r));
|
|
79
|
+
},
|
|
80
|
+
// Fires when a required minimum confidence (min > 0) is not met — the attested confidence is
|
|
81
|
+
// below it, or absent (a required confidence that was never supplied fails the bar). A min of
|
|
82
|
+
// 0 / unset is no requirement and never fires.
|
|
83
|
+
"evidence-confidence-below": (c, cfg) => {
|
|
84
|
+
const min = Number(cfg?.min ?? 0);
|
|
85
|
+
if (!(min > 0)) return false;
|
|
86
|
+
return typeof c.evidenceConfidence !== "number" || c.evidenceConfidence < min;
|
|
87
|
+
},
|
|
88
|
+
// Trust guidance (MetaMynd Trust Index / HCS-28). Fires when the counterparty's trust score is
|
|
89
|
+
// below a soft REVIEW line — intended to author an ESCALATE (route to a human), NOT a hard block.
|
|
90
|
+
// The score is server-derived (signed-last) so the agent's itinerary can't fake it; when no score
|
|
91
|
+
// is present (e.g. no counterparty resolved) the atom simply does not fire — no guidance.
|
|
92
|
+
"hol-trust-below-review": (c, cfg) => typeof c.holTrustScore === "number" && c.holTrustScore < Number(cfg?.reviewBelow ?? 60)
|
|
93
|
+
};
|
|
94
|
+
function notInAllowList(value, allowList) {
|
|
95
|
+
const v = value != null ? String(value).toLowerCase().trim() : "";
|
|
96
|
+
if (v === "") return false;
|
|
97
|
+
const allowed = (Array.isArray(allowList) ? allowList : []).map((x) => String(x).toLowerCase().trim());
|
|
98
|
+
return !allowed.includes(v);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/policy-core/atom-catalog.ts
|
|
102
|
+
var ATOM_SPECS = [
|
|
103
|
+
{
|
|
104
|
+
predicate: "amount-over",
|
|
105
|
+
label: "Per-transaction amount over limit",
|
|
106
|
+
description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
|
|
107
|
+
config: [
|
|
108
|
+
{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" },
|
|
109
|
+
{
|
|
110
|
+
key: "currency",
|
|
111
|
+
type: "string[]",
|
|
112
|
+
required: false,
|
|
113
|
+
description: `Optional currency scope for the limit (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep the limit currency-blind \u2014 the historical default: the raw number is compared regardless of currency. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount (unverifiable is treated as unsafe, not as "smaller"), so the cap can't be cleared by naming a cheaper-looking currency (e.g. 200 JPY vs 200 USD).`
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
// `currency` is NOT listed here even though the executable atom conditionally reads it:
|
|
117
|
+
// unlike `limit`, the `currency` config is OPTIONAL per atom instance, so whether an agent
|
|
118
|
+
// needs to supply it depends on how a given molecule configures this atom — something
|
|
119
|
+
// `requiredContextFor`'s per-predicate (not per-instance) model can't express. Every
|
|
120
|
+
// authorize request already carries `currency` unconditionally regardless (see
|
|
121
|
+
// AuthorizeInput), so nothing is actually left unfed by omitting it here — this only
|
|
122
|
+
// controls the Scenario Bank simulate form / docs "context contract" surfacing, and
|
|
123
|
+
// forcing it onto every amount-over molecule would spuriously mark scenarios that never
|
|
124
|
+
// configure a currency scope as unexercised (see cumulative-over-atom.test.ts's sibling
|
|
125
|
+
// comment below for the same reasoning applied there).
|
|
126
|
+
requiredContext: ["amount"]
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
predicate: "amount-unknown",
|
|
130
|
+
label: "Amount not determinable",
|
|
131
|
+
description: "Fires when the action carries no usable amount, or a NEGATIVE one \u2014 the gate cannot trust either for capping. A deny-by-default control for value-moving actions: author it with BLOCK ahead of a spend cap, otherwise an amount that is missing, unparseable, or negative passes the cap untested (amount-over only ever fires above the limit, so a negative amount clears every positive cap). A genuine $0 amount does NOT fire this \u2014 only attach it to actions that must always carry a real, non-negative amount.",
|
|
132
|
+
config: [],
|
|
133
|
+
requiredContext: ["amount"]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
predicate: "cumulative-over",
|
|
137
|
+
label: "Total budget over limit",
|
|
138
|
+
description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
|
|
139
|
+
config: [
|
|
140
|
+
{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" },
|
|
141
|
+
{
|
|
142
|
+
key: "currency",
|
|
143
|
+
type: "string[]",
|
|
144
|
+
required: false,
|
|
145
|
+
description: "Optional currency scope for the budget (e.g. ['USD'], or ['USD','GBP'] for several). Leave empty to keep it currency-blind \u2014 the historical default. Once set, a request in a currency outside this list \u2014 or with none supplied at all \u2014 fires this atom regardless of amount, same fail-closed design as amount-over's currency scope."
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
// The executable atom (atom-registry.ts) reads BOTH fields: `cumulativeSpend + amount >
|
|
149
|
+
// limit`. Omitting `cumulativeSpend` here silently broke two downstream consumers this
|
|
150
|
+
// catalog is the single source of truth for (see file header): the Scenario Bank's
|
|
151
|
+
// simulate form never rendered an "already spent" field for any set using this atom —
|
|
152
|
+
// including its own seeded preset, which supplied `cumulativeSpend` for a form field
|
|
153
|
+
// that didn't exist — so the control could never actually be exercised from the UI; and
|
|
154
|
+
// the integration docs' generated "context contract" told real SDK integrators this
|
|
155
|
+
// atom only needs `amount`, so an agent that never sends `cumulativeSpend` gets it
|
|
156
|
+
// silently treated as 0 and the total-budget cap never fires in production either.
|
|
157
|
+
//
|
|
158
|
+
// `currency`, by contrast, is deliberately NOT added here even though the executable atom
|
|
159
|
+
// conditionally reads it — see the sibling comment on `amount-over`'s currency config
|
|
160
|
+
// above: it is optional PER ATOM INSTANCE (only read when a molecule configures a
|
|
161
|
+
// currency scope), so unlike `cumulativeSpend` (always read), a static per-predicate
|
|
162
|
+
// requiredContext can't represent it without forcing every set using this atom to demand
|
|
163
|
+
// a currency it may never need.
|
|
164
|
+
requiredContext: ["amount", "cumulativeSpend"]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
predicate: "risk-at-or-above",
|
|
168
|
+
label: "Risk at or above level",
|
|
169
|
+
description: "Fires when the assessed risk level is at or above the configured threshold.",
|
|
170
|
+
config: [
|
|
171
|
+
{
|
|
172
|
+
key: "level",
|
|
173
|
+
type: "enum",
|
|
174
|
+
required: true,
|
|
175
|
+
description: "Threshold risk level",
|
|
176
|
+
options: ["low", "medium", "high", "critical"]
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
requiredContext: ["riskLevel"]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
predicate: "data-source-not-approved",
|
|
183
|
+
label: "Data source not approved",
|
|
184
|
+
description: "Fires when the action uses a data source not on the approved list.",
|
|
185
|
+
config: [
|
|
186
|
+
{ key: "approved", type: "string[]", required: true, description: "Allow-list of approved data source ids" }
|
|
187
|
+
],
|
|
188
|
+
requiredContext: ["dataSourceId"]
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
predicate: "consent-missing",
|
|
192
|
+
label: "Consent missing",
|
|
193
|
+
description: "Fires when explicit consent is absent for the action.",
|
|
194
|
+
config: [],
|
|
195
|
+
requiredContext: ["consent"]
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
predicate: "text-matches",
|
|
199
|
+
label: "Text contains prohibited terms",
|
|
200
|
+
description: "Fires when the prompt or output contains any of the configured terms.",
|
|
201
|
+
config: [{ key: "terms", type: "string[]", required: true, description: "Terms that must not appear" }],
|
|
202
|
+
requiredContext: ["prompt", "output"]
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
predicate: "jurisdiction-not-allowed",
|
|
206
|
+
label: "Jurisdiction not allowed",
|
|
207
|
+
description: "Fires when the action's jurisdiction is not on the allow-list.",
|
|
208
|
+
config: [{ key: "allowed", type: "string[]", required: true, description: "Allowed jurisdictions (e.g. US, MY, EU)" }],
|
|
209
|
+
requiredContext: ["jurisdiction"]
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
predicate: "data-residency-violation",
|
|
213
|
+
label: "Data residency violation",
|
|
214
|
+
description: "Fires when data would be processed in a region not on the allow-list.",
|
|
215
|
+
config: [{ key: "allowedRegions", type: "string[]", required: true, description: "Allowed processing regions" }],
|
|
216
|
+
requiredContext: ["dataResidency"]
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
predicate: "model-not-allowed",
|
|
220
|
+
label: "LLM model not allowed",
|
|
221
|
+
description: "Fires when the agent uses an LLM model not on the approved list.",
|
|
222
|
+
config: [{ key: "allowed", type: "string[]", required: true, description: "Approved model ids" }],
|
|
223
|
+
requiredContext: ["model"]
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
predicate: "tool-not-allowed",
|
|
227
|
+
label: "Tool not allowed",
|
|
228
|
+
description: "Fires when the agent invokes a tool/function not on the approved list.",
|
|
229
|
+
config: [{ key: "allowed", type: "string[]", required: true, description: "Approved tool names" }],
|
|
230
|
+
requiredContext: ["tool"]
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
predicate: "pii-present",
|
|
234
|
+
label: "PII present",
|
|
235
|
+
description: "Fires when the action is flagged as involving personal data (PII).",
|
|
236
|
+
config: [],
|
|
237
|
+
requiredContext: ["piiPresent"]
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
predicate: "rate-limit-exceeded",
|
|
241
|
+
label: "Rate limit exceeded",
|
|
242
|
+
description: "Fires when the rolling call count exceeds a configured maximum.",
|
|
243
|
+
config: [{ key: "max", type: "number", required: true, description: "Maximum allowed calls" }],
|
|
244
|
+
requiredContext: ["callCount"]
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
predicate: "hol-trust-below-review",
|
|
248
|
+
label: "Counterparty trust below review line",
|
|
249
|
+
description: "Routes to human review when the counterparty's MetaMynd Trust Index (HCS-28) score is below a soft review line. Guidance, not a hard block \u2014 author it with an ESCALATE decision. The score is resolved server-side; no counterparty score \u2192 the atom does not fire.",
|
|
250
|
+
config: [{ key: "reviewBelow", type: "number", required: true, description: "Trust score (0\u2013100) below which a human is asked to decide" }],
|
|
251
|
+
requiredContext: ["holTrustScore"]
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
predicate: "evidence-requirement",
|
|
255
|
+
label: "Required evidence missing",
|
|
256
|
+
description: "Fires when the action is not backed by every REQUIRED evidence type the agent attests to in `evidenceTypes` (missing evidence \u2014 including none supplied). A REQUIRE control (SAFR \xA724): author it with ESCALATE or BLOCK so an under-evidenced action is stopped or reviewed.",
|
|
257
|
+
config: [{ key: "required", type: "string[]", required: true, description: "Evidence types that must all be present (e.g. kyc, source-doc, signature)" }],
|
|
258
|
+
requiredContext: ["evidenceTypes"]
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
predicate: "evidence-confidence-below",
|
|
262
|
+
label: "Evidence confidence below minimum",
|
|
263
|
+
description: "Fires when the attested evidence confidence is below a required minimum \u2014 or absent (SAFR \xA724). A min of 0 / unset is no requirement. Author with ESCALATE to route low-confidence actions to review.",
|
|
264
|
+
config: [{ key: "min", type: "number", required: true, description: "Minimum evidence confidence (0\u20131) required" }],
|
|
265
|
+
requiredContext: ["evidenceConfidence"]
|
|
266
|
+
}
|
|
267
|
+
];
|
|
268
|
+
var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
|
|
269
|
+
function requiredContextFor(predicates) {
|
|
270
|
+
const fields = /* @__PURE__ */ new Set();
|
|
271
|
+
for (const p of predicates) {
|
|
272
|
+
const spec = ATOM_SPECS.find((s) => s.predicate === p);
|
|
273
|
+
for (const f of spec?.requiredContext ?? []) fields.add(f);
|
|
274
|
+
}
|
|
275
|
+
return [...fields].sort();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/policy-core/standards-rules.ts
|
|
279
|
+
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
280
|
+
function atomFires(atom, ctx) {
|
|
281
|
+
const pred = ATOM_REGISTRY[atom.predicate];
|
|
282
|
+
if (!pred) return false;
|
|
283
|
+
try {
|
|
284
|
+
return !!pred(ctx, atom.config);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.warn(
|
|
287
|
+
`[standards] atom '${atom.predicate}' threw during evaluation (treated as not-firing):`,
|
|
288
|
+
err instanceof Error ? err.message : err
|
|
289
|
+
);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function moleculeFires(m, ctx) {
|
|
294
|
+
if (!m.atoms || m.atoms.length === 0) return false;
|
|
295
|
+
const results = m.atoms.map((a) => atomFires(a, ctx));
|
|
296
|
+
switch (m.combinator) {
|
|
297
|
+
case "all":
|
|
298
|
+
return results.every(Boolean);
|
|
299
|
+
case "any":
|
|
300
|
+
return results.some(Boolean);
|
|
301
|
+
case "none":
|
|
302
|
+
return !results.some(Boolean);
|
|
303
|
+
default:
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function evaluateStandardRules(molecules, ctx, standardKey = null) {
|
|
308
|
+
let best = null;
|
|
309
|
+
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
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!best) return { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey };
|
|
317
|
+
return { decision: best.decision, reasonCode: best.reasonCode, firedMoleculeId: best.id, standardKey };
|
|
318
|
+
}
|
|
319
|
+
function evaluateBoundStandards(standards, ctx) {
|
|
320
|
+
let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
|
|
321
|
+
for (const s of standards) {
|
|
322
|
+
const r = evaluateStandardRules(s.document?.molecules, ctx, s.standardKey);
|
|
323
|
+
if (PRECEDENCE[r.decision] > PRECEDENCE[best.decision]) best = r;
|
|
324
|
+
}
|
|
325
|
+
return best;
|
|
326
|
+
}
|
|
327
|
+
function configValueValid(field, value) {
|
|
328
|
+
switch (field.type) {
|
|
329
|
+
case "number":
|
|
330
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
331
|
+
case "string":
|
|
332
|
+
return typeof value === "string";
|
|
333
|
+
case "string[]":
|
|
334
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
335
|
+
case "enum":
|
|
336
|
+
return typeof value === "string" && (field.options ?? []).includes(value);
|
|
337
|
+
default:
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function validateAtomConfig(predicate, config) {
|
|
342
|
+
const spec = ATOM_SPECS.find((s) => s.predicate === predicate);
|
|
343
|
+
if (!spec) return [];
|
|
344
|
+
const errors = [];
|
|
345
|
+
const cfg = config ?? {};
|
|
346
|
+
for (const field of spec.config) {
|
|
347
|
+
const present = cfg[field.key] !== void 0 && cfg[field.key] !== null;
|
|
348
|
+
if (!present) {
|
|
349
|
+
if (field.required) errors.push(`atom '${predicate}' missing required config '${field.key}'`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!configValueValid(field, cfg[field.key])) {
|
|
353
|
+
errors.push(`atom '${predicate}' config '${field.key}' must be a ${field.type}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return errors;
|
|
357
|
+
}
|
|
358
|
+
function validateMolecules(molecules) {
|
|
359
|
+
const issues = [];
|
|
360
|
+
for (const m of molecules ?? []) {
|
|
361
|
+
if (!m.id) issues.push({ moleculeId: "(missing id)", message: "molecule is missing an id" });
|
|
362
|
+
if (!["all", "any", "none"].includes(m.combinator)) {
|
|
363
|
+
issues.push({ moleculeId: m.id, message: `invalid combinator '${m.combinator}' (all|any|none)` });
|
|
364
|
+
}
|
|
365
|
+
if (!["observe", "block", "escalate", "suspend", "quarantine"].includes(m.decision)) {
|
|
366
|
+
issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (observe|block|escalate|suspend|quarantine)` });
|
|
367
|
+
}
|
|
368
|
+
if (!m.reasonCode) issues.push({ moleculeId: m.id, message: "molecule is missing a reasonCode" });
|
|
369
|
+
if (!m.atoms || m.atoms.length === 0) {
|
|
370
|
+
issues.push({ moleculeId: m.id, message: "molecule has no atoms" });
|
|
371
|
+
}
|
|
372
|
+
for (const a of m.atoms ?? []) {
|
|
373
|
+
if (!ATOM_REGISTRY[a.predicate]) {
|
|
374
|
+
issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
for (const err of validateAtomConfig(a.predicate, a.config)) {
|
|
378
|
+
issues.push({ moleculeId: m.id, message: err });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { ok: issues.length === 0, issues };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/policy-core/mandate-eval.ts
|
|
386
|
+
var toNum = (v) => typeof v === "number" ? v : Number(v);
|
|
387
|
+
var toArray = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
|
|
388
|
+
var toTime = (v) => Date.parse(String(v));
|
|
389
|
+
var OPERATORS = {
|
|
390
|
+
eq: (l, r) => l === r,
|
|
391
|
+
neq: (l, r) => l !== r,
|
|
392
|
+
lt: (l, r) => toNum(l) < toNum(r),
|
|
393
|
+
lteq: (l, r) => toNum(l) <= toNum(r),
|
|
394
|
+
gt: (l, r) => toNum(l) > toNum(r),
|
|
395
|
+
gteq: (l, r) => toNum(l) >= toNum(r),
|
|
396
|
+
isAnyOf: (l, r) => toArray(r).includes(l),
|
|
397
|
+
isNoneOf: (l, r) => !toArray(r).includes(l),
|
|
398
|
+
isPartOf: (l, r) => toArray(r).includes(l),
|
|
399
|
+
before: (l, r) => toTime(l) < toTime(r),
|
|
400
|
+
after: (l, r) => toTime(l) > toTime(r)
|
|
401
|
+
};
|
|
402
|
+
var REASON_BY_OPERAND = {
|
|
403
|
+
"mm:payAmount": "SPEND_LIMIT_EXCEEDED",
|
|
404
|
+
"mm:cumulativeSpend": "SPEND_LIMIT_EXCEEDED",
|
|
405
|
+
"mm:merchant": "MERCHANT_NOT_ALLOWED",
|
|
406
|
+
"mm:route": "ROUTE_NOT_ALLOWED",
|
|
407
|
+
"mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
|
|
408
|
+
};
|
|
409
|
+
function reasonFor(constraint) {
|
|
410
|
+
if (!constraint) return "CONSTRAINT_FAILED";
|
|
411
|
+
return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
|
|
412
|
+
}
|
|
413
|
+
function constraintSatisfied(c, req, strict) {
|
|
414
|
+
const op = OPERATORS[c.operator];
|
|
415
|
+
if (!op) return false;
|
|
416
|
+
const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
|
|
417
|
+
if (!c.unit) return op(left, c.rightOperand);
|
|
418
|
+
const currency = req.values["mm:currency"];
|
|
419
|
+
const allowedUnits = Array.isArray(c.unit) ? c.unit : [c.unit];
|
|
420
|
+
const unitMatches = typeof currency === "string" && allowedUnits.some((u) => u.toUpperCase() === currency.toUpperCase());
|
|
421
|
+
return unitMatches ? op(left, c.rightOperand) : !strict;
|
|
422
|
+
}
|
|
423
|
+
function targetOf(rule, mandate) {
|
|
424
|
+
return rule.target ?? mandate.target;
|
|
425
|
+
}
|
|
426
|
+
function isAuthorityFailure(result) {
|
|
427
|
+
return result.matched?.kind === "expiry" || result.matched?.kind === "no-permission";
|
|
428
|
+
}
|
|
429
|
+
function authorityFailure(mandate, target, now) {
|
|
430
|
+
const result = evaluateMandate(mandate, { target, now, values: {} });
|
|
431
|
+
return isAuthorityFailure(result) ? { ...result, decision: "block" } : null;
|
|
432
|
+
}
|
|
433
|
+
function evaluateMandate(mandate, req) {
|
|
434
|
+
const now = toTime(req.now);
|
|
435
|
+
if (mandate.validFrom && now < toTime(mandate.validFrom)) {
|
|
436
|
+
return { decision: "block", reasonCode: "MANDATE_NOT_YET_VALID", matched: { kind: "expiry" } };
|
|
437
|
+
}
|
|
438
|
+
if (mandate.validUntil && now > toTime(mandate.validUntil)) {
|
|
439
|
+
return { decision: "block", reasonCode: "MANDATE_EXPIRED", matched: { kind: "expiry" } };
|
|
440
|
+
}
|
|
441
|
+
for (const p of mandate.prohibition ?? []) {
|
|
442
|
+
if (targetOf(p, mandate) !== req.target) continue;
|
|
443
|
+
const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req, false));
|
|
444
|
+
if (fires) {
|
|
445
|
+
return {
|
|
446
|
+
decision: p.enforcement ?? "block",
|
|
447
|
+
reasonCode: p.reasonCode ?? "PROHIBITED",
|
|
448
|
+
matched: { kind: "prohibition", target: p.target }
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const perms = (mandate.permission ?? []).filter((p) => targetOf(p, mandate) === req.target);
|
|
453
|
+
if (perms.length === 0) {
|
|
454
|
+
return {
|
|
455
|
+
decision: "block",
|
|
456
|
+
reasonCode: "NO_PERMISSION_FOR_ACTION",
|
|
457
|
+
matched: { kind: "no-permission", target: req.target }
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
for (const p of perms) {
|
|
461
|
+
const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
462
|
+
if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
|
|
463
|
+
}
|
|
464
|
+
const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
465
|
+
return {
|
|
466
|
+
decision: firstFail?.onFail ?? "block",
|
|
467
|
+
reasonCode: reasonFor(firstFail),
|
|
468
|
+
matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function remainingBudget(b) {
|
|
472
|
+
return Math.max(0, b.cap - b.spent - b.held);
|
|
473
|
+
}
|
|
474
|
+
function canAuthorize(b, amount) {
|
|
475
|
+
return amount >= 0 && amount <= remainingBudget(b);
|
|
476
|
+
}
|
|
477
|
+
function applyHold(b, amount) {
|
|
478
|
+
return { ...b, held: b.held + amount };
|
|
479
|
+
}
|
|
480
|
+
function applyCapture(b, amount) {
|
|
481
|
+
return { cap: b.cap, spent: b.spent + amount, held: Math.max(0, b.held - amount) };
|
|
482
|
+
}
|
|
483
|
+
function releaseHold(b, amount) {
|
|
484
|
+
return { ...b, held: Math.max(0, b.held - amount) };
|
|
485
|
+
}
|
|
486
|
+
function sumEventField(events, type, field) {
|
|
487
|
+
return events.filter((e) => e.type === type).reduce((acc, e) => acc + (typeof e.payload[field] === "number" ? e.payload[field] : 0), 0);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// src/policy-core/evaluate.ts
|
|
491
|
+
var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
492
|
+
function evaluate(input) {
|
|
493
|
+
let decision = "allow";
|
|
494
|
+
let reasonCode = "AUTHORIZED";
|
|
495
|
+
const consider = (d, code) => {
|
|
496
|
+
if (PRECEDENCE2[d] > PRECEDENCE2[decision]) {
|
|
497
|
+
decision = d;
|
|
498
|
+
reasonCode = code;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const m = input.mandate && input.mandateRequest ? evaluateMandate(input.mandate, input.mandateRequest) : null;
|
|
502
|
+
const authority = m !== null && isAuthorityFailure(m);
|
|
503
|
+
if (m && authority) consider(m.decision, m.reasonCode);
|
|
504
|
+
const std = evaluateBoundStandards(input.standards ?? [], input.context);
|
|
505
|
+
if (std.decision !== "allow") consider(std.decision, std.reasonCode ?? "STANDARD_RULE");
|
|
506
|
+
const sop = evaluateBoundStandards(input.sops ?? [], input.context);
|
|
507
|
+
if (sop.decision !== "allow") consider(sop.decision, sop.reasonCode ?? "SOP_RULE");
|
|
508
|
+
if (m && !authority && m.decision !== "allow") consider(m.decision, m.reasonCode);
|
|
509
|
+
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/policy-core/canonical.ts
|
|
513
|
+
function escapeField(v) {
|
|
514
|
+
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
515
|
+
}
|
|
516
|
+
function buildAuthMessage(f) {
|
|
517
|
+
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
518
|
+
}
|
|
519
|
+
function buildLocalDecisionMessage(f) {
|
|
520
|
+
return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/policy-core/checkpoint-anchor.ts
|
|
524
|
+
function buildCheckpointAnchorMessage(f) {
|
|
525
|
+
return [f.agentDid, f.checkpointHash, f.previousCheckpointHash, f.entryCount, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/policy-core/context.ts
|
|
529
|
+
function applySignedLast(unsigned, signed) {
|
|
530
|
+
return { ...unsigned ?? {}, ...signed };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/policy-core/operating-mode.ts
|
|
534
|
+
var MODE_RANK = {
|
|
535
|
+
read_only: 0,
|
|
536
|
+
restricted: 1,
|
|
537
|
+
supervised: 2,
|
|
538
|
+
autonomous: 3
|
|
539
|
+
};
|
|
540
|
+
var MODES_BY_RANK = ["read_only", "restricted", "supervised", "autonomous"];
|
|
541
|
+
function isOperatingMode(v) {
|
|
542
|
+
return typeof v === "string" && Object.prototype.hasOwnProperty.call(MODE_RANK, v);
|
|
543
|
+
}
|
|
544
|
+
function asOperatingMode(v) {
|
|
545
|
+
return isOperatingMode(v) ? v : "autonomous";
|
|
546
|
+
}
|
|
547
|
+
function moreRestrictive(a, b) {
|
|
548
|
+
return MODE_RANK[a] <= MODE_RANK[b] ? a : b;
|
|
549
|
+
}
|
|
550
|
+
var SUPERVISED_AMOUNT_CAP = 100;
|
|
551
|
+
var RISK_RANK2 = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
552
|
+
function riskAtOrAboveHigh(riskLevel) {
|
|
553
|
+
const r = typeof riskLevel === "string" ? RISK_RANK2[riskLevel.toLowerCase()] : void 0;
|
|
554
|
+
return r !== void 0 && r >= RISK_RANK2.high;
|
|
555
|
+
}
|
|
556
|
+
function operatingModeGate(mode, ctx) {
|
|
557
|
+
const m = asOperatingMode(mode);
|
|
558
|
+
const valueBearing = (ctx.amount ?? 0) > 0;
|
|
559
|
+
if (!valueBearing || m === "autonomous") return { decision: "allow", reasonCode: null };
|
|
560
|
+
switch (m) {
|
|
561
|
+
case "read_only":
|
|
562
|
+
return { decision: "block", reasonCode: "MODE_READ_ONLY" };
|
|
563
|
+
case "restricted":
|
|
564
|
+
return { decision: "escalate", reasonCode: "MODE_RESTRICTED_REVIEW" };
|
|
565
|
+
case "supervised":
|
|
566
|
+
return riskAtOrAboveHigh(ctx.riskLevel) || (ctx.amount ?? 0) >= SUPERVISED_AMOUNT_CAP ? { decision: "escalate", reasonCode: "MODE_SUPERVISED_REVIEW" } : { decision: "allow", reasonCode: null };
|
|
567
|
+
default:
|
|
568
|
+
return { decision: "allow", reasonCode: null };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
export {
|
|
572
|
+
ATOM_REGISTRY,
|
|
573
|
+
ATOM_SPECS,
|
|
574
|
+
CATALOGUED_ATOMS,
|
|
575
|
+
MODES_BY_RANK,
|
|
576
|
+
MODE_RANK,
|
|
577
|
+
SUPERVISED_AMOUNT_CAP,
|
|
578
|
+
applyCapture,
|
|
579
|
+
applyHold,
|
|
580
|
+
applySignedLast,
|
|
581
|
+
asOperatingMode,
|
|
582
|
+
authorityFailure,
|
|
583
|
+
buildAuthMessage,
|
|
584
|
+
buildCheckpointAnchorMessage,
|
|
585
|
+
buildLocalDecisionMessage,
|
|
586
|
+
canAuthorize,
|
|
587
|
+
evaluate,
|
|
588
|
+
evaluateBoundStandards,
|
|
589
|
+
evaluateMandate,
|
|
590
|
+
evaluateStandardRules,
|
|
591
|
+
isAuthorityFailure,
|
|
592
|
+
isOperatingMode,
|
|
593
|
+
moleculeFires,
|
|
594
|
+
moreRestrictive,
|
|
595
|
+
operatingModeGate,
|
|
596
|
+
releaseHold,
|
|
597
|
+
remainingBudget,
|
|
598
|
+
requiredContextFor,
|
|
599
|
+
sumEventField,
|
|
600
|
+
validateMolecules
|
|
601
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// secure-memory.mjs — real mlock()/VirtualLock() protection for decrypted key material, backed by
|
|
2
|
+
// libsodium (sodium-native) — the ONE dependency this package has, added specifically because this
|
|
3
|
+
// property is architecturally different from every other gap this package has closed by shelling
|
|
4
|
+
// out to an already-installed OS tool (see windows-secure-pipe.mjs, kek-backends.mjs's DPAPI path):
|
|
5
|
+
// mlock/VirtualLock must run INSIDE the process that owns the memory, unlike setrlimit/prctl-style
|
|
6
|
+
// process attributes, which are inherited across exec() and so CAN be set by an external wrapper.
|
|
7
|
+
// Plain Node has no built-in FFI, so there is no shell-out equivalent here — this needed either a
|
|
8
|
+
// native addon or an FFI dependency. sodium-native ships prebuilt binaries for every platform this
|
|
9
|
+
// project supports (win32/darwin/linux, x64/arm64 — verified via its own published tarball) — no
|
|
10
|
+
// build toolchain needed on the operator's machine, matching the ease-of-install bar every other
|
|
11
|
+
// backend in this package already holds itself to.
|
|
12
|
+
//
|
|
13
|
+
// Known, disclosed residual gap: crypto.createPrivateKey() copies the DER bytes into OpenSSL's own
|
|
14
|
+
// native EVP_PKEY structure, which lives in memory this module has no visibility into or control
|
|
15
|
+
// over. Locking the DER buffer here narrows the exposure window (and is the only buffer this
|
|
16
|
+
// package itself ever holds the key in) but cannot reach that internal OpenSSL copy — no pure-JS or
|
|
17
|
+
// FFI approach available to this package can fix that from the outside. Disclosed rather than
|
|
18
|
+
// overclaimed, matching this package's own README "Status" conventions elsewhere.
|
|
19
|
+
import sodium from 'sodium-native';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Copies `sourceBuf` into a freshly `sodium_malloc`'d buffer — guard-paged and mlocked/
|
|
23
|
+
* VirtualLocked by libsodium automatically, no separate mlock call needed — zeroes `sourceBuf`
|
|
24
|
+
* immediately (shrinking its own un-mlocked exposure window to just the copy), runs `fn(lockedBuf)`,
|
|
25
|
+
* then securely zeroes and frees the locked buffer (via sodium_free) before returning fn's result.
|
|
26
|
+
* `fn` must not retain a reference to `lockedBuf` beyond its own call — touching freed
|
|
27
|
+
* `sodium_malloc` memory is a deliberate libsodium safety abort, not a silent bug.
|
|
28
|
+
*/
|
|
29
|
+
export function withLockedCopy(sourceBuf, fn) {
|
|
30
|
+
const locked = sodium.sodium_malloc(sourceBuf.length);
|
|
31
|
+
sourceBuf.copy(locked);
|
|
32
|
+
sourceBuf.fill(0);
|
|
33
|
+
try {
|
|
34
|
+
return fn(locked);
|
|
35
|
+
} finally {
|
|
36
|
+
sodium.sodium_free(locked);
|
|
37
|
+
}
|
|
38
|
+
}
|