create-metamynd-agent 0.10.10 → 0.10.16

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 +10 -0
  2. package/index.mjs +82 -36
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -388,6 +388,16 @@ polls until the owner approves, then scaffolds the project (the same default two
388
388
  the full flow above — `--no-gateway`/`--gateway-port` work here too). With `--byok` the keypair is
389
389
  generated locally and control is proven on claim — MetaMynd never sees the private key.
390
390
 
391
+ ## Risk: say it, or have your owner set it
392
+
393
+ The starter rules include a high-risk review. A request that sends **no** `riskLevel` (or an unrecognised one)
394
+ is no longer waved through: it is **escalated** (`CONTEXT_UNVERIFIABLE`) for a human, because an agent that omits
395
+ its risk is indistinguishable from one hiding it. The scaffolded financial examples send `riskLevel` (`'low'`
396
+ unless you say otherwise) — that default is a placeholder, not an assessment — and the neutral template passes
397
+ 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
398
+ permission can carry an owner-set `riskTier` that no claim can lower, and a gateway route can derive the risk
399
+ itself with `route.trustedContext` (`@metamynd/agentsafe-http-gateway` 0.10.0). See MAGP §6.3.
400
+
391
401
  ## Security
392
402
 
393
403
  `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.14.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
 
@@ -998,7 +998,7 @@ async function bookFlightViaGateway(args, decision) {
998
998
  const res = await fetch(GATEWAY + '/book-flight', {
999
999
  method: 'POST',
1000
1000
  headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) },
1001
- body: JSON.stringify(args),
1001
+ body: JSON.stringify({ amount: args.amount, merchant: args.merchant, currency: args.currency ?? '${currency}' }),
1002
1002
  });
1003
1003
  const body = await res.json().catch(() => null);
1004
1004
  if (!res.ok) {
@@ -1207,7 +1207,7 @@ async function performViaGateway(args, decision) {
1207
1207
  const res = await fetch(GATEWAY + '/perform', {
1208
1208
  method: 'POST',
1209
1209
  headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) },
1210
- body: JSON.stringify(args),
1210
+ body: '{}', // this tool reads nothing from the body; the request's fields travel in the SIGNED context
1211
1211
  });
1212
1212
  const body = await res.json().catch(() => null);
1213
1213
  if (!res.ok) {
@@ -1357,7 +1357,11 @@ async function performAction(args) {
1357
1357
  //
1358
1358
  // valueFields: [] because this action carries no amount or merchant to bind the body to — say so
1359
1359
  // explicitly rather than lean on the library default, which would demand both.
1360
- const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFields: [] }];
1360
+ //
1361
+ // allowedFields: [] because performAction() reads nothing from the body, so any key the agent adds is
1362
+ // refused (PAYLOAD_UNBINDABLE): nothing signed covers it. When your real tool reads body fields, list
1363
+ // exactly those keys here — the gateway refuses every top-level key you do not name.
1364
+ const routes = [{ method: 'POST', path: '/perform', action: '${scope}', valueFields: [], allowedFields: [] }];
1361
1365
 
1362
1366
  // requireAuthorization is OFF on purpose. It makes the gateway claim a single-use, stateful
1363
1367
  // authorization before running the tool — but the agent's guard only seals one for a value-bearing
@@ -1739,7 +1743,13 @@ async function bookFlight(args) {
1739
1743
  // this exact list anyway): a route with a real amount/merchant should always say so itself,
1740
1744
  // rather than relying on a library default to guess right. A route with NO value concept at
1741
1745
  // 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'] }];
1746
+ //
1747
+ // allowedFields is the COMPLETE list of top-level body keys bookFlight() reads. The gateway refuses
1748
+ // every other key (PAYLOAD_UNBINDABLE) because nothing the agent signed covers it — that is what
1749
+ // stops a request signed for $250 from carrying \`surcharge: 4750\` through to your tool. Add a key
1750
+ // here only when your real tool reads it; things policy needs to see (a risk level) travel in the
1751
+ // signed context, not the tool body.
1752
+ const routes = [{ method: 'POST', path: '/book-flight', action: '${scope}', valueFields: ['amount', 'merchant'], allowedFields: ['amount', 'currency', 'merchant'] }];
1743
1753
 
1744
1754
  // No serviceKey: this minimal gateway only calls verifyRequest() (re-check a signed request),
1745
1755
  // not the mutual-handshake methods, which are the only thing that needs it.
@@ -1899,6 +1909,15 @@ own code, or a network attacker) might attempt:
1899
1909
  already checked it against the mandate's TOTAL budget when it was minted — not just this one
1900
1910
  request's amount. Many small legal-looking calls can't add up past the mandate cap this way,
1901
1911
  because each needed its own real authorization first.
1912
+ - **Settlement (\`@metamynd/agentsafe-http-gateway\` ≥ 0.6.0).** A claimed hold stays against the
1913
+ mandate's cap until it is settled — it does NOT lapse after 15 minutes. So this gateway closes the
1914
+ hold it claimed once the upstream answers: a 2xx is captured at the authorized amount, and anything
1915
+ else (a rejection, a 5xx, a dropped connection) is parked as UNKNOWN so the spend stays committed.
1916
+ A rejected call therefore keeps its budget until it is reconciled; if a given upstream status
1917
+ guarantees nothing was executed, list it in \`releaseOnStatus\` on the route (e.g. \`[400, 422]\`) and
1918
+ the gateway will release that hold. Only the gateway that claimed the hold can do either — the agent
1919
+ cannot capture it lower or void it, which is what stopped it recovering the budget of a purchase it
1920
+ had just had executed.
1902
1921
  - **Amount unknown.** \`amount-unknown\` (\`@metamynd/agentsafe-mcp-guard\` ≥ 0.3.0) blocks a
1903
1922
  platform tool by default when its raw bytes or a nested payload hide the amount from a naive
1904
1923
  spend cap — AND this agent's OWN starter SOP (see \`agent.metamynd.json\` /
@@ -2211,11 +2230,18 @@ function harnessGatewayServerFile(scope, gatewayPort, agentDid, neutral = false)
2211
2230
  // harness-gateway.mjs — a SEPARATE process from your agent. It holds the tool (${neutral ? 'the action below' : 'bookFlight below'}
2212
2231
  // never runs anywhere else) and independently re-verifies every request against
2213
2232
  // ../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.
2233
+ // production Service uses, just pointed at a local file instead of a hosted issuer.
2234
+ //
2235
+ // There is NO enforcement logic of your own in this file. Routing, payload binding and the
2236
+ // deny-by-default posture come from @metamynd/agentsafe-http-gateway — the same component the
2237
+ // hosted scaffold uses — so what it refuses is what production refuses: a body that does not match
2238
+ // what the agent signed (PAYLOAD_NOT_BOUND), a value it cannot find (PAYLOAD_UNBINDABLE), and any
2239
+ // top-level key you did not list in allowedFields. See ../README.md#--gateway for what this closes.
2216
2240
  import { readFileSync } from 'node:fs';
2217
2241
  import http from 'node:http';
2218
2242
  import { createMcpGuard } from '${MCP_GUARD_PKG}';
2243
+ import { createHttpGateway } from '${GATEWAY_PKG}';
2244
+ import { matchRoute } from '${GATEWAY_PKG}/route-match';
2219
2245
 
2220
2246
  const PORT = Number(process.env.PORT || ${gatewayPort});
2221
2247
  // The agent's did:key, fixed at scaffold time — a request claiming to be any OTHER agentDid
@@ -2249,12 +2275,31 @@ const guard = createMcpGuard({
2249
2275
  },
2250
2276
  });
2251
2277
 
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
- };
2278
+ // One protected route per gated action in index.mjs. This gateway IS the tool, not a proxy in
2279
+ // front of one, so a path with no route below is refused (denyByDefault) nothing to fall through TO.
2280
+ //
2281
+ // valueFields the fields the SIGNATURE covers that this route's body must carry and match
2282
+ // allowedFields the COMPLETE list of top-level body keys the tool reads. Anything else is refused,
2283
+ // because nothing signed covers it. Add a key here only when your tool reads it.
2284
+ const ROUTES = [
2285
+ ${neutral
2286
+ ? `{ method: 'POST', path: '/perform', action: '${scope}', valueFields: [], allowedFields: [], run: async () => ({ done: true, action: '${scope}' }) },`
2287
+ : `{ method: 'POST', path: '/book-flight', action: '${scope}', valueFields: ['amount', 'merchant'], allowedFields: ['amount', 'currency', 'merchant'], run: async (args) => ({ pnr: 'PNR-DEMO', ...args }) },`}
2288
+ { method: 'POST', path: '/raise-limit', action: 'permissions.update', valueFields: [], allowedFields: ['amount', 'currency', 'merchant'], run: async (args) => ({ updated: true, ...args }) },
2289
+ ];
2290
+
2291
+ const gateway = createHttpGateway({
2292
+ guard,
2293
+ routes: ROUTES,
2294
+ denyByDefault: true,
2295
+ // Reached ONLY after the guard allowed the request AND the body was bound to what was signed.
2296
+ forward: async (req) => {
2297
+ const route = matchRoute(ROUTES, req.method, req.path);
2298
+ let args = {};
2299
+ try { args = JSON.parse(req.rawBody?.toString('utf8') || '{}'); } catch { /* empty body */ }
2300
+ return { status: 200, body: await route.run(args) };
2301
+ },
2302
+ });
2258
2303
 
2259
2304
  function readBody(req) {
2260
2305
  return new Promise((resolve, reject) => {
@@ -2266,26 +2311,19 @@ function readBody(req) {
2266
2311
  }
2267
2312
 
2268
2313
  const server = http.createServer(async (req, res) => {
2269
- const route = ROUTES[req.url];
2270
- const send = (status, body, decision) => {
2314
+ try {
2315
+ const rawBody = await readBody(req);
2316
+ const result = await gateway({ method: req.method, path: req.url, headers: req.headers, rawBody });
2271
2317
  const headers = { 'content-type': 'application/json' };
2318
+ const decision = result.governance?.decision;
2272
2319
  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);
2320
+ if (result.status === 200) console.log('[harness-gateway] ALLOW ' + req.url + ' — running the real tool here, not in the agent process');
2321
+ 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)');
2322
+ res.writeHead(result.status, headers);
2323
+ res.end(JSON.stringify(result.body ?? {}));
2287
2324
  } catch (err) {
2288
- return send(502, { decision: 'block', reasonCode: 'GATEWAY_ERROR', error: String(err?.message ?? err) });
2325
+ res.writeHead(502, { 'content-type': 'application/json' });
2326
+ res.end(JSON.stringify({ decision: 'block', reasonCode: 'GATEWAY_ERROR', error: String(err?.message ?? err) }));
2289
2327
  }
2290
2328
  });
2291
2329
 
@@ -2298,7 +2336,7 @@ server.listen(PORT, () => {
2298
2336
 
2299
2337
  function harnessGatewayPackageJson(slug) {
2300
2338
  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 } },
2339
+ { 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
2340
  null,
2303
2341
  2,
2304
2342
  ) + '\n';
@@ -2714,7 +2752,7 @@ const GATEWAY = process.env.HARNESS_GATEWAY_URL || 'http://localhost:${gatewayPo
2714
2752
  // offline, using this agent's own did:key — the gateway verifies that signature for itself.
2715
2753
  async function callGateway(path, action, args) {
2716
2754
  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 }) });
2755
+ const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) }, body: JSON.stringify({ amount: args.amount, merchant: args.merchant, currency: args.currency ?? '${currency}' }) });
2718
2756
  const body = await res.json().catch(() => null);
2719
2757
  if (!res.ok) {
2720
2758
  const err = new Error('gateway ' + res.status + ': ' + (body?.reasonCode ?? 'refused'));
@@ -2916,7 +2954,7 @@ ${withGateway ? `const GATEWAY = process.env.HARNESS_GATEWAY_URL || 'http://loca
2916
2954
  // offline with this agent's own did:key, and the gateway verifies that signature for itself.
