@1agh/maude 1.0.9 → 1.0.11

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.
@@ -222,39 +222,284 @@ export interface ClaudeAuthStatus {
222
222
  subscriptionType?: string;
223
223
  }
224
224
 
225
+ // A `claude` on PATH is not always the real binary. A version-manager shim
226
+ // (mise, asdf, volta) or a corporate `bin` wrapper routinely fronts it, and
227
+ // several of those print a banner to STDOUT before `exec`ing the real thing —
228
+ // `stderr: 'ignore'` doesn't help, because the noise isn't on stderr.
229
+ //
230
+ // Issue #107: `~/.local/bin/claude` was a mise shim whose `mise use -g claude`
231
+ // confirmation line landed ahead of the JSON. `JSON.parse(wholeStdout)` threw,
232
+ // the blanket catch returned null, and the readiness gate told a signed-in user
233
+ // they were signed OUT — then sent them to a Sign-in button that could only ever
234
+ // end in "Sign-in timed out", because the 2 s poll re-ran the same deterministic
235
+ // parse failure for 120 s.
236
+ //
237
+ // So: extract the JSON OBJECT from the blob rather than parsing the blob. This
238
+ // widens nothing about what is TRUSTED — the binary was already resolved and
239
+ // spawned by us, and the caller still narrows to three fields.
240
+ //
241
+ // The scan runs BACKWARDS from the end, and every giving-up path returns null
242
+ // rather than a best guess. Both properties are security-review findings against
243
+ // the first cut of this function (defender M1, attacker F1/F2), and they are the
244
+ // same lesson twice: a parser that fails SOFT re-creates #107 through a new door.
245
+ // • Forwards + a candidate budget meant noise was scanned first and the real
246
+ // payload could fall outside the window — 99 bare `{}` in a 242-byte prefix
247
+ // pushed a genuine `loggedIn:false` out of scan range and let a forged
248
+ // `loggedIn:true` win, turning a fail-CLOSED `JSON.parse` throw into
249
+ // fail-OPEN. Backwards makes the budget bite on the noise side, where it
250
+ // belongs, and matches the actual invariant: a wrapper's banner is written
251
+ // BEFORE it execs, so the real payload is the LAST thing on stdout.
252
+ // • Truncation now keeps the TAIL, for the same reason. Slicing the head threw
253
+ // away the answer and kept the noise.
254
+ // • Returning "the last object that parsed at all" meant an NDJSON-logging
255
+ // wrapper's `{"level":"info",...}` became `loggedIn: !!undefined` → a
256
+ // confident false "not signed in", with the dead-end Sign-in button armed.
257
+ // An object with no `loggedIn` is not a status document; null is.
258
+
259
+ /** Enough for any plausible banner + payload; a shim writes a line, not a stream. */
260
+ const AUTH_STATUS_SCAN_LIMIT = 1 << 20;
261
+ /** Bounds the candidate scan so a pathological blob of `{` can't become a CPU sink. */
262
+ const AUTH_STATUS_MAX_CANDIDATES = 100;
263
+ /** Wall clock for the whole read. The probe MUST resolve — see `readBounded`. */
264
+ const AUTH_STATUS_TIMEOUT_MS = 5000;
265
+
266
+ /** Index of the `}` closing the `{` at `from`, or -1. String- and escape-aware, so a brace inside a JSON string never miscounts. */
267
+ function matchingBrace(text: string, from: number): number {
268
+ let depth = 0;
269
+ let inString = false;
270
+ let escaped = false;
271
+ for (let i = from; i < text.length; i++) {
272
+ const ch = text[i];
273
+ if (inString) {
274
+ if (escaped) escaped = false;
275
+ else if (ch === '\\') escaped = true;
276
+ else if (ch === '"') inString = false;
277
+ continue;
278
+ }
279
+ if (ch === '"') inString = true;
280
+ else if (ch === '{') depth++;
281
+ else if (ch === '}' && --depth === 0) return i;
282
+ }
283
+ return -1;
284
+ }
285
+
286
+ /**
287
+ * The last `{…}` in `out` that parses to an object carrying `loggedIn`, found by
288
+ * scanning `{` positions backwards from the end. Returns null when no such object
289
+ * is in range — including when the candidate budget runs out, which is a scan that
290
+ * did not finish, not a status that says "signed out". The caller must render null
291
+ * as "couldn't read", never as a claim about the user.
292
+ */
293
+ function extractAuthStatusObject(out: string): Record<string, unknown> | null {
294
+ // Keep the TAIL on truncation: the payload trails the noise, so the end is the
295
+ // half worth having.
296
+ const text = out.length > AUTH_STATUS_SCAN_LIMIT ? out.slice(-AUTH_STATUS_SCAN_LIMIT) : out;
297
+ let tried = 0;
298
+ let start = text.lastIndexOf('{');
299
+ while (start !== -1 && ++tried <= AUTH_STATUS_MAX_CANDIDATES) {
300
+ const end = matchingBrace(text, start);
301
+ if (end !== -1) {
302
+ try {
303
+ const parsed: unknown = JSON.parse(text.slice(start, end + 1));
304
+ if (
305
+ typeof parsed === 'object' &&
306
+ parsed !== null &&
307
+ !Array.isArray(parsed) &&
308
+ 'loggedIn' in (parsed as Record<string, unknown>)
309
+ ) {
310
+ return parsed as Record<string, unknown>;
311
+ }
312
+ } catch {
313
+ /* noise that merely looked like an object — keep walking backwards */
314
+ }
315
+ }
316
+ // `lastIndexOf(needle, -1)` clamps to 0 and would re-test index 0 for the
317
+ // whole remaining budget (round-2 defender L7) — stop instead.
318
+ if (start === 0) break;
319
+ start = text.lastIndexOf('{', start - 1);
320
+ }
321
+ return null;
322
+ }
323
+
324
+ /**
325
+ * Read `stream` to EOF, bounded by `timeoutMs` and by a `maxBytes` sliding TAIL
326
+ * window. Always returns whatever bytes it managed to collect — never null.
327
+ *
328
+ * Security-review finding (defender M2 / attacker F3): the previous
329
+ * `await new Response(proc.stdout).text()` resolves on pipe EOF, and `proc.kill()`
330
+ * signals only the DIRECT child. #107's proven root cause was a self-recursive
331
+ * wrapper, i.e. exactly the shape that leaves descendants holding fd 1 after the
332
+ * kill — so the read never completed, `probeReadiness()` never resolved, and
333
+ * `GET /_api/preflight` hung. The honest-unknown path this whole change exists to
334
+ * render was unreachable in its own headline scenario.
335
+ *
336
+ * Round-2 finding (defender L6): the first cut of this function returned null on
337
+ * BOTH bounds, throwing away a payload it was already holding. A signed-in user
338
+ * whose wrapper backgrounds anything at all — a daemon, an update check, a
339
+ * corporate agent — printed a perfectly good status, then lost AI editing to a
340
+ * deadline that fired on the still-open pipe. Honest, but the answer was in the
341
+ * buffer. Returning it cannot fabricate a status: `extractAuthStatusObject` still
342
+ * demands a parsed `loggedIn`-bearing object, so a partial or garbled tail is
343
+ * still null.
344
+ *
345
+ * The cap is a sliding window for the same reason the scan runs backwards — the
346
+ * payload trails the noise, so when we must drop bytes we drop the OLDEST. The
347
+ * drop is byte-exact, not chunk-granular (attacker R3-2).
348
+ *
349
+ * ACCEPTED TRADE, stated plainly because the round-2 docstring overclaimed
350
+ * (attacker R3-1): **the deadline path is no longer fail-closed.** This function
351
+ * cannot distinguish "this is all of stdout" from "this is the first 5 s of
352
+ * stdout", so a deadline landing mid-stream answers from whatever complete object
353
+ * precedes the cut — a stale status where round-2 code returned null. That is
354
+ * accepted, not overlooked: the alternative cost a signed-in user AI editing for
355
+ * the far more common backgrounding-wrapper shape, and forcing the stale answer
356
+ * requires controlling the writer's timing, which is the owner position — from
357
+ * there the binary can simply return whatever status it likes.
358
+ */
359
+ async function readBounded(
360
+ stream: ReadableStream<Uint8Array>,
361
+ maxBytes: number,
362
+ timeoutMs: number
363
+ ): Promise<string> {
364
+ const reader = stream.getReader();
365
+ const chunks: Uint8Array[] = [];
366
+ let total = 0;
367
+ const deadline = Date.now() + timeoutMs;
368
+ try {
369
+ for (;;) {
370
+ const left = deadline - Date.now();
371
+ if (left <= 0) break;
372
+ let timer: ReturnType<typeof setTimeout> | undefined;
373
+ const expired = Symbol('expired');
374
+ const next = await Promise.race([
375
+ reader.read(),
376
+ new Promise<typeof expired>((resolve) => {
377
+ timer = setTimeout(() => resolve(expired), left);
378
+ }),
379
+ ]);
380
+ if (timer !== undefined) clearTimeout(timer);
381
+ if (next === expired) break;
382
+ if (next.done) break;
383
+ if (next.value?.byteLength) {
384
+ chunks.push(next.value);
385
+ total += next.value.byteLength;
386
+ // Bound memory DURING the read, not by materialising the whole blob and
387
+ // slicing after — a fast writer has no practical ceiling inside the
388
+ // timeout window otherwise.
389
+ // Byte-exact, one branch (attacker R3-2). Shifting whole chunks
390
+ // overshot by up to `sizeof(oldest chunk) - 1` — measured 262 144 B
391
+ // over-dropped for a 150-byte overflow against a real Bun pipe, so the
392
+ // effective window was as little as 75 % of the advertised cap, while
393
+ // the sibling single-chunk branch four lines away was byte-exact. Same
394
+ // window, two granularities, and the doc claimed the byte one.
395
+ while (total > maxBytes) {
396
+ const first = chunks[0];
397
+ if (!first) break;
398
+ const over = total - maxBytes;
399
+ if (first.byteLength <= over) {
400
+ chunks.shift();
401
+ total -= first.byteLength;
402
+ } else {
403
+ chunks[0] = first.subarray(over);
404
+ total -= over;
405
+ }
406
+ }
407
+ }
408
+ }
409
+ } finally {
410
+ try {
411
+ await reader.cancel();
412
+ } catch {
413
+ /* already closed */
414
+ }
415
+ }
416
+ const joined = new Uint8Array(total);
417
+ let at = 0;
418
+ for (const c of chunks) {
419
+ joined.set(c, at);
420
+ at += c.byteLength;
421
+ }
422
+ // A dropped leading chunk can split a UTF-8 sequence; the decoder emits U+FFFD
423
+ // there, which the `{`-scan simply walks past.
424
+ return new TextDecoder().decode(joined);
425
+ }
426
+
427
+ /**
428
+ * Narrow a status tag for DISPLAY. Attacker F5: `apiProvider` is interpolated
429
+ * verbatim into the readiness row, so an unbounded, unsanitized value is
430
+ * arbitrary text inside a trusted system-status line. Anything that isn't a
431
+ * short identifier becomes the literal `unrecognized` — deliberately NOT
432
+ * `undefined`, which would read as "no provider" and silently suppress the
433
+ * metered-billing warning (`offSubscription` in readiness.ts tests `!== 'firstParty'`).
434
+ */
435
+ function narrowStatusTag(value: unknown): string | undefined {
436
+ if (typeof value !== 'string') return undefined;
437
+ return /^[A-Za-z0-9_-]{1,32}$/.test(value) ? value : 'unrecognized';
438
+ }
439
+
225
440
  /**
226
441
  * Shells `claude auth status --json` and narrows the result to only the fields
227
442
  * ever trusted/exposed here. `email`/`orgId`/`orgName` are deliberately dropped
228
443
  * on read — never forwarded to a caller, never logged (security review finding:
229
444
  * raw-stdout-to-log is a real habit elsewhere in this codebase; the fix is to
230
445
  * never let the raw payload exist past this function).
446
+ *
447
+ * Returns null for BOTH "no CLI" and "couldn't read a status out of it" — see
448
+ * `readiness.ts`, which must not render the latter as a positive "not signed
449
+ * in" claim (issue #107).
231
450
  */
