create-metamynd-agent 0.7.8 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.mjs +307 -41
  2. package/package.json +4 -1
package/index.mjs CHANGED
@@ -26,13 +26,21 @@ const GUARD_PKG = '@metamynd/agentsafe-guard';
26
26
  // 0.6.0 adds the amount-unknown atom (deny-by-default when a value-moving action's amount
27
27
  // can't be determined) — this was already missed once (this constant sat at ^0.5.0 through the
28
28
  // whole 0.6.0 release), silently scaffolding every new project without that protection.
29
- const GUARD_VERSION = '^0.6.0';
29
+ // 0.7.0 adds the opt-in `signContext` envelope signature (Tier 1 context-claim binding) —
30
+ // no scaffolded behavior changes (off by default), but the floor must still cover the real
31
+ // current version regardless, per this repo's standing internal-pin invariant.
32
+ // 0.8.0 adds an optional `currency` scope to the amount-over/cumulative-over atoms
33
+ // (harnessDefaultSop, below, now sets it) — a guard below this version can't evaluate that
34
+ // field, so a scaffolded currency-scoped cap would silently never fire on a currency mismatch.
35
+ const GUARD_VERSION = '^0.8.0';
30
36
  // The default hosted scaffold's SECOND process — the tool gateway (see scaffoldProject).
31
37
  const MCP_GUARD_PKG = '@metamynd/agentsafe-mcp-guard';
32
38
  // 0.2.0 adds requireAuthorization (closes replay + cumulative spend) — this scaffold sets that
33
39
  // option, so a range that could resolve below 0.2.0 would silently scaffold a no-op.
34
40
  // 0.3.0 adds the same amount-unknown atom as the guard, above — same reasoning, same miss.
35
- const MCP_GUARD_VERSION = '^0.3.0';
41
+ // 0.4.0 adds the same amount-over/cumulative-over `currency` scope as the guard, above —
42
+ // same reasoning, same miss.
43
+ const MCP_GUARD_VERSION = '^0.4.0';
36
44
  const GATEWAY_PKG = '@metamynd/agentsafe-http-gateway';
37
45
  // 0.2.0 fixes a confused-deputy gap (payload not bound to the signed request) — the CLI must
38
46
  // never scaffold a range that could resolve below it.
@@ -87,7 +95,9 @@ ${c.b('Options')}
87
95
  --harness No login, no KYB, no network at all: a free local governance harness —
88
96
  your own rules, your own identity, decided entirely on this machine. See
89
97
  README#harness. Not for enterprise use (no anchored identity/evidence,
90
- no cross-party trust) — that is what the hosted platform adds.
98
+ no cross-party trust) — that is what the hosted platform adds. Add
99
+ --gateway for a second local process that closes the cooperative-only
100
+ gap too, still free and offline (see --gateway below).
91
101
  --sandbox No login, no KYB: scaffold against the shared sandbox agent (fastest start)
92
102
  --config <file> A JSON policy file (name/scope/limits + simple "rules") — see README#config-file.
93
103
  Flags below still override individual fields from the file. Works with
@@ -112,7 +122,12 @@ ${c.b('Options')}
112
122
  --no-gateway Hosted flow only: skip the separate tool-gateway process (see
113
123
  README#separate-tool-gateway-default) and scaffold the old
114
124
  single-process example instead. Not a separate enforcement boundary.
115
- --gateway-port <n> Hosted flow only: the gateway process's port (default 4401)
125
+ --gateway --harness only: ALSO scaffold a second local process (still zero
126
+ network, zero account) that independently re-verifies every request
127
+ against the same rules file, using the real @metamynd/agentsafe-mcp-guard.
128
+ Off by default. Does not close nonce replay/cumulative spend — see the
129
+ generated README#--gateway for exactly what it does and does not.
130
+ --gateway-port <n> The gateway process's port, hosted flow or --harness --gateway (default 4401)
116
131
  --port <n> --harness only: the local dashboard's port (default 4400)
117
132
  --yes, -y Non-interactive: use flags/env/defaults, never prompt
118
133
  -h, --help Show this help
@@ -254,6 +269,40 @@ function generateAgentKeypair() {
254
269
  };
255
270
  }
256
271
 
