create-metamynd-agent 0.10.10 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -3
  2. package/index.mjs +138 -46
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -10,9 +10,9 @@ default, a second `gateway/` process** built on
10
10
  The agent's own `guardTool()` call is a fast, local, client-side check; the gateway is the real
11
11
  enforcement boundary — see [Separate tool gateway](#separate-tool-gateway-default) below.
12
12
 
13
- > **Prerequisite:** an agent is always owned by a **KYB-verified owner** — a person/org with a
14
- > MetaMynd account. If that's you and you're verified, you're ready. Verify once in the dashboard if
15
- > not; it's the only gate.
13
+ > **Prerequisite:** an agent is always owned by a person/org with a **MetaMynd account**. Verification
14
+ > is *reported*, not required, to get started: an unverified owner works on Testnet within the
15
+ > free-tier limits, and verifying once in the dashboard (KYC/KYB) is what unlocks mainnet.
16
16
 
17
17
  ## Free local harness (no account, no network, `--harness`)
18
18
 
@@ -170,6 +170,15 @@ the whole protocol.
170
170
  Named precisely, not left implicit:
171
171
 
172
172
  - **Direct call.** `bookFlight()` doesn't exist in the agent's process.
173
+ - **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
174
+ authorization it gets from the gate is bound to that same body, and the generated gateway sets `requirePayloadBinding: true`: it
175
+ digests the body it is about to run and the issuer refuses the claim unless that is the digest the agent signed. The eight
176
+ signed fields only ever covered amount and merchant; this covers the value of every field the route allows (a payee, a passenger
177
+ list you add). A request that bound no payload is refused (`PAYLOAD_BINDING_REQUIRED`) rather than run unbound. If you add a body
178
+ field, add it to `allowedFields` **and** to the `payload` the agent signs (both sit next to each other in the generated files).
179
+ A scaffold that signs through the signer daemon (`--byok --daemon-socket`) needs `@metamynd/agentsafe-signer` 0.15.0 or later. The
180
+ hosted non-financial scaffold reads nothing from its body, so there is nothing for it to bind, and a scaffold with no gateway
181
+ (`--no-gateway`, or `--sandbox`'s in-process demo) has no separate executor to hold to the digest, so it stays as it was.
173
182
  - **Confused deputy (payload).** Signing a cheap request while executing an expensive one (a
174
183
  different amount/merchant in the body than what was signed) is refused before the tool runs —
175
184
  payload binding (`@metamynd/agentsafe-http-gateway` ≥ 0.4.0). The default binder requires
@@ -388,6 +397,16 @@ polls until the owner approves, then scaffolds the project (the same default two
388
397
  the full flow above — `--no-gateway`/`--gateway-port` work here too). With `--byok` the keypair is
389
398
  generated locally and control is proven on claim — MetaMynd never sees the private key.
390
399
 
400
+ ## Risk: say it, or have your owner set it
401
+
402
+ The starter rules include a high-risk review. A request that sends **no** `riskLevel` (or an unrecognised one)
403
+ is no longer waved through: it is **escalated** (`CONTEXT_UNVERIFIABLE`) for a human, because an agent that omits
404
+ its risk is indistinguishable from one hiding it. The scaffolded financial examples send `riskLevel` (`'low'`
405
+ unless you say otherwise) — that default is a placeholder, not an assessment — and the neutral template passes
406
+ 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
407
+ permission can carry an owner-set `riskTier` that no claim can lower, and a gateway route can derive the risk
408
+ itself with `route.trustedContext` (`@metamynd/agentsafe-http-gateway` 0.10.0). See MAGP §6.3.
409
+
391
410
  ## Security
392
411
 
393
412
  `agent.metamynd.json` contains the agent's **secret key** (a managed key, or — with `--byok` — the one
package/index.mjs CHANGED
@@ -53,7 +53,7 @@ const GUARD_PKG = '@metamynd/agentsafe-guard';
53
53
  // requires request inputs blocks a bare baseline request).
54
54
  // 0.12.4 makes `verify` probe in the currency the mandate's caps name (it assumed USD), which a scaffolded
55
55
  // non-USD agent's `npm test` needs.
56
- const GUARD_VERSION = '^0.12.4';
56
+ const GUARD_VERSION = '^0.15.0';
57
57
  /** The harness entry point's config load, shared by both harness templates: a fresh clone has no
58
58
  * agent.metamynd.json (it is gitignored), so say what to do instead of a bare ENOENT (BR-004). */
59
59
  function harnessConfigLoad() {
@@ -102,7 +102,7 @@ const MCP_GUARD_PKG = '@metamynd/agentsafe-mcp-guard';
102
102
  // 0.6.0 brings buildAuthMessage's `resource` field and buildLocalDecisionMessage into this
103
103
  // package's own bundled policy-core.mjs (alongside the guard's own 0.10.0) — no scaffolded
104
104
  // template code changes, but the floor must still cover the real current version.
105
- const MCP_GUARD_VERSION = '^0.6.0';
105
+ const MCP_GUARD_VERSION = '^0.11.0';
106
106
  const GATEWAY_PKG = '@metamynd/agentsafe-http-gateway';
107
107
  // 0.2.0 fixes a confused-deputy gap (payload not bound to the signed request) — the CLI must
108
108
  // never scaffold a range that could resolve below it.
@@ -114,7 +114,7 @@ const GATEWAY_PKG = '@metamynd/agentsafe-http-gateway';
114
114
  // 0.5.0 adds the OPTIONAL Credential Vault `resolveCredential` hook on createHttpGateway (Module
115
115
  // G) — additive and backward-compatible (every existing consumer sees zero behavior change), but
116
116
  // the floor must still cover the real current version per this repo's own package-version check.
117
- const GATEWAY_VERSION = '^0.5.0';
117
+ const GATEWAY_VERSION = '^0.11.0';
118
118
  const DEFAULT_API = 'https://metamynd.ai/api/v1';
119
119
  const DEFAULT_GATEWAY_PORT = 4401; // distinct from --harness's dashboard (4400)
120
120
 
@@ -987,18 +987,23 @@ const GATEWAY = process.env.GATEWAY_URL || 'http://localhost:${gatewayPort}';
987
987
  // --- the gateway atomically claim single-use execution, closing replay + cumulative spend, not
988
988
  // --- just re-checking policy. See ./gateway/README.md.
989
989
  async function bookFlightViaGateway(args, decision) {
990
+ // The COMPLETE body the tool receives. It is signed as a payload (MAGP 8.3.9): the eight signed fields cover amount and
991
+ // merchant, not anything else a real tool takes (a payee, a passenger list). The digest covers ALL of it, and the gateway
992
+ // refuses to run the tool on a body that is not exactly this one.
993
+ const payload = { amount: args.amount, merchant: args.merchant, currency: args.currency ?? '${currency}' };
990
994
  const signed = await guard.buildSignedRequest({
991
995
  action: '${scope}',
992
996
  amount: args.amount,
993
997
  currency: args.currency ?? '${currency}',
994
998
  merchant: args.merchant,
995
999
  context: { tool: 'book-flight', riskLevel: args.riskLevel ?? 'low' },
1000
+ payload,
996
1001
  });
997
1002
  signed.authorizationId = decision?.authorizationId;
998
1003
  const res = await fetch(GATEWAY + '/book-flight', {
999
1004
  method: 'POST',
1000
1005
  headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) },
1001
- body: JSON.stringify(args),
1006
+ body: JSON.stringify(payload),
1002
1007
  });
1003
1008
  const body = await res.json().catch(() => null);
1004
1009
  if (!res.ok) {
@@ -1020,6 +1025,9 @@ const gatedBookFlight = guard.guardTool(
1020
1025
  currency: a.currency ?? '${currency}',
1021
1026
  merchant: a.merchant,
1022
1027
  context: { tool: 'book-flight', riskLevel: a.riskLevel ?? 'low' },
1028
+ // The authorization is bound to the SAME body bookFlightViaGateway() sends, so the gate records what this agent
1029
+ // signed and the gateway can prove it is running exactly that. Keep the two in step if you add a field.
1030
+ payload: { amount: a.amount, merchant: a.merchant, currency: a.currency ?? '${currency}' },
1023
1031
  }),
1024
1032
  );
1025
1033
 
@@ -1207,7 +1215,7 @@ async function performViaGateway(args, decision) {
1207
1215
  const res = await fetch(GATEWAY + '/perform', {
1208
1216
  method: 'POST',
1209
1217
  headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) },
1210
- body: JSON.stringify(args),
1218
+ body: '{}', // this tool reads nothing from the body; the request's fields travel in the SIGNED context
1211
1219
  });
