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.
- package/README.md +18 -0
- package/index.mjs +180 -12
- 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,
|
|
603
|
-
//
|
|
604
|
-
//
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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;
|
|
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
|
-
|
|
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.
|
|
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": {
|