@metamynd/agentsafe-guard 0.12.0 → 0.12.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.
@@ -297,7 +297,7 @@ export function createGuard(opts = {}) {
297
297
  * @param {Array<{standardKey:string,document:object}>} [p.standards] enforced Standards bound to the agent
298
298
  * @param {Array<{standardKey:string,document:object}>} [p.sops] active SOPs assigned to the agent
299
299
  * @param {object} [p.mandate] the ODRL mandate document (omit to skip the mandate layer)
300
- * @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
300
+ * @param {{action:string,amount?:number,currency?:string,merchant?:string,resource?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
301
301
  * @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
302
302
  */
303
303
  function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
@@ -310,7 +310,7 @@ export function createGuard(opts = {}) {
310
310
  const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
311
311
  return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
312
312
  }
313
- const { action, amount = 0, currency = 'USD', merchant = '', context = {}, cumulativeSpend = amount, now } = request;
313
+ const { action, amount = 0, currency = 'USD', merchant = '', resource = null, context = {}, cumulativeSpend = amount, now } = request;
314
314
  // Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
315
315
  // SIBLING (like `contained`) and biases the edge verdict identically to the gate.
316
316
  // READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
@@ -326,7 +326,11 @@ export function createGuard(opts = {}) {
326
326
  standards,
327
327
  sops,
328
328
  mandate,
329
- context: applySignedLast(context, { action, agentDid, amount }),
329
+ // currency/merchant/resource are signed fields too, same as action/agentDid/amount
330
+ // omitting them here means a currency-scoped amount-over/cumulative-over Standards/SOP
331
+ // atom always sees currency as absent and fires closed. Mirrors mandate.service.ts's
332
+ // ruleCtx (PR #588) and the same fix in agentsafe-mcp-guard.mjs's verdictFromBundle.
333
+ context: applySignedLast(context, { action, agentDid, amount, currency, merchant, resource }),
330
334
  mandateRequest: mandate
331
335
  ? {
332
336
  target: action,
@@ -341,6 +345,9 @@ export function createGuard(opts = {}) {
341
345
  // amount, since undefined never equals a real unit. Defaults to 'USD' to match
342
346
  // the same default this file already uses for authorize()/buildSignedRequest().
343
347
  'mm:currency': currency,
348
+ // Unprefixed `resource` (not `mm:resource`) to match the constraint's own
349
+ // leftOperand (ResourceService.scopeConstraint()) — mirrors mandate.service.ts.
350
+ resource,
344
351
  }),
345
352
  }
346
353
  : undefined,
@@ -372,14 +379,29 @@ export function createGuard(opts = {}) {
372
379
  return b;
373
380
  }
374
381
 
375
- /** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
382
+ /**
383
+ * Map a fetched bundle into the shape evaluateLocally expects, for one action.
384
+ *
385
+ * `mandateFound` distinguishes "no mandate matches THIS action" from "omit mandate to
386
+ * skip the layer" (evaluateLocally's own documented, intentional behavior for a caller
387
+ * that never resolves one at all, e.g. guardToolLocal's caller-supplied bundle) — found
388
+ * live: this used to fall back to `mandates[0]` (an ARBITRARY, possibly unrelated
389
+ * mandate for a completely different action) rather than correctly reporting no
390
+ * authority for this one, and `authorizeLocal` never distinguished either case from a
391
+ * genuinely-mandate-less bundle, so an ungranted action with no Standard/SOP rule
392
+ * happening to also catch it was silently ALLOWED instead of NO_PERMISSION_FOR_ACTION.
393
+ */
376
394
  function _bundleFor(b, action) {
395
+ const mandates = b.mandates ?? [];
396
+ const match = mandates.find((m) => m.action === action);
377
397
  return {
378
398
  contained: b.contained ?? null,
379
399
  operatingMode: b.operatingMode ?? null,
380
400
  standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
381
401
  sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
382
- mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
402
+ mandate: match?.document,
403
+ mandateFound: !!match,
404
+ anyMandates: mandates.length > 0,
383
405
  };
384
406
  }
385
407
 
@@ -470,7 +492,26 @@ export function createGuard(opts = {}) {
470
492
  const sig = b?.proof?.signature;
471
493
  if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
472
494
  }
473
- const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
495
+ const { mandateFound, anyMandates, ...bundleForAction } = _bundleFor(b, action);
496
+ // No mandate covers this action at all — refuse outright rather than let evaluateLocally
497
+ // silently allow (its documented "omit mandate to skip the layer" behavior is for a
498
+ // caller that never intended a mandate check, not for one that looked and found none).
499
+ // Standards/SOP containment still applies first — a suspended/quarantined agent is
500
+ // refused for THAT reason, not misreported as merely lacking this one action.
501
+ if (!mandateFound) {
502
+ const contained = bundleForAction.contained;
503
+ if (contained && contained.status) {
504
+ const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
505
+ const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
506
+ const local = { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
507
+ reportLocalDecision(action, local.decision, local.reasonCode);
508
+ return local;
509
+ }
510
+ const local = { decision: 'block', reasonCode: anyMandates ? 'NO_PERMISSION_FOR_ACTION' : 'NO_MANDATE', authorizationId: null, remaining: null, proofRef: null };
511
+ reportLocalDecision(action, local.decision, local.reasonCode);
512
+ return local;
513
+ }
514
+ const local = evaluateLocally({ ...bundleForAction, request: input });
474
515
  // allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
475
516
  const permits = local.decision === 'allow' || local.decision === 'observe';
476
517
  if (!permits) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-guard",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
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",