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.
- package/README.md +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +90 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +113 -31
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/uplinkScrub.mjs
CHANGED
|
@@ -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.
|
|
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 `.` `/`
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
91
|
-
* password
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
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
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
|
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
|
|
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
|
|
260
|
-
// character, so
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
}
|
package/bin/lib/vaultDiff.mjs
CHANGED
|
@@ -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;
|