flowviant 0.116.0 → 0.124.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.
Files changed (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -10,7 +10,11 @@
10
10
  * the PRESENT values of `childEnv`'s `DEPLOY_KEEP` names out of this process's
11
11
  * own environment, which is where an operator's `CLOUDFLARE_API_TOKEN`
12
12
  * actually lives and which the deploy lane hands to a command whose stdout it
13
- * then streams to the server. It redacts on SHAPE, never on meaning: a value
13
+ * then streams to the server. (Three more feeds joined on 2026-10-10: the
14
+ * person's Codex provider secrets, the nested env files a deploy job copies in
15
+ * for that job's life, and a short list of vendor token SHAPES — see
16
+ * `learnSecrets`, `learnExtraSecrets` and `TOKEN_SHAPES`.) It redacts on
17
+ * SHAPE, never on meaning: a value
14
18
  * that looks like an ordinary word is left alone, because a relay that swaps
15
19
  * the CLI's own words for `[REDACTED:NODE_ENV]` has stopped being a relay.
16
20
  * NOTHING HERE EVER REACHES THE WIRE — it is a list of what to hide, not a
@@ -30,6 +34,9 @@
30
34
  // two drifting; `processEnvSecrets`'s docblock carries the whole argument,
31
35
  // including why none of it ever reaches the wire.
32
36
  import { processEnvSecrets } from './childEnv.mjs';
37
+ // THE PERSON'S CODEX PROVIDER SECRETS (2026-10-10, the audit's F073), read on
38
+ // the same beat as the process half — see `learnSecrets`.
39
+ import { readPersonalCodexSecrets } from './codexPersonal.mjs';
33
40
 
34
41
  // ── What `scrub` will redact ───────────────────────────────────────────────
35
42
  //
@@ -76,25 +83,36 @@ const SCRUB_MIN_LENGTH = 6;
76
83
  * than meaning (nothing here classifies, guesses, or asks a model):
77
84
  *
78
85
  * · A PLAIN IDENTIFIER — starts with a letter, runs at most sixteen
79
- * characters, and is made only of letters, digits and `.` `/` `-`. That is
80
- * what a word, a hostname, a short version string and an enum value all look
81
- * like. `_` is deliberately NOT in the set: an underscore is rare in prose
82
- * and common in key material (`sk-live_…`), so its presence is the cheapest
83
- * honest signal that a string was minted rather than written.
86
+ * characters, and is made only of letters, digits and `.` `_` `/` `-`, and
87
+ * does not mix upper case, lower case AND digits (the case-and-digit rule
88
+ * in `worthRedacting`, below). That is what a word, a hostname, a short
89
+ * version string, an enum value and a snake_case name all look like. The
90
+ * underscore IS in the set (`SCRUB_PLAIN_RE`) and has been since the first
91
+ * public release: `db_pass_2024` and `abc_defghijk` are plain and left as
92
+ * they are. (This comment said until 2026-10-10 that `_` was deliberately
93
+ * left out of the set as the mark of a minted string; the code never did
94
+ * that, the public pages — SECURITY.md and the app's Privacy page — follow
95
+ * the code, and dropping `_` is an open owner call that needs a dated
96
+ * ruling, a release and both pages changed.)
84
97
  * · A SHORT VALUE WITH LITTLE VARIETY — under twelve characters and drawing on
85
98
  * fewer than three of {lower, upper, digit, symbol}. A real credential short
86
99
  * enough to be under twelve characters is mixed; `us-east-1` and `verbose`
87
100
  * are not.
88
101
  *
102
+ * Anything under `SCRUB_MIN_LENGTH` (six) is left alone before either test.
103
+ *
89
104
  * THE ACCEPTED COST, STATED RATHER THAN HIDDEN: a genuinely secret value that
90
- * is short and lowercase — `hunter2secret`, a thirteen-character all-letters
91
- * password — is NOT redacted. Under-redaction is chosen here over making the
92
- * product illegible, because the alternative is a trace nobody can read and a
93
- * product that silently rewrites its own CLI's output. An operator whose secret
94
- * is a dictionary word has a problem this function cannot fix. Anything with a
95
- * symbol, a case mix, twelve-plus characters, or an underscore — which is every
96
- * token, key, URL, JWT and hex digest anybody actually pastes into a `.env` —
97
- * is redacted.
105
+ * is plain or short and unvaried — `hunter2secret`, a thirteen-character
106
+ * lowercase-and-digit password, or `deadbeef12345678`, a sixteen-character
107
+ * lowercase hex key that starts with a letter — is NOT redacted. Under-redaction is chosen here over making the product
108
+ * illegible, because the alternative is a trace nobody can read and a product
109
+ * that silently rewrites its own CLI's output. An operator whose secret is a
110
+ * dictionary word has a problem this function cannot fix. What IS redacted is
111
+ * a value of six or more characters that is not a plain identifier (longer
112
+ * than sixteen, a character outside the set, a leading non-letter, or an
113
+ * upper-lower-digit mix) and is twelve or more characters or draws on three
114
+ * of {lower, upper, digit, symbol} — which covers the long tokens, keys,
115
+ * URLs, JWTs and hex digests people actually paste into a `.env`.
98
116
  */
99
117
  const SCRUB_PLAIN_RE = /^[A-Za-z][A-Za-z0-9._/-]{0,15}$/;
100
118
  function characterClasses(value) {
@@ -112,7 +130,7 @@ function worthRedacting(value) {
112
130
  // password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
113
131
  // character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
114
132
  // [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
115
- // this block's own promise that "a case mix" is redacted. A word, a hostname,
133
+ // the block's promise then that "a case mix" is redacted. A word, a hostname,
116
134
  // a region or a version string does not draw on upper, lower AND digits at
117
135
  // once; a minted secret almost always does.
118
136
  if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
@@ -144,7 +162,8 @@ function worthRedacting(value) {
144
162
  * ── TWO SOURCES, KEPT IN TWO VARIABLES (2026-09-21, the review) ──
145
163
  *
146
164
  * `values` is what `scrub` reads and is rebuilt on every scan as
147
- * `processEnvSecrets()` ++ `fileValues`. The split exists because the two halves
165
+ * `processEnvSecrets()` ++ `fileValues` (++ the Codex and per-job halves below,
166
+ * since 2026-10-10 — see `rebuild`). The split exists because the two halves
148
167
  * have opposite failure modes. The CHECKOUT half can come back empty for two
149
168
  * different reasons — the operator deleted `.env`, or this pass could not read
150
169
  * the directory — so an empty read must NOT replace it (see `learnSecrets`).
@@ -164,7 +183,46 @@ function worthRedacting(value) {
164
183
  * because the filter is about redaction and the read is about what is on disk.
165
184
  */
166
185
  let fileValues = [];
167
- let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
186
+ /**
187
+ * THE PERSON'S CODEX PROVIDER SECRETS (2026-10-10, the audit's F073) — the
188
+ * selected provider's `experimental_bearer_token` and literal `http_headers`
189
+ * out of `config.toml`, plus the present values its `env_key` and
190
+ * `env_http_headers` name (`readPersonalCodexSecrets`). A fenced Codex turn
191
+ * carries the literals in its own environment, where `env` prints them, and
192
+ * every posture can read `~/.codex/config.toml` itself — so they are learned
193
+ * WHATEVER the posture, and a turn that prints them relays a redaction.
194
+ *
195
+ * Kept apart for the file half's reason: the read is a FILE, which can blink,
196
+ * so only a read that found something replaces it (see `learnSecrets`). Not
197
+ * read at import — that would be I/O on every import of this module; the
198
+ * daemon's first `learnSecrets` runs before its first poll (fleet.mjs).
199
+ */
200
+ let codexValues = [];
201
+ /**
202
+ * PER-JOB EXTRAS (2026-10-10, the audit's F085) — `key → [{ name, value }]`,
203
+ * added and removed by their owner (`learnExtraSecrets` / `forgetExtraSecrets`)
204
+ * and never touched by the 60s rescan. The deploy lane registers the nested
205
+ * env files it copied into a job's worktree here for that job's life.
206
+ */
207
+ const extraValues = new Map();
208
+ let values = [];
209
+ rebuild();
210
+
211
+ /**
212
+ * THE ONE PLACE `values` IS BUILT, from every half above (2026-10-10).
213
+ *
214
+ * LONGEST FIRST: `scrub` replaces values one after another, so a short value
215
+ * that sits INSIDE a longer one (a password inside a connection URL) was
216
+ * replaced first whenever the scan happened to list it first, and the longer
217
+ * value — now holding a marker where its substring was — no longer matched,
218
+ * leaving the rest of it in plain text. Replacing the longer value first
219
+ * removes both.
220
+ */
221
+ function rebuild() {
222
+ values = [...processEnvSecrets(), ...codexValues, ...fileValues, ...[...extraValues.values()].flat()]
223
+ .filter((v) => worthRedacting(v.value))
224
+ .sort((a, b) => b.value.length - a.value.length);
225
+ }
168
226
 
169
227
  /**
170
228
  * HAND THE SCRUBBER A FRESH READ OF THE CHECKOUT'S `.env*` VALUES —
@@ -199,24 +257,87 @@ let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
199
257
  * in this daemon takes. The REPORT half is the scan's, and is unaffected: it
200
258
  * returns the real (empty) read, so the app is told what was actually measured.
201
259
  */
202
- export function learnSecrets(scannedValues) {
260
+ export function learnSecrets(scannedValues, codexSecrets = readCodexHalf()) {
203
261
  if (scannedValues.length) fileValues = scannedValues;
204
- // FILTERED ONCE, HERE — see the predicate's own block above: `scrub` is a hot
205
- // path and a value that can never match must not survive into its loop.
206
- values = [...processEnvSecrets(), ...fileValues].filter((v) => worthRedacting(v.value));
262
+ // THE CODEX HALF RIDES THE SAME BEAT (2026-10-10), under the file half's
263
+ // rule: `config.toml` is a file, so a read that found nothing — the person
264
+ // removed the provider, or the read blinked — leaves what was learned.
265
+ if (Array.isArray(codexSecrets) && codexSecrets.length) codexValues = codexSecrets;
266
+ // FILTERED ONCE, IN `rebuild` — see the predicate's own block above: `scrub`
267
+ // is a hot path and a value that can never match must not survive into its
268
+ // loop.
269
+ rebuild();
270
+ }
271
+
272
+ /** The Codex half's read, which must never cost the file half its beat: the
273
+ * read swallows its own I/O errors, and anything else it could throw reads
274
+ * as "nothing new" (2026-10-10). */
275
+ function readCodexHalf() {
276
+ try {
277
+ return readPersonalCodexSecrets();
278
+ } catch {
279
+ return [];
280
+ }
281
+ }
282
+
283
+ /**
284
+ * A SECOND, ADDITIVE DOOR, OWNED BY WHOEVER OPENED IT (2026-10-10, the audit's
285
+ * F085).
286
+ *
287
+ * `learnSecrets` REPLACES the file half on every read that found something,
288
+ * so a value fed through it a second time would be wiped by the next 60s root
289
+ * scan. The deploy lane copies NESTED env files (`apps/api/.dev.vars`,
290
+ * `apps/web/.env.production`) into the worktree its build runs in — files the
291
+ * root-only scan deliberately never reads (env.mjs) — and then relays that
292
+ * build's output. It knows exactly which files it copied, so it teaches their
293
+ * values here under its job's key and forgets them when the job's checkout is
294
+ * gone. Filtered by `worthRedacting` like every other half; nothing here
295
+ * reaches the wire.
296
+ */
297
+ export function learnExtraSecrets(key, list) {
298
+ extraValues.set(key, (Array.isArray(list) ? list : []).filter((v) => typeof v?.value === 'string'));
299
+ rebuild();
300
+ }
301
+
302
+ /** Drop what `learnExtraSecrets` was taught under `key`. */
303
+ export function forgetExtraSecrets(key) {
304
+ if (extraValues.delete(key)) rebuild();
207
305
  }
208
306
 
209
307
  // ── Uplink scrubbing ───────────────────────────────────────────────────────
210
308
 
211
- /** Redact every known secret value from daemon-posted text. The list has
212
- * already been through `worthRedacting`, so this makes no decisions: an
213
- * ordinary word, a short flag and anything under the length floor are simply
214
- * not in it. */
309
+ /** Redact every known secret value, then every known token SHAPE, from
310
+ * daemon-posted text. The value list has already been through
311
+ * `worthRedacting`, so this makes no decisions: an ordinary word, a short flag
312
+ * and anything under the length floor are simply not in it. */
215
313
  export function scrub(text) {
216
314
  if (typeof text !== 'string' || !text) return text;
217
315
  let out = text;
218
316
  for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
219
- return out.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
317
+ return redactShapes(out);
318
+ }
319
+
320
+ /**
321
+ * SCRUB THE WHOLE VALUE, THEN CUT IT (2026-10-10, the audit's F072/F143).
322
+ *
323
+ * `scrub` matches EXACT values, so a credential that straddles a cap and is cut
324
+ * FIRST arrives in two halves that match nothing, and the surviving half ships.
325
+ * This daemon has shipped that order and recorded learning it more than once
326
+ * (workAgentCheck.mjs, agentPlan.mjs's `progressOf`, runtimeEvents.mjs's
327
+ * `toolEventOf`). These two make the right order the one that is spelled: the
328
+ * whole field is scrubbed, and only the result is cut — so a replacement that
329
+ * grows a string can never push it past `cap` either. `fn` is the scrubber,
330
+ * injectable for the parsers that take one as a parameter.
331
+ */
332
+ export function scrubCut(text, cap, fn = scrub) {
333
+ return String(fn(String(text ?? ''))).slice(0, cap);
334
+ }
335
+
336
+ /** `scrubCut`'s tail form — the LAST `cap` characters of the scrubbed text,
337
+ * for output whose end says why it failed. */
338
+ export function scrubTail(text, cap, fn = scrub) {
339
+ const out = String(fn(String(text ?? '')));
340
+ return cap > 0 ? out.slice(-cap) : '';
220
341
  }
221
342
 
222
343
  /**
@@ -238,10 +359,142 @@ export function scrub(text) {
238
359
  * process never saw, with no store read and nothing to keep in sync. Twenty is
239
360
  * the floor so the 12-character prefix the app shows for identification
240
361
  * (`fva_` + 8) still reads as itself.
362
+ *
363
+ * ONE SPELLING OF THE PREFIX, HELD BY THE RELEASE GATE (2026-10-10, audit
364
+ * F146). The pattern and the byte probe are both built from
365
+ * `FLOWVIANT_TOKEN_PREFIX`, and `scripts/check-app-parity.mjs` holds it to the
366
+ * app's `AGENT_TOKEN_PREFIX` and the floor to the length the app mints: a
367
+ * renamed token family would otherwise pass through every redaction here,
368
+ * silently, until a credential sat in a trace.
241
369
  */
242
- const FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
370
+ export const FLOWVIANT_TOKEN_PREFIX = 'fva_';
371
+ export const FLOWVIANT_TOKEN_FLOOR = 20;
372
+ const FLOWVIANT_TOKEN_RE = new RegExp(`${FLOWVIANT_TOKEN_PREFIX}[A-Za-z0-9_-]{${FLOWVIANT_TOKEN_FLOOR},}`, 'g');
243
373
  const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
244
374
 
375
+ /**
376
+ * THE VENDOR LOGINS, CAUGHT BY SHAPE TOO (2026-10-10, the audit's F014).
377
+ *
378
+ * The value lists hold what this box was TOLD about — `.env` files, a few named
379
+ * environment variables, the Codex config. They never held the logins the CLIs
380
+ * themselves keep: Claude Code's OAuth pair in `~/.claude/.credentials.json`
381
+ * (`sk-ant-oat…` / `sk-ant-ort…`), an Anthropic or OpenAI key a shell profile
382
+ * exports under a name nobody listed, Codex's `auth.json` tokens, `gh auth
383
+ * token`'s `gho_…`, a GitHub App's `ghs_…` installation token in a remote URL.
384
+ * A turn that printed one — `cat ~/.claude/.credentials.json`, `gh auth token`
385
+ * — relayed it verbatim into a trace every member of the project reads. So the
386
+ * shapes the vendors document are redacted wherever they appear, whether or
387
+ * not this process ever saw the value.
388
+ *
389
+ * REDACTION MASKS, IT NEVER WITHHOLDS: the line is still relayed, with the
390
+ * token replaced. Each floor is long enough that an ordinary word sharing the
391
+ * prefix (`sk-learn`, `ghost_town`, a `github_pat_` mentioned in prose) is left
392
+ * alone — the canaries in uplinkScrub.test.mjs pin one per shape. Order
393
+ * matters: `sk-ant-` is named before the wider `sk-` rule.
394
+ */
395
+ const TOKEN_SHAPES = [
396
+ { name: FLOWVIANT_TOKEN_NAME, re: FLOWVIANT_TOKEN_RE, needle: FLOWVIANT_TOKEN_PREFIX },
397
+ { name: 'ANTHROPIC_TOKEN', re: /sk-ant-[A-Za-z0-9_-]{20,}/g, needle: 'sk-ant-' },
398
+ { name: 'OPENAI_API_KEY', re: /\bsk-[A-Za-z0-9_-]{32,}/g, needle: 'sk-' },
399
+ // No word boundary on GitHub's shapes, like `fva_` (2026-10-10, F056): a
400
+ // token after a JSON-escaped newline (`\nghs_…`) or inside URL encoding
401
+ // (`%3Aghs_…`) follows a word character, and a hosted box's installation
402
+ // token reaches gh from a file, so no learned value ever holds it.
403
+ { name: 'GITHUB_TOKEN', re: /gh[pousr]_[A-Za-z0-9]{30,}/g, needle: '_' },
404
+ { name: 'GITHUB_TOKEN', re: /github_pat_[A-Za-z0-9_]{40,}/g, needle: 'github_pat_' },
405
+ { name: 'AWS_ACCESS_KEY_ID', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, needle: 'A' },
406
+ { name: 'SLACK_TOKEN', re: /\bxox[abpr]-[A-Za-z0-9-]{20,}/g, needle: 'xox' },
407
+ ];
408
+
409
+ /**
410
+ * AN OAUTH TOKEN IN A JSON FIELD — Codex's `auth.json` (`access_token`,
411
+ * `refresh_token`, `id_token`: JWTs with no vendor prefix) and Claude's
412
+ * credential file (`accessToken`, `refreshToken`). The key is kept and only
413
+ * the value is replaced, so the line still reads as what it was. Also matched
414
+ * with escaped quotes, as the field reads once it is itself inside a JSON
415
+ * string (a tool result relayed whole). A value something above already
416
+ * redacted is left as it is.
417
+ */
418
+ const OAUTH_FIELD_RE =
419
+ /(\\?"(?:access_token|refresh_token|id_token|accessToken|refreshToken|idToken)\\?"\s*:\s*\\?")(?!\[REDACTED:)[^"\\]{20,}/g;
420
+ const OAUTH_FIELD_NAME = 'OAUTH_TOKEN';
421
+
422
+ /**
423
+ * A PEM PRIVATE KEY — a `-----BEGIN … PRIVATE KEY-----` block (`cat
424
+ * ~/.ssh/id_ed25519`, a service-account JSON's `private_key`). The whole block
425
+ * is replaced: from BEGIN to END when everything between is key material
426
+ * (base64, line breaks, the legacy `Proc-Type`/`DEK-Info` headers) holding at
427
+ * least one 16-character base64 run; or, when no END follows (a `head` of the
428
+ * file), the BEGIN line and the base64 lines straight after it. A BEGIN line
429
+ * alone is a header named in prose, and is left alone.
430
+ *
431
+ * LINEAR, ON PURPOSE. A regex with a lazy body between BEGIN and END rescans
432
+ * the rest of the text from every BEGIN that has no END — quadratic on a text
433
+ * of nothing but BEGIN lines, the freeze `candidateObjects` was hardened
434
+ * against. So the three forward searches each keep a cursor that only moves
435
+ * forward, and the block regexes are sticky.
436
+ */
437
+ const PEM_BEGIN_RE = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----/g;
438
+ const PEM_END_RE = /-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/g;
439
+ const PEM_NOT_BODY_RE = /[^A-Za-z0-9+/=\s\\:,.-]/g;
440
+ const PEM_RUN_RE = /[A-Za-z0-9+/=]{16}/g;
441
+ const PEM_LINES_RE = /(?:(?:\r?\n|\\r\\n|\\n)[ \t]*[A-Za-z0-9+/=]+)+/y;
442
+ const PEM_MAX_BODY = 32 * 1024;
443
+ const PEM_NAME = 'PRIVATE_KEY';
444
+
445
+ function redactPem(text) {
446
+ if (!text.includes('PRIVATE KEY-----')) return text;
447
+ let out = '';
448
+ let at = 0;
449
+ // Cursors: the first END / non-key character / 16-char run at or after the
450
+ // position each was last searched from (-1: none in the rest of the text).
451
+ let end = -2;
452
+ let endLen = 0;
453
+ let bad = -2;
454
+ let run = -2;
455
+ const after = (re, cursor, from) => {
456
+ if (cursor === -1 || cursor >= from) return cursor;
457
+ re.lastIndex = from;
458
+ const m = re.exec(text);
459
+ return m ? m.index : -1;
460
+ };
461
+ PEM_BEGIN_RE.lastIndex = 0;
462
+ for (let m; (m = PEM_BEGIN_RE.exec(text)); ) {
463
+ const body = m.index + m[0].length;
464
+ let stop = -1;
465
+ if (end !== -1 && end < body) {
466
+ PEM_END_RE.lastIndex = body;
467
+ const e = PEM_END_RE.exec(text);
468
+ end = e ? e.index : -1;
469
+ endLen = e ? e[0].length : 0;
470
+ }
471
+ if (end >= 0 && end - body <= PEM_MAX_BODY) {
472
+ bad = after(PEM_NOT_BODY_RE, bad, body);
473
+ run = after(PEM_RUN_RE, run, body);
474
+ if ((bad === -1 || bad >= end) && run >= 0 && run + 16 <= end) stop = end + endLen;
475
+ }
476
+ if (stop < 0) {
477
+ PEM_LINES_RE.lastIndex = body;
478
+ const lines = PEM_LINES_RE.exec(text);
479
+ if (lines && /[A-Za-z0-9+/=]{16}/.test(lines[0])) stop = body + lines[0].length;
480
+ }
481
+ if (stop < 0) continue;
482
+ out += `${text.slice(at, m.index)}[REDACTED:${PEM_NAME}]`;
483
+ at = stop;
484
+ PEM_BEGIN_RE.lastIndex = stop;
485
+ }
486
+ return at === 0 ? text : out + text.slice(at);
487
+ }
488
+
489
+ function redactShapes(text) {
490
+ let out = text;
491
+ for (const s of TOKEN_SHAPES) {
492
+ if (out.includes(s.needle)) out = out.replace(s.re, `[REDACTED:${s.name}]`);
493
+ }
494
+ if (out.includes('oken')) out = out.replace(OAUTH_FIELD_RE, `$1[REDACTED:${OAUTH_FIELD_NAME}]`);
495
+ return redactPem(out);
496
+ }
497
+
245
498
  /**
246
499
  * The NAME of the first known secret whose value appears in `bytes` as an
247
500
  * exact byte substring, or null. For the payloads `scrub` cannot touch: a
@@ -256,10 +509,22 @@ const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
256
509
  export function secretIn(bytes) {
257
510
  if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
258
511
  for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
259
- // The token SHAPE too (see `scrub`). `latin1` maps every byte to one
260
- // character, so the ASCII pattern matches exactly where the bytes do.
261
- if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
262
- return FLOWVIANT_TOKEN_NAME;
512
+ // The token SHAPES too (see `scrub`). `latin1` maps every byte to one
513
+ // character, so an ASCII pattern matches exactly where the bytes do. Fresh,
514
+ // non-global copies: a `/g` regex's `test` keeps `lastIndex` between calls.
515
+ let text = null;
516
+ for (const s of TOKEN_SHAPES) {
517
+ if (!bytes.includes(s.needle)) continue;
518
+ text ??= bytes.toString('latin1');
519
+ if (new RegExp(s.re.source).test(text)) return s.name;
520
+ }
521
+ if (bytes.includes('oken')) {
522
+ text ??= bytes.toString('latin1');
523
+ if (new RegExp(OAUTH_FIELD_RE.source).test(text)) return OAUTH_FIELD_NAME;
524
+ }
525
+ if (bytes.includes('PRIVATE KEY-----')) {
526
+ text ??= bytes.toString('latin1');
527
+ if (redactPem(text) !== text) return PEM_NAME;
263
528
  }
264
529
  return null;
265
530
  }
package/bin/lib/vault.mjs CHANGED
@@ -27,16 +27,79 @@ import {
27
27
  } from 'node:fs';
28
28
  import { execFileSync } from 'node:child_process';
29
29
  import { join, relative, sep } from 'node:path';
30
+ import { gitRaw } from './git.mjs';
30
31
  import { planVaultSync } from './vaultDiff.mjs';
31
32
 
32
33
  const SYNC_STATE = '.flowviant-sync.json';
33
34
 
34
- /** Create the vault dir + its private git history (best-effort). */
35
+ /**
36
+ * EVERY GIT CALL IN THE VAULT, AND NONE OF THE VAULT'S OWN PROGRAMS
37
+ * (2026-10-10, the audit). The vault is a directory a cartographer turn writes:
38
+ * Codex's wiki profile can write all of it, `.git` included, and Claude's list
39
+ * denies its `.git` only as far as the CLI keeps its word. A `core.fsmonitor`
40
+ * planted in `<vault>/.git/config` ran on the daemon's own `git add -A` — the
41
+ * daemon, with the daemon's environment, outside every turn's fence — and a
42
+ * planted hook would run on its `git commit`. So the daemon's git here runs
43
+ * with the monitor off and hooks pointed at nothing, and commits `--no-verify`.
44
+ * Command-line `-c` outranks every config file.
45
+ */
46
+ const VAULT_GIT_FENCE = Object.freeze(['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null']);
47
+ export function vaultGit(args, dir) {
48
+ return execFileSync('git', [...VAULT_GIT_FENCE, ...args], { cwd: dir, stdio: 'ignore' });
49
+ }
50
+
51
+ /**
52
+ * …AND NO PROGRAM THE VAULT'S CONFIG NAMES. `-c` can switch off the monitor
53
+ * and the hooks by name, but not a program the config invents a name for: a
54
+ * `[filter "x"] clean = <cmd>` beside a `.gitattributes` of `* filter=x` ran
55
+ * on the fenced `git add -A` all the same (measured, git 2.55), and so would a
56
+ * `gpg.program` under `commit.gpgSign`. Nothing a vault needs is in its config
57
+ * beyond what `git init` writes, so a config holding anything else was not the
58
+ * daemon's — and the local commit is skipped, in words, rather than run. The
59
+ * sync itself is unaffected: it reads files, never git. A config git cannot
60
+ * read is the same answer.
61
+ */
62
+ const INIT_CONFIG_KEYS = new Set([
63
+ 'core.repositoryformatversion',
64
+ 'core.filemode',
65
+ 'core.bare',
66
+ 'core.logallrefupdates',
67
+ 'core.ignorecase',
68
+ 'core.precomposeunicode',
69
+ 'core.symlinks',
70
+ 'extensions.objectformat',
71
+ 'extensions.refstorage',
72
+ ]);
73
+ export function vaultConfigForeignKey(dir) {
74
+ let listed;
75
+ try {
76
+ // `--local` reads the config THIS repository resolves to — through a
77
+ // planted `commondir` or a `.git` file too. It does NOT follow includes
78
+ // (a file option turns them off), so an `include.path` is caught by its
79
+ // own key (measured, git 2.55). Reading config runs nothing.
80
+ listed = gitRaw([...VAULT_GIT_FENCE, 'config', '--local', '--list', '-z'], dir, { stdio: ['ignore', 'pipe', 'ignore'] });
81
+ } catch {
82
+ return '(unreadable)';
83
+ }
84
+ // `-z`: each entry is `key\nvalue\0`, so a value with a newline in it
85
+ // cannot pass for a key of its own.
86
+ for (const entry of listed.split('\0')) {
87
+ if (!entry) continue;
88
+ const key = entry.split('\n')[0].toLowerCase();
89
+ if (!INIT_CONFIG_KEYS.has(key)) return key;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** Create the vault dir + its private git history (best-effort). With NO
95
+ * template (2026-10-10): a person's `init.templateDir` can carry a `config`
96
+ * of its own, which `git init` copies in (measured, git 2.55) — and which the
97
+ * check above would then refuse on every sync. A vault needs none of it. */
35
98
  export function ensureVault(dir) {
36
99
  mkdirSync(dir, { recursive: true });
37
100
  if (!existsSync(join(dir, '.git'))) {
38
101
  try {
39
- execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
102
+ vaultGit(['init', '-q', '--template='], dir);
40
103
  } catch {
41
104
  /* git unavailable — the vault still works, just unversioned */
42
105
  }
@@ -68,14 +131,19 @@ function walkMd(dir, base = dir, out = [], errors = { count: 0 }) {
68
131
 
69
132
  /** Best-effort local history commit — identity pinned so it works on machines
70
133
  * with no global git config, and never touches the user's identity. */
71
- function commitVault(dir, message) {
134
+ function commitVault(dir, message, warn) {
135
+ // No `.git` of its own (git was unavailable at init, or a turn removed it):
136
+ // unversioned — and never a `git add -A` that walks UP into whatever
137
+ // repository encloses the vault.
138
+ if (!existsSync(join(dir, '.git'))) return;
139
+ const foreign = vaultConfigForeignKey(dir);
140
+ if (foreign) {
141
+ warn(`wiki vault history not committed — its git config holds \`${foreign}\`, which Flowviant did not write (${dir})`);
142
+ return;
143
+ }
72
144
  try {
73
- execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'ignore' });
74
- execFileSync(
75
- 'git',
76
- ['-c', 'user.name=flowviant', '-c', 'user.email=wiki@flowviant.local', 'commit', '-q', '-m', message],
77
- { cwd: dir, stdio: 'ignore' }
78
- );
145
+ vaultGit(['add', '-A'], dir);
146
+ vaultGit(['-c', 'user.name=flowviant', '-c', 'user.email=wiki@flowviant.local', 'commit', '-q', '--no-verify', '-m', message], dir);
79
147
  } catch {
80
148
  /* nothing to commit / git unavailable — fine */
81
149
  }
@@ -124,6 +192,6 @@ export async function syncVault({ dir, url, token, userAgent, finalize, grounded
124
192
  }
125
193
 
126
194
  writeFileSync(join(dir, SYNC_STATE), JSON.stringify(plan.state));
127
- commitVault(dir, plan.finalized ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`);
195
+ commitVault(dir, plan.finalized ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`, warn);
128
196
  return { pages: plan.pages, uploaded: plan.uploaded, deleted: plan.deleted };
129
197
  }
@@ -21,9 +21,12 @@ import { createHash } from 'node:crypto';
21
21
 
22
22
  // Mirror the server contract (shared schema) — a page that violates it is
23
23
  // skipped with a warning (and carried forward if previously synced), never
24
- // allowed to 400 the whole request and wedge the sync.
24
+ // allowed to 400 the whole request and wedge the sync. The release parity gate
25
+ // (scripts/check-app-parity.mjs, 2026-10-10, audit F142) holds these to the
26
+ // shared WIKI_VAULT_* names, CHUNK_FILES within the per-request file cap, and
27
+ // isSafePath to isSafeVaultPath over a vector list.
25
28
  export const MAX_FILE_BYTES = 262_144;
26
- const MAX_PATH_CHARS = 300;
29
+ export const MAX_PATH_CHARS = 300;
27
30
  export const MAX_FILES = 400;
28
31
  export const CHUNK_FILES = 30;
29
32
  export const CHUNK_BYTES = 700_000;