create-metamynd-agent 0.14.2 → 0.14.6

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 (3) hide show
  1. package/README.md +11 -3
  2. package/index.mjs +150 -36
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -175,6 +175,11 @@ the whole protocol.
175
175
  Named precisely, not left implicit:
176
176
 
177
177
  - **Direct call.** `bookFlight()` doesn't exist in the agent's process.
178
+ - **Forged or replayed policy (every gateway is pinned, 0.14.3).** The gateway's `policyPublicKey` is baked in from your
179
+ provisioning response (or, from an older API, `GET /magp/policy/pubkey`), and with `@metamynd/agentsafe-mcp-guard` 0.18.1 an
180
+ unsigned, tampered or stale bundle is refused for every action, amount 0 included — so a non-financial gateway cannot be granted
181
+ an action by whoever sits on its bundle fetch. If no key can be had, the CLI scaffolds no gateway at all (it keeps the
182
+ provisioned config and says so) rather than writing an unpinned one.
178
183
  - **Payload binding is ON by default (0.11.0, MAGP §8.3.9).** The generated agent signs the COMPLETE body it sends as a payload, the
179
184
  authorization it gets from the gate is bound to that same body, and the generated gateway sets `requirePayloadBinding: true`: it
180
185
  digests the body it is about to run and the issuer refuses the claim unless that is the digest the agent signed. The eight
@@ -428,9 +433,12 @@ signed value only and ignores one in the context. Scaffolds pin `@metamynd/agent
428
433
 
429
434
  The starter rules include a high-risk review. A request that sends **no** `riskLevel` (or an unrecognised one)
430
435
  is no longer waved through: it is **escalated** (`CONTEXT_UNVERIFIABLE`) for a human, because an agent that omits
431
- its risk is indistinguishable from one hiding it. The scaffolded financial examples send `riskLevel` (`'low'`
432
- unless you say otherwise) — that default is a placeholder, not an assessment — and the neutral template passes
433
- your own `args` as the context, so include it there. A real integration should send an honest one — or, better, not depend on the agent at all: a mandate
436
+ its risk is indistinguishable from one hiding it. The scaffolded examples pass the tool call's own `riskLevel`
437
+ through **unchanged** — there is no default, so a call that does not say is sent for review rather than treated as
438
+ `'low'` (the demo steps each state their risk explicitly). The neutral template passes your own `args` as the
439
+ context, so include it there. Either way `riskLevel` is **the agent's own claim**: it travels in the request
440
+ context, and even when the agent's key signs that context, the signature proves who said it, not that it is true —
441
+ nothing checks the value unless your owner configures provenance for it. A real integration should send an honest one — or, better, not depend on the agent at all: a mandate
434
442
  permission can carry an owner-set `riskTier` that no claim can lower, and a gateway route can derive the risk
435
443
  itself with `route.trustedContext` (`@metamynd/agentsafe-http-gateway` 0.10.0). See MAGP §6.3.
436
444
 
package/index.mjs CHANGED
@@ -59,7 +59,9 @@ const GUARD_PKG = '@metamynd/agentsafe-guard';
59
59
  // 0.17.0 signs the agent's context (envelopeSignature, MAGP 8.3.13) by default; the scaffolded agent gets it with no template
60
60
  // change (signContext: false opts out). A daemon-custody agent needs agentsafe-signer >= 0.19.0 for its no-amount requests.
61
61
  // 0.18.0 exports keepAliveFetch. No template change.
62
- const GUARD_VERSION = '^0.18.0';
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
+ // 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';
63
65
  /** The harness entry point's config load, shared by both harness templates: a fresh clone has no
64
66
  * agent.metamynd.json (it is gitignored), so say what to do instead of a bare ENOENT (BR-004). */
65
67
  function harnessConfigLoad() {
@@ -120,7 +122,10 @@ const MCP_GUARD_PKG = '@metamynd/agentsafe-mcp-guard';
120
122
  // requireContextSignature option. No template change (the scaffolded agent does not sign its context by default).
121
123
  // 0.18.0: an opt-in bundleCache (push-invalidated by the issuer's events stream) and keepAliveFetch exported. No
122
124
  // template change (the scaffolded gateways don't enable the cache).
123
- const MCP_GUARD_VERSION = '^0.18.0';
125
+ // 0.18.1: the pinned policyPublicKey every scaffolded gateway sets now refuses an unsigned or stale bundle for an
126
+ // amount-0 action too. Required: the non-financial gateway's every request is amount 0, so below this a stripped
127
+ // bundle could grant it an action the agent never had.
128
+ const MCP_GUARD_VERSION = '^0.18.1';
124
129
  const GATEWAY_PKG = '@metamynd/agentsafe-http-gateway';
125
130
  // 0.2.0 fixes a confused-deputy gap (payload not bound to the signed request) — the CLI must
126
131
  // never scaffold a range that could resolve below it.
@@ -369,6 +374,16 @@ function loadConfigFile(path) {
369
374
  return json;
370
375
  }
371
376
 
377
+ // Written into every financial scaffold's README. The generated code passes the tool call's riskLevel through
378
+ // unchanged (no `?? 'low'`): a missing one must reach the gate as missing, where it escalates by design.
379
+ const RISK_LEVEL_NOTE = `## Risk level: the agent's own claim
380
+
381
+ \`riskLevel\` is **the agent's own claim**. It travels in the request's context: the agent's key may sign that context,
382
+ which proves who said it, not that it is true, and nothing checks the value unless your owner configures provenance for it (an owner-set \`riskTier\` on the mandate permission, or a
383
+ gateway route's \`trustedContext\`). \`index.mjs\` passes the tool call's own \`riskLevel\` through unchanged —
384
+ there is **no default**. A call that does not state one (or states one the gate does not recognise) is **sent
385
+ for review** (\`CONTEXT_UNVERIFIABLE\`), never treated as \`low\`. The demo steps each state their risk explicitly.`;
386
+
372
387
  // A rule needs `when.predicate` (the atom) and `then` (the decision the gate should return