2917
2955
  async function callGateway(path, action, args) {
2918
2956
  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 }) });
2957
+ const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) }, body: '{}' });
2920
2958
  const body = await res.json().catch(() => null);
2921
2959
  if (!res.ok) {
2922
2960
  const err = new Error('gateway ' + res.status + ': ' + (body?.reasonCode ?? 'refused'));
@@ -3132,7 +3170,15 @@ directly (or skip \`index.mjs\` and hand a forged/altered request straight to
3132
3170
  \`harness-gateway.mjs\`) — either way, the gateway independently re-verifies the Ed25519
3133
3171
  signature and re-evaluates the SAME rules file for itself. There is no raw \`${tool}()\` left
3134
3172
  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 ? `
3173
+ verification regardless of which process sent it.
3174
+
3175
+ **Also closed — the executed arguments are bound to what was signed.** \`harness-gateway.mjs\` contains
3176
+ no enforcement logic of its own: routing, payload binding and deny-by-default come from
3177
+ \`@metamynd/agentsafe-http-gateway\`, the same component the hosted scaffold uses. A request signed for
3178
+ one amount and merchant but carrying another in the body is refused (\`PAYLOAD_NOT_BOUND\`) before your tool
3179
+ runs, and so is any top-level body key you did not list in that route's \`allowedFields\`
3180
+ (\`PAYLOAD_UNBINDABLE\`) — nothing signed covers it. When your real tool reads more body fields, list exactly
3181
+ those keys; the gateway refuses the rest.${neutral ? `
3136
3182
 
3137
3183
  **Also NOT closed — rule inputs are not signed.** The request fields your rules read (\`consent\`,
3138
3184
  \`piiPresent\`, \`jurisdiction\`, …) travel in the signed request's context, which the signature does
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.10.10",
3
+ "version": "0.10.16",
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 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",