flowviant 0.96.0 → 0.99.0

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 (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +174 -33
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +377 -45
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +146 -12
  12. package/bin/lib/fleet.mjs +125 -30
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +162 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +460 -23
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +354 -32
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +152 -11
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1261 -4476
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
package/bin/lib/fleet.mjs CHANGED
@@ -34,11 +34,15 @@ import {
34
34
  LIVE,
35
35
  AUTO_UPDATE,
36
36
  CREDENTIAL,
37
+ learnProjectId,
38
+ storedCredentialInUse,
37
39
  } from './config.mjs';
38
- import { projectLabel, setStoredProjectName } from './credentials.mjs';
40
+ import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
41
+ import { credentialRejected } from './authReject.mjs';
39
42
  import { handleVersionSignal } from './update.mjs';
40
43
  import {
41
44
  git,
45
+ gitNetAsync,
42
46
  resetWorktree,
43
47
  repoRootOrDie,
44
48
  detectBaseRef,
@@ -73,9 +77,11 @@ import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
73
77
  import { machineSnapshot } from './resources.mjs';
74
78
  import {
75
79
  detectRuntimes,
80
+ knownMcpServers,
76
81
  knownSkills,
77
82
  pickRuntimeFor,
78
83
  probeSkillsOnce,
84
+ recordMcpServers,
79
85
  recordSkills,
80
86
  RUNTIMES,
81
87
  THINK_MARKER,
@@ -86,6 +92,8 @@ import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
86
92
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
87
93
  import { repoState } from './repoState.mjs';
88
94
  import { claudeAuthContext } from './claudeAuth.mjs';
95
+ import { readBaseTools, runnerToolCapabilities, toolReadout } from './projectTools.mjs';
96
+ import { runtimeOfferings } from './runtimeOfferings.mjs';
89
97
 
90
98
  /** Said once per process — see the catch around `envQueryParams` below. */
91
99
  let warnedEnvIdentity = false;
@@ -161,7 +169,8 @@ async function fetchRoster(
161
169
  * rather than re-derived: `repoRootOrDie` is resolved once at startup and
162
170
  * running git on every poll to re-learn a constant would be a syscall for
163
171
  * a readout. */
164
- repoRoot = null
172
+ repoRoot = null,
173
+ baseRef = null
165
174
  ) {
166
175
  const url = new URL(FLEET_URL);
167
176
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
@@ -283,6 +292,34 @@ async function fetchRoster(
283
292
  } catch {
284
293
  /* best-effort — the poll must never fail on a readout */
285
294
  }
295
+ // WHICH MCP SERVERS AND CONNECTORS THE CLI MOUNTED, AND HOW EACH STANDS
296
+ // (0.97.0) — `[{n, s}]`, learned off the same init event as the skills, this
297
+ // daemon's own `flowviant` server excluded AND every `connected` one with it:
298
+ // only what needs something leaves the box (runtimes.mjs, recordMcpServers —
299
+ // a teammate has no business reading which services the operator signed
300
+ // into). The same three states: NOT SENT
301
+ // until a turn (or the one probe) has taught us, `[]` sent as the fact it is.
302
+ // The app names every server that is not connected — a connector that needs
303
+ // a sign-in AT THIS BOX above all — under the serving machine's row. A
304
+ // daemon→server report: an older server ignores the unknown param.
305
+ try {
306
+ const mcp = knownMcpServers();
307
+ if (mcp !== null) url.searchParams.set('mcp', JSON.stringify(mcp));
308
+ } catch {
309
+ /* best-effort — the poll must never fail on a readout */
310
+ }
311
+ if (repoRoot && baseRef) {
312
+ try {
313
+ const offerings = detectRuntimes().some((rt) => rt.id === 'codex' && rt.installed)
314
+ ? runtimeOfferings(repoRoot, baseRef) : {};
315
+ for (const [param, report] of Object.entries(offerings)) {
316
+ if (Object.keys(report).length) {
317
+ const payload = JSON.stringify(report);
318
+ if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= 14_000) url.searchParams.set(param, payload);
319
+ }
320
+ }
321
+ } catch { /* local readouts never fail the poll */ }
322
+ }
286
323
  // THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
287
324
  //
288
325
  // It carried two more params while the secrets vault existed: `envv` (the
@@ -406,10 +443,16 @@ async function fetchRoster(
406
443
  signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
407
444
  });
408
445
  if (res.status === 401 || res.status === 403) {
409
- // Fleet credential revoked/expired — retrying can't recover; signal exit.
410
- const e = new Error(`fleet credential rejected (${res.status})`);
411
- e.auth = true;
412
- throw e;
446
+ // Revoked or expired — retrying can't recover, so signal exit. But ONLY on
447
+ // the API's own refusal: an edge 403 (Cloudflare's bot checks, which this
448
+ // very client class trips) is a retryable failure, and exiting on one took
449
+ // a machine offline for good over a credential that was still valid.
450
+ if (await credentialRejected(res)) {
451
+ const e = new Error(`fleet credential rejected (${res.status})`);
452
+ e.auth = true;
453
+ throw e;
454
+ }
455
+ throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
413
456
  }
414
457
  if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
415
458
  // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
@@ -743,6 +786,36 @@ export async function maybeReportEnv(repoRoot) {
743
786
  }
744
787
  }
745
788
 
789
+ const TOOLS_REPORT_URL = FLEET_URL.replace(/\/agents\/?$/, '/tools-report');
790
+ let toolsReportSent = null;
791
+ let toolsReportUnsupported = false;
792
+ let toolsReportAt = 0;
793
+ /** Repo tool readiness is a per-box daemon report. Never include env values. */
794
+ export async function maybeReportTools(repoRoot, baseRef) {
795
+ if (Date.now() - toolsReportAt < 60_000) return;
796
+ toolsReportAt = Date.now();
797
+ if (toolsReportUnsupported) return;
798
+ const pubkey = myPubB64();
799
+ if (!pubkey) return;
800
+ let payload;
801
+ try {
802
+ const snapshot = readBaseTools(repoRoot, baseRef);
803
+ const installed = detectRuntimes().filter((r) => r.installed);
804
+ const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
805
+ payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
806
+ } catch { return; }
807
+ if (payload === toolsReportSent) return;
808
+ try {
809
+ const res = await fetch(TOOLS_REPORT_URL, {
810
+ method: 'POST',
811
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
812
+ signal: AbortSignal.timeout(15_000), body: payload,
813
+ });
814
+ if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
815
+ toolsReportSent = res.ok ? payload : null;
816
+ } catch { toolsReportSent = null; }
817
+ }
818
+
746
819
  /**
747
820
  * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
748
821
  *
@@ -873,10 +946,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
873
946
  const took = holder.took;
874
947
  if (took && typeof took === 'object' && !Array.isArray(took)) {
875
948
  standbyKey = null;
876
- const from =
877
- typeof took.from === 'string' && took.from.trim()
878
- ? took.from.trim().slice(0, 64)
879
- : 'another machine';
949
+ // scrubbed before it reaches a terminal (2026-09-24, the audit) —
950
+ // see printable.mjs.
951
+ const from = safeName(took.from)?.slice(0, 64) ?? 'another machine';
880
952
  const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
881
953
  say(
882
954
  `took this project's machine from ${from}` +
@@ -895,10 +967,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
895
967
  // The NAME is all the response carries about the other box, so it is also
896
968
  // the only thing "a distinct holder" can be keyed on. An unnamed holder
897
969
  // keys on the empty string, which is stable — one announcement, not one
898
- // per poll.
899
- const name = typeof holder.name === 'string' && holder.name.trim()
900
- ? holder.name.trim().slice(0, 64)
901
- : null;
970
+ // per poll. Scrubbed before it reaches a terminal (2026-09-24, the
971
+ // audit) — see printable.mjs.
972
+ const name = safeName(holder.name)?.slice(0, 64) ?? null;
902
973
  const key = name ?? '';
903
974
  if (key !== standbyKey) {
904
975
  standbyKey = key;
@@ -962,9 +1033,10 @@ export function createHolderWatch({ say = () => {} } = {}) {
962
1033
 
963
1034
  /** The sentence an in-flight agent turn is settled with when the machine moves
964
1035
  * out from under it. MEASURED, not inferred: the server named the box that
965
- * took over, and an unnamed one says so rather than guessing. */
1036
+ * took over, and an unnamed one says so rather than guessing. Scrubbed
1037
+ * before it is relayed anywhere (2026-09-24, the audit) — see printable.mjs. */
966
1038
  export function displacedTurnSentence(by) {
967
- const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : 'another machine';
1039
+ const name = safeName(by)?.slice(0, 64) ?? 'another machine';
968
1040
  return `The project's machine moved to ${name} while this turn was running.`;
969
1041
  }
970
1042
 
@@ -1030,7 +1102,9 @@ export async function standDownDisplaced({
1030
1102
  t.unref?.();
1031
1103
  }),
1032
1104
  ]);
