create-metamynd-agent 0.3.6 → 0.6.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 +85 -3
  2. package/index.mjs +787 -9
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -9,6 +9,47 @@ 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
+
47
+ The dashboard's rules panel is a real editor, not just JSON with input boxes: edit an existing
48
+ rule's values, **delete** a rule, or **add a new one** from a form (predicate + its typed config
49
+ fields + decision) driven by the same atom catalog and validator
50
+ ([`policy-core`](../agentsafe-guard/policy-core.mjs)) the hosted gate itself uses — so nothing you
51
+ add through it can be invalid. Hand-editing `metamynd-rules.json` still works too, if you prefer.
52
+
12
53
  ## Try it instantly — sandbox (no account, no KYB)
13
54
 
14
55
  ```bash
@@ -16,8 +57,10 @@ npm create metamynd-agent@latest -- --sandbox # or: npx create-metamynd-agent
16
57
  ```
17
58
 
18
59
  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.
60
+ `POST /onboarding/sandbox` endpoint and scaffolds a runnable example. Unlike `--harness`, this DOES
61
+ call the hosted API (a shared demo identity) it's a first look at the *hosted* platform, not a
62
+ local/offline mode. Great for a first look; use the full flow below when you want your own governed
63
+ agent with your own limits.
21
64
 
22
65
  ## Use
23
66
 
@@ -65,7 +108,10 @@ METAMYND_PASSWORD='…' npx create-metamynd-agent --yes …
65
108
 
66
109
  | Flag | Env | Default |
67
110
  |---|---|---|
