@metamynd/agentsafe-signer 0.20.1 → 0.21.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/daemon-client.mjs CHANGED
@@ -30,7 +30,7 @@ export function parseArgs(argv) {
30
30
  return args;
31
31
  }
32
32
 
33
- /** Same connection + ENOENT-retry logic as agentsafe-guard/agentsafe-mcp-guard's own
33
+ /** Same connection + ENOENT/ECONNREFUSED-retry logic as agentsafe-guard/agentsafe-mcp-guard's own
34
34
  * key-providers.mjs (see either's comment for why): on Windows the signing socket is a pool of
35
35
  * independent named-pipe instances, each consumed by one connection and replaced asynchronously,
36
36
  * so a request can transiently race that replacement window even though the daemon is healthy.
@@ -78,7 +78,12 @@ export function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000
78
78
  attemptOver = true;
79
79
  cleanup();
80
80
  if (settled) return;
81
- if (err.code === 'ENOENT' && Date.now() < deadline) {
81
+ // ECONNREFUSED is retried too: on Linux/macOS a Unix socket's path exists from bind(), a
82
+ // moment BEFORE listen() — and a stale file from a crashed daemon refuses until the new
83
+ // one replaces it. migrate.smoke.mjs hit the bind/listen gap intermittently in CI (the
84
+ // admin socket's "open" log line is printed just before it is built). Safe to retry: a
85
+ // refused connect never delivered the request, unlike a close after connecting (above).
86
+ if ((err.code === 'ENOENT' || err.code === 'ECONNREFUSED') && Date.now() < deadline) {
82
87
  setTimeout(attempt, 20);
83
88
  return;
84
89
  }
package/daemon.mjs CHANGED
@@ -84,10 +84,10 @@ export function buildServiceCallMessage({ action, authorizationId, fields, nonce
84
84
  export const DEFAULT_CHECKPOINT_INTERVAL_MS = 15 * 60 * 1000;
85
85
 
86
86
  // Matches agentsafe-guard.mjs's own REPORTABLE_LOCAL_DECISIONS exactly (LOCAL_DECISIONS the
87
- // backend's /policy/decisions/local actually accepts) — this daemon must never sign a
88
- // 'quarantine'/'suspend' or other value here just because a caller asked; only a genuine
89
- // local-first verdict shape is a valid thing to have signed under this op.
90
- const REPORTABLE_LOCAL_DECISIONS = new Set(['allow', 'observe', 'block', 'escalate']);
87
+ // backend's /policy/decisions/local accepts) — this daemon never signs another value here just because a caller asked;
88
+ // only a genuine local-first verdict shape, or (since 0.20.3, N-4) a containment refusal the guard gave from the
89
+ // server's own `contained` flag, is a valid thing to have signed under this op. Never 'decommission'.
90
+ const REPORTABLE_LOCAL_DECISIONS = new Set(['allow', 'observe', 'block', 'escalate', 'suspend', 'quarantine']);
91
91
 
92
92
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
93
93
  const HEX_RE = /^[0-9a-f]+$/i;
@@ -390,8 +390,7 @@ export class SignerDaemon {
390
390
 
391
391
  /**
392
392
  * An agent settling its OWN hold that nobody has claimed (MAGP §8.7.4): capture it, or void it. The issuer accepts an
393
- * unclaimed settlement only from that hold's agent, a counterparty the owner registered, or — for an open testnet owner —
394
- * anyone. Built from validated fields under the fixed MAGP-SETTLE-v1 prefix, which no other message this daemon signs
393
+ * unclaimed settlement only from that hold's agent or a counterparty the owner registered, on every owner. Built from validated fields under the fixed MAGP-SETTLE-v1 prefix, which no other message this daemon signs
395
394
  * starts with; it names the agent, so it is never a counterparty's signature (sign-service-call, MAGP-SERVICE-v1), and an
396
395
  * agent cannot use it to claim. Agent-role only.
397
396
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-signer",
3
- "version": "0.20.1",
3
+ "version": "0.21.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",
package/policy-core.mjs CHANGED
@@ -274,8 +274,91 @@ ${c.output ?? ""}`.toLowerCase();
274
274
  // below a soft REVIEW line — intended to author an ESCALATE (route to a human), NOT a hard block.
275
275
  // The score is server-derived (signed-last) so the agent's itinerary can't fake it; when no score
276
276
  // is present (e.g. no counterparty resolved) the atom simply does not fire — no guidance.
277
- "hol-trust-below-review": (c, cfg) => typeof c.holTrustScore === "number" && c.holTrustScore < Number(cfg?.reviewBelow ?? 60)
277
+ "hol-trust-below-review": (c, cfg) => typeof c.holTrustScore === "number" && c.holTrustScore < Number(cfg?.reviewBelow ?? 60),
278
+ // --- Owner-keyed context atoms (pre-beta rerun 6, NF-RISK-SELF). For a non-financial action the only risk input
279
+ // used to be the riskLevel the agent declares about itself: {riskLevel:'low', op:'delete-all'} ran unreviewed.
280
+ // These let the owner key a rule on WHAT is being done — a field of the context the executor acts on — so the
281
+ // agent's label no longer decides. See contextValueMatch below for the matching and fail-closed rules. ---
282
+ // Deny-list: fires when the field's value IS one of the owner's values ("deleting needs approval").
283
+ "context-value-in": (c, cfg) => contextValueMatch(c, cfg, "in") !== null,
284
+ // Allow-list: fires when the field's value is NOT one of them ("anything but read/list needs approval"). The sound
285
+ // choice when the set of safe operations is known: a spelling the owner did not foresee fires instead of passing.
286
+ "context-value-not-in": (c, cfg) => contextValueMatch(c, cfg, "not-in") !== null
278
287
  };
288
+ var LOOKALIKE_FOLD = {
289
+ // Cyrillic (lower case; upper case is lower-cased before folding)
290
+ "\u0430": "a",
291
+ "\u0432": "b",
292
+ "\u0435": "e",
293
+ "\u0451": "e",
294
+ "\u043A": "k",
295
+ "\u043C": "m",
296
+ "\u043D": "h",
297
+ "\u043E": "o",
298
+ "\u0440": "p",
299
+ "\u0441": "c",
300
+ "\u0442": "t",
301
+ "\u0443": "y",
302
+ "\u0445": "x",
303
+ "\u0455": "s",
304
+ "\u0456": "i",
305
+ "\u0457": "i",
306
+ "\u0458": "j",
307
+ "\u0501": "d",
308
+ "\u04CF": "l",
309
+ "\u04BB": "h",
310
+ "\u051B": "q",
311
+ "\u051D": "w",
312
+ // Greek
313
+ "\u03B1": "a",
314
+ "\u03B2": "b",
315
+ "\u03B5": "e",
316
+ "\u03B7": "n",
317
+ "\u03B9": "i",
318
+ "\u03BA": "k",
319
+ "\u03BD": "v",
320
+ "\u03BF": "o",
321
+ "\u03C1": "p",
322
+ "\u03C4": "t",
323
+ "\u03C5": "u",
324
+ "\u03C7": "x",
325
+ "\u03F2": "c"
326
+ };
327
+ var LOOKALIKE_RE = new RegExp(`[${Object.keys(LOOKALIKE_FOLD).join("")}]`, "g");
328
+ var INVISIBLE_RE = /[­͏؜ᅟᅠ឴឵᠋-᠏​-‏‪-‮⁠-ㅤ︀-️ᅠ]/g;
329
+ function normalizeContextToken(s) {
330
+ return s.normalize("NFKC").replace(INVISIBLE_RE, "").toLowerCase().normalize("NFKC").replace(LOOKALIKE_RE, (ch) => LOOKALIKE_FOLD[ch] ?? ch).replace(/[\s\-_‐-―−]+/g, "");
331
+ }
332
+ function contextValueAt(ctx, path) {
333
+ let cur = ctx;
334
+ for (const seg of path.split(".")) {
335
+ if (seg === "" || cur === null || typeof cur !== "object" || Array.isArray(cur)) return void 0;
336
+ if (!Object.prototype.hasOwnProperty.call(cur, seg)) return void 0;
337
+ cur = cur[seg];
338
+ }
339
+ return cur;
340
+ }
341
+ var clip = (s) => (s.length > 80 ? `${s.slice(0, 77)}...` : s).replace(/[\u0000-\u001F\u007F]/g, "");
342
+ function contextValueMatch(ctx, cfg, mode) {
343
+ const field = typeof cfg?.field === "string" ? cfg.field.trim() : "";
344
+ if (field === "") return null;
345
+ const actions = Array.isArray(cfg?.actions) ? cfg.actions.map((a) => String(a).trim()).filter(Boolean) : [];
346
+ if (actions.length > 0 && !actions.includes(String(ctx.action ?? "").trim())) return null;
347
+ const listed = (Array.isArray(cfg?.values) ? cfg.values : []).map((v) => normalizeContextToken(String(v))).filter((v) => v !== "");
348
+ const contains = cfg?.match === "contains";
349
+ const raw = contextValueAt(ctx, field);
350
+ const elements = Array.isArray(raw) ? raw : raw === void 0 || raw === null ? [] : [raw];
351
+ const present = elements.filter((e) => !(typeof e === "string" && e.trim() === "") && e !== null && e !== void 0);
352
+ if (present.length === 0) return cfg?.missing === "fire" ? { field, reason: "missing" } : null;
353
+ for (const e of present) {
354
+ if (typeof e !== "string" && typeof e !== "number" && typeof e !== "boolean") return { field, reason: "malformed" };
355
+ const v = normalizeContextToken(String(e));
356
+ const hit = listed.some((l) => contains ? v.includes(l) : v === l);
357
+ if (mode === "in" && hit) return { field, value: clip(String(e).trim()), reason: "listed" };
358
+ if (mode === "not-in" && !hit) return { field, value: clip(String(e).trim()), reason: "unlisted" };
359
+ }
360
+ return null;
361
+ }
279
362
  function notInAllowList(value, allowList) {
280
363
  const v = value != null ? String(value).toLowerCase().trim() : "";
281
364
  if (v === "") return false;
@@ -448,6 +531,36 @@ var ATOM_SPECS = [
448
531
  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.",
449
532
  config: [{ key: "min", type: "number", required: true, description: "Minimum evidence confidence (0\u20131) required" }],
450
533
  requiredContext: ["evidenceConfidence"]
534
+ },
535
+ // Owner-keyed context values (pre-beta rerun 6, NF-RISK-SELF). requiredContext is EMPTY on purpose: the field these
536
+ // read is chosen per atom instance (`field`), which a per-predicate list cannot express — the same reason amount-over's
537
+ // optional `currency` is not listed. sop-compiler.input-semantics.ts reads the configured field instead, so the
538
+ // review screen still says what an absent one does.
539
+ {
540
+ predicate: "context-value-in",
541
+ label: "Context value is on a list (owner-marked operation)",
542
+ description: 'Fires when a field of the request context (e.g. `op`, or `params.op`) has one of the listed values \u2014 key a rule on WHAT the agent is doing, not on the risk level it declares about itself. Author with ESCALATE ("deleting records needs approval") or BLOCK. Case, width, whitespace, hyphens/underscores, invisible characters and common Cyrillic/Greek look-alike letters are ignored when comparing. A deny-list cannot foresee every spelling: when the safe values are known, prefer context-value-not-in. When it fires, the verdict and the escalation carry a `context-value` risk signal naming the field and value.',
543
+ config: [
544
+ { key: "field", type: "string", required: true, description: "Dot path of the context field to read, e.g. op or params.op" },
545
+ { key: "values", type: "string[]", required: true, description: "Values that make the rule fire, e.g. delete, delete-all, purge" },
546
+ { key: "match", type: "enum", required: false, options: ["exact", "contains"], description: "exact (default), or contains: the value contains a listed entry ('bulk_delete' contains 'delete')" },
547
+ { key: "missing", type: "enum", required: false, options: ["pass", "fire"], description: "What an absent field means: pass (default) or fire \u2014 set fire so an agent cannot skip the rule by not sending the field" },
548
+ { key: "actions", type: "string[]", required: false, description: "Optional: judge only these actions (mandate targets); other actions are out of scope" }
549
+ ],
550
+ requiredContext: []
551
+ },
552
+ {
553
+ predicate: "context-value-not-in",
554
+ label: "Context value is not on an allow-list",
555
+ description: 'Fires when a field of the request context has a value that is NOT on the owner\'s allow-list ("anything other than read or list needs approval"). The sound form of an operation rule: a spelling or look-alike the owner did not foresee fires instead of passing. Same comparison rules, `missing` and `actions` options as context-value-in, and the same `context-value` risk signal when it fires.',
556
+ config: [
557
+ { key: "field", type: "string", required: true, description: "Dot path of the context field to read, e.g. op or params.op" },
558
+ { key: "values", type: "string[]", required: true, description: "The allowed values, e.g. read, list" },
559
+ { key: "match", type: "enum", required: false, options: ["exact", "contains"], description: "exact (default), or contains: the value contains an allowed entry" },
560
+ { key: "missing", type: "enum", required: false, options: ["pass", "fire"], description: "What an absent field means: pass (default) or fire (fail closed)" },
561
+ { key: "actions", type: "string[]", required: false, description: "Optional: judge only these actions (mandate targets); other actions are out of scope" }
562
+ ],
563
+ requiredContext: []
451
564
  }
452
565
  ];
453
566
  var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
@@ -467,6 +580,32 @@ function ownEntry(table, key) {
467
580
 
468
581
  // src/policy-core/standards-rules.ts
469
582
  var CONTEXT_UNVERIFIABLE = "CONTEXT_UNVERIFIABLE";
583
+ var CONTEXT_VALUE_ATOMS = ["context-value-in", "context-value-not-in"];
584
+ function contextSignalsOf(m, ctx, decision) {
585
+ if (decision === "observe" || m.combinator !== "all" && m.combinator !== "any") return [];
586
+ const out = [];
587
+ for (const a of m.atoms ?? []) {
588
+ const predicate = CONTEXT_VALUE_ATOMS.find((p) => p === a.predicate);
589
+ if (!predicate) continue;
590
+ let hit = null;
591
+ try {
592
+ hit = contextValueMatch(ctx, a.config, predicate === "context-value-in" ? "in" : "not-in");
593
+ } catch {
594
+ hit = null;
595
+ }
596
+ if (hit) out.push({ ...hit, predicate, moleculeId: m.id, decision });
597
+ }
598
+ return out;
599
+ }
600
+ function contextRiskSignals(result) {
601
+ const out = /* @__PURE__ */ new Map();
602
+ for (const s of result?.contextSignals ?? []) {
603
+ const verb = s.decision === "escalate" ? "owner-marked for review" : "owner-marked as not allowed";
604
+ const detail = s.reason === "listed" ? `${s.field}=${s.value} is ${verb}` : s.reason === "unlisted" ? `${s.field}=${s.value} is not on the owner's list (${s.decision === "escalate" ? "review" : "not allowed"})` : s.reason === "missing" ? `${s.field} was not sent, and the owner requires it` : `${s.field} is not a plain value, so it cannot be checked against the owner's list`;
605
+ out.set(detail, { signal: "context-value", level: "high", detail });
606
+ }
607
+ return [...out.values()];
608
+ }
470
609
  var JURISDICTION_ATOM = "jurisdiction-not-allowed";
