create-metamynd-agent 0.14.10 → 0.14.22

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.
Files changed (2) hide show
  1. package/index.mjs +292 -64
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -61,7 +61,14 @@ const GUARD_PKG = '@metamynd/agentsafe-guard';
61
61
  // 0.18.0 exports keepAliveFetch. No template change.
62
62
  // 0.18.1 parks a local-first ESCALATE at the gate. Required: every scaffold's demo says "approve it in the dashboard and
63
63
  // the action resumes", and below this the escalation never reached the owner's queue (decided in-process, no escalationId).
64
- const GUARD_VERSION = '^0.18.1';
64
+ // 0.19.0: a handshake signs only a plain-token nonce (a malicious Service could otherwise obtain this agent's signature on an
65
+ // authorize request of its choosing, MAGP §8.2). Required for every scaffolded agent.
66
+ // 0.20.0: guard.capture() signs as the agent (MAGP-SETTLE-v1) — a hold nobody claimed is settled only by its agent, a
67
+ // counterparty the owner registered, or (an open testnet owner) anyone (MAGP §8.7.4).
68
+ // 0.21.0: guardTool() settles the hold an allowed call was granted (captured when the tool runs, released when it cannot
69
+ // have), guard.void() releases one signed, and a gated tool's .resume(escalationId, args) runs an approved escalation once.
70
+ // Required: the scaffold's npm run resume uses .resume().
71
+ const GUARD_VERSION = '^0.21.0';
65
72
  /** The harness entry point's config load, shared by both harness templates: a fresh clone has no
66
73
  * agent.metamynd.json (it is gitignored), so say what to do instead of a bare ENOENT (BR-004). */