373
388
  // when it fires) — everything else is optional sugar. See backend's atom-catalog.ts for the
374
389
  // full predicate list (amount-over, risk-at-or-above, jurisdiction-not-allowed, ...).
@@ -471,10 +486,27 @@ function defaultNeutralDemo() {
471
486
  return demo;
472
487
  }
473
488
 
489
+ /**
490
+ * Where an agent's rules are edited in the dashboard. SOPs belong to the legal entity, so the page is
491
+ * VeriFAI → Legal Entities (/dashboard/organization), SOPs tab; the AgentSafe sidebar group links the same
492
+ * tab as "SOPs". This said "Legal Entity → SOPs", a path no menu had (2026-10-02 eval, L-1).
493
+ */
494
+ const SOPS_PATH = 'Dashboard → VeriFAI → Legal Entities → SOPs tab (also linked as AgentSafe → SOPs)';
495
+ /** The same path for the generated demo's console output, which stays ASCII. */
496
+ const SOPS_PATH_ASCII = 'Dashboard -> VeriFAI -> Legal Entities -> SOPs';
497
+ /**
498
+ * A --sandbox scaffold runs as the SHARED sandbox agent, which no user has dashboard access to, so "change
499
+ * the rules in the dashboard" sent them somewhere they could not go (L-7). Say how to get an agent whose
500
+ * rules they CAN change instead.
501
+ */
502
+ const SANDBOX_RULES_FIXED = "This is the shared sandbox agent: its rules are fixed and nobody can edit them. To change the rules, provision your own agent — re-run without --sandbox (it signs you up and creates an agent whose SOPs you own, edited at " + SOPS_PATH + "), or use --harness for a free local agent whose rules you edit by hand.";
503
+
474
504
  /** A --config file's rules cannot reach an agent the caller does not provision themselves: say so instead of implying they apply. */
