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.
- package/bin/cli.mjs +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +174 -33
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +377 -45
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +146 -12
- package/bin/lib/fleet.mjs +125 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +162 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +460 -23
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +354 -32
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +152 -11
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1261 -4476
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- 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
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
900
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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();
|