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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Canonical approval-record integrity, shared by the writer and every verifier.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS — what the old record actually proved: nothing.
|
|
6
|
+
*
|
|
7
|
+
* bin/governance/approve.js stamped
|
|
8
|
+
* signature = sha256(`${id}:${reason}:${timestamp}:${os.hostname()}`)
|
|
9
|
+
* and called it a signature. Three problems, all measured:
|
|
10
|
+
*
|
|
11
|
+
* 1. It is NOT a signature. Nothing signs it; there is no key and no asymmetry. Anyone who
|
|
12
|
+
* can write the file can compute the value.
|
|
13
|
+
* 2. It is UNVERIFIABLE by design. `os.hostname()` is part of the preimage but is NOT a field
|
|
14
|
+
* of the record, so a verifier holding the record cannot reproduce the digest. CI never
|
|
15
|
+
* could have checked it.
|
|
16
|
+
* 3. Nothing checked it anyway. control-plane.yml asserted `!rec.signature` — presence only.
|
|
17
|
+
*
|
|
18
|
+
* So the field was simultaneously misnamed, uncheckable and unchecked. It is replaced by
|
|
19
|
+
* `record_checksum`: a plain SHA-256 over the record's OWN canonical fields, which any holder
|
|
20
|
+
* can recompute. That makes it a real tamper-check on the record's contents — and nothing more,
|
|
21
|
+
* which is why it is not called a signature.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THIS DOES NOT PROVIDE. A checksum over a git-tracked file is an INTEGRITY control, never
|
|
24
|
+
* an AUTHORIZATION one: anyone with push access can mint a fresh record with a valid checksum.
|
|
25
|
+
* Authorization on this repo comes from branch protection (a required PR plus required status
|
|
26
|
+
* checks on main and develop), not from a file. Verified live before this was written:
|
|
27
|
+
* `branches/{main,develop}/protection` returned 404 and `rulesets` was empty, so for the whole
|
|
28
|
+
* period this record format existed there was no enforcement layer of any kind behind it.
|
|
29
|
+
*
|
|
30
|
+
* The construction deliberately mirrors bin/governance/audit-hash.js — twelve lines, one
|
|
31
|
+
* function, used by writer and verifier alike. That module's discipline ("MUST be the single
|
|
32
|
+
* source of truth for both") is exactly what kept the audit chain honest while this record rotted,
|
|
33
|
+
* so it is copied rather than reinvented.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
|
|
38
|
+
/** Field written last and excluded from its own digest. */
|
|
39
|
+
const CHECKSUM_FIELD = 'record_checksum';
|
|
40
|
+
|
|
41
|
+
/** Current record schema. A record without it is a pre-v2 record and is not accepted. */
|
|
42
|
+
const SCHEMA = 'mindforge.approval/v2';
|
|
43
|
+
|
|
44
|
+
/** How long a recorded acknowledgement stays current. */
|
|
45
|
+
const TTL_HOURS = 72;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* SHA-256 over the record's own fields, with the checksum field removed.
|
|
49
|
+
*
|
|
50
|
+
* Determinism note: like audit-hash.js this relies on JSON.stringify emitting keys in insertion
|
|
51
|
+
* order, which is why writer and verifier MUST both go through this function. A verifier that
|
|
52
|
+
* rebuilt the object literal in a different order would compute a different digest and reject
|
|
53
|
+
* every valid record — so do not inline this construction anywhere, including in YAML.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} record
|
|
56
|
+
* @returns {string} `sha256:<hex>`
|
|
57
|
+
*/
|
|
58
|
+
function checksumRecord(record) {
|
|
59
|
+
const material = { ...record };
|
|
60
|
+
delete material[CHECKSUM_FIELD];
|
|
61
|
+
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(material)).digest('hex')}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** ISO timestamp `hours` after `fromIso`. */
|
|
65
|
+
function expiryFrom(fromIso, hours = TTL_HOURS) {
|
|
66
|
+
return new Date(new Date(fromIso).getTime() + hours * 3600 * 1000).toISOString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const REQUIRED_FIELDS = ['schema', 'id', 'project', 'version', 'tier', 'approved_by', 'timestamp', 'expires_at', 'reason', 'identity_verification'];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check one approval record's integrity and currency.
|
|
73
|
+
*
|
|
74
|
+
* This answers "is this record intact, and is it about the release being built?" It does NOT
|
|
75
|
+
* answer "was this change authorized" — see the header.
|
|
76
|
+
*
|
|
77
|
+
* The currency checks are the ones that matter in practice. A record with no expiry and no
|
|
78
|
+
* version binding satisfied the old gate in perpetuity: the committed approval was minted
|
|
79
|
+
* 2026-06-11 for version 11.5.1 and was still accepted 67 days and 286 commits later, against
|
|
80
|
+
* 11.9.2, because the gate read only the directory's contents.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} record
|
|
83
|
+
* @param {{currentVersion:string, now?:Date}} ctx
|
|
84
|
+
* @returns {{ok:boolean, problems:string[], stale:boolean}}
|
|
85
|
+
*/
|
|
86
|
+
function verifyRecord(record, ctx) {
|
|
87
|
+
const problems = [];
|
|
88
|
+
let stale = false;
|
|
89
|
+
|
|
90
|
+
if (!record || typeof record !== 'object') {
|
|
91
|
+
return { ok: false, problems: ['record is not a JSON object'], stale: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (record.schema !== SCHEMA) {
|
|
95
|
+
problems.push(
|
|
96
|
+
record.signature && !record.schema
|
|
97
|
+
? 'pre-v2 record (has the removed `signature` field, no `schema`). Its digest included ' +
|
|
98
|
+
'os.hostname(), which is not a field of the record, so it can never be verified. Re-mint it.'
|
|
99
|
+
: `unknown schema ${JSON.stringify(record.schema)} — expected ${SCHEMA}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const f of REQUIRED_FIELDS) {
|
|
103
|
+
if (record[f] === undefined || record[f] === null || record[f] === '') {
|
|
104
|
+
problems.push(`missing required field \`${f}\``);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (record[CHECKSUM_FIELD]) {
|
|
109
|
+
const expected = checksumRecord(record);
|
|
110
|
+
if (record[CHECKSUM_FIELD] !== expected) {
|
|
111
|
+
problems.push(`${CHECKSUM_FIELD} does not match the record contents — it has been edited since minting`);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
problems.push(`missing \`${CHECKSUM_FIELD}\``);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const iv = record.identity_verification;
|
|
118
|
+
const isVerified = iv && iv.verified === true;
|
|
119
|
+
const isAckedUnverified = iv && iv.verified === false
|
|
120
|
+
&& iv.method === 'git_identity_unverified' && iv.unverified_ack === true;
|
|
121
|
+
if (!isVerified && !isAckedUnverified) {
|
|
122
|
+
problems.push('identity is neither GPG-verified nor an explicitly acknowledged unverified ' +
|
|
123
|
+
'approval (verified:false + method git_identity_unverified + unverified_ack:true)');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Currency. Reported separately from malformedness because a stale record is a correctly-made
|
|
127
|
+
// record that has simply stopped applying — a different thing from a corrupt one.
|
|
128
|
+
if (record.version && ctx.currentVersion && record.version !== ctx.currentVersion) {
|
|
129
|
+
stale = true;
|
|
130
|
+
problems.push(`records version ${record.version} but the build is ${ctx.currentVersion} — ` +
|
|
131
|
+
'an approval does not carry forward to a later release');
|
|
132
|
+
}
|
|
133
|
+
const now = ctx.now || new Date();
|
|
134
|
+
if (record.expires_at) {
|
|
135
|
+
const exp = new Date(record.expires_at);
|
|
136
|
+
if (Number.isNaN(exp.getTime())) problems.push(`expires_at is not a valid date: ${record.expires_at}`);
|
|
137
|
+
else if (exp <= now) {
|
|
138
|
+
stale = true;
|
|
139
|
+
const ageH = Math.round((now - exp) / 3600000);
|
|
140
|
+
problems.push(`expired ${ageH}h ago (expires_at ${record.expires_at})`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { ok: problems.length === 0, problems, stale };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { checksumRecord, verifyRecord, expiryFrom, CHECKSUM_FIELD, SCHEMA, TTL_HOURS, REQUIRED_FIELDS };
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const os = require('os');
|
|
12
|
-
const crypto = require('crypto');
|
|
13
11
|
const { execFileSync } = require('child_process');
|
|
12
|
+
const { checksumRecord, expiryFrom, SCHEMA } = require('./approval-record');
|
|
14
13
|
|
|
15
14
|
const ROOT = path.resolve(__dirname, '../../');
|
|
16
15
|
const APPROVALS_DIR = path.join(ROOT, '.planning/approvals');
|
|
@@ -90,21 +89,27 @@ function approve(opts = {}) {
|
|
|
90
89
|
// Verify approver identity — THROWS fail-closed if unverifiable (before any write).
|
|
91
90
|
const identityVerification = verifyApproverIdentity(approver);
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
// The old `signature` field was sha256(id:reason:timestamp:os.hostname()). It was not a
|
|
93
|
+
// signature (nothing signs it), it could not be verified by anyone holding the record
|
|
94
|
+
// (hostname is not a field of it), and the only consumer asserted its PRESENCE. Replaced by a
|
|
95
|
+
// recomputable checksum over the record's own fields — see bin/governance/approval-record.js.
|
|
96
|
+
//
|
|
97
|
+
// `expires_at` is new and load-bearing: without it, the record committed on 2026-06-11 for
|
|
98
|
+
// version 11.5.1 still satisfied the Tier-3 gate 67 days and 286 commits later, against
|
|
99
|
+
// 11.9.2. An acknowledgement now stops applying, and it does not carry to a later release.
|
|
97
100
|
const record = {
|
|
101
|
+
schema: SCHEMA,
|
|
98
102
|
id,
|
|
99
103
|
project: pkg.name,
|
|
100
104
|
version: pkg.version,
|
|
101
105
|
tier: 3,
|
|
102
106
|
approved_by: approver,
|
|
103
107
|
timestamp,
|
|
108
|
+
expires_at: expiryFrom(timestamp),
|
|
104
109
|
reason,
|
|
105
|
-
signature: `sha256:${signature}`,
|
|
106
110
|
identity_verification: identityVerification
|
|
107
111
|
};
|
|
112
|
+
record.record_checksum = checksumRecord(record);
|
|
108
113
|
|
|
109
114
|
if (!fs.existsSync(approvalsDir)) fs.mkdirSync(approvalsDir, { recursive: true });
|
|
110
115
|
const filename = `approval-${id.toLowerCase()}.json`;
|
|
@@ -74,7 +74,17 @@ class PolicyEngine {
|
|
|
74
74
|
|
|
75
75
|
// [PQAS] v7: Hardened Biometric Bypass for Risk > 95
|
|
76
76
|
if (impactScore > 95) {
|
|
77
|
-
|
|
77
|
+
// The gate looks up the attestation by `intent.requestId`, which this method never
|
|
78
|
+
// set — it generated `requestId` as a local and passed the bare intent, so the lookup
|
|
79
|
+
// always received undefined. Measured: orbital-guardian returns
|
|
80
|
+
// {verified:false, reason:"missing requestId"}, so the attestation path could NEVER
|
|
81
|
+
// approve, even immediately after a successful recordBypass(). The gate was stuck
|
|
82
|
+
// closed, which is safe but means the whole hardware-attestation feature only ever
|
|
83
|
+
// denied. tests/v8-orbital-governance.test.js passes an object that DOES carry
|
|
84
|
+
// requestId, which is why the contract looked satisfied.
|
|
85
|
+
//
|
|
86
|
+
// A new object rather than a mutation: the caller's intent is not ours to modify.
|
|
87
|
+
const gateResult = await policyGate.evaluateBypass({ ...intent, requestId }, impactScore);
|
|
78
88
|
if (gateResult.status === 'WAIT_FOR_BIOMETRIC' || gateResult.status === 'WAIT_FOR_ORBITAL') {
|
|
79
89
|
verdict = {
|
|
80
90
|
verdict: 'DENY',
|
|
@@ -86,8 +96,28 @@ class PolicyEngine {
|
|
|
86
96
|
this.logAudit(intent, impactScore, verdict);
|
|
87
97
|
return verdict;
|
|
88
98
|
}
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
// Only claim an attestation when one was actually verified. evaluateBypass has TWO
|
|
100
|
+
// non-WAIT returns, and they mean opposite things: an attestation verified in the
|
|
101
|
+
// enclave (carries attestation_id), or "Impact within standard threshold" — which
|
|
102
|
+
// states that no attestation was required. The old code logged "Biometric signature
|
|
103
|
+
// verified" and recorded the audit reason "Authorized via Biometric Bypass
|
|
104
|
+
// [WEB-AUTHN-DEX]" for BOTH, so the audit trail would assert a biometric verification
|
|
105
|
+
// for an operation the gate had explicitly waved through unchecked. That is the audit
|
|
106
|
+
// equivalent of a gate printing success without running.
|
|
107
|
+
const attested = Boolean(gateResult.attestation_id);
|
|
108
|
+
if (attested) {
|
|
109
|
+
console.log(`[PQAS-GATE] [${requestId}] Hardware attestation ${gateResult.attestation_id} verified. Proceeding with high-risk mutation.`);
|
|
110
|
+
} else {
|
|
111
|
+
console.log(`[PQAS-GATE] [${requestId}] Gate returned ${gateResult.status} without an attestation: ${gateResult.reason}. NOT recording this as attested.`);
|
|
112
|
+
}
|
|
113
|
+
verdict = {
|
|
114
|
+
verdict: 'PERMIT',
|
|
115
|
+
reason: attested
|
|
116
|
+
? `Authorized via hardware attestation [${gateResult.attestation_id}]`
|
|
117
|
+
: `Permitted without attestation: ${gateResult.reason || gateResult.status}`,
|
|
118
|
+
requestId,
|
|
119
|
+
attested,
|
|
120
|
+
};
|
|
91
121
|
this.logAudit(intent, impactScore, verdict);
|
|
92
122
|
return verdict;
|
|
93
123
|
}
|
|
@@ -12,7 +12,42 @@ const configManager = require('../governance/config-manager');
|
|
|
12
12
|
class PolicyGateHardened {
|
|
13
13
|
constructor() {
|
|
14
14
|
// bypasses.json deprecated in favor of orbital.attestations table (v8)
|
|
15
|
-
|
|
15
|
+
// Impact scores are 0-100. This used to read `governance.critical_drift_threshold` with a
|
|
16
|
+
// default of 95 — but that key is a DRIFT RATIO, read by bin/engine/logic-drift-detector.js:23
|
|
17
|
+
// with a default of 0.50, and the shipped config sets it to 0.5. One key, two incompatible
|
|
18
|
+
// scales: the drift detector was correct and this gate silently received 0.5 where it expected
|
|
19
|
+
// ~95, making the "within standard threshold" branch below permanently dead.
|
|
20
|
+
//
|
|
21
|
+
// Dead was the SAFE direction, but it is one plausible edit from the unsafe one: raising that
|
|
22
|
+
// key to 95 to "fix" this gate would return ALLOWED for every high-risk score AND break drift
|
|
23
|
+
// detection, and policy-engine.js would then log "Biometric signature verified" for an
|
|
24
|
+
// operation where no attestation was ever attempted. Hence a dedicated key, plus a range guard
|
|
25
|
+
// so a ratio can never be mistaken for a score again.
|
|
26
|
+
this.criticalThreshold = this.resolveImpactThreshold();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The impact score (0-100) at or below which no hardware attestation is demanded.
|
|
31
|
+
*
|
|
32
|
+
* Rejects anything that is not on the 0-100 scale rather than trusting it. A value of 0.5 is a
|
|
33
|
+
* ratio that somebody has pointed at the wrong knob, and silently accepting it is how this gate
|
|
34
|
+
* came to compare a drift ratio against an impact score.
|
|
35
|
+
*
|
|
36
|
+
* @returns {number}
|
|
37
|
+
*/
|
|
38
|
+
resolveImpactThreshold(fallback = 95) {
|
|
39
|
+
const raw = configManager.get('governance.critical_impact_threshold', fallback);
|
|
40
|
+
const value = Number(raw);
|
|
41
|
+
if (!Number.isFinite(value) || value < 1 || value > 100) {
|
|
42
|
+
// Deliberately loud: a mis-scaled threshold changes whether high-risk mutations demand
|
|
43
|
+
// attestation at all, so it must not be absorbed quietly.
|
|
44
|
+
console.warn(
|
|
45
|
+
`[ORBITAL-GATE] governance.critical_impact_threshold=${JSON.stringify(raw)} is not an ` +
|
|
46
|
+
'impact score on the 0-100 scale (a value below 1 is usually a drift RATIO pointed at the ' +
|
|
47
|
+
`wrong key). Using ${fallback}.`);
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
16
51
|
}
|
|
17
52
|
|
|
18
53
|
/**
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* Verify the integrity of every committed approval record, and disclose Tier-3 changes.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node bin/governance/verify-approvals.js [--tier N] [--dir <path>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* Exit 0 = every record is intact and current (or there are none).
|
|
10
|
+
* Exit 1 = at least one record is malformed, tampered with, or stale.
|
|
11
|
+
*
|
|
12
|
+
* WHAT REPLACED WHAT, and why the contract changed.
|
|
13
|
+
*
|
|
14
|
+
* The previous gate lived inline in control-plane.yml and asked: "does .planning/approvals/
|
|
15
|
+
* contain at least one file that clears an identity bar?" Measured, that question has three
|
|
16
|
+
* fatal properties:
|
|
17
|
+
*
|
|
18
|
+
* * It is a pure function of the DIRECTORY, not of the change. Running the identical script in
|
|
19
|
+
* a temp dir containing only the committed approval — with no git repository at all — exited
|
|
20
|
+
* 0 with the same success message.
|
|
21
|
+
* * It therefore granted approval in perpetuity. One record, committed 2026-06-11 for version
|
|
22
|
+
* 11.5.1, satisfied it for 67 days and 286 commits, against 11.9.2.
|
|
23
|
+
* * It was committed in c6ec9a9 — the SAME commit that relaxed the gate to accept it.
|
|
24
|
+
*
|
|
25
|
+
* So the old step could not distinguish an approved change from an unapproved one. This script
|
|
26
|
+
* does not try to fix that by making the file mean more, because a git-tracked file cannot carry
|
|
27
|
+
* authorization: anyone who can push can write one, and MINDFORGE_ALLOW_UNVERIFIED_APPROVAL=1
|
|
28
|
+
* makes minting a one-liner. Verified live: before branch protection was enabled,
|
|
29
|
+
* `branches/{main,develop}/protection` returned 404 and `rulesets` was empty, so nothing stood
|
|
30
|
+
* behind the gate at all.
|
|
31
|
+
*
|
|
32
|
+
* The honest split, and what each half is worth:
|
|
33
|
+
*
|
|
34
|
+
* INTEGRITY (enforced here, can fail). Records must be well-formed, checksum-consistent,
|
|
35
|
+
* honestly marked as to identity, bound to the release being built, and unexpired. This is
|
|
36
|
+
* what stops a record being edited after the fact or recycled across releases, and it runs on
|
|
37
|
+
* EVERY build rather than only Tier-3 ones — a corrupt record is corrupt regardless of tier.
|
|
38
|
+
*
|
|
39
|
+
* AUTHORIZATION (not enforced here, deliberately). Enforced by branch protection: a required
|
|
40
|
+
* pull request plus required status checks on main and develop. On a Tier-3 change with no
|
|
41
|
+
* current acknowledgement this script DISCLOSES loudly and exits 0, rather than blocking. That
|
|
42
|
+
* is a deliberate choice: 28% of recent commits touch the trust surface, and blocking them
|
|
43
|
+
* behind a self-issued file would add friction proportional to nothing. Overstating a check is
|
|
44
|
+
* how the previous gate came to be trusted for two months while proving nothing.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const path = require('path');
|
|
49
|
+
const { verifyRecord } = require('./approval-record');
|
|
50
|
+
|
|
51
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
52
|
+
|
|
53
|
+
function parseArgs(argv) {
|
|
54
|
+
const out = { tier: null, dir: path.join(ROOT, '.planning', 'approvals'), json: false };
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
if (argv[i] === '--tier') out.tier = String(argv[++i] ?? '').trim();
|
|
57
|
+
else if (argv[i] === '--dir') out.dir = argv[++i];
|
|
58
|
+
else if (argv[i] === '--json') out.json = true;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** GitHub Actions annotations when running there; plain text otherwise. */
|
|
64
|
+
const inActions = () => Boolean(process.env.GITHUB_ACTIONS);
|
|
65
|
+
function notice(msg) { console.log(inActions() ? `::notice::${msg}` : msg); }
|
|
66
|
+
function error(msg) { console.error(inActions() ? `::error::${msg}` : msg); }
|
|
67
|
+
function summary(lines) {
|
|
68
|
+
const f = process.env.GITHUB_STEP_SUMMARY;
|
|
69
|
+
if (!f) return;
|
|
70
|
+
try { fs.appendFileSync(f, `${lines.join('\n')}\n`); } catch { /* summary is best-effort */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function main(argv = process.argv.slice(2)) {
|
|
74
|
+
const args = parseArgs(argv);
|
|
75
|
+
const currentVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
|
76
|
+
|
|
77
|
+
let files = [];
|
|
78
|
+
try {
|
|
79
|
+
files = fs.readdirSync(args.dir).filter((f) => f.endsWith('.json'));
|
|
80
|
+
} catch {
|
|
81
|
+
files = []; // an absent directory is not an error — it means no acknowledgements exist
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
const p = path.join(args.dir, f);
|
|
87
|
+
let rec;
|
|
88
|
+
try { rec = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
89
|
+
catch (e) {
|
|
90
|
+
results.push({ file: f, ok: false, stale: false, problems: [`not valid JSON: ${e.message}`] });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
results.push({ file: f, ...verifyRecord(rec, { currentVersion }) });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const broken = results.filter((r) => !r.ok);
|
|
97
|
+
const valid = results.filter((r) => r.ok);
|
|
98
|
+
|
|
99
|
+
if (args.json) {
|
|
100
|
+
console.log(JSON.stringify({ currentVersion, tier: args.tier, results }, null, 2));
|
|
101
|
+
} else {
|
|
102
|
+
for (const r of broken) {
|
|
103
|
+
error(`approval ${r.file} is not usable:`);
|
|
104
|
+
for (const p of r.problems) console.error(` - ${p}`);
|
|
105
|
+
}
|
|
106
|
+
for (const r of valid) notice(`approval ${r.file} is intact and current (${currentVersion}).`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (broken.length) {
|
|
110
|
+
error(`${broken.length} approval record(s) rejected. Remove the stale file, or re-mint with ` +
|
|
111
|
+
'`node bin/governance/approve.js "<reason>"`. A record does not carry forward to a later release.');
|
|
112
|
+
summary([
|
|
113
|
+
'## 🔴 Approval record integrity failed',
|
|
114
|
+
'',
|
|
115
|
+
...broken.flatMap((r) => [`**${r.file}**`, ...r.problems.map((p) => `- ${p}`), '']),
|
|
116
|
+
]);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!results.length) notice('No approval records present — nothing to verify.');
|
|
121
|
+
|
|
122
|
+
// Tier-3 disclosure. Never blocks — see the header.
|
|
123
|
+
if (args.tier === '3') {
|
|
124
|
+
if (valid.length) {
|
|
125
|
+
notice(`Tier 3 change with ${valid.length} current acknowledgement(s) on record.`);
|
|
126
|
+
summary([
|
|
127
|
+
'## ⚖️ Tier 3 change — acknowledged',
|
|
128
|
+
'',
|
|
129
|
+
...valid.map((r) => `- \`${r.file}\``),
|
|
130
|
+
'',
|
|
131
|
+
'Recorded acknowledgement only. Authorization is enforced by branch protection ' +
|
|
132
|
+
'(required PR + required checks), not by this file.',
|
|
133
|
+
]);
|
|
134
|
+
} else {
|
|
135
|
+
notice('Tier 3 change with NO recorded acknowledgement. Not blocking — see the summary.');
|
|
136
|
+
summary([
|
|
137
|
+
'## ⚖️ Tier 3 change — touches the trust surface',
|
|
138
|
+
'',
|
|
139
|
+
'This change modifies a sensitive path (auth/payment/security, `bin/governance/`, ',
|
|
140
|
+
'`bin/security/`, hook registration, workflows or the approval records themselves) ',
|
|
141
|
+
'and there is **no current acknowledgement** in `.planning/approvals/`.',
|
|
142
|
+
'',
|
|
143
|
+
'**This check does not block it, on purpose.** A git-tracked approval file cannot carry ',
|
|
144
|
+
'authorization: anyone who can push can write one.',
|
|
145
|
+
'',
|
|
146
|
+
'Authorization comes from branch protection — a required pull request plus required ',
|
|
147
|
+
'status checks on `main` and `develop`.',
|
|
148
|
+
'',
|
|
149
|
+
'To record a human acknowledgement (integrity-checked, 72h, bound to this release):',
|
|
150
|
+
'',
|
|
151
|
+
'```bash',
|
|
152
|
+
'node bin/governance/approve.js "what you reviewed and why it is safe"',
|
|
153
|
+
'```',
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { main, parseArgs };
|
|
162
|
+
|
|
163
|
+
if (require.main === module) process.exit(main());
|