68
- | `--sandbox` | — | off (skips login/KYB; shared sandbox agent) |
111
+ | `--harness` | — | off (no login/KYB/network at all; free local governance — see above) |
112
+ | `--sandbox` | — | off (skips login/KYB; shared sandbox agent, still hosted) |
113
+ | `--config <file>` | — | a JSON policy file — see [Policy config file](#policy-config-file---config) |
114
+ | `--port <n>` | — | `4400` — `--harness` only, the local dashboard's port |
69
115
  | `--api <url>` | `METAMYND_API` | `https://metamynd.ai/api/v1` |
70
116
  | `--email <email>` | `METAMYND_EMAIL` | — (required) |
71
117
  | `--password <pw>` | `METAMYND_PASSWORD` | interactive masked prompt |
@@ -82,6 +128,42 @@ METAMYND_PASSWORD='…' npx create-metamynd-agent --yes …
82
128
 
83
129
  Run `npx create-metamynd-agent --help` for the full list.
84
130
 
131
+ ## Policy config file (`--config`)
132
+
133
+ Everything above works from flags and prompts, which is fine for one agent but tedious to check
134
+ into source control or hand to a teammate. `--config <file>` reads a plain **JSON** file instead —
135
+ no YAML, no new dependency, so the CLI stays exactly as dependency-free as the guard it scaffolds:
136
+
137
+ ```json
138
+ {
139
+ "name": "Procurement Agent",
140
+ "scope": "purchase-order",
141
+ "currency": "USD",
142
+ "maxAmount": 20000,
143
+ "perTxnMax": 2000,
144
+ "merchants": ["acme-supplies", "northwind-rail"],
145
+ "rules": [
146
+ { "when": { "predicate": "amount-over", "config": { "limit": 2000 } }, "then": "escalate" },
147
+ { "when": { "predicate": "risk-at-or-above", "config": { "level": "high" } }, "then": "block" }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ ```bash
153
+ npx create-metamynd-agent --config ./procurement.policy.json --email you@example.com --yes
154
+ ```
155
+
156
+ `rules` is sugar for the common one-atom-one-decision case — each entry compiles to a starter-SOP
157
+ molecule (`when.predicate` + `when.config` becomes the atom, `then` becomes the decision). See the
158
+ [protocol spec's atom catalog](https://metamynd.ai/developers/spec) for the full predicate list
159
+ (`amount-over`, `risk-at-or-above`, `jurisdiction-not-allowed`, `merchant`-style checks, and more).
160
+ If you need a real multi-atom/combinator molecule, supply `molecules` directly instead (the same
161
+ shape the dashboard's SOP editor produces) — `rules` is ignored when `molecules` is present.
162
+
163
+ Any CLI flag still overrides the matching field from the file (`--config base.json --name "Other
164
+ Bot"`), and login credentials are never read from the file — use `--email`/`METAMYND_EMAIL` and
165
+ `METAMYND_PASSWORD` as usual, so a policy file is safe to commit.
166
+
85
167
  ## Bring your own key (`--byok`)
86
168
 
87
169
  ```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,696 @@ 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, lets you add/edit/remove SOP
603
+ // rules without hand-editing JSON, holds an escalated action for YOU to approve (there is no
604
+ // hosted owner queue here — you are the owner), and logs every decision. Bound to 127.0.0.1
605
+ // by default: this is a local trust boundary, not a service.
606
+ import http from 'node:http';
607
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, writeFileSync as wf } from 'node:fs';
608
+ import { randomUUID } from 'node:crypto';
609
+ // The SAME atom catalog + validator the hosted platform's rule builder uses — so the add-rule
610
+ // form's predicate list, field types and validation never drift from what the gate accepts.
611
+ import { ATOM_SPECS, validateMolecules } from '${GUARD_PKG}/policy-core';
612
+
613
+ const OPERATORS = { lteq: '<=', gteq: '>=', lt: '<', gt: '>', eq: '==', neq: '!=', isAnyOf: 'is any of', isNoneOf: 'is none of' };
614
+ function renderConstraint(c) {
615
+ const op = OPERATORS[c.operator] || c.operator;
616
+ const right = Array.isArray(c.rightOperand) ? \`[\${c.rightOperand.join(', ')}]\` : c.rightOperand;
617
+ return \`\${String(c.leftOperand).replace(/^mm:/, '')} \${op} \${right}\${c.unit ? ' ' + c.unit : ''}\`;
618
+ }
619
+ function renderAtom(a) {
620
+ const c = a.config || {};
621
+ switch (a.predicate) {
622
+ case 'amount-over': return \`transaction amount must not exceed \${c.limit}\`;
623
+ case 'cumulative-over': return \`cumulative spend must not exceed \${c.limit}\`;
624
+ case 'jurisdiction-not-allowed': return \`jurisdiction must be one of [\${(c.allowed || []).join(', ')}]\`;
625
+ case 'tool-not-allowed': return \`tool must be one of [\${(c.allowed || []).join(', ')}]\`;
626
+ case 'risk-at-or-above': return \`risk level at or above \${c.level}\`;
627
+ default: return \`\${a.predicate}\${Object.keys(c).length ? ' ' + JSON.stringify(c) : ''}\`;
628
+ }
629
+ }
630
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
631
+
632
+ // A field's declared type (from ATOM_SPECS) coerces a raw form string authoritatively —
633
+ // no guessing, unlike the generic value-edit coerce() below.
634
+ function coerceField(raw, type) {
635
+ if (type === 'number') return Number(raw);
636
+ if (type === 'string[]') return String(raw).split(',').map((s) => s.trim()).filter(Boolean);
637
+ return raw; // string, enum
638
+ }
639
+
640
+ export function startDashboard({ port = 4400, host = '127.0.0.1', agentDid, scope, rulesPath, logPath }) {
641
+ const holds = new Map(); // id -> { id, action, args, decision, ts, status, resolve }
642
+ if (!existsSync(logPath)) wf(logPath, '');
643
+
644
+ function log(entry) {
645
+ try { appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\\n'); } catch { /* best-effort */ }
646
+ }
647
+ function tailLog(n = 25) {
648
+ try {
649
+ const lines = readFileSync(logPath, 'utf8').split('\\n').filter(Boolean);
650
+ return lines.slice(-n).reverse().map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
651
+ } catch { return []; }
652
+ }
653
+ function readRules() {
654
+ try { return JSON.parse(readFileSync(rulesPath, 'utf8')); } catch (e) { return { error: String(e?.message ?? e) }; }
655
+ }
656
+ function writeRules(next) {
657
+ writeFileSync(rulesPath, JSON.stringify(next, null, 2) + '\\n');
658
+ }
659
+
660
+ /** Called by your agent code when a governed action escalates. Registers the hold (visible
661
+ * on the dashboard immediately) and returns { id, promise } — promise resolves to
662
+ * true/false the moment a human clicks Approve/Deny here. Nothing times this out; a caller
663
+ * that wants a demo-friendly timeout should race the promise itself. */
664
+ function holdForApproval(action, args, decision) {
665
+ const id = randomUUID();
666
+ log({ type: 'escalate', id, action, args, reasonCode: decision.reasonCode });
667
+ let resolveFn;
668
+ const promise = new Promise((resolve) => { resolveFn = resolve; });
669
+ holds.set(id, { id, action, args, decision, ts: Date.now(), status: 'pending', resolve: resolveFn });
670
+ return { id, promise };
671
+ }
672
+
673
+ function logDecision(action, args, decision) {
674
+ if (decision.decision === 'escalate') return; // holdForApproval already logs this one
675
+ log({ type: decision.decision, action, args, reasonCode: decision.reasonCode });
676
+ }
677
+
678
+ function renderRulesHtml(rules) {
679
+ if (rules.error) return \`<p class="err">Could not read \${esc(rulesPath)}: \${esc(rules.error)}</p>\`;
680
+ const m = (rules.mandate?.permission || [])[0];
681
+ const mandateRows = (m?.constraint || []).map((c, i) =>
682
+ \`<div class="rule"><span class="rname">\${esc(c.leftOperand.replace(/^mm:/, ''))}</span><span class="rcond">\${esc(renderConstraint(c))}</span>
683
+ <input data-kind="mandate" data-idx="\${i}" value="\${esc(Array.isArray(c.rightOperand) ? c.rightOperand.join(',') : c.rightOperand)}" /></div>\`).join('');
684
+ // Grouped by molecule (one "rule" a person authored), not flattened — a molecule can have
685
+ // several atoms/config fields, and the delete button acts on the whole rule, not one field.
686
+ const sopGroups = (rules.sops || []).flatMap((s) => (s.document?.molecules || []).map((mo) => {
687
+ const fieldRows = (mo.atoms || []).flatMap((a) => Object.entries(a.config || {}).map(([k, v]) =>
688
+ \`<div class="rule"><span class="rcond">\${esc(renderAtom(a))}</span>
689
+ <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>\`)).join('');
690
+ return \`<div class="mgroup">
691
+ <div class="mhead"><span class="rname">\${esc(mo.name || mo.id)}</span>
692
+ <span class="reff">\${esc(mo.decision)} · \${esc(mo.reasonCode)}</span>
693
+ <button class="delmol" data-id="\${esc(mo.id)}" title="Remove this rule">Delete</button></div>
694
+ \${fieldRows}
695
+ </div>\`;
696
+ })).join('');
697
+ return \`<div class="rules">\${mandateRows}</div>\${sopGroups}<button id="save">Save changes</button><span id="saveMsg"></span>
698
+ <div id="addRule">
699
+ <h3>Add a rule</h3>
700
+ <div class="addrow">
701
+ <label>When <select id="addPredicate"></select></label>
702
+ <label>Then <select id="addDecision">
703
+ <option value="block">block</option><option value="escalate">escalate</option>
704
+ <option value="observe">observe</option><option value="suspend">suspend</option>
705
+ <option value="quarantine">quarantine</option>
706
+ </select></label>
707
+ </div>
708
+ <p class="dim" id="addDesc"></p>
709
+ <div id="addFields"></div>
710
+ <div class="addrow">
711
+ <label>Name <input id="addName" placeholder="(optional)" /></label>
712
+ <label>Reason code <input id="addReasonCode" placeholder="(auto)" /></label>
713
+ </div>
714
+ <button id="addRuleBtn">Add rule</button><span id="addMsg"></span>
715
+ </div>\`;
716
+ }
717
+
718
+ function renderHoldsHtml() {
719
+ const pending = [...holds.values()].filter((h) => h.status === 'pending').sort((a, b) => a.ts - b.ts);
720
+ if (!pending.length) return '<p class="dim">No pending approvals.</p>';
721
+ return pending.map((h) =>
722
+ \`<div class="hold"><b>\${esc(h.action)}</b> <span class="dim">\${esc(h.decision.reasonCode)}</span>
723
+ <pre>\${esc(JSON.stringify(h.args, null, 2))}</pre>
724
+ <button class="approve" data-id="\${h.id}">Approve</button>
725
+ <button class="deny" data-id="\${h.id}">Deny</button></div>\`).join('');
726
+ }
727
+
728
+ function renderLogHtml() {
729
+ const rows = tailLog(25);
730
+ if (!rows.length) return '<p class="dim">No decisions yet — run your agent.</p>';
731
+ return rows.map((r) =>
732
+ \`<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('');
733
+ }
734
+
735
+ function page() {
736
+ const rules = readRules();
737
+ return \`<!doctype html><html><head><meta charset="utf-8"><title>MetaMynd harness — \${esc(scope)}</title>
738
+ <style>
739
+ * { box-sizing: border-box; } body { margin:0; background:#f5f4f8; color:#1a1a2e; font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; }
740
+ header { padding:16px 22px; border-bottom:1px solid #e2e0eb; background:#fff; }
741
+ header h1 { font-size:16px; margin:0 0 4px; } header .did { font:11px ui-monospace,monospace; color:#6b6b80; }
742
+ main { max-width:760px; margin:0 auto; padding:20px; }
743
+ section { background:#fff; border:1px solid #e2e0eb; border-radius:10px; padding:14px 16px; margin-bottom:16px; }
744
+ section h2 { font-size:13px; margin:0 0 10px; color:#6b6b80; text-transform:uppercase; letter-spacing:.04em; }
745
+ .rule { display:flex; align-items:center; gap:10px; padding:6px 0; border-top:1px solid #eeecf3; flex-wrap:wrap; }
746
+ .rule:first-child { border-top:none; } .rname { font-weight:600; } .reff { font-weight:400; color:#6b6b80; font-size:11px; }
747
+ .rcond { font:12px ui-monospace,monospace; color:#6b6b80; flex:1; }
748
+ .rule input { font:12px ui-monospace,monospace; border:1px solid #d8d5e6; border-radius:6px; padding:4px 8px; width:140px; }
749
+ .mgroup { border-top:1px solid #eeecf3; padding:8px 0; }
750
+ .mhead { display:flex; align-items:center; gap:10px; margin-bottom:2px; }
751
+ .mhead .rname { min-width:150px; }
752
+ button { font:inherit; cursor:pointer; border:none; border-radius:8px; padding:8px 14px; background:#6c4ff2; color:#fff; font-weight:600; }
753
+ button.deny, button.delmol { background:#c02532; } button.approve { background:#0f7a43; }
754
+ button.delmol { padding:4px 10px; font-size:11px; margin-left:auto; }
755
+ #saveMsg, #addMsg { margin-left:10px; color:#0f7a43; font-size:12px; }
756
+ #addRule { margin-top:14px; padding-top:14px; border-top:1px solid #eeecf3; }
757
+ #addRule h3 { font-size:12px; margin:0 0 10px; color:#6b6b80; text-transform:uppercase; letter-spacing:.04em; }
758
+ .addrow { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:8px; }
759
+ .addrow label { display:flex; flex-direction:column; gap:3px; font-size:12px; color:#6b6b80; }
760
+ .addrow input, .addrow select, #addFields input, #addFields select { font:13px inherit; border:1px solid #d8d5e6; border-radius:6px; padding:6px 8px; min-width:160px; }
761
+ #addFields { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:8px; }
762
+ #addFields label { display:flex; flex-direction:column; gap:3px; font-size:12px; color:#6b6b80; }
763
+ #addDesc { font-size:12px; margin:2px 0 10px; }
764
+ .hold { border:1px solid #f2c46a; background:#fff8ea; border-radius:8px; padding:10px 12px; margin-bottom:8px; }
765
+ .hold pre { font-size:11px; background:#f5f4f8; padding:8px; border-radius:6px; overflow:auto; }
766
+ .dim { color:#6b6b80; } pre { margin:6px 0; }
767
+ .logrow { padding:5px 0; border-top:1px solid #eeecf3; font-size:12px; } .logrow:first-child { border-top:none; }
768
+ .logrow .dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
769
+ .logrow.allow .dot, .logrow.observe .dot { background:#0f7a43; } .logrow.block .dot { background:#c02532; } .logrow.escalate .dot { background:#c98a1c; }
770
+ .err { color:#c02532; }
771
+ </style></head><body>
772
+ <header><h1>MetaMynd governance harness</h1><div class="did">\${esc(agentDid)} · scope \${esc(scope)}</div></header>
773
+ <main>
774
+ <section><h2>Rules in force</h2>\${renderRulesHtml(rules)}</section>
775
+ <section><h2>Pending approvals</h2><div id="holds">\${renderHoldsHtml()}</div></section>
776
+ <section><h2>Recent decisions</h2><div id="log">\${renderLogHtml()}</div></section>
777
+ </main>
778
+ <script>
779
+ async function refresh() {
780
+ const r = await fetch('/state').then((x) => x.json());
781
+ document.getElementById('holds').innerHTML = r.holdsHtml;
782
+ document.getElementById('log').innerHTML = r.logHtml;
783
+ }
784
+
785
+ // --- Add-a-rule form: predicates + field types come from the SAME catalog the gate itself
786
+ // validates against (served at /catalog), so this form can never offer something invalid. ---
787
+ let CATALOG = [];
788
+ function fieldInputHtml(f) {
789
+ const id = 'af_' + f.key;
790
+ if (f.type === 'enum') {
791
+ return '<label>' + f.description + '<select id="' + id + '" data-key="' + f.key + '" data-type="' + f.type + '">' +
792
+ (f.options || []).map((o) => '<option value="' + o + '">' + o + '</option>').join('') + '</select></label>';
793
+ }
794
+ return '<label>' + f.description + (f.type === 'string[]' ? ' (comma-separated)' : '') +
795
+ '<input id="' + id + '" data-key="' + f.key + '" data-type="' + f.type + '" ' + (f.type === 'number' ? 'type="number"' : '') + ' /></label>';
796
+ }
797
+ function renderAddFields() {
798
+ const spec = CATALOG.find((s) => s.predicate === document.getElementById('addPredicate').value);
799
+ document.getElementById('addDesc').textContent = spec ? spec.description : '';
800
+ document.getElementById('addFields').innerHTML = spec ? spec.config.map(fieldInputHtml).join('') : '';
801
+ }
802
+ fetch('/catalog').then((r) => r.json()).then((specs) => {
803
+ CATALOG = specs;
804
+ document.getElementById('addPredicate').innerHTML = specs.map((s) => '<option value="' + s.predicate + '">' + s.label + '</option>').join('');
805
+ renderAddFields();
806
+ });
807
+ document.getElementById('addPredicate').addEventListener('change', renderAddFields);
808
+
809
+ document.addEventListener('click', async (e) => {
810
+ if (e.target.matches('.approve,.deny')) {
811
+ const id = e.target.dataset.id, verb = e.target.classList.contains('approve') ? 'approve' : 'deny';
812
+ await fetch('/holds/' + id + '/' + verb, { method: 'POST' });
813
+ refresh();
814
+ }
815
+ if (e.target.id === 'save') {
816
+ const mandateInputs = [...document.querySelectorAll('input[data-kind="mandate"]')];
817
+ const atomInputs = [...document.querySelectorAll('input[data-kind="atom"]')];
818
+ const edits = {
819
+ mandate: mandateInputs.map((i) => ({ idx: Number(i.dataset.idx), value: i.value })),
820
+ atoms: atomInputs.map((i) => ({ mid: i.dataset.mid, aid: i.dataset.aid, key: i.dataset.key, value: i.value })),
821
+ };
822
+ const res = await fetch('/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(edits) });
823
+ document.getElementById('saveMsg').textContent = res.ok ? 'saved — takes effect on the next decision' : 'save failed';
824
+ }
825
+ if (e.target.matches('.delmol')) {
826
+ if (!confirm('Remove this rule?')) return;
827
+ const res = await fetch('/rules/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: e.target.dataset.id }) });
828
+ if (res.ok) location.reload(); else document.getElementById('saveMsg').textContent = 'delete failed';
829
+ }
830
+ if (e.target.id === 'addRuleBtn') {
831
+ const predicate = document.getElementById('addPredicate').value;
832
+ const config = {};
833
+ for (const el of document.querySelectorAll('#addFields [data-key]')) config[el.dataset.key] = el.value;
834
+ const body = {
835
+ predicate, config,
836
+ decision: document.getElementById('addDecision').value,
837
+ name: document.getElementById('addName').value || undefined,
838
+ reasonCode: document.getElementById('addReasonCode').value || undefined,
839
+ };
840
+ const res = await fetch('/rules/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
841
+ const r = await res.json();
842
+ if (res.ok) location.reload();
843
+ else document.getElementById('addMsg').textContent = r.error || 'could not add rule';
844
+ }
845
+ });
846
+ setInterval(refresh, 3000);
847
+ </script></body></html>\`;
848
+ }
849
+
850
+ // A number-looking string edit becomes a number (spend caps etc.); a comma-list becomes an
851
+ // array (merchants/allow-lists); anything else stays a string.
852
+ function coerce(raw) {
853
+ if (raw.includes(',')) return raw.split(',').map((s) => s.trim()).filter(Boolean);
854
+ if (raw.trim() !== '' && !Number.isNaN(Number(raw))) return Number(raw);
855
+ return raw;
856
+ }
857
+
858
+ function applyEdits(rules, edits) {
859
+ const m = (rules.mandate?.permission || [])[0];
860
+ for (const e of edits.mandate || []) {
861
+ if (m?.constraint?.[e.idx]) m.constraint[e.idx].rightOperand = coerce(e.value);
862
+ }
863
+ for (const e of edits.atoms || []) {
864
+ for (const s of rules.sops || []) {
865
+ const mol = (s.document?.molecules || []).find((x) => x.id === e.mid);
866
+ const atom = mol?.atoms?.find((a) => a.id === e.aid);
867
+ if (atom) atom.config[e.key] = coerce(e.value);
868
+ }
869
+ }
870
+ return rules;
871
+ }
872
+
873
+ /** Builds one molecule from the add-rule form, validates it with the SAME validator the
874
+ * hosted platform runs, and appends it to the first SOP document (there is exactly one in
875
+ * a harness project). Single-atom, combinator "all" — the same "sugar" shape --config's
876
+ * "rules" array compiles to, so a harness rules file and a --config file stay interchangeable. */
877
+ function addRule({ predicate, config, decision, name, reasonCode }) {
878
+ const spec = ATOM_SPECS.find((s) => s.predicate === predicate);
879
+ if (!spec) return { ok: false, error: \`unknown predicate "\${predicate}"\` };
880
+ const cfg = {};
881
+ for (const f of spec.config) {
882
+ const raw = config?.[f.key];
883
+ if (raw === undefined || raw === '') { if (f.required) return { ok: false, error: \`"\${f.description}" is required\` }; continue; }
884
+ cfg[f.key] = coerceField(raw, f.type);
885
+ }
886
+ const molecule = {
887
+ id: \`\${predicate}-\${Date.now().toString(36)}\`,
888
+ name: name || spec.label,
889
+ combinator: 'all',
890
+ atoms: [{ id: 'a1', predicate, config: cfg }],
891
+ decision,
892
+ reasonCode: reasonCode || \`\${predicate.toUpperCase().replace(/-/g, '_')}_\${String(decision).toUpperCase()}\`,
893
+ };
894
+ const check = validateMolecules([molecule]);
895
+ if (!check.ok) return { ok: false, error: check.issues.map((i) => i.message).join('; ') };
896
+ const rules = readRules();
897
+ if (rules.error) return { ok: false, error: rules.error };
898
+ if (!rules.sops?.[0]) rules.sops = [{ standardKey: 'sop', document: { molecules: [] } }];
899
+ rules.sops[0].document.molecules = [...(rules.sops[0].document.molecules || []), molecule];
900
+ writeRules(rules);
901
+ log({ type: 'rule-added', id: molecule.id, predicate, decision });
902
+ return { ok: true, molecule };
903
+ }
904
+
905
+ function deleteRule(id) {
906
+ const rules = readRules();
907
+ if (rules.error) return { ok: false, error: rules.error };
908
+ for (const s of rules.sops || []) {
909
+ if (!s.document?.molecules) continue;
910
+ s.document.molecules = s.document.molecules.filter((mo) => mo.id !== id);
911
+ }
912
+ writeRules(rules);
913
+ log({ type: 'rule-deleted', id });
914
+ return { ok: true };
915
+ }
916
+
917
+ const server = http.createServer(async (req, res) => {
918
+ const path = req.url.split('?')[0];
919
+ const send = (status, body, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(type === 'application/json' ? JSON.stringify(body) : body); };
920
+ if (req.method === 'GET' && path === '/') return send(200, page(), 'text/html; charset=utf-8');
921
+ if (req.method === 'GET' && path === '/state') return send(200, { holdsHtml: renderHoldsHtml(), logHtml: renderLogHtml() });
922
+ if (req.method === 'GET' && path === '/catalog') return send(200, ATOM_SPECS);
923
+ if (req.method === 'POST' && path === '/rules') {
924
+ let body = ''; req.on('data', (c) => (body += c));
925
+ req.on('end', () => {
926
+ try { writeRules(applyEdits(readRules(), JSON.parse(body || '{}'))); return send(200, { ok: true }); }
927
+ catch (e) { return send(500, { ok: false, error: String(e?.message ?? e) }); }
928
+ });
929
+ return;
930
+ }
931
+ if (req.method === 'POST' && path === '/rules/add') {
932
+ let body = ''; req.on('data', (c) => (body += c));
933
+ req.on('end', () => {
934
+ try {
935
+ const r = addRule(JSON.parse(body || '{}'));
936
+ return send(r.ok ? 200 : 400, r);
937
+ } catch (e) { return send(500, { ok: false, error: String(e?.message ?? e) }); }
938
+ });
939
+ return;
940
+ }
941
+ if (req.method === 'POST' && path === '/rules/delete') {
942
+ let body = ''; req.on('data', (c) => (body += c));
943
+ req.on('end', () => {
944
+ try {
945
+ const { id } = JSON.parse(body || '{}');
946
+ return send(200, deleteRule(id));
947
+ } catch (e) { return send(500, { ok: false, error: String(e?.message ?? e) }); }
948
+ });
949
+ return;
950
+ }
951
+ const m = /^\\/holds\\/([^/]+)\\/(approve|deny)$/.exec(path);
952
+ if (req.method === 'POST' && m) {
953
+ const h = holds.get(m[1]);
954
+ if (h && h.status === 'pending') {
955
+ h.status = m[2] === 'approve' ? 'approved' : 'denied';
956
+ log({ type: h.status, id: h.id, action: h.action });
957
+ h.resolve(h.status === 'approved');
958
+ }
959
+ return send(200, { ok: true });
960
+ }
961
+ send(404, { error: 'not found' });
962
+ });
963
+ server.listen(port, host);
964
+ return { holdForApproval, logDecision, url: \`http://\${host}:\${port}\`, close: () => server.close() };
965
+ }
966
+ `;
967
+ }
968
+
969
+ function harnessIndexFile(scope, perTxnMax, port) {
970
+ const under = Math.max(1, Math.round(perTxnMax * 0.5));
971
+ const over = Math.round(perTxnMax + 100);
972
+ return `// index.mjs — your agent, governed entirely on this machine. No account, no network call
973
+ // for a decision: guardToolLocal() decides allow/block/escalate against ./metamynd-rules.json
974
+ // (edit it directly, or at the dashboard). An escalate is held here for YOU to approve —
975
+ // there is no hosted owner queue in this mode, so open the dashboard URL printed below.
976
+ import { readFileSync } from 'node:fs';
977
+ import { createGuard } from '${GUARD_PKG}';
978
+ import { startDashboard } from './harness-server.mjs';
979
+
980
+ const config = JSON.parse(readFileSync('./agent.metamynd.json', 'utf8'));
981
+ // 'local' as the api: guardToolLocal() never calls it. Kept required-but-unused rather than
982
+ // silently accepting no api at all, so a later switch to a real gate is one field, not a rewrite.
983
+ const guard = createGuard({ api: 'local', agentDid: config.agentDid, agentKey: config.agentKey });
984
+
985
+ const dashboard = startDashboard({
986
+ port: ${port},
987
+ agentDid: config.agentDid,
988
+ scope: '${scope}',
989
+ rulesPath: './metamynd-rules.json',
990
+ logPath: './metamynd-harness.log.jsonl',
991
+ });
992
+ console.log('\\x1b[2m dashboard: ' + dashboard.url + ' (rules, approvals, decision log)\\x1b[0m\\n');
993
+
994
+ // Reads the CURRENT rules file fresh every call — editing it (by hand, or at the dashboard)
995
+ // takes effect on the next decision, no restart, matching the "no redeploy" experience the
996
+ // hosted platform gives you.
997
+ const getBundle = () => JSON.parse(readFileSync('./metamynd-rules.json', 'utf8'));
998
+
999
+ // --- Your real tool. Replace the body with your actual implementation. ---
1000
+ async function bookFlight(args) {
1001
+ return { pnr: 'PNR-DEMO', ...args };
1002
+ }
1003
+
1004
+ // --- The GATED version. Register THIS with your agent instead of the raw handler. ---
1005
+ const gatedBookFlight = guard.guardToolLocal(
1006
+ '${scope}', // = your mandate scope
1007
+ bookFlight,
1008
+ (a) => ({ // map tool args → gate inputs
1009
+ amount: a.amount,
1010
+ merchant: a.merchant,
1011
+ context: { tool: 'book-flight', riskLevel: a.riskLevel ?? 'low' },
1012
+ }),
1013
+ getBundle,
1014
+ );
1015
+
1016
+ // --- A tool the agent was NEVER granted. Wrapping it is the demonstration: there is no
1017
+ // --- rule anywhere forbidding this. The mandate simply never mentioned the action.
1018
+ async function raiseOwnLimit(args) {
1019
+ return { updated: true, ...args }; // never runs, and that is the point
1020
+ }
1021
+
1022
+ const gatedRaiseOwnLimit = guard.guardToolLocal(
1023
+ 'permissions.update', // an action NOT in the mandate
1024
+ raiseOwnLimit,
1025
+ (a) => ({ amount: a.amount, merchant: a.merchant, context: { tool: 'permissions-update' } }),
1026
+ getBundle,
1027
+ );
1028
+
1029
+ const dim = (t) => '\\x1b[2m' + t + '\\x1b[0m';
1030
+ const bold = (t) => '\\x1b[1m' + t + '\\x1b[0m';
1031
+ const rule = (n) => ' ' + '-'.repeat(n);
1032
+
1033
+ const WHY = {
1034
+ AUTHORIZED: 'inside the mandate and under the SOP spend cap',
1035
+ SOP_SPEND_CAP: 'your SOP caps a single transaction at $${perTxnMax}',
1036
+ RISK_REVIEW: 'your SOP sends high-risk actions to a human first',
1037
+ MERCHANT_NOT_ALLOWED: 'the mandate lists which merchants this agent may pay',
1038
+ NO_PERMISSION_FOR_ACTION: 'the mandate never granted this action - at any amount',
1039
+ NO_MANDATE: 'there is no mandate for this action at all',
1040
+ };
1041
+
1042
+ async function attempt(n, intent, action, args, tool = gatedBookFlight) {
1043
+ console.log('');
1044
+ console.log(bold(' Step ' + n + ' of 4') + ' - ' + intent);
1045
+ console.log(dim(' evaluating locally, no network call...'));
1046
+ try {
1047
+ const r = await tool(args);
1048
+ console.log('\\x1b[32m ALLOWED\\x1b[0m your tool ran and returned ' + (r.pnr ?? 'ok'));
1049
+ console.log(dim(' ' + WHY.AUTHORIZED));
1050
+ } catch (e) {
1051
+ const g = e.governance ?? {};
1052
+ const why = WHY[g.reasonCode] ?? e.message;
1053
+ if (g.decision === 'escalate') {
1054
+ console.log('\\x1b[33m ESCALATED\\x1b[0m held for you to approve - ' + g.reasonCode);
1055
+ console.log(dim(' ' + why));
1056
+ const { id, promise } = dashboard.holdForApproval(action, args, g);
1057
+ console.log(dim(' open ' + dashboard.url + ' and click Approve/Deny (hold ' + id.slice(0, 8) + '…)'));
1058
+ const timeout = new Promise((r) => setTimeout(() => r('timeout'), 20000));
1059
+ const result = await Promise.race([promise, timeout]);
1060
+ if (result === 'timeout') console.log(dim(' still pending after 20s — this demo will not wait forever; the dashboard will, run it again to check.'));
1061
+ else console.log(dim(' ' + (result ? 'approved.' : 'denied.')));
1062
+ } else {
1063
+ console.log('\\x1b[31m BLOCKED\\x1b[0m ' + (g.reasonCode ?? 'refused'));
1064
+ console.log(dim(' ' + why));
1065
+ console.log(dim(' your tool never ran - the gate refused before execution.'));
1066
+ }
1067
+ dashboard.logDecision(action, args, g);
1068
+ }
1069
+ }
1070
+
1071
+ console.log('');
1072
+ console.log(bold(' What this simulation shows'));
1073
+ console.log('');
1074
+ console.log(' Same idea as the hosted platform, running entirely on this machine: an agent');
1075
+ console.log(' should not be the thing that decides what it is allowed to do. Three attempts');
1076
+ console.log(' take the SAME code path and produce three different outcomes. The fourth asks');
1077
+ console.log(' for something never granted at all - the one a prompt could not have stopped,');
1078
+ console.log(' because the decision is not made inside your program, and not on a server either.');
1079
+ console.log('');
1080
+ console.log(dim(' scope ${scope}'));
1081
+ console.log(dim(' cap $${perTxnMax} per transaction, from ./metamynd-rules.json'));
1082
+
1083
+ console.log('');
1084
+ console.log(rule(66));
1085
+ await attempt(1, 'a $${under} booking, low risk. Expected to pass.', '${scope}', { amount: ${under}, merchant: 'skyward-air', riskLevel: 'low' });
1086
+ await attempt(2, 'a $${over} booking, deliberately over the cap.', '${scope}', { amount: ${over}, merchant: 'skyward-air', riskLevel: 'low' });
1087
+ await attempt(3, 'a $${under} booking, but flagged high risk.', '${scope}', { amount: ${under}, merchant: 'skyward-air', riskLevel: 'high' });
1088
+ await attempt(4, 'the agent stops booking flights and asks to raise its OWN limit.', 'permissions.update', { amount: 100000, merchant: 'skyward-air' }, gatedRaiseOwnLimit);
1089
+ console.log('');
1090
+ console.log(rule(66));
1091
+
1092
+ console.log('');
1093
+ console.log(bold(' What this proved'));
1094
+ console.log('');
1095
+ console.log(dim(' - one code path, three outcomes, decided with zero network calls.'));
1096
+ console.log(dim(' - step 4 needed no rule to stop it. The agent could not widen its own'));
1097
+ console.log(dim(' authority, because it cannot name an action nobody delegated to it.'));
1098
+ console.log(dim(' - the blocked call never reached your tool at all.'));
1099
+ console.log(dim(' - every decision is in ./metamynd-harness.log.jsonl - yours, locally.'));
1100
+ console.log('');
1101
+ console.log(' Edit ./metamynd-rules.json (or the dashboard) and run again - the outcome');
1102
+ console.log(dim(' changes. This file does not. That is the point.'));
1103
+ console.log('');
1104
+ console.log(dim(' Ready for more than one machine, a queue someone else can approve from,'));
1105
+ console.log(dim(' anchored evidence, or KYC/KYB-backed identity? That is the hosted platform -'));
1106
+ console.log(dim(' same guardTool() call, same rules shape, drop --harness and provision there.'));
1107
+ console.log('');
1108
+ dashboard.close();
1109
+ `;
1110
+ }
1111
+
1112
+ function harnessPackageJson(slug) {
1113
+ return JSON.stringify(
1114
+ {
1115
+ name: slug,
1116
+ version: '0.1.0',
1117
+ private: true,
1118
+ type: 'module',
1119
+ scripts: { start: 'node index.mjs' },
1120
+ dependencies: { [GUARD_PKG]: GUARD_VERSION },
1121
+ },
1122
+ null,
1123
+ 2,
1124
+ ) + '\n';
1125
+ }
1126
+
1127
+ function harnessReadme(slug, scope, port) {
1128
+ return `# ${slug}
1129
+
1130
+ A free, local MetaMynd/AgentSafe governance harness — your own rules, your own identity,
1131
+ decided entirely on this machine. No account, no network call for a decision.
1132
+
1133
+ ## Run
1134
+
1135
+ \`\`\`bash
1136
+ npm install
1137
+ npm start
1138
+ \`\`\`
1139
+
1140
+ You should see an ALLOW, a BLOCK (over the per-transaction cap), an ESCALATE (high risk —
1141
+ open the dashboard to approve it), and a BLOCK (an action outside the mandate entirely).
1142
+
1143
+ ## Files
1144
+
1145
+ - \`agent.metamynd.json\` — your local identity (a generated Ed25519 keypair; \`agentDid\` is a
1146
+ local label, not an anchored/verifiable one). **Contains a secret key — never commit it.**
1147
+ - \`metamynd-rules.json\` — your rules: the mandate (scope + spend limits) and SOP (extra checks).
1148
+ Edit it directly, or at the dashboard. Reloaded on every decision — no restart.
1149
+ - \`metamynd-harness.log.jsonl\` — every decision this agent made, append-only.
1150
+ - \`harness-server.mjs\` — the local dashboard (port ${port}): rules, pending approvals, decision log.
1151
+ - \`index.mjs\` — wraps a tool with \`guard.guardToolLocal(...)\`; the tool only runs when the
1152
+ LOCAL rules permit it.
1153
+
1154
+ ## What this is not
1155
+
1156
+ No anchored/verifiable identity, no cross-party trust, no evidence anyone but you can audit,
1157
+ no dashboard reachable when this machine is off, no owner queue someone else can approve from.
1158
+ That's the hosted platform (\`npx create-metamynd-agent\`, without \`--harness\`) — same
1159
+ \`guardTool()\` call, same rules shape, so upgrading later is a config change, not a rewrite.
1160
+ `;
1161
+ }
1162
+
1163
+ /** --harness: no login, no KYB, no network — author identity + rules locally and scaffold. */
1164
+ async function runHarness(args) {
1165
+ const interactive = !args.yes && process.stdin.isTTY;
1166
+ const rl = interactive ? makeRl() : null;
1167
+ const pick = async (flag, prompt, def) => {
1168
+ const fromFlag = typeof args[flag] === 'string' ? args[flag] : undefined;
1169
+ if (fromFlag !== undefined) return fromFlag;
1170
+ if (!interactive) return def;
1171
+ return ask(rl, prompt, def);
1172
+ };
1173
+
1174
+ const fileConfig = typeof args.config === 'string' ? loadConfigFile(args.config) : null;
1175
+ if (fileConfig) console.log(` ${c.green('✓')} loaded policy config ${c.dim(args.config)}`);
1176
+
1177
+ const name = await pick('name', 'Agent name', fileConfig?.name ?? 'Local Agent');
1178
+ const scope = await pick('scope', 'Mandate scope (governed action)', fileConfig?.scope ?? 'flight-purchase');
1179
+ const perTxnMax = Number(await pick('per-txn-max', 'Per-transaction cap', String(fileConfig?.perTxnMax ?? '500'))) || 500;
1180
+ const maxAmount = Number(await pick('max-amount', 'Total mandate budget', String(fileConfig?.maxAmount ?? '10000'))) || 10000;
1181
+ const currency = (await pick('currency', 'Currency', fileConfig?.currency ?? 'USD')) || 'USD';
1182
+ const merchantsRaw = await pick('merchants', 'Allowed merchants (comma-sep, blank = any)', Array.isArray(fileConfig?.merchants) ? fileConfig.merchants.join(',') : '');
1183
+ const merchants = String(merchantsRaw).split(',').map((s) => s.trim()).filter(Boolean);
1184
+ const port = Number(args.port) || 4400;
1185
+ const slug = slugify(name);
1186
+ const outDir = resolve(String(args.out || (interactive ? await ask(rl, 'Output directory', `./${slug}`) : `./${slug}`)));
1187
+ rl?.close();
1188
+
1189
+ assertScaffoldTarget(outDir, !!args.force);
1190
+ console.log(c.dim('\n → generating a local identity (Ed25519, this machine only) …'));
1191
+ const { publicKeyHex, privateKeyHex } = generateAgentKeypair();
1192
+ const agentDid = harnessAgentDid(publicKeyHex);
1193
+ console.log(` ${c.green('✓')} local agent ${c.b(agentDid)}`);
1194
+
1195
+ const sopFields = configFileSopFields(fileConfig);
1196
+ const sopDocument = sopFields.sop ? sopFields.sop.documentJson : harnessDefaultSop(perTxnMax);
1197
+ if (sopFields.sop) console.log(` ${c.green('✓')} compiled ${sopDocument.molecules.length} rule(s) from the config file`);
1198
+ const mandate = harnessMandate({ scope, currency, maxAmount, perTxnMax, merchants });
1199
+
1200
+ console.log(`\n ${c.b('Scaffolding')} ${c.dim(outDir)}`);
1201
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
1202
+ writeFileSafe(outDir, 'agent.metamynd.json', JSON.stringify({ agentDid, agentKey: privateKeyHex, mode: 'harness' }, null, 2) + '\n', !!args.force);
1203
+ writeFileSafe(outDir, 'metamynd-rules.json', harnessRulesFile(mandate, sopDocument), !!args.force);
1204
+ writeFileSafe(outDir, 'harness-server.mjs', harnessServerFile(), !!args.force);
1205
+ writeFileSafe(outDir, 'index.mjs', harnessIndexFile(scope, perTxnMax, port), !!args.force);
1206
+ writeFileSafe(outDir, 'package.json', harnessPackageJson(slug), !!args.force);
1207
+ writeFileSafe(outDir, '.gitignore', gitignore(), !!args.force);
1208
+ writeFileSafe(outDir, 'README.md', harnessReadme(slug, scope, port), !!args.force);
1209
+
1210
+ const rel = outDir.replace(resolve('.'), '.').replace(/\\/g, '/');
1211
+ console.log(`\n${c.green(c.b(' ✓ Done.'))} Your local governance harness is ready.\n`);
1212
+ console.log(` ${c.dim('Free, local, no account. Not the hosted platform — see README#what-this-is-not.')}\n`);
1213
+ console.log(` Next:`);
1214
+ console.log(c.cyan(` cd ${rel}`));
1215
+ console.log(c.cyan(` npm install`));
1216
+ console.log(c.cyan(` npm start`) + c.dim(' → ALLOW · BLOCK (over cap) · ESCALATE (approve at the dashboard) · BLOCK (ungranted action)\n'));
1217
+ console.log(c.dim(` Edit ./metamynd-rules.json any time (by hand, or at http://127.0.0.1:${port}) — no redeploy.\n`));
1218
+ }
1219
+
458
1220
  // ---------- delegated issuance (#6) ----------
