mindforge-cc 11.9.2 → 11.9.3
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 +37 -13
- package/.agent/hooks/mindforge-block-no-verify.js +61 -13
- package/.agent/hooks/mindforge-config-protection.js +82 -3
- package/.agent/hooks/mindforge-context-monitor.js +1 -1
- package/.agent/hooks/mindforge-workflow-guard.js +2 -2
- package/.agent/hooks/run-with-flags.js +190 -20
- package/.agent/mindforge/browse.md +2 -2
- package/.agent/mindforge/checkpoint.md +1 -1
- package/.agent/mindforge/harness-audit.md +1 -1
- package/.agent/mindforge/orch-add-feature.md +1 -1
- package/.agent/mindforge/orch-build-mvp.md +1 -1
- package/.agent/mindforge/orch-change-feature.md +1 -1
- package/.agent/mindforge/orch-fix-defect.md +1 -1
- package/.agent/mindforge/orch-refine-code.md +1 -1
- package/.agent/mindforge/qa.md +2 -2
- package/.claude/CLAUDE.md +37 -13
- package/.claude/commands/mindforge/browse.md +2 -2
- package/.claude/commands/mindforge/checkpoint.md +1 -1
- package/.claude/commands/mindforge/harness-audit.md +1 -1
- package/.claude/commands/mindforge/orch-add-feature.md +1 -1
- package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
- package/.claude/commands/mindforge/orch-change-feature.md +1 -1
- package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
- package/.claude/commands/mindforge/orch-refine-code.md +1 -1
- package/.claude/commands/mindforge/qa.md +2 -2
- package/.mindforge/MINDFORGE-SCHEMA.json +1 -1
- package/.mindforge/config.json +3 -3
- package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
- package/.mindforge/engine/temporal-protocol.md +2 -2
- package/.mindforge/governance/change-classifier.md +20 -4
- package/.mindforge/memory/sync-manifest.json +1 -1
- package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
- package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
- package/CHANGELOG.md +194 -0
- package/MINDFORGE.md +13 -6
- package/README.md +4 -3
- package/RELEASENOTES.md +2 -2
- package/SECURITY.md +22 -3
- package/bin/autonomous/auto-runner.js +65 -2
- package/bin/change-classifier.js +151 -16
- package/bin/dashboard/api-router.js +18 -38
- package/bin/dashboard/frontend/app.js +429 -0
- package/bin/dashboard/frontend/index.html +13 -406
- package/bin/dashboard/metrics-aggregator.js +46 -22
- package/bin/dashboard/server.js +160 -1
- package/bin/dashboard/sse-bridge.js +11 -8
- package/bin/engine/sre-manager.js +1 -1
- package/bin/engine/temporal-cli.js +56 -6
- package/bin/engine/verification-runner.js +134 -17
- package/bin/engine/verify-cli.js +25 -7
- package/bin/governance/approval-record.js +147 -0
- package/bin/governance/approve.js +12 -7
- package/bin/governance/policy-engine.js +33 -3
- package/bin/governance/policy-gate-hardened.js +36 -1
- package/bin/governance/verify-approvals.js +163 -0
- package/bin/harness-audit.js +224 -10
- package/bin/hooks/instinct-capture-hook.js +12 -4
- package/bin/install.js +63 -3
- package/bin/installer/harness-adapter-compliance.js +339 -28
- package/bin/installer/hook-registration.js +504 -0
- package/bin/installer-core.js +451 -63
- package/bin/learning/instinct-cli.js +7 -0
- package/bin/memory/vector-hub.js +196 -13
- package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
- package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
- package/bin/mindforge-cli.js +67 -6
- package/bin/models/cost-tracker.js +104 -6
- package/bin/models/model-client.js +6 -1
- package/bin/revops/debt-monitor.js +57 -13
- package/bin/security/trust-gate-hook.js +50 -6
- package/bin/skill-validator.js +6 -1
- package/bin/skills-builder/skill-scorer.js +46 -6
- package/bin/updater/self-update.js +6 -1
- package/bin/updater/version-comparator.js +21 -1
- package/bin/utils/mindforge-version.js +99 -0
- package/bin/utils/redact-secrets.js +106 -0
- package/bin/validate-config.js +42 -2
- package/bin/wizard/setup-wizard.js +4 -1
- package/bin/wizard/theme.js +9 -1
- package/changelogs/index.json +11 -9
- package/changelogs/v11.9.3.md +195 -0
- package/docs/References/config-reference.md +5 -2
- package/docs/References/sdk-api.md +1 -1
- package/docs/Templates/Codebase/architecture.md +1 -1
- package/docs/commands-reference.md +4 -5
- package/docs/faq.md +25 -5
- package/docs/getting-started.md +3 -3
- package/docs/sdk-reference.md +15 -7
- package/docs/troubleshooting.md +10 -6
- package/docs/user-guide.md +14 -14
- package/examples/sdk-integration/README.md +1 -1
- package/package.json +7 -3
- package/subagents/.claude-plugin/marketplace.json +1 -1
- package/bin/dashboard/approval-handler.js +0 -136
package/bin/harness-audit.js
CHANGED
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
* node bin/harness-audit.js --format json # machine-readable
|
|
21
21
|
* node bin/harness-audit.js --scope security
|
|
22
22
|
* node bin/harness-audit.js --root /path/to/checkout
|
|
23
|
+
* node bin/harness-audit.js --min-score 70 # exit 1 if below threshold
|
|
24
|
+
* node bin/harness-audit.js --fail-on-findings # exit 1 if ANY check fails
|
|
25
|
+
*
|
|
26
|
+
* GATE FLAGS (opt-in): without --min-score / --fail-on-findings this command
|
|
27
|
+
* ALWAYS exited 0 — even at 0/76 against an empty directory — so wiring it into
|
|
28
|
+
* CI produced a permanently-green required check. The threshold flags are the
|
|
29
|
+
* only way to make it fail; they are opt-in so existing callers (and the
|
|
30
|
+
* `harness:audit` npm script) keep their historical exit-0 behaviour verbatim.
|
|
31
|
+
* `harness:gate` is the CI-facing script that passes a threshold.
|
|
23
32
|
*/
|
|
24
33
|
|
|
25
34
|
const fs = require('fs');
|
|
@@ -48,6 +57,22 @@ function normalizeScope(scope) {
|
|
|
48
57
|
return value;
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Coerce a --min-score value to a non-negative finite number, or throw.
|
|
62
|
+
*
|
|
63
|
+
* WHY strict instead of defaulting: a silently-ignored threshold (missing value,
|
|
64
|
+
* NaN, negative) would restore the always-green failure mode this flag exists to
|
|
65
|
+
* remove, and would do so invisibly in CI. A bad threshold is a hard error.
|
|
66
|
+
*/
|
|
67
|
+
function parseMinScore(raw) {
|
|
68
|
+
const text = raw === undefined || raw === null ? '' : String(raw).trim();
|
|
69
|
+
const value = Number(text);
|
|
70
|
+
if (text === '' || !Number.isFinite(value) || value < 0) {
|
|
71
|
+
throw new Error(`Invalid --min-score: ${raw}. Use a number >= 0 (points, compared against the scope's max_score).`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
function parseArgs(argv) {
|
|
52
77
|
const args = argv.slice(2);
|
|
53
78
|
const parsed = {
|
|
@@ -55,6 +80,10 @@ function parseArgs(argv) {
|
|
|
55
80
|
format: 'text',
|
|
56
81
|
help: false,
|
|
57
82
|
root: path.resolve(process.env.AUDIT_ROOT || process.cwd()),
|
|
83
|
+
// null (not 0) means "no threshold requested". 0 is a legitimate, if
|
|
84
|
+
// permissive, threshold, so a falsy check here would silently disable it.
|
|
85
|
+
minScore: null,
|
|
86
|
+
failOnFindings: false,
|
|
58
87
|
};
|
|
59
88
|
|
|
60
89
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -64,6 +93,9 @@ function parseArgs(argv) {
|
|
|
64
93
|
if (arg === '--format') { parsed.format = (args[index + 1] || '').toLowerCase(); index += 1; continue; }
|
|
65
94
|
if (arg === '--scope') { parsed.scope = normalizeScope(args[index + 1]); index += 1; continue; }
|
|
66
95
|
if (arg === '--root') { parsed.root = path.resolve(args[index + 1] || process.cwd()); index += 1; continue; }
|
|
96
|
+
if (arg === '--min-score') { parsed.minScore = parseMinScore(args[index + 1]); index += 1; continue; }
|
|
97
|
+
if (arg === '--fail-on-findings') { parsed.failOnFindings = true; continue; }
|
|
98
|
+
if (arg.startsWith('--min-score=')) { parsed.minScore = parseMinScore(arg.slice('--min-score='.length)); continue; }
|
|
67
99
|
if (arg.startsWith('--format=')) { parsed.format = arg.split('=')[1].toLowerCase(); continue; }
|
|
68
100
|
if (arg.startsWith('--scope=')) { parsed.scope = normalizeScope(arg.split('=')[1]); continue; }
|
|
69
101
|
if (arg.startsWith('--root=')) { parsed.root = path.resolve(arg.slice('--root='.length)); continue; }
|
|
@@ -82,6 +114,25 @@ function fileExists(rootDir, relativePath) {
|
|
|
82
114
|
return fs.existsSync(path.join(rootDir, relativePath));
|
|
83
115
|
}
|
|
84
116
|
|
|
117
|
+
/**
|
|
118
|
+
* True when ANY of `relativePaths` exists under rootDir.
|
|
119
|
+
*
|
|
120
|
+
* Several checks below asked for one hardcoded path — `.agent/hooks/mindforge-context-monitor.js`,
|
|
121
|
+
* `bin/hooks/instinct-capture-hook.js` — which is the SOURCE-REPO layout. The installer copies
|
|
122
|
+
* `.agent/hooks/` to `<localDir>/hooks/`, and `bin/` is not present in a consumer project at all, so
|
|
123
|
+
* an installed tree fails these checks while containing the very same working scripts under a
|
|
124
|
+
* different name. Measured: a fresh `--claude --local` install scores 36/76 largely on checks whose
|
|
125
|
+
* subject it genuinely has.
|
|
126
|
+
*
|
|
127
|
+
* That made the audit unusable as an install-root gate — it under-reported a real install rather
|
|
128
|
+
* than over-reporting it, which is the safer direction but still wrong. Accepting either layout is a
|
|
129
|
+
* fix to the CHECK, not a relaxation: each alternative names a specific file that must exist, and
|
|
130
|
+
* absence of all of them still fails.
|
|
131
|
+
*/
|
|
132
|
+
function fileExistsAny(rootDir, relativePaths) {
|
|
133
|
+
return relativePaths.some((rel) => fileExists(rootDir, rel));
|
|
134
|
+
}
|
|
135
|
+
|
|
85
136
|
function readText(rootDir, relativePath) {
|
|
86
137
|
return fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
|
87
138
|
}
|
|
@@ -95,6 +146,82 @@ function safeParseJson(text) {
|
|
|
95
146
|
try { return JSON.parse(text); } catch (_error) { return null; }
|
|
96
147
|
}
|
|
97
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Is a named hook genuinely WIRED in a settings file, relative to rootDir?
|
|
151
|
+
*
|
|
152
|
+
* "Wired" is asserted by three things, not by one substring:
|
|
153
|
+
* 1. the hook id appears in a command registered under PreToolUse,
|
|
154
|
+
* 2. that matcher covers Bash, and
|
|
155
|
+
* 3. every .js path in the command RESOLVES relative to rootDir.
|
|
156
|
+
*
|
|
157
|
+
* (3) is the load-bearing part. The previous check was
|
|
158
|
+
* claudeSettings.includes('trust-gate-hook') && agentSettings.includes('trust-gate-hook')
|
|
159
|
+
* — a substring scan over the raw file text, which cannot distinguish a wired hook from one whose
|
|
160
|
+
* script path resolves to nothing. That distinction is the entire difference between a gate and a
|
|
161
|
+
* silent permit: .agent/hooks/run-with-flags.js:132-136 prints "Script not found", echoes stdin
|
|
162
|
+
* and exits 0, which Claude Code reads as ALLOW. Measured against a real
|
|
163
|
+
* `node bin/install.js --claude --local`, ZERO of the eight registered command paths resolve — yet
|
|
164
|
+
* the substring check scored this category 10/10, because it was reading the REPO's settings text
|
|
165
|
+
* rather than checking anything about where the hooks would run.
|
|
166
|
+
*
|
|
167
|
+
* Consequence worth stating: pointed at an installed project this check now FAILS, truthfully.
|
|
168
|
+
* That is the intended behaviour — it is the honest report of the REG-01 gap, and it is why the
|
|
169
|
+
* check is worth the points it carries.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} rootDir
|
|
172
|
+
* @param {string} settingsRel e.g. '.claude/settings.json'
|
|
173
|
+
* @param {string} hookId substring identifying the hook script, e.g. 'trust-gate-hook'
|
|
174
|
+
* @returns {{ok: boolean, why: string}}
|
|
175
|
+
*/
|
|
176
|
+
function hookWired(rootDir, settingsRel, hookId) {
|
|
177
|
+
const parsed = safeParseJson(safeRead(rootDir, settingsRel));
|
|
178
|
+
if (!parsed) return { ok: false, why: `${settingsRel} missing or unparseable` };
|
|
179
|
+
|
|
180
|
+
// Both spellings of the pre-tool event. .claude/settings.json uses Claude Code's PreToolUse;
|
|
181
|
+
// .agent/settings.json is the Gemini/Antigravity mirror and uses BeforeTool — the same
|
|
182
|
+
// difference bin/hooks/mindforge-context-monitor.js switches on when GEMINI_API_KEY is set.
|
|
183
|
+
// Hardcoding PreToolUse made this check report the mirror as unwired, which is a defect in the
|
|
184
|
+
// check rather than in the mirror; the gate caught it.
|
|
185
|
+
const PRE_TOOL_EVENTS = ['PreToolUse', 'BeforeTool'];
|
|
186
|
+
const groups = PRE_TOOL_EVENTS.flatMap((ev) => {
|
|
187
|
+
const g = parsed.hooks && parsed.hooks[ev];
|
|
188
|
+
return Array.isArray(g) ? g : [];
|
|
189
|
+
});
|
|
190
|
+
if (!groups.length) {
|
|
191
|
+
return { ok: false, why: `${settingsRel} registers no ${PRE_TOOL_EVENTS.join('/')} groups` };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const group of groups) {
|
|
195
|
+
const matcher = String((group && group.matcher) || '');
|
|
196
|
+
if (!/bash/i.test(matcher) && matcher !== '*' && matcher !== '') continue;
|
|
197
|
+
for (const entry of (group && group.hooks) || []) {
|
|
198
|
+
const command = String((entry && entry.command) || '');
|
|
199
|
+
if (!command.includes(hookId)) continue;
|
|
200
|
+
// Strip a leading environment anchor before resolving. A registered command may legitimately
|
|
201
|
+
// be root-anchored rather than cwd-relative — `node "$CLAUDE_PROJECT_DIR/.claude/hooks/..."` —
|
|
202
|
+
// and Claude Code has set CLAUDE_PROJECT_DIR in the hook environment since 1.0.57. Without
|
|
203
|
+
// this, path.join(rootDir, '$CLAUDE_PROJECT_DIR/.claude/hooks/x.js') is checked literally, so
|
|
204
|
+
// an env-anchored registration reads as UNRESOLVED and the check reports a correctly wired
|
|
205
|
+
// hook as permitting. Handles $VAR/, ${VAR}/ and ${VAR:-default}/.
|
|
206
|
+
//
|
|
207
|
+
// Note this only affects the EXISTENCE probe. It is deliberately not a general shell expander:
|
|
208
|
+
// an anchor pointing somewhere other than the audited root cannot be validated from here, and
|
|
209
|
+
// pretending otherwise would be the same class of defect as the literal check it replaces.
|
|
210
|
+
const ENV_ANCHOR = /^\$\{?[A-Z_][A-Z0-9_]*(?::-[^}]*)?\}?\//;
|
|
211
|
+
const scripts = command.split(/\s+/)
|
|
212
|
+
.map((t) => t.replace(/^"|"$/g, ''))
|
|
213
|
+
.map((t) => t.replace(ENV_ANCHOR, ''))
|
|
214
|
+
.filter((t) => t.endsWith('.js'));
|
|
215
|
+
const unresolved = scripts.filter((rel) => !fs.existsSync(path.join(rootDir, rel)));
|
|
216
|
+
if (unresolved.length) {
|
|
217
|
+
return { ok: false, why: `${hookId} registered but ${unresolved.join(', ')} does not exist under ${rootDir} — run-with-flags exits 0 on a miss, so it permits` };
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, why: `${hookId} wired on ${matcher || '*'} with ${scripts.length} resolvable script(s)` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { ok: false, why: `${hookId} is not registered on a Bash PreToolUse matcher in ${settingsRel}` };
|
|
223
|
+
}
|
|
224
|
+
|
|
98
225
|
function countFiles(rootDir, relativeDir, extension) {
|
|
99
226
|
const dirPath = path.join(rootDir, relativeDir);
|
|
100
227
|
if (!fs.existsSync(dirPath)) return 0;
|
|
@@ -195,7 +322,9 @@ function getChecks(rootDir) {
|
|
|
195
322
|
category: 'Context Efficiency', points: 3, scopes: ['repo', 'hooks'],
|
|
196
323
|
path: '.agent/hooks/mindforge-context-monitor.js',
|
|
197
324
|
description: 'Context-monitor hook exists',
|
|
198
|
-
|
|
325
|
+
// Repo layout OR installed layout — the installer copies .agent/hooks/ to <localDir>/hooks/.
|
|
326
|
+
pass: fileExistsAny(rootDir, ['.agent/hooks/mindforge-context-monitor.js',
|
|
327
|
+
'.claude/hooks/mindforge-context-monitor.js']),
|
|
199
328
|
fix: 'Implement .agent/hooks/mindforge-context-monitor.js for context-pressure tracking.',
|
|
200
329
|
},
|
|
201
330
|
{
|
|
@@ -255,7 +384,9 @@ function getChecks(rootDir) {
|
|
|
255
384
|
category: 'Memory & Learning', points: 3, scopes: ['repo', 'hooks'],
|
|
256
385
|
path: 'bin/hooks/instinct-capture-hook.js',
|
|
257
386
|
description: 'Instinct-capture hook exists',
|
|
258
|
-
|
|
387
|
+
// bin/ does not exist in a consumer project; the installed copy lands under .claude/hooks/.
|
|
388
|
+
pass: fileExistsAny(rootDir, ['bin/hooks/instinct-capture-hook.js',
|
|
389
|
+
'.claude/hooks/instinct/instinct-capture-hook.js']),
|
|
259
390
|
fix: 'Add bin/hooks/instinct-capture-hook.js for auto-capture of instincts.',
|
|
260
391
|
},
|
|
261
392
|
{
|
|
@@ -307,7 +438,13 @@ function getChecks(rootDir) {
|
|
|
307
438
|
category: 'Security Guardrails', points: 3, scopes: ['repo', 'hooks', 'security'],
|
|
308
439
|
path: 'bin/security/trust-gate-hook.js',
|
|
309
440
|
description: 'TrustGate Bash guard exists',
|
|
310
|
-
|
|
441
|
+
// Both files must be present, in EITHER the repo layout or the installed one. Requiring one
|
|
442
|
+
// from each layout would be satisfiable by a half-copied install, so the pairs are evaluated
|
|
443
|
+
// whole rather than per-file.
|
|
444
|
+
pass: (fileExists(rootDir, 'bin/security/trust-gate-hook.js')
|
|
445
|
+
&& fileExists(rootDir, 'bin/security/trust-boundaries.js'))
|
|
446
|
+
|| (fileExists(rootDir, '.claude/hooks/security/trust-gate-hook.js')
|
|
447
|
+
&& fileExists(rootDir, '.claude/hooks/security/trust-boundaries.js')),
|
|
311
448
|
fix: 'Restore bin/security/trust-gate-hook.js + trust-boundaries.js.',
|
|
312
449
|
},
|
|
313
450
|
{
|
|
@@ -315,7 +452,8 @@ function getChecks(rootDir) {
|
|
|
315
452
|
category: 'Security Guardrails', points: 2, scopes: ['repo', 'hooks', 'security'],
|
|
316
453
|
path: '.agent/hooks/mindforge-block-no-verify.js',
|
|
317
454
|
description: 'Git-hook-bypass guard exists',
|
|
318
|
-
pass:
|
|
455
|
+
pass: fileExistsAny(rootDir, ['.agent/hooks/mindforge-block-no-verify.js',
|
|
456
|
+
'.claude/hooks/mindforge-block-no-verify.js']),
|
|
319
457
|
fix: 'Add .agent/hooks/mindforge-block-no-verify.js to block --no-verify.',
|
|
320
458
|
},
|
|
321
459
|
{
|
|
@@ -330,9 +468,19 @@ function getChecks(rootDir) {
|
|
|
330
468
|
id: 'security-bash-guard-both',
|
|
331
469
|
category: 'Security Guardrails', points: 2, scopes: ['repo', 'hooks', 'security'],
|
|
332
470
|
path: '.agent/settings.json',
|
|
333
|
-
description: 'Bash guards wired in BOTH .claude and the .agent Gemini mirror',
|
|
334
|
-
|
|
335
|
-
|
|
471
|
+
description: 'Bash guards wired in BOTH .claude and the .agent Gemini mirror (paths resolve)',
|
|
472
|
+
// The .agent mirror is required only WHEN PRESENT. No install channel writes it, and this
|
|
473
|
+
// repo's own spec records its BeforeTool/AfterTool events as never firing — so requiring it
|
|
474
|
+
// unconditionally made this check unpassable on every install root even with Claude Code
|
|
475
|
+
// enforcement fully wired. It measured layout, not enforcement. Absent mirror: .claude
|
|
476
|
+
// alone decides. Present mirror: BOTH must be wired, so a half-configured Gemini setup
|
|
477
|
+
// still fails.
|
|
478
|
+
pass: hookWired(rootDir, '.claude/settings.json', 'trust-gate-hook').ok
|
|
479
|
+
&& (hookWired(rootDir, '.agent/settings.json', 'trust-gate-hook').ok
|
|
480
|
+
|| !fileExists(rootDir, '.agent/settings.json')),
|
|
481
|
+
fix: 'Wire trust-gate into a Bash PreToolUse matcher in BOTH .claude/settings.json and '
|
|
482
|
+
+ '.agent/settings.json, with command paths that RESOLVE from the audited root. A '
|
|
483
|
+
+ 'registered command whose script is absent exits 0 and permits — see hookWired().',
|
|
336
484
|
},
|
|
337
485
|
{
|
|
338
486
|
id: 'security-threat-model',
|
|
@@ -406,7 +554,7 @@ function getChecks(rootDir) {
|
|
|
406
554
|
id: 'gov-audit-trail',
|
|
407
555
|
category: 'Governance & Identity', points: 2, scopes: ['repo'],
|
|
408
556
|
path: '.mindforge/audit/',
|
|
409
|
-
description: '
|
|
557
|
+
description: 'Hash-chained audit trail directory exists',
|
|
410
558
|
pass: fileExists(rootDir, '.mindforge/audit') || fileExists(rootDir, '.planning'),
|
|
411
559
|
fix: 'Ensure the audit-trail directory (.mindforge/audit/) is present.',
|
|
412
560
|
},
|
|
@@ -462,7 +610,15 @@ function buildReport(scope, options = {}) {
|
|
|
462
610
|
}
|
|
463
611
|
|
|
464
612
|
function printText(report) {
|
|
465
|
-
|
|
613
|
+
// The header names the SCOPE, which is independent of --root. Auditing an installed project with
|
|
614
|
+
// `--root <dir>` therefore used to print "MindForge Harness Audit (repo)" — so an install-root
|
|
615
|
+
// score read as a repo score. Measured: the repo scores 76/76 while a fresh `--claude --local`
|
|
616
|
+
// install of the SAME tree scores 36/76 with Security Guardrails 1/10 and 17 of 31 checks failing.
|
|
617
|
+
// Two very different numbers under one identical label is how the enforcement gap stayed invisible.
|
|
618
|
+
// When the audited root is not the cwd, say so in the header rather than only in the Root: line.
|
|
619
|
+
const auditedElsewhere = path.resolve(report.root_dir) !== path.resolve(process.cwd());
|
|
620
|
+
const where = auditedElsewhere ? `${report.scope}, external root` : report.scope;
|
|
621
|
+
console.log(`MindForge Harness Audit (${where}): ${report.overall_score}/${report.max_score}`);
|
|
466
622
|
console.log(`Root: ${report.root_dir}`);
|
|
467
623
|
console.log('');
|
|
468
624
|
|
|
@@ -488,13 +644,60 @@ function printText(report) {
|
|
|
488
644
|
function showHelp(exitCode = 0) {
|
|
489
645
|
console.log(`
|
|
490
646
|
Usage: node bin/harness-audit.js [scope] [--scope <${SCOPES.join('|')}>] [--format <text|json>] [--root <path>]
|
|
647
|
+
[--min-score <n>] [--fail-on-findings]
|
|
491
648
|
|
|
492
649
|
Deterministic MindForge harness audit based on explicit file/config checks.
|
|
493
650
|
Audits the current working directory by default.
|
|
651
|
+
|
|
652
|
+
Gate flags (opt-in — with neither of these the command always exits 0):
|
|
653
|
+
--min-score <n> exit 1 when overall_score < n. n is in POINTS and the max
|
|
654
|
+
is scope-dependent (repo 76, security 13, agents 4), so a
|
|
655
|
+
value above the scope's max_score is rejected as
|
|
656
|
+
unsatisfiable. CI uses: npm run harness:gate
|
|
657
|
+
--fail-on-findings exit 1 when ANY check fails (strictest: every check added
|
|
658
|
+
later becomes blocking the moment it lands)
|
|
494
659
|
`);
|
|
495
660
|
process.exit(exitCode);
|
|
496
661
|
}
|
|
497
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Decide whether the report clears the requested gate. Pure: reads only the
|
|
665
|
+
* report's existing `overall_score` / `max_score` / `checks[].pass` fields and
|
|
666
|
+
* returns a NEW object — no parallel scoring path, no mutation of `report`.
|
|
667
|
+
*
|
|
668
|
+
* WHY an unsatisfiable threshold is itself a failure: --min-score is in POINTS
|
|
669
|
+
* and the max differs per scope (repo 76, security 13, agents 4), so
|
|
670
|
+
* `--scope agents --min-score 70` can never pass. Saying so beats a red check
|
|
671
|
+
* nobody can act on — and beats silently passing it.
|
|
672
|
+
*/
|
|
673
|
+
function evaluateGate(report, options = {}) {
|
|
674
|
+
const minScore = options.minScore === undefined ? null : options.minScore;
|
|
675
|
+
const failOnFindings = Boolean(options.failOnFindings);
|
|
676
|
+
const failures = [];
|
|
677
|
+
|
|
678
|
+
if (minScore !== null) {
|
|
679
|
+
if (minScore > report.max_score) {
|
|
680
|
+
failures.push(`--min-score ${minScore} exceeds max_score ${report.max_score} for scope "${report.scope}" — unsatisfiable threshold.`);
|
|
681
|
+
} else if (report.overall_score < minScore) {
|
|
682
|
+
failures.push(`score ${report.overall_score}/${report.max_score} is below --min-score ${minScore} (scope "${report.scope}").`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (failOnFindings) {
|
|
687
|
+
const failing = report.checks.filter(check => !check.pass);
|
|
688
|
+
if (failing.length > 0) {
|
|
689
|
+
failures.push(`--fail-on-findings: ${failing.length} of ${report.checks.length} checks failing (${failing.map(check => check.id).join(', ')}).`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
enforced: minScore !== null || failOnFindings,
|
|
695
|
+
ok: failures.length === 0,
|
|
696
|
+
failures,
|
|
697
|
+
exitCode: failures.length === 0 ? 0 : 1,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
498
701
|
function main() {
|
|
499
702
|
try {
|
|
500
703
|
const args = parseArgs(process.argv);
|
|
@@ -507,6 +710,17 @@ function main() {
|
|
|
507
710
|
} else {
|
|
508
711
|
printText(report);
|
|
509
712
|
}
|
|
713
|
+
|
|
714
|
+
// Gate LAST and on stderr, so --format json keeps stdout pure JSON and the
|
|
715
|
+
// scorecard stays readable when the gate trips. There is deliberately no
|
|
716
|
+
// explicit process.exit(0) on the success path: falling off main() exits 0
|
|
717
|
+
// naturally, and a hardcoded exit(0) is how gates start lying.
|
|
718
|
+
const gate = evaluateGate(report, { minScore: args.minScore, failOnFindings: args.failOnFindings });
|
|
719
|
+
if (!gate.ok) {
|
|
720
|
+
console.error('');
|
|
721
|
+
for (const failure of gate.failures) { console.error(`Gate FAIL: ${failure}`); }
|
|
722
|
+
process.exit(gate.exitCode);
|
|
723
|
+
}
|
|
510
724
|
} catch (error) {
|
|
511
725
|
console.error(`Error: ${error.message}`);
|
|
512
726
|
process.exit(1);
|
|
@@ -517,4 +731,4 @@ if (require.main === module) {
|
|
|
517
731
|
main();
|
|
518
732
|
}
|
|
519
733
|
|
|
520
|
-
module.exports = { buildReport, parseArgs, getChecks, CATEGORIES, RUBRIC_VERSION };
|
|
734
|
+
module.exports = { buildReport, parseArgs, getChecks, evaluateGate, parseMinScore, hookWired, CATEGORIES, RUBRIC_VERSION };
|
|
@@ -15,6 +15,7 @@ const path = require('path');
|
|
|
15
15
|
const crypto = require('crypto');
|
|
16
16
|
const os = require('os');
|
|
17
17
|
const { detectProject } = require('./lib/detect-project');
|
|
18
|
+
const { redactSecrets } = require('../utils/redact-secrets');
|
|
18
19
|
|
|
19
20
|
// ── Configuration ────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -81,18 +82,25 @@ function extractPattern(payload) {
|
|
|
81
82
|
if (!command || command.length < 5) return null;
|
|
82
83
|
// Skip trivial commands
|
|
83
84
|
if (/^(ls|pwd|echo|cat|cd)\b/.test(command.trim())) return null;
|
|
85
|
+
// Redact BEFORE truncating. Slicing first can cut a credential mid-token, leaving a fragment
|
|
86
|
+
// that no longer matches its own prefix rule — a half-secret is still a secret.
|
|
87
|
+
const safe = redactSecrets(command).slice(0, 200);
|
|
84
88
|
return {
|
|
85
|
-
observation: `Bash command succeeded: ${
|
|
86
|
-
behavior: `Use pattern: ${
|
|
89
|
+
observation: `Bash command succeeded: ${safe}`,
|
|
90
|
+
behavior: `Use pattern: ${safe}`,
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
if (tool === 'task') {
|
|
91
95
|
const description = payload.description || payload.task_description || payload.name || '';
|
|
92
96
|
if (!description) return null;
|
|
97
|
+
// The Task branch leaks too, and it is easy to miss because it carries no "command" in its name.
|
|
98
|
+
// Measured: a payload {tool_name:'Task', status:'completed', description:'rotate prod secret
|
|
99
|
+
// using token <40 chars>'} wrote that token verbatim into BOTH fields.
|
|
100
|
+
const safe = redactSecrets(description).slice(0, 200);
|
|
93
101
|
return {
|
|
94
|
-
observation: `Task completed successfully: ${
|
|
95
|
-
behavior: `Reuse approach for similar tasks: ${
|
|
102
|
+
observation: `Task completed successfully: ${safe}`,
|
|
103
|
+
behavior: `Reuse approach for similar tasks: ${safe}`,
|
|
96
104
|
};
|
|
97
105
|
}
|
|
98
106
|
|
package/bin/install.js
CHANGED
|
@@ -20,13 +20,23 @@
|
|
|
20
20
|
|
|
21
21
|
'use strict';
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Resolved by package NAME, not by relative path. This file is copied into the consumer's project
|
|
24
|
+
// by --with-utils, where '../package.json' is THEIR manifest — so a relative read prints their app's
|
|
25
|
+
// version in the banner and in `--version`. resolveMindforgeVersion walks up for a package.json
|
|
26
|
+
// whose name is 'mindforge-cc', then falls back to <cwd>/.mindforge/config.json.
|
|
27
|
+
const VERSION = require('./utils/mindforge-version').resolveMindforgeVersion().version;
|
|
24
28
|
const ARGS = process.argv.slice(2);
|
|
25
29
|
const Theme = require('./wizard/theme');
|
|
26
30
|
const c = Theme.colors;
|
|
27
31
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
32
|
+
// The installation logic lives in ./installer-core.js; this file is the CLI entry point.
|
|
33
|
+
//
|
|
34
|
+
// A line here used to read "Structural integrity check requires the presence of 'verifyInstall'"
|
|
35
|
+
// — a comment whose only purpose was to contain a string that tests/install.test.js grepped for.
|
|
36
|
+
// The test asserted this file mentioned verifyInstall; the function had moved to installer-core.js
|
|
37
|
+
// and was never called from anywhere. The comment satisfied the test, the test protected the
|
|
38
|
+
// comment, and nothing verified an install. That test now asserts the delegation that actually
|
|
39
|
+
// matters, and installer-core.js calls verifyInstall for real.
|
|
30
40
|
|
|
31
41
|
// ── Minimum Node.js version gate ─────────────────────────────────────────────
|
|
32
42
|
const NODE_MAJOR = parseInt(process.versions.node.split('.')[0], 10);
|
|
@@ -50,6 +60,56 @@ if (ARGS.includes('--help') || ARGS.includes('-h')) {
|
|
|
50
60
|
process.exit(0);
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
// ── Reject unknown positional arguments ───────────────────────────────────────
|
|
64
|
+
//
|
|
65
|
+
// This installer has NO subcommands — it only takes flags. It also never looked at positionals, so
|
|
66
|
+
// any bare word was silently ignored and the installer just ran. That mattered because the last
|
|
67
|
+
// thing a successful install printed was, verbatim from bin/wizard/theme.js:
|
|
68
|
+
//
|
|
69
|
+
// Next steps:
|
|
70
|
+
// mindforge-cc init — Initialize your first workspace
|
|
71
|
+
//
|
|
72
|
+
// There is no `init`. Obeying the instruction re-ran the installer against whatever directory the
|
|
73
|
+
// user happened to be standing in: measured in an empty temp dir, `node bin/install.js init` wrote
|
|
74
|
+
// 1,836 files, created .claude/ .mindforge/ .planning/ bin/ plus CLAUDE.md, MINDFORGE.md and
|
|
75
|
+
// AGENTS_LEARNING.md, exited 0 — and re-printed the same instruction, so it loops. The real command
|
|
76
|
+
// is the slash command `/mindforge:init-project`, which the install has just placed in the harness.
|
|
77
|
+
//
|
|
78
|
+
// Rejecting is the right response rather than treating a positional as a target directory: this
|
|
79
|
+
// script's whole contract is expressed in flags, and guessing what a stray word meant is how
|
|
80
|
+
// `mindforge-cc /etc` becomes an interesting afternoon.
|
|
81
|
+
//
|
|
82
|
+
// `--runtime` takes its value as a SEPARATE token — installer-core.js's run() reads
|
|
83
|
+
// args[rtIdx + 1] after locating args.indexOf('--runtime') — so that token is skipped here; a
|
|
84
|
+
// naive "anything not starting with -" check would reject the documented `--runtime claude`.
|
|
85
|
+
//
|
|
86
|
+
// Cites the SYMBOL, not a line number. The original said :1112, which was exact when written and
|
|
87
|
+
// wrong by the time it merged: two PRs in the same batch grew installer-core.js from 1173 to 1347
|
|
88
|
+
// lines with 161 of them above that point, so the reference landed on unrelated code. A line
|
|
89
|
+
// number in a comment is a claim with an expiry date and nothing asserts it.
|
|
90
|
+
const VALUE_TAKING_FLAGS = new Set(['--runtime']);
|
|
91
|
+
const POSITIONALS = [];
|
|
92
|
+
for (let i = 0; i < ARGS.length; i++) {
|
|
93
|
+
if (ARGS[i].startsWith('-')) {
|
|
94
|
+
if (VALUE_TAKING_FLAGS.has(ARGS[i])) i += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
POSITIONALS.push(ARGS[i]);
|
|
98
|
+
}
|
|
99
|
+
if (POSITIONALS.length > 0) {
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`\n${c.red(Theme.chars.cross)} ${c.bold(`Unknown argument: ${POSITIONALS[0]}`)}\n` +
|
|
102
|
+
' mindforge-cc takes flags only — it has no subcommands.\n' +
|
|
103
|
+
(POSITIONALS[0] === 'init'
|
|
104
|
+
? ` To initialise a workspace, run the slash command ${c.cyan('/mindforge:init-project')}\n` +
|
|
105
|
+
' inside your AI harness after installing.\n'
|
|
106
|
+
: '') +
|
|
107
|
+
` Install: ${c.cyan('npx mindforge-cc --claude --local')}\n` +
|
|
108
|
+
` All flags: ${c.cyan('npx mindforge-cc --help')}\n\n`
|
|
109
|
+
);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
53
113
|
// ── Determine execution mode ──────────────────────────────────────────────────
|
|
54
114
|
const NON_INTERACTIVE_FLAGS = [
|
|
55
115
|
'--claude', '--antigravity', '--cursor', '--opencode', '--gemini', '--copilot',
|