475
- function warnRulesNotApplied(fileConfig, where) {
505
+ function warnRulesNotApplied(fileConfig, where, { shared = false } = {}) {
476
506
  if (policyMolecules(fileConfig).length === 0) return;
477
- console.log(` ${c.yellow('!')} the rules in ${c.b('the config file')} are not applied to ${where} — its rules are the platform's defaults. Set your own in the dashboard (Legal Entity → SOPs), or provision your own agent (drop this flag) to author them here.`);
507
+ console.log(shared
508
+ ? ` ${c.yellow('!')} the rules in ${c.b('the config file')} are not applied to ${where} — its rules are the platform's defaults, and nobody can change them. Provision your own agent (drop this flag) to author them here.`
509
+ : ` ${c.yellow('!')} the rules in ${c.b('the config file')} are not applied to ${where} — its rules are the platform's defaults. Its owner sets them in the dashboard (${SOPS_PATH}), or provision your own agent (drop this flag) to author them here.`);
478
510
  }
479
511
 
480
512
  /**
@@ -972,6 +1004,50 @@ async function registerGatewayCounterparty(base, token, did, label) {
972
1004
  }
973
1005
  }
974
1006
 
1007
+ /**
1008
+ * Every scaffolded gateway pins the issuer's policy-signing key, so it refuses any bundle MetaMynd did not sign —
1009
+ * an unpinned gateway trusts whatever its bundle fetch returns, and over plain http:// that is whatever an
1010
+ * interceptor wrote (mcp-guard only refuses VALUE-bearing actions there, and a non-financial agent has none).
1011
+ * The provisioning response carries the key (`issuer.policyKey`); when it does not (an older backend), it is read
1012
+ * from GET /magp/policy/pubkey on the same API this CLI just provisioned through, then saved into the config.
1013
+ * Returns the key, or null when neither source has one — the caller must then not scaffold a gateway.
1014
+ */
1015
+ async function ensurePolicyKey(config, base) {
1016
+ const isKey = (k) => typeof k === 'string' && /^[0-9a-f]{64}$/i.test(k);
1017
+ if (isKey(config.issuer?.policyKey)) return config.issuer.policyKey;
1018
+ let key = null;
1019
+ try {
1020
+ const res = await fetch(`${String(base).replace(/\/+$/, '')}/magp/policy/pubkey`);
1021
+ key = (await res.json().catch(() => null))?.data?.publicKey ?? null;
1022
+ } catch {
1023
+ key = null;
1024
+ }
1025
+ if (!isKey(key)) return null;
1026
+ config.issuer = { ...(config.issuer ?? {}), policyKey: key.toLowerCase() };
1027
+ return config.issuer.policyKey;
1028
+ }
1029
+
1030
+ const NO_POLICY_KEY = (base) =>
1031
+ `Could not get MetaMynd's policy-signing key: the provisioning response carried none, and GET ${base}/magp/policy/pubkey ` +
1032
+ 'did not return one. A gateway without it would trust whatever policy bundle its network fetch returns, so none was ' +
1033
+ 'scaffolded. Retry when the API is reachable, or pass --no-gateway for the single-process example (not an enforcement boundary).';
1034
+
1035
+ /**
1036
+ * Stop after provisioning WITHOUT losing what it returned: a managed agent key is handed back once and cannot be
1037
+ * fetched again. Saves the config into the project directory (never over an existing file), then fails with `why`.
1038
+ */
1039
+ function keepConfigAndFail(outDir, config, why) {
1040
+ let kept = null;
1041
+ try {
1042
+ mkdirSync(outDir, { recursive: true });
1043
+ kept = join(outDir, existsSync(join(outDir, 'agent.metamynd.json')) ? `agent.metamynd.${Date.now()}.json` : 'agent.metamynd.json');
1044
+ writeFileSync(kept, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
1045
+ } catch {
1046
+ kept = null;
1047
+ }
1048
+ fail(`${why}${kept ? `\n The agent was provisioned; its config (with its key) was saved to ${kept.replace(/\\/g, '/')}.` : ''}`);
1049
+ }
1050
+
975
1051
  // ---------- scaffolding ----------
976
1052
  /**
977
1053
  * The --no-gateway / --sandbox variant: the tool is a local function in the SAME process as
@@ -983,7 +1059,7 @@ async function registerGatewayCounterparty(base, token, did, label) {
983
1059
  * it — the same shape of gap --harness's README documents. See exampleIndex() below, which is
984
1060
  * what the real (non-sandbox) flow scaffolds by default instead.
985
1061
  */
986
- function exampleIndexNoGateway(scope, perTxnMax, currency, merchant) {
1062
+ function exampleIndexNoGateway(scope, perTxnMax, currency, merchant, sandbox = false) {
987
1063
  const under = Math.max(1, Math.round(perTxnMax * 0.5));
988
1064
  const over = Math.round(perTxnMax + 100);
989
1065
  return `// index.mjs — your agent, governed by MetaMynd/AgentSafe.
@@ -1012,7 +1088,10 @@ const gatedBookFlight = guard.guardTool(
1012
1088
  amount: a.amount,
1013
1089
  currency: '${currency}',
1014
1090
  merchant: a.merchant,
1015
- context: { tool: 'book-flight', riskLevel: a.riskLevel ?? 'low' },
1091
+ // riskLevel is the agent's OWN claim: signing it proves who said it, not that it is true, and nothing checks it unless
1092
+ // the owner configures provenance. Passed through as-is: when the agent does not say, it stays absent and the gate sends
1093
+ // the action for review — never 'low'.
1094
+ context: { tool: 'book-flight', riskLevel: a.riskLevel },
1016
1095
  }),
1017
1096
  );
1018
1097
 
@@ -1127,8 +1206,12 @@ console.log(bold(' Without MetaMynd, you can be bypassed.') + ' bookFlight() ru
1127
1206
  console.log(dim(' call it directly instead of gatedBookFlight and nothing above stops you.'));
1128
1207
  console.log(dim(' Re-scaffold without --sandbox/--no-gateway for the default shape, which does.'));
1129
1208
  console.log('');
1130
- console.log(' Change the cap in the dashboard (Legal Entity -> SOPs) and run again.');
1131
- console.log(dim(' The outcome changes. This file does not. That is the point.'));
1209
+ ${sandbox ? `console.log(' This is the SHARED sandbox agent: its rules are fixed and nobody can edit them.');
1210
+ console.log(dim(' To change the cap, provision your own agent: re-run create-metamynd-agent'));
1211
+ console.log(dim(' without --sandbox (it signs you up), then change the cap in your dashboard'));
1212
+ console.log(dim(' (${SOPS_PATH_ASCII}) and run again.'));
1213
+ console.log(dim(' The outcome changes. Your code does not. That is the point.'));` : `console.log(' Change the cap in the dashboard (${SOPS_PATH_ASCII}) and run again.');
1214
+ console.log(dim(' The outcome changes. This file does not. That is the point.'));`}
1132
1215
  console.log('');
1133
1216
  `;
1134
1217
  }
@@ -1173,7 +1256,8 @@ async function bookFlightViaGateway(args, decision) {
1173
1256
  amount: args.amount,
1174
1257
  currency: args.currency ?? '${currency}',
1175
1258
  merchant: args.merchant,
1176
- context: { tool: 'book-flight', riskLevel: args.riskLevel ?? 'low' },
1259
+ // Passed through as-is (never defaulted): a missing riskLevel must reach the gateway as missing.
1260
+ context: { tool: 'book-flight', riskLevel: args.riskLevel },
1177
1261
  payload,
1178
1262
  });
1179
1263
  signed.authorizationId = decision?.authorizationId;
@@ -1201,7 +1285,10 @@ const gatedBookFlight = guard.guardTool(
1201
1285
  amount: a.amount,
1202
1286
  currency: a.currency ?? '${currency}',
1203
1287
  merchant: a.merchant,
1204
- context: { tool: 'book-flight', riskLevel: a.riskLevel ?? 'low' },
1288
+ // riskLevel is the agent's OWN claim: signing it proves who said it, not that it is true, and nothing checks it unless
1289
+ // the owner configures provenance. Passed through as-is: when the agent does not say, it stays absent and the gate sends
1290
+ // the action for review — never 'low'.
1291
+ context: { tool: 'book-flight', riskLevel: a.riskLevel },
1205
1292
  // The authorization is bound to the SAME body bookFlightViaGateway() sends, so the gate records what this agent
1206
1293
  // signed and the gateway can prove it is running exactly that. Keep the two in step if you add a field.
1207
1294
  payload: { amount: a.amount, merchant: a.merchant, currency: a.currency ?? '${currency}' },
@@ -1324,7 +1411,7 @@ console.log('');
1324
1411
  console.log(bold(' With MetaMynd, you can\\'t be bypassed.') + ' ./gateway is why - it independently');
1325
1412
  console.log(dim(' re-verified step 1 before running it, and holds the tool this file never can.'));
1326
1413
  console.log('');
1327
- console.log(' Change the cap in the dashboard (Legal Entity -> SOPs) and run again.');
1414
+ console.log(' Change the cap in the dashboard (${SOPS_PATH_ASCII}) and run again.');
1328
1415
  console.log(dim(' The outcome changes. This file does not. That is the point.'));
1329
1416
  console.log('');
1330
1417
  `;
@@ -1357,7 +1444,7 @@ If a committed \`package-lock.json\` pins an older \`${GUARD_PKG}\` than this pr
1357
1444
  * with `gatewayPort` the tool lives in a separate process (./gateway, the real enforcement
1358
1445
  * boundary); without it (--no-gateway) the tool is a local function in this process.
1359
1446
  */
1360
- function exampleIndexNeutral({ scope, gatewayPort, demo, merchant }) {
1447
+ function exampleIndexNeutral({ scope, gatewayPort, demo, merchant, sandbox = false }) {
1361
1448
  const withGateway = gatewayPort != null;
1362
1449
  const steps = [
1363
1450
  ...demo.cases,
@@ -1504,8 +1591,12 @@ console.log(bold(' Without MetaMynd, you can be bypassed.') + ' performAction()
1504
1591
  console.log(dim(' call it directly instead of gatedAction and nothing above stops you.'));
1505
1592
  console.log(dim(' Re-scaffold without --no-gateway for the default shape, which closes that.'));`}
1506
1593
  console.log('');
1507
- console.log(' Change your rules in the dashboard (Legal Entity -> SOPs) and run again.');
1508
- console.log(dim(' The outcome changes. This file does not. That is the point.'));
1594
+ ${sandbox ? `console.log(' This is the SHARED sandbox agent: its rules are fixed and nobody can edit them.');
1595
+ console.log(dim(' To change them, provision your own agent: re-run create-metamynd-agent'));
1596
+ console.log(dim(' without --sandbox (it signs you up), then change its rules in your dashboard'));
1597
+ console.log(dim(' (${SOPS_PATH_ASCII}) and run again.'));
1598
+ console.log(dim(' The outcome changes. Your code does not. That is the point.'));` : `console.log(' Change your rules in the dashboard (${SOPS_PATH_ASCII}) and run again.');
1599
+ console.log(dim(' The outcome changes. This file does not. That is the point.'));`}
1509
1600
  console.log('');
1510
1601
  `;
1511
1602
  }
@@ -1561,7 +1652,7 @@ const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFie
1561
1652
  // forged bundle). Without it, verifyBundle() never runs at all: an attacker who can intercept the
1562
1653
  // fetch to \`\${MAGP_API}/policy/bundle/...\` — a MITM, a compromised DNS/proxy — can hand this gateway
1563
1654
  // a bundle with a higher cap or no rules, and it would be trusted the same as the real one.
1564
- const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API, requireAuthorization: false${policyKey ? `, policyPublicKey: '${policyKey}'` : ''} });
1655
+ const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API, requireAuthorization: false, policyPublicKey: '${policyKey}' });
1565
1656
 
1566
1657
  const gateway = createHttpGateway({
1567
1658
  guard,
@@ -1684,7 +1775,15 @@ Replace \`performAction()\` in \`server.mjs\` with your implementation and put a
1684
1775
  }
1685
1776
 
1686
1777
  /** The README for a non-financial hosted scaffold. `withGateway` selects the two-process shape. */
1687
- function exampleReadmeNeutral(slug, scope, withGateway, gatewayPort, daemonKey, demo) {
1778
+ /** 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. */
1779
+ function changeRulesSection(sandbox) {
1780
+ return sandbox
1781
+ ? `${SANDBOX_RULES_FIXED} An \`escalate\` verdict is held for an owner to approve; poll \`guard.escalationStatus(id)\`.`
1782
+ : `Edit the agent's SOPs in the dashboard (${SOPS_PATH}). The agent's behaviour changes live —
1783
+ no redeploy. An \`escalate\` verdict is held for an owner to approve; poll \`guard.escalationStatus(id)\`.`;
1784
+ }
1785
+
1786
+ function exampleReadmeNeutral(slug, scope, withGateway, gatewayPort, daemonKey, demo, sandbox = false) {
1688
1787
  const configLine = daemonKey
1689
1788
  ? `- \`agent.metamynd.json\` — your portable guard config (identity, mandate scope \`${scope}\`, issuer keys).
1690
1789
  **Holds no secret key.** Signing goes through your already-running agentsafe-signer daemon instead.`
@@ -1763,8 +1862,7 @@ ${clonedFreshSection(daemonKey)}
1763
1862
 
1764
1863
  ## Change the rules
1765
1864
 
1766
- Edit the agent's SOPs in the dashboard (Legal Entity → SOPs). The agent's behaviour changes live —
1767
- no redeploy. An \`escalate\` verdict is held for an owner to approve; poll \`guard.escalationStatus(id)\`.
1865
+ ${changeRulesSection(sandbox)}
1768
1866
 
1769
1867
  ## What this is not
1770
1868
 
@@ -1801,7 +1899,7 @@ function examplePackageJson(slug, neutral = false) {
1801
1899
  ) + '\n';
1802
1900
  }
1803
1901
 
1804
- function exampleReadme(slug, scope, withGateway, gatewayPort, daemonKey = false) {
1902
+ function exampleReadme(slug, scope, withGateway, gatewayPort, daemonKey = false, sandbox = false) {
1805
1903
  const configFileLine = daemonKey
1806
1904
  ? `- \`agent.metamynd.json\` — your portable guard config (identity, mandate scope \`${scope}\`, issuer keys).
1807
1905
  **Holds no secret key.** Signing goes through your already-running agentsafe-signer daemon
@@ -1828,6 +1926,8 @@ You should see an ALLOW (fulfilled by \`./gateway\`), a BLOCK (over the per-tran
1828
1926
  an ESCALATE (high risk). The BLOCK and ESCALATE never reach the gateway at all — this file's own
1829
1927
  \`guard.guardTool()\` refuses them first. Only the ALLOW crosses into the other process.
1830
1928
 
1929
+ ${RISK_LEVEL_NOTE}
1930
+
1831
1931
  ## Files
1832
1932
 
1833
1933
  ${configFileLine}
@@ -1849,6 +1949,8 @@ npm start
1849
1949
 
1850
1950
  You should see an ALLOW, a BLOCK (over the per-transaction cap), and an ESCALATE (high risk).
1851
1951
 
1952
+ ${RISK_LEVEL_NOTE}
1953
+
1852
1954
  ## Files
1853
1955
 
1854
1956
  ${configFileLine}
@@ -1891,8 +1993,7 @@ ${clonedFreshSection(daemonKey)}
1891
1993
 
1892
1994
  ## Change the rules
1893
1995
 
1894
- Edit the agent's SOPs in the dashboard (Legal Entity → SOPs). The agent's behaviour changes live —
1895
- no redeploy. An \`escalate\` verdict is held for an owner to approve; poll \`guard.escalationStatus(id)\`.
1996
+ ${changeRulesSection(sandbox)}
1896
1997
 
1897
1998
  Full integration guide: \`docs/integration/INTEGRATE-WITH-METAMYND.md\`.
1898
1999
  `;
@@ -1966,7 +2067,7 @@ const identity = JSON.parse(readFileSync(new URL('./service.metamynd.json', impo
1966
2067
  // forged bundle). Without it, verifyBundle() never runs at all: an attacker who can intercept the
1967
2068
  // fetch to \`\${MAGP_API}/policy/bundle/...\` — a MITM, a compromised DNS/proxy — can hand this gateway
1968
2069
  // a bundle with a higher cap or no rules, and it would be trusted the same as the real one.
1969
- const guard = createMcpGuard({ serviceDid: identity.serviceDid, serviceKey: identity.serviceKey ?? undefined, issuerApi: MAGP_API, requireAuthorization: true${policyKey ? `, policyPublicKey: '${policyKey}'` : ''} });
2070
+ const guard = createMcpGuard({ serviceDid: identity.serviceDid, serviceKey: identity.serviceKey ?? undefined, issuerApi: MAGP_API, requireAuthorization: true, policyPublicKey: '${policyKey}' });
1970
2071
 
1971
2072
  const gateway = createHttpGateway({
1972
2073
  guard,
@@ -2233,10 +2334,10 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, currency = 'U
2233
2334
  outDir,
2234
2335
  'index.mjs',
2235
2336
  neutral
2236
- ? exampleIndexNeutral({ scope, gatewayPort: withGateway ? gatewayPort : null, demo, merchant })
2337
+ ? exampleIndexNeutral({ scope, gatewayPort: withGateway ? gatewayPort : null, demo, merchant, sandbox: !!sandbox })
2237
2338
  : withGateway
2238
2339
  ? exampleIndex(scope, perTxnMax, gatewayPort, currency, paymentMerchant)
2239
- : exampleIndexNoGateway(scope, perTxnMax, currency, paymentMerchant),
2340
+ : exampleIndexNoGateway(scope, perTxnMax, currency, paymentMerchant, !!sandbox),
2240
2341
  force,
2241
2342
  );
2242
2343
  writeFileSafe(outDir, 'package.json', examplePackageJson(slug, neutral), force);
@@ -2245,8 +2346,8 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, currency = 'U
2245
2346
  outDir,
2246
2347
  'README.md',
2247
2348
  neutral
2248
- ? exampleReadmeNeutral(slug, scope, withGateway, gatewayPort, config.keyProvider === 'daemon', demo)
2249
- : exampleReadme(slug, scope, withGateway, gatewayPort, config.keyProvider === 'daemon'),
2349
+ ? exampleReadmeNeutral(slug, scope, withGateway, gatewayPort, config.keyProvider === 'daemon', demo, !!sandbox)
2350
+ : exampleReadme(slug, scope, withGateway, gatewayPort, config.keyProvider === 'daemon', !!sandbox),
2250
2351
  force,
2251
2352
  );
2252
2353
  // `npm test` (agentsafe-guard verify --context) needs the request inputs of a compliant request:
@@ -2262,12 +2363,14 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, currency = 'U
2262
2363
 
2263
2364
  if (withGateway) {
2264
2365
  const apiBase = config.apiBase ?? config.api ?? DEFAULT_API;
2265
- // The key that signs every policy bundle (magp.policy-signer.ts) — present on every provisioning
2266
- // response that talks to a real backend (full account, sandbox, delegated-claim). Baked into the
2267
- // generated gateway so it pins the bundle rather than trusting whatever the fetch returns; absent
2268
- // only for a config predating this field (an older scaffold's saved agent.metamynd.json), where
2269
- // the generated gateway is unchanged from before rather than baking in nothing and crashing.
2366
+ // The key that signs every policy bundle (magp.policy-signer.ts), baked into the generated gateway so it pins the
2367
+ // bundle rather than trusting whatever the fetch returns. REQUIRED: an unpinned gateway takes an interceptor's
2368
+ // bundle as its rules (2026-10-02 — a non-financial gateway executed an action the agent was never granted). The
2369
+ // interactive flows fill it in with ensurePolicyKey() before calling this, so a missing key here is a caller bug.
2270
2370
  const policyKey = config.issuer?.policyKey ?? null;
2371
+ if (!policyKey) {
2372
+ 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");
2373
+ }
2271
2374
  const gwDir = join(outDir, 'gateway');
2272
2375
  if (!existsSync(gwDir)) mkdirSync(gwDir, { recursive: true });
2273
2376
  if (!neutral) {
@@ -2315,7 +2418,9 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, currency = 'U
2315
2418
  console.log(c.dim(' · BLOCK (the agent asking to raise its OWN limit)\n'));
2316
2419
  }
2317
2420
  console.log(c.cyan(` npm test`) + c.dim(' → assert it CANNOT exceed its mandate. Put this in CI.\n'));
2318
- console.log(c.dim(` Change the rules any time in the dashboard (Legal Entity → SOPs) — no redeploy.\n`));
2421
+ console.log(c.dim(sandbox
2422
+ ? ` ${SANDBOX_RULES_FIXED}\n`
2423
+ : ` Change the rules any time in the dashboard (${SOPS_PATH}) — no redeploy.\n`));
2319
2424
  }
2320
2425
 
2321
2426
  /** --sandbox: no login, no KYB — fetch the shared sandbox agent config and scaffold. */
@@ -2330,7 +2435,7 @@ async function runSandbox(args) {
2330
2435
  const fileConfig = typeof args.config === 'string' ? loadConfigFile(args.config) : null;
2331
2436
  if (fileConfig) console.log(` ${c.green('✓')} loaded policy config ${c.dim(args.config)}`);
2332
2437
  const { financial } = announceFinancial(args, fileConfig);
2333
- warnRulesNotApplied(fileConfig, 'the shared sandbox agent');
2438
+ warnRulesNotApplied(fileConfig, 'the shared sandbox agent', { shared: true });
2334
2439
  if (!financial && typeof fileConfig?.scope === 'string' && fileConfig.scope !== 'perform-action') {
2335
2440
  console.log(` ${c.yellow('!')} the scope "${fileConfig.scope}" in the config file is not used: the shared sandbox agent's scope is "perform-action".`);
2336
2441
  }
@@ -3044,7 +3149,8 @@ async function callGateway(path, action, args) {
3044
3149
  // The COMPLETE body the tool receives, signed as a payload (MAGP 8.3.9): the eight signed fields cover amount and merchant
3045
3150
  // only, and this gateway refuses to run the tool on a body that is not exactly the one signed here.
3046
3151
  const payload = { amount: args.amount, merchant: args.merchant, currency: args.currency ?? '${currency}' };
3047
- const signed = await guard.buildSignedRequest({ action, amount: args.amount, currency: args.currency ?? '${currency}', merchant: args.merchant, context: { tool: '${scope}', riskLevel: args.riskLevel ?? 'low' }, payload });
3152
+ // riskLevel is passed through as-is (never defaulted): a missing one must reach the gateway as missing.
3153
+ const signed = await guard.buildSignedRequest({ action, amount: args.amount, currency: args.currency ?? '${currency}', merchant: args.merchant, context: { tool: '${scope}', riskLevel: args.riskLevel }, payload });
3048
3154
  const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) }, body: JSON.stringify(payload) });
3049
3155
  const body = await res.json().catch(() => null);
3050
3156
  if (!res.ok) {
@@ -3069,7 +3175,10 @@ const gatedBookFlight = guard.guardToolLocal(
3069
3175
  amount: a.amount,
3070
3176
  currency: a.currency ?? '${currency}',
3071
3177
  merchant: a.merchant,
3072
- context: { tool: 'book-flight', riskLevel: a.riskLevel ?? 'low' },
3178
+ // riskLevel is the agent's OWN claim: signing it proves who said it, not that it is true, and nothing checks it unless
3179
+ // the owner configures provenance. Passed through as-is: when the agent does not say, it stays absent and the gate sends
3180
+ // the action for review — never 'low'.
3181
+ context: { tool: 'book-flight', riskLevel: a.riskLevel },
3073
3182
  }),
3074
3183
  getBundle,
3075
3184
  );
@@ -3423,7 +3532,9 @@ action outside the mandate entirely. Every step says what it expects and flags a
3423
3532
  : `None of your rules could be staged in the demo (see below), so it shows only a BLOCK for an
3424
3533
  action outside the mandate. Your rules are still enforced; see \`metamynd-rules.json\`.`)
3425
3534
  : `You should see an ALLOW, a BLOCK (over the per-transaction cap), an ESCALATE (high risk —
3426
- open the dashboard to approve it), and a BLOCK (an action outside the mandate entirely).`}
3535
+ open the dashboard to approve it), and a BLOCK (an action outside the mandate entirely).
3536
+
3537
+ ${RISK_LEVEL_NOTE}`}
3427
3538
 
3428
3539
  ## Files
3429
3540
 
@@ -3784,6 +3895,7 @@ async function runClaim(args) {
3784
3895
  if (args.financial && issuedNonFinancial) {
3785
3896
  keepConfigAndStop('--financial was passed, but the approved agent has NO spending authority (the request was non-financial).');
3786
3897
  }
3898
+ if (!args['no-gateway'] && !(await ensurePolicyKey(config, config.apiBase ?? base))) keepConfigAndStop(NO_POLICY_KEY(base));
3787
3899
  const slug = slugify(state.name || 'metamynd-agent');
3788
3900
  const outDir = resolve(String(args.out || `./${slug}`));
3789
3901
  if (issuedNonFinancial) {
@@ -4019,6 +4131,7 @@ async function main() {
4019
4131
  console.log(` ${c.green('✓')} derived ${demo.cases.length} demo case(s) from your rules`);
4020
4132
  for (const n of demo.notDemonstrated) console.log(` ${c.yellow('!')} not staged in the demo: ${n.rule} ${c.dim('— ' + n.why + '; still enforced')}`);
4021
4133
  }
4134
+ if (!args['no-gateway'] && !(await ensurePolicyKey(config, config.apiBase ?? base))) keepConfigAndFail(outDir, config, NO_POLICY_KEY(base));
4022
4135
  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 });
4023
4136
  }
4024
4137
 
@@ -4028,6 +4141,7 @@ export {
4028
4141
  buildPolicyCases, resolveFinancial, policyMolecules, ruleToMolecule,
4029
4142
  generateAgentKeypair, harnessAgentDid, harnessMandate, harnessRulesFile, harnessDefaultSopNeutral,
4030
4143
  scaffoldProject, defaultNeutralDemo, exampleIndexNeutral, exampleReadmeNeutral, gatewayServerFileNeutral, gatewayReadmeNeutral,
4144
+ ensurePolicyKey,
4031
4145
  };
4032
4146
  if (!process.env.CREATE_METAMYND_AGENT_NO_MAIN) {
4033
4147
  // fail() can also be reached from a callback outside main()'s promise chain (readline, child process).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.14.2",
3
+ "version": "0.14.6",
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": {