67
74
  function harnessConfigLoad() {
@@ -128,7 +135,22 @@ const MCP_GUARD_PKG = '@metamynd/agentsafe-mcp-guard';
128
135
  // 0.19.0: a request the gateway's own policy escalates, carrying an authorization a PERSON approved, is executed (once,
129
136
  // with every claim binding) instead of refused RISK_REVIEW (MAGP §8.7.18). Required: the scaffold tells the developer
130
137
  // "approve it in the dashboard and the action resumes" — below this the gateway refused exactly that.
131
- const MCP_GUARD_VERSION = '^0.19.0';
138
+ // 0.20.0: `allowedAgents` pins a Service to the agents it acts for (MAGP §16.3) — refused AGENT_NOT_ADMITTED otherwise.
139
+ // Required: every scaffolded gateway now sets it, and an older guard would ignore the option and serve ANY agent whose
140
+ // own owner granted it the same action, with this owner's credentials (XT-1, pre-beta evaluation 2026-10-03).
141
+ // 0.21.0: guard.reportOutcome(). Required: the scaffolded gateways report what they ran or refused (MAGP §16.4), and
142
+ // agentsafe-http-gateway's reportOutcomes needs it.
143
+ // 0.22.0: allowedAgents is required, gatewayOwnerPrincipal binds the gateway to the owner of its credentials (an admitted
144
+ // agent of any other owner: GATEWAY_OWNER_MISMATCH), per-route admission (CREDENTIAL_PROFILE_NOT_PERMITTED), the refusal is
145
+ // AGENT_NOT_ADMITTED, and a handshake signs only a plain-token nonce. Required: every scaffolded gateway sets the owner.
146
+ // 0.23.0: acceptCounterpartyChallenge() — a Service signs its acceptance of an owner's registration (MAGP §8.7.6). No
147
+ // template change; required with GATEWAY_VERSION below so the gateway never resolves a second guard of its own.
148
+ // 0.24.0: reportOutcome() signs a reportId and an occurrence count (aggregated refusals, a durable spool).
149
+ // 0.25.0: honourApprovals — a Service without requireAuthorization (the value-less gateway) still runs an owner-approved
150
+ // escalation, exactly once (MAGP §8.7.18). Required: the non-financial gateway sets it and asserts guard.honoursApprovals.
151
+ const MCP_GUARD_VERSION = '^0.25.0';
152
+ /** A DID as it may appear inside a generated string literal (the gateway's allowedAgents pin): no quote, backslash or space. */
153
+ const SAFE_DID = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/;
132
154
  const GATEWAY_PKG = '@metamynd/agentsafe-http-gateway';
133
155
  // 0.2.0 fixes a confused-deputy gap (payload not bound to the signed request) — the CLI must
134
156
  // never scaffold a range that could resolve below it.
@@ -151,7 +173,14 @@ const GATEWAY_PKG = '@metamynd/agentsafe-http-gateway';
151
173
  // 0.17.0: depends on agentsafe-mcp-guard ^0.18.0 and exports keepAliveFetch. No template change.
152
174
  // 0.18.0: depends on agentsafe-mcp-guard ^0.19.0 (an approved escalation executes). Required with MCP_GUARD_VERSION
153
175
  // above, so the gateway never resolves a second, older guard of its own.
154
- const GATEWAY_VERSION = '^0.18.0';
176
+ // 0.19.0: depends on agentsafe-mcp-guard ^0.20.0 (allowedAgents) and reads AGENTSAFE_ALLOWED_AGENTS. Required with
177
+ // MCP_GUARD_VERSION above, so the gateway never resolves a second, older guard of its own.
178
+ // 0.20.0: `reportOutcomes`, which both hosted gateway templates set; depends on agentsafe-mcp-guard ^0.21.0.
179
+ // 0.21.0: per-route allowedAgents; depends on agentsafe-mcp-guard ^0.22.0.
180
+ // 0.22.0: depends on agentsafe-mcp-guard ^0.23.0.
181
+ // 0.23.0: refusals aggregated with their count, and `reportSpool` (both hosted templates set it); depends on agentsafe-mcp-guard ^0.24.0.
182
+ // 0.24.0: depends on agentsafe-mcp-guard ^0.25.0 (one guard in the tree with MCP_GUARD_VERSION above). No template change.
183
+ const GATEWAY_VERSION = '^0.24.0';
155
184
 
156
185
  /** Appended to every scaffolded gateway server: give hold settlements still running a bounded moment on shutdown. */
157
186
  const DRAIN_ON_SHUTDOWN = `
@@ -995,12 +1024,27 @@ async function resolveHostedRulePack(base, key, { financial, perTxnMax, maxAmoun
995
1024
  * dashboard registration shows its own dialog before setting the flag); see the printed message
996
1025
  * either way.
997
1026
  */
998
- async function registerGatewayCounterparty(base, token, did, label) {
1027
+ async function registerGatewayCounterparty(base, token, did, label, purpose = 'claim', privateKeyHex = null) {
999
1028
  try {
1029
+ // The gateway accepts its own registration (MAGP §8.7.6): ask for a challenge naming this owner, sign it with the gateway's
1030
+ // key (generated here, so this process holds it), and register with that proof. A DID is never registered on its text.
1031
+ let proof;
1032
+ if (privateKeyHex) {
1033
+ const ch = await fetch(`${base}/policy/counterparties/challenge`, {
1034
+ method: 'POST',
1035
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1036
+ body: JSON.stringify({ did, purpose }),
1037
+ });
1038
+ const chJson = await ch.json().catch(() => null);
1039
+ if (!ch.ok || !chJson?.data?.message) return { ok: false, message: chJson?.message ?? `challenge HTTP ${ch.status}` };
1040
+ proof = { challengeToken: chJson.data.challengeToken, signature: signChallengeHex(privateKeyHex, chJson.data.message) };
1041
+ }
1000
1042
  const res = await fetch(`${base}/policy/counterparties`, {
1001
1043
  method: 'POST',
1002
1044
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1003
- body: JSON.stringify({ did, label, confirmEnforcementChange: true }),
1045
+ // `report` (a non-financial gateway, which never claims): it may only report to this owner, and it changes nothing about
1046
+ // who may claim their holds, so it needs no enforcement confirmation (MAGP §8.7.6, §16.4).
1047
+ body: JSON.stringify({ did, label, purpose, ...(proof ? { proof } : {}), ...(purpose === 'claim' ? { confirmEnforcementChange: true } : {}) }),
1004
1048
  });
1005
1049
  const json = await res.json().catch(() => null);
1006
1050
  if (!res.ok || json?.success === false) return { ok: false, message: json?.message ?? `HTTP ${res.status}` };
@@ -1033,6 +1077,30 @@ async function ensurePolicyKey(config, base) {
1033
1077
  return config.issuer.policyKey;
1034
1078
  }
1035
1079
 
1080
+ /**
1081
+ * The principal DID that owns this agent (and so its gateway's credentials), read from the agent's own policy bundle
1082
+ * (`ownerPrincipal`, MAGP §16.3) on the API it was just provisioned through, and saved into the config. A gateway is bound
1083
+ * to it: an admitted agent of any other owner is refused. Returns it, or null when the bundle names none.
1084
+ */
1085
+ async function ensureOwnerPrincipal(config, base) {
1086
+ if (typeof config.ownerPrincipal === 'string' && SAFE_DID.test(config.ownerPrincipal)) return config.ownerPrincipal;
1087
+ let owner = null;
1088
+ try {
1089
+ const res = await fetch(`${String(base).replace(/\/+$/, '')}/policy/bundle/${encodeURIComponent(config.agentDid)}`);
1090
+ owner = (await res.json().catch(() => null))?.data?.ownerPrincipal ?? null;
1091
+ } catch {
1092
+ owner = null;
1093
+ }
1094
+ if (typeof owner !== 'string' || !SAFE_DID.test(owner)) return null;
1095
+ config.ownerPrincipal = owner;
1096
+ return owner;
1097
+ }
1098
+
1099
+ const NO_OWNER_PRINCIPAL = (base) =>
1100
+ `Could not read the principal that owns this agent from its policy bundle (GET ${base}/policy/bundle/<agent DID>, ownerPrincipal). ` +
1101
+ 'A gateway is bound to the owner of its credentials, so none was scaffolded. Retry when the API is reachable (it needs a ' +
1102
+ 'backend that names the owner), or pass --no-gateway for the single-process example (not an enforcement boundary).';
1103
+
1036
1104
  const NO_POLICY_KEY = (base) =>
1037
1105
  `Could not get MetaMynd's policy-signing key: the provisioning response carried none, and GET ${base}/magp/policy/pubkey ` +
1038
1106
  'did not return one. A gateway without it would trust whatever policy bundle its network fetch returns, so none was ' +
@@ -1072,12 +1140,66 @@ function keepConfigAndFail(outDir, config, why) {
1072
1140
  * guard.escalationStatus(id) for the authorization and presents it to run the action (MAGP §8.7.18). Generated code: it
1073
1141
  * reads `g`, the refusal's governance verdict, and `dim`, both in scope at every call site.
1074
1142
  */
1075
- function escalateNote(sandbox) {
1076
- return sandbox
1077
- ? `console.log(dim(' nobody can approve it here: the shared sandbox has no owner you can sign in as.'));
1078
- console.log(dim(' Provision your own agent to approve one in your dashboard.'));`
1079
- : `console.log(dim(' not a failure: it waits in your dashboard, under Escalations' + (g.escalationId ? ' (' + g.escalationId + ')' : '') + '.'));
1143
+ function escalateNote(sandbox, argsExpr = null) {
1144
+ if (sandbox) {
1145
+ return `console.log(dim(' nobody can approve it here: the shared sandbox has no owner you can sign in as.'));
1146
+ console.log(dim(' Provision your own agent to approve one in your dashboard.'));`;
1147
+ }
1148
+ if (!argsExpr) {
1149
+ return `console.log(dim(' not a failure: it waits in your dashboard, under Escalations' + (g.escalationId ? ' (' + g.escalationId + ')' : '') + '.'));
1080
1150
  console.log(dim(' Once you approve it, guard.escalationStatus(id) gives the agent the authorization to run it.'));`;
1151
+ }
1152
+ // Resumable (0.14.21; pre-beta rerun 3, E-1): the call is saved so `npm run resume -- <id>` can run it, once approved.
1153
+ return `console.log(dim(' not a failure: it waits in your dashboard, under Escalations' + (g.escalationId ? ' (' + g.escalationId + ')' : '') + '.'));
1154
+ if (g.escalationId) {
1155
+ saveEscalation(g.escalationId, ${argsExpr});
1156
+ console.log(dim(' Approve it there, then run it - exactly once - with: npm run resume -- ' + g.escalationId));
1157
+ }`;
1158
+ }
1159
+
1160
+ /**
1161
+ * The resume path a resumable scaffold carries (0.14.21; pre-beta rerun 3, E-1 — resuming used to mean writing ~15 lines
1162
+ * by hand). `npm start` saves an escalated call's arguments to ./escalations/<id>.json; `npm run resume -- <id>` hands them
1163
+ * to the gated tool's `.resume()` (agentsafe-guard >= 0.21.0), which waits for the owner's decision and then runs the tool
1164
+ * exactly once under the authorization the approval minted. Generated code; `tool` is the gated tool's variable name.
1165
+ */
1166
+ function resumeBlock(tool) {
1167
+ return `// --- Escalations: \`npm run resume -- <escalationId>\` runs a call your owner APPROVED, exactly once. ---
1168
+ // npm start saves an escalated call's arguments to ./escalations/<id>.json. Approve it in the dashboard (AgentSafe ->
1169
+ // Escalations), then resume it: ${tool}.resume() waits for the decision and runs the tool once, under the authorization
1170
+ // the approval minted. Your own agent does the same with the escalationId from the refusal (e.governance.escalationId).
1171
+ import { mkdirSync as __mkdir, writeFileSync as __write, readFileSync as __read, rmSync as __rm } from 'node:fs';
1172
+ const ESCALATIONS = new URL('./escalations/', import.meta.url);
1173
+ const escalationFile = (id) => {
1174
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(String(id ?? ''))) throw new Error('not an escalation id: ' + id);
1175
+ return new URL(id + '.json', ESCALATIONS);
1176
+ };
1177
+ function saveEscalation(id, args) {
1178
+ __mkdir(ESCALATIONS, { recursive: true });
1179
+ __write(escalationFile(id), JSON.stringify(args, null, 2));
1180
+ }
1181
+ if (process.argv[2] === '--resume') {
1182
+ const id = process.argv[3];
1183
+ let saved;
1184
+ try { saved = JSON.parse(__read(escalationFile(id), 'utf8')); } catch {
1185
+ console.error(' No saved escalation "' + (id ?? '') + '". Run npm start first, then: npm run resume -- <escalationId>');
1186
+ process.exit(1);
1187
+ }
1188
+ console.log(' waiting for your decision on ' + id + ' (approve it in the dashboard: AgentSafe -> Escalations) ...');
1189
+ try {
1190
+ const result = await ${tool}.resume(id, saved);
1191
+ __rm(escalationFile(id), { force: true }); // used: a second resume has nothing to run
1192
+ console.log('\\x1b[32m RAN\\x1b[0m approved, and run exactly once: ' + JSON.stringify(result));
1193
+ } catch (e) {
1194
+ const g = e.governance ?? {};
1195
+ // A final answer (rejected, already used, expired) retires the saved call; still pending or unreachable keeps it.
1196
+ if (g.decision === 'block' && g.reasonCode !== 'GATE_UNREACHABLE') __rm(escalationFile(id), { force: true });
1197
+ console.log('\\x1b[31m NOT RUN\\x1b[0m ' + (g.reasonCode ?? e.message) + (g.status ? ' (' + g.status + ')' : ''));
1198
+ process.exitCode = 1;
1199
+ }
1200
+ process.exit();
1201
+ }
1202
+ `;
1081
1203
  }
1082
1204
 
1083
1205
  function exampleIndexNoGateway(scope, perTxnMax, currency, merchant, sandbox = false) {
@@ -1133,6 +1255,7 @@ const gatedRaiseOwnLimit = guard.guardTool(
1133
1255
  }),
1134
1256
  );
1135
1257
 
1258
+ ${sandbox ? '' : resumeBlock('gatedBookFlight')}
1136
1259
  const dim = (t) => '\\x1b[2m' + t + '\\x1b[0m';
1137
1260
  const bold = (t) => '\\x1b[1m' + t + '\\x1b[0m';
1138
1261
  const rule = (n) => ' ' + '-'.repeat(n);
@@ -1148,7 +1271,7 @@ const WHY = {
1148
1271
  // gate, and neither of them depends on the amount.
1149
1272
  NO_PERMISSION_FOR_ACTION: 'the mandate never granted this action - at any amount',
1150
1273
  NO_MANDATE: 'there is no mandate for this action at all',
1151
- MANDATE_REVOKED: 'the owner revoked the mandate for this action',
1274
+ MANDATE_REVOKED: 'the owner revoked the mandate for this action',
1152
1275
  };
1153
1276
 
1154
1277
  // ---------------------------------------------------------------- 1. CONTEXT
@@ -1168,7 +1291,9 @@ console.log('');
1168
1291
  console.log(dim(' 1. this project holds an agent identity (a DID) and its signing key'));
1169
1292
  console.log(dim(' 2. that agent has a mandate - a scope it may act in, and a spend cap'));
1170
1293
  console.log(dim(' 3. guardTool() wraps your tool, so nothing calls the raw handler'));
1171
- console.log(dim(' 4. each attempt is signed here, then decided by MetaMynd remotely'));
1294
+ console.log(dim(' 4. each attempt is signed here and checked against your signed rules: what'));
1295
+ console.log(dim(' they refuse stops HERE, with no network call; what could run is decided'));
1296
+ console.log(dim(' by the MetaMynd gate, which records it'));
1172
1297
  console.log(dim(' 5. your tool runs ONLY if that decision is ALLOW'));
1173
1298
  console.log('');
1174
1299
  console.log(dim(' scope ${scope}'));
@@ -1178,7 +1303,7 @@ console.log(dim(' cap ${currency} ${perTxnMax} per transaction, set by your
1178
1303
  async function attempt(n, intent, args, tool = gatedBookFlight) {
1179
1304
  console.log('');
1180
1305
  console.log(bold(' Step ' + n + ' of 4') + ' - ' + intent);
1181
- console.log(dim(' signing the request locally, then asking the gate to decide...'));
1306
+ console.log(dim(' signing the request here, checking it against your signed rules...'));
1182
1307
  try {
1183
1308
  const r = await tool(args);
1184
1309
  console.log('\\x1b[32m ALLOWED\\x1b[0m your tool ran and returned ' + (r.pnr ?? 'ok'));
@@ -1189,11 +1314,11 @@ async function attempt(n, intent, args, tool = gatedBookFlight) {
1189
1314
  if (g.decision === 'escalate') {
1190
1315
  console.log('\\x1b[33m ESCALATED\\x1b[0m held for a human - ' + g.reasonCode);
1191
1316
  console.log(dim(' ' + why));
1192
- ${escalateNote(sandbox)}
1317
+ ${escalateNote(sandbox, sandbox ? null : 'args')}
1193
1318
  } else {
1194
1319
  console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? 'refused'));
1195
1320
  console.log(dim(' ' + why));
1196
- console.log(dim(' your tool never ran - the gate refused before execution.'));
1321
+ console.log(dim(g.eventId ? ' decided by the MetaMynd gate - your tool never ran.' : ' decided right here from your signed rules (no network call), and reported for audit - your tool never ran.'));
1197
1322
  }
1198
1323
  }
1199
1324
  }
@@ -1336,6 +1461,7 @@ const gatedRaiseOwnLimit = guard.guardTool(
1336
1461
  }),
1337
1462
  );
1338
1463
 
1464
+ ${resumeBlock('gatedBookFlight')}
1339
1465
  const dim = (t) => '\\x1b[2m' + t + '\\x1b[0m';
1340
1466
  const bold = (t) => '\\x1b[1m' + t + '\\x1b[0m';
1341
1467
  const rule = (n) => ' ' + '-'.repeat(n);
@@ -1352,7 +1478,7 @@ const WHY = {
1352
1478
  // gate, and neither of them depends on the amount.
1353
1479
  NO_PERMISSION_FOR_ACTION: 'the mandate never granted this action - at any amount',
1354
1480
  NO_MANDATE: 'there is no mandate for this action at all',
1355
- MANDATE_REVOKED: 'the owner revoked the mandate for this action',
1481
+ MANDATE_REVOKED: 'the owner revoked the mandate for this action',
1356
1482
  };
1357
1483
 
1358
1484
  // ---------------------------------------------------------------- 1. CONTEXT
@@ -1385,29 +1511,30 @@ console.log(dim(' gateway ' + GATEWAY + ' (run it in a separate terminal - se
1385
1511
  async function attempt(n, intent, args, tool = gatedBookFlight) {
1386
1512
  console.log('');
1387
1513
  console.log(bold(' Step ' + n + ' of 4') + ' - ' + intent);
1388
- console.log(dim(' signing the request locally, then asking the gate to decide...'));
1514
+ console.log(dim(' signing the request here, checking it against your signed rules...'));
1389
1515
  try {
1390
1516
  const r = await tool(args);
1391
1517
  console.log('\\x1b[32m ALLOWED\\x1b[0m your tool ran (in ./gateway) and returned ' + (r.pnr ?? 'ok'));
1392
1518
  console.log(dim(' ' + WHY.AUTHORIZED));
1519
+ return true;
1393
1520
  } catch (e) {
1394
1521
  const g = e.governance ?? {};
1395
1522
  const why = WHY[g.reasonCode] ?? e.message;
1396
1523
  if (g.decision === 'escalate') {
1397
1524
  console.log('\\x1b[33m ESCALATED\\x1b[0m held for a human - ' + g.reasonCode);
1398
1525
  console.log(dim(' ' + why));
1399
- ${escalateNote(false)}
1526
+ ${escalateNote(false, 'args')}
1400
1527
  } else {
1401
1528
  console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? 'refused'));
1402
1529
  console.log(dim(' ' + why));
1403
- console.log(dim(' your tool never ran - refused before execution.'));
1530
+ console.log(dim(g.eventId ? ' decided by the MetaMynd gate - your tool never ran.' : ' decided right here from your signed rules (no network call), and reported for audit - your tool never ran.'));
1404
1531
  }
1405
1532
  }
1406
1533
  }
1407
1534
 
1408
1535
  console.log('');
1409
1536
  console.log(rule(66));
1410
- await attempt(1, 'a ${currency} ${under} booking, low risk. Expected to pass.', { amount: ${under}, currency: '${currency}', merchant: '${merchant}', riskLevel: 'low' });
1537
+ const step1Ran = await attempt(1, 'a ${currency} ${under} booking, low risk. Expected to pass.', { amount: ${under}, currency: '${currency}', merchant: '${merchant}', riskLevel: 'low' });
1411
1538
  await attempt(2, 'a ${currency} ${over} booking, deliberately over the cap.', { amount: ${over}, currency: '${currency}', merchant: '${merchant}', riskLevel: 'low' });
1412
1539
  await attempt(3, 'a ${currency} ${under} booking, but flagged high risk.', { amount: ${under}, currency: '${currency}', merchant: '${merchant}', riskLevel: 'high' });
1413
1540
  await attempt(
@@ -1425,7 +1552,13 @@ console.log(bold(' What this proved'));
1425
1552
  console.log('');
1426
1553
  console.log(dim(' - one code path, three outcomes. The rules decided, not this file'));
1427
1554
  console.log(dim(' and not the model driving it.'));
1428
- console.log(dim(' - step 1 ran in ./gateway, a process this file cannot reach into. There'));
1555
+ // Only claim what happened (pre-beta rerun 2026-10-03, L-h: this said "step 1 ran in ./gateway" when step 1 was refused).
1556
+ if (step1Ran) {
1557
+ console.log(dim(' - step 1 ran in ./gateway, a process this file cannot reach into. There'));
1558
+ } else {
1559
+ console.log(dim(' - step 1 did not run this time (see above). When it does, it runs in'));
1560
+ console.log(dim(' ./gateway, a process this file cannot reach into. There'));
1561
+ }
1429
1562
  console.log(dim(' is no rawBookFlight() here to call instead - that is what actually'));
1430
1563
  console.log(dim(' stops a bypass, not the guardTool() call above it.'));
1431
1564
  console.log(dim(' - step 4 needed no rule to stop it. The agent could not widen its own'));
@@ -1434,7 +1567,7 @@ console.log(dim(' - every blocked/escalated call never reached a real tool at
1434
1567
  console.log(dim(' - if the gate were unreachable the guard fails CLOSED: it blocks.'));
1435
1568
  console.log('');
1436
1569
  console.log(bold(' With MetaMynd, you can\\'t be bypassed.') + ' ./gateway is why - it independently');
1437
- console.log(dim(' re-verified step 1 before running it, and holds the tool this file never can.'));
1570
+ console.log(dim(step1Ran ? ' re-verified step 1 before running it, and holds the tool this file never can.' : ' re-verifies every call before running it, and holds the tool this file never can.'));
1438
1571
  console.log('');
1439
1572
  console.log(' Change the cap in the dashboard (${SOPS_PATH_ASCII}) and run again.');
1440
1573
  console.log(dim(' The outcome changes. This file does not. That is the point.'));
@@ -1544,7 +1677,7 @@ const gatedChangeOwnPermissions = guard.guardTool(
1544
1677
  changeOwnPermissions,
1545
1678
  (a) => ({ context: a }),
1546
1679
  );
1547
-
1680
+ ${!sandbox ? '\n' + resumeBlock('gatedAction') : ''}
1548
1681
  const STEPS = ${JSON.stringify(steps, null, 2)};
1549
1682
  const INPUTS = ${JSON.stringify(demo.inputs)};
1550
1683
  const NOT_STAGED = ${JSON.stringify(demo.notDemonstrated)};
@@ -1553,7 +1686,7 @@ async function attempt(n, total, step) {
1553
1686
  const tool = step.action === 'permissions.update' ? gatedChangeOwnPermissions : gatedAction;
1554
1687
  console.log('');
1555
1688
  console.log(bold(' Step ' + n + ' of ' + total) + ' - ' + step.intent);
1556
- console.log(dim(' signing the request locally, then asking the gate to decide...'));
1689
+ console.log(dim(' signing the request here, checking it against your signed rules...'));
1557
1690
  let got;
1558
1691
  let g = {};
1559
1692
  try {
@@ -1565,10 +1698,10 @@ async function attempt(n, total, step) {
1565
1698
  got = g.decision ?? 'error';
1566
1699
  if (g.decision === 'escalate') {
1567
1700
  console.log('\\x1b[33m ESCALATED\\x1b[0m held for a human - ' + g.reasonCode);
1568
- ${escalateNote(sandbox)}
1701
+ ${escalateNote(sandbox, !sandbox ? 'step.context' : null)}
1569
1702
  } else {
1570
1703
  console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? e.message));
1571
- console.log(dim(' your tool never ran - refused before execution.'));
1704
+ console.log(dim(g.eventId ? ' decided by the MetaMynd gate - your tool never ran.' : ' decided right here from your signed rules (no network call), and reported for audit - your tool never ran.'));
1572
1705
  }
1573
1706
  }
1574
1707
  const asExpected = got === step.expect && (!step.reasonCode || g.reasonCode === step.reasonCode);
@@ -1633,7 +1766,7 @@ console.log('');
1633
1766
  * allowed value-less request would be refused AUTHORIZATION_REQUIRED. The README states what that
1634
1767
  * leaves open (replay) rather than implying the financial scaffold's guarantees.
1635
1768
  */
1636
- function gatewayServerFileNeutral(scope, port, apiBase, policyKey) {
1769
+ function gatewayServerFileNeutral(scope, port, apiBase, policyKey, agentDid, ownerPrincipal) {
1637
1770
  return `#!/usr/bin/env node
1638
1771
  // gateway/server.mjs — the enforcement boundary for this agent's tool(s).
1639
1772
  //
@@ -1643,6 +1776,7 @@ function gatewayServerFileNeutral(scope, port, apiBase, policyKey) {
1643
1776
  // compromised or dishonest agent calling its own local function gets nothing here, because
1644
1777
  // there is no local function: the tool only runs in this process.
1645
1778
  import http from 'node:http';
1779
+ import { readFileSync } from 'node:fs';
1646
1780
  import { createMcpGuard } from '${MCP_GUARD_PKG}';
1647
1781
  import { createHttpGateway } from '${GATEWAY_PKG}';
1648
1782
 
@@ -1666,6 +1800,11 @@ async function performAction(args) {
1666
1800
  // exactly those keys here — the gateway refuses every top-level key you do not name.
1667
1801
  const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFields: [], allowedFields: [] }];
1668
1802
 
1803
+ // This gateway's OWN identity (a did:key, separate from the agent's), registered as your counterparty when
1804
+ // \`npx create-metamynd-agent\` provisioned it. It signs the reports below, so the issuer files them under YOUR account and
1805
+ // nobody else can write into it. Keep service.metamynd.json out of version control (it is gitignored).
1806
+ const identity = JSON.parse(readFileSync(new URL('./service.metamynd.json', import.meta.url)));
1807
+
1669
1808
  // requireAuthorization is OFF on purpose. It makes the gateway claim a single-use, stateful
1670
1809
  // authorization before running the tool — but the agent's guard only seals one for a value-bearing
1671
1810
  // action (amount > 0), and this agent has no spending authority. With it ON, every ALLOWED request
@@ -1677,11 +1816,28 @@ const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFie
1677
1816
  // forged bundle). Without it, verifyBundle() never runs at all: an attacker who can intercept the
1678
1817
  // fetch to \`\${MAGP_API}/policy/bundle/...\` — a MITM, a compromised DNS/proxy — can hand this gateway
1679
1818
  // a bundle with a higher cap or no rules, and it would be trusted the same as the real one.
1680
- const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API, requireAuthorization: false, policyPublicKey: '${policyKey}' });
1819
+ // allowedAgents pins this gateway to the ONE agent it was scaffolded for (MAGP §16.3). Every other check here judges the
1820
+ // caller by the caller's OWN mandate and SOP — written by the caller's owner. Without the pin, any agent on the platform
1821
+ // whose owner granted it this same action would run your tool with your credentials (refused AGENT_NOT_ADMITTED instead).
1822
+ // Add a DID here only for another agent you mean this gateway to act for. gatewayOwnerPrincipal binds it to YOU, the owner of
1823
+ // its credentials: an agent listed here that another principal owns is refused anyway (GATEWAY_OWNER_MISMATCH).
1824
+ // honourApprovals: with requireAuthorization off this gateway claims nothing — except an escalated call that presents the
1825
+ // authorization your APPROVAL minted (Dashboard -> Escalations). That one it claims as an approval: the issuer grants it
1826
+ // once, only for a hold a person approved, for this agent and these values, so the approved call runs here exactly once.
1827
+ const guard = createMcpGuard({ serviceDid: identity.serviceDid, serviceKey: identity.serviceKey ?? undefined, issuerApi: MAGP_API, requireAuthorization: false, policyPublicKey: '${policyKey}', allowedAgents: ['${agentDid}'], gatewayOwnerPrincipal: '${ownerPrincipal}', honourApprovals: true });
1828
+ // A guard below 0.22.0 cannot enforce both pins (below 0.20.0 it ignores allowedAgents and serves every agent; 0.22.0 added gatewayOwnerPrincipal): refuse to start on one (a stale lockfile, say).
1829
+ if (!Array.isArray(guard.allowedAgents) || !guard.gatewayOwnerPrincipal) throw new Error('this gateway needs @metamynd/agentsafe-mcp-guard >= 0.22.0 to enforce allowedAgents and gatewayOwnerPrincipal - run npm install');
1830
+ // A guard below 0.25.0 ignores honourApprovals, and an approved escalation would stay escalated here: refuse to start on one.
1831
+ if (guard.honoursApprovals !== true) throw new Error('this gateway needs @metamynd/agentsafe-mcp-guard >= 0.25.0 to run approved escalations - run npm install');
1681
1832
 
