moflo 4.9.29 → 4.9.30
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.
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential Validation
|
|
3
|
+
*
|
|
4
|
+
* Lightweight, no-config shape checks applied to values pulled from the
|
|
5
|
+
* encrypted credential store before they are promoted to `process.env`.
|
|
6
|
+
*
|
|
7
|
+
* Two heuristics, both conservative — only invalidate when there is
|
|
8
|
+
* positive evidence the stored value is bad. Anything we can't classify
|
|
9
|
+
* passes through unchanged.
|
|
10
|
+
*
|
|
11
|
+
* - JWT-shaped values (3 base64url segments) get their `exp` claim
|
|
12
|
+
* parsed and compared to "now". An expired JWT is reported as such.
|
|
13
|
+
* - Env keys ending in `_URL` must parse via the WHATWG `URL`
|
|
14
|
+
* constructor and have a non-empty host.
|
|
15
|
+
*
|
|
16
|
+
* Story #1007: avoid silently reusing stale stored credentials (e.g.
|
|
17
|
+
* Microsoft Graph access tokens, which expire in ~1h) so the resolver
|
|
18
|
+
* can fall through to the prompt path and the user understands why.
|
|
19
|
+
*/
|
|
20
|
+
const VALID_JWT_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
21
|
+
export function validateStoredCredential(envKey, value) {
|
|
22
|
+
if (envKey.endsWith('_URL')) {
|
|
23
|
+
return validateUrlValue(value);
|
|
24
|
+
}
|
|
25
|
+
if (looksLikeJwt(value)) {
|
|
26
|
+
return validateJwtExpiry(value);
|
|
27
|
+
}
|
|
28
|
+
return { valid: true };
|
|
29
|
+
}
|
|
30
|
+
function validateUrlValue(value) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(value);
|
|
33
|
+
if (!parsed.host) {
|
|
34
|
+
return { valid: false, reason: 'stored value is not a valid URL (missing host)' };
|
|
35
|
+
}
|
|
36
|
+
return { valid: true };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { valid: false, reason: 'stored value is not a valid URL' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function looksLikeJwt(value) {
|
|
43
|
+
const parts = value.split('.');
|
|
44
|
+
if (parts.length !== 3)
|
|
45
|
+
return false;
|
|
46
|
+
return parts.every(p => p.length > 0 && VALID_JWT_SEGMENT.test(p));
|
|
47
|
+
}
|
|
48
|
+
function validateJwtExpiry(value) {
|
|
49
|
+
const exp = readJwtExp(value);
|
|
50
|
+
if (exp == null)
|
|
51
|
+
return { valid: true };
|
|
52
|
+
const expiryMs = exp * 1000;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (expiryMs >= now)
|
|
55
|
+
return { valid: true };
|
|
56
|
+
return { valid: false, reason: `JWT expired ${formatDuration(now - expiryMs)} ago` };
|
|
57
|
+
}
|
|
58
|
+
function readJwtExp(value) {
|
|
59
|
+
try {
|
|
60
|
+
const payload = value.split('.')[1];
|
|
61
|
+
const padLen = (4 - (payload.length % 4)) % 4;
|
|
62
|
+
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen);
|
|
63
|
+
const decoded = Buffer.from(b64, 'base64').toString('utf-8');
|
|
64
|
+
const parsed = JSON.parse(decoded);
|
|
65
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
66
|
+
const exp = parsed.exp;
|
|
67
|
+
if (typeof exp === 'number' && Number.isFinite(exp))
|
|
68
|
+
return exp;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function formatDuration(ms) {
|
|
77
|
+
const s = Math.floor(ms / 1000);
|
|
78
|
+
if (s < 60)
|
|
79
|
+
return `${s}s`;
|
|
80
|
+
const m = Math.floor(s / 60);
|
|
81
|
+
if (m < 60)
|
|
82
|
+
return `${m}m`;
|
|
83
|
+
const h = Math.floor(m / 60);
|
|
84
|
+
if (h < 24)
|
|
85
|
+
return `${h}h ${m % 60}m`;
|
|
86
|
+
const d = Math.floor(h / 24);
|
|
87
|
+
return `${d}d ${h % 24}h`;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=credential-validation.js.map
|
|
@@ -18,6 +18,7 @@ import { access } from 'node:fs/promises';
|
|
|
18
18
|
import { promisify } from 'node:util';
|
|
19
19
|
import { acquireTTYLock } from './tty-lock.js';
|
|
20
20
|
import { readLineFromStdin } from './stdin-reader.js';
|
|
21
|
+
import { validateStoredCredential } from './credential-validation.js';
|
|
21
22
|
const execFileAsync = promisify(execFile);
|
|
22
23
|
/**
|
|
23
24
|
* Check whether a CLI command is available on the system PATH.
|
|
@@ -190,19 +191,28 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
190
191
|
// lets us skip a re-check of the cheap env detector.
|
|
191
192
|
// `forceCredentialReprompt` bypasses this step so users can rotate or
|
|
192
193
|
// correct stored credentials by re-running through the prompt path.
|
|
194
|
+
// Stored values are shape-validated (#1007): expired JWTs and malformed
|
|
195
|
+
// _URL values are rejected and surface in the preflight banner, so the
|
|
196
|
+
// resolver re-prompts instead of silently feeding garbage to the spell.
|
|
193
197
|
const resolvedFromStoreNames = [];
|
|
194
198
|
const storeResolved = new Set();
|
|
199
|
+
const rejectedFromStore = [];
|
|
195
200
|
if (credentials && !options.forceCredentialReprompt) {
|
|
196
201
|
await Promise.all(unmetIndices.map(async (i) => {
|
|
197
202
|
const prereq = prerequisites[i];
|
|
198
203
|
if (!prereq.envKey)
|
|
199
204
|
return;
|
|
200
205
|
const stored = await credentials.get(prereq.envKey);
|
|
201
|
-
if (typeof stored
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
if (typeof stored !== 'string' || stored.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
const validation = validateStoredCredential(prereq.envKey, stored);
|
|
209
|
+
if (!validation.valid) {
|
|
210
|
+
rejectedFromStore.push({ envKey: prereq.envKey, reason: validation.reason });
|
|
211
|
+
return;
|
|
205
212
|
}
|
|
213
|
+
process.env[prereq.envKey] = stored;
|
|
214
|
+
storeResolved.add(i);
|
|
215
|
+
resolvedFromStoreNames.push(prereq.name);
|
|
206
216
|
}));
|
|
207
217
|
}
|
|
208
218
|
const stillUnmetIdx = unmetIndices.filter(i => !storeResolved.has(i));
|
|
@@ -218,7 +228,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
218
228
|
if (promptableEnvKeys.length > 0) {
|
|
219
229
|
return {
|
|
220
230
|
ok: false,
|
|
221
|
-
message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet),
|
|
231
|
+
message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet, rejectedFromStore),
|
|
222
232
|
resolvedNames: resolvedFromStoreNames,
|
|
223
233
|
errorCode: 'MISSING_CREDENTIAL',
|
|
224
234
|
missingCredentials: promptableEnvKeys,
|
|
@@ -231,6 +241,12 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
231
241
|
};
|
|
232
242
|
}
|
|
233
243
|
printPreflightBanner(log, stillUnmet.length);
|
|
244
|
+
if (rejectedFromStore.length > 0) {
|
|
245
|
+
for (const r of rejectedFromStore) {
|
|
246
|
+
log(` Stored ${r.envKey} rejected — ${r.reason}. Re-enter below.`);
|
|
247
|
+
}
|
|
248
|
+
log('');
|
|
249
|
+
}
|
|
234
250
|
const promptLine = options.promptLine ?? readLineFromStdin;
|
|
235
251
|
const promptedNames = [];
|
|
236
252
|
const promptableSet = new Set(promptable);
|
|
@@ -309,13 +325,20 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
309
325
|
}
|
|
310
326
|
return { ok: true, resolvedNames: [...resolvedFromStoreNames, ...promptedNames] };
|
|
311
327
|
}
|
|
312
|
-
function formatMissingCredentialMessage(envKeys, prereqs) {
|
|
328
|
+
function formatMissingCredentialMessage(envKeys, prereqs, rejectedFromStore) {
|
|
313
329
|
const lines = ['Missing credentials (cannot prompt — non-interactive run):'];
|
|
314
330
|
for (const key of envKeys) {
|
|
315
331
|
const prereq = prereqs.find(p => p.envKey === key);
|
|
316
332
|
const label = `${prereq?.name ?? key} (${key})`;
|
|
317
333
|
appendPrereqLine(lines, label, prereq?.installHint, prereq?.url);
|
|
318
334
|
}
|
|
335
|
+
if (rejectedFromStore.length > 0) {
|
|
336
|
+
lines.push('');
|
|
337
|
+
lines.push('Stored value(s) rejected as stale or invalid:');
|
|
338
|
+
for (const r of rejectedFromStore) {
|
|
339
|
+
lines.push(` - ${r.envKey}: ${r.reason}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
319
342
|
lines.push('');
|
|
320
343
|
lines.push('Prime these by casting the spell once interactively, or run:');
|
|
321
344
|
lines.push(' flo spell credentials set <name>');
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.30",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
85
85
|
"@typescript-eslint/parser": "^7.18.0",
|
|
86
86
|
"eslint": "^8.0.0",
|
|
87
|
-
"moflo": "^4.9.
|
|
87
|
+
"moflo": "^4.9.29",
|
|
88
88
|
"tsx": "^4.21.0",
|
|
89
89
|
"typescript": "^5.9.3",
|
|
90
90
|
"vitest": "^4.0.0"
|