@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.
- package/dist/doctor-script.js +745 -29
- package/package.json +1 -1
package/dist/doctor-script.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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.
|
|
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,
|