471
610
  function documentEnforcesJurisdiction(doc) {
472
611
  return (doc?.molecules ?? []).some((m) => m?.decision !== "observe" && (m?.atoms ?? []).some((a) => a?.predicate === JURISDICTION_ATOM));
@@ -523,10 +662,12 @@ function moleculeUnverifiable(m, ctx) {
523
662
  }
524
663
  function evaluateStandardRules(molecules, ctx, standardKey = null) {
525
664
  let best = null;
665
+ const contextSignals = [];
526
666
  for (const m of molecules ?? []) {
527
667
  const fired = moleculeFires(m, ctx);
528
668
  const unverifiable = moleculeUnverifiable(m, ctx);
529
669
  if (!fired && unverifiable.length === 0) continue;
670
+ if (fired) contextSignals.push(...contextSignalsOf(m, ctx, m.decision));
530
671
  let decision = fired ? m.decision : "escalate";
531
672
  if (unverifiable.length > 0 && PRECEDENCE[decision] < PRECEDENCE.escalate) decision = "escalate";
532
673
  const reasonCode = fired ? m.reasonCode : CONTEXT_UNVERIFIABLE;
@@ -540,16 +681,22 @@ function evaluateStandardRules(molecules, ctx, standardKey = null) {
540
681
  reasonCode: best.reasonCode,
541
682
  firedMoleculeId: best.id,
542
683
  standardKey,
543
- ...best.unverifiable ? { unverifiableContext: best.unverifiable } : {}
684
+ ...best.unverifiable ? { unverifiableContext: best.unverifiable } : {},
685
+ ...contextSignals.length > 0 ? { contextSignals } : {}
544
686
  };
545
687
  }
546
688
  function evaluateBoundStandards(standards, ctx) {
547
689
  let best = { decision: "allow", reasonCode: null, firedMoleculeId: null, standardKey: null };
690
+ const contextSignals = [];
548
691
  for (const s of standards) {
549
692
  const r = evaluateStandardRules(s.document?.molecules, ctx, s.standardKey);
693
+ if (r.contextSignals) contextSignals.push(...r.contextSignals);
550
694
  if (PRECEDENCE[r.decision] > PRECEDENCE[best.decision]) best = r;
551
695
  }
552
- return standards.some((s) => documentEnforcesJurisdiction(s.document)) ? { ...best, jurisdictionRequired: true } : best;
696
+ const out = { ...best };
697
+ delete out.contextSignals;
698
+ if (contextSignals.length > 0) out.contextSignals = contextSignals;
699
+ return standards.some((s) => documentEnforcesJurisdiction(s.document)) ? { ...out, jurisdictionRequired: true } : out;
553
700
  }
554
701
  function configValueValid(field, value) {
555
702
  switch (field.type) {
@@ -578,6 +725,8 @@ function validateAtomConfig(predicate, config) {
578
725
  }
579
726
  if (!configValueValid(field, cfg[field.key])) {
580
727
  errors.push(`atom '${predicate}' config '${field.key}' must be a ${field.type}`);
728
+ } else if (field.required && field.type === "string" && String(cfg[field.key]).trim() === "") {
729
+ errors.push(`atom '${predicate}' config '${field.key}' must not be blank`);
581
730
  }
582
731
  }
583
732
  return errors;
@@ -866,6 +1015,9 @@ export {
866
1015
  buildRuleContext,
867
1016
  canAuthorize,
868
1017
  contextFieldProblem,
1018
+ contextRiskSignals,
1019
+ contextValueAt,
1020
+ contextValueMatch,
869
1021
  documentEnforcesJurisdiction,
870
1022
  effectiveRiskFloor,
871
1023
  evaluate,
@@ -881,6 +1033,7 @@ export {
881
1033
  moleculeFires,
882
1034
  moleculeUnverifiable,
883
1035
  moreRestrictive,
1036
+ normalizeContextToken,
884
1037
  normalizeRiskLevel,
885
1038
  operatingModeGate,
886
1039
  perTxnCapFor,