flowviant 0.96.0 → 0.97.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.
@@ -37,13 +37,27 @@
37
37
  * this is somebody's disk and one place checking is one deploy from zero) are
38
38
  * reported BY NAME and never read. Over `ARTIFACT_MAX_BYTES` likewise.
39
39
  *
40
- * ── TEXT IS SCRUBBED ──
40
+ * ── TEXT IS SCRUBBED; A BINARY CARRYING A SECRET IS WITHHELD ──
41
41
  *
42
42
  * html, md, svg, json, csv and txt pass through `envScrub` before upload — the
43
43
  * turn trace's own scrub, for the trace's own reason: a page the agent wrote
44
44
  * can quote a value out of the checkout's `.env`, and an artifact is served to
45
- * a browser. Binary images are not scrubbed (there is no text to match), and
46
- * the SERVER computes the sha256 of what it stored; the one sent here is for
45
+ * a browser.
46
+ *
47
+ * The binary types CAN carry text, and this header used to say they could
48
+ * not. A PNG has tEXt chunks, a PDF has streams that are often stored
49
+ * uncompressed, a docx/pptx/xlsx is a zip whose entries may be STORED rather
50
+ * than deflated — and a turn that can write a file can write a secret into
51
+ * any of them. What they cannot be is REWRITTEN: `envScrub` swapping bytes
52
+ * inside one corrupts it, and a corrupted binary served as the thing the agent
53
+ * made is worse than none. So each binary is checked against the same scrub
54
+ * list as exact byte substrings (`secretIn`, env.mjs) before it is uploaded,
55
+ * and a hit SKIPS the file with a warn line naming it and the variable — never
56
+ * a redacted copy, never a by-name row the page would draw as a file it merely
57
+ * could not show. STATED: a value inside a deflated stream is not a substring
58
+ * of the file, so this catches what is stored plainly and nothing more.
59
+ *
60
+ * The SERVER computes the sha256 of what it stored; the one sent here is for
47
61
  * the log.
48
62
  *
49
63
  * ── DELIVERY ──
@@ -91,6 +105,15 @@ export const ARTIFACT_TYPES = {
91
105
  json: { mime: 'application/json', text: true },
92
106
  csv: { mime: 'text/csv', text: true },
93
107
  txt: { mime: 'text/plain', text: true },
108
+ // DOCUMENTS (0.97.0): a Word file, a deck, a sheet or a PDF — what the
109
+ // machine's docx/pptx/xlsx/pdf skills write. Binary, so never REWRITTEN (a
110
+ // scrub inside a zip or a PDF corrupts it) but checked for a known secret's
111
+ // bytes and withheld on a hit — see the header. Under the same cap. The app
112
+ // offers the first three as a download and frames the PDF.
113
+ docx: { mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', text: false },
114
+ pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: false },
115
+ xlsx: { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: false },
116
+ pdf: { mime: 'application/pdf', text: false },
94
117
  };
95
118
 