272
+ // did:key (base58btc multibase over an Ed25519-multicodec-prefixed raw public key) — mirrors
273
+ // backend/src/features/agent-identity/did.util.ts / magp-did.mjs's buildDidKey exactly, so a
274
+ // did:key this CLI mints is resolvable by any real MAGP verifier (agentsafe-guard,
275
+ // agentsafe-mcp-guard) with zero network calls: the public key is embedded in the DID string
276
+ // itself. Reimplemented inline (not imported) — this CLI stays zero-dependency, and it is
277
+ // ~15 lines of pure math, not something worth a package for.
278
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
279
+ function base58(bytes) {
280
+ let zeros = 0;
281
+ while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
282
+ const digits = [];
283
+ for (let i = zeros; i < bytes.length; i++) {
284
+ let carry = bytes[i];
285
+ for (let j = 0; j < digits.length; j++) { carry += digits[j] << 8; digits[j] = carry % 58; carry = (carry / 58) | 0; }
286
+ while (carry > 0) { digits.push(carry % 58); carry = (carry / 58) | 0; }
287
+ }
288
+ let out = '';
289
+ for (let k = 0; k < zeros; k++) out += BASE58_ALPHABET[0];
290
+ for (let q = digits.length - 1; q >= 0; q--) out += BASE58_ALPHABET[digits[q]];
291
+ return out;
292
+ }
293
+ const ED25519_MULTICODEC = Uint8Array.of(0xed, 0x01);
294
+ const ED25519_SPKI_PREFIX_LEN = 12; // 302a300506032b6570032100 — fixed for every Ed25519 SPKI DER key
295
+ /** The RAW 32-byte Ed25519 public key from this CLI's own DER/SPKI hex export. */
296
+ function rawPublicKeyFromSpkiHex(publicKeyHex) {
297
+ return Buffer.from(publicKeyHex, 'hex').subarray(ED25519_SPKI_PREFIX_LEN);
298
+ }
299
+ function buildDidKey(publicKeyBytes) {
300
+ const prefixed = new Uint8Array(ED25519_MULTICODEC.length + publicKeyBytes.length);
301
+ prefixed.set(ED25519_MULTICODEC, 0);
302
+ prefixed.set(publicKeyBytes, ED25519_MULTICODEC.length);
303
+ return `did:key:z${base58(prefixed)}`;
304
+ }
305
+
257
306
  // Sign a BYOK challenge exactly as the gate verifies it: Ed25519 over the UTF-8 bytes of the raw
258
307
  // challenge nonce, hex-encoded. Mirrors agentsafe-guard's sign().
