@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 +7 -2
- package/daemon.mjs +5 -6
- package/package.json +1 -1
- package/policy-core.mjs +156 -3
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
|
-
|
|
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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,
|