@flowrail/init 0.0.12 → 0.0.14

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.
Files changed (2) hide show
  1. package/dist/doctor-script.js +745 -29
  2. package/package.json +1 -1
@@ -15,12 +15,24 @@ exports.DOCTOR_SCRIPT_FILENAME = 'scripts/check-flowrail.mjs';
15
15
  const DOCTOR_SCRIPT_CONTENT = String.raw `#!/usr/bin/env node
16
16
  /**
17
17
  * FlowRail doctor script — checks that .mcp.json is present and wired,
18
- * and that runtime source imports are declared in package.json.
18
+ * that runtime source imports are declared in package.json, that the
19
+ * hook/server versions are not skewed, and that the write gate can
20
+ * actually render verdicts (fail-mode posture + verify latency, #338).
19
21
  * Generated by @flowrail/init. Safe to commit to the repo.
20
22
  * Re-run the installer if this script fails: npx @flowrail/init@latest
21
23
  */
22
24
  import { execFileSync } from 'node:child_process';
23
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
+ import { randomUUID } from 'node:crypto';
26
+ import {
27
+ existsSync,
28
+ mkdirSync,
29
+ readFileSync,
30
+ readdirSync,
31
+ renameSync,
32
+ statSync,
33
+ unlinkSync,
34
+ writeFileSync,
35
+ } from 'node:fs';
24
36
  import { builtinModules } from 'node:module';
25
37
  import path from 'node:path';
26
38
 
@@ -1270,6 +1282,148 @@ function settingsHasFlowrailStopWiring(settings) {
1270
1282
  return false;
1271
1283
  }
1272
1284
 
1285
+ // ---------------------------------------------------------------------------
1286
+ // Trusted endpoint/posture extraction. Mirrors @flowrail/hook status-cli's
1287
+ // inspectHookWiring rule: a FLOWRAIL_MCP_URL (or FLOWRAIL_FAIL_MODE) prefix
1288
+ // is trusted ONLY from a command whose subcommand AND matcher are the
1289
+ // installer's exact pairs — a stale or hand-rolled entry under a wrong
1290
+ // matcher never fires at runtime, so its baked values must not steer where
1291
+ // this script POSTs the bearer token (or what posture it reports).
1292
+ // ---------------------------------------------------------------------------
1293
+
1294
+ function matcherIsExactly(matcher, expected) {
1295
+ const tokens = new Set(
1296
+ typeof matcher === 'string'
1297
+ ? matcher.split('|').map((t) => t.trim()).filter(Boolean)
1298
+ : [],
1299
+ );
1300
+ return tokens.size === expected.length && expected.every((t) => tokens.has(t));
1301
+ }
1302
+
1303
+ /** Correctly-paired PreToolUse flowrail-hook commands: pre-write under
1304
+ * exactly Write|Edit|MultiEdit, pre-bash under exactly Bash. */
1305
+ function validatedPreToolUseCommands(settings) {
1306
+ const out = [];
1307
+ const hooks = settings && typeof settings === 'object' ? settings.hooks : null;
1308
+ const groups = hooks && Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
1309
+ for (const group of groups) {
1310
+ const entries = group && Array.isArray(group.hooks) ? group.hooks : [];
1311
+ const isWrite = matcherIsExactly(group && group.matcher, ['Write', 'Edit', 'MultiEdit']);
1312
+ const isBash = matcherIsExactly(group && group.matcher, ['Bash']);
1313
+ for (const hook of entries) {
1314
+ const cmd = hook && typeof hook.command === 'string' ? hook.command : '';
1315
+ if (!cmd.includes('flowrail-hook')) continue;
1316
+ if (isWrite && cmd.includes('pre-write')) out.push({ command: cmd, kind: 'pre-write' });
1317
+ if (isBash && cmd.includes('pre-bash')) out.push({ command: cmd, kind: 'pre-bash' });
1318
+ }
1319
+ }
1320
+ return out;
1321
+ }
1322
+
1323
+ function readSettingsFile(filePath) {
1324
+ try {
1325
+ return JSON.parse(readFileSync(filePath, 'utf8'));
1326
+ } catch {
1327
+ return null;
1328
+ }
1329
+ }
1330
+
1331
+ function doctorSettingsLayers(settings) {
1332
+ const layers = [
1333
+ readSettingsFile(path.join('.claude', 'settings.local.json')),
1334
+ settings,
1335
+ ];
1336
+ const home = process.env.HOME || process.env.USERPROFILE;
1337
+ if (home) layers.push(readSettingsFile(path.join(home, '.claude', 'settings.json')));
1338
+ return layers;
1339
+ }
1340
+
1341
+ const PROD_MCP_BASE_URL = 'https://api.flowrail.ai';
1342
+
1343
+ /**
1344
+ * The MCP base URL the installed hooks actually call. Same precedence as
1345
+ * "flowrail status" (resolveConfiguredMcpBaseUrl): validated hook-command
1346
+ * prefix across local -> project -> user settings, then those settings'
1347
+ * env blocks in the same order, then .mcp.json, then process env — so an
1348
+ * env-configured install does not silently lose the server-side checks,
1349
+ * and a wrong-pair entry cannot redirect the bearer.
1350
+ */
1351
+ function resolveDoctorBaseUrl(settings) {
1352
+ const layers = doctorSettingsLayers(settings);
1353
+ for (const layer of layers) {
1354
+ for (const entry of validatedPreToolUseCommands(layer || {})) {
1355
+ const match = entry.command.match(/FLOWRAIL_MCP_URL=(\S+)/);
1356
+ if (match) return match[1].replace(/\/mcp\/?$/, '');
1357
+ }
1358
+ }
1359
+ for (const layer of layers) {
1360
+ const fromSettingsEnv = settingsEnvString(layer, 'FLOWRAIL_MCP_URL');
1361
+ if (fromSettingsEnv && fromSettingsEnv.trim()) {
1362
+ return fromSettingsEnv.trim().replace(/\/mcp\/?$/, '');
1363
+ }
1364
+ }
1365
+ try {
1366
+ const cfg = JSON.parse(readFileSync('.mcp.json', 'utf8'));
1367
+ const url = cfg && cfg.mcpServers && cfg.mcpServers.flowrail ? cfg.mcpServers.flowrail.url : null;
1368
+ if (typeof url === 'string' && url) return url.replace(/\/mcp\/?$/, '');
1369
+ } catch {
1370
+ /* fall through to env */
1371
+ }
1372
+ const fromEnv = (process.env.FLOWRAIL_MCP_URL || '').trim();
1373
+ return fromEnv
1374
+ ? fromEnv.replace(/\/mcp\/?$/, '')
1375
+ : PROD_MCP_BASE_URL;
1376
+ }
1377
+
1378
+ /**
1379
+ * One bounded MCP POST. The timer covers the WHOLE exchange — fetch AND
1380
+ * body read (a stalled response body must not hang the doctor past the
1381
+ * cap) — and is cleared on every path (a leaked timer keeps the node
1382
+ * process alive for the full cap after the report prints).
1383
+ */
1384
+ async function mcpPost(baseUrl, apiKey, requestBody, timeoutMs) {
1385
+ const controller = new AbortController();
1386
+ const abortTimer = setTimeout(() => controller.abort(), timeoutMs);
1387
+ let hardCapTimer;
1388
+ const hardCap = new Promise((resolve) => {
1389
+ hardCapTimer = setTimeout(
1390
+ () => resolve({ transportError: 'operation was aborted after hard cap' }),
1391
+ timeoutMs + 2000,
1392
+ );
1393
+ });
1394
+ const exchange = (async () => {
1395
+ let response;
1396
+ try {
1397
+ response = await fetch(baseUrl.replace(/\/+$/, '') + '/mcp', {
1398
+ method: 'POST',
1399
+ headers: {
1400
+ 'content-type': 'application/json',
1401
+ authorization: 'Bearer ' + apiKey,
1402
+ },
1403
+ body: JSON.stringify(requestBody),
1404
+ signal: controller.signal,
1405
+ });
1406
+ } catch (err) {
1407
+ return { transportError: String((err && err.message) || err) };
1408
+ }
1409
+ try {
1410
+ const body = await response.json();
1411
+ return { httpStatus: response.status, body };
1412
+ } catch (err) {
1413
+ return {
1414
+ httpStatus: response.status,
1415
+ protocolError: String((err && err.message) || err),
1416
+ };
1417
+ }
1418
+ })();
1419
+ try {
1420
+ return await Promise.race([exchange, hardCap]);
1421
+ } finally {
1422
+ clearTimeout(abortTimer);
1423
+ clearTimeout(hardCapTimer);
1424
+ }
1425
+ }
1426
+
1273
1427
  /**
1274
1428
  * (b) When the server advertises flowrail_review_app (tools/list) but
1275
1429
  * .claude/settings.json has no FlowRail Stop wiring, whole-app reviews
@@ -1278,42 +1432,27 @@ function settingsHasFlowrailStopWiring(settings) {
1278
1432
  * prefix) and FLOWRAIL_API_KEY; missing either skips silently.
1279
1433
  */
1280
1434
  async function checkStopWiringAgainstServer(settings) {
1281
- let baseUrl = null;
1282
- for (const command of flowrailHookCommands(settings)) {
1283
- const match = command.match(/FLOWRAIL_MCP_URL=(\S+)/);
1284
- if (match) {
1285
- baseUrl = match[1];
1286
- break;
1287
- }
1288
- }
1435
+ const baseUrl = resolveDoctorBaseUrl(settings);
1289
1436
  const apiKey = process.env.FLOWRAIL_API_KEY;
1290
1437
  if (!baseUrl || !apiKey) return true;
1291
1438
 
1292
- let tools;
1293
- try {
1294
- const controller = new AbortController();
1295
- const timer = setTimeout(() => controller.abort(), 5000);
1296
- const response = await fetch(baseUrl.replace(/\/+$/, '') + '/mcp', {
1297
- method: 'POST',
1298
- headers: {
1299
- 'content-type': 'application/json',
1300
- authorization: 'Bearer ' + apiKey,
1301
- },
1302
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
1303
- signal: controller.signal,
1304
- });
1305
- clearTimeout(timer);
1306
- const body = await response.json();
1307
- tools = body && body.result && Array.isArray(body.result.tools)
1308
- ? body.result.tools
1309
- : null;
1310
- } catch {
1439
+ const out = await mcpPost(
1440
+ baseUrl,
1441
+ apiKey,
1442
+ { jsonrpc: '2.0', id: 1, method: 'tools/list' },
1443
+ 5000,
1444
+ );
1445
+ if (out.transportError) {
1311
1446
  console.warn(
1312
1447
  'FlowRail: could not reach the server for tools/list; skipping the ' +
1313
1448
  'Stop-wiring check.',
1314
1449
  );
1315
1450
  return true;
1316
1451
  }
1452
+ const tools =
1453
+ out.body && out.body.result && Array.isArray(out.body.result.tools)
1454
+ ? out.body.result.tools
1455
+ : null;
1317
1456
  if (!tools) return true;
1318
1457
  const advertisesReviewApp = tools.some(
1319
1458
  (tool) => tool && tool.name === 'flowrail_review_app',
@@ -1329,11 +1468,588 @@ async function checkStopWiringAgainstServer(settings) {
1329
1468
  return false;
1330
1469
  }
1331
1470
 
1471
+ // ---------------------------------------------------------------------------
1472
+ // #338 — write-gate fail-mode posture + verify-latency probe.
1473
+ //
1474
+ // Everything above can pass while the write gate still allows every code
1475
+ // write: under the open fail mode a verifier error/timeout degrades to a
1476
+ // SILENT allow, and since #300 the deployed verifier can exceed the hook's
1477
+ // inline budget on every call. These two checks make that visible.
1478
+ // ---------------------------------------------------------------------------
1479
+
1480
+ // MUST mirror @flowrail/hook src/env.ts. The generated doctor is
1481
+ // dependency-free, so it carries the same strict resolver and bounds.
1482
+ const DEFAULT_VERIFY_TIMEOUT_MS = 30_000;
1483
+ const MIN_VERIFY_TIMEOUT_MS = 1_000;
1484
+ const MAX_VERIFY_TIMEOUT_MS = 30_000;
1485
+ // The probe waits well past the resolver's 30s ceiling so it can report the
1486
+ // TRUE round-trip number after the hook itself would have given up.
1487
+ const VERIFY_LATENCY_PROBE_CAP_MS = 60000;
1488
+ const VERIFY_RETRY_BACKOFF_MS = 1500;
1489
+ const PROBE_MARKER_FILE = 'status-probe.json';
1490
+ const DIAGNOSTIC_DETAIL_MAX_CHARS = 300;
1491
+
1492
+ function failModeSettingsLayers(settings) {
1493
+ return doctorSettingsLayers(settings);
1494
+ }
1495
+
1496
+ function settingsEnvString(settings, name) {
1497
+ const env = settings && typeof settings === 'object' ? settings.env : null;
1498
+ const raw = env && typeof env === 'object' && !Array.isArray(env)
1499
+ ? env[name]
1500
+ : undefined;
1501
+ return typeof raw === 'string' ? raw : undefined;
1502
+ }
1503
+
1504
+ const FAIL_MODE_KEYS = [
1505
+ 'FLOWRAIL_FAIL_MODE',
1506
+ 'FLOWRAIL_DEP_FAIL_MODE',
1507
+ 'FLOWRAIL_VERIFY_FAIL_MODE',
1508
+ ];
1509
+
1510
+ function stripSurroundingShellQuotes(value) {
1511
+ if (value.length < 2) return value;
1512
+ const first = value.charAt(0);
1513
+ const last = value.charAt(value.length - 1);
1514
+ return (first === '"' && last === '"') ||
1515
+ (first === "'" && last === "'")
1516
+ ? value.slice(1, -1)
1517
+ : value;
1518
+ }
1519
+
1520
+ /**
1521
+ * Resolve the same per-variable environment as one installed dispatcher:
1522
+ * validated command prefix, then local/project/user settings env, then the
1523
+ * doctor's process env. Each key resolves independently so a pre-bash-only
1524
+ * dependency incident lever cannot be hidden by the pre-write posture.
1525
+ */
1526
+ function effectiveDispatcherFailModeEnv(settings, dispatcher) {
1527
+ const layers = failModeSettingsLayers(settings);
1528
+ const effective = {};
1529
+ for (const key of FAIL_MODE_KEYS) {
1530
+ for (const layer of layers) {
1531
+ for (const entry of validatedPreToolUseCommands(layer || {})) {
1532
+ if (entry.kind !== dispatcher) continue;
1533
+ const match = entry.command.match(
1534
+ new RegExp('(?:^|\\s)' + key + '=(\\S+)'),
1535
+ );
1536
+ if (match) {
1537
+ effective[key] = match[1];
1538
+ break;
1539
+ }
1540
+ }
1541
+ if (effective[key] !== undefined) break;
1542
+ }
1543
+ if (effective[key] !== undefined) continue;
1544
+ for (const layer of layers) {
1545
+ const raw = settingsEnvString(layer, key);
1546
+ if (raw !== undefined) {
1547
+ effective[key] = raw;
1548
+ break;
1549
+ }
1550
+ }
1551
+ if (effective[key] === undefined && process.env[key] !== undefined) {
1552
+ effective[key] = process.env[key];
1553
+ }
1554
+ }
1555
+ return effective;
1556
+ }
1557
+
1558
+ function parseFailMode(raw) {
1559
+ if (typeof raw !== 'string') return undefined;
1560
+ const normalized = raw.trim().toLowerCase();
1561
+ return normalized === 'open' || normalized === 'closed'
1562
+ ? normalized
1563
+ : undefined;
1564
+ }
1565
+
1566
+ /** Mirrors @flowrail/hook resolveFailMode's direction-aware legacy map. */
1567
+ function resolveDispatcherFailMode(env, dispatcher) {
1568
+ const dedicatedKey =
1569
+ dispatcher === 'verify'
1570
+ ? 'FLOWRAIL_VERIFY_FAIL_MODE'
1571
+ : 'FLOWRAIL_DEP_FAIL_MODE';
1572
+ const dedicated = parseFailMode(env[dedicatedKey]);
1573
+ if (dedicated !== undefined) return dedicated;
1574
+ const legacy = parseFailMode(env.FLOWRAIL_FAIL_MODE);
1575
+ if (dispatcher === 'dep') return legacy;
1576
+ if (legacy === 'closed') return 'closed';
1577
+ if (legacy === 'open') return 'advisory';
1578
+ return undefined;
1579
+ }
1580
+
1581
+ function reportInvalidDedicatedFailMode(env, dispatcher) {
1582
+ const key =
1583
+ dispatcher === 'verify'
1584
+ ? 'FLOWRAIL_VERIFY_FAIL_MODE'
1585
+ : 'FLOWRAIL_DEP_FAIL_MODE';
1586
+ const raw = env[key];
1587
+ if (raw === undefined || parseFailMode(raw) !== undefined) return;
1588
+ console.warn(
1589
+ 'FlowRail: ' + key + '=' + JSON.stringify(raw) +
1590
+ ' is not recognized; falling through to direction-aware ' +
1591
+ 'FLOWRAIL_FAIL_MODE handling.',
1592
+ );
1593
+ }
1594
+
1595
+ function failModePostureMessage(verifyMode, depMode, rawEnv) {
1596
+ const dedicatedRaw = rawEnv.FLOWRAIL_VERIFY_FAIL_MODE;
1597
+ const dedicatedRawTrimmed =
1598
+ typeof dedicatedRaw === 'string' ? dedicatedRaw.trim() : undefined;
1599
+ const dedicatedNormalized = parseFailMode(dedicatedRaw);
1600
+ const invalidDedicatedNote =
1601
+ dedicatedRaw !== undefined && dedicatedNormalized === undefined
1602
+ ? '; FLOWRAIL_VERIFY_FAIL_MODE=' +
1603
+ (dedicatedRawTrimmed || JSON.stringify(dedicatedRaw)) +
1604
+ ' is not a recognised value and was ignored'
1605
+ : '';
1606
+ const depGateNote =
1607
+ depMode === 'closed'
1608
+ ? '; the pre-bash dependency-install adapter is also closed'
1609
+ : depMode === 'open'
1610
+ ? '; the pre-bash dependency-install adapter opens new-dependency checks'
1611
+ : '; pre-bash new-dependency installs still fail closed (ADR-008)';
1612
+
1613
+ if (verifyMode === 'closed') {
1614
+ const origin =
1615
+ dedicatedNormalized === 'closed'
1616
+ ? 'FLOWRAIL_VERIFY_FAIL_MODE=closed'
1617
+ : 'FLOWRAIL_FAIL_MODE=closed';
1618
+ return (
1619
+ 'FlowRail: write gate fail mode is CLOSED — verifier errors/timeouts ' +
1620
+ 'block code writes (' + origin + ')' + invalidDedicatedNote +
1621
+ depGateNote + '.'
1622
+ );
1623
+ }
1624
+ if (verifyMode === 'advisory') {
1625
+ return (
1626
+ 'FlowRail: write gate fail mode is ADVISORY (FLOWRAIL_FAIL_MODE=open) — ' +
1627
+ 'verifier errors/timeouts allow code writes with loud stderr and ' +
1628
+ 'agent-visible context' + invalidDedicatedNote + depGateNote + '. ' +
1629
+ 'Set FLOWRAIL_VERIFY_FAIL_MODE=closed to block instead.'
1630
+ );
1631
+ }
1632
+
1633
+ const legacyRaw =
1634
+ typeof rawEnv.FLOWRAIL_FAIL_MODE === 'string'
1635
+ ? rawEnv.FLOWRAIL_FAIL_MODE.trim()
1636
+ : undefined;
1637
+ const origin =
1638
+ verifyMode === 'open'
1639
+ ? 'FLOWRAIL_VERIFY_FAIL_MODE=open'
1640
+ : dedicatedRawTrimmed
1641
+ ? 'FLOWRAIL_VERIFY_FAIL_MODE=' + dedicatedRawTrimmed +
1642
+ ' is not a recognised value — no valid legacy tightening'
1643
+ : legacyRaw
1644
+ ? 'FLOWRAIL_FAIL_MODE=' + legacyRaw +
1645
+ ' is not a recognised value — treated as unset'
1646
+ : 'default — FLOWRAIL_FAIL_MODE unset';
1647
+ return (
1648
+ 'FlowRail: write gate fail mode is OPEN (' + origin + ') — verifier ' +
1649
+ 'errors/timeouts allow code writes silently' + depGateNote + '. ' +
1650
+ 'Set FLOWRAIL_VERIFY_FAIL_MODE=closed to block instead.'
1651
+ );
1652
+ }
1653
+
1654
+ function effectiveWriteGateVerifyTimeoutRaw(settings) {
1655
+ const layers = failModeSettingsLayers(settings);
1656
+ for (const layer of layers) {
1657
+ for (const entry of validatedPreToolUseCommands(layer || {})) {
1658
+ if (entry.kind !== 'pre-write') continue;
1659
+ const match = entry.command.match(/FLOWRAIL_VERIFY_TIMEOUT_MS=(\S+)/);
1660
+ if (match) return stripSurroundingShellQuotes(match[1]);
1661
+ }
1662
+ }
1663
+ for (const layer of layers) {
1664
+ const raw = settingsEnvString(layer, 'FLOWRAIL_VERIFY_TIMEOUT_MS');
1665
+ if (raw !== undefined) return raw;
1666
+ }
1667
+ return process.env.FLOWRAIL_VERIFY_TIMEOUT_MS;
1668
+ }
1669
+
1670
+ function resolveHookVerifyBudgetMs(settings) {
1671
+ const raw = effectiveWriteGateVerifyTimeoutRaw(settings);
1672
+ if (raw === undefined) return DEFAULT_VERIFY_TIMEOUT_MS;
1673
+ const normalized = raw.trim();
1674
+ if (!/^\d+$/.test(normalized) || /^0+$/.test(normalized)) {
1675
+ console.warn(
1676
+ 'FlowRail: FLOWRAIL_VERIFY_TIMEOUT_MS=' + normalized +
1677
+ ' is not a valid positive integer millisecond value; using the ' +
1678
+ DEFAULT_VERIFY_TIMEOUT_MS + 'ms default.',
1679
+ );
1680
+ return DEFAULT_VERIFY_TIMEOUT_MS;
1681
+ }
1682
+ const parsed = BigInt(normalized);
1683
+ const min = BigInt(MIN_VERIFY_TIMEOUT_MS);
1684
+ const max = BigInt(MAX_VERIFY_TIMEOUT_MS);
1685
+ const resolvedBigInt = parsed < min ? min : parsed > max ? max : parsed;
1686
+ const resolved = Number(resolvedBigInt);
1687
+ if (resolvedBigInt !== parsed) {
1688
+ console.warn(
1689
+ 'FlowRail: FLOWRAIL_VERIFY_TIMEOUT_MS=' + normalized + ' is outside ' +
1690
+ MIN_VERIFY_TIMEOUT_MS + '-' + MAX_VERIFY_TIMEOUT_MS + 'ms; using ' +
1691
+ resolved + 'ms.',
1692
+ );
1693
+ }
1694
+ return resolved;
1695
+ }
1696
+
1697
+ function reportHookVerifyBudget(settings, hookVerifyBudgetMs) {
1698
+ const raw = effectiveWriteGateVerifyTimeoutRaw(settings);
1699
+ const budgetLabel = hookVerifyBudgetMs / 1000 + 's';
1700
+ if (raw === undefined) {
1701
+ console.log(
1702
+ 'FlowRail: Write gate verify budget: ' + budgetLabel +
1703
+ ' (default — FLOWRAIL_VERIFY_TIMEOUT_MS unset).',
1704
+ );
1705
+ return;
1706
+ }
1707
+ const normalized = raw.trim();
1708
+ if (!/^\d+$/.test(normalized) || /^0+$/.test(normalized)) {
1709
+ console.log(
1710
+ 'FlowRail: Write gate verify budget: ' + budgetLabel + ' — ' +
1711
+ 'FLOWRAIL_VERIFY_TIMEOUT_MS=' + normalized +
1712
+ ' is not a valid positive integer millisecond value; using the ' +
1713
+ budgetLabel + ' default.',
1714
+ );
1715
+ return;
1716
+ }
1717
+ if (BigInt(normalized) !== BigInt(hookVerifyBudgetMs)) {
1718
+ console.log(
1719
+ 'FlowRail: Write gate verify budget: ' + budgetLabel + ' — ' +
1720
+ 'FLOWRAIL_VERIFY_TIMEOUT_MS=' + normalized +
1721
+ ' was clamped to the supported range.',
1722
+ );
1723
+ return;
1724
+ }
1725
+ console.log(
1726
+ 'FlowRail: Write gate verify budget: ' + budgetLabel +
1727
+ ' (FLOWRAIL_VERIFY_TIMEOUT_MS=' + normalized + ').',
1728
+ );
1729
+ }
1730
+
1731
+ function reportFailModePosture(settings) {
1732
+ const hasWriteGate = failModeSettingsLayers(settings).some(
1733
+ (layer) => validatedPreToolUseCommands(layer || {}).length > 0,
1734
+ );
1735
+ if (!hasWriteGate) return;
1736
+ const verifyEnv = effectiveDispatcherFailModeEnv(settings, 'pre-write');
1737
+ const depEnv = effectiveDispatcherFailModeEnv(settings, 'pre-bash');
1738
+ reportInvalidDedicatedFailMode(verifyEnv, 'verify');
1739
+ reportInvalidDedicatedFailMode(depEnv, 'dep');
1740
+ const verifyMode = resolveDispatcherFailMode(verifyEnv, 'verify');
1741
+ const depMode = resolveDispatcherFailMode(depEnv, 'dep');
1742
+ const message = failModePostureMessage(
1743
+ verifyMode,
1744
+ depMode,
1745
+ verifyEnv,
1746
+ );
1747
+ if (verifyMode === 'closed') console.log(message);
1748
+ else console.warn(message);
1749
+ }
1750
+
1751
+ function safeDiagnosticDetail(value) {
1752
+ return String(value)
1753
+ .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
1754
+ .replace(/[\x00-\x1F\x7F]/g, '')
1755
+ .slice(0, DIAGNOSTIC_DETAIL_MAX_CHARS);
1756
+ }
1757
+
1758
+ function resolveProbeStateRoot(cwd) {
1759
+ let dir = cwd;
1760
+ for (let depth = 0; depth < 32; depth++) {
1761
+ try {
1762
+ if (statSync(path.join(dir, '.flowrail')).isDirectory()) return dir;
1763
+ } catch {
1764
+ /* keep walking */
1765
+ }
1766
+ const parent = path.dirname(dir);
1767
+ if (parent === dir) break;
1768
+ dir = parent;
1769
+ }
1770
+ return cwd;
1771
+ }
1772
+
1773
+ function writeProbeMarker(probeStartedAt) {
1774
+ const root = resolveProbeStateRoot(process.cwd());
1775
+ const dir = path.join(root, '.flowrail');
1776
+ const target = path.join(dir, PROBE_MARKER_FILE);
1777
+ const temp = target + '.' + process.pid + '.tmp';
1778
+ try {
1779
+ mkdirSync(dir, { recursive: true });
1780
+ writeFileSync(temp, JSON.stringify({ probeStartedAt }), 'utf8');
1781
+ renameSync(temp, target);
1782
+ } catch {
1783
+ try {
1784
+ unlinkSync(temp);
1785
+ } catch {
1786
+ /* best-effort marker */
1787
+ }
1788
+ }
1789
+ }
1790
+
1791
+ /**
1792
+ * One real, cache-busted flowrail_verify_code round trip, measured against
1793
+ * the hook budget. Needs the base URL (from the hook command's
1794
+ * FLOWRAIL_MCP_URL prefix) and FLOWRAIL_API_KEY; missing either prints a
1795
+ * visible skip line. Skipped on predev/prebuild:
1796
+ * the probe is an LLM call server-side and up to 60s of wall time — it
1797
+ * must not tax every dev-server start or build. Run the doctor directly
1798
+ * (node scripts/check-flowrail.mjs) to measure.
1799
+ *
1800
+ * An over-budget probe or two consecutive transport failures after the
1801
+ * status route succeeds fail the doctor: both prove the write-time verify
1802
+ * route cannot serve the gate. Each attempt consumes daily verify quota;
1803
+ * exhausting it can hard-block all real writes until UTC rollover.
1804
+ */
1805
+ async function checkVerifyLatency(settings, hookVerifyBudgetMs) {
1806
+ const lifecycle = process.env.npm_lifecycle_event;
1807
+ if (lifecycle === 'predev' || lifecycle === 'prebuild') {
1808
+ console.warn(
1809
+ 'FlowRail: Verify latency probe skipped (' + lifecycle + ' lifecycle).',
1810
+ );
1811
+ return true;
1812
+ }
1813
+ const baseUrl = resolveDoctorBaseUrl(settings);
1814
+ const apiKey = process.env.FLOWRAIL_API_KEY;
1815
+ if (!apiKey) {
1816
+ console.warn(
1817
+ 'FlowRail: Verify latency probe skipped (no API key).',
1818
+ );
1819
+ return true;
1820
+ }
1821
+ // Deliberately NOT derived from the resolved budget: the doctor's probe cap
1822
+ // is a flat 60s so it can still measure a round trip that overruns whatever
1823
+ // budget the gate is configured with. That overrun is the finding.
1824
+ const verifyProbeCapMs = VERIFY_LATENCY_PROBE_CAP_MS;
1825
+
1826
+ // Reachability precheck (cheap, bounded) uses the same status route as
1827
+ // flowrail status. A healthy status response followed by two verify
1828
+ // transport failures is route asymmetry, not a whole-server blip.
1829
+ const pre = await mcpPost(
1830
+ baseUrl,
1831
+ apiKey,
1832
+ {
1833
+ jsonrpc: '2.0',
1834
+ id: 1,
1835
+ method: 'tools/call',
1836
+ params: { name: 'flowrail_get_status', arguments: {} },
1837
+ },
1838
+ 5000,
1839
+ );
1840
+ const statusCapturedAt = Date.now();
1841
+ if (pre.transportError || pre.httpStatus !== 200 || !pre.body || !pre.body.result) {
1842
+ // 401/403 (bad key) also lands here — the doctor cannot probe.
1843
+ console.warn(
1844
+ 'FlowRail: server not probeable (flowrail_get_status failed); skipping the ' +
1845
+ 'write-gate latency check.',
1846
+ );
1847
+ return true;
1848
+ }
1849
+
1850
+ const verifyRequest = () => ({
1851
+ jsonrpc: '2.0',
1852
+ id: 1,
1853
+ method: 'tools/call',
1854
+ params: {
1855
+ name: 'flowrail_verify_code',
1856
+ arguments: {
1857
+ // Unique per run: the server's verifier cache short-circuits on
1858
+ // identical content, which would measure the cache, not the model.
1859
+ content:
1860
+ '// FlowRail doctor latency probe ' + randomUUID() +
1861
+ ' — benign fixture, unique per run.\n' +
1862
+ 'export const flowrailLatencyProbe = true;\n',
1863
+ file_path: 'flowrail-latency-probe.ts',
1864
+ },
1865
+ },
1866
+ });
1867
+ console.log(
1868
+ 'FlowRail: Verify latency probe uses up to two daily cache-miss verifies; ' +
1869
+ 'repeated runs can exhaust quota and block all code writes until UTC rollover.',
1870
+ );
1871
+ const serverNowRaw = pre.body.result.server_now;
1872
+ const serverNow = typeof serverNowRaw === 'string' ? Date.parse(serverNowRaw) : NaN;
1873
+ let startedAt = Date.now();
1874
+ let probeStartedAt = Number.isNaN(serverNow)
1875
+ ? null
1876
+ : new Date(serverNow + (startedAt - statusCapturedAt)).toISOString();
1877
+ if (probeStartedAt === null) {
1878
+ console.warn(
1879
+ 'FlowRail: latency-probe marker not written (server time unavailable; ' +
1880
+ 'cannot safely align local and server timestamps).',
1881
+ );
1882
+ }
1883
+ let out = await mcpPost(
1884
+ baseUrl,
1885
+ apiKey,
1886
+ verifyRequest(),
1887
+ verifyProbeCapMs,
1888
+ );
1889
+ let elapsedMs = Date.now() - startedAt;
1890
+ const retryableTransport =
1891
+ (out.transportError &&
1892
+ elapsedMs < verifyProbeCapMs &&
1893
+ !/operation was aborted/i.test(out.transportError)) ||
1894
+ out.httpStatus === 429 ||
1895
+ (out.httpStatus >= 500 && out.httpStatus <= 599);
1896
+ if (retryableTransport) {
1897
+ await new Promise((resolve) => setTimeout(resolve, VERIFY_RETRY_BACKOFF_MS));
1898
+ startedAt = Date.now();
1899
+ probeStartedAt = Number.isNaN(serverNow)
1900
+ ? null
1901
+ : new Date(serverNow + (startedAt - statusCapturedAt)).toISOString();
1902
+ out = await mcpPost(
1903
+ baseUrl,
1904
+ apiKey,
1905
+ verifyRequest(),
1906
+ verifyProbeCapMs,
1907
+ );
1908
+ elapsedMs = Date.now() - startedAt;
1909
+ }
1910
+ if (probeStartedAt !== null) writeProbeMarker(probeStartedAt);
1911
+ const elapsedLabel = (elapsedMs / 1000).toFixed(1) + 's';
1912
+ const budgetLabel = hookVerifyBudgetMs / 1000 + 's';
1913
+ const posture = resolveDispatcherFailMode(
1914
+ effectiveDispatcherFailModeEnv(settings, 'pre-write'),
1915
+ 'verify',
1916
+ );
1917
+ const consequence =
1918
+ posture === 'closed'
1919
+ ? 'with the closed verifier posture the write gate will block every code write (issue #300)'
1920
+ : posture === 'advisory'
1921
+ ? 'the write gate will allow every code write with advisory context (issue #300)'
1922
+ : 'the write gate is failing open on every code write (issue #300)';
1923
+
1924
+ if (out.transportError) {
1925
+ if (
1926
+ elapsedMs >= verifyProbeCapMs ||
1927
+ /operation was aborted/i.test(out.transportError)
1928
+ ) {
1929
+ console.error(
1930
+ 'FlowRail: no verify verdict within ' + budgetLabel + ' (gave up after ' +
1931
+ elapsedLabel + ') — ' + consequence + '.',
1932
+ );
1933
+ return false;
1934
+ }
1935
+ console.error(
1936
+ 'FlowRail: verify-latency probe failed after two transport errors while ' +
1937
+ 'flowrail_get_status was healthy (' +
1938
+ safeDiagnosticDetail(out.transportError) + ').',
1939
+ );
1940
+ return false;
1941
+ }
1942
+ if (
1943
+ out.httpStatus === 429 ||
1944
+ (out.httpStatus >= 500 && out.httpStatus <= 599)
1945
+ ) {
1946
+ console.error(
1947
+ 'FlowRail: verify-latency probe failed after two transport errors while ' +
1948
+ 'flowrail_get_status was healthy ' +
1949
+ '(HTTP ' + out.httpStatus + '); retry later.',
1950
+ );
1951
+ return false;
1952
+ }
1953
+
1954
+ // The server answered. A JSON-RPC {error} (the server's only encoding
1955
+ // for a crashed verify tool), a non-200, or a result without a
1956
+ // recognisable status (contract mismatch — the real gate BLOCKS every
1957
+ // write on it, ADR-005) is a broken verify path on a reachable server:
1958
+ // the gate cannot render verdicts, and that must be loud, not a skip.
1959
+ const protocolError = out.protocolError;
1960
+ const rpcError = out.body && out.body.error;
1961
+ const result = out.body ? out.body.result : null;
1962
+ const structured =
1963
+ result && result.structuredContent && typeof result.structuredContent === 'object'
1964
+ ? result.structuredContent
1965
+ : null;
1966
+ // Match the write gate's unflattened body.result contract: top-level
1967
+ // status wins. structuredContent is compatibility fallback only and
1968
+ // must not override a status the gate itself will act on.
1969
+ const status = result
1970
+ ? result.status ?? (structured ? structured.status : undefined)
1971
+ : undefined;
1972
+ if (
1973
+ out.httpStatus !== 200 ||
1974
+ protocolError ||
1975
+ rpcError ||
1976
+ (status !== 'pass' && status !== 'fail')
1977
+ ) {
1978
+ const detail = protocolError
1979
+ ? 'invalid MCP response: ' + safeDiagnosticDetail(protocolError)
1980
+ : rpcError
1981
+ ? 'MCP error ' + safeDiagnosticDetail(rpcError.code) + ': ' +
1982
+ safeDiagnosticDetail(rpcError.message)
1983
+ : out.httpStatus !== 200
1984
+ ? 'HTTP ' + out.httpStatus
1985
+ : 'unrecognised verify result';
1986
+ const contractMismatch =
1987
+ result &&
1988
+ status !== 'pass' &&
1989
+ status !== 'fail' &&
1990
+ out.httpStatus === 200 &&
1991
+ !protocolError &&
1992
+ !rpcError;
1993
+ const brokenConsequence =
1994
+ contractMismatch
1995
+ ? 'writes are BLOCKED regardless of fail mode (ADR-005).'
1996
+ : consequence + '.';
1997
+ console.error(
1998
+ 'FlowRail: the verify path is broken even though the server is ' +
1999
+ 'reachable (' + detail + ') — the write gate cannot render verdicts; ' +
2000
+ brokenConsequence,
2001
+ );
2002
+ return false;
2003
+ }
2004
+ // Synthetic verdicts (quota exhausted / credential unavailable / cost
2005
+ // cap) are produced WITHOUT invoking the judge, so they arrive fast —
2006
+ // reporting their round trip as verifier latency would be a false
2007
+ // green. Sentinel ids: leading underscore (_quota_exceeded,
2008
+ // _credential_unavailable) or cost_cap_exceeded; real guardrail ids
2009
+ // are kebab-case.
2010
+ const structuredVerdicts = structured ? structured.per_guardrail_verdicts : undefined;
2011
+ const verdicts = Array.isArray(structuredVerdicts)
2012
+ ? structuredVerdicts
2013
+ : Array.isArray(result.per_guardrail_verdicts)
2014
+ ? result.per_guardrail_verdicts
2015
+ : [];
2016
+ for (const v of verdicts) {
2017
+ const gid = v && typeof v.guardrail_id === 'string' ? v.guardrail_id : '';
2018
+ if (gid.charAt(0) === '_' || gid === 'cost_cap_exceeded') {
2019
+ console.warn(
2020
+ 'FlowRail: verify-latency probe inconclusive — the server answered ' +
2021
+ 'with a synthetic verdict (' +
2022
+ safeDiagnosticDetail((v && v.reason_summary) || gid) +
2023
+ '); the latency of the real verifier was not measured.',
2024
+ );
2025
+ return true;
2026
+ }
2027
+ }
2028
+ if (elapsedMs > hookVerifyBudgetMs) {
2029
+ console.error(
2030
+ 'FlowRail: verify round trip ' + elapsedLabel + ' exceeds the ' +
2031
+ budgetLabel + ' hook write-gate budget (FLOWRAIL_VERIFY_TIMEOUT_MS) — ' +
2032
+ consequence + '.',
2033
+ );
2034
+ return false;
2035
+ }
2036
+ console.log(
2037
+ 'FlowRail: verify round trip ' + elapsedLabel + ' (within the ' +
2038
+ budgetLabel + ' hook write-gate budget).',
2039
+ );
2040
+ return true;
2041
+ }
2042
+
1332
2043
  const claudeSettings = readClaudeSettings();
2044
+ const effectiveClaudeSettings = claudeSettings || {};
2045
+ reportFailModePosture(effectiveClaudeSettings);
2046
+ const hookVerifyBudgetMs = resolveHookVerifyBudgetMs(effectiveClaudeSettings);
2047
+ reportHookVerifyBudget(effectiveClaudeSettings, hookVerifyBudgetMs);
1333
2048
  if (claudeSettings !== null) {
1334
2049
  if (!checkHookSubcommandCapabilities(claudeSettings)) ok = false;
1335
2050
  if (!(await checkStopWiringAgainstServer(claudeSettings))) ok = false;
1336
2051
  }
2052
+ if (!(await checkVerifyLatency(effectiveClaudeSettings, hookVerifyBudgetMs))) ok = false;
1337
2053
 
1338
2054
  if (!ok) process.exit(1);
1339
2055
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowrail/init",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "One-shot FlowRail installer for Claude Code. Wires PreToolUse hooks, MCP server config, and skill markdown into a tester's repo, then probes the FlowRail server with their bearer.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,