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.
- package/bin/lib/artifacts.mjs +38 -7
- package/bin/lib/claude.mjs +241 -4
- package/bin/lib/env.mjs +17 -0
- package/bin/lib/fleet.mjs +26 -2
- package/bin/lib/hooks/readGuard.mjs +138 -0
- package/bin/lib/knowledge.mjs +330 -13
- package/bin/lib/prompts.mjs +339 -22
- package/bin/lib/runtimes.mjs +81 -2
- package/bin/lib/work.mjs +193 -16
- package/package.json +1 -1
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -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.
|
|
46
|
-
*
|
|
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);
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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();
|