232
451
  export async function getClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
233
452
  const bin = resolveClaudePath();
234
453
  if (!bin) return null;
235
454
  try {
236
- const proc = Bun.spawn([bin, 'auth', 'status', '--json'], {
237
- env: scrubAgentEnv(),
238
- stdout: 'pipe',
239
- stderr: 'ignore',
240
- });
241
- const timeout = setTimeout(() => proc.kill(), 5000);
242
- const out = await new Response(proc.stdout).text();
243
- await proc.exited;
244
- clearTimeout(timeout);
245
- const parsed: unknown = JSON.parse(out);
246
- if (typeof parsed !== 'object' || parsed === null) return null;
247
- const p = parsed as Record<string, unknown>;
455
+ // Whatever bytes arrived inside the deadline — possibly empty, possibly a
456
+ // truncated tail. `extractAuthStatusObject` is the only thing that decides
457
+ // whether that is a status document.
458
+ const p = extractAuthStatusObject(await readAuthStatusStdout(bin));
459
+ if (!p) return null;
248
460
  return {
249
- loggedIn: !!p.loggedIn,
250
- apiProvider: typeof p.apiProvider === 'string' ? p.apiProvider : undefined,
251
- subscriptionType: typeof p.subscriptionType === 'string' ? p.subscriptionType : undefined,
461
+ // Strict `=== true`, not `!!` — a wrapper's `"loggedIn":"no"` or `:1` must
462
+ // not truthy-coerce into a sign-in (attacker F5's neighbour).
463
+ loggedIn: p.loggedIn === true,
464
+ apiProvider: narrowStatusTag(p.apiProvider),
465
+ subscriptionType: narrowStatusTag(p.subscriptionType),
252
466
  };
253
467
  } catch {
254
468
  return null;
255
469
  }