259
308
  function signChallengeHex(privateKeyHex, challenge) {
@@ -1084,12 +1133,21 @@ async function runSandbox(args) {
1084
1133
  * entirely client-side with no schema boundary in front of it, so nothing stops a caller from
1085
1134
  * passing amount: "5000" (a string) or omitting amount entirely — `amount-over` silently does
1086
1135
  * not fire on either (`typeof c.amount === 'number'` is false), so the cap passes untested,
1087
- * not safe. Ordering amount-unknown first blocks that instead of letting it through. */
1088
- function harnessDefaultSop(perTxnMax) {
1136
+ * not safe. Ordering amount-unknown first blocks that instead of letting it through.
1137
+ *
1138
+ * The per-transaction cap is scoped to `currency` (atom-catalog.ts's `amount-over` currency
1139
+ * config), mirroring the hosted default exactly — see mandate-eval.ts for why an
1140
+ * unscoped numeric cap can be cleared just by naming a different currency. The atom's
1141
+ * `currency` config is declared `type: 'string[]'` (always an array, e.g. `['USD']`, never
1142
+ * a bare string — see atom-catalog.ts's own field doc); `harnessMandate` below correctly
1143
+ * passes the bare string straight through to the ODRL `unit` field, which is a DIFFERENT,
1144
+ * genuinely string-or-array field — the two must not be confused (see the backend's own
1145
+ * currencyScopeFor/currencyUnitFor split in currency-unit.ts for the same distinction). */
1146
+ function harnessDefaultSop(perTxnMax, currency) {
1089
1147
  return {
1090
1148
  molecules: [
1091
1149
  { id: 'amount-known', name: 'Amount must be determinable', combinator: 'any', atoms: [{ id: 'a0', predicate: 'amount-unknown' }], decision: 'block', reasonCode: 'AMOUNT_NOT_DETERMINABLE' },
1092
- { id: 'cap', name: 'Per-transaction cap', combinator: 'any', atoms: [{ id: 'a1', predicate: 'amount-over', config: { limit: perTxnMax } }], decision: 'block', reasonCode: 'SOP_SPEND_CAP' },
1150
+ { id: 'cap', name: 'Per-transaction cap', combinator: 'any', atoms: [{ id: 'a1', predicate: 'amount-over', config: { limit: perTxnMax, currency: [currency] } }], decision: 'block', reasonCode: 'SOP_SPEND_CAP' },
1093
1151
  { id: 'review', name: 'High-risk review', combinator: 'any', atoms: [{ id: 'a2', predicate: 'risk-at-or-above', config: { level: 'high' } }], decision: 'escalate', reasonCode: 'RISK_REVIEW' },
1094
1152
  ],
1095
1153
  };
@@ -1118,11 +1176,14 @@ function harnessMandate({ scope, currency, maxAmount, perTxnMax, merchants }) {
1118
1176
  };
1119
1177
  }
1120
1178
 
1121
- /** A clearly-local, clearly-not-anchored identifier `guard.agentDid` is just a signing
1122
- * subject in the local path (never resolved against Hedera), but the format should not
1123
- * read as a verified did:hedera when it is not one. */
1179
+ /** A genuine did:key — self-certifying (the verification key is embedded in the DID itself,
1180
+ * §4.1.2), clearly NOT a did:hedera (never resolved against Hedera, never anchored) but still
1181
+ * a REAL, resolvable DID: any MAGP verifier can check a signature against it completely
1182
+ * offline. This matters once --gateway is on (below): the harness's second local process
1183
+ * verifies the agent's requests via key-in-DID, exactly like a real did:hedera counterparty
1184
+ * would, just with no chain underneath it. */
1124
1185
  function harnessAgentDid(publicKeyHex) {
1125
- return `did:key:local-${crypto.createHash('sha256').update(publicKeyHex, 'hex').digest('hex').slice(0, 32)}`;
1186
+ return buildDidKey(rawPublicKeyFromSpkiHex(publicKeyHex));
1126
1187
  }
1127
1188
 
1128
1189
  function harnessRulesFile(mandate, sopDocument) {
@@ -1138,6 +1199,128 @@ function harnessRulesFile(mandate, sopDocument) {
1138
1199
  ) + '\n';
1139
1200
  }
1140
1201
 
1202
+ // ---------- --harness --gateway: a second local process, still zero network -----------------
1203
+ //
1204
+ // Everything above is ONE process: guardToolLocal() decides, and the SAME process holds the
1205
+ // tool. "Without MetaMynd, you can be bypassed" (the harness README says so directly) — call
1206
+ // bookFlight() instead of gatedBookFlight() and nothing stops you, because there is no
1207
+ // counterparty in the loop to disagree with you.
1208
+ //
1209
+ // --gateway adds one: a SEPARATE local process, using the real @metamynd/agentsafe-mcp-guard
1210
+ // (the same package a production Service uses), that independently re-verifies every signed
1211
+ // request against the SAME metamynd-rules.json — not by trusting the agent process, by
1212
+ // checking the Ed25519 signature itself via the agent's did:key (key-in-DID, §4.1.2, fully
1213
+ // offline). Still no account, still no network call, still free.
1214
+ //
1215
+ // What this DOES close: the agent process lying to itself. A compromised or dishonest agent
1216
+ // that skips its own guardToolLocal() call, or calls bookFlight() directly, gets nothing —
1217
+ // the tool only runs in the gateway process now.
1218
+ //
1219
+ // What this does NOT close (be precise, this is a local demo, not the hosted platform):
1220
+ // nonce replay and cumulative-spend across many calls. Those need a STATEFUL authority — the
1221
+ // hosted gate's `requireAuthorization` claims a real, single-use authorizationId against a
1222
+ // database. A local harness has no such database (that is the whole point of --harness), so
1223
+ // this gateway does per-request re-evaluation only, same as the hosted gateway's baseline
1224
+ // before `requireAuthorization` is added. The README says so.
1225
+
1226
+ function harnessGatewayServerFile(scope, gatewayPort, agentDid) {
1227
+ return `#!/usr/bin/env node
1228
+ // harness-gateway.mjs — a SEPARATE process from your agent. It holds the tool (bookFlight
1229
+ // below never runs anywhere else) and independently re-verifies every request against
1230
+ // ../metamynd-rules.json using the REAL @metamynd/agentsafe-mcp-guard — the same package a
1231
+ // production Service uses, just pointed at a local file instead of a hosted issuer. See
1232
+ // ../README.md#--gateway for exactly what this does and does not close.
1233
+ import { readFileSync } from 'node:fs';
1234
+ import http from 'node:http';
1235
+ import { createMcpGuard } from '${MCP_GUARD_PKG}';
1236
+
1237
+ const PORT = Number(process.env.PORT || ${gatewayPort});
1238
+ // The agent's did:key, fixed at scaffold time — a request claiming to be any OTHER agentDid
1239
+ // fails BUNDLE_SUBJECT_MISMATCH, not just an unmatched-signature error, because the bundle
1240
+ // this gateway serves is only ever this one agent's.
1241
+ const AGENT_DID = '${agentDid}';
1242
+
1243
+ // Reshapes the harness's own rules-file shape ({mandate, sops:[{standardKey,document}],
1244
+ // standards:[{standardKey,document}]}) into what agentsafe-mcp-guard's verifyRequest expects
1245
+ // ({mandates:[{action,document}], sops:[{id,document}], standards:[{key,document}]}) — a pure
1246
+ // format adapter, not a second source of truth: both this and index.mjs's dashboard read the
1247
+ // SAME ../metamynd-rules.json.
1248
+ function bundleFromRules(rules) {
1249
+ return {
1250
+ mandates: [{ action: '${scope}', document: rules.mandate }],
1251
+ sops: (rules.sops ?? []).map((s) => ({ id: s.standardKey, document: s.document })),
1252
+ standards: (rules.standards ?? []).map((s) => ({ key: s.standardKey, document: s.document })),
1253
+ };
1254
+ }
1255
+
1256
+ // No serviceKey: this gateway only calls verifyRequest() (re-check a signed request), not the
1257
+ // mutual-handshake methods, which are the only thing that needs one. No issuerApi either — the
1258
+ // whole point of --harness is no network; fetchBundle reads the SAME rules file the dashboard
1259
+ // and your agent process both read, so editing it takes effect on the next request everywhere.
1260
+ const guard = createMcpGuard({
1261
+ serviceDid: 'did:local:${scope}-gateway',
1262
+ fetchBundle: async (agentDid) => {
1263
+ if (agentDid !== AGENT_DID) return { subject: AGENT_DID, mandates: [], sops: [], standards: [] };
1264
+ const rules = JSON.parse(readFileSync('../metamynd-rules.json', 'utf8'));
1265
+ return { subject: AGENT_DID, ...bundleFromRules(rules) };
1266
+ },
1267
+ });
1268
+
1269
+ // One protected route per gated action in index.mjs. A path with no route below is refused —
1270
+ // there is nothing to fall through TO; this gateway IS the tool, not a proxy in front of one.
1271
+ const ROUTES = {
1272
+ '/book-flight': { action: '${scope}', run: async (args) => ({ pnr: 'PNR-DEMO', ...args }) },
1273
+ '/raise-limit': { action: 'permissions.update', run: async (args) => ({ updated: true, ...args }) },
1274
+ };
1275
+
1276
+ function readBody(req) {
1277
+ return new Promise((resolve, reject) => {
1278
+ const chunks = [];
1279
+ req.on('data', (c) => chunks.push(c));
1280
+ req.on('end', () => resolve(Buffer.concat(chunks)));
1281
+ req.on('error', reject);
1282
+ });
1283
+ }
1284
+
1285
+ const server = http.createServer(async (req, res) => {
1286
+ const route = ROUTES[req.url];
1287
+ const send = (status, body, decision) => {
1288
+ const headers = { 'content-type': 'application/json' };
1289
+ if (decision) headers['x-agentsafe-decision'] = decision;
1290
+ res.writeHead(status, headers);
1291
+ res.end(JSON.stringify(body));
1292
+ };
1293
+ if (req.method !== 'POST' || !route) return send(404, { decision: 'block', reasonCode: 'NO_SUCH_ROUTE' });
1294
+ try {
1295
+ const raw = await readBody(req);
1296
+ const { signed, args } = JSON.parse(raw.toString('utf8') || '{}');
1297
+ const verdict = await guard.verifyRequest({ ...signed, action: route.action });
1298
+ if (verdict.decision !== 'allow' && verdict.decision !== 'observe') {
1299
+ console.log('[harness-gateway] ' + verdict.decision.toUpperCase() + ' ' + req.url + ' — ' + verdict.reasonCode + ' (re-evaluated independently, did not trust the agent)');
1300
+ return send(403, verdict, verdict.decision);
1301
+ }
1302
+ console.log('[harness-gateway] ALLOW ' + req.url + ' — running the real tool here, not in the agent process');
1303
+ return send(200, await route.run(args ?? {}), verdict.decision);
1304
+ } catch (err) {
1305
+ return send(502, { decision: 'block', reasonCode: 'GATEWAY_ERROR', error: String(err?.message ?? err) });
1306
+ }
1307
+ });
1308
+
1309
+ server.listen(PORT, () => {
1310
+ console.log('[harness-gateway] listening on :' + PORT + ' — the only place your tools run.');
1311
+ console.log('[harness-gateway] re-verifying against ../metamynd-rules.json, independently of index.mjs.');
1312
+ });
1313
+ `;
1314
+ }
1315
+
1316
+ function harnessGatewayPackageJson(slug) {
1317
+ return JSON.stringify(
1318
+ { 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 } },
1319
+ null,
1320
+ 2,
1321
+ ) + '\n';
1322
+ }
1323
+
1141
1324
  function harnessServerFile() {
1142
1325
  return `// harness-server.mjs — the free local governance dashboard. Zero dependencies.
1143
1326
  // Runs in-process with your agent: shows the rules in force, lets you add/edit/remove SOP
@@ -1508,13 +1691,15 @@ setInterval(refresh, 3000);
1508
1691
  `;
1509
1692
  }
1510
1693
 
1511
- function harnessIndexFile(scope, perTxnMax, port) {
1694
+ function harnessIndexFile(scope, perTxnMax, port, withGateway, gatewayPort) {
1512
1695
  const under = Math.max(1, Math.round(perTxnMax * 0.5));
1513
1696
  const over = Math.round(perTxnMax + 100);
1514
1697
  return `// index.mjs — your agent, governed entirely on this machine. No account, no network call
1515
1698
  // for a decision: guardToolLocal() decides allow/block/escalate against ./metamynd-rules.json
1516
1699
  // (edit it directly, or at the dashboard). An escalate is held here for YOU to approve —
1517
- // there is no hosted owner queue in this mode, so open the dashboard URL printed below.
1700
+ // there is no hosted owner queue in this mode, so open the dashboard URL printed below.${withGateway ? `
1701
+ // Your tools run in ./harness-gateway.mjs, a SEPARATE process — it independently re-verifies
1702
+ // every signed request for itself. See README.md#--gateway for what that closes.` : ''}
1518
1703
  import { readFileSync } from 'node:fs';
1519
1704
  import { createGuard } from '${GUARD_PKG}';
1520
1705
  import { startDashboard } from './harness-server.mjs';
@@ -1531,22 +1716,41 @@ const dashboard = startDashboard({
1531
1716
  rulesPath: './metamynd-rules.json',
1532
1717
  logPath: './metamynd-harness.log.jsonl',
1533
1718
  });
1534
- console.log('\\x1b[2m dashboard: ' + dashboard.url + ' (rules, approvals, decision log)\\x1b[0m\\n');
1719
+ console.log('\\x1b[2m dashboard: ' + dashboard.url + ' (rules, approvals, decision log)\\x1b[0m');
1720
+ ${withGateway ? `console.log('\\x1b[2m gateway : http://localhost:${gatewayPort} (a SEPARATE process — run \\'npm start\\' in ./harness-gateway first)\\x1b[0m\\n');` : `console.log('');`}
1535
1721
 
1536
1722
  // Reads the CURRENT rules file fresh every call — editing it (by hand, or at the dashboard)
1537
1723
  // takes effect on the next decision, no restart, matching the "no redeploy" experience the
1538
- // hosted platform gives you.
1724
+ // hosted platform gives you. The gateway process (below, when scaffolded) reads the SAME file.
1539
1725
  const getBundle = () => JSON.parse(readFileSync('./metamynd-rules.json', 'utf8'));
1540
-
1726
+ ${withGateway ? `
1727
+ const GATEWAY = process.env.HARNESS_GATEWAY_URL || 'http://localhost:${gatewayPort}';
1728
+ // Calls the gateway process instead of a local function — there is no raw bookFlight() or
1729
+ // raiseOwnLimit() in THIS file to call directly. buildSignedRequest() is pure (no network, no
1730
+ // issuer): it builds and signs the same canonical message a real gate would verify, entirely
1731
+ // offline, using this agent's own did:key — the gateway verifies that signature for itself.
1732
+ async function callGateway(path, action, args) {
1733
+ const signed = guard.buildSignedRequest({ action, amount: args.amount, currency: 'USD', merchant: args.merchant, context: { tool: '${scope}', riskLevel: args.riskLevel ?? 'low' } });
1734
+ const res = await fetch(GATEWAY + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ signed, args }) });
1735
+ const body = await res.json().catch(() => null);
1736
+ if (!res.ok) {
1737
+ const err = new Error('gateway ' + res.status + ': ' + (body?.reasonCode ?? 'refused'));
1738
+ err.name = 'GovernanceBlocked';
1739
+ err.governance = { decision: body?.decision ?? 'block', reasonCode: body?.reasonCode ?? 'GATEWAY_ERROR' };
1740
+ throw err;
1741
+ }
1742
+ return body;
1743
+ }
1744
+ ` : `
1541
1745
  // --- Your real tool. Replace the body with your actual implementation. ---
1542
1746
  async function bookFlight(args) {
1543
1747
  return { pnr: 'PNR-DEMO', ...args };
1544
1748
  }
1545
-
1749
+ `}
1546
1750
  // --- The GATED version. Register THIS with your agent instead of the raw handler. ---
1547
1751
  const gatedBookFlight = guard.guardToolLocal(
1548
1752
  '${scope}', // = your mandate scope
1549
- bookFlight,
1753
+ ${withGateway ? `(args) => callGateway('/book-flight', '${scope}', args)` : 'bookFlight'},
1550
1754
  (a) => ({ // map tool args → gate inputs
1551
1755
  amount: a.amount,
1552
1756
  merchant: a.merchant,
@@ -1556,14 +1760,14 @@ const gatedBookFlight = guard.guardToolLocal(
1556
1760
  );
1557
1761
 
1558
1762
  // --- A tool the agent was NEVER granted. Wrapping it is the demonstration: there is no
1559
- // --- rule anywhere forbidding this. The mandate simply never mentioned the action.
1763
+ // --- rule anywhere forbidding this. The mandate simply never mentioned the action.${withGateway ? '' : `
1560
1764
  async function raiseOwnLimit(args) {
1561
1765
  return { updated: true, ...args }; // never runs, and that is the point
1562
- }
1766
+ }`}
1563
1767
 
1564
1768
  const gatedRaiseOwnLimit = guard.guardToolLocal(
1565
1769
  'permissions.update', // an action NOT in the mandate
1566
- raiseOwnLimit,
1770
+ ${withGateway ? `(args) => callGateway('/raise-limit', 'permissions.update', args)` : 'raiseOwnLimit'},
1567
1771
  (a) => ({ amount: a.amount, merchant: a.merchant, context: { tool: 'permissions-update' } }),
1568
1772
  getBundle,
1569
1773
  );
@@ -1639,7 +1843,23 @@ console.log(dim(' - step 4 needed no rule to stop it. The agent could not wide
1639
1843
  console.log(dim(' authority, because it cannot name an action nobody delegated to it.'));
1640
1844
  console.log(dim(' - the blocked call never reached your tool at all.'));
1641
1845
  console.log(dim(' - every decision is in ./metamynd-harness.log.jsonl - yours, locally.'));
1846
+ console.log('');${withGateway ? `
1847
+ console.log(bold(' Checked twice, by two processes.') + ' bookFlight() lives in ./harness-gateway.mjs -');
1848
+ console.log(dim(' not here. It independently re-verified every attempt above against the SAME'));
1849
+ console.log(dim(' ./metamynd-rules.json, over a signed request, before running your tool.'));
1850
+ console.log('');
1851
+ console.log(dim(' What --gateway does NOT close: nonce replay and cumulative spend across many'));
1852
+ console.log(dim(' calls. Those need a STATEFUL authority (the hosted gate\\'s requireAuthorization'));
1853
+ console.log(dim(' claims a real, single-use id against a database) - a local harness has none.'));
1854
+ console.log(dim(' See README.md#--gateway for exactly what this does and does not prove.'));
1642
1855
  console.log('');
1856
+ console.log(' Edit ./metamynd-rules.json (or the dashboard) and run again - the outcome');
1857
+ console.log(dim(' changes, in BOTH processes, from the one file. That is the point.'));
1858
+ console.log('');
1859
+ console.log(dim(' Ready for more than one machine, a queue someone else can approve from,'));
1860
+ console.log(dim(' anchored evidence, KYC/KYB-backed identity, or nonce/cumulative-spend closure?'));
1861
+ console.log(dim(' That is the hosted platform - drop --harness and provision there; the same'));
1862
+ console.log(dim(' guardTool() call keeps working, sealed by a real gate instead of this file.'));` : `
1643
1863
  console.log(bold(' Without MetaMynd, you can be bypassed.') + ' bookFlight() above runs in THIS');
1644
1864
  console.log(dim(' process - call it directly instead of gatedBookFlight and nothing stops you.'));
1645
1865
  console.log(dim(' --harness proves your policy logic; it does not enforce it against that.'));
@@ -1647,10 +1867,11 @@ console.log('');
1647
1867
  console.log(' Edit ./metamynd-rules.json (or the dashboard) and run again - the outcome');
1648
1868
  console.log(dim(' changes. This file does not. That is the point.'));
1649
1869
  console.log('');
1650
- console.log(dim(' Ready for more than one machine, a queue someone else can approve from,'));
1651
- console.log(dim(' anchored evidence, or KYC/KYB-backed identity, AND a separate gateway process'));
1652
- console.log(dim(' that closes the bypass above? That is the hosted platform - drop --harness'));
1653
- console.log(dim(' and provision there; the same guardTool() call keeps working.'));
1870
+ console.log(dim(' Ready for a SEPARATE process that closes the bypass above, still free and'));
1871
+ console.log(dim(' local? Re-scaffold with --gateway. Ready for more than one machine, a queue'));
1872
+ console.log(dim(' someone else can approve from, anchored evidence, or KYC/KYB-backed identity?'));
1873
+ console.log(dim(' That is the hosted platform - drop --harness and provision there; the same'));
1874
+ console.log(dim(' guardTool() call keeps working.'));`}
1654
1875
  console.log('');
1655
1876
  dashboard.close();
1656
1877
  `;
@@ -1671,7 +1892,7 @@ function harnessPackageJson(slug) {
1671
1892
  ) + '\n';
1672
1893
  }
1673
1894
 
1674
- function harnessReadme(slug, scope, port) {
1895
+ function harnessReadme(slug, scope, port, withGateway, gatewayPort) {
1675
1896
  return `# ${slug}
1676
1897
 
1677
1898
  A free, local MetaMynd/AgentSafe governance harness — your own rules, your own identity,
@@ -1680,8 +1901,8 @@ decided entirely on this machine. No account, no network call for a decision.
1680
1901
  ## Run
1681
1902
 
1682
1903
  \`\`\`bash
1683
- npm install
1684
- npm start
1904
+ npm install${withGateway ? ' && (cd harness-gateway && npm install)' : ''}
1905
+ ${withGateway ? `(cd harness-gateway && npm start &) # the second process, in the background\n` : ''}npm start
1685
1906
  \`\`\`
1686
1907
 
1687
1908
  You should see an ALLOW, a BLOCK (over the per-transaction cap), an ESCALATE (high risk —
@@ -1689,18 +1910,45 @@ open the dashboard to approve it), and a BLOCK (an action outside the mandate en
1689
1910
 
1690
1911
  ## Files
1691
1912
 
1692
- - \`agent.metamynd.json\` — your local identity (a generated Ed25519 keypair; \`agentDid\` is a
1693
- local label, not an anchored/verifiable one). **Contains a secret key never commit it.**
1913
+ - \`agent.metamynd.json\` — your local identity: a generated Ed25519 keypair, and a REAL
1914
+ \`did:key\` (self-certifying the verification key is embedded in the DID itself, so a
1915
+ signature against it is checkable completely offline). Not anchored to Hedera; that's the
1916
+ hosted platform. **Contains a secret key — never commit it.**
1694
1917
  - \`metamynd-rules.json\` — your rules: the mandate (scope + spend limits) and SOP (extra checks).
1695
- Edit it directly, or at the dashboard. Reloaded on every decision — no restart.
1918
+ Edit it directly, or at the dashboard. Reloaded on every decision — no restart${withGateway ? ', in BOTH processes' : ''}.
1696
1919
  - \`metamynd-harness.log.jsonl\` — every decision this agent made, append-only.
1697
1920
  - \`harness-server.mjs\` — the local dashboard (port ${port}): rules, pending approvals, decision log.
1698
1921
  - \`index.mjs\` — wraps a tool with \`guard.guardToolLocal(...)\`; the tool only runs when the
1699
- LOCAL rules permit it.
1922
+ LOCAL rules permit it${withGateway ? ', AND the SEPARATE gateway process (below) independently agrees' : ''}.${withGateway ? `
1923
+ - \`harness-gateway/harness-gateway.mjs\` — a SECOND process. Your tools live HERE now, not in
1924
+ \`index.mjs\`. It re-verifies every signed request for itself against the SAME
1925
+ \`../metamynd-rules.json\`, using the real \`@metamynd/agentsafe-mcp-guard\` — the identical
1926
+ package a production Service uses, just pointed at a local file instead of a hosted issuer.` : ''}
1700
1927
 
1701
1928
  ## What this is not
1702
1929
 
1703
- **Without MetaMynd, you can be bypassed.** Everything below is why, precisely.
1930
+ ${withGateway ? `**\`--gateway\` closes one real gap, not every gap.** Precisely:
1931
+
1932
+ **Closed:** the agent process lying to itself. Call \`gatedBookFlight\`'s underlying handler
1933
+ directly (or skip \`index.mjs\` and hand a forged/altered request straight to
1934
+ \`harness-gateway.mjs\`) — either way, the gateway independently re-verifies the Ed25519
1935
+ signature and re-evaluates the SAME rules file for itself. There is no raw \`bookFlight()\` left
1936
+ in \`index.mjs\` to call for a shortcut, and a signature over an altered amount/merchant fails
1937
+ verification regardless of which process sent it.
1938
+
1939
+ **NOT closed:** nonce replay and cumulative spend across many calls. Those need a STATEFUL
1940
+ authority — the hosted gate's \`requireAuthorization\` atomically claims a real, single-use
1941
+ \`authorizationId\` against a database before a Service executes anything (see
1942
+ \`@metamynd/agentsafe-mcp-guard\`'s own README). A local harness has no database; that is the
1943
+ whole point of \`--harness\`. \`harness-gateway.mjs\` re-checks POLICY per request, which is
1944
+ real and worth having, but replaying the exact same signed request twice is NOT refused here
1945
+ the way it would be against the hosted gate.
1946
+
1947
+ Also not closed by \`--gateway\` alone: cross-party trust (nobody but you can verify this agent's
1948
+ identity or its decisions), evidence anyone but you can audit, a dashboard reachable when this
1949
+ machine is off, an owner queue someone else can approve from. That's the hosted platform
1950
+ (\`npx create-metamynd-agent\`, without \`--harness\`) — same \`guardTool()\` call, same rules
1951
+ shape, so upgrading later is a config change, not a rewrite.` : `**Without MetaMynd, you can be bypassed.** Everything below is why, precisely.
1704
1952
 
1705
1953
  No anchored/verifiable identity, no cross-party trust, no evidence anyone but you can audit,
1706
1954
  no dashboard reachable when this machine is off, no owner queue someone else can approve from.
@@ -1711,9 +1959,11 @@ It is also **not a separate enforcement boundary**. \`guardToolLocal()\` (in \`i
1711
1959
  cooperative library this process embeds — call the tool handler directly instead of the guarded
1712
1960
  one and nothing stops you, because there is no second party in the loop to disagree with you.
1713
1961
  That's structural, not a bug: use this harness to govern your own agent's own honest behavior,
1714
- not as a defense against an agent (or a person) actively trying to get around it. The hosted
1715
- platform's default scaffold doesn't have this gap, because a SEPARATE gateway process re-verifies
1716
- the agent's signed authority for itself instead of trusting that the agent's own guard ran.
1962
+ not as a defense against an agent (or a person) actively trying to get around it. Re-scaffold
1963
+ with \`--gateway\` for a SECOND local process that closes exactly this, still free and offline
1964
+ (see README#--gateway once scaffolded) or drop \`--harness\` entirely for the hosted platform's
1965
+ default scaffold, which has this gap closed AND closes nonce replay/cumulative spend, because a
1966
+ SEPARATE gateway process re-verifies the agent's signed authority against a real, stateful gate.`}
1717
1967
  `;
1718
1968
  }
1719
1969
 
@@ -1739,6 +1989,8 @@ async function runHarness(args) {
1739
1989
  const merchantsRaw = await pick('merchants', 'Allowed merchants (comma-sep, blank = any)', Array.isArray(fileConfig?.merchants) ? fileConfig.merchants.join(',') : '');
1740
1990
  const merchants = String(merchantsRaw).split(',').map((s) => s.trim()).filter(Boolean);
1741
1991
  const port = Number(args.port) || 4400;
1992
+ const withGateway = !!args.gateway;
1993
+ const gatewayPort = Number(args['gateway-port']) || DEFAULT_GATEWAY_PORT;
1742
1994
  const slug = slugify(name);
1743
1995
  const outDir = resolve(String(args.out || (interactive ? await ask(rl, 'Output directory', `./${slug}`) : `./${slug}`)));
1744
1996
  rl?.close();
@@ -1750,7 +2002,7 @@ async function runHarness(args) {
1750
2002
  console.log(` ${c.green('✓')} local agent ${c.b(agentDid)}`);
1751
2003
 
1752
2004
  const sopFields = configFileSopFields(fileConfig);
1753
- const sopDocument = sopFields.sop ? sopFields.sop.documentJson : harnessDefaultSop(perTxnMax);
2005
+ const sopDocument = sopFields.sop ? sopFields.sop.documentJson : harnessDefaultSop(perTxnMax, currency);
1754
2006
  if (sopFields.sop) console.log(` ${c.green('✓')} compiled ${sopDocument.molecules.length} rule(s) from the config file`);
1755
2007
  const mandate = harnessMandate({ scope, currency, maxAmount, perTxnMax, merchants });
1756
2008
 
@@ -1759,18 +2011,32 @@ async function runHarness(args) {
1759
2011
  writeFileSafe(outDir, 'agent.metamynd.json', JSON.stringify({ agentDid, agentKey: privateKeyHex, mode: 'harness' }, null, 2) + '\n', !!args.force);
1760
2012
  writeFileSafe(outDir, 'metamynd-rules.json', harnessRulesFile(mandate, sopDocument), !!args.force);
1761
2013
  writeFileSafe(outDir, 'harness-server.mjs', harnessServerFile(), !!args.force);
1762
- writeFileSafe(outDir, 'index.mjs', harnessIndexFile(scope, perTxnMax, port), !!args.force);
2014
+ writeFileSafe(outDir, 'index.mjs', harnessIndexFile(scope, perTxnMax, port, withGateway, gatewayPort), !!args.force);
1763
2015
  writeFileSafe(outDir, 'package.json', harnessPackageJson(slug), !!args.force);
1764
2016
  writeFileSafe(outDir, '.gitignore', gitignore(), !!args.force);
1765
- writeFileSafe(outDir, 'README.md', harnessReadme(slug, scope, port), !!args.force);
2017
+ writeFileSafe(outDir, 'README.md', harnessReadme(slug, scope, port, withGateway, gatewayPort), !!args.force);
2018
+
2019
+ if (withGateway) {
2020
+ const gwDir = join(outDir, 'harness-gateway');
2021
+ if (!existsSync(gwDir)) mkdirSync(gwDir, { recursive: true });
2022
+ writeFileSafe(gwDir, 'harness-gateway.mjs', harnessGatewayServerFile(scope, gatewayPort, agentDid), !!args.force);
2023
+ writeFileSafe(gwDir, 'package.json', harnessGatewayPackageJson(slug), !!args.force);
2024
+ writeFileSafe(gwDir, '.gitignore', gatewayGitignore(), !!args.force);
2025
+ }
1766
2026
 
1767
2027
  const rel = outDir.replace(resolve('.'), '.').replace(/\\/g, '/');
1768
2028
  console.log(`\n${c.green(c.b(' ✓ Done.'))} Your local governance harness is ready.\n`);
1769
2029
  console.log(` ${c.dim('Free, local, no account. Not the hosted platform — see README#what-this-is-not.')}\n`);
1770
2030
  console.log(` Next:`);
1771
2031
  console.log(c.cyan(` cd ${rel}`));
1772
- console.log(c.cyan(` npm install`));
1773
- console.log(c.cyan(` npm start`) + c.dim(' → ALLOW · BLOCK (over cap) · ESCALATE (approve at the dashboard) · BLOCK (ungranted action)\n'));
2032
+ if (withGateway) {
2033
+ console.log(c.cyan(` npm install && (cd harness-gateway && npm install)`));
2034
+ console.log(c.cyan(` (cd harness-gateway && npm start &)`) + c.dim(' → the second process, in the background'));
2035
+ console.log(c.cyan(` npm start`) + c.dim(' → ALLOW · BLOCK (over cap) · ESCALATE (approve at the dashboard) · BLOCK (ungranted action)\n'));
2036
+ } else {
2037
+ console.log(c.cyan(` npm install`));
2038
+ console.log(c.cyan(` npm start`) + c.dim(' → ALLOW · BLOCK (over cap) · ESCALATE (approve at the dashboard) · BLOCK (ungranted action)\n'));
2039
+ }
1774
2040
  console.log(c.dim(` Edit ./metamynd-rules.json any time (by hand, or at http://127.0.0.1:${port}) — no redeploy.\n`));
1775
2041
  }
1776
2042
 
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.7.8",
3
+ "version": "0.9.1",
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
+ "scripts": {
10
+ "test": "node harness-gateway.smoke.mjs"
11
+ },
9
12
  "files": [
10
13
  "index.mjs",
11
14
  "README.md"