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 === 'string' && stored.length > 0) {
202
- process.env[prereq.envKey] = stored;
203
- storeResolved.add(i);
204
- resolvedFromStoreNames.push(prereq.name);
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>');
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.9.29';
5
+ export const VERSION = '4.9.30';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.29",
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.28",
87
+ "moflo": "^4.9.29",
88
88
  "tsx": "^4.21.0",
89
89
  "typescript": "^5.9.3",
90
90
  "vitest": "^4.0.0"