256
470
  }
257
471
 
472
+ /**
473
+ * Spawn + bounded read, kept in its own function so the `Bun.spawn` call stays
474
+ * inline with its literal options — that is what gives `proc.stdout` its precise
475
+ * `ReadableStream` type instead of the widened `number | ReadableStream | undefined`.
476
+ *
477
+ * Deliberately does NOT await `proc.exited`: a self-recursive wrapper's
478
+ * descendants can outlive the kill, and this function's contract is that it
479
+ * always resolves. The bounded read already has whatever bytes exist.
480
+ */
481
+ async function readAuthStatusStdout(bin: string): Promise<string> {
482
+ const proc = Bun.spawn([bin, 'auth', 'status', '--json'], {
483
+ env: scrubAgentEnv(),
484
+ // A wrapper that prompts would otherwise block forever on a tty-less read.
485
+ stdin: 'ignore',
486
+ stdout: 'pipe',
487
+ stderr: 'ignore',
488
+ });
489
+ try {
490
+ return await readBounded(proc.stdout, AUTH_STATUS_SCAN_LIMIT, AUTH_STATUS_TIMEOUT_MS);
491
+ } finally {
492
+ // Best-effort reap on every path, including the deadline one. This kills the
493
+ // DIRECT child only — an orphaned descendant tree is a known residual, but it
494
+ // can no longer wedge the probe (see `readBounded`).
495
+ try {
496
+ proc.kill();
497
+ } catch {
498
+ /* already exited */
499
+ }
500
+ }
501
+ }
502
+
258
503
  /**
259
504
  * The fuller availability check backing `GET /_api/acp/status` — the ONE
260
505
  * signal the ChatPanel UI's connected/not-connected gate actually watches
@@ -280,7 +525,14 @@ export async function probeAcpAvailabilityAuthed(): Promise<AcpAvailability> {
280
525
  return {
281
526
  ...base,
282
527
  available: false,
283
- reason: 'Claude Code is installed but not signed in — sign in to connect.',
528
+ // `base.available` is true here, so the CLI DID resolve — a null status
529
+ // therefore means "we couldn't read it", not "signed out". Say so
530
+ // (issue #107): telling a signed-in user they aren't is a claim they
531
+ // have no way to act on, and it hides the real cause.
532
+ reason:
533
+ authStatus === null
534
+ ? "Claude Code is installed, but Maude couldn't read its sign-in state — check Help ▸ Check AI editing readiness."
535
+ : 'Claude Code is installed but not signed in — sign in to connect.',
284
536
  };
285
537
  }
286
538
  return base;
@@ -292,7 +292,13 @@ function Row({ item, refresh }) {
292
292
  ) : null}
293
293
  </span>
294
294
  ) : null}
295
- {item.action === 'signin' && item.resolvedPath ? (
295
+ {/* Shown whenever a path resolved — NOT only in the signin state. DDR-166
296
+ Decision 3 calls this binding ("so a pre-existing PATH-hijacked `claude`
297
+ isn't invisible to the one human capable of noticing it's wrong"), and
298
+ gating it on `action === 'signin'` hid it in the one state whose most
299
+ likely cause IS something unexpected fronting the binary (issue #107,
300
+ attacker finding F4). */}
301
+ {item.resolvedPath ? (
296
302
  <span className="rdy-fix-tx rdy-resolved-path">
297
303
  {item.resolvedViaMaude ? 'Installed by Maude: ' : 'Found on your PATH: '}
298
304
  <code>{item.resolvedPath}</code>