1212
1220
  const body = await res.json().catch(() => null);
1213
1221
  if (!res.ok) {
@@ -1330,7 +1338,7 @@ console.log('');
1330
1338
  * allowed value-less request would be refused AUTHORIZATION_REQUIRED. The README states what that
1331
1339
  * leaves open (replay) rather than implying the financial scaffold's guarantees.
1332
1340
  */
1333
- function gatewayServerFileNeutral(scope, port, apiBase) {
1341
+ function gatewayServerFileNeutral(scope, port, apiBase, policyKey) {
1334
1342
  return `#!/usr/bin/env node
1335
1343
  // gateway/server.mjs — the enforcement boundary for this agent's tool(s).
1336
1344
  //
@@ -1357,7 +1365,11 @@ async function performAction(args) {
1357
1365
  //
1358
1366
  // valueFields: [] because this action carries no amount or merchant to bind the body to — say so
1359
1367
  // explicitly rather than lean on the library default, which would demand both.
1360
- const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFields: [] }];
1368
+ //
1369
+ // allowedFields: [] because performAction() reads nothing from the body, so any key the agent adds is
1370
+ // refused (PAYLOAD_UNBINDABLE): nothing signed covers it. When your real tool reads body fields, list
1371
+ // exactly those keys here — the gateway refuses every top-level key you do not name.
1372
+ const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFields: [], allowedFields: [] }];
1361
1373
 
1362
1374
  // requireAuthorization is OFF on purpose. It makes the gateway claim a single-use, stateful
1363
1375
  // authorization before running the tool — but the agent's guard only seals one for a value-bearing
@@ -1365,7 +1377,12 @@ const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFie
1365
1377
  // would be refused AUTHORIZATION_REQUIRED. What that leaves open (a signed request is reusable for
1366
1378
  // the guard's 5-minute freshness window; the issuer's stateful rate/circuit-breaker floors; and the
1367
1379
  // rule inputs, which are not covered by the signature) is spelled out in README.md.
1368
- const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API, requireAuthorization: false });
1380
+ // policyPublicKey pins the bundle to MetaMynd's OWN signing key (from your provisioning response,
1381
+ // baked in here — never fetched at runtime, so a compromised network can't swap it out alongside a
1382
+ // forged bundle). Without it, verifyBundle() never runs at all: an attacker who can intercept the
1383
+ // fetch to \`\${MAGP_API}/policy/bundle/...\` — a MITM, a compromised DNS/proxy — can hand this gateway
1384
+ // a bundle with a higher cap or no rules, and it would be trusted the same as the real one.
1385
+ const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API, requireAuthorization: false${policyKey ? `, policyPublicKey: '${policyKey}'` : ''} });
1369
1386
 
1370
1387
  const gateway = createHttpGateway({
1371
1388
  guard,
@@ -1446,7 +1463,10 @@ This is narrower than it sounds, so read it before relying on it.
1446
1463
  **Closed:** no tool call without a validly signed request from THIS agent for an action in its
1447
1464
  mandate. The agent's Ed25519 signature is checked against its DID, the action must be granted, and a
1448
1465
  suspended or quarantined agent is refused. Code that skips the agent's own guard and cannot sign as
1449
- the agent gets nothing, and there is no local function to call directly.
1466
+ the agent gets nothing, and there is no local function to call directly. The policy bundle itself is
1467
+ pinned to MetaMynd's own signing key (\`policyPublicKey\`, baked in from your provisioning response) —
1468
+ a party that can intercept the bundle fetch (a MITM, a compromised DNS/proxy) cannot hand this gateway
1469
+ a forged bundle with a higher cap or no rules; \`server.mjs\` refuses an unsigned or tampered one outright.
1450
1470
 
1451
1471
  **NOT closed — be precise about this:**
1452
1472
 
@@ -1604,7 +1624,7 @@ function exampleReadme(slug, scope, withGateway, gatewayPort, daemonKey = false)
1604
1624
  const configFileLine = daemonKey
1605
1625
  ? `- \`agent.metamynd.json\` — your portable guard config (identity, mandate scope \`${scope}\`, issuer keys).
1606
1626
  **Holds no secret key.** Signing goes through your already-running agentsafe-signer daemon
1607
- (\`daemonSocketPath\`) instead — see \`docs/integration/INSTALL-AGENTSAFE-SIGNER.md\`.`
1627
+ (\`daemonSocketPath\`) instead — see \`docs/integration/INSTALL-AGENTSAFE-SIGNER.md\`.${withGateway ? ' Payload binding is on by default and signs through the daemon too, which needs agentsafe-signer 0.15.0 or later.' : ''}`
1608
1628
  : `- \`agent.metamynd.json\` — your portable guard config (identity, mandate scope \`${scope}\`, issuer keys).
1609
1629
  **Contains the agent's secret key — never commit it.** It is already in \`.gitignore\`.`;
1610
1630
  const gatewaySection = withGateway
@@ -1710,7 +1730,7 @@ function gitignore() {
1710
1730
  // in the AgentSafe repo for the full pattern (mutual handshake, x402 payment, capability
1711
1731
  // binding) this is a minimal slice of.
1712
1732
 
1713
- function gatewayServerFile(scope, port, apiBase) {
1733
+ function gatewayServerFile(scope, port, apiBase, policyKey) {
1714
1734
  return `#!/usr/bin/env node
1715
1735
  // gateway/server.mjs — the REAL enforcement boundary for this agent's tool(s).
1716
1736
  //
@@ -1739,7 +1759,13 @@ async function bookFlight(args) {
1739
1759
  // this exact list anyway): a route with a real amount/merchant should always say so itself,
1740
1760
  // rather than relying on a library default to guess right. A route with NO value concept at
1741
1761
  // all (a read, a status check) should set valueFields: [] instead — see the gateway's README.
1742
- const routes = [{ method: 'POST', path: '/book-flight', action: '${scope}', valueFields: ['amount', 'merchant'] }];
1762
+ //
1763
+ // allowedFields is the COMPLETE list of top-level body keys bookFlight() reads. The gateway refuses
1764
+ // every other key (PAYLOAD_UNBINDABLE) because nothing the agent signed covers it — that is what
1765
+ // stops a request signed for $250 from carrying \`surcharge: 4750\` through to your tool. Add a key
1766
+ // here only when your real tool reads it; things policy needs to see (a risk level) travel in the
1767
+ // signed context, not the tool body.
1768
+ const routes = [{ method: 'POST', path: '/book-flight', action: '${scope}', valueFields: ['amount', 'merchant'], allowedFields: ['amount', 'currency', 'merchant'] }];
1743
1769
 
1744
1770
  // No serviceKey: this minimal gateway only calls verifyRequest() (re-check a signed request),
1745
1771
  // not the mutual-handshake methods, which are the only thing that needs it.
@@ -1747,11 +1773,22 @@ const routes = [{ method: 'POST', path: '/book-flight', action: '${scope}', valu
1747
1773
  // requireAuthorization: true is what closes replay and cumulative spend, not just per-request
1748
1774
  // policy — it requires the agent's authorizationId (from a REAL guard.authorize() call) to
1749
1775
  // atomically claim single-use execution against the issuer before this gateway runs the tool.
1750
- const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API, requireAuthorization: true });
1776
+ //
1777
+ // policyPublicKey pins the bundle to MetaMynd's OWN signing key (from your provisioning response,
1778
+ // baked in here — never fetched at runtime, so a compromised network can't swap it out alongside a
1779
+ // forged bundle). Without it, verifyBundle() never runs at all: an attacker who can intercept the
1780
+ // fetch to \`\${MAGP_API}/policy/bundle/...\` — a MITM, a compromised DNS/proxy — can hand this gateway
1781
+ // a bundle with a higher cap or no rules, and it would be trusted the same as the real one.
1782
+ const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API, requireAuthorization: true${policyKey ? `, policyPublicKey: '${policyKey}'` : ''} });
1751
1783
 
1752
1784
  const gateway = createHttpGateway({
1753
1785
  guard,
1754
1786
  routes,
1787
+ // Payload binding (MAGP 8.3.9): the agent signs a digest of the WHOLE body, this gateway digests the body it is about to
1788
+ // run, and the issuer refuses the claim unless the two are the digest the agent signed at authorize time. allowedFields
1789
+ // above lists which keys may appear; this makes the VALUES of every one of them (a payee, a passenger list) the agent's too.
1790
+ // A request whose authorization bound no payload is refused (PAYLOAD_BINDING_REQUIRED) rather than run unbound.
1791
+ requirePayloadBinding: true,
1755
1792
  forward: async (req) => {
1756
1793
  let args = {};
1757
1794
  try { args = JSON.parse(req.rawBody?.toString('utf8') || '{}'); } catch { /* empty body */ }
@@ -1877,6 +1914,10 @@ Four independent checks, each closing a different bypass an agent (or anything a
1877
1914
  own code, or a network attacker) might attempt:
1878
1915
 
1879
1916
  - **Direct call.** \`bookFlight()\` doesn't exist in the agent's process. There's nothing to call.
1917
+ - **Forged policy bundle.** \`server.mjs\` bakes in \`policyPublicKey\` (from your provisioning
1918
+ response) and refuses an unsigned or tampered bundle outright — a party that can intercept the
1919
+ fetch (a MITM, a compromised DNS/proxy) cannot hand this gateway a bundle with a higher cap or
1920
+ no rules and have it trusted the same as the real one.
1880
1921
  - **Confused deputy (payload).** The gateway re-verifies the signed request against this agent's
1881
1922
  own policy AND binds it to the actual request body (payload binding,
1882
1923
  \`@metamynd/agentsafe-http-gateway\` ≥ 0.4.5) — signing a cheap request while executing an
@@ -1899,6 +1940,15 @@ own code, or a network attacker) might attempt:
1899
1940
  already checked it against the mandate's TOTAL budget when it was minted — not just this one
1900
1941
  request's amount. Many small legal-looking calls can't add up past the mandate cap this way,
1901
1942
  because each needed its own real authorization first.
1943
+ - **Settlement (\`@metamynd/agentsafe-http-gateway\` ≥ 0.6.0).** A claimed hold stays against the
1944
+ mandate's cap until it is settled — it does NOT lapse after 15 minutes. So this gateway closes the
1945
+ hold it claimed once the upstream answers: a 2xx is captured at the authorized amount, and anything
1946
+ else (a rejection, a 5xx, a dropped connection) is parked as UNKNOWN so the spend stays committed.
1947
+ A rejected call therefore keeps its budget until it is reconciled; if a given upstream status
1948
+ guarantees nothing was executed, list it in \`releaseOnStatus\` on the route (e.g. \`[400, 422]\`) and
1949
+ the gateway will release that hold. Only the gateway that claimed the hold can do either — the agent
1950
+ cannot capture it lower or void it, which is what stopped it recovering the budget of a purchase it
1951
+ had just had executed.
1902
1952
  - **Amount unknown.** \`amount-unknown\` (\`@metamynd/agentsafe-mcp-guard\` ≥ 0.3.0) blocks a
1903
1953
  platform tool by default when its raw bytes or a nested payload hide the amount from a naive
1904
1954
  spend cap — AND this agent's OWN starter SOP (see \`agent.metamynd.json\` /
@@ -2002,9 +2052,15 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, currency = 'U
2002
2052
 
2003
2053
  if (withGateway) {
2004
2054
  const apiBase = config.apiBase ?? config.api ?? DEFAULT_API;
2055
+ // The key that signs every policy bundle (magp.policy-signer.ts) — present on every provisioning
2056
+ // response that talks to a real backend (full account, sandbox, delegated-claim). Baked into the
2057
+ // generated gateway so it pins the bundle rather than trusting whatever the fetch returns; absent
2058
+ // only for a config predating this field (an older scaffold's saved agent.metamynd.json), where
2059
+ // the generated gateway is unchanged from before rather than baking in nothing and crashing.
2060
+ const policyKey = config.issuer?.policyKey ?? null;
2005
2061
  const gwDir = join(outDir, 'gateway');
2006
2062
  if (!existsSync(gwDir)) mkdirSync(gwDir, { recursive: true });
2007
- writeFileSafe(gwDir, 'server.mjs', neutral ? gatewayServerFileNeutral(scope, gatewayPort, apiBase) : gatewayServerFile(scope, gatewayPort, apiBase), force);
2063
+ writeFileSafe(gwDir, 'server.mjs', neutral ? gatewayServerFileNeutral(scope, gatewayPort, apiBase, policyKey) : gatewayServerFile(scope, gatewayPort, apiBase, policyKey), force);
2008
2064
  writeFileSafe(gwDir, 'package.json', gatewayPackageJson(slug), force);
2009
2065
  writeFileSafe(gwDir, '.env.example', neutral ? gatewayEnvExampleNeutral() : gatewayEnvExample(), force);
2010
2066
  writeFileSafe(gwDir, '.gitignore', gatewayGitignore(), force);
@@ -2211,11 +2267,18 @@ function harnessGatewayServerFile(scope, gatewayPort, agentDid, neutral = false)
2211
2267
  // harness-gateway.mjs — a SEPARATE process from your agent. It holds the tool (${neutral ? 'the action below' : 'bookFlight below'}
2212
2268
  // never runs anywhere else) and independently re-verifies every request against
2213
2269
  // ../metamynd-rules.json using the REAL @metamynd/agentsafe-mcp-guard — the same package a
2214
- // production Service uses, just pointed at a local file instead of a hosted issuer. See
2215
- // ../README.md#--gateway for exactly what this does and does not close.
2270
+ // production Service uses, just pointed at a local file instead of a hosted issuer.
2271
+ //
2272
+ // There is NO enforcement logic of your own in this file. Routing, payload binding and the
2273
+ // deny-by-default posture come from @metamynd/agentsafe-http-gateway — the same component the
2274
+ // hosted scaffold uses — so what it refuses is what production refuses: a body that does not match
2275
+ // what the agent signed (PAYLOAD_NOT_BOUND), a value it cannot find (PAYLOAD_UNBINDABLE), and any
2276
+ // top-level key you did not list in allowedFields. See ../README.md#--gateway for what this closes.
2216
2277
  import { readFileSync } from 'node:fs';
2217
2278
  import http from 'node:http';
2218
2279
  import { createMcpGuard } from '${MCP_GUARD_PKG}';
2280
+ import { createHttpGateway } from '${GATEWAY_PKG}';
2281
+ import { matchRoute } from '${GATEWAY_PKG}/route-match';
2219
2282
 
2220
2283
  const PORT = Number(process.env.PORT || ${gatewayPort});
2221
2284
  // The agent's did:key, fixed at scaffold time — a request claiming to be any OTHER agentDid
@@ -2249,12 +2312,35 @@ const guard = createMcpGuard({
2249
2312
  },
2250
2313
  });
2251
2314
 
2252
- // One protected route per gated action in index.mjs. A path with no route below is refused —
2253
- // there is nothing to fall through TO; this gateway IS the tool, not a proxy in front of one.
2254
- const ROUTES = {
2255
- ${neutral ? `'/perform': { action: '${scope}', run: async (args) => ({ done: true, action: '${scope}' }) },` : `'/book-flight': { action: '${scope}', run: async (args) => ({ pnr: 'PNR-DEMO', ...args }) },`}
2256
- '/raise-limit': { action: 'permissions.update', run: async (args) => ({ updated: true, ...args }) },
2257
- };
2315
+ // One protected route per gated action in index.mjs. This gateway IS the tool, not a proxy in
2316
+ // front of one, so a path with no route below is refused (denyByDefault) — nothing to fall through TO.
2317
+ //
2318
+ // valueFields the fields the SIGNATURE covers that this route's body must carry and match
2319
+ // allowedFields the COMPLETE list of top-level body keys the tool reads. Anything else is refused,
2320
+ // because nothing signed covers it. Add a key here only when your tool reads it.
2321
+ const ROUTES = [
2322
+ ${neutral
2323
+ ? `{ method: 'POST', path: '/perform', action: '${scope}', valueFields: [], allowedFields: [], run: async () => ({ done: true, action: '${scope}' }) },`
2324
+ : `{ method: 'POST', path: '/book-flight', action: '${scope}', valueFields: ['amount', 'merchant'], allowedFields: ['amount', 'currency', 'merchant'], run: async (args) => ({ pnr: 'PNR-DEMO', ...args }) },`}
2325
+ { method: 'POST', path: '/raise-limit', action: 'permissions.update', valueFields: [], allowedFields: ['amount', 'currency', 'merchant'], run: async (args) => ({ updated: true, ...args }) },
2326
+ ];
2327
+
2328
+ const gateway = createHttpGateway({
2329
+ guard,
2330
+ routes: ROUTES,
2331
+ denyByDefault: true,
2332
+ // Payload binding (MAGP 8.3.9): the agent signs a digest of the WHOLE body and this gateway refuses to run a tool on a
2333
+ // body that is not exactly that one — the values of every allowed field, not only amount and merchant. A request that
2334
+ // bound no payload is refused (PAYLOAD_BINDING_REQUIRED) instead of run unbound.
2335
+ requirePayloadBinding: true,
2336
+ // Reached ONLY after the guard allowed the request AND the body was bound to what was signed.
2337
+ forward: async (req) => {
2338
+ const route = matchRoute(ROUTES, req.method, req.path);
2339
+ let args = {};
2340
+ try { args = JSON.parse(req.rawBody?.toString('utf8') || '{}'); } catch { /* empty body */ }
2341
+ return { status: 200, body: await route.run(args) };
2342
+ },
2343
+ });
2258
2344
 
2259
2345
  function readBody(req) {
2260
2346
  return new Promise((resolve, reject) => {
@@ -2266,26 +2352,19 @@ function readBody(req) {
2266
2352
  }
2267
2353
 
2268
2354
  const server = http.createServer(async (req, res) => {
2269
- const route = ROUTES[req.url];
2270
- const send = (status, body, decision) => {
2355
+ try {
2356
+ const rawBody = await readBody(req);
2357
+ const result = await gateway({ method: req.method, path: req.url, headers: req.headers, rawBody });
2271
2358
  const headers = { 'content-type': 'application/json' };
2359
+ const decision = result.governance?.decision;
2272
2360
  if (decision) headers['x-agentsafe-decision'] = decision;
2273
- res.writeHead(status, headers);
2274
- res.end(JSON.stringify(body));
2275
- };
2276
- if (req.method !== 'POST' || !route) return send(404, { decision: 'block', reasonCode: 'NO_SUCH_ROUTE' });
2277
- try {
2278
- const raw = await readBody(req);
2279
- const { signed, args } = JSON.parse(raw.toString('utf8') || '{}');
2280
- const verdict = await guard.verifyRequest({ ...signed, action: route.action });
2281
- if (verdict.decision !== 'allow' && verdict.decision !== 'observe') {
2282
- console.log('[harness-gateway] ' + verdict.decision.toUpperCase() + ' ' + req.url + ' — ' + verdict.reasonCode + ' (re-evaluated independently, did not trust the agent)');
2283
- return send(403, verdict, verdict.decision);
2284
- }
2285
- console.log('[harness-gateway] ALLOW ' + req.url + ' — running the real tool here, not in the agent process');
2286
- return send(200, await route.run(args ?? {}), verdict.decision);
2361
+ if (result.status === 200) console.log('[harness-gateway] ALLOW ' + req.url + ' — running the real tool here, not in the agent process');
2362
+ else console.log('[harness-gateway] ' + String(result.body?.decision ?? 'block').toUpperCase() + ' ' + req.url + ' — ' + (result.body?.reasonCode ?? 'refused') + ' (re-evaluated independently, did not trust the agent)');
2363
+ res.writeHead(result.status, headers);
2364
+ res.end(JSON.stringify(result.body ?? {}));
2287
2365
  } catch (err) {
2288
- return send(502, { decision: 'block', reasonCode: 'GATEWAY_ERROR', error: String(err?.message ?? err) });
2366
+ res.writeHead(502, { 'content-type': 'application/json' });
2367
+ res.end(JSON.stringify({ decision: 'block', reasonCode: 'GATEWAY_ERROR', error: String(err?.message ?? err) }));
2289
2368
  }
2290
2369
  });
2291
2370
 
@@ -2298,7 +2377,7 @@ server.listen(PORT, () => {
2298
2377
 
2299
2378
  function harnessGatewayPackageJson(slug) {
2300
2379
  return JSON.stringify(
2301
- { name: slug + '-harness-gateway', version: '0.1.0', private: true, type: 'module', scripts: { start: 'node harness-gateway.mjs' }, dependencies: { [MCP_GUARD_PKG]: MCP_GUARD_VERSION } },
2380
+ { name: slug + '-harness-gateway', version: '0.1.0', private: true, type: 'module', scripts: { start: 'node harness-gateway.mjs' }, dependencies: { [MCP_GUARD_PKG]: MCP_GUARD_VERSION, [GATEWAY_PKG]: GATEWAY_VERSION } },
2302
2381
  null,
2303
2382
  2,
2304
2383
  ) + '\n';
@@ -2713,8 +2792,11 @@ const GATEWAY = process.env.HARNESS_GATEWAY_URL || 'http://localhost:${gatewayPo
2713
2792
  // issuer): it builds and signs the same canonical message a real gate would verify, entirely
2714
2793
  // offline, using this agent's own did:key — the gateway verifies that signature for itself.
2715
2794
  async function callGateway(path, action, args) {
2716
- const signed = await guard.buildSignedRequest({ action, amount: args.amount, currency: args.currency ?? '${currency}', merchant: args.merchant, context: { tool: '${scope}', riskLevel: args.riskLevel ?? 'low' } });
2717
- const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ signed, args }) });
2795
+ // The COMPLETE body the tool receives, signed as a payload (MAGP 8.3.9): the eight signed fields cover amount and merchant
2796
+ // only, and this gateway refuses to run the tool on a body that is not exactly the one signed here.
2797
+ const payload = { amount: args.amount, merchant: args.merchant, currency: args.currency ?? '${currency}' };
2798
+ const signed = await guard.buildSignedRequest({ action, amount: args.amount, currency: args.currency ?? '${currency}', merchant: args.merchant, context: { tool: '${scope}', riskLevel: args.riskLevel ?? 'low' }, payload });
2799
+ const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) }, body: JSON.stringify(payload) });
2718
2800
  const body = await res.json().catch(() => null);
2719
2801
  if (!res.ok) {
2720
2802
  const err = new Error('gateway ' + res.status + ': ' + (body?.reasonCode ?? 'refused'));
@@ -2915,8 +2997,10 @@ ${withGateway ? `const GATEWAY = process.env.HARNESS_GATEWAY_URL || 'http://loca
2915
2997
  // file to call directly. buildSignedRequest() is pure (no network, no issuer): it signs the request
2916
2998
  // offline with this agent's own did:key, and the gateway verifies that signature for itself.
2917
2999
  async function callGateway(path, action, args) {
2918
- const signed = await guard.buildSignedRequest({ action, merchant: MERCHANT, context: args });
2919
- const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ signed, args }) });
3000
+ // The body is empty on purpose (this tool reads nothing from it), and it is signed as the payload so the gateway can
3001
+ // require binding on every route: a body with anything in it is not the one signed (MAGP 8.3.9).
3002
+ const signed = await guard.buildSignedRequest({ action, merchant: MERCHANT, context: args, payload: {} });
3003
+ const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) }, body: '{}' });
2920
3004
  const body = await res.json().catch(() => null);
