create-metamynd-agent 0.3.6 → 0.5.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 +79 -3
  2. package/index.mjs +627 -9
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -9,6 +9,41 @@ portable `agent.metamynd.json`, and drops a runnable example that gates a tool t
9
9
  > MetaMynd account. If that's you and you're verified, you're ready. Verify once in the dashboard if
10
10
  > not; it's the only gate.
11
11
 
12
+ ## Free local harness (no account, no network, `--harness`)
13
+
14
+ ```bash
15
+ npm create metamynd-agent@latest -- --harness # or: npx create-metamynd-agent --harness
16
+ ```
17
+
18
+ No login, no KYB, **no network call at all** — generates a local identity and a local rules file
19
+ (mandate + starter SOP), and scaffolds a project whose `guardTool()` calls are decided entirely on
20
+ your machine by the same deterministic evaluator ([`policy-core`](../agentsafe-guard/policy-core.mjs))
21
+ the hosted gate runs. An escalated action is held for **you** to approve at a small local dashboard
22
+ (`http://127.0.0.1:4400` by default) — there's no hosted owner queue in this mode, because there's
23
+ no hosted anything. Real gating, your own rules, free, forever.
24
+
25
+ ```
26
+ my-agent/
27
+ ├─ agent.metamynd.json # a locally-generated identity — NOT anchored/verifiable
28
+ ├─ metamynd-rules.json # your rules — edit by hand, or at the dashboard
29
+ ├─ metamynd-harness.log.jsonl # every decision, append-only
30
+ ├─ harness-server.mjs # the local dashboard: rules, pending approvals, decision log
31
+ ├─ index.mjs # runnable example: ALLOW · BLOCK · ESCALATE (approve locally) · BLOCK
32
+ └─ package.json / .gitignore / README.md
33
+ ```
34
+
35
+ ### What this is not
36
+
37
+ No anchored or cross-party-verifiable identity, no dashboard reachable when your machine is off, no
38
+ owner queue someone *else* can approve from, no anchored evidence, no enforced platform Standards.
39
+ That set of things is the hosted platform — and getting there later is a **config change, not a
40
+ rewrite**: the exact same `guardTool()` call your harness project already makes just needs a real
41
+ `bundleUrl`/`api` pointed at a real gate (provision normally, without `--harness`) instead of a rules
42
+ file you authored yourself. Nothing about how you wrote your agent changes.
43
+
44
+ Works with `--config` too — its `rules` become the harness's starter rules file, same as the hosted
45
+ flow. See [Policy config file](#policy-config-file---config) below.
46
+
12
47
  ## Try it instantly — sandbox (no account, no KYB)
13
48
 
14
49
  ```bash
@@ -16,8 +51,10 @@ npm create metamynd-agent@latest -- --sandbox # or: npx create-metamynd-agent
16
51
  ```
17
52
 
18
53
  Skips login and provisioning entirely — fetches a **shared sandbox agent** config from the public
19
- `POST /onboarding/sandbox` endpoint and scaffolds a runnable example. Great for a first look; use
20
- the full flow below when you want your own governed agent with your own limits.
54
+ `POST /onboarding/sandbox` endpoint and scaffolds a runnable example. Unlike `--harness`, this DOES
55
+ call the hosted API (a shared demo identity) it's a first look at the *hosted* platform, not a
56
+ local/offline mode. Great for a first look; use the full flow below when you want your own governed
57
+ agent with your own limits.
21
58
 
22
59
  ## Use
23
60
 
@@ -65,7 +102,10 @@ METAMYND_PASSWORD='…' npx create-metamynd-agent --yes …
65
102
 
66
103
  | Flag | Env | Default |
67
104
  |---|---|---|
68
- | `--sandbox` | — | off (skips login/KYB; shared sandbox agent) |
105
+ | `--harness` | — | off (no login/KYB/network at all; free local governance — see above) |
106
+ | `--sandbox` | — | off (skips login/KYB; shared sandbox agent, still hosted) |
107
+ | `--config <file>` | — | a JSON policy file — see [Policy config file](#policy-config-file---config) |
108
+ | `--port <n>` | — | `4400` — `--harness` only, the local dashboard's port |
69
109
  | `--api <url>` | `METAMYND_API` | `https://metamynd.ai/api/v1` |
70
110
  | `--email <email>` | `METAMYND_EMAIL` | — (required) |
71
111
  | `--password <pw>` | `METAMYND_PASSWORD` | interactive masked prompt |
@@ -82,6 +122,42 @@ METAMYND_PASSWORD='…' npx create-metamynd-agent --yes …
82
122
 
83
123
  Run `npx create-metamynd-agent --help` for the full list.
84
124
 
125
+ ## Policy config file (`--config`)
126
+
127
+ Everything above works from flags and prompts, which is fine for one agent but tedious to check
128
+ into source control or hand to a teammate. `--config <file>` reads a plain **JSON** file instead —
129
+ no YAML, no new dependency, so the CLI stays exactly as dependency-free as the guard it scaffolds:
130
+
131
+ ```json
132
+ {
133
+ "name": "Procurement Agent",
134
+ "scope": "purchase-order",
135
+ "currency": "USD",
136
+ "maxAmount": 20000,
137
+ "perTxnMax": 2000,
138
+ "merchants": ["acme-supplies", "northwind-rail"],
139
+ "rules": [
140
+ { "when": { "predicate": "amount-over", "config": { "limit": 2000 } }, "then": "escalate" },
141
+ { "when": { "predicate": "risk-at-or-above", "config": { "level": "high" } }, "then": "block" }
142
+ ]
143
+ }
144
+ ```
145
+
146
+ ```bash
147
+ npx create-metamynd-agent --config ./procurement.policy.json --email you@example.com --yes
148
+ ```
149
+
150
+ `rules` is sugar for the common one-atom-one-decision case — each entry compiles to a starter-SOP
151
+ molecule (`when.predicate` + `when.config` becomes the atom, `then` becomes the decision). See the
152
+ [protocol spec's atom catalog](https://metamynd.ai/developers/spec) for the full predicate list
153
+ (`amount-over`, `risk-at-or-above`, `jurisdiction-not-allowed`, `merchant`-style checks, and more).
154
+ If you need a real multi-atom/combinator molecule, supply `molecules` directly instead (the same
155
+ shape the dashboard's SOP editor produces) — `rules` is ignored when `molecules` is present.
156
+
157
+ Any CLI flag still overrides the matching field from the file (`--config base.json --name "Other
158
+ Bot"`), and login credentials are never read from the file — use `--email`/`METAMYND_EMAIL` and
159
+ `METAMYND_PASSWORD` as usual, so a policy file is safe to commit.
160
+
85
161
  ## Bring your own key (`--byok`)
86
162
 
87
163
  ```bash
package/index.mjs CHANGED
@@ -63,7 +63,14 @@ ${c.b('Usage')}
63
63
  npx create-metamynd-agent [options]
64
64
 
65
65
  ${c.b('Options')}
66
+ --harness No login, no KYB, no network at all: a free local governance harness —
67
+ your own rules, your own identity, decided entirely on this machine. See
68
+ README#harness. Not for enterprise use (no anchored identity/evidence,
69
+ no cross-party trust) — that is what the hosted platform adds.
66
70
  --sandbox No login, no KYB: scaffold against the shared sandbox agent (fastest start)
71
+ --config <file> A JSON policy file (name/scope/limits + simple "rules") — see README#config-file.
72
+ Flags below still override individual fields from the file. Works with
73
+ --harness too (its rules become the harness's starter rules file).
67
74
  --request Delegated: request an agent for an owner's org (--owner <email>, +--byok)
68
75
  --claim [--watch] Delegated: claim the config once the owner approves (reads metamynd-request.json)
69
76
  --owner <email> Target owner's email (with --request)
@@ -81,6 +88,7 @@ ${c.b('Options')}
81
88
  (MetaMynd never sees the private key). Overridden by --public-key.
82
89
  --public-key <hex> BYOK with a key you already hold (SPKI/raw hex); you prove control yourself
83
90
  --out <dir> Output project directory (default ./<agent-slug>)
91
+ --port <n> --harness only: the local dashboard's port (default 4400)
84
92
  --yes, -y Non-interactive: use flags/env/defaults, never prompt
85
93
  -h, --help Show this help
86
94
  -v, --version Show version
@@ -139,6 +147,70 @@ function fail(msg) {
139
147
  process.exit(1);
140
148
  }
141
149
 
150
+ // ---------- policy config file (--config) ----------
151
+ // The API/SOP/molecule authoring surface is real, but it is not where a developer wants to
152
+ // START — round-five feedback named this precisely: "developers need a simpler policy file
153
+ // first." Everything a simple file needs already exists server-side (ProvisionSchema already
154
+ // takes mandate limits + an optional sop.documentJson.molecules array in one flat JSON body),
155
+ // so this is a thin, ZERO-DEPENDENCY translator — plain JSON, not YAML, so the CLI keeps the
156
+ // "no dependencies at all" property the guard itself is built on — not a new policy engine.
157
+ //
158
+ // Shape:
159
+ // {
160
+ // "name": "Procurement Agent", "scope": "purchase-order", "currency": "USD",
161
+ // "maxAmount": 20000, "perTxnMax": 2000, "merchants": ["acme-supplies"],
162
+ // "rules": [
163
+ // { "when": { "predicate": "amount-over", "config": { "limit": 2000 } }, "then": "escalate" }
164
+ // ]
165
+ // }
166
+ // `rules` is sugar for the common one-atom-one-decision case, compiled to a `molecules` array
167
+ // below. A caller who needs a real combinator/multi-atom molecule can supply `molecules`
168
+ // directly instead — `rules` is ignored when `molecules` is present.
169
+ function loadConfigFile(path) {
170
+ let raw;
171
+ try { raw = readFileSync(resolve(path), 'utf8'); }
172
+ catch (e) { fail(`Could not read config file ${path} (${e.message})`); }
173
+ let json;
174
+ try { json = JSON.parse(raw); }
175
+ catch (e) { fail(`${path} is not valid JSON (${e.message})`); }
176
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) {
177
+ fail(`${path} must be a JSON object.`);
178
+ }
179
+ return json;
180
+ }
181
+
182
+ // A rule needs `when.predicate` (the atom) and `then` (the decision the gate should return
183
+ // when it fires) — everything else is optional sugar. See backend's atom-catalog.ts for the
184
+ // full predicate list (amount-over, risk-at-or-above, jurisdiction-not-allowed, ...).
185
+ function ruleToMolecule(rule, i) {
186
+ const when = rule?.when;
187
+ if (!when || typeof when.predicate !== 'string') {
188
+ fail(`rules[${i}] needs a "when.predicate" — see the config file docs for the atom list.`);
189
+ }
190
+ if (typeof rule.then !== 'string') {
191
+ fail(`rules[${i}] needs a "then" decision (e.g. "block", "escalate").`);
192
+ }
193
+ return {
194
+ id: `r${i + 1}`,
195
+ name: rule.name,
196
+ combinator: 'all',
197
+ atoms: [{ id: 'a1', predicate: when.predicate, config: when.config ?? {} }],
198
+ decision: rule.then,
199
+ reasonCode: rule.reasonCode ?? `${when.predicate.toUpperCase().replace(/-/g, '_')}_${String(rule.then).toUpperCase()}`,
200
+ };
201
+ }
202
+
203
+ /** Builds the `sop`/`rulePack` fields to merge into the provisioning body, or {} if the config file specifies neither. */
204
+ function configFileSopFields(config) {
205
+ if (!config) return {};
206
+ if (Array.isArray(config.molecules)) return { sop: { documentJson: { molecules: config.molecules } } };
207
+ if (Array.isArray(config.rules) && config.rules.length) {
208
+ return { sop: { documentJson: { molecules: config.rules.map(ruleToMolecule) } } };
209
+ }
210
+ if (typeof config.rulePack === 'string') return { rulePack: config.rulePack };
211
+ return {};
212
+ }
213
+
142
214
  function slugify(name) {
143
215
  return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'metamynd-agent';
144
216
  }
@@ -455,6 +527,536 @@ async function runSandbox(args) {
455
527
  scaffoldProject({ outDir, config, slug: 'metamynd-sandbox', scope, perTxnMax, sandbox: true, force: !!args.force });
456
528
  }
457
529
 
530
+ // ---------- --harness: a free, local, zero-network governance harness ----------
531
+ //
532
+ // Not the hosted platform, and not trying to be. `guardToolLocal()` + `evaluateLocally()`
533
+ // (agentsafe-guard.mjs) already decide allow/block/escalate with NO network call, given
534
+ // {standards, sops, mandate} as plain objects — this mode is just the missing packaging:
535
+ // author those objects locally instead of fetching a signed bundle from a backend, add
536
+ // somewhere for a human to approve an escalate, and a page to see any of it.
537
+ //
538
+ // What you get: real gating, on your own machine, your own rules, no account.
539
+ // What you don't: anchored/verifiable identity, cross-party trust, evidence anyone but you
540
+ // can audit, a dashboard reachable when your machine is off. That gap is the paid platform —
541
+ // and it's a config change to cross, not a rewrite: point `bundleUrl` at a real MAGP_API
542
+ // (or re-provision with `create-metamynd-agent`, no --harness) and the SAME guardTool() calls
543
+ // keep working, sealed by a real gate instead of a rules file you authored yourself.
544
+
545
+ /** Mirrors defaultSopDocument() in backend/src/features/onboarding/onboarding.provision.ts —
546
+ * same starter rules the hosted platform issues, so a harness project behaves identically
547
+ * to a freshly-provisioned one before anyone edits either. */
548
+ function harnessDefaultSop(perTxnMax) {
549
+ return {
550
+ molecules: [
551
+ { id: 'cap', name: 'Per-transaction cap', combinator: 'any', atoms: [{ id: 'a1', predicate: 'amount-over', config: { limit: perTxnMax } }], decision: 'block', reasonCode: 'SOP_SPEND_CAP' },
552
+ { id: 'review', name: 'High-risk review', combinator: 'any', atoms: [{ id: 'a2', predicate: 'risk-at-or-above', config: { level: 'high' } }], decision: 'escalate', reasonCode: 'RISK_REVIEW' },
553
+ ],
554
+ };
555
+ }
556
+
557
+ /** Mirrors issueMandate()'s document shape in backend/src/features/policy/mandate/mandate.service.ts
558
+ * (minus the parts only a real principal/issuer can do: no VC, no Hedera anchor, no signature) —
559
+ * same shape evaluateMandate() in policy-core.mjs expects either way. */
560
+ function harnessMandate({ scope, currency, maxAmount, perTxnMax, merchants }) {
561
+ return {
562
+ uid: `urn:metamynd:mandate:local-${crypto.randomUUID()}`,
563
+ profile: 'https://metamynd.ai/odrl/agent-mandate/v1',
564
+ validFrom: new Date().toISOString(),
565
+ validUntil: null,
566
+ permission: [
567
+ {
568
+ target: scope,
569
+ action: 'execute',
570
+ constraint: [
571
+ { leftOperand: 'mm:payAmount', operator: 'lteq', rightOperand: perTxnMax, unit: currency },
572
+ { leftOperand: 'mm:cumulativeSpend', operator: 'lteq', rightOperand: maxAmount, unit: currency },
573
+ ...(merchants?.length ? [{ leftOperand: 'mm:merchant', operator: 'isAnyOf', rightOperand: merchants }] : []),
574
+ ],
575
+ },
576
+ ],
577
+ };
578
+ }
579
+
580
+ /** A clearly-local, clearly-not-anchored identifier — `guard.agentDid` is just a signing
581
+ * subject in the local path (never resolved against Hedera), but the format should not
582
+ * read as a verified did:hedera when it is not one. */
583
+ function harnessAgentDid(publicKeyHex) {
584
+ return `did:key:local-${crypto.createHash('sha256').update(publicKeyHex, 'hex').digest('hex').slice(0, 32)}`;
585
+ }
586
+
587
+ function harnessRulesFile(mandate, sopDocument) {
588
+ return JSON.stringify(
589
+ {
590
+ _comment: 'Your rules — edit here, or at the dashboard below. Reloaded on every decision, no restart needed.',
591
+ mandate,
592
+ sops: [{ standardKey: 'sop', document: sopDocument }],
593
+ standards: [],
594
+ },
595
+ null,
596
+ 2,
597
+ ) + '\n';
598
+ }
599
+
600
+ function harnessServerFile() {
601
+ return `// harness-server.mjs — the free local governance dashboard. Zero dependencies.
602
+ // Runs in-process with your agent: shows the rules in force, holds an escalated action for
603
+ // YOU to approve (there is no hosted owner queue here — you are the owner), and logs every
604
+ // decision. Bound to 127.0.0.1 by default: this is a local trust boundary, not a service.
605
+ import http from 'node:http';
606
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, writeFileSync as wf } from 'node:fs';
607
+ import { randomUUID } from 'node:crypto';
608
+
609
+ const OPERATORS = { lteq: '<=', gteq: '>=', lt: '<', gt: '>', eq: '==', neq: '!=', isAnyOf: 'is any of', isNoneOf: 'is none of' };
610
+ function renderConstraint(c) {
611
+ const op = OPERATORS[c.operator] || c.operator;
612
+ const right = Array.isArray(c.rightOperand) ? \`[\${c.rightOperand.join(', ')}]\` : c.rightOperand;
613
+ return \`\${String(c.leftOperand).replace(/^mm:/, '')} \${op} \${right}\${c.unit ? ' ' + c.unit : ''}\`;
614
+ }
615
+ function renderAtom(a) {
616
+ const c = a.config || {};
617
+ switch (a.predicate) {
618
+ case 'amount-over': return \`transaction amount must not exceed \${c.limit}\`;
619
+ case 'cumulative-over': return \`cumulative spend must not exceed \${c.limit}\`;
620
+ case 'jurisdiction-not-allowed': return \`jurisdiction must be one of [\${(c.allowed || []).join(', ')}]\`;
621
+ case 'tool-not-allowed': return \`tool must be one of [\${(c.allowed || []).join(', ')}]\`;
622
+ case 'risk-at-or-above': return \`risk level at or above \${c.level}\`;
623
+ default: return \`\${a.predicate}\${Object.keys(c).length ? ' ' + JSON.stringify(c) : ''}\`;
624
+ }
625
+ }
626
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
627
+
628
+ export function startDashboard({ port = 4400, host = '127.0.0.1', agentDid, scope, rulesPath, logPath }) {
629
+ const holds = new Map(); // id -> { id, action, args, decision, ts, status, resolve }
630
+ if (!existsSync(logPath)) wf(logPath, '');
631
+
632
+ function log(entry) {
633
+ try { appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\\n'); } catch { /* best-effort */ }
634
+ }
635
+ function tailLog(n = 25) {
636
+ try {
637
+ const lines = readFileSync(logPath, 'utf8').split('\\n').filter(Boolean);
638
+ return lines.slice(-n).reverse().map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
639
+ } catch { return []; }
640
+ }
641
+ function readRules() {
642
+ try { return JSON.parse(readFileSync(rulesPath, 'utf8')); } catch (e) { return { error: String(e?.message ?? e) }; }
643
+ }
644
+ function writeRules(next) {
645
+ writeFileSync(rulesPath, JSON.stringify(next, null, 2) + '\\n');
646
+ }
647
+
648
+ /** Called by your agent code when a governed action escalates. Registers the hold (visible
649
+ * on the dashboard immediately) and returns { id, promise } — promise resolves to
650
+ * true/false the moment a human clicks Approve/Deny here. Nothing times this out; a caller
651
+ * that wants a demo-friendly timeout should race the promise itself. */
652
+ function holdForApproval(action, args, decision) {
653
+ const id = randomUUID();
654
+ log({ type: 'escalate', id, action, args, reasonCode: decision.reasonCode });
655
+ let resolveFn;
656
+ const promise = new Promise((resolve) => { resolveFn = resolve; });
657
+ holds.set(id, { id, action, args, decision, ts: Date.now(), status: 'pending', resolve: resolveFn });
658
+ return { id, promise };
659
+ }
660
+
661
+ function logDecision(action, args, decision) {
662
+ if (decision.decision === 'escalate') return; // holdForApproval already logs this one
663
+ log({ type: decision.decision, action, args, reasonCode: decision.reasonCode });
664
+ }
665
+
666
+ function renderRulesHtml(rules) {
667
+ if (rules.error) return \`<p class="err">Could not read \${esc(rulesPath)}: \${esc(rules.error)}</p>\`;
668
+ const m = (rules.mandate?.permission || [])[0];
669
+ const mandateRows = (m?.constraint || []).map((c, i) =>
670
+ \`<div class="rule"><span class="rname">\${esc(c.leftOperand.replace(/^mm:/, ''))}</span><span class="rcond">\${esc(renderConstraint(c))}</span>
671
+ <input data-kind="mandate" data-idx="\${i}" value="\${esc(Array.isArray(c.rightOperand) ? c.rightOperand.join(',') : c.rightOperand)}" /></div>\`).join('');
672
+ const sopRows = (rules.sops || []).flatMap((s) => (s.document?.molecules || []).flatMap((mo) =>
673
+ (mo.atoms || []).flatMap((a) => Object.entries(a.config || {}).map(([k, v]) =>
674
+ \`<div class="rule"><span class="rname">\${esc(mo.name || mo.id)} <span class="reff">\${esc(mo.decision)} · \${esc(mo.reasonCode)}</span></span>
675
+ <span class="rcond">\${esc(renderAtom(a))}</span>
676
+ <input data-kind="atom" data-mid="\${esc(mo.id)}" data-aid="\${esc(a.id)}" data-key="\${esc(k)}" value="\${esc(Array.isArray(v) ? v.join(',') : v)}" /></div>\`))));
677
+ return \`<div class="rules">\${mandateRows}\${sopRows.join('')}</div><button id="save">Save rules</button><span id="saveMsg"></span>\`;
678
+ }
679
+
680
+ function renderHoldsHtml() {
681
+ const pending = [...holds.values()].filter((h) => h.status === 'pending').sort((a, b) => a.ts - b.ts);
682
+ if (!pending.length) return '<p class="dim">No pending approvals.</p>';
683
+ return pending.map((h) =>
684
+ \`<div class="hold"><b>\${esc(h.action)}</b> <span class="dim">\${esc(h.decision.reasonCode)}</span>
685
+ <pre>\${esc(JSON.stringify(h.args, null, 2))}</pre>
686
+ <button class="approve" data-id="\${h.id}">Approve</button>
687
+ <button class="deny" data-id="\${h.id}">Deny</button></div>\`).join('');
688
+ }
689
+
690
+ function renderLogHtml() {
691
+ const rows = tailLog(25);
692
+ if (!rows.length) return '<p class="dim">No decisions yet — run your agent.</p>';
693
+ return rows.map((r) =>
694
+ \`<div class="logrow \${esc(r.type)}"><span class="dot"></span><b>\${esc(r.action)}</b> \${esc(r.type)} <span class="dim">\${esc(r.reasonCode || '')} · \${esc(r.ts)}</span></div>\`).join('');
695
+ }
696
+
697
+ function page() {
698
+ const rules = readRules();
699
+ return \`<!doctype html><html><head><meta charset="utf-8"><title>MetaMynd harness — \${esc(scope)}</title>
700
+ <style>
701
+ * { box-sizing: border-box; } body { margin:0; background:#f5f4f8; color:#1a1a2e; font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; }
702
+ header { padding:16px 22px; border-bottom:1px solid #e2e0eb; background:#fff; }
703
+ header h1 { font-size:16px; margin:0 0 4px; } header .did { font:11px ui-monospace,monospace; color:#6b6b80; }
704
+ main { max-width:760px; margin:0 auto; padding:20px; }
705
+ section { background:#fff; border:1px solid #e2e0eb; border-radius:10px; padding:14px 16px; margin-bottom:16px; }
706
+ section h2 { font-size:13px; margin:0 0 10px; color:#6b6b80; text-transform:uppercase; letter-spacing:.04em; }
707
+ .rule { display:flex; align-items:center; gap:10px; padding:6px 0; border-top:1px solid #eeecf3; flex-wrap:wrap; }
708
+ .rule:first-child { border-top:none; } .rname { font-weight:600; min-width:150px; } .reff { font-weight:400; color:#6b6b80; font-size:11px; }
709
+ .rcond { font:12px ui-monospace,monospace; color:#6b6b80; flex:1; }
710
+ .rule input { font:12px ui-monospace,monospace; border:1px solid #d8d5e6; border-radius:6px; padding:4px 8px; width:140px; }
711
+ button { font:inherit; cursor:pointer; border:none; border-radius:8px; padding:8px 14px; background:#6c4ff2; color:#fff; font-weight:600; }
712
+ button.deny { background:#c02532; } button.approve { background:#0f7a43; }
713
+ #saveMsg { margin-left:10px; color:#0f7a43; font-size:12px; }
714
+ .hold { border:1px solid #f2c46a; background:#fff8ea; border-radius:8px; padding:10px 12px; margin-bottom:8px; }
715
+ .hold pre { font-size:11px; background:#f5f4f8; padding:8px; border-radius:6px; overflow:auto; }
716
+ .dim { color:#6b6b80; } pre { margin:6px 0; }
717
+ .logrow { padding:5px 0; border-top:1px solid #eeecf3; font-size:12px; } .logrow:first-child { border-top:none; }
718
+ .logrow .dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
719
+ .logrow.allow .dot, .logrow.observe .dot { background:#0f7a43; } .logrow.block .dot { background:#c02532; } .logrow.escalate .dot { background:#c98a1c; }
720
+ .err { color:#c02532; }
721
+ </style></head><body>
722
+ <header><h1>MetaMynd governance harness</h1><div class="did">\${esc(agentDid)} · scope \${esc(scope)}</div></header>
723
+ <main>
724
+ <section><h2>Rules in force</h2>\${renderRulesHtml(rules)}</section>
725
+ <section><h2>Pending approvals</h2><div id="holds">\${renderHoldsHtml()}</div></section>
726
+ <section><h2>Recent decisions</h2><div id="log">\${renderLogHtml()}</div></section>
727
+ </main>
728
+ <script>
729
+ async function refresh() {
730
+ const r = await fetch('/state').then((x) => x.json());
731
+ document.getElementById('holds').innerHTML = r.holdsHtml;
732
+ document.getElementById('log').innerHTML = r.logHtml;
733
+ }
734
+ document.addEventListener('click', async (e) => {
735
+ if (e.target.matches('.approve,.deny')) {
736
+ const id = e.target.dataset.id, verb = e.target.classList.contains('approve') ? 'approve' : 'deny';
737
+ await fetch('/holds/' + id + '/' + verb, { method: 'POST' });
738
+ refresh();
739
+ }
740
+ if (e.target.id === 'save') {
741
+ const mandateInputs = [...document.querySelectorAll('input[data-kind="mandate"]')];
742
+ const atomInputs = [...document.querySelectorAll('input[data-kind="atom"]')];
743
+ const edits = {
744
+ mandate: mandateInputs.map((i) => ({ idx: Number(i.dataset.idx), value: i.value })),
745
+ atoms: atomInputs.map((i) => ({ mid: i.dataset.mid, aid: i.dataset.aid, key: i.dataset.key, value: i.value })),
746
+ };
747
+ const res = await fetch('/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(edits) });
748
+ document.getElementById('saveMsg').textContent = res.ok ? 'saved — takes effect on the next decision' : 'save failed';
749
+ }
750
+ });
751
+ setInterval(refresh, 3000);
752
+ </script></body></html>\`;
753
+ }
754
+
755
+ // A number-looking string edit becomes a number (spend caps etc.); a comma-list becomes an
756
+ // array (merchants/allow-lists); anything else stays a string.
757
+ function coerce(raw) {
758
+ if (raw.includes(',')) return raw.split(',').map((s) => s.trim()).filter(Boolean);
759
+ if (raw.trim() !== '' && !Number.isNaN(Number(raw))) return Number(raw);
760
+ return raw;
761
+ }
762
+
763
+ function applyEdits(rules, edits) {
764
+ const m = (rules.mandate?.permission || [])[0];
765
+ for (const e of edits.mandate || []) {
766
+ if (m?.constraint?.[e.idx]) m.constraint[e.idx].rightOperand = coerce(e.value);
767
+ }
768
+ for (const e of edits.atoms || []) {
769
+ for (const s of rules.sops || []) {
770
+ const mol = (s.document?.molecules || []).find((x) => x.id === e.mid);
771
+ const atom = mol?.atoms?.find((a) => a.id === e.aid);
772
+ if (atom) atom.config[e.key] = coerce(e.value);
773
+ }
774
+ }
775
+ return rules;
776
+ }
777
+
778
+ const server = http.createServer(async (req, res) => {
779
+ const path = req.url.split('?')[0];
780
+ const send = (status, body, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(type === 'application/json' ? JSON.stringify(body) : body); };
781
+ if (req.method === 'GET' && path === '/') return send(200, page(), 'text/html; charset=utf-8');
782
+ if (req.method === 'GET' && path === '/state') return send(200, { holdsHtml: renderHoldsHtml(), logHtml: renderLogHtml() });
783
+ if (req.method === 'POST' && path === '/rules') {
784
+ let body = ''; req.on('data', (c) => (body += c));
785
+ req.on('end', () => {
786
+ try { writeRules(applyEdits(readRules(), JSON.parse(body || '{}'))); return send(200, { ok: true }); }
787
+ catch (e) { return send(500, { ok: false, error: String(e?.message ?? e) }); }
788
+ });
789
+ return;
790
+ }
791
+ const m = /^\\/holds\\/([^/]+)\\/(approve|deny)$/.exec(path);
792
+ if (req.method === 'POST' && m) {
793
+ const h = holds.get(m[1]);
794
+ if (h && h.status === 'pending') {
795
+ h.status = m[2] === 'approve' ? 'approved' : 'denied';
796
+ log({ type: h.status, id: h.id, action: h.action });
797
+ h.resolve(h.status === 'approved');
798
+ }
799
+ return send(200, { ok: true });
800
+ }
801
+ send(404, { error: 'not found' });
802
+ });
803
+ server.listen(port, host);
804
+ return { holdForApproval, logDecision, url: \`http://\${host}:\${port}\`, close: () => server.close() };
805
+ }
806
+ `;
807
+ }
808
+
809
+ function harnessIndexFile(scope, perTxnMax, port) {
810
+ const under = Math.max(1, Math.round(perTxnMax * 0.5));
811
+ const over = Math.round(perTxnMax + 100);
812
+ return `// index.mjs — your agent, governed entirely on this machine. No account, no network call
813
+ // for a decision: guardToolLocal() decides allow/block/escalate against ./metamynd-rules.json
814
+ // (edit it directly, or at the dashboard). An escalate is held here for YOU to approve —
815
+ // there is no hosted owner queue in this mode, so open the dashboard URL printed below.
816
+ import { readFileSync } from 'node:fs';
817
+ import { createGuard } from '${GUARD_PKG}';
818
+ import { startDashboard } from './harness-server.mjs';
819
+
820
+ const config = JSON.parse(readFileSync('./agent.metamynd.json', 'utf8'));
821
+ // 'local' as the api: guardToolLocal() never calls it. Kept required-but-unused rather than
822
+ // silently accepting no api at all, so a later switch to a real gate is one field, not a rewrite.
823
+ const guard = createGuard({ api: 'local', agentDid: config.agentDid, agentKey: config.agentKey });
824
+
825
+ const dashboard = startDashboard({
826
+ port: ${port},
827
+ agentDid: config.agentDid,
828
+ scope: '${scope}',
829
+ rulesPath: './metamynd-rules.json',
830
+ logPath: './metamynd-harness.log.jsonl',
831
+ });
832
+ console.log('\\x1b[2m dashboard: ' + dashboard.url + ' (rules, approvals, decision log)\\x1b[0m\\n');
833
+
834
+ // Reads the CURRENT rules file fresh every call — editing it (by hand, or at the dashboard)
835
+ // takes effect on the next decision, no restart, matching the "no redeploy" experience the
836
+ // hosted platform gives you.
837
+ const getBundle = () => JSON.parse(readFileSync('./metamynd-rules.json', 'utf8'));
838
+
839
+ // --- Your real tool. Replace the body with your actual implementation. ---
840
+ async function bookFlight(args) {
841
+ return { pnr: 'PNR-DEMO', ...args };
842
+ }
843
+
844
+ // --- The GATED version. Register THIS with your agent instead of the raw handler. ---
845
+ const gatedBookFlight = guard.guardToolLocal(
846
+ '${scope}', // = your mandate scope
847
+ bookFlight,
848
+ (a) => ({ // map tool args → gate inputs
849
+ amount: a.amount,
850
+ merchant: a.merchant,
851
+ context: { tool: 'book-flight', riskLevel: a.riskLevel ?? 'low' },
852
+ }),
853
+ getBundle,
854
+ );
855
+
856
+ // --- A tool the agent was NEVER granted. Wrapping it is the demonstration: there is no
857
+ // --- rule anywhere forbidding this. The mandate simply never mentioned the action.
858
+ async function raiseOwnLimit(args) {
859
+ return { updated: true, ...args }; // never runs, and that is the point
860
+ }
861
+
862
+ const gatedRaiseOwnLimit = guard.guardToolLocal(
863
+ 'permissions.update', // an action NOT in the mandate
864
+ raiseOwnLimit,
865
+ (a) => ({ amount: a.amount, merchant: a.merchant, context: { tool: 'permissions-update' } }),
866
+ getBundle,
867
+ );
868
+
869
+ const dim = (t) => '\\x1b[2m' + t + '\\x1b[0m';
870
+ const bold = (t) => '\\x1b[1m' + t + '\\x1b[0m';
871
+ const rule = (n) => ' ' + '-'.repeat(n);
872
+
873
+ const WHY = {
874
+ AUTHORIZED: 'inside the mandate and under the SOP spend cap',
875
+ SOP_SPEND_CAP: 'your SOP caps a single transaction at $${perTxnMax}',
876
+ RISK_REVIEW: 'your SOP sends high-risk actions to a human first',
877
+ MERCHANT_NOT_ALLOWED: 'the mandate lists which merchants this agent may pay',
878
+ NO_PERMISSION_FOR_ACTION: 'the mandate never granted this action - at any amount',
879
+ NO_MANDATE: 'there is no mandate for this action at all',
880
+ };
881
+
882
+ async function attempt(n, intent, action, args, tool = gatedBookFlight) {
883
+ console.log('');
884
+ console.log(bold(' Step ' + n + ' of 4') + ' - ' + intent);
885
+ console.log(dim(' evaluating locally, no network call...'));
886
+ try {
887
+ const r = await tool(args);
888
+ console.log('\\x1b[32m ALLOWED\\x1b[0m your tool ran and returned ' + (r.pnr ?? 'ok'));
889
+ console.log(dim(' ' + WHY.AUTHORIZED));
890
+ } catch (e) {
891
+ const g = e.governance ?? {};
892
+ const why = WHY[g.reasonCode] ?? e.message;
893
+ if (g.decision === 'escalate') {
894
+ console.log('\\x1b[33m ESCALATED\\x1b[0m held for you to approve - ' + g.reasonCode);
895
+ console.log(dim(' ' + why));
896
+ const { id, promise } = dashboard.holdForApproval(action, args, g);
897
+ console.log(dim(' open ' + dashboard.url + ' and click Approve/Deny (hold ' + id.slice(0, 8) + '…)'));
898
+ const timeout = new Promise((r) => setTimeout(() => r('timeout'), 20000));
899
+ const result = await Promise.race([promise, timeout]);
900
+ if (result === 'timeout') console.log(dim(' still pending after 20s — this demo will not wait forever; the dashboard will, run it again to check.'));
901
+ else console.log(dim(' ' + (result ? 'approved.' : 'denied.')));
902
+ } else {
903
+ console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? 'refused'));
904
+ console.log(dim(' ' + why));
905
+ console.log(dim(' your tool never ran - the gate refused before execution.'));
906
+ }
907
+ dashboard.logDecision(action, args, g);
908
+ }
909
+ }
910
+
911
+ console.log('');
912
+ console.log(bold(' What this simulation shows'));
913
+ console.log('');
914
+ console.log(' Same idea as the hosted platform, running entirely on this machine: an agent');
915
+ console.log(' should not be the thing that decides what it is allowed to do. Three attempts');
916
+ console.log(' take the SAME code path and produce three different outcomes. The fourth asks');
917
+ console.log(' for something never granted at all - the one a prompt could not have stopped,');
918
+ console.log(' because the decision is not made inside your program, and not on a server either.');
919
+ console.log('');
920
+ console.log(dim(' scope ${scope}'));
921
+ console.log(dim(' cap $${perTxnMax} per transaction, from ./metamynd-rules.json'));
922
+
923
+ console.log('');
924
+ console.log(rule(66));
925
+ await attempt(1, 'a $${under} booking, low risk. Expected to pass.', '${scope}', { amount: ${under}, merchant: 'skyward-air', riskLevel: 'low' });
926
+ await attempt(2, 'a $${over} booking, deliberately over the cap.', '${scope}', { amount: ${over}, merchant: 'skyward-air', riskLevel: 'low' });
927
+ await attempt(3, 'a $${under} booking, but flagged high risk.', '${scope}', { amount: ${under}, merchant: 'skyward-air', riskLevel: 'high' });
928
+ await attempt(4, 'the agent stops booking flights and asks to raise its OWN limit.', 'permissions.update', { amount: 100000, merchant: 'skyward-air' }, gatedRaiseOwnLimit);
929
+ console.log('');
930
+ console.log(rule(66));
931
+
932
+ console.log('');
933
+ console.log(bold(' What this proved'));
934
+ console.log('');
935
+ console.log(dim(' - one code path, three outcomes, decided with zero network calls.'));
936
+ console.log(dim(' - step 4 needed no rule to stop it. The agent could not widen its own'));
937
+ console.log(dim(' authority, because it cannot name an action nobody delegated to it.'));
938
+ console.log(dim(' - the blocked call never reached your tool at all.'));
939
+ console.log(dim(' - every decision is in ./metamynd-harness.log.jsonl - yours, locally.'));
940
+ console.log('');
941
+ console.log(' Edit ./metamynd-rules.json (or the dashboard) and run again - the outcome');
942
+ console.log(dim(' changes. This file does not. That is the point.'));
943
+ console.log('');
944
+ console.log(dim(' Ready for more than one machine, a queue someone else can approve from,'));
945
+ console.log(dim(' anchored evidence, or KYC/KYB-backed identity? That is the hosted platform -'));
946
+ console.log(dim(' same guardTool() call, same rules shape, drop --harness and provision there.'));
947
+ console.log('');
948
+ dashboard.close();
949
+ `;
950
+ }
951
+
952
+ function harnessPackageJson(slug) {
953
+ return JSON.stringify(
954
+ {
955
+ name: slug,
956
+ version: '0.1.0',
957
+ private: true,
958
+ type: 'module',
959
+ scripts: { start: 'node index.mjs' },
960
+ dependencies: { [GUARD_PKG]: GUARD_VERSION },
961
+ },
962
+ null,
963
+ 2,
964
+ ) + '\n';
965
+ }
966
+
967
+ function harnessReadme(slug, scope, port) {
968
+ return `# ${slug}
969
+
970
+ A free, local MetaMynd/AgentSafe governance harness — your own rules, your own identity,
971
+ decided entirely on this machine. No account, no network call for a decision.
972
+
973
+ ## Run
974
+
975
+ \`\`\`bash
976
+ npm install
977
+ npm start
978
+ \`\`\`
979
+
980
+ You should see an ALLOW, a BLOCK (over the per-transaction cap), an ESCALATE (high risk —
981
+ open the dashboard to approve it), and a BLOCK (an action outside the mandate entirely).
982
+
983
+ ## Files
984
+
985
+ - \`agent.metamynd.json\` — your local identity (a generated Ed25519 keypair; \`agentDid\` is a
986
+ local label, not an anchored/verifiable one). **Contains a secret key — never commit it.**
987
+ - \`metamynd-rules.json\` — your rules: the mandate (scope + spend limits) and SOP (extra checks).
988
+ Edit it directly, or at the dashboard. Reloaded on every decision — no restart.
989
+ - \`metamynd-harness.log.jsonl\` — every decision this agent made, append-only.
990
+ - \`harness-server.mjs\` — the local dashboard (port ${port}): rules, pending approvals, decision log.
991
+ - \`index.mjs\` — wraps a tool with \`guard.guardToolLocal(...)\`; the tool only runs when the
992
+ LOCAL rules permit it.
993
+
994
+ ## What this is not
995
+
996
+ No anchored/verifiable identity, no cross-party trust, no evidence anyone but you can audit,
997
+ no dashboard reachable when this machine is off, no owner queue someone else can approve from.
998
+ That's the hosted platform (\`npx create-metamynd-agent\`, without \`--harness\`) — same
999
+ \`guardTool()\` call, same rules shape, so upgrading later is a config change, not a rewrite.
1000
+ `;
1001
+ }
1002
+
1003
+ /** --harness: no login, no KYB, no network — author identity + rules locally and scaffold. */
1004
+ async function runHarness(args) {
1005
+ const interactive = !args.yes && process.stdin.isTTY;
1006
+ const rl = interactive ? makeRl() : null;
1007
+ const pick = async (flag, prompt, def) => {
1008
+ const fromFlag = typeof args[flag] === 'string' ? args[flag] : undefined;
1009
+ if (fromFlag !== undefined) return fromFlag;
1010
+ if (!interactive) return def;
1011
+ return ask(rl, prompt, def);
1012
+ };
1013
+
1014
+ const fileConfig = typeof args.config === 'string' ? loadConfigFile(args.config) : null;
1015
+ if (fileConfig) console.log(` ${c.green('✓')} loaded policy config ${c.dim(args.config)}`);
1016
+
1017
+ const name = await pick('name', 'Agent name', fileConfig?.name ?? 'Local Agent');
1018
+ const scope = await pick('scope', 'Mandate scope (governed action)', fileConfig?.scope ?? 'flight-purchase');
1019
+ const perTxnMax = Number(await pick('per-txn-max', 'Per-transaction cap', String(fileConfig?.perTxnMax ?? '500'))) || 500;
1020
+ const maxAmount = Number(await pick('max-amount', 'Total mandate budget', String(fileConfig?.maxAmount ?? '10000'))) || 10000;
1021
+ const currency = (await pick('currency', 'Currency', fileConfig?.currency ?? 'USD')) || 'USD';
1022
+ const merchantsRaw = await pick('merchants', 'Allowed merchants (comma-sep, blank = any)', Array.isArray(fileConfig?.merchants) ? fileConfig.merchants.join(',') : '');
1023
+ const merchants = String(merchantsRaw).split(',').map((s) => s.trim()).filter(Boolean);
1024
+ const port = Number(args.port) || 4400;
1025
+ const slug = slugify(name);
1026
+ const outDir = resolve(String(args.out || (interactive ? await ask(rl, 'Output directory', `./${slug}`) : `./${slug}`)));
1027
+ rl?.close();
1028
+
1029
+ assertScaffoldTarget(outDir, !!args.force);
1030
+ console.log(c.dim('\n → generating a local identity (Ed25519, this machine only) …'));
1031
+ const { publicKeyHex, privateKeyHex } = generateAgentKeypair();
1032
+ const agentDid = harnessAgentDid(publicKeyHex);
1033
+ console.log(` ${c.green('✓')} local agent ${c.b(agentDid)}`);
1034
+
1035
+ const sopFields = configFileSopFields(fileConfig);
1036
+ const sopDocument = sopFields.sop ? sopFields.sop.documentJson : harnessDefaultSop(perTxnMax);
1037
+ if (sopFields.sop) console.log(` ${c.green('✓')} compiled ${sopDocument.molecules.length} rule(s) from the config file`);
1038
+ const mandate = harnessMandate({ scope, currency, maxAmount, perTxnMax, merchants });
1039
+
1040
+ console.log(`\n ${c.b('Scaffolding')} ${c.dim(outDir)}`);
1041
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
1042
+ writeFileSafe(outDir, 'agent.metamynd.json', JSON.stringify({ agentDid, agentKey: privateKeyHex, mode: 'harness' }, null, 2) + '\n', !!args.force);
1043
+ writeFileSafe(outDir, 'metamynd-rules.json', harnessRulesFile(mandate, sopDocument), !!args.force);
1044
+ writeFileSafe(outDir, 'harness-server.mjs', harnessServerFile(), !!args.force);
1045
+ writeFileSafe(outDir, 'index.mjs', harnessIndexFile(scope, perTxnMax, port), !!args.force);
1046
+ writeFileSafe(outDir, 'package.json', harnessPackageJson(slug), !!args.force);
1047
+ writeFileSafe(outDir, '.gitignore', gitignore(), !!args.force);
1048
+ writeFileSafe(outDir, 'README.md', harnessReadme(slug, scope, port), !!args.force);
1049
+
1050
+ const rel = outDir.replace(resolve('.'), '.').replace(/\\/g, '/');
1051
+ console.log(`\n${c.green(c.b(' ✓ Done.'))} Your local governance harness is ready.\n`);
1052
+ console.log(` ${c.dim('Free, local, no account. Not the hosted platform — see README#what-this-is-not.')}\n`);
1053
+ console.log(` Next:`);
1054
+ console.log(c.cyan(` cd ${rel}`));
1055
+ console.log(c.cyan(` npm install`));
1056
+ console.log(c.cyan(` npm start`) + c.dim(' → ALLOW · BLOCK (over cap) · ESCALATE (approve at the dashboard) · BLOCK (ungranted action)\n'));
1057
+ console.log(c.dim(` Edit ./metamynd-rules.json any time (by hand, or at http://127.0.0.1:${port}) — no redeploy.\n`));
1058
+ }
1059
+
458
1060
  // ---------- delegated issuance (#6) ----------
459
1061
  async function apiGet(base, path, { claimToken } = {}) {
460
1062
  let res;
@@ -581,12 +1183,21 @@ async function main() {
581
1183
 
582
1184
  console.log(`\n${c.b(c.cyan(' create-metamynd-agent'))} ${c.dim('— provision a governed agent in ~2 minutes')}\n`);
583
1185
 
1186
+ // --harness: skip login + provisioning + the network entirely.
1187
+ if (args.harness) { await runHarness(args); return; }
584
1188
  // --sandbox: skip login + provisioning entirely.
585
1189
  if (args.sandbox) { await runSandbox(args); return; }
586
1190
  // Delegated issuance (#6): request an agent for an owner's org / claim it once approved.
587
1191
  if (args.request) { await runRequest(args); return; }
588
1192
  if (args.claim) { await runClaim(args); return; }
589
1193
 
1194
+ // --config: a JSON policy file. Its fields become the DEFAULT for each prompt/flag below —
1195
+ // an explicit CLI flag still wins (e.g. `--config base.json --name "Other Bot"`), and
1196
+ // env vars still win over the file for login credentials specifically (never put a
1197
+ // password in a policy file that gets checked into source control).
1198
+ const fileConfig = typeof args.config === 'string' ? loadConfigFile(args.config) : null;
1199
+ if (fileConfig) console.log(` ${c.green('✓')} loaded policy config ${c.dim(args.config)}`);
1200
+
590
1201
  const interactive = !args.yes && process.stdin.isTTY;
591
1202
  const rl = interactive ? makeRl() : null;
592
1203
  const pick = async (flag, envVar, prompt, def) => {
@@ -618,13 +1229,16 @@ async function main() {
618
1229
  if (!token) { rl?.close(); fail('Login succeeded but no access token was returned.'); }
619
1230
  console.log(` ${c.green('✓')} authenticated as ${email}`);
620
1231
 
621
- // 2. Agent details
622
- const name = await pick('name', null, 'Agent name', 'Support Bot');
623
- const scope = await pick('scope', null, 'Mandate scope (governed action)', 'flight-purchase');
624
- const perTxnMax = Number(await pick('per-txn-max', null, 'Per-transaction cap', '500')) || 500;
625
- const maxAmount = Number(await pick('max-amount', null, 'Total mandate budget', '10000')) || 10000;
626
- const currency = (await pick('currency', null, 'Currency', 'USD')) || 'USD';
627
- const merchantsRaw = await pick('merchants', null, 'Allowed merchants (comma-sep, blank = any)', '');
1232
+ // 2. Agent details — a --config file's fields are the default at every prompt/flag below.
1233
+ const name = await pick('name', null, 'Agent name', fileConfig?.name ?? 'Support Bot');
1234
+ const scope = await pick('scope', null, 'Mandate scope (governed action)', fileConfig?.scope ?? 'flight-purchase');
1235
+ const perTxnMax = Number(await pick('per-txn-max', null, 'Per-transaction cap', String(fileConfig?.perTxnMax ?? '500'))) || 500;
1236
+ const maxAmount = Number(await pick('max-amount', null, 'Total mandate budget', String(fileConfig?.maxAmount ?? '10000'))) || 10000;
1237
+ const currency = (await pick('currency', null, 'Currency', fileConfig?.currency ?? 'USD')) || 'USD';
1238
+ const merchantsRaw = await pick(
1239
+ 'merchants', null, 'Allowed merchants (comma-sep, blank = any)',
1240
+ Array.isArray(fileConfig?.merchants) ? fileConfig.merchants.join(',') : '',
1241
+ );
628
1242
  const merchants = String(merchantsRaw).split(',').map((s) => s.trim()).filter(Boolean);
629
1243
 
630
1244
  // BYOK: --byok generates a keypair on THIS machine (MetaMynd never sees the private key). An
@@ -642,9 +1256,13 @@ async function main() {
642
1256
 
643
1257
  rl?.close();
644
1258
 
645
- // 3. Provision (one call)
1259
+ // 3. Provision (one call) — a --config file's `rules`/`molecules`/`rulePack` become the
1260
+ // starter SOP; with none of those, provisionGuardConfig falls back to its own default
1261
+ // (a per-transaction cap + high-risk review), same as before --config existed.
1262
+ const sopFields = configFileSopFields(fileConfig);
1263
+ if (sopFields.sop) console.log(` ${c.green('✓')} compiled ${sopFields.sop.documentJson.molecules.length} rule(s) from the config file`);
646
1264
  console.log(c.dim(`\n → provisioning "${name}" (identity + mandate + SOP + Standards) …`));
647
- const body = { name, scope, currency, maxAmount, perTxnMax, merchants, ...(publicKey ? { publicKey } : {}) };
1265
+ const body = { name, scope, currency, maxAmount, perTxnMax, merchants, ...(publicKey ? { publicKey } : {}), ...sopFields };
648
1266
  const provisioned = await apiPost(base, '/onboarding/agent', body, token);
649
1267
  const config = provisioned?.data;
650
1268
  if (!config?.agentDid) fail('Provisioning did not return a config with an agentDid.');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.3.6",
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.",
3
+ "version": "0.5.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.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-metamynd-agent": "index.mjs"