@metamynd/agentsafe-guard 0.6.6 → 0.8.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.
@@ -11,6 +11,7 @@
11
11
  import crypto from 'node:crypto';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
14
+ import { envelopeHashFor } from './governance-envelope.mjs';
14
15
  import { verifyDidSignature } from './magp-did.mjs';
15
16
  import { checkSettlementBinding } from './x402.mjs';
16
17
 
@@ -120,6 +121,20 @@ export function createGuard(opts = {}) {
120
121
  return crypto.sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
121
122
  }
122
123
 
124
+ // Tier 1 context-claim binding (opt-in, docs/design/context-claim-binding.md): when
125
+ // on, sign the GovernanceEnvelope hash too, so a counterparty/gate can prove the
126
+ // agent's OWN key attested to the context it submitted — not just the signed action
127
+ // subset. Off by default: a bare request stays a valid degenerate envelope, exactly
128
+ // like today, and the wire body carries no envelopeSignature field at all.
129
+ const signContext = opts.signContext ?? cfg?.signContext ?? false;
130
+ function envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }) {
131
+ if (!signContext) return undefined;
132
+ // The hash is independent of `signature` (excluded from what it commits to — see
133
+ // governance-envelope.ts), so an empty placeholder here is exact, not approximate.
134
+ const hash = envelopeHashFor({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: '' });
135
+ return sign(hash);
136
+ }
137
+
123
138
  // --- Enforcement mode (spec §9.2 + local-first plan) --------------------------------------
124
139
  // 'local' (DEFAULT): decide the rule layer LOCALLY against a cached signed bundle — a
125
140
  // block/escalate needs no network; an allowed VALUE action is still sealed by the remote
@@ -158,7 +173,11 @@ export function createGuard(opts = {}) {
158
173
  const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
159
174
  // trace/materiality are GovernanceEnvelope fields (SAFR §5) — unsigned metadata; the
160
175
  // signed message stays the action subset, so verification is unchanged.
161
- return { agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) };
176
+ return {
177
+ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt,
178
+ signature: sign(message),
179
+ envelopeSignature: envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
180
+ };
162
181
  }
163
182
 