1033
- const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : null;
1105
+ // Scrubbed before it reaches this box's own console (2026-09-24, the
1106
+ // audit) — see printable.mjs.
1107
+ const name = safeName(by)?.slice(0, 64) ?? null;
1034
1108
  /**
1035
1109
  * TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
1036
1110
  *
@@ -1046,8 +1120,7 @@ export async function standDownDisplaced({
1046
1120
  * drift and neither call site can invent a third.
1047
1121
  */
1048
1122
  const removed = kind === 'removed';
1049
- const projectName =
1050
- typeof project === 'string' && project.trim() ? project.trim().slice(0, 64) : 'this project';
1123
+ const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
1051
1124
  log.warn(
1052
1125
  removed
1053
1126
  ? `removed from ${projectName} in the app — stopping.`
@@ -1086,7 +1159,7 @@ export async function standDownDisplaced({
1086
1159
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
1087
1160
  // base between tasks (fresh conversation), resume in place while on a blocker.
1088
1161
 
1089
- export async function runFleetDaemon() {
1162
+ export async function runFleetDaemon({ afterLock = null } = {}) {
1090
1163
  console.log('');
1091
1164
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
1092
1165
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
@@ -1127,7 +1200,7 @@ export async function runFleetDaemon() {
1127
1200
  // about to serve" must not require a network round trip to answer. Only when
1128
1201
  // the credential came from the STORE: a --fleet/env token names no project
1129
1202
  // until the roster does.
1130
- if (CREDENTIAL?.entry) {
1203
+ if (CREDENTIAL?.entry && storedCredentialInUse()) {
1131
1204
  info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
1132
1205
  }
1133
1206
  info(`repo · ${repoRoot}`);
@@ -1196,6 +1269,16 @@ export async function runFleetDaemon() {
1196
1269
  }
1197
1270
  if (instance.unguarded)
1198
1271
  warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
1272
+ // The repo binding the start path's picker or confirm was answered with.
1273
+ // Persisted HERE, after the lock, so a refused start moves nothing: moving it
1274
+ // first stranded the daemon already serving that project's own checkout at
1275
+ // its next unattended restart. Best-effort — an unwritable store costs the
1276
+ // next start a question, never this one its machine.
1277
+ try {
1278
+ afterLock?.();
1279
+ } catch {
1280
+ /* the binding is asked again next time */
1281
+ }
1199
1282
 
1200
1283
  await preflight({ needGit: true });
1201
1284
 
@@ -1718,10 +1801,11 @@ export async function runFleetDaemon() {
1718
1801
  } else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
1719
1802
  try {
1720
1803
  // Explicit refspec form so a leading '-' can't be a git flag.
1721
- execFileSync('git', ['push', 'origin', `:refs/heads/${job.branch}`], {
1722
- cwd: repoRoot,
1723
- stdio: ['ignore', 'pipe', 'pipe'],
1724
- });
1804
+ // NETWORK call, so timed and non-interactive (2026-09-24, the
1805
+ // audit): a bare execFileSync here had no timeout and could
1806
+ // prompt on /dev/tty, freezing every poll and lease on the
1807
+ // machine behind this cleanup's own async loop.
1808
+ await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
1725
1809
  } catch {
1726
1810
  /* branch already gone — fine */
1727
1811
  }
@@ -2120,7 +2204,10 @@ export async function runFleetDaemon() {
2120
2204
  // only thing missing was the handler. The wiki turn runs in a
2121
2205
  // detached worktree of THIS repo, so its `.claude/skills` and the
2122
2206
  // machine's personal ones resolve identically to a tab's.
2123
- onInit: (i) => recordSkills(i.skills),
2207
+ onInit: (i) => {
2208
+ recordSkills(i.skills);
2209
+ recordMcpServers(i.mcpServers);
2210
+ },
2124
2211
  onSpawn: (ch) => {
2125
2212
  wikiChild = ch;
2126
2213
  },
@@ -2169,7 +2256,10 @@ export async function runFleetDaemon() {
2169
2256
  streamJson: true,
2170
2257
  onActivity,
2171
2258
  // Same free harvest as the sweep above.
2172
- onInit: (i) => recordSkills(i.skills),
2259
+ onInit: (i) => {
2260
+ recordSkills(i.skills);
2261
+ recordMcpServers(i.mcpServers);
2262
+ },
2173
2263
  onSpawn: (ch) => {
2174
2264
  wikiChild = ch;
2175
2265
  },
@@ -2312,7 +2402,8 @@ export async function runFleetDaemon() {
2312
2402
  livePreviewIds(),
2313
2403
  heldSessionIds(),
2314
2404
  admit('churn'),
2315
- repoRoot
2405
+ repoRoot,
2406
+ getBaseRef()
2316
2407
  );
2317
2408
  } catch (e) {
2318
2409
  if (e.auth) {
@@ -2337,7 +2428,7 @@ export async function runFleetDaemon() {
2337
2428
  // you're viewing project B's wiki) is obvious instead of a silent no-op.
2338
2429
  if (roster.project) {
2339
2430
  note(
2340
- `${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
2431
+ `${c.cyan('project')} · ${c.bold(projectLabel({ name: roster.project.name, projectId: roster.project.id }))} ${c.dim(`(${safeName(roster.project.id) ?? ''})`)}`
2341
2432
  );
2342
2433
  note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
2343
2434
  // Remember the NAME beside the stored credential, so the picker and
@@ -2392,6 +2483,9 @@ export async function runFleetDaemon() {
2392
2483
  */
2393
2484
  setServerMaxTurns(roster.maxTurns);
2394
2485
  if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
2486
+ // The server's word on which project this token serves — settles the env
2487
+ // report's salt for a token that came from --fleet or the environment.
2488
+ learnProjectId(roster.project?.id);
2395
2489
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
2396
2490
  // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
2397
2491
  // this sits ABOVE the version signal rather than inside it. Both read the
@@ -2486,7 +2580,7 @@ export async function runFleetDaemon() {
2486
2580
  // The project's NAME rather than the box that took over, because no box
2487
2581
  // took over. Falls back to "this project" inside — an unnamed project
2488
2582
  // says so rather than being guessed at.
2489
- project: roster.standDown.project ?? roster.project?.name,
2583
+ project: safeName(roster.standDown.project) ?? safeName(roster.project?.name) ?? undefined,
2490
2584
  settleAgentTurns,
2491
2585
  flushReports: flushWorkReports,
2492
2586
  teardown,
@@ -2698,6 +2792,7 @@ export async function runFleetDaemon() {
2698
2792
  // throttled and deduped inside, and silent forever on an older server.
2699
2793
  // This is what replaced the vault's sync tick — see `maybeReportEnv`.
2700
2794
  void maybeReportEnv(repoRoot);
2795
+ void maybeReportTools(repoRoot, getBaseRef());
2701
2796
 
2702
2797
  // Tell the app what this machine is doing with itself. Every reconcile,
2703
2798
  // best-effort, and never awaited — telemetry that can delay a dispatch is
package/bin/lib/git.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Git worktree helpers (fleet & static-fleet modes). */
2
2
 
3
- import { execFileSync } from 'node:child_process';
3
+ import { execFileSync, spawn } from 'node:child_process';
4
4
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
 
@@ -8,6 +8,134 @@ export function git(args, cwd) {
8
8
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
9
9
  }
10
10
 
11
+ /**
12
+ * THE SSH COMMAND A NETWORK GIT CALL RUNS UNDER when the operator has not named
13
+ * one. `BatchMode` turns a passphrase or host-key prompt on /dev/tty into an
14
+ * immediate failure; the keepalive pair ends a half-open connection (a laptop
15
+ * that slept, a Wi-Fi change) in ~30s instead of when the kernel gives up hours
16
+ * later. An agent key still works — BatchMode disables prompts, not keys.
17
+ */
18
+ export const NET_SSH_COMMAND =
19
+ 'ssh -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=2';
20
+
21
+ /** The default bound on one network git call. */
22
+ export const GIT_NET_TIMEOUT_MS = 60_000;
23
+
24
+ /**
25
+ * The environment a NETWORK git call runs under — never interactive.
26
+ *
27
+ * `GIT_TERMINAL_PROMPT=0` makes git fail instead of opening /dev/tty for a
28
+ * username (an HTTPS remote whose credential cache expired), and
29
+ * `GCM_INTERACTIVE=never` says the same to Git Credential Manager. The SSH
30
+ * command is set only when the operator has not chosen one — `GIT_SSH_COMMAND`,
31
+ * `GIT_SSH` or `core.sshCommand` — because the env var OUTRANKS the config key,
32
+ * and overriding somebody's `-i ~/.ssh/deploy_key` would break every fetch to
33
+ * make it non-interactive. Their own command is still bounded by the timeout.
34
+ */
35
+ export function gitNetEnv(cwd, env = process.env) {
36
+ const out = { ...env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
37
+ if (env.GIT_SSH_COMMAND || env.GIT_SSH) return out;
38
+ let configured = '';
39
+ try {
40
+ configured = execFileSync('git', ['config', '--get', 'core.sshCommand'], {
41
+ cwd,
42
+ encoding: 'utf8',
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ timeout: 5_000,
45
+ }).trim();
46
+ } catch {
47
+ /* unset (exit 1) or unreadable — ours, then */
48
+ }
49
+ if (!configured) out.GIT_SSH_COMMAND = NET_SSH_COMMAND;
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * A NETWORK git call (fetch, push), TIMED AND NON-INTERACTIVE.
55
+ *
56
+ * `git()` above is `execFileSync` with no timeout: right for a local
57
+ * `rev-parse`, and a daemon-wide freeze for a `fetch` — the call blocks the
58
+ * whole event loop, so a remote that prompts on /dev/tty or a connection that
59
+ * went half-open stops every poll, settle, lease renewal and relay on the
60
+ * machine until it returns. Every call that talks to a remote goes through
61
+ * here. Returns the untrimmed stdout; throws like `git()` does (a timeout
62
+ * throws with `code: 'ETIMEDOUT'`).
63
+ */
64
+ export function gitNet(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
65
+ return execFileSync('git', args, {
66
+ cwd,
67
+ encoding: 'utf8',
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ timeout: ms,
70
+ killSignal: 'SIGKILL',
71
+ env: gitNetEnv(cwd),
72
+ });
73
+ }
74
+
75
+ /**
76
+ * The same call WITHOUT BLOCKING — for the unattended periodic fetch, where
77
+ * even a bounded freeze every few minutes is a daemon that goes dark for a
78
+ * remote's bad day. Spawned as its own process group so a timeout takes the
79
+ * ssh or credential helper under git with it. Resolves stdout; rejects on a
80
+ * non-zero exit, a spawn error or the timeout.
81
+ */
82
+ export function gitNetAsync(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
83
+ return new Promise((resolvePromise, reject) => {
84
+ let child;
85
+ try {
86
+ child = spawn('git', args, {
87
+ cwd,
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ env: gitNetEnv(cwd),
90
+ detached: true,
91
+ });
92
+ } catch (e) {
93
+ reject(e);
94
+ return;
95
+ }
96
+ let out = '';
97
+ let err = '';
98
+ let settled = false;
99
+ const finish = (fn, v) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ fn(v);
104
+ };
105
+ const timer = setTimeout(() => {
106
+ try {
107
+ process.kill(-child.pid, 'SIGKILL');
108
+ } catch {
109
+ try {
110
+ child.kill('SIGKILL');
111
+ } catch {
112
+ /* already gone */
113
+ }
114
+ }
115
+ const e = new Error(`git ${args[0]} timed out after ${ms}ms`);
116
+ e.code = 'ETIMEDOUT';
117
+ finish(reject, e);
118
+ }, ms);
119
+ timer.unref?.();
120
+ child.stdout.on('data', (d) => {
121
+ out += d;
122
+ });
123
+ child.stderr.on('data', (d) => {
124
+ err += d;
125
+ });
126
+ child.on('error', (e) => finish(reject, e));
127
+ child.on('close', (code) => {
128
+ if (code === 0) finish(resolvePromise, out);
129
+ else {
130
+ const e = new Error(`git ${args[0]} exited ${code}`);
131
+ e.stderr = err;
132
+ e.status = code;
133
+ finish(reject, e);
134
+ }
135
+ });
136
+ });
137
+ }
138
+
11
139
  /**
12
140
  * Same call, UNTRIMMED — for `-z` (NUL-separated) output, where trimming would
13
141
  * eat the final separator and the leading space of a status code.
@@ -143,9 +271,9 @@ export function baseBranchName(baseRef) {
143
271
 
144
272
  export function resetWorktree(wt, baseRef) {
145
273
  try {
146
- git(['fetch', 'origin', '--quiet'], wt);
274
+ gitNet(['fetch', 'origin', '--quiet'], wt);
147
275
  } catch {
148
- /* offline / no remote — reset to whatever we have */
276
+ /* offline / no remote / timed out — reset to whatever we have */
149
277
  }
150
278
  try {
151
279
  git(['checkout', '--detach', baseRef], wt);
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * THE READ GUARD (2026-09-23, 0.97.0) — a Claude Code `PreToolUse` hook that
4
+ * refuses a Bash command able to WRITE A FILE OR RUN A PROGRAM, attached to
5
+ * every curated posture (consult, plan, wiki, design, research) and never to
6
+ * the build posture, whose `--dangerously-skip-permissions` is the operator's
7
+ * own choice.
8
+ *
9
+ * WHY A HOOK AND NOT A NARROWER LIST. `--allowedTools 'Bash(git log:*)'` is a
10
+ * PREFIX match: it says which program may start, never what its arguments may
11
+ * do. And the "read-only" git readers carry arguments that are writes or
12
+ * executions in disguise — `--output=<path>` (log, show and diff write their
13
+ * output to any file you name), `--ext-diff` and `--textconv` (run a
14
+ * configured program), `-c core.fsmonitor=…` / `--config-env` (run one named
15
+ * inline), `-O<pager>`, `--exec-path`, `--git-dir` / `--work-tree` (point git
16
+ * at a planted repository whose config does the above). A reviewer proved it:
17
+ * `git log -1 --format='tformat:x' --output=pwned.txt` landed under
18
+ * RESEARCH_PERM with `permission_denials: []`. Re-measured here with a
19
+ * friendlier phrasing — the file was written and nothing refused it. There is
20
+ * no permission-rule syntax for "this prefix, minus these arguments".
21
+ *
22
+ * PROBED on Claude Code 2.1.281 before relying on it, in a scratch git repo,
23
+ * `-p … --output-format stream-json --verbose --settings '<json>'` with the
24
+ * curated list beside it: the hook FIRED under `--allowedTools` (the input on
25
+ * stdin carried `tool_name: "Bash"` and the exact `tool_input.command`), exit 2
26
+ * BLOCKED the call (the file was not written; the result listed it under
27
+ * `permission_denials`), and the stderr sentence REACHED THE MODEL, which
28
+ * quoted it back. So this is a real fence, not a hint.
29
+ *
30
+ * WHAT IT REFUSES, after one normalisation: every quote and backslash is
31
+ * dropped before matching, because the shell splices `--out''put` and
32
+ * `--out\put` into `--output` and a raw substring test would read neither.
33
+ * Dropping them can only make a harmless command look worse, never a harmful
34
+ * one look better — over-refusal is the safe direction for a read-only turn.
35
+ *
36
+ * · shell composition — `>`, `<(`, `|`, `;`, `&`, a backtick, any `$`
37
+ * (command substitution, `${…}`, `$'…'` escapes, a variable that expands
38
+ * into a flag), a newline. A read-only turn runs ONE plain command; every
39
+ * one of these is a second command or a redirect wearing the first one's
40
+ * permission.
41
+ * · shell syntax that REWRITES A WORD after this guard read it — braces,
42
+ * parentheses and glob characters (`{ } ( ) * ? [`). `--outp{u,u}t=x` is
43
+ * `--output=x` by the time git sees it, and the substring tests below can
44
+ * only judge the word as written. (Quoted or not: the normalisation above
45
+ * has already dropped the quotes, so a quoted `*` is refused too — the
46
+ * over-refusal the paragraph above accepts.)
47
+ * · the git arguments above, as substrings (so `--config` covers
48
+ * `--config-env`, `--output` covers `--output-directory`), plus a bare `-c`
49
+ * token and any token beginning `-O`.
50
+ * · an environment assignment — any `GIT_*=` anywhere, and any `NAME=` as the
51
+ * command's first word (`GIT_DIR`, `GIT_EXTERNAL_DIFF`, `PAGER`,
52
+ * `LESSOPEN` all change what a reader runs).
53
+ *
54
+ * FAILS CLOSED: input it cannot parse is refused, because a fence that opens
55
+ * whenever the CLI changes its hook payload is a fence that opens silently.
56
+ * The Read, Grep and Glob tools are untouched by it — the matcher is Bash —
57
+ * so a refused command always has a tool-shaped way to do the reading.
58
+ *
59
+ * OUT OF ITS REACH, stated: an operator who sets `disableAllHooks` (or a
60
+ * managed `allowManagedHooksOnly`) in their own settings turns this off, the
61
+ * way they could turn off anything on their own box; the prefix list is still
62
+ * the first fence and this is the second.
63
+ *
64
+ * Run by the CLI as `node readGuard.mjs`, the hook JSON on stdin. Imported by
65
+ * the tests for `readGuardRefusal`, which is the whole decision.
66
+ */
67
+
68
+ import { pathToFileURL } from 'node:url';
69
+
70
+ /** Substrings that turn a git reader into a writer or an executor. */
71
+ const BANNED_FLAGS = ['--output', '--ext-diff', '--textconv', '--exec-path', '--git-dir', '--work-tree', '--config'];
72
+
73
+ /** Shell composition: each is a second command or a redirect. */
74
+ const SHELL = [
75
+ ['>', 'a redirect'],
76
+ ['<(', 'process substitution'],
77
+ ['|', 'a pipe'],
78
+ [';', 'a second command'],
79
+ ['&', 'a second command'],
80
+ ['`', 'command substitution'],
81
+ ['$', 'an expansion'],
82
+ ['\n', 'a second line'],
83
+ ['\r', 'a second line'],
84
+ // SHELL SYNTAX THE SUBSTRING TESTS BELOW DO NOT MODEL (2026-09-24, the
85
+ // audit). The shell rewrites a word AFTER this guard has read it, so a banned
86
+ // flag can be ASSEMBLED past a substring check: `--outp{u,u}t=x` and
87
+ // `-{c,c}` brace-expand into `--output=x` and `-c`, and `git log -1
88
+ // --outp{u,u}t=pwned.txt` wrote the file with this guard returning null
89
+ // (measured, git 2.55). Globs do the same with a planted file name —
90
+ // `-?` expands to a file called `-c` — and zsh adds glob QUALIFIERS
91
+ // (`*(e:…:)`, which evaluate code). None of them is something a single
92
+ // plain read needs, so the guard refuses the syntax rather than trying to
93
+ // expand it the way some shell would: over-refusal is the safe direction,
94
+ // and the Glob tool is the tool-shaped way to match names.
95
+ ['{', 'brace expansion'],
96
+ ['}', 'brace expansion'],
97
+ ['(', 'a subshell or glob qualifier'],
98
+ [')', 'a subshell or glob qualifier'],
99
+ ['*', 'a glob'],
100
+ ['?', 'a glob'],
101
+ ['[', 'a glob'],
102
+ ];
103
+
104
+ /**
105
+ * The reason this command is refused, or null when it may run.
106
+ * @param {string} command
107
+ */
108
+ export function readGuardRefusal(command) {
109
+ if (typeof command !== 'string') return 'the command could not be read';
110
+ const flat = command.replace(/['"\\]/g, '');
111
+ for (const [op, what] of SHELL) {
112
+ if (flat.includes(op)) return `${what} (\`${op === '\n' || op === '\r' ? '\\n' : op}\`)`;
113
+ }
114
+ for (const f of BANNED_FLAGS) if (flat.includes(f)) return `\`${f}\``;
115
+ const words = flat.trim().split(/\s+/).filter(Boolean);
116
+ for (const w of words) {
117
+ if (w === '-c') return '`-c` (an inline config)';
118
+ if (w.startsWith('-O')) return '`-O`';
119
+ if (/^GIT_[A-Za-z0-9_]*=/.test(w)) return `an environment assignment (\`${w.split('=')[0]}=\`)`;
120
+ }
121
+ if (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
122
+ return `an environment assignment (\`${words[0].split('=')[0]}=\`)`;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /** The sentence the model reads when a call is refused. */
128
+ export const readGuardSentence = (why) =>
129
+ `flowviant: this turn is read-only, so ${why} is refused here — it can write a file or run a program. ` +
130
+ 'Run one plain read command at a time with no redirects, pipes or expansions, or use the Read, Grep and Glob tools.';
131
+
132
+ function main() {
133
+ // A hook that CRASHES is not a hook that refused: Claude Code treats any
134
+ // exit other than 2 as a non-blocking error and runs the command anyway. So
135
+ // every way out of this process that is not a verdict is a refusal.
136
+ process.on('uncaughtException', () => {
137
+ process.stderr.write(readGuardSentence('a command the guard could not judge'));
138
+ process.exit(2);
139
+ });
140
+ let buf = '';
141
+ process.stdin.setEncoding('utf8');
142
+ process.stdin.on('data', (d) => (buf += d));
143
+ process.stdin.on('end', () => {
144
+ let payload;
145
+ try {
146
+ payload = JSON.parse(buf);
147
+ } catch {
148
+ process.stderr.write(readGuardSentence('a command the guard could not read'));
149
+ process.exit(2);
150
+ }
151
+ // The matcher is Bash; anything else reaching here is not ours to judge.
152
+ if (payload?.tool_name !== 'Bash') process.exit(0);
153
+ const why = readGuardRefusal(payload?.tool_input?.command);
154
+ if (why) {
155
+ process.stderr.write(readGuardSentence(why));
156
+ process.exit(2);
157
+ }
158
+ process.exit(0);
159
+ });
160
+ }
161
+
162
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();