mindforge-cc 11.4.0 → 11.5.1
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/.agent/CLAUDE.md +13 -0
- package/.agent/hooks/lib/hook-flags.js +78 -0
- package/.agent/hooks/lib/pretooluse-visible-output.js +46 -0
- package/.agent/hooks/mindforge-block-no-verify.js +552 -0
- package/.agent/hooks/mindforge-config-protection.js +144 -0
- package/.agent/hooks/run-with-flags.js +207 -0
- package/.agent/mindforge/checkpoint.md +76 -0
- package/.agent/mindforge/harness-audit.md +59 -0
- package/.agent/mindforge/instinct.md +46 -0
- package/.agent/mindforge/orch-add-feature.md +43 -0
- package/.agent/mindforge/orch-build-mvp.md +48 -0
- package/.agent/mindforge/orch-change-feature.md +45 -0
- package/.agent/mindforge/orch-fix-defect.md +43 -0
- package/.agent/mindforge/orch-refine-code.md +43 -0
- package/.claude/CLAUDE.md +13 -0
- package/.claude/commands/mindforge/checkpoint.md +76 -0
- package/.claude/commands/mindforge/execute-phase.md +47 -6
- package/.claude/commands/mindforge/harness-audit.md +59 -0
- package/.claude/commands/mindforge/instinct.md +46 -0
- package/.claude/commands/mindforge/orch-add-feature.md +43 -0
- package/.claude/commands/mindforge/orch-build-mvp.md +48 -0
- package/.claude/commands/mindforge/orch-change-feature.md +45 -0
- package/.claude/commands/mindforge/orch-fix-defect.md +43 -0
- package/.claude/commands/mindforge/orch-refine-code.md +43 -0
- package/.claude/commands/mindforge/plan-write.md +11 -0
- package/.claude/commands/mindforge/product-spec.md +76 -0
- package/.mindforge/config.json +2 -2
- package/.mindforge/engine/instincts/instinct-schema.md +17 -9
- package/.mindforge/imported-agents.jsonl +10 -0
- package/.mindforge/manifests/install-components.json +36 -0
- package/.mindforge/manifests/install-modules.json +193 -0
- package/.mindforge/manifests/install-profiles.json +57 -0
- package/.mindforge/memory/sync-manifest.json +1 -1
- package/.mindforge/personas/gan-evaluator.md +226 -0
- package/.mindforge/personas/gan-generator.md +151 -0
- package/.mindforge/personas/gan-planner.md +118 -0
- package/.mindforge/personas/harness-optimizer.md +55 -0
- package/.mindforge/personas/loop-operator.md +58 -0
- package/.mindforge/schemas/hooks.schema.json +199 -0
- package/.mindforge/schemas/install-modules.schema.json +44 -0
- package/.mindforge/schemas/install-state.schema.json +95 -0
- package/.mindforge/schemas/plugin.schema.json +75 -0
- package/.mindforge/schemas/provenance.schema.json +31 -0
- package/.mindforge/skills/agent-architecture-audit/SKILL.md +272 -0
- package/.mindforge/skills/continuous-learning/SKILL.md +16 -0
- package/.mindforge/skills/orch-pipeline/SKILL.md +284 -0
- package/.mindforge/skills/writing-plans/SKILL.md +76 -0
- package/CHANGELOG.md +120 -0
- package/MINDFORGE.md +3 -3
- package/README.md +0 -1
- package/RELEASENOTES.md +131 -0
- package/SECURITY.md +16 -0
- package/bin/autonomous/auto-runner.js +46 -5
- package/bin/autonomous/handoff-schema.js +114 -0
- package/bin/autonomous/session-guardian.sh +138 -0
- package/bin/autonomous/supervisor.js +98 -0
- package/bin/change-classifier.js +19 -5
- package/bin/dashboard/api-router.js +10 -1
- package/bin/governance/approve.js +65 -28
- package/bin/governance/config-manager.js +3 -1
- package/bin/governance/rbac-manager.js +14 -6
- package/bin/harness-audit.js +520 -0
- package/bin/hooks/instinct-capture-hook.js +16 -1
- package/bin/hooks/lib/detect-project.js +72 -0
- package/bin/installer/harness-adapter-compliance.js +321 -0
- package/bin/installer/install-manifests.js +200 -0
- package/bin/installer/install-state.js +243 -0
- package/bin/installer-core.js +1 -1
- package/bin/learning/instinct-cli.js +359 -0
- package/bin/learning/lib/ssrf-guard.js +252 -0
- package/bin/memory/eis-client.js +31 -10
- package/bin/memory/federated-sync.js +11 -2
- package/bin/memory/knowledge-capture.js +10 -1
- package/bin/memory/pillar-health-tracker.js +9 -1
- package/bin/models/llm-errors.js +79 -0
- package/bin/models/model-client.js +39 -4
- package/bin/models/ollama-provider.js +115 -0
- package/bin/models/openai-provider.js +40 -9
- package/bin/models/profiles-loader.js +147 -0
- package/bin/models/provider-registry.js +59 -0
- package/bin/review/ads-engine.js +2 -2
- package/bin/revops/market-evaluator.js +23 -2
- package/bin/revops/router-steering-v2.js +17 -2
- package/bin/security/trust-boundaries.js +20 -3
- package/bin/utils/readiness-gate.js +169 -0
- package/bin/worktree/engine.js +497 -0
- package/package.json +8 -2
- package/subagents/categories/04-quality-security/.claude-plugin/plugin.json +10 -0
- package/subagents/categories/04-quality-security/go-build-resolver.md +105 -0
- package/subagents/categories/04-quality-security/go-reviewer.md +87 -0
- package/subagents/categories/04-quality-security/python-reviewer.md +109 -0
- package/subagents/categories/04-quality-security/react-build-resolver.md +215 -0
- package/subagents/categories/04-quality-security/react-reviewer.md +167 -0
- package/subagents/categories/04-quality-security/rust-build-resolver.md +159 -0
- package/subagents/categories/04-quality-security/rust-reviewer.md +105 -0
- package/subagents/categories/04-quality-security/silent-failure-hunter.md +67 -0
- package/subagents/categories/04-quality-security/type-design-analyzer.md +58 -0
- package/subagents/categories/04-quality-security/typescript-reviewer.md +126 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MindForge — SSRF + path-traversal + id-validation guards for instinct import.
|
|
5
|
+
*
|
|
6
|
+
* Ported from ECC's instinct-cli.py (_validate_import_url / _fetch_import_url /
|
|
7
|
+
* _validate_file_path / _validate_instinct_id). Node has no stdlib IP
|
|
8
|
+
* classification (Python used `ipaddress`), so isPublicAddress is hand-written
|
|
9
|
+
* to cover IPv4 + IPv6 private/loopback/link-local/multicast/reserved/
|
|
10
|
+
* unspecified, INCLUDING IPv4-mapped IPv6 (::ffff:x) and IPv6 ULA/link-local.
|
|
11
|
+
*
|
|
12
|
+
* Layer-3 hardening OVER the donor: 3xx redirects are REJECTED (the donor's
|
|
13
|
+
* urlopen followed them, so an allowed host could 302 to a private one).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const dns = require('dns').promises;
|
|
17
|
+
const https = require('https');
|
|
18
|
+
const net = require('net');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; // 2 MB
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
24
|
+
|
|
25
|
+
// A remote instinct import is plain HTTPS, so only the standard HTTPS port is
|
|
26
|
+
// meaningful. Allowing an explicit non-standard port would let an allowed
|
|
27
|
+
// public host be used to reach an internal service co-located on it
|
|
28
|
+
// (e.g. https://public-host:6379/) — the destination port is otherwise carried
|
|
29
|
+
// through unchecked. An empty port means the URL uses the https default (443).
|
|
30
|
+
const ALLOWED_IMPORT_PORTS = new Set(['', '443']);
|
|
31
|
+
|
|
32
|
+
// System dirs an import/export path must never target (port of the py list;
|
|
33
|
+
// macOS resolves /etc -> /private/etc, so both forms are blocked).
|
|
34
|
+
const BLOCKED_PREFIXES = [
|
|
35
|
+
'/etc', '/usr', '/bin', '/sbin', '/proc', '/sys',
|
|
36
|
+
'/var/log', '/var/run', '/var/lib', '/var/spool',
|
|
37
|
+
'/private/etc', '/private/var/log', '/private/var/run', '/private/var/db',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True if `ip` is a public, routable address (NOT private/loopback/link-local/
|
|
42
|
+
* multicast/reserved/unspecified). Handles IPv4, IPv6, and IPv4-mapped IPv6.
|
|
43
|
+
*/
|
|
44
|
+
function isPublicAddress(ip) {
|
|
45
|
+
const v = net.isIP(ip);
|
|
46
|
+
if (v === 4) return isPublicV4(ip);
|
|
47
|
+
if (v === 6) {
|
|
48
|
+
// Unwrap IPv4-mapped IPv6 (::ffff:127.0.0.1) and classify the V4 part.
|
|
49
|
+
const mapped = ip.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
50
|
+
if (mapped) return isPublicV4(mapped[1]);
|
|
51
|
+
return isPublicV6(ip);
|
|
52
|
+
}
|
|
53
|
+
return false; // not a valid IP literal → treat as non-public (fail closed)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPublicV4(ip) {
|
|
57
|
+
const o = ip.split('.').map(Number);
|
|
58
|
+
if (o.length !== 4 || o.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
|
59
|
+
const [a, b] = o;
|
|
60
|
+
if (a === 10) return false; // 10/8 private
|
|
61
|
+
if (a === 172 && b >= 16 && b <= 31) return false; // 172.16/12 private
|
|
62
|
+
if (a === 192 && b === 168) return false; // 192.168/16 private
|
|
63
|
+
if (a === 127) return false; // 127/8 loopback
|
|
64
|
+
if (a === 169 && b === 254) return false; // 169.254/16 link-local
|
|
65
|
+
if (a === 0) return false; // 0.0.0.0/8 unspecified/reserved
|
|
66
|
+
if (a >= 224) return false; // 224/4 multicast + 240/4 reserved
|
|
67
|
+
if (a === 100 && b >= 64 && b <= 127) return false; // 100.64/10 CGNAT (treat as non-public)
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isPublicV6(ip) {
|
|
72
|
+
const lower = ip.toLowerCase();
|
|
73
|
+
if (lower === '::1') return false; // loopback
|
|
74
|
+
if (lower === '::') return false; // unspecified
|
|
75
|
+
// Classify by the FIRST 16-bit hextet as a NUMBER, not a string prefix.
|
|
76
|
+
// String prefixes silently miss sub-ranges: e.g. fe80::/10 spans fe80-febf,
|
|
77
|
+
// and `startsWith('fe9'|'fea'|'feb')` left fe81-fe8f (fe8x) reachable as
|
|
78
|
+
// "public" — an SSRF hole. Numeric masks check the actual bit ranges.
|
|
79
|
+
const firstHextet = lower.startsWith('::') ? 0 : parseInt(lower.split(':')[0] || '0', 16);
|
|
80
|
+
if (Number.isNaN(firstHextet)) return false; // unparseable → fail closed
|
|
81
|
+
if ((firstHextet & 0xffc0) === 0xfe80) return false; // fe80::/10 link-local (fe80-febf)
|
|
82
|
+
if ((firstHextet & 0xffc0) === 0xfec0) return false; // fec0::/10 site-local (deprecated, non-routable)
|
|
83
|
+
if ((firstHextet & 0xfe00) === 0xfc00) return false; // fc00::/7 unique-local (fc00-fdff)
|
|
84
|
+
if ((firstHextet & 0xff00) === 0xff00) return false; // ff00::/8 multicast
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate a remote import URL: https only, resolvable hostname, and EVERY
|
|
90
|
+
* resolved address must be public. Returns the normalized URL string.
|
|
91
|
+
*/
|
|
92
|
+
async function validateImportUrl(source) {
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = new URL(source);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error('invalid import URL');
|
|
98
|
+
}
|
|
99
|
+
if (parsed.protocol !== 'https:') {
|
|
100
|
+
throw new Error('remote instinct imports require https URLs');
|
|
101
|
+
}
|
|
102
|
+
if (!parsed.hostname) {
|
|
103
|
+
throw new Error('remote import URL is missing a hostname');
|
|
104
|
+
}
|
|
105
|
+
// Port allowlist: only the standard HTTPS port (or none) is permitted. This
|
|
106
|
+
// blocks using an allowed public host to reach an internal service port
|
|
107
|
+
// (6379/27017/8080/...) without maintaining a per-service blocklist.
|
|
108
|
+
if (!ALLOWED_IMPORT_PORTS.has(parsed.port)) {
|
|
109
|
+
throw new Error(`remote import port not allowed: ${parsed.port} (only the standard https port is permitted)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let addrs;
|
|
113
|
+
try {
|
|
114
|
+
addrs = await dns.lookup(parsed.hostname, { all: true });
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`remote import host could not be resolved: ${parsed.hostname}`);
|
|
117
|
+
}
|
|
118
|
+
if (!addrs.length) {
|
|
119
|
+
throw new Error(`remote import host could not be resolved: ${parsed.hostname}`);
|
|
120
|
+
}
|
|
121
|
+
for (const { address } of addrs) {
|
|
122
|
+
if (!isPublicAddress(address)) {
|
|
123
|
+
throw new Error(`remote import host resolves to a non-public address: ${address}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return parsed.toString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fetch a validated remote instinct file with bounded size + timeout, an allowed
|
|
131
|
+
* Content-Type, and NO redirect following (3xx is rejected). Resolves the body.
|
|
132
|
+
*/
|
|
133
|
+
async function fetchImportUrl(source, opts = {}) {
|
|
134
|
+
const maxBytes = opts.maxBytes || DEFAULT_MAX_BYTES;
|
|
135
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
136
|
+
const url = await validateImportUrl(source);
|
|
137
|
+
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const req = https.get(url, { headers: { 'User-Agent': 'MindForge-instinct-import/1' } }, res => {
|
|
140
|
+
const status = res.statusCode || 0;
|
|
141
|
+
if (status >= 300 && status < 400) {
|
|
142
|
+
res.destroy();
|
|
143
|
+
return reject(new Error(`remote import returned a ${status} redirect — refusing to follow (SSRF guard)`));
|
|
144
|
+
}
|
|
145
|
+
if (status !== 200) {
|
|
146
|
+
res.destroy();
|
|
147
|
+
return reject(new Error(`remote import failed with status ${status}`));
|
|
148
|
+
}
|
|
149
|
+
const ctype = (res.headers['content-type'] || '').toLowerCase();
|
|
150
|
+
const allowed = ['text/', 'markdown', 'yaml', 'json', 'octet-stream'];
|
|
151
|
+
if (ctype && !allowed.some(a => ctype.includes(a))) {
|
|
152
|
+
res.destroy();
|
|
153
|
+
return reject(new Error(`unsupported remote content type: ${ctype}`));
|
|
154
|
+
}
|
|
155
|
+
let bytes = 0;
|
|
156
|
+
const chunks = [];
|
|
157
|
+
res.on('data', chunk => {
|
|
158
|
+
bytes += chunk.length;
|
|
159
|
+
if (bytes > maxBytes) {
|
|
160
|
+
res.destroy();
|
|
161
|
+
return reject(new Error(`remote import exceeds ${maxBytes} bytes`));
|
|
162
|
+
}
|
|
163
|
+
chunks.push(chunk);
|
|
164
|
+
});
|
|
165
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
166
|
+
});
|
|
167
|
+
req.setTimeout(timeoutMs, () => {
|
|
168
|
+
req.destroy();
|
|
169
|
+
reject(new Error(`remote import timed out after ${timeoutMs}ms`));
|
|
170
|
+
});
|
|
171
|
+
req.on('error', err => reject(new Error(`remote import failed: ${err.message}`)));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve + validate a filesystem path, blocking system dirs (both import-from
|
|
177
|
+
* and export-to). Returns the resolved absolute path.
|
|
178
|
+
*/
|
|
179
|
+
function validateFilePath(pathStr, { mustExist = false } = {}) {
|
|
180
|
+
if (typeof pathStr !== 'string' || !pathStr) {
|
|
181
|
+
throw new Error('path must be a non-empty string');
|
|
182
|
+
}
|
|
183
|
+
// expanduser
|
|
184
|
+
const expanded = pathStr.startsWith('~')
|
|
185
|
+
? path.join(require('os').homedir(), pathStr.slice(1))
|
|
186
|
+
: pathStr;
|
|
187
|
+
const lexical = path.resolve(expanded);
|
|
188
|
+
|
|
189
|
+
// Canonicalize BEFORE the system-dir check. path.resolve() only normalizes
|
|
190
|
+
// lexically — it does NOT follow symlinks — so a symlink planted in a writable
|
|
191
|
+
// dir (e.g. /tmp/x -> /etc) would pass the prefix check while the subsequent
|
|
192
|
+
// fs.read/writeFileSync followed the link into a blocked dir. Resolve the real
|
|
193
|
+
// path so the check sees the actual on-disk target. Export targets may not
|
|
194
|
+
// exist yet, so fall back to realpath-ing the deepest existing ancestor and
|
|
195
|
+
// re-joining the not-yet-created tail (a symlinked ancestor is still caught).
|
|
196
|
+
const resolved = canonicalize(lexical);
|
|
197
|
+
|
|
198
|
+
for (const prefix of BLOCKED_PREFIXES) {
|
|
199
|
+
if (resolved === prefix || resolved.startsWith(prefix + path.sep)) {
|
|
200
|
+
throw new Error(`path '${resolved}' targets a system directory`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (mustExist && !fs.existsSync(resolved)) {
|
|
204
|
+
throw new Error(`path does not exist: ${resolved}`);
|
|
205
|
+
}
|
|
206
|
+
return resolved;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve symlinks in an absolute path, tolerating a not-yet-created tail.
|
|
211
|
+
* Walks up to the deepest existing ancestor, realpath()s THAT (catching a
|
|
212
|
+
* symlinked parent), then re-appends the non-existent remainder.
|
|
213
|
+
*/
|
|
214
|
+
function canonicalize(absPath) {
|
|
215
|
+
let existing = absPath;
|
|
216
|
+
const tail = [];
|
|
217
|
+
// Find the deepest ancestor that exists on disk.
|
|
218
|
+
while (!fs.existsSync(existing)) {
|
|
219
|
+
const parent = path.dirname(existing);
|
|
220
|
+
if (parent === existing) break; // reached filesystem root
|
|
221
|
+
tail.unshift(path.basename(existing));
|
|
222
|
+
existing = parent;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const realExisting = fs.realpathSync(existing);
|
|
226
|
+
return tail.length ? path.join(realExisting, ...tail) : realExisting;
|
|
227
|
+
} catch {
|
|
228
|
+
return absPath; // realpath failed (race/permission) → fall back, fail closed downstream
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Validate an instinct id before using it anywhere id-sensitive.
|
|
234
|
+
*/
|
|
235
|
+
function validateInstinctId(id) {
|
|
236
|
+
if (typeof id !== 'string' || !id || id.length > 128) return false;
|
|
237
|
+
if (id.includes('/') || id.includes('\\')) return false;
|
|
238
|
+
if (id.includes('..')) return false;
|
|
239
|
+
if (id.startsWith('.')) return false;
|
|
240
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
DEFAULT_MAX_BYTES,
|
|
245
|
+
DEFAULT_TIMEOUT_MS,
|
|
246
|
+
BLOCKED_PREFIXES,
|
|
247
|
+
isPublicAddress,
|
|
248
|
+
validateImportUrl,
|
|
249
|
+
fetchImportUrl,
|
|
250
|
+
validateFilePath,
|
|
251
|
+
validateInstinctId,
|
|
252
|
+
};
|
package/bin/memory/eis-client.js
CHANGED
|
@@ -150,20 +150,41 @@ class EISClient {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
153
|
+
* Lazily registers (once) this client's outbound node identity in the ZTAI
|
|
154
|
+
* trust registry and returns its DID. ZTAI is a SINGLETON instance (exported
|
|
155
|
+
* as `new ZTAIManager()`), so we call it directly — never `new ZTAI()`. The
|
|
156
|
+
* DID is cached so every header from this client shares one resolvable key.
|
|
157
|
+
*/
|
|
158
|
+
async _getNodeDid() {
|
|
159
|
+
if (!this._nodeDid) {
|
|
160
|
+
// Tier 3: the enclave provider signs with real Ed25519 (see ztai-manager).
|
|
161
|
+
this._nodeDid = await ZTAI.registerAgent(`eis-client:${this.orgId}`, 3);
|
|
162
|
+
}
|
|
163
|
+
return this._nodeDid;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* [HARDEN] Generates a cryptographically signed auth header using this node's
|
|
168
|
+
* DID. Attaches OUTBOUND provenance to locally-originated requests (it signs
|
|
169
|
+
* what this node sends). It does NOT verify inbound remote entries — that is
|
|
170
|
+
* verifyRemoteProvenance's job, which fails closed unless a signature is
|
|
171
|
+
* cryptographically verified against a resolvable public key.
|
|
172
|
+
*
|
|
173
|
+
* FAIL-CLOSED: uses the real ZTAI API (registerAgent -> signData) and asserts
|
|
174
|
+
* the produced signature is a non-empty string. A signing failure THROWS
|
|
175
|
+
* rather than shipping a malformed `ZTAI-v5 <did>:` header with an empty sig.
|
|
158
176
|
*/
|
|
159
177
|
async getAuthHeader(action, resource) {
|
|
160
|
-
const
|
|
161
|
-
const identity = manager.getIdentity();
|
|
178
|
+
const did = await this._getNodeDid();
|
|
162
179
|
const payload = `${this.orgId}:${action}:${resource}:${Date.now()}`;
|
|
163
|
-
const signature =
|
|
164
|
-
|
|
180
|
+
const signature = await ZTAI.signData(did, payload);
|
|
181
|
+
|
|
182
|
+
if (typeof signature !== 'string' || signature.length === 0) {
|
|
183
|
+
throw new Error('EIS auth header signing failed: ZTAI produced an empty signature');
|
|
184
|
+
}
|
|
185
|
+
|
|
165
186
|
return {
|
|
166
|
-
'Authorization': `ZTAI-v5 ${
|
|
187
|
+
'Authorization': `ZTAI-v5 ${did}:${signature}`,
|
|
167
188
|
'X-MF-Org': this.orgId,
|
|
168
189
|
'X-MF-Timestamp': Date.now().toString()
|
|
169
190
|
};
|
|
@@ -58,7 +58,11 @@ class FederatedSync {
|
|
|
58
58
|
const statsPath = path.join(this.localStore.getPaths().MEMORY_DIR, 'sync-stats.json');
|
|
59
59
|
let stats = { failures: 0 };
|
|
60
60
|
if (fs.existsSync(statsPath)) {
|
|
61
|
-
|
|
61
|
+
try {
|
|
62
|
+
stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
|
63
|
+
} catch {
|
|
64
|
+
stats = { failures: 0 };
|
|
65
|
+
}
|
|
62
66
|
}
|
|
63
67
|
stats.failures = (stats.failures || 0) + 1;
|
|
64
68
|
stats.last_error = err.message;
|
|
@@ -122,7 +126,12 @@ class FederatedSync {
|
|
|
122
126
|
resetFailures() {
|
|
123
127
|
const statsPath = path.join(this.localStore.getPaths().MEMORY_DIR, 'sync-stats.json');
|
|
124
128
|
if (fs.existsSync(statsPath)) {
|
|
125
|
-
|
|
129
|
+
let stats = { failures: 0 };
|
|
130
|
+
try {
|
|
131
|
+
stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
|
132
|
+
} catch {
|
|
133
|
+
stats = { failures: 0 };
|
|
134
|
+
}
|
|
126
135
|
stats.failures = 0;
|
|
127
136
|
fs.writeFileSync(statsPath, JSON.stringify(stats, null, 2));
|
|
128
137
|
}
|
|
@@ -238,7 +238,16 @@ function captureFromPhaseCompletion(phaseNum) {
|
|
|
238
238
|
function captureFromCompaction(handoffPath) {
|
|
239
239
|
if (!fs.existsSync(handoffPath)) return [];
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
let handoff;
|
|
242
|
+
try {
|
|
243
|
+
handoff = JSON.parse(fs.readFileSync(handoffPath, 'utf8'));
|
|
244
|
+
} catch (err) {
|
|
245
|
+
// Malformed handoff.json must not crash the capture pipeline — mirror the
|
|
246
|
+
// missing-file path and return [] after logging the parse failure.
|
|
247
|
+
console.error(`[knowledge-capture] Failed to parse handoff file ${handoffPath}: ${err.message}`);
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
|
|
242
251
|
const items = handoff.implicit_knowledge || [];
|
|
243
252
|
const project = getProjectName();
|
|
244
253
|
const captured = [];
|
|
@@ -18,7 +18,15 @@ class PillarHealthTracker {
|
|
|
18
18
|
if (!fs.existsSync(auditPath)) return null;
|
|
19
19
|
|
|
20
20
|
const lines = fs.readFileSync(auditPath, 'utf8').trim().split('\n');
|
|
21
|
-
const events = lines
|
|
21
|
+
const events = lines
|
|
22
|
+
.map(l => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(l);
|
|
25
|
+
} catch {
|
|
26
|
+
return null; // Skip malformed lines rather than crashing the pipeline.
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
.filter(Boolean);
|
|
22
30
|
|
|
23
31
|
// 1. RSA (Mission Fidelity) Analysis
|
|
24
32
|
const rsaEvents = events.filter(e => e.type === 'mission_fidelity' || e.event === 'scs_homing_injected');
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MindForge — Typed cross-provider LLM error taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from ECC (src/llm/core/interface.py LLMError hierarchy). Named error
|
|
5
|
+
* classes carrying { provider, code, retryable } so the model-client fallback /
|
|
6
|
+
* circuit breaker can branch on error TYPE instead of a raw count:
|
|
7
|
+
* - ContextLengthError -> escalate to a larger-context model
|
|
8
|
+
* - AuthenticationError -> skip this provider permanently (bad/missing key)
|
|
9
|
+
* - RateLimitError -> circuit-break + back off, retryable
|
|
10
|
+
* - ModelNotFoundError -> skip the model, try fallback
|
|
11
|
+
*
|
|
12
|
+
* mapHttpStatus() turns a provider HTTP status into the right typed error so all
|
|
13
|
+
* providers classify failures consistently.
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
class LLMError extends Error {
|
|
18
|
+
constructor(message, { provider = null, code = null, retryable = false, status = null } = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = this.constructor.name;
|
|
21
|
+
this.provider = provider;
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.retryable = retryable;
|
|
24
|
+
this.status = status;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class AuthenticationError extends LLMError {
|
|
29
|
+
constructor(message, opts = {}) { super(message, { ...opts, retryable: false }); }
|
|
30
|
+
}
|
|
31
|
+
class RateLimitError extends LLMError {
|
|
32
|
+
constructor(message, opts = {}) { super(message, { ...opts, retryable: true }); }
|
|
33
|
+
}
|
|
34
|
+
class ContextLengthError extends LLMError {
|
|
35
|
+
constructor(message, opts = {}) { super(message, { ...opts, retryable: false }); }
|
|
36
|
+
}
|
|
37
|
+
class ModelNotFoundError extends LLMError {
|
|
38
|
+
constructor(message, opts = {}) { super(message, { ...opts, retryable: false }); }
|
|
39
|
+
}
|
|
40
|
+
class ServiceUnavailableError extends LLMError {
|
|
41
|
+
constructor(message, opts = {}) { super(message, { ...opts, retryable: true }); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Classify a provider failure into a typed error. Prefers HTTP status, then
|
|
46
|
+
* falls back to string heuristics on the message (ECC astraflow.py approach) for
|
|
47
|
+
* providers that don't surface a clean status.
|
|
48
|
+
*/
|
|
49
|
+
function classifyError(message, { provider = null, status = null } = {}) {
|
|
50
|
+
const msg = String(message || '');
|
|
51
|
+
const lower = msg.toLowerCase();
|
|
52
|
+
|
|
53
|
+
if (status === 401 || status === 403 || /\b401\b|\b403\b|unauthorized|invalid api key|authentication/.test(lower)) {
|
|
54
|
+
return new AuthenticationError(msg, { provider, code: 'auth', status });
|
|
55
|
+
}
|
|
56
|
+
if (status === 429 || /\b429\b|rate.?limit|too many requests/.test(lower)) {
|
|
57
|
+
return new RateLimitError(msg, { provider, code: 'rate_limit', status });
|
|
58
|
+
}
|
|
59
|
+
if (status === 404 || /\b404\b|model.*not.*found|no such model/.test(lower)) {
|
|
60
|
+
return new ModelNotFoundError(msg, { provider, code: 'model_not_found', status });
|
|
61
|
+
}
|
|
62
|
+
if (/context.*length|maximum context|context window|too long|token limit/.test(lower)) {
|
|
63
|
+
return new ContextLengthError(msg, { provider, code: 'context_length', status });
|
|
64
|
+
}
|
|
65
|
+
if (status === 503 || status === 502 || status === 408 || /\b50[23]\b|timeout|unavailable|connection/.test(lower)) {
|
|
66
|
+
return new ServiceUnavailableError(msg, { provider, code: 'unavailable', status });
|
|
67
|
+
}
|
|
68
|
+
return new LLMError(msg, { provider, code: 'unknown', status, retryable: false });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
LLMError,
|
|
73
|
+
AuthenticationError,
|
|
74
|
+
RateLimitError,
|
|
75
|
+
ContextLengthError,
|
|
76
|
+
ModelNotFoundError,
|
|
77
|
+
ServiceUnavailableError,
|
|
78
|
+
classifyError,
|
|
79
|
+
};
|
|
@@ -9,11 +9,17 @@ const CostTracker = require('./cost-tracker');
|
|
|
9
9
|
const AnthropicProvider = require('./anthropic-provider');
|
|
10
10
|
const OpenAIProvider = require('./openai-provider');
|
|
11
11
|
const GeminiProvider = require('./gemini-provider');
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const OllamaProvider = require('./ollama-provider');
|
|
13
|
+
const { resolveProvider } = require('./provider-registry');
|
|
14
|
+
const { LLMError } = require('./llm-errors');
|
|
15
|
+
|
|
16
|
+
// v9: Fallback chains aligned to Claude 4.x family.
|
|
17
|
+
// llama-3-70b-local is the sovereign last-resort — only reachable when
|
|
18
|
+
// OLLAMA_BASE_URL is set (see _getProvider); otherwise _getProvider returns
|
|
19
|
+
// null and the chain skips it, leaving cloud routing unchanged.
|
|
14
20
|
const FALLBACK_CHAINS = {
|
|
15
21
|
'claude-opus-4-7': ['claude-sonnet-4-6', 'gemini-2.5-pro'],
|
|
16
|
-
'claude-sonnet-4-6': ['claude-haiku-4-5', 'gemini-2.5-pro'],
|
|
22
|
+
'claude-sonnet-4-6': ['claude-haiku-4-5', 'gemini-2.5-pro', 'llama-3-70b-local'],
|
|
17
23
|
'gemini-2.5-pro': ['claude-sonnet-4-6'],
|
|
18
24
|
};
|
|
19
25
|
|
|
@@ -72,10 +78,27 @@ class ModelClient {
|
|
|
72
78
|
|
|
73
79
|
} catch (err) {
|
|
74
80
|
const safeMsg = (err.message || '').replace(/sk-[a-zA-Z0-9_-]+/g, 'sk-***').replace(/key-[a-zA-Z0-9_-]+/g, 'key-***');
|
|
75
|
-
process.stderr.write(`[model-client] ${currentModel} failed: ${safeMsg}\n`);
|
|
81
|
+
process.stderr.write(`[model-client] ${currentModel} failed (${err.name || 'Error'}/${err.code || 'unknown'}): ${safeMsg}\n`);
|
|
82
|
+
|
|
83
|
+
// Context-aware fallback: branch on the typed-error taxonomy instead of
|
|
84
|
+
// blindly trying the next model.
|
|
85
|
+
// - ContextLengthError: this model's window is too small — keep trying
|
|
86
|
+
// the chain (later entries may have larger context); don't abort.
|
|
87
|
+
// - AuthenticationError: the provider's key is bad/missing — trying it
|
|
88
|
+
// again is pointless, but other providers in the chain may work, so
|
|
89
|
+
// continue to the next attempt.
|
|
90
|
+
// - Otherwise: continue through the chain as before.
|
|
91
|
+
const isTyped = err instanceof LLMError;
|
|
92
|
+
const isAuth = isTyped && err.name === 'AuthenticationError';
|
|
93
|
+
if (isAuth) {
|
|
94
|
+
process.stderr.write(`[model-client] ${currentModel}: auth failure — skipping this provider for the rest of this call\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
if (attempts.indexOf(currentModel) === attempts.length - 1) {
|
|
77
98
|
const safeErr = new Error(safeMsg);
|
|
78
99
|
safeErr.code = err.code;
|
|
100
|
+
safeErr.name = err.name;
|
|
101
|
+
safeErr.retryable = isTyped ? err.retryable : undefined;
|
|
79
102
|
throw safeErr;
|
|
80
103
|
}
|
|
81
104
|
}
|
|
@@ -103,6 +126,12 @@ class ModelClient {
|
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
static _getProvider(modelId) {
|
|
129
|
+
// Consult the pluggable registry first (honors MINDFORGE_LLM_PROVIDER
|
|
130
|
+
// override for sovereignty/offline test isolation). Falls through to the
|
|
131
|
+
// built-in prefix routing when nothing is registered.
|
|
132
|
+
const registered = resolveProvider(modelId);
|
|
133
|
+
if (registered) return registered;
|
|
134
|
+
|
|
106
135
|
if (modelId.startsWith('claude') || modelId.startsWith('anthropic.claude')) {
|
|
107
136
|
if (!process.env.ANTHROPIC_API_KEY) return null;
|
|
108
137
|
return new AnthropicProvider(process.env.ANTHROPIC_API_KEY);
|
|
@@ -115,6 +144,12 @@ class ModelClient {
|
|
|
115
144
|
if (!process.env.GOOGLE_API_KEY) return null;
|
|
116
145
|
return new GeminiProvider(process.env.GOOGLE_API_KEY);
|
|
117
146
|
}
|
|
147
|
+
// Sovereign local models (e.g. llama-3-70b-local). Gated on OLLAMA_BASE_URL
|
|
148
|
+
// being explicitly set, so cloud routing is unchanged unless opted in.
|
|
149
|
+
if (modelId.includes('local') || modelId.includes('llama')) {
|
|
150
|
+
if (!process.env.OLLAMA_BASE_URL) return null;
|
|
151
|
+
return new OllamaProvider(process.env.OLLAMA_BASE_URL);
|
|
152
|
+
}
|
|
118
153
|
return null;
|
|
119
154
|
}
|
|
120
155
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MindForge — Ollama / local-model Provider
|
|
3
|
+
*
|
|
4
|
+
* Adapted from ECC (src/llm/providers/ollama.py). Talks to a local Ollama
|
|
5
|
+
* server (default http://localhost:11434) so the sovereign `llama-3-70b-local`
|
|
6
|
+
* entry in revops.market_registry is actually reachable — previously a dead
|
|
7
|
+
* link the MIR cost-arbitrage math could pick but never execute.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors AnthropicProvider.complete() return shape so it drops into
|
|
10
|
+
* model-client.js unchanged. GATED on OLLAMA_BASE_URL: model-client only
|
|
11
|
+
* constructs this provider when OLLAMA_BASE_URL is set, so cloud routing is
|
|
12
|
+
* unchanged unless the operator explicitly opts in.
|
|
13
|
+
*/
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const { URL } = require('url');
|
|
18
|
+
|
|
19
|
+
class OllamaProvider {
|
|
20
|
+
constructor(baseUrl) {
|
|
21
|
+
this.baseUrl = baseUrl || process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async complete(params) {
|
|
25
|
+
const { model, systemPrompt, userMessage, temperature = 0.7 } = params;
|
|
26
|
+
|
|
27
|
+
const messages = [];
|
|
28
|
+
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
|
|
29
|
+
messages.push({ role: 'user', content: userMessage });
|
|
30
|
+
|
|
31
|
+
const payload = JSON.stringify({
|
|
32
|
+
model,
|
|
33
|
+
messages,
|
|
34
|
+
stream: false,
|
|
35
|
+
options: temperature !== 1.0 ? { temperature } : undefined,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const url = new URL('/api/chat', this.baseUrl);
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const req = http.request({
|
|
42
|
+
hostname: url.hostname,
|
|
43
|
+
port: url.port || 11434,
|
|
44
|
+
path: url.pathname,
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
49
|
+
},
|
|
50
|
+
timeout: 120_000,
|
|
51
|
+
}, res => {
|
|
52
|
+
let body = '';
|
|
53
|
+
res.on('data', chunk => body += chunk);
|
|
54
|
+
res.on('end', () => {
|
|
55
|
+
try {
|
|
56
|
+
if (res.statusCode !== 200) {
|
|
57
|
+
return reject(Object.assign(
|
|
58
|
+
new Error(`Ollama API error (${res.statusCode})`),
|
|
59
|
+
{ status: res.statusCode }
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
const json = JSON.parse(body);
|
|
63
|
+
const content = json.message?.content || '';
|
|
64
|
+
|
|
65
|
+
// Ollama reports token counts as prompt_eval_count / eval_count.
|
|
66
|
+
const inputTokens = json.prompt_eval_count || 0;
|
|
67
|
+
const outputTokens = json.eval_count || 0;
|
|
68
|
+
|
|
69
|
+
// Price via the registry id (e.g. llama-3-70b-local). Local models
|
|
70
|
+
// are effectively free, but we still record for MIR/ROI accounting.
|
|
71
|
+
let cost = 0;
|
|
72
|
+
try {
|
|
73
|
+
const { priceCall } = require('./pricing-registry');
|
|
74
|
+
cost = priceCall(model, { input_tokens: inputTokens, output_tokens: outputTokens });
|
|
75
|
+
} catch (_e) {
|
|
76
|
+
cost = 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
resolve({
|
|
80
|
+
model,
|
|
81
|
+
content,
|
|
82
|
+
input_tokens: inputTokens,
|
|
83
|
+
output_tokens: outputTokens,
|
|
84
|
+
cost_usd: cost,
|
|
85
|
+
provider: 'ollama',
|
|
86
|
+
});
|
|
87
|
+
} catch (e) {
|
|
88
|
+
reject(new Error('Failed to parse Ollama response: ' + e.message));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
req.on('error', err => {
|
|
94
|
+
// Connection refused = local server not running. Surface as a clear,
|
|
95
|
+
// retryable-by-fallback error rather than a cryptic ECONNREFUSED.
|
|
96
|
+
reject(Object.assign(
|
|
97
|
+
new Error(`Ollama connection failed (${this.baseUrl}): ${err.message}`),
|
|
98
|
+
{ status: 503 }
|
|
99
|
+
));
|
|
100
|
+
});
|
|
101
|
+
req.on('timeout', () => {
|
|
102
|
+
req.destroy();
|
|
103
|
+
reject(Object.assign(new Error('Ollama timeout'), { status: 408 }));
|
|
104
|
+
});
|
|
105
|
+
req.write(payload);
|
|
106
|
+
req.end();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
validateConfig() {
|
|
111
|
+
return Boolean(this.baseUrl);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = OllamaProvider;
|