459
1221
  async function apiGet(base, path, { claimToken } = {}) {
460
1222
  let res;
@@ -581,12 +1343,21 @@ async function main() {
581
1343
 
582
1344
  console.log(`\n${c.b(c.cyan(' create-metamynd-agent'))} ${c.dim('— provision a governed agent in ~2 minutes')}\n`);
583
1345
 
1346
+ // --harness: skip login + provisioning + the network entirely.
1347
+ if (args.harness) { await runHarness(args); return; }
584
1348
  // --sandbox: skip login + provisioning entirely.
585
1349
  if (args.sandbox) { await runSandbox(args); return; }
586
1350
  // Delegated issuance (#6): request an agent for an owner's org / claim it once approved.
587
1351
  if (args.request) { await runRequest(args); return; }
588
1352
  if (args.claim) { await runClaim(args); return; }
589
1353
 
1354
+ // --config: a JSON policy file. Its fields become the DEFAULT for each prompt/flag below —
1355
+ // an explicit CLI flag still wins (e.g. `--config base.json --name "Other Bot"`), and
1356
+ // env vars still win over the file for login credentials specifically (never put a
1357
+ // password in a policy file that gets checked into source control).
1358
+ const fileConfig = typeof args.config === 'string' ? loadConfigFile(args.config) : null;
1359
+ if (fileConfig) console.log(` ${c.green('✓')} loaded policy config ${c.dim(args.config)}`);
1360
+
590
1361
  const interactive = !args.yes && process.stdin.isTTY;
591
1362
  const rl = interactive ? makeRl() : null;
592
1363
  const pick = async (flag, envVar, prompt, def) => {
@@ -618,13 +1389,16 @@ async function main() {
618
1389
  if (!token) { rl?.close(); fail('Login succeeded but no access token was returned.'); }
619
1390
  console.log(` ${c.green('✓')} authenticated as ${email}`);
620
1391
 
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)', '');
1392
+ // 2. Agent details — a --config file's fields are the default at every prompt/flag below.
1393
+ const name = await pick('name', null, 'Agent name', fileConfig?.name ?? 'Support Bot');
1394
+ const scope = await pick('scope', null, 'Mandate scope (governed action)', fileConfig?.scope ?? 'flight-purchase');
1395
+ const perTxnMax = Number(await pick('per-txn-max', null, 'Per-transaction cap', String(fileConfig?.perTxnMax ?? '500'))) || 500;
1396
+ const maxAmount = Number(await pick('max-amount', null, 'Total mandate budget', String(fileConfig?.maxAmount ?? '10000'))) || 10000;
1397
+ const currency = (await pick('currency', null, 'Currency', fileConfig?.currency ?? 'USD')) || 'USD';
1398
+ const merchantsRaw = await pick(
1399
+ 'merchants', null, 'Allowed merchants (comma-sep, blank = any)',
1400
+ Array.isArray(fileConfig?.merchants) ? fileConfig.merchants.join(',') : '',
1401
+ );
628
1402
  const merchants = String(merchantsRaw).split(',').map((s) => s.trim()).filter(Boolean);
629
1403
 
630
1404
  // BYOK: --byok generates a keypair on THIS machine (MetaMynd never sees the private key). An
@@ -642,9 +1416,13 @@ async function main() {
642
1416
 
643
1417
  rl?.close();
644
1418
 
645
- // 3. Provision (one call)
1419
+ // 3. Provision (one call) — a --config file's `rules`/`molecules`/`rulePack` become the
1420
+ // starter SOP; with none of those, provisionGuardConfig falls back to its own default
1421
+ // (a per-transaction cap + high-risk review), same as before --config existed.
1422
+ const sopFields = configFileSopFields(fileConfig);
1423
+ if (sopFields.sop) console.log(` ${c.green('✓')} compiled ${sopFields.sop.documentJson.molecules.length} rule(s) from the config file`);
646
1424
  console.log(c.dim(`\n → provisioning "${name}" (identity + mandate + SOP + Standards) …`));
647
- const body = { name, scope, currency, maxAmount, perTxnMax, merchants, ...(publicKey ? { publicKey } : {}) };
1425
+ const body = { name, scope, currency, maxAmount, perTxnMax, merchants, ...(publicKey ? { publicKey } : {}), ...sopFields };
648
1426
  const provisioned = await apiPost(base, '/onboarding/agent', body, token);
649
1427
  const config = provisioned?.data;
650
1428
  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.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.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-metamynd-agent": "index.mjs"