2921
3005
  if (!res.ok) {
2922
3006
  const err = new Error('gateway ' + res.status + ': ' + (body?.reasonCode ?? 'refused'));
@@ -3132,7 +3216,15 @@ directly (or skip \`index.mjs\` and hand a forged/altered request straight to
3132
3216
  \`harness-gateway.mjs\`) — either way, the gateway independently re-verifies the Ed25519
3133
3217
  signature and re-evaluates the SAME rules file for itself. There is no raw \`${tool}()\` left
3134
3218
  in \`index.mjs\` to call for a shortcut, and a signature over an altered request fails
3135
- verification regardless of which process sent it.${neutral ? `
3219
+ verification regardless of which process sent it.
3220
+
3221
+ **Also closed — the executed arguments are bound to what was signed.** \`harness-gateway.mjs\` contains
3222
+ no enforcement logic of its own: routing, payload binding and deny-by-default come from
3223
+ \`@metamynd/agentsafe-http-gateway\`, the same component the hosted scaffold uses. A request signed for
3224
+ one amount and merchant but carrying another in the body is refused (\`PAYLOAD_NOT_BOUND\`) before your tool
3225
+ runs, and so is any top-level body key you did not list in that route's \`allowedFields\`
3226
+ (\`PAYLOAD_UNBINDABLE\`) — nothing signed covers it. When your real tool reads more body fields, list exactly
3227
+ those keys; the gateway refuses the rest.${neutral ? `
3136
3228
 
3137
3229
  **Also NOT closed — rule inputs are not signed.** The request fields your rules read (\`consent\`,
3138
3230
  \`piiPresent\`, \`jurisdiction\`, …) travel in the signed request's context, which the signature does
@@ -3643,6 +3735,6 @@ async function main() {
3643
3735
  export {
3644
3736
  buildPolicyCases, resolveFinancial, policyMolecules, ruleToMolecule,
3645
3737
  generateAgentKeypair, harnessAgentDid, harnessMandate, harnessRulesFile, harnessDefaultSopNeutral,
3646
- scaffoldProject, exampleIndexNeutral, exampleReadmeNeutral, gatewayServerFileNeutral, gatewayReadmeNeutral,
3738
+ scaffoldProject, defaultNeutralDemo, exampleIndexNeutral, exampleReadmeNeutral, gatewayServerFileNeutral, gatewayReadmeNeutral,
3647
3739
  };
3648
3740
  if (!process.env.CREATE_METAMYND_AGENT_NO_MAIN) main().catch((e) => fail(e?.stack || e?.message || String(e)));
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.10.10",
3
+ "version": "0.11.2",
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": {
7
7
  "create-metamynd-agent": "index.mjs"
8
8
  },
9
9
  "scripts": {
10
- "test": "node harness-gateway.smoke.mjs && node policy-demo.smoke.mjs && node hosted-nonfinancial.smoke.mjs && node modes-nonfinancial.smoke.mjs && node byok-daemon.smoke.mjs"
10
+ "test": "node generated-code-parses.smoke.mjs && node harness-gateway.smoke.mjs && node harness-gateway-bind.smoke.mjs && node policy-demo.smoke.mjs && node hosted-nonfinancial.smoke.mjs && node modes-nonfinancial.smoke.mjs && node byok-daemon.smoke.mjs"
11
11
  },
12
12
  "files": [
13
13
  "index.mjs",