@metamynd/agentsafe-guard 0.6.1 → 0.6.4

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
@@ -84,6 +84,21 @@ payload can carry its amount somewhere a naive check never looks. `amount-unknow
84
84
  deny-by-default atom for exactly that case — an action whose value the gate can't determine
85
85
  is blocked, not silently waved through an untested cap.
86
86
 
87
+ **0.6.2 — `amount-unknown` closes negative amounts too.** A negative `amount` (e.g. `-5`)
88
+ previously read as "a real, known number" and sailed straight past `amount-unknown` — and
89
+ past a naive `amount-over` cap, since `-5 > 500` is never true. An attacker submitting a
90
+ negative value could clear a spend cap for free, or erode a cumulative-spend tracker that
91
+ sums signed amounts over time. `amount-unknown` now fires on any non-finite, non-numeric,
92
+ **or negative** amount; a genuine `0` still passes.
93
+
94
+ **0.6.4 — a mandate's currency check no longer lets a PROHIBITION be dodged by relabeling
95
+ the currency.** A payAmount/cumulativeSpend constraint issued with a `unit` (currency) is
96
+ only satisfied in that currency — correct for a PERMISSION (fail closed to deny on a
97
+ mismatch), but a prohibition only fires when every one of its own constraints is satisfied,
98
+ so the identical "mismatch → not satisfied" rule let a prohibition like `payAmount gteq 1000
99
+ unit USD` be silently skipped by declaring any other currency, including a mere case
100
+ difference (`'usd'` vs `'USD'`). The currency comparison is also now case-insensitive.
101
+
87
102
  ```yaml
88
103
  # .github/workflows/governance.yml
89
104
  name: Governance
@@ -120,7 +135,7 @@ jobs:
120
135
  runs-on: ubuntu-latest
121
136
  steps:
122
137
  - uses: actions/checkout@v5
123
- - uses: Metamynd/agentsafe-guard/packages/agentsafe-guard@v0.6.1
138
+ - uses: Metamynd/agentsafe-guard/packages/agentsafe-guard@v0.6.4
124
139
  with:
125
140
  config: ./agent.metamynd.json
126
141
  require: merchants,perTxn
@@ -226,7 +226,7 @@ export function createGuard(opts = {}) {
226
226
  const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
227
227
  return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
228
228
  }
229
- const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
229
+ const { action, amount = 0, currency = 'USD', merchant = '', context = {}, cumulativeSpend = amount, now } = request;
230
230
  // Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
231
231
  // SIBLING (like `contained`) and biases the edge verdict identically to the gate.
232
232
  // READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
@@ -251,6 +251,12 @@ export function createGuard(opts = {}) {
251
251
  'mm:payAmount': amount,
252
252
  'mm:cumulativeSpend': cumulativeSpend,
253
253
  'mm:merchant': merchant,
254
+ // A payAmount/cumulativeSpend constraint issued with a `unit` (currency) is
255
+ // only satisfied in that currency (see mandate-eval.ts's constraintSatisfied)
256
+ // — omitting this here would make EVERY unit-bearing cap fail regardless of
257
+ // amount, since undefined never equals a real unit. Defaults to 'USD' to match
258
+ // the same default this file already uses for authorize()/buildSignedRequest().
259
+ 'mm:currency': currency,
254
260
  }),
255
261
  }
256
262
  : undefined,
