flowviant 0.114.1 → 0.115.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 CHANGED
@@ -147,6 +147,15 @@ Two knobs bound the blast radius, and both are worth setting on a shared box:
147
147
  its permission engine is machine-wide — which is surfaced in the app rather
148
148
  than papered over.
149
149
 
150
+ Fenced turns — design, 3D-model, deck and research cards, the pre-review,
151
+ the planner, intake, the wiki and the New task chat — read none of your Claude
152
+ Code settings files: their permissions and hooks are Flowviant's posture
153
+ alone, and of `~/.claude/settings.json` they keep only how Claude signs in
154
+ (`apiKeyHelper`, `env` and the like) and your model defaults. Code agents and
155
+ Terminal tabs still read yours, so your own allow rules and `defaultMode` add
156
+ to `FLOWVIANT_SAFE=1`'s allowlist there. Codex's fenced turns run with
157
+ `--ignore-user-config` the same way.
158
+
150
159
  The posture is reported on every poll and shown in the project's
151
160
  Settings → Machine section, so the team can see whether the box runs the
152
161
  guarded toolset or full permissions.
@@ -33,9 +33,13 @@
33
33
  * sentence a turn settles `nothing` with when the proof was not written;
34
34
  * - `offRuntime(rt)`: the sentence when the chosen CLI cannot run the posture.
35
35
  */
36
- /** The mockup, 3D-model, presentation and write-up kinds run on Claude: the one
37
- * CLI that declares their postures (`design`, `research`). */
38
- const CLAUDE_ONLY = 'mockup, 3D model, presentation and write-up cards run on Claude on this machine';
36
+ /** The mockup, 3D-model and presentation kinds run under the `design` posture,
37
+ * which Claude and — since 0.115.0 — Codex declare (2026-09-29, measured:
38
+ * runtimeCodex.mjs `codexDesignFence`). */
39
+ const DESIGN_CLIS = 'mockup, 3D model and presentation cards run on Claude or Codex on this machine';
40
+ /** The write-up kind runs on Claude alone: Codex cannot fence the web it needs
41
+ * apart from the whole-box reads its sandbox allows (same place). */
42
+ const CLAUDE_ONLY = 'write-up cards run on Claude on this machine';
39
43
  /** The image kind runs on Codex: the one CLI that declares its posture, because
40
44
  * the pictures come from Codex's own image tool on its own login. */
41
45
  const CODEX_ONLY = 'image cards run on Codex on this machine';
@@ -52,7 +56,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
52
56
  match: /\.html?$/i,
53
57
  missing: 'the turn ended without writing a mockup under .flowviant/artifacts/',
54
58
  }),
55
- offRuntime: () => CLAUDE_ONLY,
59
+ offRuntime: () => DESIGN_CLIS,
56
60
  }),
57
61
  /**
58
62
  * THE 3D-MODEL KIND (0.105.0; one page since 0.107.0): a three.js page that
@@ -69,7 +73,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
69
73
  match: /\.html?$/i,
70
74
  missing: 'the turn ended without writing a 3D model page under .flowviant/artifacts/',
71
75
  }),
72
- offRuntime: () => CLAUDE_ONLY,
76
+ offRuntime: () => DESIGN_CLIS,
73
77
  }),
74
78
  /**
75
79
  * THE IMAGE KIND (0.114.0): one or more raster pictures, generated by
@@ -97,7 +101,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
97
101
  match: /\.html?$/i,
98
102
  missing: 'the turn ended without writing a deck under .flowviant/artifacts/',
99
103
  }),
100
- offRuntime: () => CLAUDE_ONLY,
104
+ offRuntime: () => DESIGN_CLIS,
101
105
  }),
102
106
  research: Object.freeze({
103
107
  posture: 'research',
@@ -74,7 +74,7 @@
74
74
  * that cannot show an artifact must not have the CLI told it will.
75
75
  */
76
76
 
77
- import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from 'node:fs';
77
+ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync } from 'node:fs';
78
78
  import { createHash } from 'node:crypto';
79
79
  import { join } from 'node:path';
80
80
  import { cutKeepingExtension } from './safeFileName.mjs';
@@ -138,6 +138,48 @@ export function ensureArtifactDir(placeDir) {
138
138
  return real(parent) === true && real(dir) === true;
139
139
  }
140
140
 