96
119
  export function artifactTypeFor(name) {
@@ -182,7 +205,7 @@ function readNoFollow(path, limit) {
182
205
  * re-sent exactly as it was first built (scrubbed once, never re-read from a
183
206
  * disk the agent may have changed since).
184
207
  */
185
- export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s) {
208
+ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s, secretIn = () => null) {
186
209
  const type = artifactTypeFor(entry.name);
187
210
  const fields = {
188
211
  ...(sessionId ? { sessionId } : { agentId }),
@@ -199,7 +222,11 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
199
222
  return null; // vanished, or a symlink swapped in: nothing to say
200
223
  }
201
224
  if (!bytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
202
- if (type.text) {
225
+ if (!type.text) {
226
+ // A binary is never rewritten; one carrying a known secret is not sent.
227
+ const secret = secretIn(bytes);
228
+ if (secret) return { withheld: secret, fields };
229
+ } else {
203
230
  bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
204
231
  // A redaction marker can be longer than the value it replaced.
205
232
  if (bytes.byteLength > ARTIFACT_MAX_BYTES) {
@@ -222,7 +249,7 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
222
249
  * finished turn and never awaited by the turn (a slow uplink must not hold the
223
250
  * next turn behind a readout); `retryPending` rides the settle retry beat.
224
251
  */
225
- export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetchImpl, log = () => {} }) {
252
+ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secretIn, fetchImpl, log = () => {} }) {
226
253
  const url = String(fleetUrl).replace(/\/agents\/?$/, '/artifact');
227
254
  const doFetch = fetchImpl ?? ((...a) => fetch(...a));
228
255
  const pending = new Map(); // `${owner}:${name}` -> { body, tries }
@@ -284,8 +311,12 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetc
284
311
  const changed = changedArtifacts(before, scanArtifacts(placeDir));
285
312
  let n = 0;
286
313
  for (const entry of changed) {
287
- const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub);
314
+ const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
288
315
  if (!body) continue;
316
+ if (body.withheld) {
317
+ log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
318
+ continue;
319
+ }
289
320
  const key = `${sessionId ?? agentId}:${entry.name}`;
290
321
  pending.delete(key); // this copy supersedes a held older one
291
322
  await send(key, body, 0);
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
15
16
  import { SAFE } from './config.mjs';
16
17
  import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
17
18
 
@@ -20,6 +21,46 @@ import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
20
21
  // the strings live.
21
22
  export * from './prompts.mjs';
22
23
 
24
+ /**
25
+ * THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
26
+ * hooks/readGuard.mjs for what it refuses and the probe that proved it fires.
27
+ *
28
+ * A `Bash(git log:*)` allow is a PREFIX: it names the program and says nothing
29
+ * about the arguments, and `git log --output=<path>` writes any file while
30
+ * `-c core.fsmonitor=…` / `--ext-diff` run a program. A reviewer landed
31
+ * `git log -1 --format='tformat:x' --output=pwned.txt` under RESEARCH_PERM with
32
+ * no denial, and it re-measured the same way here. So every list below that
33
+ * promises "reads, and nothing else" carries a `PreToolUse` hook through
34
+ * `--settings`, measured on Claude Code 2.1.281: it fires under
35
+ * `--allowedTools`, exit 2 blocks the call, and its sentence reaches the model.
36
+ *
37
+ * NEVER ON THE BUILD POSTURE: `--dangerously-skip-permissions` is the
38
+ * operator's own choice for a turn that is meant to write, and a guard there
39
+ * would be refusing the job. Nor on PLAN_MODE_PERM, which is the CLI's own
40
+ * posture on the owner's own tab, not a list this file promises anything about.
41
+ *
42
+ * The hook runs as `<this node> <readGuard.mjs>` — the daemon's own
43
+ * `process.execPath`, never a `node` looked up on a PATH the operator may not
44
+ * have, because a hook that fails to START is a non-blocking error and the
45
+ * command runs anyway.
46
+ */
47
+ export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
48
+ const hookQuote = (s) =>
49
+ process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
50
+ export const READ_GUARD_SETTINGS = JSON.stringify({
51
+ hooks: {
52
+ PreToolUse: [
53
+ {
54
+ matcher: 'Bash',
55
+ hooks: [{ type: 'command', command: `${hookQuote(process.execPath)} ${hookQuote(READ_GUARD_PATH)}` }],
56
+ },
57
+ ],
58
+ },
59
+ });
60
+ /** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
61
+ * `--settings` pair must stand before it or be swallowed as a tool name. */
62
+ const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
63
+
23
64
  /**
24
65
  * PLAN — read the repo, write the plan, never the code.
25
66
  *
@@ -37,6 +78,7 @@ export * from './prompts.mjs';
37
78
  * touching.
38
79
  */
39
80
  const PLAN_PERM = [
81
+ ...READ_GUARD,
40
82
  '--allowedTools',
41
83
  'mcp__flowviant',
42
84
  'Read',
@@ -81,6 +123,7 @@ const PERM = SAFE
81
123
  // after every wiki turn, and the vault has its own git history.
82
124
  // (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
83
125
  const WIKI_PERM = [
126
+ ...READ_GUARD,
84
127
  '--allowedTools',
85
128
  'Read',
86
129
  'Grep',
@@ -112,6 +155,7 @@ const WIKI_PERM = [
112
155
  // "do not change anything" is only an instruction, and instructions are exactly
113
156
  // what an injected question competes with.
114
157
  const CONSULT_PERM = [
158
+ ...READ_GUARD,
115
159
  '--allowedTools',
116
160
  'Read',
117
161
  'Grep',
@@ -126,6 +170,152 @@ const CONSULT_PERM = [
126
170
  'Bash(git rev-parse:*)',
127
171
  ];
128
172
 
173
+ /**
174
+ * THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
175
+ * WRITE, and only under `.flowviant/artifacts/`.
176
+ *
177
+ * The card's words are steered by anyone who can file a card, so "change
178
+ * nothing" as prose is only an instruction, and the permission list is the
179
+ * enforcement. What each carries is the ONE write its contract needs:
180
+ *
181
+ * · `Edit(.flowviant/artifacts/**)` — PROBED on Claude Code 2.1.281 before
182
+ * relying on it (2026-09-23): with `--allowedTools 'Read'
183
+ * 'Edit(.flowviant/artifacts/**)'`, a `-p` turn told to write
184
+ * `.flowviant/artifacts/a.html` AND `b.txt` at the root wrote the first and
185
+ * was DENIED the second. The Edit rule governs every file-writing tool,
186
+ * Write included. Spelled `Write(.flowviant/artifacts/**)` — or `./`- or
187
+ * `//abs`-anchored — the same probe was denied BOTH files, so that spelling
188
+ * is not a scoped write at all, it is no write. Relative to the turn's cwd,
189
+ * which is the agent's own worktree, which is where the artifact scan looks.
190
+ * `**` AND NOT `*`, measured the same day: `Edit(.flowviant/artifacts/*)`
191
+ * let a Write to `.flowviant/artifacts/sub/b.html` through exactly as `**`
192
+ * did, so the single-level spelling is not narrower on this CLI and would
193
+ * only read as if it were. The scan keeps top-level files, so the
194
+ * contracts say "no subfolders" — the prompt is the only thing that can.
195
+ *
196
+ * THEY ARE NOT THE SAME READER, and the difference is the network:
197
+ *
198
+ * · DESIGN draws THIS product, so it reads the repo broadly — Read, Grep,
199
+ * Glob and `ls` — and has no web at all (a mockup that went looking on the
200
+ * web would be a mockup of somebody else's product). No git readers and no
201
+ * cat/head/wc: Read covers every one of them, and each git reader was an
202
+ * `--output` away from a write (see READ_GUARD, which it still carries for
203
+ * the `ls` it keeps).
204
+ * · RESEARCH has the web — WebSearch and WebFetch — and a turn that can both
205
+ * read a secret and fetch a URL can send one. So its reading is FENCED to
206
+ * what the card is about: `Read(./**)` (the worktree) plus the project's
207
+ * knowledge library by its absolute path, `Glob(./**)` for names, NO Grep
208
+ * (an unscoped search of the home directory prints matching lines), NO
209
+ * Bash at all, and `.env*` DENIED by name. PROBED on 2.1.281, the whole
210
+ * list with `--add-dir <knowledge>`: a file in the cwd read; `/etc/hostname`,
211
+ * a sibling directory's file and `~/.flowviant/credentials.json` were each
212
+ * refused ("Claude requested permissions to read from …, but you haven't
213
+ * granted it yet"); `.env` was refused ("File is in a directory that is
214
+ * denied by your permission settings"); the knowledge file read. Two
215
+ * controls: WITHOUT the `--disallowedTools` pair, `Read(./**)` read `.env`
216
+ * — the deny is load-bearing; and a bare `Glob` listed `~/.flowviant`
217
+ * while `Glob(./**)` refused it and still listed the cwd and the added
218
+ * directory. The knowledge `Read(//…)` rule is belt: `--add-dir` alone
219
+ * admitted the file, and the rule keeps the read allowed should the
220
+ * adapter ever stop passing that flag.
221
+ * AND `Bash` IS DENIED BY NAME, because leaving it off the allow list is
222
+ * not "no Bash": measured the same day, with Bash absent from
223
+ * `--allowedTools` the CLI still ran `git log -1 --format=%s` and `ls` on
224
+ * its own read-only classifier (it did refuse `git log … --output=…`,
225
+ * "This command requires approval" — the reviewer's write needed the
226
+ * explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
227
+ * `--disallowedTools` the tool was not there at all.
228
+ *
229
+ * NO MCP, and no shell that could commit: the agent turn has no MCP anyway
230
+ * (see SYSTEM_AGENT's header), and a posture that could run `git commit` could
231
+ * commit the mockup it was told to keep out of git.
232
+ *
233
+ * STATED, not closed: research may still read any file in the worktree and
234
+ * name it in a WebFetch URL — the repository is what a research card is about,
235
+ * and a turn that could not read it could not answer. Design may read any
236
+ * file the Read tool reaches and put it in an artifact the server stores;
237
+ * text artifacts are scrubbed of the machine's known secret values on the way
238
+ * out (artifacts.mjs), and binary ones are withheld when they carry one.
239
+ * Design still runs a plain `git log` on the CLI's own read-only classifier
240
+ * though no git reader is allowed (measured) — the guard is what refuses its
241
+ * `--output`. And the Agent tool is offered with no allow rule on this CLI:
242
+ * a research turn with Bash denied launched a subagent to run one, and the
243
+ * subagent had no Bash either (measured) — the rules are inherited. Whether
244
+ * the guard's hook sees a subagent's own calls was not measured here.
245
+ */
246
+ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
247
+ /**
248
+ * PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
249
+ * own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
250
+ * decides and answers with a plan, and refuses each write itself.
251
+ *
252
+ * PROBED on Claude Code 2.1.281 before relying on it (2026-09-23), in a scratch
253
+ * git repo, `-p … --output-format stream-json --verbose`:
254
+ *
255
+ * · ALONE, asked to "add a subtract(a, b) function to app.js": the init event
256
+ * reads `permissionMode: "plan"`, the turn Read the file, ran a read-only
257
+ * `find`, wrote its plan to `~/.claude/plans/<slug>.md` (the CLI's own plan
258
+ * file, outside the repo), tried `ExitPlanMode` and was told "Error: No such
259
+ * tool available: ExitPlanMode. ExitPlanMode is disabled for this session",
260
+ * and ended with the plan as its result text. app.js was untouched.
261
+ * · BESIDE `--dangerously-skip-permissions`, same ask: the init event reads
262
+ * `permissionMode: "bypassPermissions"` — no error, no warning — and the
263
+ * edit LANDED. The bypass silently wins. So this list is a REPLACEMENT for
264
+ * `perm`, never an addition, and a plan turn can never carry both.
265
+ * · WITH `--mcp-config` and `--allowedTools mcp__flowviant`: the server
266
+ * connected and its tools were listed, but a call was refused — "Cannot
267
+ * call mcp__flowviant__log_work while in plan mode." — and without the
268
+ * `--allowedTools` entry, "Claude requested permissions to use
269
+ * mcp__flowviant__log_work, but you haven't granted it yet." Plan mode
270
+ * admitted the call ONLY when the server annotated the tool
271
+ * `readOnlyHint: true`, and none of the session tools (stream_session_turn,
272
+ * log_work, file_card …) are read-only or annotated so. So a plan turn runs
273
+ * PLAIN — no MCP, the plain tab's contract — rather than mounting a control
274
+ * plane whose every call the CLI refuses. See work.mjs.
275
+ */
276
+ export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
277
+ export const DESIGN_PERM = [
278
+ ...READ_GUARD,
279
+ '--allowedTools',
280
+ 'Read',
281
+ 'Grep',
282
+ 'Glob',
283
+ 'Bash(ls:*)',
284
+ ARTIFACT_WRITE,
285
+ ];
286
+
287
+ /** A path a permission rule can name: absolute POSIX, and free of the glob and
288
+ * rule-syntax characters that would change what the rule means. Anything else
289
+ * gets no rule — `--add-dir` still admits the directory (measured). */
290
+ const ruleSafeAbsolute = (p) =>
291
+ typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
292
+
293
+ /**
294
+ * RESEARCH, built at spawn — the knowledge library is the one directory
295
+ * outside the worktree it may read, and only the spawn knows where that is.
296
+ * `//` is the CLI's own spelling for an absolute path in a rule.
297
+ */
298
+ export function researchPerm(knowledgeDir) {
299
+ const kd = ruleSafeAbsolute(knowledgeDir);
300
+ return [
301
+ ...READ_GUARD,
302
+ '--allowedTools',
303
+ 'Read(./**)',
304
+ ...(kd ? [`Read(/${kd}/**)`] : []),
305
+ 'Glob(./**)',
306
+ ...(kd ? [`Glob(/${kd}/**)`] : []),
307
+ 'WebSearch',
308
+ 'WebFetch',
309
+ ARTIFACT_WRITE,
310
+ '--disallowedTools',
311
+ 'Read(./.env*)',
312
+ 'Read(./**/.env*)',
313
+ 'Bash',
314
+ ];
315
+ }
316
+ /** The list with no library — what a project that keeps none runs under. */
317
+ export const RESEARCH_PERM = researchPerm(null);
318
+
129
319
  export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
130
320
 
131
321
  // Sentinels must appear on their OWN line (the prompts require it). Substring
@@ -300,10 +490,17 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
300
490
  // ADOPTABLE — so any headless turn we run for our own purposes would leave
301
491
  // a phantom untitled session in the `+` menu. The caller that needs to
302
492
  // clean up after itself cannot do so without this id.
303
- if (Array.isArray(ev.skills) || typeof ev.session_id === 'string') {
493
+ //
494
+ // `mcpServers` (0.97.0) is the same kind of fact from the same event: the
495
+ // MCP servers and claude.ai connectors the CLI mounted, each with its own
496
+ // status (`needs-auth` above all — a sign-in that has to happen at this
497
+ // box). Relayed raw; `recordMcpServers` (runtimes.mjs) normalises it and
498
+ // drops this daemon's own `flowviant` server.
499
+ if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
304
500
  onInit?.({
305
501
  skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
306
502
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
503
+ mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
307
504
  });
308
505
  }
309
506
  } else if (ev.type === 'result') {
@@ -351,9 +548,32 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
351
548
  // returned string for sentinel detection, and each activity is handed to
352
549
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
353
550
  // off and keep the raw text passthrough + line sentinels.
354
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
551
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, planMode, posture, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
355
552
  return new Promise((resolve) => {
356
553
  const rt = runtimeById(runtime);
554
+ // PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
555
+ // from `profile` and never read `perm`, so a codex or agy turn handed
556
+ // `planMode` would run a BUILD turn wearing the word "plan". The caller
557
+ // refuses first, in words; this is the belt — fail the turn, never
558
+ // substitute.
559
+ if (planMode && rt.id !== 'claude') {
560
+ console.error(`\nerror: plan mode runs on Claude Code only — not '${rt.label}'`);
561
+ resolve('');
562
+ return;
563
+ }
564
+ // …AND SO ARE THE TWO NON-CODE POSTURES, for the same reason and in the
565
+ // same shape. The agent lane asks `canRun` first and settles `nothing` in
566
+ // words; this is the layer no caller can skip. A codex or agy adapter
567
+ // handed `posture: 'design'` never reads `perm` — it builds from `profile`
568
+ // and runs whatever that falls back to, which for codex is its build
569
+ // branch, `--sandbox danger-full-access`: a research card's words with the
570
+ // whole machine to act on. Asked against the runtime's own declared profiles,
571
+ // so the day an adapter learns to express one this opens by itself.
572
+ if ((posture === 'design' || posture === 'research') && !(rt.profiles ?? []).includes(posture)) {
573
+ console.error(`\nerror: a ${posture} card runs on Claude Code only — not '${rt.label}'`);
574
+ resolve('');
575
+ return;
576
+ }
357
577
  if (!rt.args) {
358
578
  // Reached only if a brief names a runtime this daemon declares but cannot
359
579
  // drive. Fail as a turn with no sentinel — the loop already treats that as
@@ -385,7 +605,15 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
385
605
  // of the two and a planning turn that fell through to 'consult' would lose
386
606
  // the control plane it exists to use — it would read the repo, decide what
387
607
  // the slices are, and have no way to write any of them down.
388
- const profile = planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
608
+ // A DESIGN OR RESEARCH CARD'S POSTURE (0.97.0) is asked before all of
609
+ // them, by NAME, because it is the narrowest promise here: write only the
610
+ // artifacts directory. It is set only by the agent lane, from the card's
611
+ // kind; every other caller passes nothing and gets exactly the branch it
612
+ // got before this existed.
613
+ const profile =
614
+ posture === 'design' || posture === 'research'
615
+ ? posture
616
+ : planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
389
617
  const args = rt.args({
390
618
  prompt,
391
619
  system,
@@ -418,7 +646,16 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
418
646
  // prompt as a trailing positional, so a flag after it is in the wrong
419
647
  // place.
420
648
  resultSchemaArgs,
421
- perm: planPerm ? PLAN_PERM : readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
649
+ // PLAN MODE REPLACES THE POSTURE, never joins it: beside
650
+ // `--dangerously-skip-permissions` the bypass wins silently (measured —
651
+ // see PLAN_MODE_PERM).
652
+ perm: planMode
653
+ ? PLAN_MODE_PERM
654
+ : profile === 'design'
655
+ ? DESIGN_PERM
656
+ : profile === 'research'
657
+ ? researchPerm(knowledgeDir)
658
+ : planPerm ? PLAN_PERM : readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
422
659
  // Handed to the adapter rather than appended here, because WHERE these go
423
660
  // is a property of the CLI: Codex reads its prompt as a trailing
424
661
  // positional, so a flag after it is a flag in the wrong place.
package/bin/lib/env.mjs CHANGED
@@ -725,3 +725,20 @@ export function scrub(text) {
725
725
  for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
726
726
  return out;
727
727
  }
728
+
729
+ /**
730
+ * The NAME of the first known secret whose value appears in `bytes` as an
731
+ * exact byte substring, or null. For the payloads `scrub` cannot touch: a
732
+ * binary artifact (a PNG's text chunk, a PDF's uncompressed stream, a zip's
733
+ * stored entry) is not text to rewrite — replacing bytes inside one corrupts
734
+ * it — so the caller WITHHOLDS a hit rather than redacting it. The same list,
735
+ * already filtered by `worthRedacting`, so the two can never disagree about
736
+ * what counts as a secret. It sees only what is stored plainly: a value inside
737
+ * a deflated stream is not a substring of the file, and that is stated where
738
+ * it is used (artifacts.mjs).
739
+ */
740
+ export function secretIn(bytes) {
741
+ if (!Buffer.isBuffer(bytes) || !bytes.length || !values.length) return null;
742
+ for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
743
+ return null;
744
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -73,9 +73,11 @@ import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
73
73
  import { machineSnapshot } from './resources.mjs';
74
74
  import {
75
75
  detectRuntimes,
76
+ knownMcpServers,
76
77
  knownSkills,
77
78
  pickRuntimeFor,
78
79
  probeSkillsOnce,
80
+ recordMcpServers,
79
81
  recordSkills,
80
82
  RUNTIMES,
81
83
  THINK_MARKER,
@@ -283,6 +285,22 @@ async function fetchRoster(
283
285
  } catch {
284
286
  /* best-effort — the poll must never fail on a readout */
285
287
  }
288
+ // WHICH MCP SERVERS AND CONNECTORS THE CLI MOUNTED, AND HOW EACH STANDS
289
+ // (0.97.0) — `[{n, s}]`, learned off the same init event as the skills, this
290
+ // daemon's own `flowviant` server excluded AND every `connected` one with it:
291
+ // only what needs something leaves the box (runtimes.mjs, recordMcpServers —
292
+ // a teammate has no business reading which services the operator signed
293
+ // into). The same three states: NOT SENT
294
+ // until a turn (or the one probe) has taught us, `[]` sent as the fact it is.
295
+ // The app names every server that is not connected — a connector that needs
296
+ // a sign-in AT THIS BOX above all — under the serving machine's row. A
297
+ // daemon→server report: an older server ignores the unknown param.
298
+ try {
299
+ const mcp = knownMcpServers();
300
+ if (mcp !== null) url.searchParams.set('mcp', JSON.stringify(mcp));
301
+ } catch {
302
+ /* best-effort — the poll must never fail on a readout */
303
+ }
286
304
  // THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
287
305
  //
288
306
  // It carried two more params while the secrets vault existed: `envv` (the
@@ -2120,7 +2138,10 @@ export async function runFleetDaemon() {
2120
2138
  // only thing missing was the handler. The wiki turn runs in a
2121
2139
  // detached worktree of THIS repo, so its `.claude/skills` and the
2122
2140
  // machine's personal ones resolve identically to a tab's.
2123
- onInit: (i) => recordSkills(i.skills),
2141
+ onInit: (i) => {
2142
+ recordSkills(i.skills);
2143
+ recordMcpServers(i.mcpServers);
2144
+ },
2124
2145
  onSpawn: (ch) => {
2125
2146
  wikiChild = ch;
2126
2147
  },
@@ -2169,7 +2190,10 @@ export async function runFleetDaemon() {
2169
2190
  streamJson: true,
2170
2191
  onActivity,
2171
2192
  // Same free harvest as the sweep above.
2172
- onInit: (i) => recordSkills(i.skills),
2193
+ onInit: (i) => {
2194
+ recordSkills(i.skills);
2195
+ recordMcpServers(i.mcpServers);
2196
+ },
2173
2197
  onSpawn: (ch) => {
2174
2198
  wikiChild = ch;
2175
2199
  },
@@ -0,0 +1,138 @@
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
+ * · the git arguments above, as substrings (so `--config` covers
42
+ * `--config-env`, `--output` covers `--output-directory`), plus a bare `-c`
43
+ * token and any token beginning `-O`.
44
+ * · an environment assignment — any `GIT_*=` anywhere, and any `NAME=` as the
45
+ * command's first word (`GIT_DIR`, `GIT_EXTERNAL_DIFF`, `PAGER`,
46
+ * `LESSOPEN` all change what a reader runs).
47
+ *
48
+ * FAILS CLOSED: input it cannot parse is refused, because a fence that opens
49
+ * whenever the CLI changes its hook payload is a fence that opens silently.
50
+ * The Read, Grep and Glob tools are untouched by it — the matcher is Bash —
51
+ * so a refused command always has a tool-shaped way to do the reading.
52
+ *
53
+ * OUT OF ITS REACH, stated: an operator who sets `disableAllHooks` (or a
54
+ * managed `allowManagedHooksOnly`) in their own settings turns this off, the
55
+ * way they could turn off anything on their own box; the prefix list is still
56
+ * the first fence and this is the second.
57
+ *
58
+ * Run by the CLI as `node readGuard.mjs`, the hook JSON on stdin. Imported by
59
+ * the tests for `readGuardRefusal`, which is the whole decision.
60
+ */
61
+
62
+ import { pathToFileURL } from 'node:url';
63
+
64
+ /** Substrings that turn a git reader into a writer or an executor. */
65
+ const BANNED_FLAGS = ['--output', '--ext-diff', '--textconv', '--exec-path', '--git-dir', '--work-tree', '--config'];
66
+
67
+ /** Shell composition: each is a second command or a redirect. */
68
+ const SHELL = [
69
+ ['>', 'a redirect'],
70
+ ['<(', 'process substitution'],
71
+ ['|', 'a pipe'],
72
+ [';', 'a second command'],
73
+ ['&', 'a second command'],
74
+ ['`', 'command substitution'],
75
+ ['$', 'an expansion'],
76
+ ['\n', 'a second line'],
77
+ ['\r', 'a second line'],
78
+ ];
79
+
80
+ /**
81
+ * The reason this command is refused, or null when it may run.
82
+ * @param {string} command
83
+ */
84
+ export function readGuardRefusal(command) {
85
+ if (typeof command !== 'string') return 'the command could not be read';
86
+ const flat = command.replace(/['"\\]/g, '');
87
+ for (const [op, what] of SHELL) {
88
+ if (flat.includes(op)) return `${what} (\`${op === '\n' || op === '\r' ? '\\n' : op}\`)`;
89
+ }
90
+ for (const f of BANNED_FLAGS) if (flat.includes(f)) return `\`${f}\``;
91
+ const words = flat.trim().split(/\s+/).filter(Boolean);
92
+ for (const w of words) {
93
+ if (w === '-c') return '`-c` (an inline config)';
94
+ if (w.startsWith('-O')) return '`-O`';
95
+ if (/^GIT_[A-Za-z0-9_]*=/.test(w)) return `an environment assignment (\`${w.split('=')[0]}=\`)`;
96
+ }
97
+ if (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
98
+ return `an environment assignment (\`${words[0].split('=')[0]}=\`)`;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /** The sentence the model reads when a call is refused. */
104
+ export const readGuardSentence = (why) =>
105
+ `flowviant: this turn is read-only, so ${why} is refused here — it can write a file or run a program. ` +
106
+ 'Run one plain read command at a time with no redirects, pipes or expansions, or use the Read, Grep and Glob tools.';
107
+
108
+ function main() {
109
+ // A hook that CRASHES is not a hook that refused: Claude Code treats any
110
+ // exit other than 2 as a non-blocking error and runs the command anyway. So
111
+ // every way out of this process that is not a verdict is a refusal.
112
+ process.on('uncaughtException', () => {
113
+ process.stderr.write(readGuardSentence('a command the guard could not judge'));
114
+ process.exit(2);
115
+ });
116
+ let buf = '';
117
+ process.stdin.setEncoding('utf8');
118
+ process.stdin.on('data', (d) => (buf += d));
119
+ process.stdin.on('end', () => {
120
+ let payload;
121
+ try {
122
+ payload = JSON.parse(buf);
123
+ } catch {
124
+ process.stderr.write(readGuardSentence('a command the guard could not read'));
125
+ process.exit(2);
126
+ }
127
+ // The matcher is Bash; anything else reaching here is not ours to judge.
128
+ if (payload?.tool_name !== 'Bash') process.exit(0);
129
+ const why = readGuardRefusal(payload?.tool_input?.command);
130
+ if (why) {
131
+ process.stderr.write(readGuardSentence(why));
132
+ process.exit(2);
133
+ }
134
+ process.exit(0);
135
+ });
136
+ }
137
+
138
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();