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
|
@@ -49,21 +49,119 @@ function getTodaySpendCached() {
|
|
|
49
49
|
return _dailyCache.value;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// COST-02 — the $25/day cap declared at MINDFORGE.md:54 was inert from the day it
|
|
53
|
+
// was written. v11.9.2 read `settings.MODEL_COST_HARD_LIMIT_USD`; the registry
|
|
54
|
+
// declares `[COST_HARD_LIMIT_USD]`. The MODEL_-prefixed name appears in exactly one
|
|
55
|
+
// shipped file (.mindforge/MINDFORGE-V2-SCHEMA.json:58) and that file has no code
|
|
56
|
+
// reader, so the lookup was always undefined -> parseFloat('0.0') -> 0 -> the
|
|
57
|
+
// `hardLimit <= 0` guard returned before any spend was compared. Canonical registry
|
|
58
|
+
// key first; the MODEL_-prefixed name is still read second so anyone who copied it
|
|
59
|
+
// out of the V2 schema keeps the working cap they have instead of silently losing it.
|
|
60
|
+
const HARD_LIMIT_KEYS = ['COST_HARD_LIMIT_USD', 'MODEL_COST_HARD_LIMIT_USD'];
|
|
61
|
+
const WARN_LIMIT_KEYS = ['COST_WARN_USD', 'MODEL_COST_WARN_USD'];
|
|
62
|
+
|
|
63
|
+
/** First non-empty key from `keys`, canonical-first. Returns null when none is set. */
|
|
64
|
+
function findThreshold(settings, keys) {
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
const raw = settings[key];
|
|
67
|
+
if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
|
|
68
|
+
return { key, raw: String(raw) };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Classify a registry cost threshold. Returns a new object, never mutates input:
|
|
76
|
+
* { state: 'unset' } key absent/empty -> caller fails OPEN
|
|
77
|
+
* { state: 'disabled', key, value:0 } explicit 0 -> caller fails OPEN
|
|
78
|
+
* { state: 'armed', key, value } finite positive USD -> caller enforces
|
|
79
|
+
* { state: 'invalid', key, raw } unreadable or negative -> caller fails CLOSED
|
|
80
|
+
*
|
|
81
|
+
* parseFloat after stripping a leading `$` and thousands separators — not Number() —
|
|
82
|
+
* because `= $25.00` and `= 25.00 USD` are shapes a human types into MINDFORGE.md and
|
|
83
|
+
* both plainly mean 25; Number() would call them invalid and refuse every model call.
|
|
84
|
+
* Only a value with no leading number at all is invalid. Non-finite is invalid on
|
|
85
|
+
* purpose: `Infinity` compares false against every projection, i.e. it is not a cap.
|
|
86
|
+
*/
|
|
87
|
+
function classifyThreshold(found) {
|
|
88
|
+
if (!found) return { state: 'unset' };
|
|
89
|
+
const value = parseFloat(found.raw.trim());
|
|
90
|
+
if (!Number.isFinite(value) || value < 0) return { state: 'invalid', key: found.key, raw: found.raw };
|
|
91
|
+
if (value === 0) return { state: 'disabled', key: found.key, value: 0 };
|
|
92
|
+
return { state: 'armed', key: found.key, value };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// [COST_WARN_USD] had no reader anywhere in bin/ before COST-02. At most one line per
|
|
96
|
+
// UTC day per threshold, so an armed warning does not append to stderr on every call.
|
|
97
|
+
// Replaced as a whole object rather than mutated in place.
|
|
98
|
+
let _warnState = { day: '', threshold: 0 };
|
|
99
|
+
|
|
100
|
+
/** Soft threshold. Must never throw — a warning that blocks is a second hard cap. */
|
|
101
|
+
function warnIfCrossed(settings, projected, hardLimit) {
|
|
102
|
+
const warn = classifyThreshold(findThreshold(settings, WARN_LIMIT_KEYS));
|
|
103
|
+
if (warn.state !== 'armed') return;
|
|
104
|
+
// Suppress only when an ARMED hard cap sits at or below the warn value — there the
|
|
105
|
+
// throw preempts this line anyway. With no hard cap, nothing preempts it, and an
|
|
106
|
+
// upgraded install without [COST_HARD_LIMIT_USD] is the common case
|
|
107
|
+
// (installer-core.js:706 never rewrites an existing MINDFORGE.md) — precisely the
|
|
108
|
+
// install that most needs a spend warning.
|
|
109
|
+
if (hardLimit !== null && warn.value >= hardLimit) return;
|
|
110
|
+
if (projected < warn.value) return;
|
|
111
|
+
|
|
112
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
113
|
+
if (_warnState.day === today && _warnState.threshold === warn.value) return;
|
|
114
|
+
_warnState = { day: today, threshold: warn.value };
|
|
115
|
+
const cap = hardLimit === null ? 'no hard cap set' : `hard cap ${hardLimit}`;
|
|
116
|
+
process.stderr.write(
|
|
117
|
+
`[cost-tracker] Projected daily spend ${projected.toFixed(4)} crossed [${warn.key}] = ${warn.value} (${cap})\n`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
52
121
|
async function preflight(estimatedCost = 0) {
|
|
53
122
|
const settings = require('./model-router').getAllSettings();
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
123
|
+
const limit = classifyThreshold(findThreshold(settings, HARD_LIMIT_KEYS));
|
|
124
|
+
|
|
125
|
+
// Fail CLOSED on a limit that is present but unreadable: a cap nobody can parse is
|
|
126
|
+
// not a cap. Not a new surprise either — bin/validate-config.js already rejects this
|
|
127
|
+
// exact config with exit 1, because COST_HARD_LIMIT_USD is typed "number" at
|
|
128
|
+
// .mindforge/MINDFORGE-SCHEMA.json:87. The code is distinct from COST_LIMIT_REACHED
|
|
129
|
+
// so a caller can tell a spend stop from a config fault; bin/models/model-client.js
|
|
130
|
+
// re-throws both, which is the only reason this throw is not swallowed.
|
|
131
|
+
if (limit.state === 'invalid') {
|
|
132
|
+
throw Object.assign(
|
|
133
|
+
new Error(`[${limit.key}] = "${limit.raw}" is not a USD amount — the daily cost cap cannot be evaluated. Set a number in MINDFORGE.md (0 disables the cap).`),
|
|
134
|
+
{ code: 'COST_LIMIT_MISCONFIGURED', key: limit.key, raw: limit.raw }
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Fail OPEN when the key is absent or explicitly 0. DELIBERATE — do not invert it.
|
|
139
|
+
// docs/research/2026-08-v12-upgrade-report.md:85 recommends making an unset limit a
|
|
140
|
+
// config error, but bin/installer-core.js:706 writes MINDFORGE.md only when it does
|
|
141
|
+
// not already exist, so every install upgraded from a registry predating this key
|
|
142
|
+
// would start refusing every model call. The shipped schema agrees the key is
|
|
143
|
+
// optional: it sits in `recommended`, not `required` (.mindforge/MINDFORGE-SCHEMA.json
|
|
144
|
+
// :13-17), and bin/validate-config.js:48-49 only warns and exits 0 when it is absent.
|
|
145
|
+
if (limit.state !== 'armed') {
|
|
146
|
+
// The soft threshold must NOT depend on the hard cap. Guarded on the warn key so
|
|
147
|
+
// the no-cost-config fast path stays a pure early return and never reads the ledger.
|
|
148
|
+
if (classifyThreshold(findThreshold(settings, WARN_LIMIT_KEYS)).state === 'armed') {
|
|
149
|
+
warnIfCrossed(settings, getTodaySpendCached() + estimatedCost, null);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
57
153
|
|
|
58
154
|
const todaySpend = getTodaySpendCached();
|
|
59
155
|
const projected = todaySpend + estimatedCost;
|
|
60
156
|
|
|
61
|
-
if (projected >=
|
|
157
|
+
if (projected >= limit.value) {
|
|
62
158
|
throw Object.assign(
|
|
63
|
-
new Error(`Daily cost limit $${
|
|
64
|
-
{ code: 'COST_LIMIT_REACHED', spend: todaySpend, limit:
|
|
159
|
+
new Error(`Daily cost limit $${limit.value} reached (Today: $${todaySpend.toFixed(4)})`),
|
|
160
|
+
{ code: 'COST_LIMIT_REACHED', spend: todaySpend, limit: limit.value }
|
|
65
161
|
);
|
|
66
162
|
}
|
|
163
|
+
|
|
164
|
+
warnIfCrossed(settings, projected, limit.value);
|
|
67
165
|
}
|
|
68
166
|
|
|
69
167
|
async function record(entry) {
|
|
@@ -41,10 +41,15 @@ class ModelClient {
|
|
|
41
41
|
let modelId = routing.model;
|
|
42
42
|
|
|
43
43
|
// 2. Pre-flight cost check
|
|
44
|
+
// Both COST_ codes must propagate. This catch re-threw only COST_LIMIT_REACHED,
|
|
45
|
+
// so COST-02's fail-closed config fault would have been discarded here and the
|
|
46
|
+
// call would have proceeded uncapped — an interlock reporting success while doing
|
|
47
|
+
// nothing. Anything else still falls through on purpose: an unreadable ledger is
|
|
48
|
+
// a broken meter, and a broken meter must not block work.
|
|
44
49
|
try {
|
|
45
50
|
await CostTracker.preflight(0.05); // Conservative estimate
|
|
46
51
|
} catch (e) {
|
|
47
|
-
if (e.code === 'COST_LIMIT_REACHED') throw e;
|
|
52
|
+
if (e.code === 'COST_LIMIT_REACHED' || e.code === 'COST_LIMIT_MISCONFIGURED') throw e;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
// 3. Execute with fallbacks
|
|
@@ -4,27 +4,69 @@
|
|
|
4
4
|
*/
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// A `require('fs')` and a `this.auditPath = path.join(...)` used to sit here. Both were dead —
|
|
8
|
+
// `grep 'fs\.'` over this file returns nothing and no caller reads `auditPath` — and their presence
|
|
9
|
+
// implied this module reads the audit log, which it does not. Its only input is the array handed to
|
|
10
|
+
// monitor() by bin/dashboard/revops-api.js. Removed so the module's actual surface is legible.
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The score is computed from exactly these three event types. If an audit log contains none of them,
|
|
14
|
+
* this module measured NOTHING — which is a different state from measuring cleanly, and the whole
|
|
15
|
+
* point of the sentinel below.
|
|
16
|
+
*/
|
|
17
|
+
const TRACKED_EVENTS = ['security_finding', 'approval_granted', 'policy_bypass'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reported in place of a score, a status and a debt level when nothing was measured.
|
|
21
|
+
*
|
|
22
|
+
* WHY A STRING and not null. Both dashboard frontends call `.toUpperCase()` on `governance_status`
|
|
23
|
+
* and `debt_level` (index.html:735,747 and, in PR #186's extracted copy, app.js:395,407 — verified
|
|
24
|
+
* byte-identical), so null or a number is a hard TypeError there, not a blank tile.
|
|
25
|
+
* `security_health_score` is assigned with a bare `textContent =` (index.html:732), so a string
|
|
26
|
+
* passes straight through and renders as the word. Confirmed by loading the real render function into
|
|
27
|
+
* a headless browser: the tile reads "Unmeasured", the badge "STATUS: UNMEASURED", and the `> 80`
|
|
28
|
+
* colour comparison is false, so the badge goes red rather than green. No frontend edit needed.
|
|
29
|
+
*/
|
|
30
|
+
const UNMEASURED = 'Unmeasured';
|
|
14
31
|
|
|
32
|
+
class DebtMonitor {
|
|
15
33
|
/**
|
|
16
34
|
* Monitor governance debt and security health.
|
|
17
35
|
* @param {Object} metrics - From MetricsAggregator
|
|
18
36
|
*/
|
|
19
37
|
monitor(metrics) {
|
|
20
38
|
const auditEntries = metrics.auditEntries || [];
|
|
21
|
-
|
|
22
|
-
// 1. Identify high-risk events
|
|
23
|
-
const criticalFindings = auditEntries.filter(e => e.event === 'security_finding' && e.severity === 'critical');
|
|
24
|
-
const tier3Approvals = auditEntries.filter(e => e.event === 'approval_granted' && e.tier === 3);
|
|
25
|
-
const policyBypasses = auditEntries.filter(e => e.event === 'policy_bypass');
|
|
26
39
|
|
|
27
|
-
//
|
|
40
|
+
// 1. Identify high-risk events.
|
|
41
|
+
// Severity is compared case-INSENSITIVELY. It used to require lowercase 'critical', while
|
|
42
|
+
// .mindforge/audit/AUDIT-SCHEMA.md:269 declares severity uppercase ("HIGH"). Measured: three
|
|
43
|
+
// schema-compliant `severity: 'CRITICAL'` findings scored 100/"Excellent"/"Minimal", while the
|
|
44
|
+
// same three lowercase scored 70/"Warning"/"Moderate" — so a compliant writer was invisible.
|
|
45
|
+
const criticalFindings = auditEntries.filter((e) => e.event === 'security_finding'
|
|
46
|
+
&& String(e.severity || '').toLowerCase() === 'critical');
|
|
47
|
+
const tier3Approvals = auditEntries.filter((e) => e.event === 'approval_granted' && e.tier === 3);
|
|
48
|
+
const policyBypasses = auditEntries.filter((e) => e.event === 'policy_bypass');
|
|
49
|
+
|
|
50
|
+
// 2. Refuse to report health that was never measured.
|
|
51
|
+
// Previously an absent audit log, an empty one, and a clean one were indistinguishable: all
|
|
52
|
+
// three returned 100 / "Excellent" / "Minimal". Measured, the absent and empty cases were
|
|
53
|
+
// byte-identical but for the timestamp. Reporting a maximum for the absence of evidence is
|
|
54
|
+
// the inverse of what a security score is for.
|
|
55
|
+
const measuredEvents = auditEntries.filter((e) => TRACKED_EVENTS.includes(e.event)).length;
|
|
56
|
+
if (measuredEvents === 0) {
|
|
57
|
+
return {
|
|
58
|
+
security_health_score: UNMEASURED,
|
|
59
|
+
governance_status: UNMEASURED,
|
|
60
|
+
critical_findings: 0,
|
|
61
|
+
tier3_approvals: 0,
|
|
62
|
+
policy_bypasses: 0,
|
|
63
|
+
debt_level: UNMEASURED,
|
|
64
|
+
measured: false,
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Calculate Health Score (starts at 100)
|
|
28
70
|
let score = 100;
|
|
29
71
|
score -= (criticalFindings.length * 10);
|
|
30
72
|
score -= (tier3Approvals.length * 5);
|
|
@@ -32,7 +74,7 @@ class DebtMonitor {
|
|
|
32
74
|
|
|
33
75
|
const healthScore = Math.max(0, score);
|
|
34
76
|
|
|
35
|
-
//
|
|
77
|
+
// 4. Determine status
|
|
36
78
|
let status = 'Excellent';
|
|
37
79
|
if (healthScore < 90) status = 'Good';
|
|
38
80
|
if (healthScore < 75) status = 'Warning';
|
|
@@ -45,6 +87,7 @@ class DebtMonitor {
|
|
|
45
87
|
tier3_approvals: tier3Approvals.length,
|
|
46
88
|
policy_bypasses: policyBypasses.length,
|
|
47
89
|
debt_level: this.getDebtLevel(healthScore),
|
|
90
|
+
measured: true,
|
|
48
91
|
timestamp: new Date().toISOString()
|
|
49
92
|
};
|
|
50
93
|
}
|
|
@@ -58,3 +101,4 @@ class DebtMonitor {
|
|
|
58
101
|
}
|
|
59
102
|
|
|
60
103
|
module.exports = new DebtMonitor();
|
|
104
|
+
module.exports.UNMEASURED = UNMEASURED;
|
|
@@ -3,6 +3,53 @@
|
|
|
3
3
|
|
|
4
4
|
const { isHighImpact } = require('./trust-boundaries');
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* WHY THERE IS NO TOOL-NAME CHECK HERE.
|
|
8
|
+
*
|
|
9
|
+
* THE DEFECT. This hook used to open with `if (event.tool_name !== 'Bash') process.exit(0)`, so a
|
|
10
|
+
* deny-class gate permitted every call whose tool was not spelled with that exact string. Measured
|
|
11
|
+
* against the real hook with one destructive command and only the tool name varied:
|
|
12
|
+
*
|
|
13
|
+
* tool_name=Bash -> DENY (exit 2)
|
|
14
|
+
* tool_name=Shell -> PERMIT (exit 0)
|
|
15
|
+
* tool_name=shell -> PERMIT (exit 0)
|
|
16
|
+
* tool_name=PowerShell -> PERMIT (exit 0)
|
|
17
|
+
* tool_name=run_terminal_cmd -> PERMIT (exit 0)
|
|
18
|
+
* tool_name=Terminal -> PERMIT (exit 0)
|
|
19
|
+
*
|
|
20
|
+
* That is reachable today, not hypothetical. Verified inside cursor-agent 2026.04.17's own bundle:
|
|
21
|
+
* it loads the exact file the installer writes —
|
|
22
|
+
*
|
|
23
|
+
* claudeProjectConfigPath: join(e, ".claude", "settings.json")
|
|
24
|
+
* claudeUserConfigPath: join(homedir(), ".claude", "settings.json")
|
|
25
|
+
*
|
|
26
|
+
* — and translates Claude matcher names to its own tool names through
|
|
27
|
+
* `{Bash:"Shell", Read:"Read", Write:"Write", Edit:"Write", Grep:"Grep", ...}`, while its hook
|
|
28
|
+
* normaliser defaults to `{loop_limit:null, failClosed:!1}`. So opening a MindForge-installed project
|
|
29
|
+
* in Cursor silently drops this gate, and its shell payload carries the SAME
|
|
30
|
+
* `{command, workingDirectory, timeout}` shape — meaning nothing but the name check stood between a
|
|
31
|
+
* destructive command and execution, while the install receipt reported three deny-class hooks
|
|
32
|
+
* verified blocking.
|
|
33
|
+
*
|
|
34
|
+
* THE SCOPE IS `tool_input.command`, AND THAT IS SUFFICIENT. No name list replaces the check, because
|
|
35
|
+
* a list only ever covers the harnesses someone thought to enumerate and the next one that spells its
|
|
36
|
+
* tool differently reopens the hole in silence. Every shell tool observed — Claude Code's Bash and
|
|
37
|
+
* Cursor's Shell — passes the command as `tool_input.command`, so reading that field IS the scoping:
|
|
38
|
+
* a Write, Edit, Read or Grep payload has no `.command`, yields the empty string, and is permitted
|
|
39
|
+
* without a name ever being consulted. An unknown harness using the conventional shape now fails
|
|
40
|
+
* CLOSED instead of open.
|
|
41
|
+
*
|
|
42
|
+
* A FIRST ATTEMPT AT THIS FIX ADDED A `looksLikeShellCall()` GUARD, and falsification proved it did
|
|
43
|
+
* nothing. Deleting that guard outright left all 14 tests green, because the extraction below already
|
|
44
|
+
* scopes the gate. Its comment claimed removal would over-block Write payloads whose CONTENT mentions
|
|
45
|
+
* a destructive command — measurably false, since such payloads carry no `.command`. Keeping it would
|
|
46
|
+
* have shipped machinery that looks like enforcement and performs none, which is the exact defect
|
|
47
|
+
* class this gate was being repaired for. So it is gone, and this comment stands in its place.
|
|
48
|
+
*
|
|
49
|
+
* MCP calls are unaffected: Cursor passes `tool_input: JSON.stringify(d)` for those, so `.command` on
|
|
50
|
+
* a string is undefined and the gate declines rather than guessing.
|
|
51
|
+
*/
|
|
52
|
+
|
|
6
53
|
let input = '';
|
|
7
54
|
process.stdin.setEncoding('utf8');
|
|
8
55
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
@@ -10,12 +57,9 @@ process.stdin.on('end', () => {
|
|
|
10
57
|
try {
|
|
11
58
|
const event = JSON.parse(input);
|
|
12
59
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const fullCommand = event.tool_input?.command || '';
|
|
60
|
+
// Scoped by payload shape, NOT by tool name — see the header for the measured fail-open this
|
|
61
|
+
// replaces. A non-executing call has no `.command`, so it yields '' and is permitted below.
|
|
62
|
+
const fullCommand = (typeof event.tool_input?.command === 'string' ? event.tool_input.command : '');
|
|
19
63
|
|
|
20
64
|
// Check the whole command AND every individual line, blocking if ANY
|
|
21
65
|
// segment is high-impact. Per-line scanning means a benign first line
|
package/bin/skill-validator.js
CHANGED
|
@@ -22,7 +22,12 @@ const colors = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
function main() {
|
|
25
|
-
|
|
25
|
+
// The first NON-FLAG argument. This was `ARGS[0]`, so any flag placed before the path was taken
|
|
26
|
+
// as the filename: `validate-skill --no-color skill/SKILL.md` reported
|
|
27
|
+
// "File not found: <cwd>/--no-color" and exited 1. Flags-before-operands is the ordering most
|
|
28
|
+
// people reach for, and the two flags this tool documents (--no-color, --enterprise) are both
|
|
29
|
+
// read from anywhere in ARGS, so only the target lookup was position-sensitive.
|
|
30
|
+
const target = ARGS.find((a) => !a.startsWith('-'));
|
|
26
31
|
if (!target || ARGS.includes('--help') || ARGS.includes('-h')) {
|
|
27
32
|
console.log('\nUsage: mindforge-cc validate-skill <path-to-SKILL.md>\n');
|
|
28
33
|
process.exit(0);
|
|
@@ -44,17 +44,57 @@ const GENERIC_TRIGGERS = new Set([
|
|
|
44
44
|
'response', 'handler', 'controller', 'repository', 'schema',
|
|
45
45
|
]);
|
|
46
46
|
|
|
47
|
+
// ── Trigger frontmatter, in both grammars ─────────────────────────────────────
|
|
48
|
+
//
|
|
49
|
+
// THE DEFECT THIS FIXES. This parser required a YAML BLOCK list:
|
|
50
|
+
//
|
|
51
|
+
// const triggersSection = frontmatter.match(/^triggers:\n((?: {2}- .+\n?)*)/m);
|
|
52
|
+
//
|
|
53
|
+
// The shipped corpus is written in the INLINE comma form. Measured by driving this module's real
|
|
54
|
+
// parseSkill over the real .mindforge/skills tree: 232 skills, 232 with ZERO triggers, mean 0.00 —
|
|
55
|
+
// 232 inline, 0 block. So every engine skill scored 0 of the 30 points `score()` awards for trigger
|
|
56
|
+
// coverage (:95-99), and the suggestion at :233 told skills carrying 12 triggers to "add 25 more to
|
|
57
|
+
// reach 25+ (currently 0)". Three callers — learn-cli.js, skill-registrar.js, skill-generator.js —
|
|
58
|
+
// make registration decisions against that uniformly-zero scoreboard.
|
|
59
|
+
//
|
|
60
|
+
// bin/skill-validator.js:119 has always read the inline form (`fm.triggers.split(',')`). Two readers
|
|
61
|
+
// of the same field, two incompatible grammars, and the corpus written in the one the scorer could
|
|
62
|
+
// not read. The DIVERGENCE is the real defect; the regex is just where it surfaced.
|
|
63
|
+
//
|
|
64
|
+
// BOTH grammars are supported deliberately. The block form is not legacy cruft: all three fixtures
|
|
65
|
+
// in tests/self-building-skills.test.js use it, and :209 asserts >= 24 triggers on one of them, so
|
|
66
|
+
// dropping it would silently zero those fixtures and the test would still pass for the wrong reason.
|
|
67
|
+
//
|
|
68
|
+
// DELIBERATELY NOT DONE HERE: extracting one shared frontmatter reader so the scorer and the
|
|
69
|
+
// validator cannot diverge again. That is the durable fix, but it edits bin/skill-validator.js,
|
|
70
|
+
// which PR #185 is currently changing. Doing it here would collide. Follow-up once #185 lands.
|
|
71
|
+
function parseTriggers(frontmatter) {
|
|
72
|
+
// Block form first. The ordering is defence in depth, not the load-bearing part — verified rather
|
|
73
|
+
// than assumed: the `\S` in the inline pattern below already makes it REJECT a block-form header,
|
|
74
|
+
// because after `triggers:` the next character is a newline and `.` does not match one. Loosen
|
|
75
|
+
// that `\S` to `.` and the inline branch starts matching block form with an EMPTY capture, so a
|
|
76
|
+
// list of triggers would silently parse as none. Two independent things prevent that; keep both.
|
|
77
|
+
// `[ \t]+` rather than the old `{2}` — two spaces is a convention, not a requirement.
|
|
78
|
+
const block = frontmatter.match(/^triggers:[ \t]*\n((?:[ \t]+-[ \t]+.+\n?)+)/m);
|
|
79
|
+
if (block) {
|
|
80
|
+
return block[1].split('\n').map((l) => l.replace(/^\s*-\s*/, '').trim()).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Inline comma form, e.g. `triggers: a11y testing, axe-core, WCAG compliance test`.
|
|
84
|
+
const inline = frontmatter.match(/^triggers:[ \t]*(\S.*)$/m);
|
|
85
|
+
if (inline) {
|
|
86
|
+
return inline[1].split(',').map((t) => t.trim()).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
47
92
|
// ── SKILL.md parser ───────────────────────────────────────────────────────────
|
|
48
93
|
function parseSkill(content) {
|
|
49
94
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
50
95
|
const frontmatter = frontmatterMatch?.[1] || '';
|
|
51
96
|
|
|
52
|
-
|
|
53
|
-
const triggersSection = frontmatter.match(/^triggers:\n((?: {2}- .+\n?)*)/m);
|
|
54
|
-
const triggers = (triggersSection?.[1] || '')
|
|
55
|
-
.split('\n')
|
|
56
|
-
.map(l => l.replace(/^\s*- /, '').trim())
|
|
57
|
-
.filter(Boolean);
|
|
97
|
+
const triggers = parseTriggers(frontmatter);
|
|
58
98
|
|
|
59
99
|
// Count code blocks with ≥ 3 lines (meaningful examples, not one-liners)
|
|
60
100
|
const codeBlockMatches = content.match(/```[\s\S]*?```/g) || [];
|
|
@@ -10,7 +10,12 @@ const { execSync } = require('child_process');
|
|
|
10
10
|
const { compareSemver, upgradeType, fetchLatestVersion } = require('./version-comparator');
|
|
11
11
|
const { fetchChangelog } = require('./changelog-fetcher');
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// MindForge's OWN version, not the host project's. `require('../../package.json')` resolved to
|
|
14
|
+
// the CONSUMER's manifest once bin/updater/ was copied into a project — measured 1.0.0 for an
|
|
15
|
+
// app installed alongside MindForge 11.9.2, which made every upgrade read as MAJOR and fed a
|
|
16
|
+
// bogus "from" version into the migration path below.
|
|
17
|
+
const { resolveMindforgeVersion } = require('../utils/mindforge-version');
|
|
18
|
+
const CURRENT_VERSION = resolveMindforgeVersion({ fromDir: __dirname }).version;
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Detect where MindForge was originally installed.
|
|
@@ -37,6 +37,26 @@ function upgradeType(current, latest) {
|
|
|
37
37
|
* Returns null on any error — callers must handle gracefully.
|
|
38
38
|
* Timeout: 5 seconds (respects enterprise proxies that may be slow).
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* MindForge's own version for the User-Agent, or 'unknown'.
|
|
42
|
+
*
|
|
43
|
+
* The header used to interpolate `require('../../package.json').version`, which in an INSTALL is
|
|
44
|
+
* the consumer's manifest — measured 1.0.0 for a host app sitting alongside MindForge 11.9.2, so
|
|
45
|
+
* the registry saw the wrong client version. Resolved by package NAME instead.
|
|
46
|
+
*
|
|
47
|
+
* Required inline, and failure degrades to 'unknown' rather than throwing: this file documents
|
|
48
|
+
* itself as pure with no external dependencies, and an unresolvable version is no reason to break
|
|
49
|
+
* an update check. self-update.js throws in the same situation, correctly — there a wrong version
|
|
50
|
+
* would misclassify the upgrade.
|
|
51
|
+
*/
|
|
52
|
+
function userAgentVersion() {
|
|
53
|
+
try {
|
|
54
|
+
return require('../utils/mindforge-version').resolveMindforgeVersion({ fromDir: __dirname }).version;
|
|
55
|
+
} catch {
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
async function fetchLatestVersion(packageName = 'mindforge-cc') {
|
|
41
61
|
const https = require('https');
|
|
42
62
|
return new Promise(resolve => {
|
|
@@ -44,7 +64,7 @@ async function fetchLatestVersion(packageName = 'mindforge-cc') {
|
|
|
44
64
|
hostname: 'registry.npmjs.org',
|
|
45
65
|
path: `/${encodeURIComponent(packageName)}/latest`,
|
|
46
66
|
method: 'GET',
|
|
47
|
-
headers: { 'Accept': 'application/json', 'User-Agent': `mindforge-cc/${
|
|
67
|
+
headers: { 'Accept': 'application/json', 'User-Agent': `mindforge-cc/${userAgentVersion()}` },
|
|
48
68
|
timeout: 5000,
|
|
49
69
|
};
|
|
50
70
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve MINDFORGE's own version, from any context it might be running in.
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS REPLACES. Three modules read `require('../../package.json').version`:
|
|
6
|
+
*
|
|
7
|
+
* bin/updater/self-update.js:13 const CURRENT_VERSION = ...
|
|
8
|
+
* bin/updater/version-comparator.js:47 User-Agent: `mindforge-cc/${...}`
|
|
9
|
+
* bin/wizard/setup-wizard.js:9 const VERSION = ...
|
|
10
|
+
*
|
|
11
|
+
* In the repo that path IS MindForge's manifest, so it looks correct. In an INSTALL it is not: the
|
|
12
|
+
* installer copies bin/updater/ into the consumer's project, where `../../package.json` is the
|
|
13
|
+
* CONSUMER's manifest. Measured on a clean `--claude --local` into an app at 1.0.0:
|
|
14
|
+
*
|
|
15
|
+
* ../../package.json from bin/updater/ -> <project>/package.json
|
|
16
|
+
* its version: 1.0.0 (the consumer app)
|
|
17
|
+
* MindForge actual: 11.9.2
|
|
18
|
+
*
|
|
19
|
+
* That is not cosmetic. self-update.js:89 classifies the upgrade with
|
|
20
|
+
* `upgradeType(CURRENT_VERSION, latestVersion)`, so 1.0.0 -> 11.9.x reads as a MAJOR upgrade, and
|
|
21
|
+
* :133 uses `readHandoffSchemaVersion() || CURRENT_VERSION` as the "from" version driving
|
|
22
|
+
* migrations. A bogus origin version steers migration behaviour.
|
|
23
|
+
*
|
|
24
|
+
* Same family as the auto-runner defect fixed in ed977e9, which passed process.cwd() to a version
|
|
25
|
+
* check that compares MindForge's own manifests: both mistake "the directory I am running in" for
|
|
26
|
+
* "the package I am part of".
|
|
27
|
+
*
|
|
28
|
+
* HOW IT RESOLVES, in order, with the discriminator that makes it work:
|
|
29
|
+
*
|
|
30
|
+
* 1. The nearest ancestor package.json whose `name` is 'mindforge-cc'. The NAME is the whole
|
|
31
|
+
* trick — it is what distinguishes MindForge's manifest from a consumer's, which a relative
|
|
32
|
+
* path cannot do. Covers the repo, a global npm install, and node_modules layouts.
|
|
33
|
+
* 2. <cwd>/.mindforge/config.json `.version`. In an install this is written BY the installer and
|
|
34
|
+
* carries MindForge's version (measured: 11.9.2 in a project whose app is 1.0.0).
|
|
35
|
+
* tests/version-consistency.test.js asserts it equals package.json in the repo, so the two
|
|
36
|
+
* sources cannot silently disagree.
|
|
37
|
+
* 3. <cwd>/node_modules/mindforge-cc/package.json, for a consumer that has the package but no
|
|
38
|
+
* .mindforge/ yet.
|
|
39
|
+
*
|
|
40
|
+
* It THROWS rather than guessing. A wrong version is worse than an absent one here: it produces a
|
|
41
|
+
* confident, incorrect upgrade classification instead of an error somebody can act on.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require('fs');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
|
|
47
|
+
const PACKAGE_NAME = 'mindforge-cc';
|
|
48
|
+
|
|
49
|
+
function readJson(file) {
|
|
50
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Walk up from `dir` looking for a package.json that belongs to MindForge itself. */
|
|
54
|
+
function findOwnManifest(dir) {
|
|
55
|
+
let current = path.resolve(dir);
|
|
56
|
+
// Bounded by the filesystem root; path.dirname('/') === '/' terminates the loop.
|
|
57
|
+
for (let depth = 0; depth < 40; depth++) {
|
|
58
|
+
const candidate = path.join(current, 'package.json');
|
|
59
|
+
const pkg = readJson(candidate);
|
|
60
|
+
if (pkg && pkg.name === PACKAGE_NAME && pkg.version) {
|
|
61
|
+
return { version: pkg.version, source: candidate };
|
|
62
|
+
}
|
|
63
|
+
const parent = path.dirname(current);
|
|
64
|
+
if (parent === current) break;
|
|
65
|
+
current = parent;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{fromDir?: string, cwd?: string}} [opts]
|
|
72
|
+
* fromDir - where to start walking for MindForge's own manifest (defaults to this file).
|
|
73
|
+
* cwd - the project root to consult for installed-context sources.
|
|
74
|
+
* @returns {{version: string, source: string}}
|
|
75
|
+
* @throws {Error} when no source can establish MindForge's version.
|
|
76
|
+
*/
|
|
77
|
+
function resolveMindforgeVersion(opts = {}) {
|
|
78
|
+
const fromDir = opts.fromDir || __dirname;
|
|
79
|
+
const cwd = opts.cwd || process.cwd();
|
|
80
|
+
|
|
81
|
+
const own = findOwnManifest(fromDir);
|
|
82
|
+
if (own) return own;
|
|
83
|
+
|
|
84
|
+
const cfgPath = path.join(cwd, '.mindforge', 'config.json');
|
|
85
|
+
const cfg = readJson(cfgPath);
|
|
86
|
+
if (cfg && cfg.version) return { version: cfg.version, source: cfgPath };
|
|
87
|
+
|
|
88
|
+
const depPath = path.join(cwd, 'node_modules', PACKAGE_NAME, 'package.json');
|
|
89
|
+
const dep = readJson(depPath);
|
|
90
|
+
if (dep && dep.version) return { version: dep.version, source: depPath };
|
|
91
|
+
|
|
92
|
+
throw new Error(
|
|
93
|
+
'Cannot determine the MindForge version. Looked for a package.json named ' +
|
|
94
|
+
`"${PACKAGE_NAME}" above ${fromDir}, then ${path.join(cwd, '.mindforge/config.json')}, then ` +
|
|
95
|
+
`${depPath}. Refusing to guess: a wrong version produces a confident but incorrect upgrade ` +
|
|
96
|
+
'classification.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { resolveMindforgeVersion, findOwnManifest, PACKAGE_NAME };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Redact credential-shaped substrings from text that is about to be persisted.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `bin/hooks/instinct-capture-hook.js` writes the first 200 characters of a raw
|
|
6
|
+
* Bash command — and of a raw Task description — into BOTH an `observation` and a `behavior` field
|
|
7
|
+
* of `.mindforge/engine/instincts/instinct-store.jsonl`. Measured on real store files left by
|
|
8
|
+
* probes: an entry whose `observation` begins `Bash command succeeded: AWS_SE...`, i.e. an
|
|
9
|
+
* `AWS_SECRET_ACCESS_KEY=<value>` assignment captured verbatim. The store path is not gitignored,
|
|
10
|
+
* `.mindforge/engine/` is inside `package.json` files[] with no negation for instincts, and the
|
|
11
|
+
* promotion path (`.mindforge/engine/instincts/promotion-engine.md:33,41,46`) copies these fields
|
|
12
|
+
* into a generated, tracked, PUBLISHED `SKILL.md`. So a captured secret is amplified from a
|
|
13
|
+
* data file into committed source.
|
|
14
|
+
*
|
|
15
|
+
* WHY REDACT RATHER THAN HASH. Measured, not assumed: three consumers read this text and all three
|
|
16
|
+
* need it readable.
|
|
17
|
+
*
|
|
18
|
+
* bin/learning/instinct-cli.js:114 `list` prints observation AND behavior verbatim to a human
|
|
19
|
+
* bin/learning/instinct-cli.js:241 `promote` prints behavior verbatim as the suggestion
|
|
20
|
+
* .mindforge/skills/instinct-clustering/SKILL.md:57-63 word-overlap Jaccard on observation
|
|
21
|
+
*
|
|
22
|
+
* And nothing in code dedups on the text — `cmdImport` keys on `e.id` and the hook stamps a fresh
|
|
23
|
+
* randomUUID per event — so a hash would preserve no collision that anything relies on. Hashing
|
|
24
|
+
* would break three consumers to buy nothing.
|
|
25
|
+
*
|
|
26
|
+
* WHY NOT "KEEP argv[0], REDACT THE REST". Because of the env-assignment form: in
|
|
27
|
+
* `SECRET=value cmd ...` the secret precedes the program, so the first retained token IS the
|
|
28
|
+
* secret. That is not hypothetical — it is the shape of the measured on-disk entry above.
|
|
29
|
+
*
|
|
30
|
+
* FAIL CLOSED. If redaction throws, this returns a placeholder rather than the input. A redactor
|
|
31
|
+
* that fails open is not a redactor. Note this does NOT make the calling hook fail closed: the hook
|
|
32
|
+
* stays advisory and still exits 0. "Do not block the tool call" and "do not write the secret
|
|
33
|
+
* anyway" are different promises, and both are kept.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const PLACEHOLDER = (kind) => `<redacted:${kind}>`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ordered most-specific first. Each entry replaces only the SECRET portion where a prefix carries
|
|
40
|
+
* meaning worth keeping (a scheme, a key name), so the redacted text still reads as the same shape
|
|
41
|
+
* of command — which is what the human-facing and Jaccard consumers need.
|
|
42
|
+
*/
|
|
43
|
+
const RULES = [
|
|
44
|
+
// PEM private key blocks, header through footer.
|
|
45
|
+
{ kind: 'pem', re: /-----BEGIN[^-]{0,40}(?:PRIVATE )?KEY-----[\s\S]*?-----END[^-]{0,40}KEY-----/g,
|
|
46
|
+
replace: () => PLACEHOLDER('pem') },
|
|
47
|
+
|
|
48
|
+
// Vendor tokens with recognisable prefixes. Keep the prefix: it says WHICH credential leaked,
|
|
49
|
+
// which is exactly what an operator needs in order to rotate the right one.
|
|
50
|
+
{ kind: 'vendor-token',
|
|
51
|
+
re: /\b(sk-(?:proj-|ant-)?|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-|npm_|xox[baprse]-|AKIA|ASIA|AIza|hf_|dop_v1_)[A-Za-z0-9_-]{16,}/g,
|
|
52
|
+
replace: (m, prefix) => `${prefix}${PLACEHOLDER('token')}` },
|
|
53
|
+
|
|
54
|
+
// Authorization headers. Keep the scheme, drop the credential.
|
|
55
|
+
{ kind: 'auth-header', re: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9+/=._~-]{8,}/gi,
|
|
56
|
+
replace: (m, scheme) => `${scheme} ${PLACEHOLDER('auth')}` },
|
|
57
|
+
|
|
58
|
+
// Credentials embedded in a URL: scheme://user:pass@host
|
|
59
|
+
{ kind: 'url-credential', re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]+@/gi,
|
|
60
|
+
replace: (m, upToUser) => `${upToUser}:${PLACEHOLDER('url-password')}@` },
|
|
61
|
+
|
|
62
|
+
// Environment-assignment form, keyed on the NAME looking secret-bearing. This is the case that
|
|
63
|
+
// defeats prefix-preserving designs, because the assignment can precede the program name.
|
|
64
|
+
{ kind: 'assignment',
|
|
65
|
+
re: /\b([A-Za-z_][A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|AUTH)[A-Za-z0-9_]*)\s*=\s*("[^"]*"|'[^']*'|[^\s;|&]+)/gi,
|
|
66
|
+
replace: (m, name) => `${name}=${PLACEHOLDER('assignment')}` },
|
|
67
|
+
|
|
68
|
+
// Flag form: --password X, --token=X, -p X. Keep the flag so the command still parses visually.
|
|
69
|
+
{ kind: 'flag',
|
|
70
|
+
re: /(--?(?:password|passwd|token|secret|api[-_]?key|access[-_]?key|auth|credential)(?:[=\s]))(?:"[^"]*"|'[^']*'|[^\s;|&]+)/gi,
|
|
71
|
+
replace: (m, flag) => `${flag}${PLACEHOLDER('flag-value')}` },
|
|
72
|
+
|
|
73
|
+
// Generic high-entropy run, last resort for credentials with no recognisable prefix. Requires
|
|
74
|
+
// BOTH a letter and a digit and >= 28 characters, so English words, long flag names and ordinary
|
|
75
|
+
// identifiers are left alone. `/` is excluded so a long path is examined segment by segment.
|
|
76
|
+
{ kind: 'opaque', re: /\b(?=[A-Za-z0-9+=_-]{28,}\b)(?=[^\s]*[A-Za-z])(?=[^\s]*\d)[A-Za-z0-9+=_-]{28,}\b/g,
|
|
77
|
+
replace: () => PLACEHOLDER('opaque') },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} text
|
|
82
|
+
* @returns {string} the text with credential-shaped substrings replaced
|
|
83
|
+
*/
|
|
84
|
+
function redactSecrets(text) {
|
|
85
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
86
|
+
try {
|
|
87
|
+
let out = text;
|
|
88
|
+
for (const rule of RULES) out = out.replace(rule.re, rule.replace);
|
|
89
|
+
return out;
|
|
90
|
+
} catch {
|
|
91
|
+
// Fail closed. Withholding telemetry is always cheaper than persisting a credential.
|
|
92
|
+
return PLACEHOLDER('redaction-failed');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when redactSecrets would change the text. Useful for asserting a fixture is actually
|
|
98
|
+
* secret-shaped before relying on it, so a test cannot pass because its fixture was inert.
|
|
99
|
+
* @param {string} text
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
function containsSecretShape(text) {
|
|
103
|
+
return typeof text === 'string' && text !== '' && redactSecrets(text) !== text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { redactSecrets, containsSecretShape, RULES };
|