flowviant 0.95.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/README.md +2 -2
- package/bin/cli.mjs +31 -16
- package/bin/lib/artifacts.mjs +38 -7
- package/bin/lib/claude.mjs +241 -4
- package/bin/lib/credentials.mjs +39 -0
- 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/login.mjs +16 -73
- package/bin/lib/machines.mjs +6 -6
- 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/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Launch with `@latest` so each start pulls the newest published version — a bar
|
|
|
54
54
|
|
|
55
55
|
## Machines on this box
|
|
56
56
|
|
|
57
|
-
One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not
|
|
57
|
+
One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not — and a directory serves one project, so `npx flowviant` there refuses to start until one of them goes.
|
|
58
58
|
|
|
59
59
|
On a terminal the listing is a menu: ↑/↓ over the projects, enter for what you can do about one, esc to leave. Two verbs, each acting on **this box's own connection** and nothing else:
|
|
60
60
|
|
|
@@ -63,7 +63,7 @@ On a terminal the listing is a menu: ↑/↓ over the projects, enter for what y
|
|
|
63
63
|
|
|
64
64
|
Stopping or removing a daemon on **another** computer is done in the app (project settings → Machines), where the person pressing it can see what they are changing.
|
|
65
65
|
|
|
66
|
-
`flowviant login`
|
|
66
|
+
**A directory serves one project.** `flowviant login` refuses to bind a second project to a repository that is already connected to one, and a store that already holds two projects for one repository refuses to start there — both name the projects and the remedy: delete the project you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it with `flowviant machines`, then try again.
|
|
67
67
|
|
|
68
68
|
## Sessions
|
|
69
69
|
|
package/bin/cli.mjs
CHANGED
|
@@ -46,7 +46,10 @@
|
|
|
46
46
|
* a daemon on another computer is invisible from here; since 0.95.0 it says
|
|
47
47
|
* out loud when two projects are bound to one repo, and on a terminal it is a
|
|
48
48
|
* menu (↑/↓, enter) whose verbs disconnect THIS box from a project or forget
|
|
49
|
-
* a credential here — `--remove <id>` / `--forget <id>` for a script.
|
|
49
|
+
* a credential here — `--remove <id>` / `--forget <id>` for a script. A
|
|
50
|
+
* DIRECTORY SERVES ONE PROJECT: login refuses to bind a second project to a
|
|
51
|
+
* repo, and a store that already holds two for one repo refuses to start
|
|
52
|
+
* until one is deleted in the app or disconnected here.
|
|
50
53
|
*
|
|
51
54
|
* Env:
|
|
52
55
|
* FLOWVIANT_MACHINE_TOKEN the machine credential (or use `flowviant login`);
|
|
@@ -89,7 +92,8 @@ if (process.argv[2] === 'login') {
|
|
|
89
92
|
// A login the person CANCELLED at the second-project question saved nothing,
|
|
90
93
|
// so there is no credential for the child to serve — starting it would end
|
|
91
94
|
// in "no credential found" over a choice they just made on purpose.
|
|
92
|
-
if (
|
|
95
|
+
if (!login?.saved) process.exit(1); // refused: the directory already serves another project
|
|
96
|
+
if (noStart) process.exit(0);
|
|
93
97
|
// Re-exec as a plain `flowviant` rather than falling through. config.mjs reads
|
|
94
98
|
// the credential at IMPORT time — which was before the login we just did — so
|
|
95
99
|
// this process still has an empty FLEET_TOKEN and would exit with "no
|
|
@@ -460,7 +464,7 @@ const externalToken =
|
|
|
460
464
|
* existed, so this process cannot serve; the child can. */
|
|
461
465
|
async function reexecAfterLogin() {
|
|
462
466
|
const login = await runLogin({ thenStart: false });
|
|
463
|
-
if (!login?.saved) process.exit(
|
|
467
|
+
if (!login?.saved) process.exit(1); // refused: the directory already serves another project
|
|
464
468
|
const { spawn } = await import('node:child_process');
|
|
465
469
|
const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
|
|
466
470
|
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
@@ -470,7 +474,9 @@ async function reexecAfterLogin() {
|
|
|
470
474
|
// the connected date to the COLLIDING rows only — production held two projects
|
|
471
475
|
// both called "BRIF AI", both bound to the same checkout, so this picker
|
|
472
476
|
// offered two identical lines and the only way to answer was to guess. See
|
|
473
|
-
// credentials.mjs for why the suffix is not on every row.
|
|
477
|
+
// credentials.mjs for why the suffix is not on every row. (That exact case is
|
|
478
|
+
// a refusal now rather than a picker — a directory serves one project — but
|
|
479
|
+
// two same-named projects can still meet in the no-match picker.)
|
|
474
480
|
function listLines(entries, { projectRowLabel }) {
|
|
475
481
|
return entries
|
|
476
482
|
.map(
|
|
@@ -486,6 +492,20 @@ if (!FLEET_TOKEN) {
|
|
|
486
492
|
console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
|
|
487
493
|
process.exit(1);
|
|
488
494
|
}
|
|
495
|
+
// A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
|
|
496
|
+
// directory cannot have more than one project on flowviant. if one already
|
|
497
|
+
// exists, it would warn the user and ask them to delete the project on
|
|
498
|
+
// flowviant first. because 2 projects shouldnt be able to edit a directory
|
|
499
|
+
// at the same time."). This used to be a PICKER — two projects bound to one
|
|
500
|
+
// checkout, choose which this daemon serves — which is a control offering to
|
|
501
|
+
// do the forbidden thing politely. It is a refusal now, on a TTY and
|
|
502
|
+
// headless alike, naming both projects and the remedy. `credentialRefusal`
|
|
503
|
+
// is the same sentence login prints when it declines to bind a second one.
|
|
504
|
+
if (CREDENTIAL.reason === 'multiple-bound') {
|
|
505
|
+
const { directoryTakenRefusal } = await import('./lib/credentials.mjs');
|
|
506
|
+
console.error(directoryTakenRefusal(CREDENTIAL.choices, CREDENTIAL.repoRoot));
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
489
509
|
if (CREDENTIAL.choices?.length && interactive) {
|
|
490
510
|
const creds = await import('./lib/credentials.mjs');
|
|
491
511
|
const { originSlug } = await import('./lib/git.mjs');
|
|
@@ -494,9 +514,7 @@ if (!FLEET_TOKEN) {
|
|
|
494
514
|
console.log(
|
|
495
515
|
CREDENTIAL.reason === 'outside-repo'
|
|
496
516
|
? 'flowviant is not inside a git repo, and more than one project is connected here.'
|
|
497
|
-
:
|
|
498
|
-
? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
|
|
499
|
-
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
517
|
+
: `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
|
|
500
518
|
);
|
|
501
519
|
|
|
502
520
|
const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
|
|
@@ -505,16 +523,13 @@ if (!FLEET_TOKEN) {
|
|
|
505
523
|
// this only decides which row the cursor starts on, using the repo's folder
|
|
506
524
|
// name and its github repo-name against the stored project names. A unique
|
|
507
525
|
// match becomes ONE keypress; a wrong guess costs nothing, because the human
|
|
508
|
-
// still confirms.
|
|
509
|
-
//
|
|
526
|
+
// still confirms. (Two projects bound to THIS repo never reach here: that
|
|
527
|
+
// is a refusal above, since 2026-09-23.)
|
|
510
528
|
const slug = repoRoot ? originSlug(repoRoot) : null;
|
|
511
|
-
const likely =
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
repoBasename: repoRoot ? basename(repoRoot) : null,
|
|
516
|
-
repoSlugName: slug ? slug.split('/')[1] : null,
|
|
517
|
-
});
|
|
529
|
+
const likely = creds.likelyChoiceIndex(choices, {
|
|
530
|
+
repoBasename: repoRoot ? basename(repoRoot) : null,
|
|
531
|
+
repoSlugName: slug ? slug.split('/')[1] : null,
|
|
532
|
+
});
|
|
518
533
|
|
|
519
534
|
// Bounded like the confirm below, and for the same reason — but silence
|
|
520
535
|
// means something DIFFERENT here and the difference is load-bearing. There
|
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/credentials.mjs
CHANGED
|
@@ -227,6 +227,42 @@ export function boundElsewhere(entries, repoRoot, projectId) {
|
|
|
227
227
|
return entries.filter((e) => e.projectId !== projectId && samePath(e.repoRoot, repoRoot));
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* THE REFUSAL, in one place, because two commands print it (2026-09-23).
|
|
232
|
+
*
|
|
233
|
+
* The owner, verbatim: "a directory cannot have more than one project on
|
|
234
|
+
* flowviant. if one already exists, it would warn the user and ask them to
|
|
235
|
+
* delete the project on flowviant first. because 2 projects shouldnt be able
|
|
236
|
+
* to edit a directory at the same time." So `flowviant login` refuses to bind
|
|
237
|
+
* a second project to a repo, and a start in a repo the store already holds
|
|
238
|
+
* two projects for refuses to serve either — the picker that used to stand
|
|
239
|
+
* there was a control offering to do the forbidden thing politely.
|
|
240
|
+
*
|
|
241
|
+
* It names every project on the directory, the rule, and the remedy in the
|
|
242
|
+
* owner's own order: delete the project in Flowviant first. The disconnect
|
|
243
|
+
* this box can do on its own (`flowviant machines`) is named second, for the
|
|
244
|
+
* case where the project must survive somewhere else — it satisfies the same
|
|
245
|
+
* rule, because a project this box no longer serves cannot edit the directory
|
|
246
|
+
* from here.
|
|
247
|
+
*/
|
|
248
|
+
export function directoryTakenRefusal(bound, repoRoot, { incoming } = {}) {
|
|
249
|
+
// Every project in the sentence is labelled against the WHOLE set, the
|
|
250
|
+
// incoming one included: a refusal reading "already connected to BRIF AI,
|
|
251
|
+
// so BRIF AI was not connected" is the puzzle it exists to end.
|
|
252
|
+
const all = [...bound, ...(incoming ? [incoming] : [])];
|
|
253
|
+
const names = bound.map((e) => projectRowLabel(e, all));
|
|
254
|
+
const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0];
|
|
255
|
+
const head = incoming
|
|
256
|
+
? `${repoRoot} is already connected to ${list}, so ${projectRowLabel(incoming, all)} was not connected here.`
|
|
257
|
+
: `${repoRoot} is connected to ${list}.`;
|
|
258
|
+
return (
|
|
259
|
+
`${head}\n` +
|
|
260
|
+
'A directory serves one project — two projects must not edit it at the same time.\n' +
|
|
261
|
+
'Delete the project you do not mean in Flowviant first (project settings → General → Delete project),\n' +
|
|
262
|
+
'or disconnect this box from it with `flowviant machines`, then run this again.'
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
230
266
|
/** Collapse a name or slug for loose comparison: "My Project", "my-project"
|
|
231
267
|
* and "myproject" all become "myproject". */
|
|
232
268
|
function normalizeName(s) {
|
|
@@ -387,6 +423,9 @@ export function matchStoredProject(ref) {
|
|
|
387
423
|
* this returns. Shapes:
|
|
388
424
|
* { entry, source: 'project-flag' | 'repo' | 'only', needsConfirm? }
|
|
389
425
|
* { choices, reason: 'no-match' | 'multiple-bound' | 'outside-repo', repoRoot }
|
|
426
|
+
* (`multiple-bound` is a REFUSAL in cli.mjs since 2026-09-23 — a directory
|
|
427
|
+
* serves one project — and never a picker; the shape is kept because it
|
|
428
|
+
* names the projects the refusal has to print)
|
|
390
429
|
* { none: true }
|
|
391
430
|
* { error }
|
|
392
431
|
*
|
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
|
+
}
|