@@ -417,7 +423,7 @@ export function createGuard(opts = {}) {
417
423
  *
418
424
  * @param {string} action
419
425
  * @param {(args:any, decision:any)=>any} handler
420
- * @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
426
+ * @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
421
427
  * @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
422
428
  */
423
429
  function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}, toolOpts = {}) {
@@ -425,9 +431,9 @@ export function createGuard(opts = {}) {
425
431
  return async (args) => {
426
432
  let decision;
427
433
  try {
428
- const { amount, merchant, context } = mapArgs(args);
434
+ const { amount, currency, merchant, context } = mapArgs(args);
429
435
  const bundle = typeof getBundle === 'function' ? await getBundle(args) : getBundle;
430
- decision = evaluateLocally({ ...bundle, request: { action, amount, merchant, context } });
436
+ decision = evaluateLocally({ ...bundle, request: { action, amount, currency, merchant, context } });
431
437
  } catch (err) {
432
438
  decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
433
439
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-guard",
3
- "version": "0.6.1",
3
+ "version": "0.6.4",
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",
package/policy-core.mjs CHANGED
@@ -12,13 +12,23 @@ var ATOM_REGISTRY = {
12
12
  },
13
13
  "amount-over": (c, cfg) => typeof c.amount === "number" && c.amount > Number(cfg?.limit ?? 0),
14
14
  // Deny-by-default primitive for value-moving actions. Fires on ABSENCE (like the
15
- // evidence atoms below, and unlike `amount-over`): true when the context carries no
16
- // usable amount the gate cannot tell how much value the call would move, so a
17
- // spend cap authored next to it would silently never fire. Author it with BLOCK as
18
- // the FIRST rule of a spend policy; the cap that follows then only ever judges a
19
- // known number. Opt-in: only a rule that keys it runs it, so actions that carry no
20
- // amount by nature are unaffected.
21
- "amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount)),
15
+ // evidence atoms below, and unlike `amount-over`) OR on a NEGATIVE amount: true when
16
+ // the context carries no usable amount, or one that cannot be trusted for capping
17
+ // the gate cannot tell how much value the call would move, so a spend cap authored
18
+ // next to it would silently never fire. `amount-over` only ever fires on `> limit`,
19
+ // so a negative amount clears every positive cap by construction, and on a system
20
+ // that tracks committed spend ADDITIVELY (reserved += amount), a negative claim can
21
+ // net-reduce what's already committed rather than add to it — the same "cap never
22
+ // fires" failure as a missing amount, reached from the other side of zero. Zero
23
+ // itself is NOT covered here: a genuine $0 action (a read, a no-op) is a valid,
24
+ // known amount, not an unknown one. Author this with BLOCK as the FIRST rule of a
25
+ // spend policy; the cap that follows then only ever judges a known, non-negative
26
+ // number. Opt-in: only a rule that keys it runs it, so actions that carry no amount
27
+ // by nature are unaffected. The public authorize endpoint's own schema already
28
+ // rejects a negative amount before it reaches this atom (defense in depth, not the
29
+ // only layer) — this is what closes the same gap for paths that schema doesn't
30
+ // cover: the local/harness evaluator and the platform's own MCP tool policies.
31
+ "amount-unknown": (c) => !(typeof c.amount === "number" && Number.isFinite(c.amount) && c.amount >= 0),
22
32
  // Total budget: cumulativeSpend is a SERVER-derived, signed-last context field (never
23
33
  // shadowable by the agent's itinerary), so this compares already-spent + this amount.
24
34
  "cumulative-over": (c, cfg) => Number(c.cumulativeSpend ?? 0) + Number(c.amount ?? 0) > Number(cfg?.limit ?? 0),
@@ -83,7 +93,7 @@ var ATOM_SPECS = [
83
93
  {
84
94
  predicate: "amount-unknown",
85
95
  label: "Amount not determinable",
86
- description: "Fires when the action carries no usable amount \u2014 the gate cannot tell how much value it would move. A deny-by-default control for value-moving actions: author it with BLOCK ahead of a spend cap, otherwise an action whose amount is missing or unparseable passes the cap untested. Fires on ABSENCE, so only attach it to actions that must always carry an amount.",
96
+ 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.",
87
97
  config: [],
88
98
  requiredContext: ["amount"]
89
99
  },
@@ -341,11 +351,14 @@ function reasonFor(constraint) {
341
351
  if (!constraint) return "CONSTRAINT_FAILED";
342
352
  return REASON_BY_OPERAND[constraint.leftOperand] ?? `CONSTRAINT_FAILED:${constraint.leftOperand}`;
343
353
  }
344
- function constraintSatisfied(c, req) {
354
+ function constraintSatisfied(c, req, strict) {
345
355
  const op = OPERATORS[c.operator];
346
356
  if (!op) return false;
347
357
  const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
348
- return op(left, c.rightOperand);
358
+ if (!c.unit) return op(left, c.rightOperand);
359
+ const currency = req.values["mm:currency"];
360
+ const unitMatches = typeof currency === "string" && currency.toUpperCase() === c.unit.toUpperCase();
361
+ return unitMatches ? op(left, c.rightOperand) : !strict;
349
362
  }
350
363
  function targetOf(rule, mandate) {
351
364
  return rule.target ?? mandate.target;
@@ -367,7 +380,7 @@ function evaluateMandate(mandate, req) {
367
380
  }
368
381
  for (const p of mandate.prohibition ?? []) {
369
382
  if (targetOf(p, mandate) !== req.target) continue;
370
- const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req));
383
+ const fires = (p.constraint ?? []).every((c) => constraintSatisfied(c, req, false));
371
384
  if (fires) {
372
385
  return {
373
386
  decision: p.enforcement ?? "block",
@@ -385,10 +398,10 @@ function evaluateMandate(mandate, req) {
385
398
  };
386
399
  }
387
400
  for (const p of perms) {
388
- const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req));
401
+ const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
389
402
  if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
390
403
  }
391
- const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req));
404
+ const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
392
405
  return {
393
406
  decision: firstFail?.onFail ?? "block",
394
407
  reasonCode: reasonFor(firstFail),
@@ -437,8 +450,11 @@ function evaluate(input) {
437
450
  }
438
451
 
439
452
  // src/policy-core/canonical.ts
453
+ function escapeField(v) {
454
+ return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
455
+ }
440
456
  function buildAuthMessage(f) {
441
- return `${f.agentDid}|${f.action}|${f.amount}|${f.currency}|${f.merchant ?? ""}|${f.nonce}|${f.issuedAt}`;
457
+ return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
442
458
  }
443
459
 
444
460
  // src/policy-core/context.ts