@metamynd/agentsafe-signer 0.18.0 → 0.19.2

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
@@ -209,6 +209,14 @@ from an older one, which ignores the field and would sign v1 for a request that
209
209
  payee's country wins over the signed one (`JURISDICTION_MISMATCH`); the other refusals are `JURISDICTION_REQUIRED`
210
210
  and `JURISDICTION_NOT_ALLOWED`.
211
211
 
212
+ ## Context signature over the request as sent (`sign-envelope`, since 0.19.0)
213
+
214
+ `sign-envelope` hashes the envelope (MAGP §8.3.13) over the fields **as the request sends them**: an omitted
215
+ `currency` stays omitted (0.18.0 and earlier hashed it as `USD`) and an omitted `amount` is signed (they refused it), so
216
+ the signature matches the gate's `envelopeHashFor` for a non-financial request too. agentsafe-guard ≥ 0.17.0 signs the
217
+ context by default and checks the daemon's signature against the hash it sends: with an older daemon such a request is
218
+ refused `CONTEXT_SIGNING_UNSUPPORTED` before it is sent (upgrade the daemon, or `signContext: false`).
219
+
212
220
  ## Hardening tiers — what a real host actually confirms (T3, T5)
213
221
 
214
222
  `daemon-hardening.smoke.mjs` checks the tier directives **from outside** the process, against the
