create-metamynd-agent 0.6.0 → 0.7.0

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 +71 -8
  2. package/index.mjs +477 -15
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -2,8 +2,13 @@
2
2
 
3
3
  Scaffold a **MetaMynd/AgentSafe-governed** AI agent in one command. It logs you in, provisions the
4
4
  agent in a **single call** (identity + mandate + starter SOP + all enforced Standards), writes a
5
- portable `agent.metamynd.json`, and drops a runnable example that gates a tool through the
6
- [`@metamynd/agentsafe-guard`](https://www.npmjs.com/package/@metamynd/agentsafe-guard).
5
+ portable `agent.metamynd.json`, and drops a runnable agent that gates a tool through the
6
+ [`@metamynd/agentsafe-guard`](https://www.npmjs.com/package/@metamynd/agentsafe-guard) — **plus, by
7
+ default, a second `gateway/` process** built on
8
+ [`@metamynd/agentsafe-mcp-guard`](https://www.npmjs.com/package/@metamynd/agentsafe-mcp-guard) and
9
+ [`@metamynd/agentsafe-http-gateway`](https://www.npmjs.com/package/@metamynd/agentsafe-http-gateway).
10
+ The agent's own `guardTool()` call is a fast, local, client-side check; the gateway is the real
11
+ enforcement boundary — see [Separate tool gateway](#separate-tool-gateway-default) below.
7
12
 
8
13
  > **Prerequisite:** an agent is always owned by a **KYB-verified owner** — a person/org with a
9
14
  > MetaMynd account. If that's you and you're verified, you're ready. Verify once in the dashboard if
@@ -41,6 +46,18 @@ rewrite**: the exact same `guardTool()` call your harness project already makes
41
46
  `bundleUrl`/`api` pointed at a real gate (provision normally, without `--harness`) instead of a rules
42
47
  file you authored yourself. Nothing about how you wrote your agent changes.
43
48
 
49
+ It is also **not a separate enforcement boundary**, and this matters more than the list above.
50
+ `guardToolLocal()` is a cooperative library your own process embeds — call the raw handler directly
51
+ instead of the guarded one and nothing stops you, because there is no second party in the loop to
52
+ disagree with you. Confirmed by direct testing: a bypass attempt (skip the guard, call the tool
53
+ function underneath it) succeeds every time, structurally, not as a bug. What actually closes this
54
+ is a **counterparty** — a separate process holding the tool, that independently re-verifies the
55
+ agent's signed authority for itself rather than trusting that the agent's own guard ran. `--harness`
56
+ never has one, by design (there's no second party on one machine with no network). **Just dropping
57
+ `--harness` is not enough on its own to get one either** — see
58
+ [Separate tool gateway](#separate-tool-gateway-default) below for what actually provides it, and
59
+ `--no-gateway`'s own caveat for what happens if you opt out of it.
60
+
44
61
  Works with `--config` too — its `rules` become the harness's starter rules file, same as the hosted
45
62
  flow. See [Policy config file](#policy-config-file---config) below.
46
63
 
@@ -62,6 +79,10 @@ call the hosted API (a shared demo identity) — it's a first look at the *hoste
62
79
  local/offline mode. Great for a first look; use the full flow below when you want your own governed
63
80
  agent with your own limits.
64
81
 
82
+ `--sandbox` always scaffolds the single-process shape (no `gateway/`) — it's a shared identity never
83
+ meant to hold real credentials, so there's nothing here worth a separate enforcement boundary for.
84
+ The generated project's own README says so. The full flow below is what scaffolds one by default.
85
+
65
86
  ## Use
66
87
 
67
88
  ```bash
@@ -70,18 +91,32 @@ npm create metamynd-agent@latest
70
91
  npx create-metamynd-agent
71
92
  ```
72
93
 
73
- Answer a few prompts (API, owner email/password, agent name, scope, per-transaction cap) and you get:
94
+ Answer a few prompts (API, owner email/password, agent name, scope, per-transaction cap) and you get
95
+ **two** scaffolded projects — the agent, and its tool gateway:
74
96
 
75
97
  ```
76
98
  my-agent/
77
99
  ├─ agent.metamynd.json # portable guard config — HOLDS THE AGENT SECRET KEY (gitignored)
78
- ├─ index.mjs # runnable example: ALLOW · BLOCK (over cap) · ESCALATE (high risk)
100
+ ├─ index.mjs # runnable example: signs + calls ./gateway; guardTool() here is a
101
+ │ # fast local pre-check, NOT the enforcement boundary
79
102
  ├─ package.json # depends on @metamynd/agentsafe-guard
80
103
  ├─ .gitignore
81
- └─ README.md
104
+ ├─ README.md
105
+ └─ gateway/ # a SEPARATE process — the real enforcement boundary. Read its
106
+ ├─ server.mjs # README first if you only read one.
107
+ ├─ package.json # depends on @metamynd/agentsafe-mcp-guard + @metamynd/agentsafe-http-gateway
108
+ ├─ .env.example # real tool credentials go here, never in the agent directory
109
+ ├─ .gitignore
110
+ └─ README.md
82
111
  ```
83
112
 
84
- Then:
113
+ Then, in **two terminals** — the gateway first:
114
+
115
+ ```bash
116
+ cd my-agent/gateway
117
+ npm install
118
+ npm start
119
+ ```
85
120
 
86
121
  ```bash
87
122
  cd my-agent
@@ -89,6 +124,27 @@ npm install
89
124
  npm start
90
125
  ```
91
126
 
127
+ ### Separate tool gateway (default)
128
+
129
+ `guard.guardTool()` in `index.mjs` still runs — it's a fast, local, client-side pre-check that gives
130
+ good UX (fail fast on an obviously-blocked call, no round trip) — but it is **not** what stops a
131
+ bypass. It still calls its handler in the SAME process regardless of where the decision came from,
132
+ so anything able to call that handler directly gets the same result the gate would have given it.
133
+
134
+ What actually stops a bypass is that `bookFlight()` doesn't exist in the agent's process at all.
135
+ It exists only in `gateway/server.mjs` — a separate process, started separately, holding any real
136
+ tool credentials the agent process never sees — which independently re-verifies every request
137
+ against the agent's own published policy bundle before running it (same shape as the mutual
138
+ counterparty check in [`@metamynd/agentsafe-mcp-guard`](https://www.npmjs.com/package/@metamynd/agentsafe-mcp-guard),
139
+ built with [`@metamynd/agentsafe-http-gateway`](https://www.npmjs.com/package/@metamynd/agentsafe-http-gateway)).
140
+ It's a minimal slice of the same pattern proven end to end in `demo/duffel-mcp-gateway` in the
141
+ AgentSafe repo (mutual handshake, x402 payment binding, capability tokens) — this scaffold gives you
142
+ just the part that closes the bypass, not the whole protocol.
143
+
144
+ Pass `--no-gateway` to opt out and get the old single-process scaffold instead — e.g. if you're
145
+ already running your own separate gateway and don't need this one. The generated project's own
146
+ README says plainly that this is *not* a separate enforcement boundary if you do.
147
+
92
148
  ## Non-interactive
93
149
 
94
150
  Every prompt has a flag or environment-variable fallback, so it scripts cleanly in CI:
@@ -111,6 +167,8 @@ METAMYND_PASSWORD='…' npx create-metamynd-agent --yes …
111
167
  | `--harness` | — | off (no login/KYB/network at all; free local governance — see above) |
112
168
  | `--sandbox` | — | off (skips login/KYB; shared sandbox agent, still hosted) |
113
169
  | `--config <file>` | — | a JSON policy file — see [Policy config file](#policy-config-file---config) |
170
+ | `--no-gateway` | — | off — hosted flow only; skips the default separate tool gateway (see above) |
171
+ | `--gateway-port <n>` | — | `4401` — hosted flow only, the gateway process's port |
114
172
  | `--port <n>` | — | `4400` — `--harness` only, the local dashboard's port |
115
173
  | `--api <url>` | `METAMYND_API` | `https://metamynd.ai/api/v1` |
116
174
  | `--email <email>` | `METAMYND_EMAIL` | — (required) |
@@ -188,14 +246,19 @@ npx create-metamynd-agent --claim --watch
188
246
  ```
189
247
 
190
248
  `--request` submits the request (as your own authed user) and stores the claim token locally; `--claim`
191
- polls until the owner approves, then scaffolds the project. With `--byok` the keypair is generated
192
- locally and control is proven on claim MetaMynd never sees the private key.
249
+ polls until the owner approves, then scaffolds the project (the same default two-process shape as
250
+ the full flow above `--no-gateway`/`--gateway-port` work here too). With `--byok` the keypair is
251
+ generated locally and control is proven on claim — MetaMynd never sees the private key.
193
252
 
194
253
  ## Security
195
254
 
196
255
  `agent.metamynd.json` contains the agent's **secret key** (a managed key, or — with `--byok` — the one
197
256
  generated locally). The scaffolded project gitignores it. Never commit it or paste it anywhere public.
198
257
 
258
+ Any REAL tool credential (an airline API key, a payment key, ...) belongs in `gateway/.env` — never
259
+ in the agent directory. That's the whole point of the default two-process shape: the agent process
260
+ should never be able to hold, or leak, a credential it doesn't have.
261
+
199
262
  ## Full guide
200
263
 
201
264
  `docs/integration/INTEGRATE-WITH-METAMYND.md` — the complete integration front-door (payments,
package/index.mjs CHANGED
@@ -3,8 +3,10 @@
3
3
  //
4
4
  // Logs a KYB-verified owner in, provisions the agent in ONE call
5
5
  // (POST /onboarding/agent → identity + mandate + starter SOP + enforced Standards),
6
- // writes the portable `agent.metamynd.json`, and drops a runnable example that gates a
7
- // tool through the guard (allow / block / escalate).
6
+ // writes the portable `agent.metamynd.json` and a runnable agent example, PLUS (by default)
7
+ // a separate `gateway/` process — a second, independent guard that re-verifies every request
8
+ // and holds the real tool, so the agent's own guardTool() call is a convenience, not the
9
+ // enforcement boundary. `--no-gateway` skips it (see README#separate-tool-gateway-default).
8
10
  //
9
11
  // ZERO dependencies: Node ≥ 18 built-ins only (fetch, readline).
10
12
  //
@@ -22,7 +24,13 @@ const GUARD_PKG = '@metamynd/agentsafe-guard';
22
24
  // >=0.4.0 <0.5.0, so leaving this at ^0.4.0 would scaffold an agent whose `npm test` runs
23
25
  // `agentsafe-guard verify` against a guard that has no such command.
24
26
  const GUARD_VERSION = '^0.5.0';
27
+ // The default hosted scaffold's SECOND process — the tool gateway (see scaffoldProject).
28
+ const MCP_GUARD_PKG = '@metamynd/agentsafe-mcp-guard';
29
+ const MCP_GUARD_VERSION = '^0.1.0';
30
+ const GATEWAY_PKG = '@metamynd/agentsafe-http-gateway';
31
+ const GATEWAY_VERSION = '^0.1.0';
25
32
  const DEFAULT_API = 'https://metamynd.ai/api/v1';
33
+ const DEFAULT_GATEWAY_PORT = 4401; // distinct from --harness's dashboard (4400)
26
34
 
27
35
  // ---------- tiny ANSI ----------
28
36
  const c = {
@@ -88,6 +96,10 @@ ${c.b('Options')}
88
96
  (MetaMynd never sees the private key). Overridden by --public-key.
89
97
  --public-key <hex> BYOK with a key you already hold (SPKI/raw hex); you prove control yourself
90
98
  --out <dir> Output project directory (default ./<agent-slug>)
99
+ --no-gateway Hosted flow only: skip the separate tool-gateway process (see
100
+ README#separate-tool-gateway-default) and scaffold the old
101
+ single-process example instead. Not a separate enforcement boundary.
102
+ --gateway-port <n> Hosted flow only: the gateway process's port (default 4401)
91
103
  --port <n> --harness only: the local dashboard's port (default 4400)
92
104
  --yes, -y Non-interactive: use flags/env/defaults, never prompt
93
105
  -h, --help Show this help
@@ -99,7 +111,9 @@ ${c.b('Environment')}
99
111
  ${c.b('What it does')}
100
112
  1. Logs in as a KYB-verified owner → owner access token
101
113
  2. POST /onboarding/agent (one call) → identity + mandate + SOP + Standards
102
- 3. Writes agent.metamynd.json + a runnable example that gates a tool through the guard.
114
+ 3. Writes agent.metamynd.json + index.mjs, PLUS (by default) a separate gateway/ process
115
+ the real enforcement boundary, not index.mjs's own guard.guardTool() call. --no-gateway
116
+ skips it.
103
117
  `;
104
118
 
105
119
  // ---------- prompts ----------
@@ -257,7 +271,17 @@ async function apiPost(base, path, body, token) {
257
271
  }
258
272
 
259
273
  // ---------- scaffolding ----------
260
- function exampleIndex(scope, perTxnMax) {
274
+ /**
275
+ * The --no-gateway / --sandbox variant: the tool is a local function in the SAME process as
276
+ * guard.guardTool(). Fine for a demo with nothing real behind it (--sandbox always uses this —
277
+ * it's a shared identity, never meant to hold real credentials). For anything that touches a
278
+ * real credential, guard.guardTool() alone is a client-side convenience, not a boundary: it
279
+ * still calls this handler in-process regardless of where the decision came from, so an agent
280
+ * that skips it and calls bookFlight() directly gets the same result the gate would have given
281
+ * it — the same shape of gap --harness's README documents. See exampleIndex() below, which is
282
+ * what the real (non-sandbox) flow scaffolds by default instead.
283
+ */
284
+ function exampleIndexNoGateway(scope, perTxnMax) {
261
285
  const under = Math.max(1, Math.round(perTxnMax * 0.5));
262
286
  const over = Math.round(perTxnMax + 100);
263
287
  return `// index.mjs — your agent, governed by MetaMynd/AgentSafe.
@@ -268,6 +292,11 @@ import { createGuardFromConfig } from '${GUARD_PKG}';
268
292
  const guard = await createGuardFromConfig('./agent.metamynd.json'); // no env vars
269
293
 
270
294
  // --- Your real tool. Replace the body with your actual implementation. ---
295
+ // --- If that implementation touches a real credential, this in-process call is NOT an
296
+ // --- enforcement boundary: guard.guardTool() below still calls this function directly in
297
+ // --- THIS process regardless of the decision's source, so anything that can call it directly
298
+ // --- gets the same result the gate would have given it. A real (non --sandbox) scaffold
299
+ // --- without --no-gateway moves this behind a separate process instead. See README.
271
300
  async function bookFlight(args) {
272
301
  return { pnr: 'PNR-DEMO', ...args };
273
302
  }
@@ -397,6 +426,186 @@ console.log('');
397
426
  `;
398
427
  }
399
428
 
429
+ /**
430
+ * The DEFAULT hosted scaffold: the tool lives in a separate process (./gateway), not here.
431
+ * guard.guardTool() below is still called — it is a fast, local, client-side pre-check that
432
+ * gives good UX (fail fast, no round trip for an obviously-blocked call) — but it is not what
433
+ * stops a bypass. What stops a bypass is that there is no bookFlight() in THIS process to call
434
+ * directly: it only exists in ./gateway, which independently re-verifies every request against
435
+ * this agent's own policy bundle before it runs, and holds any real credentials the tool needs.
436
+ */
437
+ function exampleIndex(scope, perTxnMax, gatewayPort) {
438
+ const under = Math.max(1, Math.round(perTxnMax * 0.5));
439
+ const over = Math.round(perTxnMax + 100);
440
+ return `// index.mjs — your agent, governed by MetaMynd/AgentSafe.
441
+ // Every governed tool call is checked TWICE before it runs: once here (fast, local, client-side),
442
+ // and independently again by ./gateway — a SEPARATE process that holds the real tool and its
443
+ // credentials. That second check is the actual enforcement boundary; see ./gateway/README.md.
444
+ import { createGuardFromConfig } from '${GUARD_PKG}';
445
+
446
+ // Loads agent.metamynd.json: the agent's DID, its signing key, and the gate to call.
447
+ const guard = await createGuardFromConfig('./agent.metamynd.json'); // no env vars
448
+
449
+ const GATEWAY = process.env.GATEWAY_URL || 'http://localhost:${gatewayPort}';
450
+
451
+ // --- Calls the gateway process instead of a local function. There is no raw bookFlight() in
452
+ // --- this file to call directly — the tool, and any real credentials it needs, live only in
453
+ // --- ./gateway, which independently re-verifies this signed request itself.
454
+ async function bookFlightViaGateway(args) {
455
+ const signed = guard.buildSignedRequest({
456
+ action: '${scope}',
457
+ amount: args.amount,
458
+ currency: 'USD',
459
+ merchant: args.merchant,
460
+ context: { tool: 'book-flight', riskLevel: args.riskLevel ?? 'low' },
461
+ });
462
+ const res = await fetch(GATEWAY + '/book-flight', {
463
+ method: 'POST',
464
+ headers: { 'content-type': 'application/json', 'x-magp-request': JSON.stringify(signed) },
465
+ body: JSON.stringify(args),
466
+ });
467
+ const body = await res.json().catch(() => null);
468
+ if (!res.ok) {
469
+ const err = new Error('gateway ' + res.status + ': ' + (body?.reasonCode ?? 'refused'));
470
+ err.name = 'GovernanceBlocked';
471
+ err.governance = { decision: body?.decision ?? 'block', reasonCode: body?.reasonCode ?? 'GATEWAY_ERROR' };
472
+ throw err;
473
+ }
474
+ return body;
475
+ }
476
+
477
+ // --- The GATED version. Register THIS with your agent instead of calling the gateway directly.
478
+ // --- This local check and the gateway's own re-check are independent; neither trusts the other.
479
+ const gatedBookFlight = guard.guardTool(
480
+ '${scope}', // = your mandate scope
481
+ bookFlightViaGateway,
482
+ (a) => ({ // map tool args → gate inputs
483
+ amount: a.amount,
484
+ currency: 'USD',
485
+ merchant: a.merchant,
486
+ context: { tool: 'book-flight', riskLevel: a.riskLevel ?? 'low' },
487
+ }),
488
+ );
489
+
490
+ // --- A tool the agent was NEVER granted. Wrapping it is the demonstration: there is no
491
+ // --- rule anywhere forbidding this. The mandate simply never mentioned the action.
492
+ async function raiseOwnLimit(args) {
493
+ return { updated: true, ...args }; // never runs, and that is the point
494
+ }
495
+
496
+ const gatedRaiseOwnLimit = guard.guardTool(
497
+ 'permissions.update', // an action NOT in the mandate
498
+ raiseOwnLimit,
499
+ (a) => ({
500
+ amount: a.amount,
501
+ currency: 'USD',
502
+ merchant: a.merchant,
503
+ context: { tool: 'permissions-update' },
504
+ }),
505
+ );
506
+
507
+ const dim = (t) => '\\x1b[2m' + t + '\\x1b[0m';
508
+ const bold = (t) => '\\x1b[1m' + t + '\\x1b[0m';
509
+ const rule = (n) => ' ' + '-'.repeat(n);
510
+
511
+ // Plain-English meaning for the reason codes this demo can produce. The gateway re-evaluates
512
+ // the SAME policy bundle with the SAME evaluator the gate uses, so it produces these same codes.
513
+ const WHY = {
514
+ AUTHORIZED: 'inside the mandate and under the SOP spend cap',
515
+ SOP_SPEND_CAP: 'your SOP caps a single transaction at $${perTxnMax}',
516
+ RISK_REVIEW: 'your SOP sends high-risk actions to a human first',
517
+ MERCHANT_NOT_ALLOWED: 'the mandate lists which merchants this agent may pay',
518
+ // Both say the same thing from where you are standing: the mandate does not cover that
519
+ // action. Which one you see depends on whether the verdict was reached here or at the
520
+ // gate, and neither of them depends on the amount.
521
+ NO_PERMISSION_FOR_ACTION: 'the mandate never granted this action - at any amount',
522
+ NO_MANDATE: 'there is no mandate for this action at all',
523
+ };
524
+
525
+ // ---------------------------------------------------------------- 1. CONTEXT
526
+ console.log('');
527
+ console.log(bold(' What this simulation shows'));
528
+ console.log('');
529
+ console.log(' An agent should not be the thing that decides what it is allowed to do — and');
530
+ console.log(' it should not be the thing that RUNS what it decided, either. This run makes');
531
+ console.log(' both concrete. Three attempts take the SAME code path and produce three');
532
+ console.log(' different outcomes. The fourth asks for something the agent was never granted');
533
+ console.log(' at all - and that is the one a prompt could not have stopped, because the');
534
+ console.log(' decision is not made inside your program, and the tool is not either.');
535
+
536
+ // ---------------------------------------------------------------- 2. MECHANISM
537
+ console.log('');
538
+ console.log(bold(' How it does that'));
539
+ console.log('');
540
+ console.log(dim(' 1. this project holds an agent identity (a DID) and its signing key'));
541
+ console.log(dim(' 2. that agent has a mandate - a scope it may act in, and a spend cap'));
542
+ console.log(dim(' 3. guardTool() wraps your tool call, giving a fast local pre-check'));
543
+ console.log(dim(' 4. each attempt is ALSO signed and sent to ./gateway - a separate process'));
544
+ console.log(dim(' 5. the gateway independently re-verifies before your tool runs there'));
545
+ console.log(dim(' 6. there is no local bookFlight() to call directly - only the gateway has it'));
546
+ console.log('');
547
+ console.log(dim(' scope ${scope}'));
548
+ console.log(dim(' cap $${perTxnMax} per transaction, set by your SOP'));
549
+ console.log(dim(' gateway ' + GATEWAY + ' (run it in a separate terminal - see ./gateway)'));
550
+
551
+ // ---------------------------------------------------------------- 3. THE STEPS
552
+ async function attempt(n, intent, args, tool = gatedBookFlight) {
553
+ console.log('');
554
+ console.log(bold(' Step ' + n + ' of 4') + ' - ' + intent);
555
+ console.log(dim(' signing the request locally, then asking the gate to decide...'));
556
+ try {
557
+ const r = await tool(args);
558
+ console.log('\\x1b[32m ALLOWED\\x1b[0m your tool ran (in ./gateway) and returned ' + (r.pnr ?? 'ok'));
559
+ console.log(dim(' ' + WHY.AUTHORIZED));
560
+ } catch (e) {
561
+ const g = e.governance ?? {};
562
+ const why = WHY[g.reasonCode] ?? e.message;
563
+ if (g.decision === 'escalate') {
564
+ console.log('\\x1b[33m ESCALATED\\x1b[0m held for a human - ' + g.reasonCode);
565
+ console.log(dim(' ' + why));
566
+ console.log(dim(' not a failure: approve it in the dashboard and the action resumes.'));
567
+ } else {
568
+ console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? 'refused'));
569
+ console.log(dim(' ' + why));
570
+ console.log(dim(' your tool never ran - refused before execution.'));
571
+ }
572
+ }
573
+ }
574
+
575
+ console.log('');
576
+ console.log(rule(66));
577
+ await attempt(1, 'a $${under} booking, low risk. Expected to pass.', { amount: ${under}, merchant: 'skyward-air', riskLevel: 'low' });
578
+ await attempt(2, 'a $${over} booking, deliberately over the cap.', { amount: ${over}, merchant: 'skyward-air', riskLevel: 'low' });
579
+ await attempt(3, 'a $${under} booking, but flagged high risk.', { amount: ${under}, merchant: 'skyward-air', riskLevel: 'high' });
580
+ await attempt(
581
+ 4,
582
+ 'the agent stops booking flights and asks to raise its OWN limit.',
583
+ { amount: 100000, merchant: 'skyward-air' },
584
+ gatedRaiseOwnLimit,
585
+ );
586
+ console.log('');
587
+ console.log(rule(66));
588
+
589
+ // ---------------------------------------------------------------- 4. RESULT
590
+ console.log('');
591
+ console.log(bold(' What this proved'));
592
+ console.log('');
593
+ console.log(dim(' - one code path, three outcomes. The rules decided, not this file'));
594
+ console.log(dim(' and not the model driving it.'));
595
+ console.log(dim(' - step 1 ran in ./gateway, a process this file cannot reach into. There'));
596
+ console.log(dim(' is no rawBookFlight() here to call instead - that is what actually'));
597
+ console.log(dim(' stops a bypass, not the guardTool() call above it.'));
598
+ console.log(dim(' - step 4 needed no rule to stop it. The agent could not widen its own'));
599
+ console.log(dim(' authority, because it cannot name an action nobody delegated to it.'));
600
+ console.log(dim(' - every blocked/escalated call never reached a real tool at all.'));
601
+ console.log(dim(' - if the gate were unreachable the guard fails CLOSED: it blocks.'));
602
+ console.log('');
603
+ console.log(' Change the cap in the dashboard (Legal Entity -> SOPs) and run again.');
604
+ console.log(dim(' The outcome changes. This file does not. That is the point.'));
605
+ console.log('');
606
+ `;
607
+ }
608
+
400
609
  function examplePackageJson(slug) {
401
610
  return JSON.stringify(
402
611
  {
@@ -415,12 +624,41 @@ function examplePackageJson(slug) {
415
624
  ) + '\n';
416
625
  }
417
626
 
418
- function exampleReadme(slug, scope) {
419
- return `# ${slug}
627
+ function exampleReadme(slug, scope, withGateway, gatewayPort) {
628
+ const gatewaySection = withGateway
629
+ ? `## Run
420
630
 
421
- A MetaMynd/AgentSafe-governed agent, scaffolded with \`create-metamynd-agent\`.
631
+ Two processes — start the gateway first, in its own terminal:
422
632
 
423
- ## Run
633
+ \`\`\`bash
634
+ cd gateway && npm install && npm start # the REAL enforcement boundary — see gateway/README.md
635
+ \`\`\`
636
+
637
+ Then, in this directory:
638
+
639
+ \`\`\`bash
640
+ npm install
641
+ npm start
642
+ \`\`\`
643
+
644
+ You should see an ALLOW (fulfilled by \`./gateway\`), a BLOCK (over the per-transaction cap), and
645
+ an ESCALATE (high risk). The BLOCK and ESCALATE never reach the gateway at all — this file's own
646
+ \`guard.guardTool()\` refuses them first. Only the ALLOW crosses into the other process.
647
+
648
+ ## Files
649
+
650
+ - \`agent.metamynd.json\` — your portable guard config (identity, mandate scope \`${scope}\`, issuer keys).
651
+ **Contains the agent's secret key — never commit it.** It is already in \`.gitignore\`.
652
+ - \`index.mjs\` — signs each request and calls \`./gateway\` for it; \`guard.guardTool()\` here is a
653
+ fast local pre-check, not the enforcement boundary.
654
+ - \`gateway/\` — a **separate process**. It holds the real tool and independently re-verifies every
655
+ request against this agent's own policy before running it. See \`gateway/README.md\` — read that
656
+ one first if you're only going to read one.
657
+
658
+ ## What this is not
659
+
660
+ `
661
+ : `## Run
424
662
 
425
663
  \`\`\`bash
426
664
  npm install
@@ -435,6 +673,34 @@ You should see an ALLOW, a BLOCK (over the per-transaction cap), and an ESCALATE
435
673
  **Contains the agent's secret key — never commit it.** It is already in \`.gitignore\`.
436
674
  - \`index.mjs\` — wraps a tool with \`guard.guardTool(...)\`; the tool only runs when the gate allows.
437
675
 
676
+ ## What this is not
677
+
678
+ `;
679
+ return `# ${slug}
680
+
681
+ A MetaMynd/AgentSafe-governed agent, scaffolded with \`create-metamynd-agent\`.
682
+
683
+ ${gatewaySection}${
684
+ withGateway
685
+ ? `This scaffold's default shape (agent + separate gateway process, port ${gatewayPort} by
686
+ default) is the actual enforcement boundary: \`guard.guardTool()\` in \`index.mjs\` is a
687
+ client-side convenience, not a boundary — it still runs its handler in-process regardless of
688
+ where the decision came from. What actually stops a bypass is that \`bookFlight()\` itself only
689
+ exists in \`./gateway\`, a process this one cannot reach into, which independently re-verifies
690
+ every request against this agent's own policy bundle. Re-scaffold with \`--no-gateway\` for the
691
+ old single-process shape — it is NOT a separate enforcement boundary; see its own generated
692
+ README for why.`
693
+ : `This scaffold has no separate gateway process (either \`--sandbox\`, which never
694
+ provisions real credentials, or \`--no-gateway\` was passed): \`guard.guardTool()\` wraps a tool
695
+ in the SAME process as the check itself. That is a client-side convenience, not a boundary — it
696
+ still runs your tool's handler in-process regardless of where the decision came from, so
697
+ anything able to call \`bookFlight()\` directly gets the same result the gate would have given
698
+ it. If this tool ever holds a real credential, provision for real (drop \`--sandbox\`) without
699
+ \`--no-gateway\` for the default shape, which puts the tool behind a separate process instead.
700
+ This is the same structural gap \`--harness\`'s README documents, for the same reason: a
701
+ cooperative in-process check has no counterparty to disagree with a caller that skips it.`
702
+ }
703
+
438
704
  ## Change the rules
439
705
 
440
706
  Edit the agent's SOPs in the dashboard (Legal Entity → SOPs). The agent's behaviour changes live —
@@ -448,6 +714,172 @@ function gitignore() {
448
714
  return `node_modules/\nagent.metamynd.json\n.env\n`;
449
715
  }
450
716
 
717
+ // ---------- the default hosted scaffold's second process: a separate tool gateway ----------
718
+ //
719
+ // Not a new protocol — @metamynd/agentsafe-mcp-guard (trustless verifyRequest, already public)
720
+ // and @metamynd/agentsafe-http-gateway (the generic reverse-proxy built on it, already public)
721
+ // do the real work. This just wires up the smallest useful shape: one protected route, one
722
+ // tool, re-verified independently of the agent that's calling it. See demo/duffel-mcp-gateway
723
+ // in the AgentSafe repo for the full pattern (mutual handshake, x402 payment, capability
724
+ // binding) this is a minimal slice of.
725
+
726
+ function gatewayServerFile(scope, port, apiBase) {
727
+ return `#!/usr/bin/env node
728
+ // gateway/server.mjs — the REAL enforcement boundary for this agent's tool(s).
729
+ //
730
+ // This is a SEPARATE process from the agent. It holds the tool's real credentials (the agent
731
+ // process never does), and it independently re-verifies every request against this agent's OWN
732
+ // published policy bundle — it does not trust the agent's own guard.guardTool() check. A
733
+ // compromised or dishonest agent calling its own local function gets nothing here, because
734
+ // there is no local function: the tool only runs in this process.
735
+ import http from 'node:http';
736
+ import { createMcpGuard } from '${MCP_GUARD_PKG}';
737
+ import { createHttpGateway } from '${GATEWAY_PKG}';
738
+
739
+ const PORT = Number(process.env.PORT || ${port});
740
+ const MAGP_API = process.env.MAGP_API || '${apiBase}';
741
+
742
+ // --- Your real tool. Real credentials (an airline API key, a payment key, ...) belong ONLY
743
+ // --- here, read from process.env (see .env.example) — never in the agent process.
744
+ async function bookFlight(args) {
745
+ return { pnr: 'PNR-DEMO', ...args };
746
+ }
747
+
748
+ // One protected route: only a request signed by this agent, for exactly this action, and
749
+ // re-verified against this agent's own mandate/SOP, reaches bookFlight() below.
750
+ const routes = [{ method: 'POST', path: '/book-flight', action: '${scope}' }];
751
+
752
+ // No serviceKey: this minimal gateway only calls verifyRequest() (re-check a signed request),
753
+ // not the mutual-handshake methods, which are the only thing that needs it.
754
+ const guard = createMcpGuard({ serviceDid: 'did:local:${scope}-gateway', issuerApi: MAGP_API });
755
+
756
+ const gateway = createHttpGateway({
757
+ guard,
758
+ routes,
759
+ forward: async (req) => {
760
+ let args = {};
761
+ try { args = JSON.parse(req.rawBody?.toString('utf8') || '{}'); } catch { /* empty body */ }
762
+ const result = await bookFlight(args);
763
+ return { status: 200, body: result };
764
+ },
765
+ // This gateway IS the tool, not a proxy in front of one — an unmatched path has nothing to
766
+ // pass through TO. Without this, any path a route doesn't match falls through ungoverned
767
+ // straight to forward() above, which would run bookFlight() with no check at all.
768
+ denyByDefault: true,
769
+ });
770
+
771
+ function readBody(req) {
772
+ return new Promise((resolve, reject) => {
773
+ const chunks = [];
774
+ req.on('data', (c) => chunks.push(c));
775
+ req.on('end', () => resolve(Buffer.concat(chunks)));
776
+ req.on('error', reject);
777
+ });
778
+ }
779
+
780
+ const server = http.createServer(async (req, res) => {
781
+ try {
782
+ const rawBody = await readBody(req);
783
+ const result = await gateway({ method: req.method, path: req.url, headers: req.headers, rawBody });
784
+ const headers = { 'content-type': 'application/json' };
785
+ if (result.governance) headers['x-agentsafe-decision'] = result.governance.decision;
786
+ res.writeHead(result.status, headers);
787
+ res.end(JSON.stringify(result.body ?? {}));
788
+ } catch (err) {
789
+ // Fail CLOSED on any gateway error.
790
+ res.writeHead(502, { 'content-type': 'application/json' });
791
+ res.end(JSON.stringify({ decision: 'block', reasonCode: 'GATEWAY_ERROR', error: String(err?.message ?? err) }));
792
+ }
793
+ });
794
+
795
+ server.listen(PORT, () => {
796
+ console.log('[gateway] listening on :' + PORT + ' -> the only place bookFlight() runs.');
797
+ console.log('[gateway] every request is independently re-verified against this agent\\'s own policy.');
798
+ });
799
+ `;
800
+ }
801
+
802
+ function gatewayPackageJson(slug) {
803
+ return JSON.stringify(
804
+ {
805
+ name: slug + '-gateway',
806
+ version: '0.1.0',
807
+ private: true,
808
+ type: 'module',
809
+ scripts: { start: 'node server.mjs' },
810
+ dependencies: { [MCP_GUARD_PKG]: MCP_GUARD_VERSION, [GATEWAY_PKG]: GATEWAY_VERSION },
811
+ },
812
+ null,
813
+ 2,
814
+ ) + '\n';
815
+ }
816
+
817
+ function gatewayEnvExample() {
818
+ return `# Real tool credentials belong HERE, read from process.env in server.mjs — never in the
819
+ # agent process one directory up.
820
+ # AIRLINE_API_KEY=
821
+ `;
822
+ }
823
+
824
+ function gatewayGitignore() {
825
+ return `node_modules/\n.env\n`;
826
+ }
827
+
828
+ function gatewayReadme(slug, scope, port) {
829
+ return `# ${slug}-gateway
830
+
831
+ This is the **real enforcement boundary** for \`${slug}\`'s tool(s) — not \`../index.mjs\`.
832
+
833
+ ## Why this exists
834
+
835
+ \`guard.guardTool()\` in the agent's \`index.mjs\` is a client-side convenience: it gives fast,
836
+ local ALLOW/BLOCK/ESCALATE feedback, but it still runs its handler in the SAME process
837
+ regardless of where that decision came from. Anything able to call the agent's tool function
838
+ directly — a bug, a compromised dependency, a dishonest fork of the agent's own code — gets the
839
+ same result the gate would have given it. That is not a defect in \`guardTool()\`; a cooperative
840
+ in-process check has no counterparty to disagree with a caller that skips it. See \`--harness\`'s
841
+ own README for the same structural point in the free local-demo mode.
842
+
843
+ This process closes that gap by being a **separate** one. The agent has no way to reach into it
844
+ and call \`bookFlight()\` directly, because \`bookFlight()\` doesn't exist in the agent's process —
845
+ it exists only here, and every request that reaches it has already been independently
846
+ re-verified against this agent's OWN published policy bundle, fetched over the network by THIS
847
+ process, not trusted from the agent's say-so.
848
+
849
+ ## Run
850
+
851
+ \`\`\`bash
852
+ npm install
853
+ npm start
854
+ \`\`\`
855
+
856
+ Listens on \`:${port}\` by default (\`PORT\` env var to change it — keep \`../index.mjs\`'s
857
+ \`GATEWAY_URL\` in sync if you do).
858
+
859
+ ## Add real credentials
860
+
861
+ Edit \`server.mjs\`'s \`bookFlight()\` with your real implementation, reading any credentials it
862
+ needs from \`process.env\` (see \`.env.example\`). Load \`.env\` however you prefer (e.g.
863
+ \`node --env-file=.env server.mjs\`, Node ≥ 20.6) — it is already in \`.gitignore\`. The agent
864
+ directory one level up must never hold these credentials; if it needs to call a DIFFERENT tool,
865
+ add another protected route here rather than adding a local function back in \`index.mjs\`.
866
+
867
+ ## Files
868
+
869
+ - \`server.mjs\` — the gateway: one protected route (\`POST /book-flight\`, action \`${scope}\`),
870
+ \`@metamynd/agentsafe-mcp-guard\`'s \`verifyRequest()\` re-checking every request, and the real
871
+ \`bookFlight()\`.
872
+ - \`.env.example\` — where real tool credentials go (copy to \`.env\`, fill in, never commit).
873
+
874
+ ## Beyond this minimal slice
875
+
876
+ This gateway only re-verifies a signed request (§9.3/§9.6 of the MAGP spec). It does not do the
877
+ mutual DID handshake, x402 payment binding, or commitment-bound capability tokens that a
878
+ production Service integration would add — see \`@metamynd/agentsafe-mcp-guard\`'s own README for
879
+ those, and \`demo/duffel-mcp-gateway\` in the AgentSafe repo for a full worked example.
880
+ `;
881
+ }
882
+
451
883
  function writeFileSafe(dir, name, content, force = false) {
452
884
  const p = join(dir, name);
453
885
  const exists = existsSync(p);
@@ -480,16 +912,32 @@ function assertScaffoldTarget(outDir, force) {
480
912
  );
481
913
  }
482
914
 
483
- /** Write the scaffolded project + print next steps. Shared by the provision and sandbox paths. */
484
- function scaffoldProject({ outDir, config, slug, scope, perTxnMax, sandbox, force = false }) {
915
+ /**
916
+ * Write the scaffolded project + print next steps. Shared by the provision and sandbox paths.
917
+ * `withGateway`: scaffold the default two-process shape (agent + ./gateway) — the real
918
+ * enforcement boundary. Off for --sandbox (shared demo identity, never real credentials
919
+ * anyway) and --no-gateway (opt out, e.g. you're already running your own separate gateway).
920
+ */
921
+ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, sandbox, withGateway, gatewayPort = DEFAULT_GATEWAY_PORT, force = false }) {
485
922
  assertScaffoldTarget(outDir, force);
486
923
  console.log(`\n ${c.b('Scaffolding')} ${c.dim(outDir)}`);
487
924
  if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
488
925
  writeFileSafe(outDir, 'agent.metamynd.json', JSON.stringify(config, null, 2) + '\n', force);
489
- writeFileSafe(outDir, 'index.mjs', exampleIndex(scope, perTxnMax), force);
926
+ writeFileSafe(outDir, 'index.mjs', withGateway ? exampleIndex(scope, perTxnMax, gatewayPort) : exampleIndexNoGateway(scope, perTxnMax), force);
490
927
  writeFileSafe(outDir, 'package.json', examplePackageJson(slug), force);
491
928
  writeFileSafe(outDir, '.gitignore', gitignore(), force);
492
- writeFileSafe(outDir, 'README.md', exampleReadme(slug, scope), force);
929
+ writeFileSafe(outDir, 'README.md', exampleReadme(slug, scope, withGateway, gatewayPort), force);
930
+
931
+ if (withGateway) {
932
+ const apiBase = config.apiBase ?? config.api ?? DEFAULT_API;
933
+ const gwDir = join(outDir, 'gateway');
934
+ if (!existsSync(gwDir)) mkdirSync(gwDir, { recursive: true });
935
+ writeFileSafe(gwDir, 'server.mjs', gatewayServerFile(scope, gatewayPort, apiBase), force);
936
+ writeFileSafe(gwDir, 'package.json', gatewayPackageJson(slug), force);
937
+ writeFileSafe(gwDir, '.env.example', gatewayEnvExample(), force);
938
+ writeFileSafe(gwDir, '.gitignore', gatewayGitignore(), force);
939
+ writeFileSafe(gwDir, 'README.md', gatewayReadme(slug, scope, gatewayPort), force);
940
+ }
493
941
 
494
942
  const rel = outDir.replace(resolve('.'), '.').replace(/\\/g, '/');
495
943
  console.log(`\n${c.green(c.b(' ✓ Done.'))} Your governed agent is ready.\n`);
@@ -498,7 +946,13 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, sandbox, forc
498
946
  } else if (config.agentKey) {
499
947
  console.log(` ${c.yellow('⚠ agent.metamynd.json holds the agent secret key')} — it is gitignored; never commit it.\n`);
500
948
  }
949
+ if (withGateway) {
950
+ console.log(` ${c.yellow('⚠ two processes now')} — \`gateway/\` is the real enforcement boundary, not \`index.mjs\`. Read \`gateway/README.md\`.\n`);
951
+ }
501
952
  console.log(` Next:`);
953
+ if (withGateway) {
954
+ console.log(c.cyan(` cd ${rel}/gateway && npm install && npm start`) + c.dim(' (separate terminal — start this first)'));
955
+ }
502
956
  console.log(c.cyan(` cd ${rel}`));
503
957
  console.log(c.cyan(` npm install`));
504
958
  // The example runs FOUR attempts. This summary promised three, so the one carrying the
@@ -524,7 +978,7 @@ async function runSandbox(args) {
524
978
  console.log(` ${c.green('✓')} sandbox agent ${c.b(config.agentDid)} ${c.dim('(shared test identity)')}`);
525
979
  const scope = config.mandate?.scope || 'flight-purchase';
526
980
  const perTxnMax = Number(config.perTxnMax) || 500;
527
- scaffoldProject({ outDir, config, slug: 'metamynd-sandbox', scope, perTxnMax, sandbox: true, force: !!args.force });
981
+ scaffoldProject({ outDir, config, slug: 'metamynd-sandbox', scope, perTxnMax, sandbox: true, withGateway: false, force: !!args.force });
528
982
  }
529
983
 
530
984
  // ---------- --harness: a free, local, zero-network governance harness ----------
@@ -1157,6 +1611,14 @@ No anchored/verifiable identity, no cross-party trust, no evidence anyone but yo
1157
1611
  no dashboard reachable when this machine is off, no owner queue someone else can approve from.
1158
1612
  That's the hosted platform (\`npx create-metamynd-agent\`, without \`--harness\`) — same
1159
1613
  \`guardTool()\` call, same rules shape, so upgrading later is a config change, not a rewrite.
1614
+
1615
+ It is also **not a separate enforcement boundary**. \`guardToolLocal()\` (in \`index.mjs\`) is a
1616
+ cooperative library this process embeds — call the tool handler directly instead of the guarded
1617
+ one and nothing stops you, because there is no second party in the loop to disagree with you.
1618
+ That's structural, not a bug: use this harness to govern your own agent's own honest behavior,
1619
+ not as a defense against an agent (or a person) actively trying to get around it. The hosted
1620
+ platform's \`guardTool()\` doesn't have this gap, because the MCP/tool service re-verifies the
1621
+ agent's signed authority for itself instead of trusting that the agent's own guard ran.
1160
1622
  `;
1161
1623
  }
1162
1624
 
@@ -1328,7 +1790,7 @@ async function runClaim(args) {
1328
1790
 
1329
1791
  const slug = slugify(state.name || 'metamynd-agent');
1330
1792
  const outDir = resolve(String(args.out || `./${slug}`));
1331
- scaffoldProject({ outDir, config, slug, scope: state.scope || config.mandate?.scope || 'flight-purchase', perTxnMax: Number(state.perTxnMax) || 500, sandbox: false, force: !!args.force });
1793
+ scaffoldProject({ outDir, config, slug, scope: state.scope || config.mandate?.scope || 'flight-purchase', perTxnMax: Number(state.perTxnMax) || 500, sandbox: false, withGateway: !args['no-gateway'], gatewayPort: Number(args['gateway-port']) || DEFAULT_GATEWAY_PORT, force: !!args.force });
1332
1794
  }
1333
1795
 
1334
1796
  // ---------- main ----------
@@ -1451,7 +1913,7 @@ async function main() {
1451
1913
  }
1452
1914
 
1453
1915
  // 4. Scaffold + next steps
1454
- scaffoldProject({ outDir, config, slug, scope, perTxnMax, sandbox: false, force: !!args.force });
1916
+ scaffoldProject({ outDir, config, slug, scope, perTxnMax, sandbox: false, withGateway: !args['no-gateway'], gatewayPort: Number(args['gateway-port']) || DEFAULT_GATEWAY_PORT, force: !!args.force });
1455
1917
  }
1456
1918
 
1457
1919
  main().catch((e) => fail(e?.stack || e?.message || String(e)));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.6.0",
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, and drops a runnable example. --harness scaffolds a free, local, zero-network governance harness instead.",
3
+ "version": "0.7.0",
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 (the real enforcement boundary). --harness scaffolds a free, local, zero-network governance harness instead.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-metamynd-agent": "index.mjs"