141
+ /**
142
+ * A FENCED TURN'S OWN SCRATCH — `<place>/.flowviant/tmp`, beside the artifacts
143
+ * directory, under the `.flowviant/` git already ignores (0.115.0, 2026-09-29).
144
+ *
145
+ * MEASURED on codex-cli 0.156.1: under an artifacts-only profile `/tmp` is
146
+ * read-only, and a shell here-document needs a temp file (zsh writes it under
147
+ * `$TMPPREFIX`, bash and `mktemp` under `$TMPDIR`) — so `cat > page.html
148
+ * <<EOF` failed "can't create temp file for here document: read-only file
149
+ * system", left an empty page, and the turn stopped to ask why the artifacts
150
+ * directory was read-only. Codex's design fence grants this directory and
151
+ * points both variables at it (runtimeCodex.mjs `codexDesignFence`), so the
152
+ * shell works like a shell; nothing here is uploaded, committed or read back.
153
+ *
154
+ * Made EMPTY before each turn (a previous turn's scratch is nobody's), and
155
+ * only as a real directory under a real `.flowviant` — the artifacts
156
+ * directory's rule, for its reason: a fence grant on a path through a
157
+ * committed symlink is a write grant on its target. False creates nothing
158
+ * that matters, and the caller refuses the turn in words.
159
+ */
160
+ export const FENCE_TMP_DIR = '.flowviant/tmp';
161
+ export function ensureFenceScratch(placeDir) {
162
+ const parent = join(placeDir, '.flowviant');
163
+ const dir = join(placeDir, FENCE_TMP_DIR);
164
+ const kind = (p) => {
165
+ try {
166
+ const st = lstatSync(p);
167
+ return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
168
+ } catch (e) {
169
+ return e?.code === 'ENOENT' ? 'none' : 'other';
170
+ }
171
+ };
172
+ if (kind(parent) !== 'dir' || kind(dir) === 'other') return false;
173
+ try {
174
+ // A real directory (checked above): its contents go, never through a link.
175
+ if (kind(dir) === 'dir') rmSync(dir, { recursive: true, force: true });
176
+ mkdirSync(dir);
177
+ } catch {
178
+ return false;
179
+ }
180
+ return kind(parent) === 'dir' && kind(dir) === 'dir';
181
+ }
182
+
141
183
  export function artifactTypeFor(name) {
142
184
  const dot = String(name).lastIndexOf('.');
143
185
  if (dot <= 0) return null;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * A FENCED CLAUDE TURN TAKES ITS PERMISSIONS AND HOOKS FROM FLOWVIANT ALONE
3
+ * (2026-09-29, the owner, asked whether an agent's fence should ignore the
4
+ * person's own permission settings: "yes, the agent's fence can ignore").
5
+ *
6
+ * A 3D-model agent under the design posture, whose whole shell is
7
+ * `Bash(ls:*)`, ran `google-chrome` and Playwright scripts. Every fenced lane
8
+ * read the person's `~/.claude/settings.json` — an agent turn through
9
+ * `--setting-sources user`, the pre-review, planner, intake, wiki and capture
10
+ * chat by naming no source at all, which ALSO reads the cwd's
11
+ * `.claude/settings*.json`, and the pre-review's cwd is the agent's own
12
+ * worktree. A settings file is a second author of the posture. MEASURED on
13
+ * Claude Code 2.1.284 — see claudePersonal.test.mjs for the probes and the
14
+ * offline check that pins them on the real CLI. In short: a personal
15
+ * `permissions.allow` rule admitted a command the list refused, a personal
16
+ * `defaultMode: "bypassPermissions"` turned the fenced turn into a bypass, and
17
+ * personal and worktree hooks fired; under `--setting-sources ''` none of them
18
+ * loaded, and the turn ran on the same login.
19
+ *
20
+ * AN EMPTY SOURCE LIST DROPS THE WHOLE USER SOURCE, not only its permissions:
21
+ * `~/.claude/CLAUDE.md`, personal skills and subagents went too (measured),
22
+ * and with the file go the plugins it enables. No flag keeps half of it — a
23
+ * flag setting's allow rules only add to the person's. On a fenced lane that
24
+ * is the right loss: the posture and the contract are Flowviant's, the base
25
+ * branch's instructions and skills arrive through the prompt and the reviewed
26
+ * plugin (`--plugin-dir` still loads, measured), and a personal plugin brings
27
+ * hooks of its own. Codex's fenced postures are hermetic the same way
28
+ * (`--ignore-user-config`, runtimeCodex.mjs).
29
+ *
30
+ * WHAT IS KEPT is what makes the CLI the person's CLI, never what it may do:
31
+ * how it signs in and reaches its provider, how a model the turn does not pin
32
+ * runs, and how long the person's own store is kept. An ALLOWLIST, so a key a
33
+ * later CLI adds — a sandbox auto-allow, a permission mode — never rides by
34
+ * default. It rides INSIDE the posture's own `--settings`, because the CLI
35
+ * reads one: given two, only the last one's hook fired (measured).
36
+ */
37
+ import { readFileSync, statSync } from 'node:fs';
38
+ import { homedir } from 'node:os';
39
+ import { join } from 'node:path';
40
+ import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
41
+ import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
42
+
43
+ /** The flag pair that loads no settings file — user, project or local.
44
+ * Managed (policy) settings and `--settings` still apply. */
45
+ export const NO_SETTING_SOURCES = Object.freeze(['--setting-sources', '']);
46
+
47
+ /**
48
+ * The personal settings a fenced turn keeps, by key.
49
+ *
50
+ * · THE LOGIN — the helpers the CLI runs to authenticate and where it signs
51
+ * in, and `env`, where a login routes (ANTHROPIC_BASE_URL,
52
+ * CLAUDE_CODE_USE_BEDROCK, AWS_PROFILE). `env` gives the person's file no
53
+ * more reach than the operator's shell, which every turn inherits already;
54
+ * what a turn's environment promises is still removed from it
55
+ * (`keptEnv`).
56
+ * · HOW AN UNPINNED MODEL RUNS ("Machine default uses the CLI's own
57
+ * setting"): per-model effort, thinking, the model map a provider needs.
58
+ * `model` itself is not here: every Claude turn names `--model`, the
59
+ * daemon's pin when the card has none (config.mjs), so the person's
60
+ * default model never reached a daemon turn before either.
61
+ * · THE PERSON'S OWN STORE: how long transcripts live and whether memory is
62
+ * kept, so a fenced turn's start never prunes or writes on a default the
63
+ * person turned off.
64
+ */
65
+ export const PERSONAL_KEPT = Object.freeze([
66
+ 'apiKeyHelper',
67
+ 'proxyAuthHelper',
68
+ 'awsAuthRefresh',
69
+ 'awsCredentialExport',
70
+ 'gcpAuthRefresh',
71
+ 'forceLoginMethod',
72
+ 'forceLoginOrgUUID',
73
+ 'forceLoginGatewayUrl',
74
+ 'env',
75
+ 'modelOverrides',
76
+ 'modelSettings',
77
+ 'effortLevel',
78
+ 'maxEffortLevel',
79
+ 'alwaysThinkingEnabled',
80
+ 'fallbackModel',
81
+ 'cleanupPeriodDays',
82
+ 'autoMemoryEnabled',
83
+ ]);
84
+
85
+ /** What a turn nobody is sitting at has set or removed (noWindowEnv.mjs) and
86
+ * the machine credential (machineEnv.mjs): never put back by a settings file. */
87
+ const TURN_OWNED_ENV = new Set([
88
+ ...MACHINE_CREDENTIAL_ENV,
89
+ ...WINDOW_ENV_REMOVED,
90
+ ...Object.keys(withoutWindows({}, '/')),
91
+ ]);
92
+ function keptEnv(env) {
93
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return null;
94
+ const out = {};
95
+ for (const [k, v] of Object.entries(env)) if (typeof v === 'string' && !TURN_OWNED_ENV.has(k)) out[k] = v;
96
+ return Object.keys(out).length ? out : null;
97
+ }
98
+
99
+ /** Far past any real settings file; a bigger one is not read. */
100
+ const SETTINGS_MAX_BYTES = 1024 * 1024;
101
+
102
+ /**
103
+ * The person's user settings file — `$CLAUDE_CONFIG_DIR/settings.json`, else
104
+ * `~/.claude/settings.json`, where the CLI itself looks — reduced to
105
+ * PERSONAL_KEPT. `{}` when it is absent, unreadable or not a JSON object: the
106
+ * turn then runs on the CLI's stored login and defaults, and the fence is the
107
+ * same either way.
108
+ */
109
+ export function personalClaudeSettings(env = process.env) {
110
+ const dir = env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude');
111
+ const path = join(dir, 'settings.json');
112
+ let raw;
113
+ try {
114
+ if (statSync(path).size > SETTINGS_MAX_BYTES) return {};
115
+ raw = JSON.parse(readFileSync(path, 'utf8'));
116
+ } catch {
117
+ return {};
118
+ }
119
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
120
+ const out = {};
121
+ for (const key of PERSONAL_KEPT) {
122
+ if (!Object.hasOwn(raw, key) || raw[key] == null) continue;
123
+ if (key !== 'env') out[key] = raw[key];
124
+ else {
125
+ const kept = keptEnv(raw.env);
126
+ if (kept) out.env = kept;
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * `perm` with `kept` folded into its `--settings` — the posture's keys win, so
134
+ * a kept key a posture ever names is the fence's word. A posture with no
135
+ * `--settings` gets one before its lists (`--allowedTools` is variadic). With
136
+ * nothing kept, `perm` is returned as it was.
137
+ */
138
+ export function withPersonalSettings(perm, kept) {
139
+ const personal = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
140
+ if (!Object.keys(personal).length) return [...perm];
141
+ const at = perm.indexOf('--settings');
142
+ if (at < 0) return ['--settings', JSON.stringify(personal), ...perm];
143
+ const posture = JSON.parse(perm[at + 1]);
144
+ return [...perm.slice(0, at), '--settings', JSON.stringify({ ...personal, ...posture }), ...perm.slice(at + 2)];
145
+ }