1682
1833
  const gateway = createHttpGateway({
1683
1834
  guard,
1684
1835
  routes,
1836
+ // Every request this gateway runs or refuses is reported, signed with the identity above, to your Activity Log (MAGP
1837
+ // 16.4). It claims only an approved escalation, so without this the issuer would never see what it executed - or that another agent tried.
1838
+ reportOutcomes: true,
1839
+ // A report the issuer could not take (it was down, say) is kept here and re-sent, so nothing it saw is lost. Gitignored.
1840
+ reportSpool: './reports.spool.jsonl',
1685
1841
  forward: async (req) => {
1686
1842
  let args = {};
1687
1843
  try { args = JSON.parse(req.rawBody?.toString('utf8') || '{}'); } catch { /* empty body */ }
@@ -1762,6 +1918,11 @@ the agent gets nothing, and there is no local function to call directly. The pol
1762
1918
  pinned to MetaMynd's own signing key (\`policyPublicKey\`, baked in from your provisioning response) —
1763
1919
  a party that can intercept the bundle fetch (a MITM, a compromised DNS/proxy) cannot hand this gateway
1764
1920
  a forged bundle with a higher cap or no rules; \`server.mjs\` refuses an unsigned or tampered one outright.
1921
+ It is also pinned to THIS agent, and bound to you as the owner of its credentials (\`allowedAgents\` and
1922
+ \`gatewayOwnerPrincipal\` in \`server.mjs\`, MAGP 16.3): any other agent - even one whose own
1923
+ owner granted it this same action - is refused \`AGENT_NOT_ADMITTED\` before its rules are read. Every request it runs or
1924
+ refuses is reported, signed with its own registered identity (\`service.metamynd.json\`), to your Activity Log (MAGP 16.4) -
1925
+ it claims nothing, so this is the only way what it ran reaches you.
1765
1926
 
1766
1927
  **NOT closed — be precise about this:**
1767
1928
 
@@ -1801,10 +1962,18 @@ Replace \`performAction()\` in \`server.mjs\` with your implementation and put a
1801
1962
 
1802
1963
  /** The README for a non-financial hosted scaffold. `withGateway` selects the two-process shape. */
1803
1964
  /** The generated README's "Change the rules" body: the dashboard path, or — for the shared sandbox agent — how to get an agent whose rules you can change. */
1804
- function changeRulesSection(sandbox) {
1965
+ function changeRulesSection(sandbox, resumable = false) {
1805
1966
  return sandbox
1806
1967
  ? `${SANDBOX_RULES_FIXED} An \`escalate\` verdict is held for an owner to approve; poll \`guard.escalationStatus(id)\`.`
1807
- : `Edit the agent's SOPs in the dashboard (${SOPS_PATH}). The agent's behaviour changes live —
1968
+ : resumable
1969
+ ? `Edit the agent's SOPs in the dashboard (${SOPS_PATH}). The agent's behaviour changes live —
1970
+ no redeploy.
1971
+
1972
+ An \`escalate\` verdict is held for an owner to approve. \`npm start\` saves an escalated call and prints its id; approve it
1973
+ in the dashboard (AgentSafe → Escalations), then \`npm run resume -- <escalationId>\` runs it exactly once under the
1974
+ authorization the approval minted. In your own agent that is one call: \`await gatedTool.resume(escalationId, sameArgs)\`
1975
+ (\`@metamynd/agentsafe-guard\` ≥ 0.21.0) — pass the SAME arguments; the approval is bound to that request.`
1976
+ : `Edit the agent's SOPs in the dashboard (${SOPS_PATH}). The agent's behaviour changes live —
1808
1977
  no redeploy. An \`escalate\` verdict is held for an owner to approve; poll \`guard.escalationStatus(id)\`.`;
1809
1978
  }
1810
1979
 
@@ -1887,7 +2056,7 @@ ${clonedFreshSection(daemonKey)}
1887
2056
 
1888
2057
  ## Change the rules
1889
2058
 
1890
- ${changeRulesSection(sandbox)}
2059
+ ${changeRulesSection(sandbox, !sandbox)}
1891
2060
 
1892
2061
  ## What this is not
1893
2062
 
@@ -1906,7 +2075,7 @@ Full integration guide: \`docs/integration/INTEGRATE-WITH-METAMYND.md\`.
1906
2075
  `;
1907
2076
  }
1908
2077
 
1909
- function examplePackageJson(slug, neutral = false) {
2078
+ function examplePackageJson(slug, neutral = false, resumable = false) {
1910
2079
  return JSON.stringify(
1911
2080
  {
1912
2081
  name: slug,
@@ -1916,7 +2085,7 @@ function examplePackageJson(slug, neutral = false) {
1916
2085
  // `verify` is scaffolded in because governance that lives only in a dashboard is a
1917
2086
  // thing someone has to remember to look at. As a build step it is a control: a change
1918
2087
  // that widens this agent's authority fails `npm test`.
1919
- scripts: { start: 'node index.mjs', test: neutral ? 'agentsafe-guard verify --context ./verify-context.json' : 'agentsafe-guard verify' },
2088
+ scripts: { start: 'node index.mjs', ...(resumable ? { resume: 'node index.mjs --resume' } : {}), test: neutral ? 'agentsafe-guard verify --context ./verify-context.json' : 'agentsafe-guard verify' },
1920
2089
  dependencies: { [GUARD_PKG]: GUARD_VERSION },
1921
2090
  },
1922
2091
  null,
@@ -2018,14 +2187,14 @@ ${clonedFreshSection(daemonKey)}
2018
2187
 
2019
2188
  ## Change the rules
2020
2189
 
2021
- ${changeRulesSection(sandbox)}
2190
+ ${changeRulesSection(sandbox, !sandbox)}
2022
2191
 
2023
2192
  Full integration guide: \`docs/integration/INTEGRATE-WITH-METAMYND.md\`.
2024
2193
  `;
2025
2194
  }
2026
2195
 
2027
2196
  function gitignore() {
2028
- return `node_modules/\nagent.metamynd.json\n.env\n`;
2197
+ return `node_modules/\nagent.metamynd.json\n.env\nescalations/\n`;
2029
2198
  }
2030
2199
 
2031
2200
  // ---------- the default hosted scaffold's second process: a separate tool gateway ----------
@@ -2037,7 +2206,7 @@ function gitignore() {
2037
2206
  // in the AgentSafe repo for the full pattern (mutual handshake, x402 payment, capability
2038
2207
  // binding) this is a minimal slice of.
2039
2208
 
2040
- function gatewayServerFile(scope, port, apiBase, policyKey) {
2209
+ function gatewayServerFile(scope, port, apiBase, policyKey, agentDid, ownerPrincipal) {
2041
2210
  return `#!/usr/bin/env node
2042
2211
  // gateway/server.mjs — the REAL enforcement boundary for this agent's tool(s).
2043
2212
  //
@@ -2092,7 +2261,16 @@ const identity = JSON.parse(readFileSync(new URL('./service.metamynd.json', impo
2092
2261
  // forged bundle). Without it, verifyBundle() never runs at all: an attacker who can intercept the
2093
2262
  // fetch to \`\${MAGP_API}/policy/bundle/...\` — a MITM, a compromised DNS/proxy — can hand this gateway
2094
2263
  // a bundle with a higher cap or no rules, and it would be trusted the same as the real one.
2095
- const guard = createMcpGuard({ serviceDid: identity.serviceDid, serviceKey: identity.serviceKey ?? undefined, issuerApi: MAGP_API, requireAuthorization: true, policyPublicKey: '${policyKey}' });
2264
+ // allowedAgents pins this gateway to the ONE agent it was scaffolded for (MAGP §16.3). Every other check here judges the
2265
+ // caller by the caller's OWN mandate and SOP — written by the caller's owner. Without the pin, any agent on the platform
2266
+ // whose owner granted it this same action would run your tool with your credentials (refused AGENT_NOT_ADMITTED instead).
2267
+ // Add a DID here only for another agent you mean this gateway to act for. gatewayOwnerPrincipal binds it to YOU, the owner of
2268
+ // its credentials: an agent listed here that another principal owns is refused anyway (GATEWAY_OWNER_MISMATCH).
2269
+ // The claim below does not stop that on its own: the hold belongs to the CALLER, and the caller's owner decides which
2270
+ // services may claim it — including registering this gateway's DID as one of theirs.
2271
+ const guard = createMcpGuard({ serviceDid: identity.serviceDid, serviceKey: identity.serviceKey ?? undefined, issuerApi: MAGP_API, requireAuthorization: true, policyPublicKey: '${policyKey}', allowedAgents: ['${agentDid}'], gatewayOwnerPrincipal: '${ownerPrincipal}' });
2272
+ // A guard below 0.22.0 cannot enforce both pins (below 0.20.0 it ignores allowedAgents and serves every agent; 0.22.0 added gatewayOwnerPrincipal): refuse to start on one (a stale lockfile, say).
2273
+ if (!Array.isArray(guard.allowedAgents) || !guard.gatewayOwnerPrincipal) throw new Error('this gateway needs @metamynd/agentsafe-mcp-guard >= 0.22.0 to enforce allowedAgents and gatewayOwnerPrincipal - run npm install');
2096
2274
 
2097
2275
  const gateway = createHttpGateway({
2098
2276
  guard,
@@ -2102,6 +2280,11 @@ const gateway = createHttpGateway({
2102
2280
  // above lists which keys may appear; this makes the VALUES of every one of them (a payee, a passenger list) the agent's too.
2103
2281
  // A request whose authorization bound no payload is refused (PAYLOAD_BINDING_REQUIRED) rather than run unbound.
2104
2282
  requirePayloadBinding: true,
2283
+ // What this gateway refuses itself (another agent, a rule, a binding failure) is reported, signed, to your Activity Log
2284
+ // (MAGP 16.4). Claimed executions are already there, through the claim.
2285
+ reportOutcomes: true,
2286
+ // A report the issuer could not take (it was down, say) is kept here and re-sent, so nothing it saw is lost. Gitignored.
2287
+ reportSpool: './reports.spool.jsonl',
2105
2288
  forward: async (req) => {
2106
2289
  let args = {};
2107
2290
  try { args = JSON.parse(req.rawBody?.toString('utf8') || '{}'); } catch { /* empty body */ }
@@ -2168,7 +2351,7 @@ function gatewayEnvExample() {
2168
2351
  }
2169
2352
 
2170
2353
  function gatewayGitignore() {
2171
- return `node_modules/\n.env\nservice.metamynd.json\n`;
2354
+ return `node_modules/\n.env\nservice.metamynd.json\nreports.spool.jsonl\nreports.spool.jsonl.flushing\n`;
2172
2355
  }
2173
2356
 
2174
2357
  function gatewayReadme(slug, scope, port) {
@@ -2227,14 +2410,20 @@ add another protected route here rather than adding a local function back in \`i
2227
2410
 
2228
2411
  ## What this closes, precisely
2229
2412
 
2230
- Four independent checks, each closing a different bypass an agent (or anything able to call its
2231
- own code, or a network attacker) might attempt:
2413
+ Five independent checks, each closing a different bypass an agent (or anything able to call its
2414
+ own code, or a network attacker) might attempt - and a record of what it turned away:
2232
2415
 
2233
2416
  - **Direct call.** \`bookFlight()\` doesn't exist in the agent's process. There's nothing to call.
2234
2417
  - **Forged policy bundle.** \`server.mjs\` bakes in \`policyPublicKey\` (from your provisioning
2235
2418
  response) and refuses an unsigned or tampered bundle outright — a party that can intercept the
2236
2419
  fetch (a MITM, a compromised DNS/proxy) cannot hand this gateway a bundle with a higher cap or
2237
2420
  no rules and have it trusted the same as the real one.
2421
+ - **Another agent.** Every other check judges the caller by the caller's OWN mandate, which its own owner
2422
+ writes. \`server.mjs\` pins \`allowedAgents\` to this agent and \`gatewayOwnerPrincipal\` to you (MAGP 16.3), so another agent - even one whose
2423
+ owner granted it this action and registered this gateway as its counterparty - is refused
2424
+ \`AGENT_NOT_ADMITTED\` before anything is claimed.
2425
+ - **Visibility.** What it refuses itself (another agent, a rule, a binding failure) is reported, signed with its own
2426
+ registered identity, to your Activity Log (MAGP 16.4); what it executes is already there, through the claim.
2238
2427
  - **Confused deputy (payload).** The gateway re-verifies the signed request against this agent's
2239
2428
  own policy AND binds it to the actual request body (payload binding,
2240
2429
  \`@metamynd/agentsafe-http-gateway\` ≥ 0.4.5) — signing a cheap request while executing an
@@ -2274,17 +2463,33 @@ on purpose: an agent must never be able to release or lower-settle a hold it aut
2274
2463
  provisioning, \`npx create-metamynd-agent\` registered that DID as a trusted counterparty for you
2275
2464
  (\`POST /policy/counterparties\`). A **MAINNET** hold is *always* registered-only, regardless of your
2276
2465
  registry (MAGP §8.7.10) — a testnet hold stays open unless you have registered anything at all.
2277
- Manage the registry at \`/dashboard/counterparties\`, or:
2466
+ Manage the registry at \`/dashboard/counterparties\` (it walks you through the proof below), or
2467
+ re-register this gateway yourself — after removing it from the registry, say.
2468
+
2469
+ Registration needs **proof of control** (MAGP §8.7.6): you ask for a challenge naming you, this
2470
+ gateway signs it with its own key from \`service.metamynd.json\`, and you register with that
2471
+ signature. A DID is never registered on its text alone, so nobody can register a gateway they do
2472
+ not hold the key for. Run this from this directory (bash; \`TOKEN\` is your owner access token):
2278
2473
 
2279
2474
  \`\`\`bash
2280
- curl -X POST $MAGP_API/policy/counterparties \\
2281
- -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \\
2282
- -d '{ "did": "'"$(node -e "console.log(JSON.parse(require('fs').readFileSync('./service.metamynd.json')).serviceDid)")"'", "confirmEnforcementChange": true }'
2475
+ MAGP_API=https://metamynd.ai/api/v1 TOKEN=<owner access token> node --input-type=module -e "
2476
+ import { readFileSync } from 'node:fs'; import crypto from 'node:crypto';
2477
+ const { serviceDid, serviceKey } = JSON.parse(readFileSync('./service.metamynd.json', 'utf8'));
2478
+ const api = process.env.MAGP_API, headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + process.env.TOKEN };
2479
+ const post = (path, body) => fetch(api + path, { method: 'POST', headers, body: JSON.stringify(body) });
2480
+ const ch = (await (await post('/policy/counterparties/challenge', { did: serviceDid, purpose: 'claim' })).json()).data;
2481
+ const key = crypto.createPrivateKey({ key: Buffer.from(serviceKey, 'hex'), format: 'der', type: 'pkcs8' });
2482
+ const signature = crypto.sign(null, Buffer.from(ch.message, 'utf8'), key).toString('hex');
2483
+ const r = await post('/policy/counterparties', { did: serviceDid, purpose: 'claim', proof: { challengeToken: ch.challengeToken, signature }, confirmEnforcementChange: true });
2484
+ console.log(r.status, await r.text());
2485
+ "
2283
2486
  \`\`\`
2284
2487
 
2285
- \`confirmEnforcementChange: true\` is required only for your account's FIRST-EVER registration —
2286
- it switches EVERY one of your holds (not just this agent's) from open to registered-only, so the
2287
- API refuses a first registration without it.
2488
+ The gateway's own \`guard.acceptCounterpartyChallenge(message)\` (\`@metamynd/agentsafe-mcp-guard\`
2489
+ ≥ 0.23.0) signs the same thing, if you would rather expose it from \`server.mjs\`.
2490
+ \`confirmEnforcementChange: true\` matters only for your account's FIRST-EVER registration — it
2491
+ switches EVERY one of your holds (not just this agent's) from open to registered-only, so the API
2492
+ refuses a first registration without it.
2288
2493
  - **Amount unknown.** \`amount-unknown\` (\`@metamynd/agentsafe-mcp-guard\` ≥ 0.3.0) blocks a
2289
2494
  platform tool by default when its raw bytes or a nested payload hide the amount from a naive
2290
2495
  spend cap — AND this agent's OWN starter SOP (see \`agent.metamynd.json\` /
@@ -2365,7 +2570,8 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, currency = 'U
2365
2570
  : exampleIndexNoGateway(scope, perTxnMax, currency, paymentMerchant, !!sandbox),
2366
2571
  force,
2367
2572
  );
2368
- writeFileSafe(outDir, 'package.json', examplePackageJson(slug, neutral), force);
2573
+ // Resumable = the index carries the resume path (resumeBlock): every agent but the shared sandbox, where nobody can approve.
2574
+ writeFileSafe(outDir, 'package.json', examplePackageJson(slug, neutral, !sandbox), force);
2369
2575
  writeFileSafe(outDir, '.gitignore', gitignore(), force);
2370
2576
  writeFileSafe(
2371
2577
  outDir,
@@ -2396,20 +2602,30 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, currency = 'U
2396
2602
  if (!policyKey) {
2397
2603
  throw new Error("scaffoldProject: a gateway needs the issuer's policy-signing key (config.issuer.policyKey) to pin its bundle — call ensurePolicyKey() first, or pass withGateway: false");
2398
2604
  }
2605
+ // The gateway acts for exactly this agent (allowedAgents, MAGP §16.3) — never scaffold one that would pin to nobody.
2606
+ if (!config.agentDid) {
2607
+ throw new Error('scaffoldProject: a gateway is pinned to the agent it serves (config.agentDid) — none was given; pass withGateway: false for an agent-only scaffold');
2608
+ }
2609
+ // It is written into generated JavaScript as a string literal: anything but a plain DID is refused, never quoted around.
2610
+ if (!SAFE_DID.test(config.agentDid)) throw new Error(`scaffoldProject: config.agentDid is not a DID: ${JSON.stringify(config.agentDid)}`);
2611
+ // ...and to the owner of its credentials (gatewayOwnerPrincipal, MAGP §16.3), read from the agent's bundle (ensureOwnerPrincipal).
2612
+ if (typeof config.ownerPrincipal !== 'string' || !SAFE_DID.test(config.ownerPrincipal)) {
2613
+ throw new Error('scaffoldProject: a gateway is bound to the principal that owns its credentials (config.ownerPrincipal) — call ensureOwnerPrincipal() first, or pass withGateway: false');
2614
+ }
2399
2615
  const gwDir = join(outDir, 'gateway');
2400
2616
  if (!existsSync(gwDir)) mkdirSync(gwDir, { recursive: true });
2401
- if (!neutral) {
2617
+ {
2402
2618
  // The gateway's OWN identity — the one that claims and settles this agent's holds (MAGP §8.7.6/
2403
2619
  // §8.7.10), separate from the agent's own DID on purpose (an agent must not be able to release or
2404
- // lower-settle a hold it authorized itself). `gatewayIdentity` is real and registered when the
2405
- // caller generated + registered one (main()'s hosted-financial path); a caller that didn't
2620
+ // lower-settle a hold it authorized itself), and the key its reports are signed with (MAGP 16.4) on either shape.
2621
+ // `gatewayIdentity` is real and registered when the caller generated + registered one (main()'s hosted path); a caller that didn't
2406
2622
  // (an older code path, or a direct scaffoldProject() call in a test) falls back to the historical
2407
2623
  // non-cryptographic placeholder — same shape as before this field existed, still parses and runs,
2408
2624
  // just unable to claim a MAINNET hold or sign an authenticated claim on any network.
2409
2625
  const identity = gatewayIdentity ?? { did: `did:local:${scope}-gateway`, keyHex: null };
2410
2626
  writeFileSafe(gwDir, 'service.metamynd.json', JSON.stringify({ serviceDid: identity.did, serviceKey: identity.keyHex }, null, 2) + '\n', force, 0o600);
2411
2627
  }
2412
- writeFileSafe(gwDir, 'server.mjs', neutral ? gatewayServerFileNeutral(scope, gatewayPort, apiBase, policyKey) : gatewayServerFile(scope, gatewayPort, apiBase, policyKey), force);
2628
+ writeFileSafe(gwDir, 'server.mjs', neutral ? gatewayServerFileNeutral(scope, gatewayPort, apiBase, policyKey, config.agentDid, config.ownerPrincipal) : gatewayServerFile(scope, gatewayPort, apiBase, policyKey, config.agentDid, config.ownerPrincipal), force);
2413
2629
  writeFileSafe(gwDir, 'package.json', gatewayPackageJson(slug), force);
2414
2630
  writeFileSafe(gwDir, '.env.example', neutral ? gatewayEnvExampleNeutral() : gatewayEnvExample(), force);
2415
2631
  writeFileSafe(gwDir, '.gitignore', gatewayGitignore(), force);
@@ -2638,6 +2854,8 @@ const PORT = Number(process.env.PORT || ${gatewayPort});
2638
2854
  // fails BUNDLE_SUBJECT_MISMATCH, not just an unmatched-signature error, because the bundle
2639
2855
  // this gateway serves is only ever this one agent's.
2640
2856
  const AGENT_DID = '${agentDid}';
2857
+ // No principal exists offline: the harness bundle and this gateway name the same local owner (MAGP §16.3).
2858
+ const HARNESS_OWNER = 'did:local:harness-owner';
2641
2859
 
2642
2860
  // Reshapes the harness's own rules-file shape ({mandate, sops:[{standardKey,document}],
2643
2861
  // standards:[{standardKey,document}]}) into what agentsafe-mcp-guard's verifyRequest expects
@@ -2658,12 +2876,16 @@ function bundleFromRules(rules) {
2658
2876
  // and your agent process both read, so editing it takes effect on the next request everywhere.
2659
2877
  const guard = createMcpGuard({
2660
2878
  serviceDid: 'did:local:${scope}-gateway',
2879
+ allowedAgents: [AGENT_DID], // MAGP §16.3: any other agent is refused AGENT_NOT_ADMITTED before its rules are read
2880
+ gatewayOwnerPrincipal: HARNESS_OWNER, // the local stand-in for the principal that owns this gateway's credentials
2661
2881
  fetchBundle: async (agentDid) => {
2662
2882
  if (agentDid !== AGENT_DID) return { subject: AGENT_DID, mandates: [], sops: [], standards: [] };
2663
2883
  const rules = JSON.parse(readFileSync('../metamynd-rules.json', 'utf8'));
2664
- return { subject: AGENT_DID, ...bundleFromRules(rules) };
2884
+ return { subject: AGENT_DID, ownerPrincipal: HARNESS_OWNER, ...bundleFromRules(rules) };
2665
2885
  },
2666
2886
  });
2887
+ // A guard below 0.22.0 cannot enforce both pins (below 0.20.0 it ignores allowedAgents and serves every agent; 0.22.0 added gatewayOwnerPrincipal): refuse to start on one (a stale lockfile, say).
2888
+ if (!Array.isArray(guard.allowedAgents) || !guard.gatewayOwnerPrincipal) throw new Error('this gateway needs @metamynd/agentsafe-mcp-guard >= 0.22.0 to enforce allowedAgents and gatewayOwnerPrincipal - run npm install');
2667
2889
 
2668
2890
  // One protected route per gated action in index.mjs. This gateway IS the tool, not a proxy in
2669
2891
  // front of one, so a path with no route below is refused (denyByDefault) — nothing to fall through TO.
@@ -3232,7 +3454,7 @@ const WHY = {
3232
3454
  MERCHANT_NOT_ALLOWED: 'the mandate lists which merchants this agent may pay',
3233
3455
  NO_PERMISSION_FOR_ACTION: 'the mandate never granted this action - at any amount',
3234
3456
  NO_MANDATE: 'there is no mandate for this action at all',
3235
- MANDATE_REVOKED: 'the owner revoked the mandate for this action',
3457
+ MANDATE_REVOKED: 'the owner revoked the mandate for this action',
3236
3458
  };
3237
3459
 
3238
3460
  async function attempt(n, intent, action, args, tool = gatedBookFlight) {
@@ -3258,7 +3480,7 @@ async function attempt(n, intent, action, args, tool = gatedBookFlight) {
3258
3480
  } else {
3259
3481
  console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? 'refused'));
3260
3482
  console.log(dim(' ' + why));
3261
- console.log(dim(' your tool never ran - the gate refused before execution.'));
3483
+ console.log(dim(g.eventId ? ' decided by the MetaMynd gate - your tool never ran.' : ' decided right here from your signed rules (no network call), and reported for audit - your tool never ran.'));
3262
3484
  }
3263
3485
  dashboard.logDecision(action, args, g);
3264
3486
  }
@@ -3455,7 +3677,7 @@ async function attempt(n, total, step) {
3455
3677
  }
3456
3678
  } else {
3457
3679
  console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? e.message));
3458
- console.log(dim(' your tool never ran - the gate refused before execution.'));
3680
+ console.log(dim(g.eventId ? ' decided by the MetaMynd gate - your tool never ran.' : ' decided right here from your signed rules (no network call), and reported for audit - your tool never ran.'));
3459
3681
  }
3460
3682
  dashboard.logDecision(action, step.context, g);
3461
3683
  }
@@ -3928,6 +4150,7 @@ async function runClaim(args) {
3928
4150
  keepConfigAndStop('--financial was passed, but the approved agent has NO spending authority (the request was non-financial).');
3929
4151
  }
3930
4152
  if (!args['no-gateway'] && !(await ensurePolicyKey(config, config.apiBase ?? base))) keepConfigAndStop(NO_POLICY_KEY(base));
4153
+ if (!args['no-gateway'] && !(await ensureOwnerPrincipal(config, config.apiBase || base))) keepConfigAndStop(NO_OWNER_PRINCIPAL(base));
3931
4154
  const slug = slugify(state.name || 'metamynd-agent');
3932
4155
  const outDir = resolve(String(args.out || `./${slug}`));
3933
4156
  if (issuedNonFinancial) {
@@ -4137,22 +4360,26 @@ async function main() {
4137
4360
 
4138
4361
  // 3c. The gateway's OWN identity — a real did:key, separate from the agent's own (an agent must
4139
4362
  // never be able to release or lower-settle a hold it authorized itself; only the SERVICE that
4140
- // claimed it may). Only the financial, with-gateway shape ever claims a hold at all; the
4141
- // non-financial gateway never calls claimAuthorization() (requireAuthorization is off there), so
4142
- // it has no counterparty identity to register.
4363
+ // claimed it may) — and, on either shape, the key that signs its reports to your Activity Log (MAGP 16.4): a
4364
+ // non-financial gateway claims nothing, so without a registered identity nothing it ran would reach you.
4143
4365
  let gatewayIdentity = null;
4144
- if (financial && !args['no-gateway']) {
4366
+ if (!args['no-gateway']) {
4145
4367
  const gwKeypair = generateAgentKeypair();
4146
4368
  const gatewayDid = buildDidKey(rawPublicKeyFromSpkiHex(gwKeypair.publicKeyHex));
4147
4369
  gatewayIdentity = { did: gatewayDid, keyHex: gwKeypair.privateKeyHex };
4148
- console.log(c.dim(`\n → registering the gateway's identity as a trusted counterparty (so it can claim this agent's holds) …`));
4149
- const reg = await registerGatewayCounterparty(base, token, gatewayDid, `${scope}-gateway`);
4370
+ console.log(c.dim(`\n → registering the gateway's identity as a trusted counterparty (${financial ? "so it can claim this agent's holds and report to you" : 'so its reports reach your Activity Log'}) …`));
4371
+ const reg = await registerGatewayCounterparty(base, token, gatewayDid, `${scope}-gateway`, financial ? 'claim' : 'report', gwKeypair.privateKeyHex);
4150
4372
  if (reg.ok) {
4151
4373
  console.log(` ${c.green('✓')} gateway DID ${c.b(gatewayDid)} — ${reg.message}`);
4152
4374
  } else {
4153
4375
  console.log(` ${c.yellow('!')} could not register the gateway as a trusted counterparty: ${reg.message}`);
4154
- console.log(c.dim(` it still works on testnet (open by default with no registry entries); a MAINNET hold cannot be`));
4155
- console.log(c.dim(` claimed until it is registered — POST ${base}/policy/counterparties { "did": "${gatewayDid}", "confirmEnforcementChange": true }`));
4376
+ console.log(c.dim(financial
4377
+ ? ` it still works on testnet (open by default with no registry entries); a MAINNET hold cannot be`
4378
+ : ` it still enforces; only its refusal reports cannot reach your Activity Log until it is`));
4379
+ console.log(c.dim(financial
4380
+ ? ` claimed until it is registered — gateway/README.md ("Who may claim this agent's holds") shows how, with the`
4381
+ : ` registered — at /dashboard/counterparties, with the`));
4382
+ console.log(c.dim(` proof-of-control step registration requires, for ${gatewayDid}`));
4156
4383
  }
4157
4384
  }
4158
4385
 
@@ -4165,6 +4392,7 @@ async function main() {
4165
4392
  for (const n of demo.notDemonstrated) console.log(` ${c.yellow('!')} not staged in the demo: ${n.rule} ${c.dim('— ' + n.why + '; still enforced')}`);
4166
4393
  }
4167
4394
  if (!args['no-gateway'] && !(await ensurePolicyKey(config, config.apiBase ?? base))) keepConfigAndFail(outDir, config, NO_POLICY_KEY(base));
4395
+ if (!args['no-gateway'] && !(await ensureOwnerPrincipal(config, config.apiBase || base))) keepConfigAndFail(outDir, config, NO_OWNER_PRINCIPAL(base));
4168
4396
  scaffoldProject({ demo, outDir, config, slug, scope, perTxnMax, currency, merchant: financial ? merchants[0] || 'demo-merchant' : merchants[0], sandbox: false, withGateway: !args['no-gateway'], gatewayPort: Number(args['gateway-port']) || DEFAULT_GATEWAY_PORT, force: !!args.force, gatewayIdentity });
4169
4397
  }
4170
4398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.14.10",
3
+ "version": "0.14.22",
4
4
  "description": "Scaffold a MetaMynd/AgentSafe-governed AI agent in one command — logs in, provisions the agent (identity + mandate + SOP + Standards) in a single call, writes agent.metamynd.json plus a runnable agent + separate tool-gateway process that closes direct-call, confused-deputy, replay, and cumulative-spend bypasses. --harness scaffolds a free, local, zero-network governance harness instead.",
5
5
  "type": "module",
6
6
  "bin": {