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.
Files changed (95) hide show
  1. package/.agent/CLAUDE.md +37 -13
  2. package/.agent/hooks/mindforge-block-no-verify.js +61 -13
  3. package/.agent/hooks/mindforge-config-protection.js +82 -3
  4. package/.agent/hooks/mindforge-context-monitor.js +1 -1
  5. package/.agent/hooks/mindforge-workflow-guard.js +2 -2
  6. package/.agent/hooks/run-with-flags.js +190 -20
  7. package/.agent/mindforge/browse.md +2 -2
  8. package/.agent/mindforge/checkpoint.md +1 -1
  9. package/.agent/mindforge/harness-audit.md +1 -1
  10. package/.agent/mindforge/orch-add-feature.md +1 -1
  11. package/.agent/mindforge/orch-build-mvp.md +1 -1
  12. package/.agent/mindforge/orch-change-feature.md +1 -1
  13. package/.agent/mindforge/orch-fix-defect.md +1 -1
  14. package/.agent/mindforge/orch-refine-code.md +1 -1
  15. package/.agent/mindforge/qa.md +2 -2
  16. package/.claude/CLAUDE.md +37 -13
  17. package/.claude/commands/mindforge/browse.md +2 -2
  18. package/.claude/commands/mindforge/checkpoint.md +1 -1
  19. package/.claude/commands/mindforge/harness-audit.md +1 -1
  20. package/.claude/commands/mindforge/orch-add-feature.md +1 -1
  21. package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
  22. package/.claude/commands/mindforge/orch-change-feature.md +1 -1
  23. package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
  24. package/.claude/commands/mindforge/orch-refine-code.md +1 -1
  25. package/.claude/commands/mindforge/qa.md +2 -2
  26. package/.mindforge/MINDFORGE-SCHEMA.json +1 -1
  27. package/.mindforge/config.json +3 -3
  28. package/.mindforge/engine/autonomous/headless-adapter.md +9 -2
  29. package/.mindforge/engine/temporal-protocol.md +2 -2
  30. package/.mindforge/governance/change-classifier.md +20 -4
  31. package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
  32. package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
  33. package/CHANGELOG.md +357 -0
  34. package/MINDFORGE.md +13 -6
  35. package/README.md +49 -30
  36. package/RELEASENOTES.md +65 -2
  37. package/SECURITY.md +22 -3
  38. package/bin/autonomous/auto-runner.js +65 -2
  39. package/bin/change-classifier.js +151 -16
  40. package/bin/dashboard/api-router.js +18 -38
  41. package/bin/dashboard/frontend/app.js +429 -0
  42. package/bin/dashboard/frontend/index.html +13 -406
  43. package/bin/dashboard/metrics-aggregator.js +52 -23
  44. package/bin/dashboard/server.js +160 -1
  45. package/bin/dashboard/sse-bridge.js +11 -8
  46. package/bin/engine/sre-manager.js +1 -1
  47. package/bin/engine/temporal-cli.js +56 -6
  48. package/bin/engine/verification-runner.js +134 -17
  49. package/bin/engine/verify-cli.js +25 -7
  50. package/bin/governance/approval-record.js +147 -0
  51. package/bin/governance/approve.js +24 -8
  52. package/bin/governance/policy-engine.js +33 -3
  53. package/bin/governance/policy-gate-hardened.js +36 -1
  54. package/bin/governance/verify-approvals.js +175 -0
  55. package/bin/harness-audit.js +224 -10
  56. package/bin/hooks/instinct-capture-hook.js +12 -4
  57. package/bin/install.js +63 -3
  58. package/bin/installer/harness-adapter-compliance.js +339 -28
  59. package/bin/installer/hook-registration.js +547 -0
  60. package/bin/installer-core.js +481 -65
  61. package/bin/learning/instinct-cli.js +7 -0
  62. package/bin/memory/vector-hub.js +196 -13
  63. package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
  64. package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
  65. package/bin/mindforge-cli.js +67 -6
  66. package/bin/models/cost-tracker.js +104 -6
  67. package/bin/models/model-client.js +6 -1
  68. package/bin/revops/debt-monitor.js +57 -13
  69. package/bin/security/trust-gate-hook.js +50 -6
  70. package/bin/skill-validator.js +6 -1
  71. package/bin/skills-builder/skill-scorer.js +46 -6
  72. package/bin/updater/self-update.js +6 -1
  73. package/bin/updater/version-comparator.js +21 -1
  74. package/bin/utils/mindforge-version.js +99 -0
  75. package/bin/utils/redact-secrets.js +106 -0
  76. package/bin/validate-config.js +42 -2
  77. package/bin/wizard/setup-wizard.js +4 -1
  78. package/bin/wizard/theme.js +9 -1
  79. package/changelogs/index.json +11 -9
  80. package/changelogs/v11.9.3.md +204 -0
  81. package/changelogs/v11.9.4.md +155 -0
  82. package/docs/References/config-reference.md +5 -2
  83. package/docs/References/sdk-api.md +1 -1
  84. package/docs/Templates/Codebase/architecture.md +1 -1
  85. package/docs/commands-reference.md +4 -5
  86. package/docs/faq.md +25 -5
  87. package/docs/getting-started.md +10 -4
  88. package/docs/sdk-reference.md +15 -7
  89. package/docs/troubleshooting.md +65 -6
  90. package/docs/user-guide.md +14 -14
  91. package/examples/sdk-integration/README.md +1 -1
  92. package/package.json +8 -3
  93. package/subagents/.claude-plugin/marketplace.json +1 -1
  94. package/.mindforge/memory/sync-manifest.json +0 -6
  95. 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.0 | **npm audit:** 0 vulnerabilities across root, sdk, mcp-server
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
- - **Merkle-chain audit log** — Every entry in `AUDIT.jsonl` includes a SHA-256 hash of the previous entry. Tampering with any historical entry breaks the chain, making modifications detectable.
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 Merkle chain (`bin/governance/audit-hash.js`) provides tamper-evidence for content and ordering but does not prevent replay of identical entries. Restrict OS-level write access to `.planning/AUDIT.jsonl` to prevent replay attacks.
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(process.cwd());
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' };
@@ -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
- // Get list of changed files compared to origin/<base> or HEAD~1.
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
- const matchedPath = diffFiles.find(file => SENSITIVE_PATHS.some(p => file.startsWith(p)));
54
- if (matchedPath) {
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
- reasons.push(`Sensitive path modified: ${matchedPath}`);
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
- console.error(`❌ Classification failed: ${err.message}`);
98
- // Default to Tier 3 for safety if classification fails
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
- process.exit(0); // Don't fail the pipeline yet, let the gate handle it
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 {