164
183
  /**
@@ -176,9 +195,14 @@ export function createGuard(opts = {}) {
176
195
  const res = await fetch(`${base}/policy/mandate/authorize`, {
177
196
  method: 'POST',
178
197
  headers: { 'Content-Type': 'application/json' },
179
- // trace/materiality (SAFR §5 envelope) ride as unsigned metadata; JSON.stringify
180
- // drops them when undefined, so an agent that omits them sends the legacy body.
181
- body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) }),
198
+ // trace/materiality (SAFR §5 envelope) and envelopeSignature (Tier 1, opt-in) ride
199
+ // as unsigned-message metadata; JSON.stringify drops them when undefined, so an
200
+ // agent that omits them (or leaves signContext off) sends the legacy body.
201
+ body: JSON.stringify({
202
+ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt,
203
+ signature: sign(message),
204
+ envelopeSignature: envelopeSignatureFor({ action, amount, currency, merchant, context, trace, materiality, nonce, issuedAt }),
205
+ }),
182
206
  });
183
207
  const body = await res.json().catch(() => null);
184
208
  return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
@@ -0,0 +1,69 @@
1
+ // GENERATED from backend/src/features/policy/mandate/governance-envelope.ts — do not edit. Regenerate: npm run build:guard-core
2
+
3
+ // src/features/policy/mandate/governance-envelope.ts
4
+ import { createHash } from "node:crypto";
5
+ var ENVELOPE_VERSION = "1.0";
6
+ function stableStringify(value) {
7
+ if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
8
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
9
+ const obj = value;
10
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
11
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
12
+ }
13
+ function envelopeIntegrityHash(env) {
14
+ const { integrity: _omit, ...rest } = env;
15
+ return createHash("sha256").update(stableStringify(rest)).digest("hex");
16
+ }
17
+ function buildGovernanceEnvelope(input) {
18
+ const base = {
19
+ envelopeId: `env:${input.nonce}`,
20
+ version: ENVELOPE_VERSION,
21
+ createdAt: input.issuedAt,
22
+ agent: { did: input.agentDid },
23
+ action: {
24
+ actionId: `act:${input.nonce}`,
25
+ actionType: input.action,
26
+ amount: input.amount,
27
+ currency: input.currency,
28
+ merchant: input.merchant ?? null,
29
+ ...input.materiality ? { materiality: input.materiality } : {}
30
+ },
31
+ ...input.trace ? { trace: input.trace } : {},
32
+ ...input.itinerary ? { context: input.itinerary } : {}
33
+ };
34
+ return {
35
+ ...base,
36
+ integrity: {
37
+ payloadHash: envelopeIntegrityHash(base),
38
+ signature: input.signature,
39
+ signatureType: "Ed25519"
40
+ }
41
+ };
42
+ }
43
+ function envelopeHashFor(input) {
44
+ return buildGovernanceEnvelope(input).integrity.payloadHash;
45
+ }
46
+ function authorizeInputFromEnvelope(env) {
47
+ const nonce = env.envelopeId.startsWith("env:") ? env.envelopeId.slice(4) : env.envelopeId;
48
+ return {
49
+ agentDid: env.agent.did,
50
+ action: env.action.actionType,
51
+ amount: env.action.amount ?? 0,
52
+ currency: env.action.currency ?? "",
53
+ merchant: env.action.merchant ?? void 0,
54
+ itinerary: env.context,
55
+ trace: env.trace,
56
+ materiality: env.action.materiality,
57
+ nonce,
58
+ issuedAt: env.createdAt,
59
+ signature: env.integrity.signature ?? ""
60
+ };
61
+ }
62
+ export {
63
+ ENVELOPE_VERSION,
64
+ authorizeInputFromEnvelope,
65
+ buildGovernanceEnvelope,
66
+ envelopeHashFor,
67
+ envelopeIntegrityHash,
68
+ stableStringify
69
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-guard",
3
- "version": "0.6.6",
3
+ "version": "0.8.0",
4
4
  "description": "Zero-dependency runtime governance for any Node AI agent \u2014 gate tool calls through MetaMynd/AgentSafe (allow / block / escalate) against the agent's mandate, enforced Standards, and SOPs. Ed25519-signed, deterministic, fail-closed.",
5
5
  "type": "module",
6
6
  "main": "./agentsafe-guard.mjs",
@@ -17,6 +17,7 @@
17
17
  "verify.mjs",
18
18
  "agentsafe-guard.mjs",
19
19
  "policy-core.mjs",
20
+ "governance-envelope.mjs",
20
21
  "magp-did.mjs",
21
22
  "x402.mjs",
22
23
  "example-openclaw-agent.mjs",
@@ -24,7 +25,7 @@
24
25
  "LICENSE"
25
26
  ],
26
27
  "scripts": {
27
- "test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs",
28
+ "test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs && node verify.smoke.mjs && node context-signature.smoke.mjs",
28
29
  "demo": "node demo.mjs"
29
30
  },
30
31
  "engines": {
package/policy-core.mjs CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  // src/policy-core/atom-registry.ts
4
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
+ }
5
13
  var ATOM_REGISTRY = {
6
14
  "data-source-not-approved": (c, cfg) => !!c.dataSourceId && !(cfg?.approved ?? []).includes(String(c.dataSourceId)),
7
15
  "consent-missing": (c) => c.consent === false,
@@ -10,7 +18,11 @@ var ATOM_REGISTRY = {
10
18
  const need = RISK_RANK[String(cfg?.level ?? "high")];
11
19
  return have !== void 0 && need !== void 0 && have >= need;
12
20
  },
13
- "amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
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
+ },
14
26
  // Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
15
27
  // evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
16
28
  // the context carries no usable amount, or one that cannot be trusted for capping —
@@ -31,7 +43,12 @@ var ATOM_REGISTRY = {
31
43
  "amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
32
44
  // Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
33
45
  // shadowable by the agent's itinerary), so this compares already-spent + this amount.
34
- "cumulative-over": (c, cfg) => Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0),
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
+ },
35
52
  // Fires if any configured term appears in the prompt and/or output text.
36
53
  // Used to govern agent responses on content (prohibited claims, sensitive advice).
37
54
  "text-matches": (c, cfg) => {
@@ -87,7 +104,25 @@ var ATOM_SPECS = [
87
104
  predicate: "amount-over",
88
105
  label: "Per-transaction amount over limit",
89
106
  description: "Fires when a single action amount exceeds a configured limit (per-transaction cap).",
90
- config: [{ key: "limit", type: "number", required: true, description: "Maximum allowed amount for one transaction" }],
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).
91
126
  requiredContext: ["amount"]
92
127
  },
93
128
  {
@@ -101,8 +136,32 @@ var ATOM_SPECS = [
101
136
  predicate: "cumulative-over",
102
137
  label: "Total budget over limit",
103
138
  description: "Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget.",
104
- config: [{ key: "limit", type: "number", required: true, description: "Maximum total budget across all transactions" }],
105
- requiredContext: ["amount"]
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"]
106
165
  },
107
166
  {
108
167
  predicate: "risk-at-or-above",
@@ -357,7 +416,8 @@ function constraintSatisfied(c, req, strict) {
357
416
  const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
358
417
  if (!c.unit) return op(left, c.rightOperand);
359
418
  const currency = req.values["mm:currency"];
360
- const unitMatches = typeof currency === "string" && currency.toUpperCase() === c.unit.toUpperCase();
419
+ const allowedUnits = Array.isArray(c.unit) ? c.unit : [c.unit];
420
+ const unitMatches = typeof currency === "string" && allowedUnits.some((u) => u.toUpperCase() === currency.toUpperCase());
361
421
  return unitMatches ? op(left, c.rightOperand) : !strict;
362
422
  }
363
423
  function targetOf(rule, mandate) {