mindforge-cc 11.9.2 → 11.9.4
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 +9 -2
- package/.mindforge/engine/temporal-protocol.md +2 -2
- package/.mindforge/governance/change-classifier.md +20 -4
- package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
- package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
- package/CHANGELOG.md +357 -0
- package/MINDFORGE.md +13 -6
- package/README.md +49 -30
- package/RELEASENOTES.md +65 -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 +52 -23
- 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 +24 -8
- package/bin/governance/policy-engine.js +33 -3
- package/bin/governance/policy-gate-hardened.js +36 -1
- package/bin/governance/verify-approvals.js +175 -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 +547 -0
- package/bin/installer-core.js +481 -65
- 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 +204 -0
- package/changelogs/v11.9.4.md +155 -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 +10 -4
- package/docs/sdk-reference.md +15 -7
- package/docs/troubleshooting.md +65 -6
- package/docs/user-guide.md +14 -14
- package/examples/sdk-integration/README.md +1 -1
- package/package.json +8 -3
- package/subagents/.claude-plugin/marketplace.json +1 -1
- package/.mindforge/memory/sync-manifest.json +0 -6
- package/bin/dashboard/approval-handler.js +0 -136
package/SECURITY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Security Policy
|
|
2
2
|
|
|
3
|
-
> **Current version:** 11.9.
|
|
3
|
+
> **Current version:** 11.9.4 | **npm audit:** 0 vulnerabilities across root, sdk, mcp-server
|
|
4
4
|
|
|
5
5
|
## Supported Versions
|
|
6
6
|
|
|
@@ -66,7 +66,9 @@ We follow responsible disclosure practices. We will credit reporters in the rele
|
|
|
66
66
|
|
|
67
67
|
### Audit & Integrity
|
|
68
68
|
|
|
69
|
-
- **
|
|
69
|
+
- **Hash-chained audit log (SHA-256 back-links)** — Every entry in `AUDIT.jsonl` carries the SHA-256
|
|
70
|
+
hash of the previous entry. Not a Merkle tree: there is no hash tree and no inclusion proof, so
|
|
71
|
+
"Merkle" was the wrong word for it. What it detects, and does not, is measured below.
|
|
70
72
|
- **AuditWriter with buffered writes** — Atomic append operations prevent partial writes from corrupting the log.
|
|
71
73
|
- **Log rotation with archival** — AUDIT.jsonl auto-archives beyond 5000 lines with gzip compression, preventing unbounded disk growth.
|
|
72
74
|
- **npm provenance** — Published packages include SLSA Build Level 2 attestation via `--provenance`, proving the package was built from the stated source commit in CI.
|
|
@@ -143,7 +145,24 @@ For security questions that are not vulnerability reports, open a GitHub Discuss
|
|
|
143
145
|
Tier-3 trust in v11.x uses **in-process key simulation** (`bin/governance/ztai-manager.js` `SecureEnclaveProvider`). Key material resides in the Node.js heap — it is NOT hardware-isolated. A real TPM/HSM provider is planned for v12.x. **Do not use Tier-3 trust for production credential workflows in v11.x.**
|
|
144
146
|
|
|
145
147
|
## Audit Log Tamper Evidence
|
|
146
|
-
The
|
|
148
|
+
The hash chain (`bin/governance/audit-hash.js`) is real and independently verifiable — `node
|
|
149
|
+
bin/verify-audit.js` recomputes it and exits 1 on a break. Measured on a 200-entry chain taken from
|
|
150
|
+
the head of a live log:
|
|
151
|
+
|
|
152
|
+
| Attack | Detected? | Evidence |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| Mutate a middle entry | **YES** | `BROKEN at entry 79: hash mismatch (entry mutated)`, exit 1 |
|
|
155
|
+
| Delete a middle entry | **YES** | `BROKEN at entry 79: previous_hash mismatch`, exit 1 |
|
|
156
|
+
| **Truncate the tail** | **NO** | 40 entries removed from the end → `audit chain valid: 160 entries`, **exit 0** |
|
|
157
|
+
| Replay identical entries | **NO** | the chain constrains content and ordering, not multiplicity |
|
|
158
|
+
|
|
159
|
+
The truncation gap is inherent to a bare hash chain: each entry commits to its predecessor, so a
|
|
160
|
+
prefix of a valid chain is itself a valid chain. Closing it needs a commitment to LENGTH — an external
|
|
161
|
+
anchor, a signed length attestation, or a witnessed head — none of which ships today.
|
|
162
|
+
|
|
163
|
+
So the accurate claim is **"detects mutation and mid-file deletion"**, not "immutable" or
|
|
164
|
+
"non-repudiable". Restrict OS-level write access to `.planning/AUDIT.jsonl`; the chain does not
|
|
165
|
+
substitute for filesystem permissions.
|
|
147
166
|
|
|
148
167
|
## Agent Dispatch (spawn mode)
|
|
149
168
|
`bin/spawn-agent.js` spawn mode exits with an error in v11.x — real agent dispatch is not yet implemented at the shell level. Use Claude Code slash commands (`/mindforge:auto`, `/mindforge:next`) to dispatch agents.
|
|
@@ -117,6 +117,12 @@ class AutoRunner {
|
|
|
117
117
|
this.phase = String(options.phase);
|
|
118
118
|
this.isHeadless = options.headless || false;
|
|
119
119
|
this.isPaused = false;
|
|
120
|
+
// Optional async function(task) that performs the actual work for one task. When absent,
|
|
121
|
+
// executeWave() aborts the wave rather than writing task_completed entries for work that would
|
|
122
|
+
// not run — see the comment there. Deliberately NOT defaulted to a no-op: bin/autonomous/
|
|
123
|
+
// wave-executor.js:133 defaults its own `executor` to `async () => {}`, and a silent no-op
|
|
124
|
+
// default is exactly how a wave came to report every task fulfilled while dispatching nothing.
|
|
125
|
+
this.taskExecutor = typeof options.taskExecutor === 'function' ? options.taskExecutor : null;
|
|
120
126
|
|
|
121
127
|
// Paths
|
|
122
128
|
const planningDir = path.join(process.cwd(), '.planning');
|
|
@@ -306,10 +312,30 @@ class AutoRunner {
|
|
|
306
312
|
runPreFlight() {
|
|
307
313
|
console.log('🔍 Running pre-flight checks...');
|
|
308
314
|
|
|
309
|
-
// UC-01: fail closed on version drift before any wave executes
|
|
315
|
+
// UC-01: fail closed on version drift before any wave executes.
|
|
316
|
+
//
|
|
317
|
+
// The root MUST be MindForge's own install root, NOT process.cwd(). This check compares
|
|
318
|
+
// package.json against .mindforge/config.json, sdk/package.json and MINDFORGE.md — all
|
|
319
|
+
// MindForge's own manifests. Pointed at a consumer's cwd it takes THEIR application
|
|
320
|
+
// version as canonical and reports MindForge's own config as drift. Measured, with an app
|
|
321
|
+
// at 1.0.0 and MindForge at 11.9.2:
|
|
322
|
+
// [".mindforge/config.json declares 11.9.2 but canonical (package.json) is 1.0.0"]
|
|
323
|
+
// and a consumer with no package.json at all fails closed on a missing canonical. Only a
|
|
324
|
+
// project whose app version coincidentally equalled MindForge's would have passed.
|
|
325
|
+
//
|
|
326
|
+
// This has never fired in a shipped build, because nothing constructs AutoRunner today
|
|
327
|
+
// (verified: no `new AutoRunner` anywhere in bin/; the only requirer is
|
|
328
|
+
// tests/wave-timeout-rollback.test.js, for the pure helpers). It is a landmine that arms
|
|
329
|
+
// the moment the runner is wired — which DEL-02 proposes doing — so it is disarmed here
|
|
330
|
+
// rather than left for whoever wires it to find in the field.
|
|
331
|
+
//
|
|
332
|
+
// __dirname/../.. is MindForge's root in both layouts: <repo> from a checkout, and
|
|
333
|
+
// <project>/node_modules/mindforge-cc when installed. In the installed shape
|
|
334
|
+
// .mindforge/config.json is absent (the installer writes that into the CONSUMER project),
|
|
335
|
+
// which reads as null and is SKIPPED rather than counted as drift — verified.
|
|
310
336
|
try {
|
|
311
337
|
const { assertVersionConsistency } = require('../utils/version-check');
|
|
312
|
-
assertVersionConsistency(
|
|
338
|
+
assertVersionConsistency(path.resolve(__dirname, '..', '..'));
|
|
313
339
|
} catch (e) {
|
|
314
340
|
throw new Error(`[pre-flight] ${e.message}`);
|
|
315
341
|
}
|
|
@@ -357,6 +383,40 @@ class AutoRunner {
|
|
|
357
383
|
|
|
358
384
|
console.log(`\n⚡ Wave ${waveNum}/${this.waves.length}: ${pending.length} tasks (concurrency: ${maxConcurrency})`);
|
|
359
385
|
if (idcStatus.action === 'UPGRADE_MIR') console.log(` [IDC-ACTIVE] MIR Override: ${idcStatus.new_mir}`);
|
|
386
|
+
|
|
387
|
+
// REFUSE TO FABRICATE. The task body below used to be, in full:
|
|
388
|
+
//
|
|
389
|
+
// try {
|
|
390
|
+
// this.writeAudit({ event: 'task_started', ... });
|
|
391
|
+
// this.writeAudit({ event: 'task_completed', ... duration_ms: Date.now() - taskStart });
|
|
392
|
+
// this.completedTasks.add(task.id);
|
|
393
|
+
//
|
|
394
|
+
// Nothing between the two writes. No dispatch. So every task was recorded as completed with a
|
|
395
|
+
// duration of ~0ms, the catch block was unreachable because nothing could throw, and the
|
|
396
|
+
// hash-chained audit log — the one component of this project that is genuinely production-grade
|
|
397
|
+
// and independently verifiable — became a tamper-evident record of statements that were false.
|
|
398
|
+
// A verifiable false record is worse than no record: it survives verify-audit and carries the
|
|
399
|
+
// authority of the chain.
|
|
400
|
+
//
|
|
401
|
+
// Measured before this change: task_completed = 0 in the live 3056-entry chain, and no module
|
|
402
|
+
// under bin/ requires auto-runner (every repo-wide match is a comment, a test, or an
|
|
403
|
+
// "extracted from" note). So the defect was LATENT — the code would have lied the first time
|
|
404
|
+
// anything invoked it, and nothing had. That is why this is a refusal rather than a cleanup:
|
|
405
|
+
// there is no contamination to repair, only a trap to disarm.
|
|
406
|
+
//
|
|
407
|
+
// A wave with no executor now writes ONE honest entry and throws, instead of N false ones.
|
|
408
|
+
if (typeof this.taskExecutor !== 'function') {
|
|
409
|
+
this.writeAudit({
|
|
410
|
+
event: 'wave_aborted', phase: this.phase, wave: waveNum, task_count: pending.length,
|
|
411
|
+
reason: 'no task executor is wired, so no task can run. Refusing to write task_completed '
|
|
412
|
+
+ 'entries for work that would not be performed — see auto-runner.executeWave.',
|
|
413
|
+
});
|
|
414
|
+
throw new Error(
|
|
415
|
+
`Wave ${waveNum} cannot execute: no task executor wired. Pass { taskExecutor } to the `
|
|
416
|
+
+ 'AutoRunner constructor, or drive waves through bin/autonomous/wave-executor.js. '
|
|
417
|
+
+ 'Refusing to record completions for work that would not run.');
|
|
418
|
+
}
|
|
419
|
+
|
|
360
420
|
this.writeAudit({ event: 'wave_started', phase: this.phase, wave: waveNum, task_count: pending.length });
|
|
361
421
|
|
|
362
422
|
const semaphore = new Semaphore(maxConcurrency);
|
|
@@ -368,6 +428,9 @@ class AutoRunner {
|
|
|
368
428
|
console.log(` → Task: ${task.name || task.id}`);
|
|
369
429
|
try {
|
|
370
430
|
this.writeAudit({ event: 'task_started', phase: this.phase, wave: waveNum, task_id: task.id, task_name: task.name || task.id });
|
|
431
|
+
// The dispatch that was missing. Its absence is what made the catch below unreachable and
|
|
432
|
+
// every duration_ms ~0; task_completed is now written only after real work returns.
|
|
433
|
+
await this.taskExecutor(task);
|
|
371
434
|
this.writeAudit({ event: 'task_completed', phase: this.phase, wave: waveNum, task_id: task.id, task_name: task.name || task.id, duration_ms: Date.now() - taskStart });
|
|
372
435
|
this.completedTasks.add(task.id);
|
|
373
436
|
return { taskId: task.id, status: 'fulfilled' };
|
package/bin/change-classifier.js
CHANGED
|
@@ -9,16 +9,55 @@
|
|
|
9
9
|
const { execSync, execFileSync } = require('child_process');
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
|
|
12
|
+
// Matched with String.prototype.startsWith against repo-relative diff paths.
|
|
13
|
+
//
|
|
14
|
+
// Two groups, because this file SHIPS (package.json files[] includes bin/) and therefore runs
|
|
15
|
+
// against two different trees:
|
|
16
|
+
//
|
|
17
|
+
// (a) CONSUMER paths — a project that installed MindForge. Measured against MindForge's own
|
|
18
|
+
// tree these match ZERO tracked files, which is correct: they describe the consumer's
|
|
19
|
+
// layout, not the framework's. Do not remove them.
|
|
20
|
+
// (b) FRAMEWORK paths — MindForge's OWN trust surface, which control-plane.yml classifies on
|
|
21
|
+
// every push and PR to this repo. These were absent, so the detector protected the five
|
|
22
|
+
// markdown files under .mindforge/governance/ while leaving the 15 executable modules
|
|
23
|
+
// under bin/governance/ — the audit hasher, the verifier, RBAC, the policy engine and
|
|
24
|
+
// approve.js itself — unclassified. Editing the prose ABOUT governance tripped Tier 3;
|
|
25
|
+
// editing the code that enforces it did not.
|
|
12
26
|
const SENSITIVE_PATHS = [
|
|
27
|
+
// (a) consumer-project layout
|
|
13
28
|
'auth/',
|
|
14
29
|
'payment/',
|
|
15
30
|
'security/',
|
|
31
|
+
// (b) MindForge's own trust surface
|
|
32
|
+
'bin/governance/', // audit-hash, audit-verifier, rbac, policy-engine, approve
|
|
33
|
+
'bin/security/', // trust-gate-hook and friends — NOT matched by 'security/'
|
|
34
|
+
'bin/hooks/', // instinct-capture-hook, context-monitor
|
|
35
|
+
'bin/models/',
|
|
36
|
+
'.agent/hooks/', // the hook dispatcher the installer copies
|
|
37
|
+
'.claude/settings.json', // hook registration
|
|
38
|
+
'.agent/settings.json',
|
|
39
|
+
'.planning/approvals/', // the approval records themselves
|
|
16
40
|
'.github/workflows/',
|
|
17
|
-
'.mindforge/governance/'
|
|
18
|
-
'bin/models/'
|
|
41
|
+
'.mindforge/governance/'
|
|
19
42
|
];
|
|
20
43
|
|
|
44
|
+
// Scanned against the DIFF CONTENT, so security-critical code in an innocuously-named file still
|
|
45
|
+
// reaches Tier 3 — that is the stated purpose of "Signal B" in
|
|
46
|
+
// .mindforge/governance/change-classifier.md.
|
|
47
|
+
//
|
|
48
|
+
// The doc lists 19 patterns; measured, only 6 of them were detected. The second group below was
|
|
49
|
+
// specified and absent: argon2 (a password hasher), the jose signing pair, paypal.,
|
|
50
|
+
// createCipheriv/createDecipheriv and crypto.subtle (cipher construction), encrypt(/decrypt(,
|
|
51
|
+
// role.*permission and hasPermission (authorization), and SET ROLE / GRANT (SQL privilege
|
|
52
|
+
// changes). tests/governance.test.js pins every documented pattern against the live set, so the
|
|
53
|
+
// doc and this array cannot drift apart again.
|
|
54
|
+
//
|
|
55
|
+
// Adding them cost nothing measurable: over the last 40 commits they caught ZERO additional
|
|
56
|
+
// changes, because anything they would flag was already Tier 3 by path or by one of the broad
|
|
57
|
+
// substring patterns above. So this closes a documented gap without moving the Tier-3 rate, which
|
|
58
|
+
// sat at 50% (path OR pattern) before and after.
|
|
21
59
|
const SENSITIVE_PATTERNS = [
|
|
60
|
+
// broad substring signals
|
|
22
61
|
/jwt/i,
|
|
23
62
|
/bcrypt/i,
|
|
24
63
|
/stripe/i,
|
|
@@ -26,18 +65,89 @@ const SENSITIVE_PATTERNS = [
|
|
|
26
65
|
/password/i,
|
|
27
66
|
/secret/i,
|
|
28
67
|
/token/i,
|
|
29
|
-
/PII
|
|
68
|
+
/PII/,
|
|
69
|
+
// the specified-but-missing set
|
|
70
|
+
/argon2/i,
|
|
71
|
+
/jose\.(sign|verify)/i,
|
|
72
|
+
/paypal\./i,
|
|
73
|
+
/createCipheriv/,
|
|
74
|
+
/createDecipheriv/,
|
|
75
|
+
/crypto\.subtle/,
|
|
76
|
+
/\bencrypt\(/,
|
|
77
|
+
/\bdecrypt\(/,
|
|
78
|
+
/role.*permission/i,
|
|
79
|
+
/hasPermission/,
|
|
80
|
+
/\bSET ROLE\b/i,
|
|
81
|
+
/\bGRANT\b/
|
|
30
82
|
];
|
|
31
83
|
|
|
84
|
+
/** True if `rev` resolves to an object in this clone (false on a shallow/partial fetch). */
|
|
85
|
+
function revExists(rev) {
|
|
86
|
+
try {
|
|
87
|
+
execFileSync('git', ['rev-parse', '--verify', '--quiet', `${rev}^{commit}`], { stdio: 'pipe' });
|
|
88
|
+
return true;
|
|
89
|
+
} catch { return false; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The pre-push tip GitHub reports for a push event, or '' if unavailable. */
|
|
93
|
+
function pushBefore() {
|
|
94
|
+
if (process.env.MINDFORGE_PUSH_BEFORE) return process.env.MINDFORGE_PUSH_BEFORE;
|
|
95
|
+
// Read the event payload rather than requiring the workflow to thread a variable through.
|
|
96
|
+
// A fix that depends on someone remembering `env: BEFORE: ${{ github.event.before }}` is a
|
|
97
|
+
// fix that silently reverts the first time a workflow is copied.
|
|
98
|
+
const p = process.env.GITHUB_EVENT_PATH;
|
|
99
|
+
if (!p || !fs.existsSync(p)) return '';
|
|
100
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')).before || ''; } catch { return ''; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the commit range to classify.
|
|
105
|
+
*
|
|
106
|
+
* A push is a RANGE, not a commit. The old code used HEAD~1..HEAD whenever GITHUB_BASE_REF was
|
|
107
|
+
* unset, so on a push it inspected only the tip. Measured: two commits where the first adds
|
|
108
|
+
* auth/login.js with a hardcoded password and the second is docs-only classify as TIER=1 on a
|
|
109
|
+
* push and TIER=3 as a PR — the sensitive file rides in completely unclassified. control-plane.yml
|
|
110
|
+
* triggers on `push: [main, develop]`, and this repo has allow_rebase_merge enabled, so the
|
|
111
|
+
* multi-commit push is the normal case, not an edge case.
|
|
112
|
+
*
|
|
113
|
+
* @returns {{range:string, how:string}}
|
|
114
|
+
* @throws {Error} with .failClosed when a CI push range cannot be resolved.
|
|
115
|
+
*/
|
|
116
|
+
function resolveRange() {
|
|
117
|
+
// Pull request: three-dot diffs against the MERGE-BASE, so a branch that is merely behind
|
|
118
|
+
// its base does not pick up base-only changes. (Two-dot here caused Tier-3 false positives.)
|
|
119
|
+
if (process.env.GITHUB_BASE_REF) {
|
|
120
|
+
return { range: `origin/${process.env.GITHUB_BASE_REF}...HEAD`, how: 'pull_request' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const inCiPush = process.env.GITHUB_EVENT_NAME === 'push';
|
|
124
|
+
if (inCiPush) {
|
|
125
|
+
const before = pushBefore();
|
|
126
|
+
const isBranchCreation = /^0{40}$/.test(before);
|
|
127
|
+
if (before && !isBranchCreation && revExists(before)) {
|
|
128
|
+
return { range: `${before}..HEAD`, how: 'push' };
|
|
129
|
+
}
|
|
130
|
+
// Fail CLOSED. Every remaining case genuinely cannot be scoped: a new branch has no prior
|
|
131
|
+
// tip, and a shallow clone cannot reach `before`. Falling back to HEAD~1 here is what let
|
|
132
|
+
// a sensitive commit ride in behind a benign tip.
|
|
133
|
+
const why = isBranchCreation ? 'branch creation (before is all-zeros)'
|
|
134
|
+
: !before ? 'no `before` in the push event payload'
|
|
135
|
+
: `\`before\` (${before.slice(0, 12)}) is not present in this clone — shallow fetch?`;
|
|
136
|
+
const err = new Error(`cannot scope the push range: ${why}`);
|
|
137
|
+
err.failClosed = true;
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Local invocation (`mindforge classify`). HEAD~1..HEAD is a developer convenience, not a
|
|
142
|
+
// gate, and must never be reached in CI — the branch above owns every CI push.
|
|
143
|
+
return { range: 'HEAD~1..HEAD', how: 'local' };
|
|
144
|
+
}
|
|
145
|
+
|
|
32
146
|
function classify() {
|
|
33
147
|
try {
|
|
34
|
-
|
|
35
|
-
// Three-dot (...) diffs against the MERGE-BASE, so on a PR branch that is behind its base
|
|
36
|
-
// we see ONLY this branch's own changes — not unrelated commits already on the base.
|
|
37
|
-
// (Two-dot here caused Tier-3 false positives by pulling in base-only changes.)
|
|
38
|
-
const base = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : 'HEAD~1';
|
|
39
|
-
const range = process.env.GITHUB_BASE_REF ? `${base}...HEAD` : `${base}..HEAD`;
|
|
148
|
+
const { range, how } = resolveRange();
|
|
40
149
|
const diffFiles = execFileSync('git', ['diff', '--name-only', range], { encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
150
|
+
if (process.env.MINDFORGE_CLASSIFY_DEBUG) console.error(`[classify] ${how}: ${range} (${diffFiles.length} file(s))`);
|
|
41
151
|
|
|
42
152
|
// Test and documentation files are excluded from the sensitive-PATTERN scan below: a test
|
|
43
153
|
// asserting on "password"/key patterns, or a doc mentioning secrets, is not a sensitive
|
|
@@ -50,10 +160,16 @@ function classify() {
|
|
|
50
160
|
let reasons = [];
|
|
51
161
|
|
|
52
162
|
// 1. Path-based detection (Tier 3)
|
|
53
|
-
|
|
54
|
-
|
|
163
|
+
// Reports EVERY matched path, not just the first. `find` short-circuited, so a change
|
|
164
|
+
// touching both .github/workflows/ and bin/governance/ was attributed only to the workflow —
|
|
165
|
+
// which made the CI log unable to show that the newly-added framework paths were doing any
|
|
166
|
+
// work, and hid the more interesting of the two matches from the reviewer.
|
|
167
|
+
const matchedPaths = diffFiles.filter(file => SENSITIVE_PATHS.some(p => file.startsWith(p)));
|
|
168
|
+
if (matchedPaths.length) {
|
|
55
169
|
tier = 3;
|
|
56
|
-
|
|
170
|
+
const shown = matchedPaths.slice(0, 5).join(', ');
|
|
171
|
+
const more = matchedPaths.length > 5 ? ` (+${matchedPaths.length - 5} more)` : '';
|
|
172
|
+
reasons.push(`Sensitive path${matchedPaths.length > 1 ? 's' : ''} modified: ${shown}${more}`);
|
|
57
173
|
}
|
|
58
174
|
|
|
59
175
|
// 2. Pattern-based detection in diff (Tier 3) — non-test/doc files only
|
|
@@ -94,13 +210,32 @@ function classify() {
|
|
|
94
210
|
|
|
95
211
|
return tier;
|
|
96
212
|
} catch (err) {
|
|
97
|
-
|
|
98
|
-
//
|
|
213
|
+
// Fail closed: an unscopeable change is treated as maximum risk. Emit the SAME
|
|
214
|
+
// TIER=/REASONS= lines as the success path so every consumer — the workflow's stdout
|
|
215
|
+
// parsing, GITHUB_OUTPUT, and a human reading the log — sees tier 3 rather than nothing.
|
|
216
|
+
// Previously only GITHUB_OUTPUT was written, so anything reading stdout saw no tier at all.
|
|
217
|
+
const reason = err.failClosed
|
|
218
|
+
? `Fail-closed: ${err.message}`
|
|
219
|
+
: `Fail-closed: classification error — ${err.message}`;
|
|
220
|
+
console.log('TIER=3');
|
|
221
|
+
console.log(`REASONS=${reason}`);
|
|
222
|
+
console.error(`❌ Classification could not be scoped — defaulting to Tier 3. ${err.message}`);
|
|
223
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
224
|
+
console.error(`::error title=Change classification failed closed::${reason}`);
|
|
225
|
+
}
|
|
99
226
|
if (process.env.GITHUB_OUTPUT) {
|
|
100
227
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'tier=3\n');
|
|
101
228
|
}
|
|
102
|
-
|
|
229
|
+
// Exit 0 deliberately: this step's contract is to REPORT a tier, and the downstream gate
|
|
230
|
+
// is what blocks. Exiting non-zero here would fail the job before the gate can annotate
|
|
231
|
+
// why. That contract is only honest while a gate actually consumes tier 3 — see
|
|
232
|
+
// tests/change-classifier.test.js, which pins that the tier-3 consumer exists.
|
|
233
|
+
return 3;
|
|
103
234
|
}
|
|
104
235
|
}
|
|
105
236
|
|
|
106
|
-
classify
|
|
237
|
+
module.exports = { classify, resolveRange, revExists, pushBefore, SENSITIVE_PATHS, SENSITIVE_PATTERNS };
|
|
238
|
+
|
|
239
|
+
// Behind a require.main guard so tests can import the pure pieces without triggering a run.
|
|
240
|
+
// bin/mindforge-cli.js:51 spawns this file as a script, which still executes normally.
|
|
241
|
+
if (require.main === module) classify();
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const Metrics = require('./metrics-aggregator');
|
|
10
|
-
const Approval = require('./approval-handler');
|
|
11
10
|
const SSE = require('./sse-bridge');
|
|
12
11
|
const { sendServerError } = require('./error-response');
|
|
13
12
|
|
|
@@ -64,7 +63,24 @@ function register(app) {
|
|
|
64
63
|
}
|
|
65
64
|
});
|
|
66
65
|
|
|
67
|
-
// ── Approvals
|
|
66
|
+
// ── Approvals (read-only) ───────────────────────────────────────────────────
|
|
67
|
+
// Returns the approval records that exist, each with its verified integrity state.
|
|
68
|
+
//
|
|
69
|
+
// `POST /api/approve/:id` was REMOVED. It implemented a pending-request -> decide workflow
|
|
70
|
+
// that nothing in MindForge has ever produced: bin/governance/approve.js records an
|
|
71
|
+
// already-made decision, and no code path anywhere writes a request awaiting one. The endpoint
|
|
72
|
+
// was unreachable six independent ways — it required a 36-char UUID id while the writer emits
|
|
73
|
+
// `MF-AUTH-<base36>`, required `status: 'pending'` which no producer writes, and looked for
|
|
74
|
+
// `APPROVAL-*.json` while the writer emits `approval-*.json`; the reader returned an object
|
|
75
|
+
// where the frontend expected an array; and the UI rendered `phase`, `plan` and `summary`,
|
|
76
|
+
// none of which any producer has ever written. It was speculative UI against a schema that
|
|
77
|
+
// never existed, not a flow with a bug.
|
|
78
|
+
//
|
|
79
|
+
// Disclosure note: requireAuth exempts GET, so this body is unauthenticated. That is
|
|
80
|
+
// proportionate here — the server binds to 127.0.0.1 only (ADR-017) as a single-operator
|
|
81
|
+
// tool, and the fields returned are the operator's own git-committed records. It is a
|
|
82
|
+
// different case from LEAK-01 (revops-api.js), which concerned error messages leaking
|
|
83
|
+
// filesystem paths the operator never chose to expose.
|
|
68
84
|
app.get('/api/approvals', (req, res) => {
|
|
69
85
|
try {
|
|
70
86
|
res.json(Metrics.getApprovals());
|
|
@@ -73,42 +89,6 @@ function register(app) {
|
|
|
73
89
|
}
|
|
74
90
|
});
|
|
75
91
|
|
|
76
|
-
app.post('/api/approve/:id', (req, res) => {
|
|
77
|
-
try {
|
|
78
|
-
const { id } = req.params;
|
|
79
|
-
const { decision, comment } = req.body || {};
|
|
80
|
-
|
|
81
|
-
if (!decision) {
|
|
82
|
-
return res.status(400).json({ error: 'Missing "decision" field (approve|reject)' });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// SECURITY (v11.5.1): do NOT trust a client-supplied `approver` for the
|
|
86
|
-
// recorded identity — it is forgeable and would let any caller write a
|
|
87
|
-
// false approval audit trail (e.g. resolved_by: 'admin'). requireAuth
|
|
88
|
-
// (server.js) proves the caller holds the owner-only dashboard token but
|
|
89
|
-
// exposes no named principal, so we attribute the action to a FIXED
|
|
90
|
-
// trusted actor. (A future RBAC pass can map a Bearer token -> DID and
|
|
91
|
-
// record the real principal; until then, never echo req.body.approver.)
|
|
92
|
-
const approver = 'dashboard-authenticated';
|
|
93
|
-
|
|
94
|
-
const result = Approval.processDecision(id, decision, comment, approver);
|
|
95
|
-
|
|
96
|
-
if (!result.success) {
|
|
97
|
-
return res.status(400).json(result);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Broadcast approval event to all SSE clients
|
|
101
|
-
SSE.broadcast(
|
|
102
|
-
decision === 'approve' ? 'approval:resolved' : 'approval:resolved',
|
|
103
|
-
{ approval_id: id, decision, message: result.message }
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
res.json(result);
|
|
107
|
-
} catch (err) {
|
|
108
|
-
sendServerError(res, 'POST /api/approve/:id', err, 'Failed to record approval decision');
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
92
|
// ── Team activity ───────────────────────────────────────────────────────────
|
|
113
93
|
app.get('/api/team', (req, res) => {
|
|
114
94
|
try {
|