create-metamynd-agent 0.5.0 → 0.6.2

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 +18 -0
  2. package/index.mjs +180 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -41,9 +41,27 @@ rewrite**: the exact same `guardTool()` call your harness project already makes
41
41
  `bundleUrl`/`api` pointed at a real gate (provision normally, without `--harness`) instead of a rules
42
42
  file you authored yourself. Nothing about how you wrote your agent changes.
43
43
 
44
+ It is also **not a separate enforcement boundary**, and this matters more than the list above.
45
+ `guardToolLocal()` is a cooperative library your own process embeds — call the raw handler directly
46
+ instead of the guarded one and nothing stops you, because there is no second party in the loop to
47
+ disagree with you. Confirmed by direct testing: a bypass attempt (skip the guard, call the tool
48
+ function underneath it) succeeds every time, structurally, not as a bug. In the hosted flow this is
49
+ what the **counterparty** is for — the MCP/tool service independently re-verifies the agent's signed
50
+ authority for itself rather than trusting that the agent's own guard ran, which is why a compromised
51
+ or dishonest agent still can't get an honest service to act (see the three-party demo at
52
+ [metamynd.ai/developers/quickstart](https://metamynd.ai/developers/quickstart)). `--harness` has no
53
+ counterparty, so it can't have that property. Use it to govern your own agent's own honest behavior
54
+ — not as a defense against an agent (or a person) that's actively trying to get around it.
55
+
44
56
  Works with `--config` too — its `rules` become the harness's starter rules file, same as the hosted
45
57
  flow. See [Policy config file](#policy-config-file---config) below.
46
58
 
59
+ The dashboard's rules panel is a real editor, not just JSON with input boxes: edit an existing
60
+ rule's values, **delete** a rule, or **add a new one** from a form (predicate + its typed config
61
+ fields + decision) driven by the same atom catalog and validator
62
+ ([`policy-core`](../agentsafe-guard/policy-core.mjs)) the hosted gate itself uses — so nothing you
63
+ add through it can be invalid. Hand-editing `metamynd-rules.json` still works too, if you prefer.
64
+
47
65
  ## Try it instantly — sandbox (no account, no KYB)
48
66
 
49
67
  ```bash
package/index.mjs CHANGED
@@ -599,12 +599,16 @@ function harnessRulesFile(mandate, sopDocument) {
599
599
 
600
600
  function harnessServerFile() {
601
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.
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.
605
606
  import http from 'node:http';
606
607
  import { readFileSync, writeFileSync, appendFileSync, existsSync, writeFileSync as wf } from 'node:fs';
607
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';
608
612
 
609
613
  const OPERATORS = { lteq: '<=', gteq: '>=', lt: '<', gt: '>', eq: '==', neq: '!=', isAnyOf: 'is any of', isNoneOf: 'is none of' };
610
614
  function renderConstraint(c) {
@@ -625,6 +629,14 @@ function renderAtom(a) {
625
629
  }
626
630
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
627
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
+
628
640
  export function startDashboard({ port = 4400, host = '127.0.0.1', agentDid, scope, rulesPath, logPath }) {
629
641
  const holds = new Map(); // id -> { id, action, args, decision, ts, status, resolve }
630
642
  if (!existsSync(logPath)) wf(logPath, '');
@@ -669,12 +681,38 @@ export function startDashboard({ port = 4400, host = '127.0.0.1', agentDid, scop
669
681
  const mandateRows = (m?.constraint || []).map((c, i) =>
670
682
  \`<div class="rule"><span class="rname">\${esc(c.leftOperand.replace(/^mm:/, ''))}</span><span class="rcond">\${esc(renderConstraint(c))}</span>
671
683
  <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>\`;
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>\`;
678
716
  }
679
717
 
680
718
  function renderHoldsHtml() {
@@ -705,12 +743,24 @@ export function startDashboard({ port = 4400, host = '127.0.0.1', agentDid, scop
705
743
  section { background:#fff; border:1px solid #e2e0eb; border-radius:10px; padding:14px 16px; margin-bottom:16px; }
706
744
  section h2 { font-size:13px; margin:0 0 10px; color:#6b6b80; text-transform:uppercase; letter-spacing:.04em; }
707
745
  .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; }
746
+ .rule:first-child { border-top:none; } .rname { font-weight:600; } .reff { font-weight:400; color:#6b6b80; font-size:11px; }
709
747
  .rcond { font:12px ui-monospace,monospace; color:#6b6b80; flex:1; }
710
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; }
711
752
  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; }
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; }
714
764
  .hold { border:1px solid #f2c46a; background:#fff8ea; border-radius:8px; padding:10px 12px; margin-bottom:8px; }
715
765
  .hold pre { font-size:11px; background:#f5f4f8; padding:8px; border-radius:6px; overflow:auto; }
716
766
  .dim { color:#6b6b80; } pre { margin:6px 0; }
@@ -731,6 +781,31 @@ async function refresh() {
731
781
  document.getElementById('holds').innerHTML = r.holdsHtml;
732
782
  document.getElementById('log').innerHTML = r.logHtml;
733
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
+
734
809
  document.addEventListener('click', async (e) => {
735
810
  if (e.target.matches('.approve,.deny')) {
736
811
  const id = e.target.dataset.id, verb = e.target.classList.contains('approve') ? 'approve' : 'deny';
@@ -747,6 +822,26 @@ document.addEventListener('click', async (e) => {
747
822
  const res = await fetch('/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(edits) });
748
823
  document.getElementById('saveMsg').textContent = res.ok ? 'saved — takes effect on the next decision' : 'save failed';
749
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
+ }
750
845
  });
751
846
  setInterval(refresh, 3000);
752
847
  </script></body></html>\`;
@@ -775,11 +870,56 @@ setInterval(refresh, 3000);
775
870
  return rules;
776
871
  }
777
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
+
778
917
  const server = http.createServer(async (req, res) => {
779
918
  const path = req.url.split('?')[0];
780
919
  const send = (status, body, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(type === 'application/json' ? JSON.stringify(body) : body); };
781
920
  if (req.method === 'GET' && path === '/') return send(200, page(), 'text/html; charset=utf-8');
782
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);
783
923
  if (req.method === 'POST' && path === '/rules') {
784
924
  let body = ''; req.on('data', (c) => (body += c));
785
925
  req.on('end', () => {
@@ -788,6 +928,26 @@ setInterval(refresh, 3000);
788
928
  });
789
929
  return;
790
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
+ }
791
951
  const m = /^\\/holds\\/([^/]+)\\/(approve|deny)$/.exec(path);
792
952
  if (req.method === 'POST' && m) {
793
953
  const h = holds.get(m[1]);
@@ -997,6 +1157,14 @@ No anchored/verifiable identity, no cross-party trust, no evidence anyone but yo
997
1157
  no dashboard reachable when this machine is off, no owner queue someone else can approve from.
998
1158
  That's the hosted platform (\`npx create-metamynd-agent\`, without \`--harness\`) — same
999
1159
  \`guardTool()\` call, same rules shape, so upgrading later is a config change, not a rewrite.
1160
+
1161
+ It is also **not a separate enforcement boundary**. \`guardToolLocal()\` (in \`index.mjs\`) is a
1162
+ cooperative library this process embeds — call the tool handler directly instead of the guarded
1163
+ one and nothing stops you, because there is no second party in the loop to disagree with you.
1164
+ That's structural, not a bug: use this harness to govern your own agent's own honest behavior,
1165
+ not as a defense against an agent (or a person) actively trying to get around it. The hosted
1166
+ platform's \`guardTool()\` doesn't have this gap, because the MCP/tool service re-verifies the
1167
+ agent's signed authority for itself instead of trusting that the agent's own guard ran.
1000
1168
  `;
1001
1169
  }
1002
1170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-metamynd-agent",
3
- "version": "0.5.0",
3
+ "version": "0.6.2",
4
4
  "description": "Scaffold a MetaMynd/AgentSafe-governed AI agent in one command — logs in, provisions the agent (identity + mandate + SOP + Standards) in a single call, writes agent.metamynd.json, and drops a runnable example. --harness scaffolds a free, local, zero-network governance harness instead.",
5
5
  "type": "module",
6
6
  "bin": {