create-metamynd-agent 0.3.4 → 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.
- package/README.md +79 -3
- package/index.mjs +632 -10
- package/package.json +8 -3
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.
|
|
20
|
-
the
|
|
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
|
-
| `--
|
|
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
|
}
|
|
@@ -429,7 +501,11 @@ function scaffoldProject({ outDir, config, slug, scope, perTxnMax, sandbox, forc
|
|
|
429
501
|
console.log(` Next:`);
|
|
430
502
|
console.log(c.cyan(` cd ${rel}`));
|
|
431
503
|
console.log(c.cyan(` npm install`));
|
|
432
|
-
|
|
504
|
+
// The example runs FOUR attempts. This summary promised three, so the one carrying the
|
|
505
|
+
// whole argument — the agent asking to raise its own limit — arrived unannounced.
|
|
506
|
+
console.log(c.cyan(` npm start`) + c.dim(' → ALLOW · BLOCK (over cap) · ESCALATE (high risk)'));
|
|
507
|
+
console.log(c.dim(' · BLOCK (the agent asking to raise its OWN limit)\n'));
|
|
508
|
+
console.log(c.cyan(` npm test`) + c.dim(' → assert it CANNOT exceed its mandate. Put this in CI.\n'));
|
|
433
509
|
console.log(c.dim(` Change the rules any time in the dashboard (Legal Entity → SOPs) — no redeploy.\n`));
|
|
434
510
|
}
|
|
435
511
|
|
|
@@ -451,6 +527,536 @@ async function runSandbox(args) {
|
|
|
451
527
|
scaffoldProject({ outDir, config, slug: 'metamynd-sandbox', scope, perTxnMax, sandbox: true, force: !!args.force });
|
|
452
528
|
}
|
|
453
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
+
|
|
454
1060
|
// ---------- delegated issuance (#6) ----------
|
|
455
1061
|
async function apiGet(base, path, { claimToken } = {}) {
|
|
456
1062
|
let res;
|
|
@@ -577,12 +1183,21 @@ async function main() {
|
|
|
577
1183
|
|
|
578
1184
|
console.log(`\n${c.b(c.cyan(' create-metamynd-agent'))} ${c.dim('— provision a governed agent in ~2 minutes')}\n`);
|
|
579
1185
|
|
|
1186
|
+
// --harness: skip login + provisioning + the network entirely.
|
|
1187
|
+
if (args.harness) { await runHarness(args); return; }
|
|
580
1188
|
// --sandbox: skip login + provisioning entirely.
|
|
581
1189
|
if (args.sandbox) { await runSandbox(args); return; }
|
|
582
1190
|
// Delegated issuance (#6): request an agent for an owner's org / claim it once approved.
|
|
583
1191
|
if (args.request) { await runRequest(args); return; }
|
|
584
1192
|
if (args.claim) { await runClaim(args); return; }
|
|
585
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
|
+
|
|
586
1201
|
const interactive = !args.yes && process.stdin.isTTY;
|
|
587
1202
|
const rl = interactive ? makeRl() : null;
|
|
588
1203
|
const pick = async (flag, envVar, prompt, def) => {
|
|
@@ -614,13 +1229,16 @@ async function main() {
|
|
|
614
1229
|
if (!token) { rl?.close(); fail('Login succeeded but no access token was returned.'); }
|
|
615
1230
|
console.log(` ${c.green('✓')} authenticated as ${email}`);
|
|
616
1231
|
|
|
617
|
-
// 2. Agent details
|
|
618
|
-
const name = await pick('name', null, 'Agent name', 'Support Bot');
|
|
619
|
-
const scope = await pick('scope', null, 'Mandate scope (governed action)', 'flight-purchase');
|
|
620
|
-
const perTxnMax = Number(await pick('per-txn-max', null, 'Per-transaction cap', '500')) || 500;
|
|
621
|
-
const maxAmount = Number(await pick('max-amount', null, 'Total mandate budget', '10000')) || 10000;
|
|
622
|
-
const currency = (await pick('currency', null, 'Currency', 'USD')) || 'USD';
|
|
623
|
-
const merchantsRaw = await pick(
|
|
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
|
+
);
|
|
624
1242
|
const merchants = String(merchantsRaw).split(',').map((s) => s.trim()).filter(Boolean);
|
|
625
1243
|
|
|
626
1244
|
// BYOK: --byok generates a keypair on THIS machine (MetaMynd never sees the private key). An
|
|
@@ -638,9 +1256,13 @@ async function main() {
|
|
|
638
1256
|
|
|
639
1257
|
rl?.close();
|
|
640
1258
|
|
|
641
|
-
// 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`);
|
|
642
1264
|
console.log(c.dim(`\n → provisioning "${name}" (identity + mandate + SOP + Standards) …`));
|
|
643
|
-
const body = { name, scope, currency, maxAmount, perTxnMax, merchants, ...(publicKey ? { publicKey } : {}) };
|
|
1265
|
+
const body = { name, scope, currency, maxAmount, perTxnMax, merchants, ...(publicKey ? { publicKey } : {}), ...sopFields };
|
|
644
1266
|
const provisioned = await apiPost(base, '/onboarding/agent', body, token);
|
|
645
1267
|
const config = provisioned?.data;
|
|
646
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.
|
|
4
|
-
"description": "Scaffold a MetaMynd/AgentSafe-governed AI agent in one command
|
|
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"
|
|
@@ -28,8 +28,13 @@
|
|
|
28
28
|
"author": "MetaMynd",
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"homepage": "https://metamynd.ai/developers/quickstart",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/Metamynd/agentsafe-guard.git",
|
|
34
|
+
"directory": "packages/create-metamynd-agent"
|
|
35
|
+
},
|
|
31
36
|
"bugs": {
|
|
32
|
-
"url": "https://
|
|
37
|
+
"url": "https://github.com/Metamynd/agentsafe-guard/issues"
|
|
33
38
|
},
|
|
34
39
|
"publishConfig": {
|
|
35
40
|
"access": "public"
|