package/daemon-client.mjs CHANGED
@@ -45,17 +45,37 @@ export function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000
45
45
  return new Promise((resolve, reject) => {
46
46
  const deadline = Date.now() + connectTimeoutMs;
47
47
  let settled = false;
48
+ let inFlight = null;
48
49
  const overallTimer = setTimeout(() => {
49
50
  settled = true;
51
+ // Close the attempt still in flight. Left open, a connect that completes AFTER this timeout
52
+ // stayed connected, silently, holding the daemon's pipe instance — on the ONE-SHOT admin
53
+ // socket that is its only connection, so the caller's retry found the admin socket gone.
54
+ inFlight?.destroy();
50
55
  reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: timed out after ${connectTimeoutMs}ms`), { code: 'DAEMON_UNREACHABLE' }));
51
56
  }, connectTimeoutMs);
52
57
  function attempt() {
53
58
  if (settled) return;
54
59
  const sock = net.connect(toPlatformSocketPath(socketPath));
60
+ inFlight = sock;
55
61
  const requestId = crypto.randomUUID();
56
62
  let buf = '';
57
63
  const cleanup = () => sock.destroy();
64
+ let attemptOver = false; // this attempt already errored — its 'close' is not news
65
+ // The daemon side closing the connection before a full response line arrived used to go
66
+ // unnoticed (no 'close' handler), so the call sat until the overall timeout and then blamed
67
+ // reachability. Fail at once instead, and do NOT retry: a close with no response does not mean
68
+ // the request was not handled (windows-secure-pipe.mjs's one-shot close once dropped a
69
+ // generate-key response AFTER the daemon had rekeyed), so re-sending could run it twice.
70
+ sock.once('close', () => {
71
+ if (settled || attemptOver) return;
72
+ attemptOver = true;
73
+ settled = true;
74
+ clearTimeout(overallTimer);
75
+ reject(Object.assign(new Error(`agentsafe-signer daemon at ${socketPath} closed the connection before responding (the request may or may not have been handled)`), { code: 'DAEMON_CONNECTION_CLOSED' }));
76
+ });
58
77
  sock.once('error', (err) => {
78
+ attemptOver = true;
59
79
  cleanup();
60
80
  if (settled) return;
61
81
  if (err.code === 'ENOENT' && Date.now() < deadline) {
@@ -67,7 +87,10 @@ export function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000
67
87
  reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
68
88
  });
69
89
  sock.once('connect', () => {
70
- if (settled) return;
90
+ if (settled) {
91
+ sock.destroy();
92
+ return;
93
+ }
71
94
  sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
72
95
  });
73
96
  sock.on('data', (chunk) => {
package/daemon.mjs CHANGED
@@ -418,8 +418,11 @@ export class SignerDaemon {
418
418
 
419
419
  #handleSignEnvelope(params = {}) {
420
420
  this.#assertRateLimit('sign-envelope');
421
- this.#validateCoreFields(params);
422
- const { agentDid, action, amount, currency = 'USD', merchant, itinerary, trace, materiality, nonce, issuedAt } = params;
421
+ // The envelope hash is over the request AS SENT (MAGP §8.3.13): an omitted amount/currency stays omitted — no 0/'USD'
422
+ // default here (that default belongs to the authorize message only). Before signer 0.19.0 this refused an omitted
423
+ // amount and hashed an omitted currency as 'USD', so the gate refused the context signature.
424
+ this.#validateCoreFields({ ...params, amount: params.amount ?? 0 });
425
+ const { agentDid, action, amount, currency, merchant, itinerary, trace, materiality, nonce, issuedAt } = params;
423
426
  const hash = envelopeHashFor({ agentDid, action, amount, currency, merchant, itinerary, trace, materiality, nonce, issuedAt, signature: '' });
424
427
  return { envelopeSignature: this.#sign(Buffer.from(hash, 'utf8')) };
425
428
  }
package/migrate.mjs CHANGED
@@ -30,6 +30,13 @@ import path from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
  import { parseArgs, daemonRequest } from './daemon-client.mjs';
32
32
 
33
+ // The admin socket is ONE-SHOT: its first connection is its only one. On Windows each pipe is
34
+ // built by a relay process (windows-secure-pipe.mjs, with bounded spawn retries), which under load
35
+ // can take longer than daemonRequest's 3s default — and giving up early does not just fail this
36
+ // call, it can spend the single connection, so a rerun finds the admin socket closed. Wait longer
37
+ // here (still bounded) rather than burn the window; the signing socket keeps the default.
38
+ const ADMIN_CONNECT_TIMEOUT_MS = 15_000;
39
+
33
40
  async function apiCall(apiUrl, method, urlPath, authToken, body) {
34
41
  const res = await fetch(new URL(urlPath, apiUrl), {
35
42
  method,
@@ -53,8 +60,8 @@ async function apiCall(apiUrl, method, urlPath, authToken, body) {
53
60
  * than assumed impossible). Returns the new DID and the exact restart command, rather than
54
61
  * hot-swapping the running daemon's identity itself — see this file's own header on why.
55
62
  */
56
- export async function runMigrate({ stateDir, socketPath, adminSocketPath, apiUrl, authToken, ref, networkIdentityId }) {
57
- const { publicKeyHex } = await daemonRequest(adminSocketPath, 'generate-key', { allowRekey: true });
63
+ export async function runMigrate({ stateDir, socketPath, adminSocketPath, apiUrl, authToken, ref, networkIdentityId, adminConnectTimeoutMs = ADMIN_CONNECT_TIMEOUT_MS }) {
64
+ const { publicKeyHex } = await daemonRequest(adminSocketPath, 'generate-key', { allowRekey: true }, { connectTimeoutMs: adminConnectTimeoutMs });
58
65
 
59
66
  const rotated = await apiCall(
60
67
  apiUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-signer",
3
- "version": "0.18.0",
3
+ "version": "0.19.2",
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
@@ -409,6 +409,11 @@ function requiredContextFor(predicates) {
409
409
  return [...fields].sort();
410
410
  }
411
411
 
412
+ // src/policy-core/own-entry.ts
413
+ function ownEntry(table, key) {
414
+ return typeof key === "string" && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : void 0;
415
+ }
416
+
412
417
  // src/policy-core/standards-rules.ts
413
418
  var CONTEXT_UNVERIFIABLE = "CONTEXT_UNVERIFIABLE";
414
419
  var JURISDICTION_ATOM = "jurisdiction-not-allowed";
@@ -417,7 +422,7 @@ function documentEnforcesJurisdiction(doc) {
417
422
  }
418
423
  var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
419
424
  function atomFires(atom, ctx) {
420
- const pred = ATOM_REGISTRY[atom.predicate];
425
+ const pred = ownEntry(ATOM_REGISTRY, atom.predicate);
421
426
  if (!pred) return false;
422
427
  try {
423
428
  return !!pred(ctx, atom.config);
@@ -554,7 +559,7 @@ function validateMolecules(molecules) {
554
559
  }
555
560
  }
556
561
  for (const a of m.atoms ?? []) {
557
- if (!ATOM_REGISTRY[a.predicate]) {
562
+ if (!ownEntry(ATOM_REGISTRY, a.predicate)) {
558
563
  issues.push({ moleculeId: m.id, message: `unknown atom predicate '${a.predicate}'` });
559
564
  continue;
560
565
  }
@@ -602,10 +607,10 @@ function reasonFor(constraint, req) {
602
607
  const v = req.values[leftOperand];
603
608
  return v === void 0 || v === null || v === "" ? "JURISDICTION_REQUIRED" : "JURISDICTION_NOT_ALLOWED";
604
609
  }
605
- return REASON_BY_OPERAND[leftOperand] ?? `CONSTRAINT_FAILED:${leftOperand}`;
610
+ return ownEntry(REASON_BY_OPERAND, leftOperand) ?? `CONSTRAINT_FAILED:${leftOperand}`;
606
611
  }
607
612
  function constraintSatisfied(c, req, strict) {
608
- const op = OPERATORS[c.operator];
613
+ const op = ownEntry(OPERATORS, c.operator);
609
614
  if (!op) return false;
610
615
  const left = Object.prototype.hasOwnProperty.call(req.values, c.leftOperand) ? req.values[c.leftOperand] : void 0;
611
616
  if (!c.unit) return op(left, c.rightOperand);
@@ -36,6 +36,9 @@ const SPAWN_TIMEOUT_MS = 1500;
36
36
  // faster from the common case — the observed contention tends to clear within one or two
37
37
  // retries — while keeping a similar worst-case total bound.
38
38
  const MAX_SPAWN_ATTEMPTS = 8;
39
+ // How long createSecurePipeOnce's close() lets a relay with a connected client drain its stdin to
40
+ // the pipe and exit on its own before killing it anyway.
41
+ const GRACEFUL_CLOSE_MS = 2000;
39
42
 
40
43
  function relayScript(pipeName, maxInstances) {
41
44
  return [
@@ -264,6 +267,7 @@ export function createSecurePipeOnce(pipeName, onConnection, { timeoutMs = 60_00
264
267
  let closed = false; // true once emitter.close() has been called, OR a terminal finish/fail happened
265
268
  let everReady = false; // true once 'aclVerified' has fired at least once, across any attempt
266
269
  let child = null;
270
+ let connectedChild = null; // the relay whose client connected (see emitter.close)
267
271
  let attempts = 0;
268
272
  const timer = setTimeout(() => emitter.close(), timeoutMs);
269
273
  emitter.closed = false; // readable synchronously by a caller that only gets `emitter` AFTER it
@@ -319,7 +323,10 @@ export function createSecurePipeOnce(pipeName, onConnection, { timeoutMs = 60_00
319
323
  clearTimeout(spawnTimer);
320
324
  emitter.emit('aclVerified', n);
321
325
  },
322
- onConnected: () => onConnection(socketAdapter(child)),
326
+ onConnected: () => {
327
+ connectedChild = child;
328
+ onConnection(socketAdapter(child));
329
+ },
323
330
  });
324
331
 
325
332
  const onExit = () => {
@@ -356,6 +363,29 @@ export function createSecurePipeOnce(pipeName, onConnection, { timeoutMs = 60_00
356
363
  emitter.close = () => {
357
364
  if (closed) return;
358
365
  closed = true;
366
+ if (connectedChild === child) {
367
+ // A client is connected, and the caller (daemon.mjs's startAdminServer) closes right after
368
+ // writing that client's response into the relay's stdin. Killing the relay here raced its
369
+ // stdin->pipe copy: the response was often lost, so generate-key REKEYED the daemon while the
370
+ // client saw only a dropped connection (found chasing migrate.smoke's flake: CONNECTED, then
371
+ // "admin socket closed" 17ms later, no response). End stdin instead — the relay copies what is
372
+ // left, its CopyToAsync completes, it disposes the pipe and exits on its own, and that exit is
373
+ // what finish()es. The kill stays as a bounded fallback for a relay that does not exit.
374
+ try {
375
+ child.stdin.end();
376
+ } catch {
377
+ /* stdin already gone — the fallback below still bounds it */
378
+ }
379
+ const fallback = setTimeout(() => {
380
+ try {
381
+ child.kill();
382
+ } catch {
383
+ /* already exited */
384
+ }
385
+ }, GRACEFUL_CLOSE_MS);
386
+ fallback.unref?.();
387
+ return;
388
+ }
359
389
  try {
360
390
  child.kill();
361
391
  } catch {