@tekyzinc/gsd-t 5.1.12 → 5.2.10

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.
@@ -0,0 +1,1384 @@
1
+ "use strict";
2
+
3
+ // bin/gsd-t-env-registry.cjs
4
+ //
5
+ // M102 — Environment Registry helper. Records every environment's connection
6
+ // MAP (never a secret VALUE) into docs/infrastructure.md via the ONE shared
7
+ // marker-block writer, and reads it back read-first (No-Re-Research) so a
8
+ // missing entry HALTs-and-documents rather than being guessed (No-Fallback-Ever).
9
+ //
10
+ // Contract: .gsd-t/contracts/env-registry-contract.md
11
+ //
12
+ // Public surface:
13
+ // recordEnvironment({ projectDir, scope, kind, host, port, name, authMethod,
14
+ // secretVault, secretEnvVarName, fetchCommand,
15
+ // connectCommand, gotchas, readOnlyDefault })
16
+ // -> upsert row by (scope, kind) into the Environments table (idempotent).
17
+ // lookupEnvironment(projectDir, scope, kind) -> row object | null.
18
+ // detectEnvConfig(projectDir) -> { vault, fetchCommand, secretEnvVarNames, ... }
19
+ // addPermissionEntry(projectDir, connectCommand) -> { added, tool, entry }
20
+ // ensureEnvGitignored(projectDir) -> { added }
21
+ //
22
+ // Zero external npm runtime deps — fs/path only. Reuses the M100 detectStack
23
+ // SHAPE and the ONE shared marker-block doc-writer (gsd-t-doc-marker.cjs).
24
+
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+ const { upsertMarkedDocBlock } = require("./gsd-t-doc-marker.cjs");
28
+
29
+ // ─── Markers + schema ────────────────────────────────────────────────────────
30
+
31
+ const ENV_MARKER_START = "<!-- gsd-t-env-registry:start -->";
32
+ const ENV_MARKER_END = "<!-- gsd-t-env-registry:end -->";
33
+
34
+ // Map-only columns — NO secret-value column exists (a row is structurally
35
+ // incapable of holding a secret VALUE). `secret vault` + `secret env-var NAME`
36
+ // + `fetch command` record WHERE the secret lives and HOW to fetch it, never
37
+ // the value itself.
38
+ const ENV_COLUMNS = [
39
+ "id",
40
+ "scope",
41
+ "kind",
42
+ "host",
43
+ "port",
44
+ "db/name",
45
+ "auth method",
46
+ "secret vault",
47
+ "secret env-var NAME",
48
+ "fetch command",
49
+ "connect command",
50
+ "access gotchas",
51
+ "read-only default",
52
+ "recorded",
53
+ ];
54
+
55
+ // ─── Secret-value BACKSTOP detector (known-prefix / JWT / embedded-cred) ─────
56
+ //
57
+ // THIS IS A BACKSTOP ONLY — never the primary guard. The PRIMARY guard for
58
+ // every free-ish column is a TIGHT POSITIVE ALLOWLIST (see the per-column
59
+ // validators below): a value is accepted ONLY if it matches the shape its
60
+ // column is supposed to hold (a real hostname / an enum member / a $VAR / a
61
+ // curated subcommand word). A value like `hunter2hunter2hunter` or
62
+ // `correcthorsebattery` is rejected because it is NOT a valid hostname / NOT
63
+ // the enum / NOT a $VAR — with ZERO entropy or character-class threshold.
64
+ //
65
+ // THREE Red Team passes proved the inverse ("reject if it looks like a secret",
66
+ // a denylist with an entropy floor) leaks: every floor eventually admits a
67
+ // secret that sits below it. The floor is the defect; it is GONE as a primary
68
+ // guard. This backstop remains only to cheaply catch the well-known credential
69
+ // SHAPES (GitHub / Stripe / AWS / Slack / Google prefixes, JWTs, embedded
70
+ // proto://user:pw@ creds) — an EXTRA layer, never the sole thing between a
71
+ // value and the doc.
72
+
73
+ // Known secret prefixes. A token STARTING with one of these is a live secret.
74
+ // Belt-and-suspenders only — the structural validators (command allowlist +
75
+ // enumerated gotchas + gate high-entropy scan) are the real guard for an OPEN
76
+ // category. This list catches the well-known SHAPES cheaply.
77
+ const KNOWN_SECRET_PREFIXES = [
78
+ "ghp_", "gho_", "ghs_", "ghu_", "ghr_", // GitHub PAT / OAuth / server / user / refresh
79
+ "github_pat_", // GitHub fine-grained PAT
80
+ "sk_live", "sk_test", "pk_live", "rk_live", // Stripe
81
+ "AKIA", "ASIA", // AWS access-key ids
82
+ "xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-", "xapp-", // Slack (incl. app-level)
83
+ "AIza", // Google API key
84
+ "glpat-", // GitLab personal access token
85
+ "gitlab", // GitLab-prefixed tokens (belt-and-suspenders)
86
+ "npm_", // npm automation token
87
+ "dop_v1_", // DigitalOcean PAT
88
+ "SG.", // SendGrid API key
89
+ "shpat_", "shpss_", // Shopify access / shared-secret
90
+ ];
91
+
92
+ // proto://user:PASSWORD@host for ANY scheme (not a hardcoded scheme allowlist).
93
+ // The password segment is a literal only when it is NOT a $-reference.
94
+ const EMBEDDED_CRED = /[a-z][a-z0-9+.\-]*:\/\/[^\s:/@]+:(?!\$)[^\s:/@]+@/i;
95
+
96
+ // JWT: three base64url segments joined by dots. A real JWT's header segment is
97
+ // the base64url of a JSON object beginning `{"` → it ALWAYS starts with `eyJ`.
98
+ // Anchoring on that avoids a false positive on a 3-part dotted hostname
99
+ // (`ep-neon.neon.tech`), while still catching every genuine token.
100
+ const JWT_SHAPE = /^eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}$/;
101
+
102
+ // A contiguous base64/hex run inside a larger string (e.g. `Bearer <token>`).
103
+ const BASE64_RUN = /[A-Za-z0-9+/]{24,}={0,2}/;
104
+ const HEX_RUN = /\b[0-9a-fA-F]{32,}\b/;
105
+
106
+ // True when `s` contains (or is) a live secret value. Used BOTH as the reject
107
+ // test inside the per-column allowlist checks AND as the verify gate's
108
+ // independent detector.
109
+ function looksLikeSecretValue(s) {
110
+ if (typeof s !== "string" || !s) return false;
111
+ // Known prefixes — anywhere a whitespace/quote-delimited token begins.
112
+ for (const tok of s.split(/[\s"'`=:,()<>]+/)) {
113
+ if (!tok) continue;
114
+ for (const p of KNOWN_SECRET_PREFIXES) {
115
+ if (tok.startsWith(p) && tok.length > p.length) return true;
116
+ }
117
+ if (JWT_SHAPE.test(tok)) return true;
118
+ }
119
+ if (EMBEDDED_CRED.test(s)) return true;
120
+ // High-entropy runs — but ignore a $-reference and skip pure hostnames
121
+ // (dotted, no long unbroken run) which the base64 run would otherwise catch.
122
+ if (HEX_RUN.test(s)) return true;
123
+ const b64 = s.match(BASE64_RUN);
124
+ if (b64) {
125
+ const run = b64[0];
126
+ // A hostname segment is short and dotted; a real blob is one long run.
127
+ if (run.length >= 24) return true;
128
+ }
129
+ // Cycle-8 fix: a high-entropy token can DODGE the contiguous-run tests by
130
+ // inserting dots (`kR8mNp2qX7wZ4vT6.aB9cD1eF3gH5jK0lM2n` splits into sub-24
131
+ // labels). Re-test with separators (dots/hyphens/underscores/colons/slashes)
132
+ // stripped, so a dotted blob is measured as ONE run. A REAL dotted hostname
133
+ // survives this because its labels are lowercase words that don't form a long
134
+ // base64/hex run once joined (`api.example.com` → `apiexamplecom`, no ≥24
135
+ // run) AND it contains no MIXED-CASE-with-digits density. To avoid flagging a
136
+ // long lowercase-only real domain, only trip when the joined run also carries
137
+ // BOTH upper- and lower-case OR is a ≥32 hex — i.e. token-like, not word-like.
138
+ const joined = s.replace(/[.\-_:/]+/g, "");
139
+ if (/[0-9a-fA-F]{32,}/.test(joined)) return true;
140
+ const jb64 = joined.match(/[A-Za-z0-9+/]{24,}={0,2}/);
141
+ if (jb64) {
142
+ const run = jb64[0];
143
+ const hasUpper = /[A-Z]/.test(run);
144
+ const hasLower = /[a-z]/.test(run);
145
+ const hasDigit = /[0-9]/.test(run);
146
+ // token-like = mixed case, or (case + digits). A long all-lowercase word
147
+ // (a real domain joined) is NOT flagged.
148
+ if ((hasUpper && hasLower) || (hasDigit && (hasUpper || hasLower && run.length >= 32))) {
149
+ return true;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ // Back-compat alias — the OLD name is kept exported so nothing that imports it
156
+ // breaks, but it now points at the strict entropy/prefix detector.
157
+ function looksLikeInlineSecret(value) {
158
+ return looksLikeSecretValue(value);
159
+ }
160
+
161
+ // ─── POSITIVE-ALLOWLIST shape primitives (shared: writer + verify gate) ──────
162
+ //
163
+ // These are the PRIMARY guard for every free-ish column. NOT an entropy floor —
164
+ // a set of TIGHT POSITIVE shapes. A value is accepted ONLY if it IS a real
165
+ // hostname / IP / $VAR / enum member / curated subcommand word. Everything else
166
+ // is REJECTED, so `hunter2hunter2hunter` / `8675309…` / `correcthorsebattery` /
167
+ // `Tr0ub4dor` fail because they are none of those shapes — with ZERO entropy or
168
+ // character-class threshold anywhere.
169
+
170
+ // A dotted hostname/domain: labels of [A-Za-z0-9-] joined by dots, at least one
171
+ // dot, TLD-ish last label. Recognizes `ep-cool.us-east-2.aws.neon.tech`,
172
+ // `bastion.example.com`, `api.example.com`.
173
+ const DOTTED_HOSTNAME =
174
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
175
+
176
+ // STRICT command-context hostname (cycle-8 fix). A dotted host appearing INSIDE
177
+ // a connect/fetch command must look like a REAL DNS name, because the loose
178
+ // DOTTED_HOSTNAME let a high-entropy token launder through by inserting a dot
179
+ // (`kR8mNp2qX7wZ4vT6.aB9cD1eF3gH5jK0lM2n` split into sub-24 labels dodged the
180
+ // backstop AND passed as a "hostname"). A real DNS name is:
181
+ // • lowercase-led labels of [a-z0-9-] (DNS is case-insensitive; a mixed-case
182
+ // high-entropy label is not a real host — force lowercase here),
183
+ // • each label ≤ 24 chars (a 16+ char RANDOM label is a laundered token, not a
184
+ // real DNS label — real labels are short words / regions / ids),
185
+ // • a purely ALPHABETIC TLD of ≥2 chars (`.tech`, `.com`, `.io`) — a token's
186
+ // trailing label is not an alpha TLD.
187
+ // This is a SHAPE tightening (real-hostname structure), NOT an entropy floor.
188
+ const STRICT_CMD_HOSTNAME =
189
+ /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?\.)+[a-z]{2,}$/;
190
+
191
+ // IPv4 dotted-quad (loose — 1-3 digits per octet; not validating 0-255 range,
192
+ // only the SHAPE, which is enough to distinguish a real IP from a digit blob).
193
+ const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
194
+
195
+ // A bare single-label host: letters + hyphen ONLY (NO digits), ≤ 16 chars,
196
+ // lowercase-led. Accepts docker/service names `db`, `postgres`, `db-primary`,
197
+ // and the single-char test host `h`. REJECTS `hunter2hunter2hunter` (digits),
198
+ // `8675309…` (digits), and a long single-label passphrase like
199
+ // `correcthorsebattery` (19 > 16) — a bare blob is never a valid host here.
200
+ // Real docker/service hostnames are short; a genuinely long endpoint is dotted
201
+ // DNS or an IP, both of which have their own positive shapes.
202
+ const BARE_HOST_LABEL = /^[a-z][a-z-]{0,15}$/;
203
+
204
+ // A $VAR / ${VAR} reference, optionally single/double-quoted — the ONLY
205
+ // sanctioned way a secret ever appears in a command.
206
+ const VAR_REF = /^["']?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?["']?$/;
207
+
208
+ // A pure UPPER_SNAKE env-var NAME (e.g. DATABASE_URL_PROD, SECRET) — a NAME,
209
+ // never a value.
210
+ const UPPER_SNAKE = /^[A-Z][A-Z0-9_]*$/;
211
+
212
+ // A short lowercase-led SNAKE identifier used for db / resource names. Positive
213
+ // shape (NOT an entropy floor): lowercase-led, [a-z0-9_], ≤ 16 chars, and NO
214
+ // digit-immediately-followed-by-a-letter (a `2h`-style alternation reads as a
215
+ // blob, never a real db name). Accepts `binvoice_prod` (13), `analytics`,
216
+ // `neondb_prod`, `appdb`; REJECTS `hunter2hunter2hunter` (`2h` transition),
217
+ // `correcthorsebattery` (19 > 16), `Xk9…` (uppercase).
218
+ const DB_NAME_MAX = 16;
219
+ function isDbNameShape(s) {
220
+ if (typeof s !== "string" || s.length === 0 || s.length > DB_NAME_MAX) return false;
221
+ if (!/^[a-z][a-z0-9_]*$/.test(s)) return false;
222
+ if (/[0-9][a-z]/.test(s)) return false; // digit→letter interior = blob-like
223
+ return true;
224
+ }
225
+
226
+ // A dotted hostname OR IPv4 OR `localhost` — any real network endpoint shape.
227
+ function isHostShape(s) {
228
+ if (s === "localhost") return true;
229
+ if (IPV4.test(s)) return true;
230
+ if (DOTTED_HOSTNAME.test(s)) return true;
231
+ if (BARE_HOST_LABEL.test(s)) return true;
232
+ return false;
233
+ }
234
+
235
+ // ─── Per-column ALLOWLIST validators ─────────────────────────────────────────
236
+ //
237
+ // Each column may contain ONLY its expected shape. Anything else THROWS — the
238
+ // row is REJECTED and never written. This is the inversion the Red Team
239
+ // prescribed: a bare secret in ANY field fails its column's shape check.
240
+
241
+ const SCOPES = new Set(["local", "staging", "prod"]);
242
+ const VAULTS = new Set([
243
+ "local", "local (.env)", ".env", "env",
244
+ "vercel", "neon", "gcp-secret-manager", "google secret manager",
245
+ "aws-secrets-manager", "aws secrets manager", "doppler", "1password",
246
+ "hashicorp-vault", "vault", "azure-key-vault", "infisical",
247
+ ]);
248
+
249
+ // `auth method` is an ENUM (positive allowlist), exactly like `secret vault`
250
+ // and `access gotchas`. A value NOT in this set is rejected — so
251
+ // `hunter2hunter2hunter` fails because it is not an auth-method name, with NO
252
+ // entropy check. Extend as real auth methods appear (this is the curated set,
253
+ // not a floor).
254
+ const AUTH_METHODS = new Set([
255
+ "password", "iam", "oauth", "oauth2", "service-account", "ssh-key",
256
+ "api-key", "none", "scram-sha-256", "md5", "trust", "token", "key",
257
+ "cert", "mtls", "kerberos", "ldap",
258
+ ]);
259
+
260
+ // Curated CLI / DB subcommand words a command may carry as a BARE literal arg.
261
+ // A positive allowlist — a bare arg is accepted only if it is one of these OR
262
+ // the row's own db-name shape OR a hostname / $VAR / flag. Anything else must
263
+ // become a $VAR (see classifyCommandToken). Extend as real tools appear.
264
+ const CLI_WORDS = new Set([
265
+ // DB / shell clients
266
+ "psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
267
+ "pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
268
+ // vault / platform CLIs
269
+ "neonctl", "vercel", "gcloud", "aws", "az", "doppler", "supabase",
270
+ "flyctl", "fly", "heroku", "railway", "wrangler", "turso", "op", "infisical",
271
+ "kubectl", "helm", "terraform", "vault",
272
+ // network clients
273
+ "ssh", "scp", "sftp", "curl", "wget", "ldapsearch", "nc", "openssl", "rsync",
274
+ // common subcommands / operands
275
+ "env", "pull", "push", "list", "get", "set", "secrets", "versions",
276
+ "access", "version", "exec", "run", "connect", "login", "logout",
277
+ "connection-string", "db-url", "database-url", "redis-url",
278
+ "admin", "default", "latest", "read", "write", "describe", "show",
279
+ // descriptive-fetch operands (the detectEnvConfig fallback: "read $VAR from .env")
280
+ "from", "cat", "source", "printenv", "dotenv",
281
+ ]);
282
+
283
+ // A dotfile operand a command may reference (e.g. `.env`, `.env.local`,
284
+ // `.gcloudignore`). REQUIRES a leading dot — that is what makes it a dotfile
285
+ // and NOT a bare blob (`hunter2…` has no leading dot, so it never matches).
286
+ // Short lowercase labels only; a leading-dot filename is a filesystem path,
287
+ // never a secret VALUE.
288
+ const DOTFILE_TOKEN = /^\.[a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*$/;
289
+
290
+ // The doc-block markers a smuggled cell could use to corrupt upsert indexOf.
291
+ const RESERVED_MARKER_SUBSTRINGS = [
292
+ ENV_MARKER_START, ENV_MARKER_END,
293
+ "gsd-t-env-registry:start", "gsd-t-env-registry:end",
294
+ "gsd-t-logging-scaffold:start", "gsd-t-logging-scaffold:end",
295
+ ];
296
+
297
+ function rejectMarkerSmuggle(field, value) {
298
+ if (typeof value !== "string" || !value) return;
299
+ for (const m of RESERVED_MARKER_SUBSTRINGS) {
300
+ if (value.includes(m)) {
301
+ throw new Error(
302
+ `recordEnvironment: field "${field}" contains a reserved doc-marker substring ` +
303
+ `("${m}") — refusing to write a value that could corrupt the marked doc block.`
304
+ );
305
+ }
306
+ }
307
+ }
308
+
309
+ function rejectSecret(field, value) {
310
+ if (looksLikeSecretValue(value)) {
311
+ throw new Error(
312
+ `recordEnvironment: refusing to write an inline literal secret in field "${field}". ` +
313
+ `Record the secret env-var NAME and reference it as $VAR (e.g. psql "$DATABASE_URL_PROD"), ` +
314
+ `never a literal password/token/connection-string.`
315
+ );
316
+ }
317
+ }
318
+
319
+ // Optional-string helper: undefined/null/"" pass through as empty (—).
320
+ function optStr(v) {
321
+ return v === undefined || v === null ? "" : String(v);
322
+ }
323
+
324
+ function validateScope(v) {
325
+ if (!SCOPES.has(v)) {
326
+ throw new Error(`recordEnvironment: scope must be one of local|staging|prod, got "${v}"`);
327
+ }
328
+ }
329
+
330
+ function validateKind(v) {
331
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(v)) {
332
+ throw new Error(`recordEnvironment: kind must match [a-z0-9-]+ (e.g. postgres, http-api), got "${v}"`);
333
+ }
334
+ // Symmetry with the gate: a real kind is a short word (`postgres`, `http-api`),
335
+ // never a long hex/token blob. The backstop rejects a hex≥32 / base64≥24 kind
336
+ // so the writer never accepts what the gate would fail (closes the cycle-9
337
+ // writer/gate divergence — harmless, but no reason to keep it).
338
+ rejectSecret("kind", v);
339
+ }
340
+
341
+ function validateHost(field, v) {
342
+ const s = optStr(v);
343
+ if (s === "") return;
344
+ // POSITIVE shape: `localhost` | IPv4 | dotted-DNS | a bare service label
345
+ // (letters+hyphen, ≤24, no digits). Strip an optional trailing `:port`.
346
+ const hostPart = s.replace(/:\d+$/, "");
347
+ if (!isHostShape(hostPart)) {
348
+ throw new Error(
349
+ `recordEnvironment: ${field} "${s}" is not a valid host shape. ` +
350
+ `A host must be "localhost", an IPv4 address, a dotted DNS hostname ` +
351
+ `(e.g. ep-cool.us-east-2.aws.neon.tech), or a short lowercase service ` +
352
+ `name (letters+hyphen, no digits — e.g. db, postgres, db-primary). ` +
353
+ `A bare token like "${hostPart}" is rejected — if it is a secret, move ` +
354
+ `it to an env var and reference it as $VAR.`
355
+ );
356
+ }
357
+ // Backstop (extra layer, never the primary guard).
358
+ rejectSecret(field, s);
359
+ }
360
+
361
+ function validatePort(v) {
362
+ const s = optStr(v);
363
+ if (s === "") return;
364
+ if (!/^\d+$/.test(s)) {
365
+ throw new Error(`recordEnvironment: port must be digits only, got "${s}"`);
366
+ }
367
+ }
368
+
369
+ function validateName(v) {
370
+ const s = optStr(v);
371
+ if (s === "") return;
372
+ // POSITIVE shape: a short lowercase-led snake db-name (≤16, no digit→letter
373
+ // interior). Accepts `binvoice_prod`, `analytics`, `neondb_prod`, `appdb`;
374
+ // REJECTS `hunter2hunter2hunter` (`2h` transition), `correcthorsebattery`
375
+ // (too long), `Xk9…` (uppercase) — NO entropy check.
376
+ if (!isDbNameShape(s)) {
377
+ throw new Error(
378
+ `recordEnvironment: db/name "${s}" is not a valid db-name shape. ` +
379
+ `A db name must be a short lowercase identifier (≤${DB_NAME_MAX} chars, ` +
380
+ `lowercase-led [a-z][a-z0-9_]*, no digit-then-letter run — e.g. ` +
381
+ `binvoice_prod, analytics). A blob like "${s}" is rejected; if it is a ` +
382
+ `secret, move it to an env var and reference it as $VAR.`
383
+ );
384
+ }
385
+ // Backstop (extra layer).
386
+ rejectSecret("db/name", s);
387
+ }
388
+
389
+ function validateAuthMethod(v) {
390
+ const s = optStr(v);
391
+ if (s === "") return;
392
+ // POSITIVE ENUM (like `secret vault` / `access gotchas`). A value NOT in the
393
+ // curated set is rejected — `hunter2hunter2hunter` fails because it is not an
394
+ // auth-method name, with NO entropy check.
395
+ if (!AUTH_METHODS.has(s.toLowerCase())) {
396
+ throw new Error(
397
+ `recordEnvironment: auth method "${s}" is not an enumerated auth method. ` +
398
+ `Allowed: ${[...AUTH_METHODS].join(" | ")}. A secret is never an ` +
399
+ `auth-method value — record the method name only.`
400
+ );
401
+ }
402
+ // Backstop (extra layer).
403
+ rejectSecret("auth method", s);
404
+ }
405
+
406
+ function validateVault(v) {
407
+ const s = optStr(v);
408
+ if (s === "") return;
409
+ rejectSecret("secret vault", s);
410
+ if (!VAULTS.has(s.toLowerCase())) {
411
+ throw new Error(
412
+ `recordEnvironment: secret vault must be an enumerated vault name ` +
413
+ `(local|vercel|neon|gcp-secret-manager|aws-secrets-manager|env|…), got "${s}"`
414
+ );
415
+ }
416
+ }
417
+
418
+ function validateEnvVarName(v) {
419
+ const s = optStr(v);
420
+ if (s === "") return;
421
+ // an env-var NAME only: UPPER_SNAKE. A real value has lowercase/entropy.
422
+ // Backstop first: `AKIAIOSFODNN7EXAMPLE` is valid UPPER_SNAKE but is an AWS
423
+ // key id — the known-prefix detector rejects it.
424
+ rejectSecret("secret env-var NAME", s);
425
+ if (!/^[A-Z][A-Z0-9_]*$/.test(s)) {
426
+ throw new Error(
427
+ `recordEnvironment: secret env-var NAME must be [A-Z][A-Z0-9_]* (a NAME, not a value), got "${s}"`
428
+ );
429
+ }
430
+ }
431
+
432
+ // ─── Command POSITIVE allowlist ──────────────────────────────────────────────
433
+ //
434
+ // A command token is accepted ONLY if it IS one of a small set of SAFE POSITIVE
435
+ // shapes; ANYTHING else THROWS. This is the PRIMARY guard for the connect/fetch
436
+ // columns. NO entropy threshold — THREE Red Team passes proved a floor always
437
+ // admits a secret below it. The positive rule instead admits ONLY:
438
+ //
439
+ // 1. a $VAR / ${VAR} reference (optionally quoted) — the ONLY sanctioned way
440
+ // a secret ever appears in a command;
441
+ // 2. a flag: `-x` | `--flag` (bare) | `--flag=<value>` (value re-checked
442
+ // by the SAME positive rule — a bare literal flag value must itself be a
443
+ // $VAR / hostname / curated word);
444
+ // 3. a dotted hostname / IPv4 / `localhost` (a network endpoint);
445
+ // 4. a bare literal arg ONLY if it is a curated CLI/DB subcommand word OR the
446
+ // row's own db-name shape (short lowercase snake, ≤16, no digit→letter).
447
+ //
448
+ // A bare literal that is NONE of these — `hunter2hunter2hunter`,
449
+ // `correcthorsebattery`, `Xk9#mPq2vLzR8nQw`, a UUID, a platform token — is
450
+ // REJECTED with "put this value in an env var and reference it as $VAR". We
451
+ // ACCEPT the false positive that an unusual-but-legit bare arg must become a
452
+ // $VAR — that is the correct trade for a secrets-adjacent file.
453
+
454
+ // True if a BARE literal command arg is on the positive allowlist: a curated
455
+ // subcommand word OR a db-name shape (row identifier). NO entropy check.
456
+ function isSafeBareArg(bare) {
457
+ if (CLI_WORDS.has(bare.toLowerCase())) return true;
458
+ if (isDbNameShape(bare)) return true;
459
+ return false;
460
+ }
461
+
462
+ // ─── Command grammar — CONVERGED (cycle-6 pivot: SAFE-LABEL ALLOWLIST) ────────
463
+ //
464
+ // SIX Red Team cycles leaked a credential through the command columns. The wrong
465
+ // primitive each time: a hardcoded set of SECRET-bearing flag names (`--password`,
466
+ // `--pw`, `-a`, …) — an OPEN category that can never be listed completely. Cycle
467
+ // 6 walked straight past it with `psql --password swordfish` (a flag name not
468
+ // yet listed, value in the next token matching the host-label shape).
469
+ //
470
+ // THE FIX — invert the list. A username (`-U binvoice`) and a password
471
+ // (`--password swordfish`) are the SAME shape; only the LABEL in front tells them
472
+ // apart. So instead of an endless denylist of secret labels, use a SHORT CLOSED
473
+ // ALLOWLIST of the labels whose value may be a bare identifier:
474
+ // VALUE_FLAGS = { -U/--user/--username, -d/--dbname/--db, -h/--host/--hostname,
475
+ // -p-as-PORT/--port, --secret (gcloud resource NAME) }
476
+ // A bare identifier value is allowed ONLY right after one of these. After ANY
477
+ // OTHER flag — and for any standalone bare arg — the value MUST be a $VAR or a
478
+ // provably-safe shape (dotted-DNS / IPv4 / localhost / .env dotfile / curated CLI
479
+ // word). `--password`, `--pw`, `-a`, `--dsn`, `--token` are leak-proof because
480
+ // they are NOT on the safe list, so their value can only be a $VAR.
481
+ //
482
+ // The safe list is CLOSED and completable (there are only so many ways to name a
483
+ // host / user / db / port). The secret list was open and never was. That is why
484
+ // this converges where six denylist cycles did not.
485
+ //
486
+ // Local-scope escape hatch: a `scope=local` row MAY be exempted from the command
487
+ // secret-guard per project (allowLocalLiteral). Default OFF (strict everywhere).
488
+ // staging/prod are ALWAYS strict.
489
+
490
+ // Split a command into whitespace-delimited tokens, but keep a quoted segment
491
+ // (single or double quotes) as ONE token so `"$DATABASE_URL"` stays intact.
492
+ function tokenizeCommand(s) {
493
+ const tokens = [];
494
+ const re = /"[^"]*"|'[^']*'|\S+/g;
495
+ let m;
496
+ while ((m = re.exec(s)) !== null) tokens.push(m[0]);
497
+ return tokens;
498
+ }
499
+
500
+ // A $VAR / ${VAR} reference, quoted or bare.
501
+ function isVarToken(tok, bare) {
502
+ return VAR_REF.test(tok) || /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(bare);
503
+ }
504
+
505
+ // Is this token a bare flag NAME with NO attached value? A LONG flag `--word`,
506
+ // or a SHORT flag that is EXACTLY one letter (`-U`, `-d`). A multi-char single-
507
+ // dash token (`-pMyPass`, `-Wswordfish`) is a GLUED flag+value, NOT a bare flag
508
+ // name — it must NOT be treated as a value-less flag (that was leak: the glued
509
+ // password was read as a flag name and never checked). `--` long flags are bare;
510
+ // `-XY…` short-with-payload is glued.
511
+ function isBareFlag(bare) {
512
+ if (/^--[A-Za-z][A-Za-z0-9-]*$/.test(bare)) return true; // --long
513
+ if (/^-[A-Za-z]$/.test(bare)) return true; // -X (single letter only)
514
+ return false;
515
+ }
516
+
517
+ // CLOSED allowlist of flag names whose VALUE may be a bare literal — but each
518
+ // maps to a TYPED SHAPE, never a wide "any identifier" (cycle 7 leaked because
519
+ // the value slot accepted any ≤23-char blob, so a strong token after `-U`/`-h`/
520
+ // `--port` passed, caught only by the entropy backstop — the un-winnable
521
+ // primitive). Each flag's value must be the SAME tight shape its COLUMN uses:
522
+ // port → digits only (`--port swordfish` fails)
523
+ // host → dotted-DNS / IPv4 / localhost / short service label (`-h swordfish`
524
+ // passes only as a service label; a strong
525
+ // token fails)
526
+ // user/db → tight db-name shape (≤16 lowercase snake, no digit→letter — a
527
+ // strong/random token `aB3xK9…` / `S3cretP4ss`
528
+ // FAILS → must be $VAR; a weak dictionary
529
+ // username like `binvoice` passes)
530
+ // secret → resource-NAME shape (db-name-like) OR $VAR (a real credential value
531
+ // fails the shape → must be $VAR)
532
+ // A strong/structured credential fails EVERY one of these tight shapes, so it can
533
+ // only be a $VAR. That is the convergence: the value shape is now as tight as the
534
+ // column's, not an entropy floor.
535
+ const VALUE_FLAG_SHAPE = {
536
+ u: "dbname", user: "dbname", username: "dbname",
537
+ d: "dbname", dbname: "dbname", db: "dbname", database: "dbname",
538
+ h: "host", host: "host", hostname: "host",
539
+ port: "port",
540
+ secret: "dbname", // gcloud resource NAME — same tight shape, else $VAR
541
+ };
542
+ function flagBareName(tok) {
543
+ return tok.replace(/^--?/, "").toLowerCase();
544
+ }
545
+ function isValueFlagAllowingBare(name) {
546
+ return Object.prototype.hasOwnProperty.call(VALUE_FLAG_SHAPE, name);
547
+ }
548
+
549
+ // Does `bareVal` satisfy the TYPED shape a given value-flag requires?
550
+ // Reuses the SAME tight shapes the columns use (isDbNameShape / isHostShape),
551
+ // NOT a wide identifier regex — so a strong/random credential fails.
552
+ function flagValueMatchesTypedShape(name, bareVal) {
553
+ const shape = VALUE_FLAG_SHAPE[name];
554
+ if (shape === "port") return /^\d+$/.test(bareVal);
555
+ // host flag value uses the STRICT command hostname (or IPv4/localhost/short
556
+ // service label) — NOT the loose column shape — so `-h <dotted-token>` fails.
557
+ if (shape === "host") {
558
+ return bareVal === "localhost" || IPV4.test(bareVal) ||
559
+ STRICT_CMD_HOSTNAME.test(bareVal) || BARE_HOST_LABEL.test(bareVal);
560
+ }
561
+ if (shape === "dbname") return isDbNameShape(bareVal);
562
+ return false;
563
+ }
564
+
565
+ // The SAFE non-$VAR shapes ANY token may take (standalone or after any flag):
566
+ // a real network endpoint (dotted-DNS / IPv4 / localhost), a .env dotfile, or a
567
+ // curated CLI word. NO bare host-label, NO db-name-shape — those are
568
+ // password-ambiguous and are ONLY allowed right after an allowlisted value-flag.
569
+ // A URL whose authority is a STRICT hostname/IP/localhost and which carries NO
570
+ // embedded credential (`user:pw@` is caught by EMBEDDED_CRED separately). The
571
+ // host part must pass the strict shape, so a `http://<dotted-token>` cannot
572
+ // launder a secret as a URL either. Path/query are allowed but the WHOLE token
573
+ // is still run through the secret backstop by validateCommand.
574
+ const SAFE_URL =
575
+ /^[a-z][a-z0-9+.\-]*:\/\/(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|(?:[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?\.)+[a-z]{2,})(?::\d+)?(?:[/?#]\S*)?$/;
576
+
577
+ function isSafeNonSecretToken(bare) {
578
+ if (bare === "localhost") return true;
579
+ if (IPV4.test(bare)) return true;
580
+ // STRICT command hostname (lowercase labels ≤24, alpha TLD) — NOT the loose
581
+ // DOTTED_HOSTNAME, which let a dotted high-entropy token launder through.
582
+ if (STRICT_CMD_HOSTNAME.test(bare)) return true;
583
+ if (SAFE_URL.test(bare)) return true; // scheme://strict-host/… (no user:pw@)
584
+ if (DOTFILE_TOKEN.test(bare)) return true;
585
+ if (CLI_WORDS.has(bare.toLowerCase())) return true;
586
+ return false;
587
+ }
588
+
589
+ // Classify ONE command token WITHOUT adjacency (attached flag values + standalone
590
+ // args). Returns null if safe, else a reason string.
591
+ function classifyCommandToken(tok) {
592
+ const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
593
+ if (bare === "") return null;
594
+
595
+ // 1. $VAR — the only way a secret appears.
596
+ if (isVarToken(tok, bare)) return null;
597
+
598
+ // 2. `--flag=value` — an allowlisted value-flag may carry a bare identifier;
599
+ // any OTHER flag's attached value must be a $VAR or a safe non-secret shape.
600
+ const eqMatch = bare.match(/^(--?[A-Za-z][A-Za-z0-9-]*)=(.*)$/);
601
+ if (eqMatch) {
602
+ const name = flagBareName(eqMatch[1]);
603
+ const value = eqMatch[2];
604
+ if (value === "") return null;
605
+ return classifyFlagValue(eqMatch[1] + "=", name, value);
606
+ }
607
+
608
+ // 3. glued short flag `-pVALUE`. If the letter is `p` it's PORT (digits ok);
609
+ // an allowlisted value-flag letter may carry a bare identifier; otherwise
610
+ // the glued value must be a $VAR / safe shape.
611
+ const gluedMatch = bare.match(/^(-[A-Za-z])(.+)$/);
612
+ if (gluedMatch) {
613
+ const name = flagBareName(gluedMatch[1]);
614
+ return classifyFlagValue(gluedMatch[1], name, gluedMatch[2]);
615
+ }
616
+
617
+ // 4. a bare flag NAME with no attached value.
618
+ if (isBareFlag(bare)) return null;
619
+
620
+ // 5. a safe non-secret shape: endpoint / dotfile / curated word.
621
+ if (isSafeNonSecretToken(bare)) return null;
622
+
623
+ // 6. anything else → REJECT (a bare literal — password-ambiguous by shape).
624
+ return `bare argument "${bare}" is not a $VAR reference, a flag, a dotted ` +
625
+ `hostname/IP, a .env file, nor a curated CLI word — it is a probable inline ` +
626
+ `literal secret. Move it to an env var and reference it as $VAR ` +
627
+ `(e.g. psql "$DATABASE_URL_PROD")`;
628
+ }
629
+
630
+ // A flag's VALUE (attached `=`, glued, or — via classifyCommand — the next
631
+ // token). `name` is the flag's bare name. An allowlisted value-flag may carry a
632
+ // bare literal ONLY if it matches that flag's TYPED shape (port=digits,
633
+ // host=host-shape, user/db/secret=tight db-name shape); a strong/random
634
+ // credential fails the shape → must be a $VAR. EVERY other flag's value must be
635
+ // a $VAR or a safe non-secret shape. Returns null if safe.
636
+ function classifyFlagValue(flagPart, name, value) {
637
+ const bareVal = value.replace(/^["']/, "").replace(/["']$/, "");
638
+ if (isVarToken(value, bareVal)) return null;
639
+ if (isSafeNonSecretToken(bareVal)) return null; // dotted host / IP / dotfile / curated
640
+ if (isValueFlagAllowingBare(name) && flagValueMatchesTypedShape(name, bareVal)) return null;
641
+ return `flag "${flagPart}" carries a bare literal value "${bareVal}" — a value-flag ` +
642
+ `may hold only its TYPED shape (--port=digits, --host=hostname/IP, ` +
643
+ `--user/--dbname=short lowercase identifier); a strong/random token fails that ` +
644
+ `shape and every other flag's value must be a $VAR reference ` +
645
+ `(e.g. --password "$DB_PASSWORD"). A bare literal here is a probable secret.`;
646
+ }
647
+
648
+ // Validate a full command with TOKEN-ADJACENCY. A bare flag NAME followed by a
649
+ // value token: the value belongs to that flag and is checked by classifyFlagValue
650
+ // with the flag's name — so `-U binvoice` passes (allowlisted) but
651
+ // `--password swordfish` is REJECTED (not allowlisted → value must be $VAR).
652
+ // Returns null if the whole command is safe, else a reason string.
653
+ function classifyCommand(s) {
654
+ const tokens = tokenizeCommand(s);
655
+ for (let i = 0; i < tokens.length; i++) {
656
+ const tok = tokens[i];
657
+ const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
658
+
659
+ if (isBareFlag(bare) && !isVarToken(tok, bare)) {
660
+ const next = tokens[i + 1];
661
+ if (next !== undefined) {
662
+ const nextBare = next.replace(/^["']/, "").replace(/["']$/, "");
663
+ const nextIsFlag = /^-/.test(nextBare);
664
+ if (!nextIsFlag) {
665
+ // next token is THIS flag's value — classify it with this flag's name.
666
+ const reason = classifyFlagValue(bare, flagBareName(bare), next);
667
+ if (reason) return reason;
668
+ i++; // consume the value token
669
+ continue;
670
+ }
671
+ }
672
+ continue; // bare flag with no following value
673
+ }
674
+ const reason = classifyCommandToken(tok);
675
+ if (reason) return reason;
676
+ }
677
+ return null;
678
+ }
679
+
680
+ function validateCommand(field, v) {
681
+ const s = optStr(v);
682
+ if (s === "") return;
683
+ // Backstop first (known prefixes / JWT / base64 / hex / embedded cred).
684
+ rejectSecret(field, s);
685
+ // PRIMARY structural guard: adjacency-aware command grammar (a flag's value —
686
+ // attached, glued, OR the next token — must be $VAR or a safe non-secret shape).
687
+ const reason = classifyCommand(s);
688
+ if (reason) {
689
+ throw new Error(
690
+ `recordEnvironment: ${field} rejected — ${reason} ` +
691
+ `A command may contain only a tool, flags, $VAR/\${VAR} references, dotted ` +
692
+ `hostnames/IPs, .env dotfiles, and curated CLI/subcommand words; a bare ` +
693
+ `literal value is forbidden. Put the value in an env var and reference it ` +
694
+ `as $VAR (e.g. psql "$DATABASE_URL_PROD").`
695
+ );
696
+ }
697
+ }
698
+
699
+ // ─── Gotchas ENUMERATED (no free prose) ──────────────────────────────────────
700
+ //
701
+ // Free prose gave a secret somewhere to hide. `gotchas` is now an enumerated /
702
+ // short-token column: a comma/space-separated list drawn from a fixed enum,
703
+ // plus an optional `via <hostname/identifier>` qualifier. Anything else THROWS.
704
+
705
+ const GOTCHA_ENUM = new Set([
706
+ "vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none",
707
+ ]);
708
+
709
+ function validateGotchas(v) {
710
+ const s = optStr(v);
711
+ if (s === "") return;
712
+ rejectSecret("access gotchas", s);
713
+ // Tokenize on commas and whitespace.
714
+ const tokens = s.split(/[,\s]+/).filter(Boolean);
715
+ for (let i = 0; i < tokens.length; i++) {
716
+ const t = tokens[i].toLowerCase();
717
+ if (GOTCHA_ENUM.has(t)) continue;
718
+ if (t === "via") {
719
+ // the NEXT token must be a POSITIVE host shape (dotted DNS / IPv4 /
720
+ // localhost / short service label) — never a free literal.
721
+ const next = tokens[i + 1];
722
+ if (!next) {
723
+ throw new Error(`recordEnvironment: access gotchas — "via" must be followed by a hostname/identifier`);
724
+ }
725
+ if (!isHostShape(next)) {
726
+ throw new Error(
727
+ `recordEnvironment: access gotchas — "via ${next}" target must be a hostname/IP ` +
728
+ `(dotted DNS, IPv4, localhost, or a short lowercase service name), got "${next}"`
729
+ );
730
+ }
731
+ i++; // consume the hostname token
732
+ continue;
733
+ }
734
+ // A standalone POSITIVE host shape is allowed (naming a bastion directly).
735
+ if (isHostShape(tokens[i])) {
736
+ continue;
737
+ }
738
+ throw new Error(
739
+ `recordEnvironment: access gotchas must be an enumerated list ` +
740
+ `(vpn | ip-allowlist | ssh-tunnel | bastion | none), optionally with ` +
741
+ `"via <hostname>" — got "${tokens[i]}". Free prose is forbidden (nowhere for a secret to hide).`
742
+ );
743
+ }
744
+ }
745
+
746
+ // Run the full per-column allowlist + marker-smuggle + secret backstop over a
747
+ // proposed row. Throws on the FIRST violation — the row is never written.
748
+ // ─── Per-project local-literal switch ────────────────────────────────────────
749
+ //
750
+ // A project may opt IN to allowing a LITERAL secret in `scope=local` rows (the
751
+ // user's testing convenience — "I don't care if you have the local password").
752
+ // Config: `.gsd-t/env-registry-config.json` → `{ "allowLocalLiteral": true }`.
753
+ // Default OFF → strict everywhere (even local rows use $VAR). staging/prod are
754
+ // ALWAYS strict — the switch NEVER relaxes them. A relaxed local row STILL runs
755
+ // the marker-smuggle guard (a literal secret is fine; corrupting the doc block
756
+ // is not). recordEnvironment returns `localLiteralWarning:true` on a relaxed
757
+ // write so the caller can print the one-time "committed file → git history" note.
758
+ function readEnvRegistryConfig(projectDir) {
759
+ if (!projectDir) return { allowLocalLiteral: false };
760
+ const p = path.join(projectDir, ".gsd-t", "env-registry-config.json");
761
+ try {
762
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
763
+ return { allowLocalLiteral: cfg && cfg.allowLocalLiteral === true };
764
+ } catch (_) {
765
+ return { allowLocalLiteral: false }; // absent / invalid → strict (no silent relax)
766
+ }
767
+ }
768
+
769
+ function validateRow(opts, relaxSecrets) {
770
+ // Marker-smuggling guard FIRST, on EVERY user-supplied field — ALWAYS run, even
771
+ // when secrets are relaxed for a local row (a literal secret is allowed there;
772
+ // corrupting the marked doc block is NEVER allowed).
773
+ for (const [field, value] of Object.entries({
774
+ scope: opts.scope,
775
+ kind: opts.kind,
776
+ host: opts.host,
777
+ port: opts.port,
778
+ name: opts.name,
779
+ authMethod: opts.authMethod,
780
+ secretVault: opts.secretVault,
781
+ secretEnvVarName: opts.secretEnvVarName,
782
+ fetchCommand: opts.fetchCommand,
783
+ connectCommand: opts.connectCommand,
784
+ gotchas: opts.gotchas,
785
+ })) {
786
+ rejectMarkerSmuggle(field, value);
787
+ }
788
+
789
+ // scope/kind/port/vault/env-var-NAME are STRUCTURAL (enum/digits/UPPER_SNAKE)
790
+ // and stay strict even when relaxed — they can't hold a free-form secret and a
791
+ // malformed one breaks parsing.
792
+ validateScope(opts.scope);
793
+ validateKind(opts.kind);
794
+ validatePort(opts.port);
795
+ validateVault(opts.secretVault);
796
+ validateEnvVarName(opts.secretEnvVarName);
797
+
798
+ // The free-ish, secret-capable columns. Relaxed ONLY for an opted-in local row.
799
+ if (relaxSecrets) return;
800
+
801
+ validateHost("host", opts.host);
802
+ validateName(opts.name);
803
+ validateAuthMethod(opts.authMethod);
804
+ validateCommand("fetch command", opts.fetchCommand);
805
+ validateCommand("connect command", opts.connectCommand);
806
+ validateGotchas(opts.gotchas);
807
+ }
808
+
809
+ // ─── Table serialization ─────────────────────────────────────────────────────
810
+
811
+ function escapeCell(v) {
812
+ if (v === undefined || v === null || v === "") return "—";
813
+ return String(v).replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
814
+ }
815
+
816
+ function rowToLine(row) {
817
+ return "| " + ENV_COLUMNS.map((c) => escapeCell(row[c])).join(" | ") + " |";
818
+ }
819
+
820
+ function headerLines() {
821
+ return [
822
+ "| " + ENV_COLUMNS.join(" | ") + " |",
823
+ "| " + ENV_COLUMNS.map(() => "---").join(" | ") + " |",
824
+ ];
825
+ }
826
+
827
+ // Build the full marker-delimited block from a list of row objects.
828
+ function buildTableBlock(rows) {
829
+ const lines = [
830
+ ENV_MARKER_START,
831
+ "## Environments",
832
+ "",
833
+ "> **Map, not secrets.** This table records only WHERE an environment lives and",
834
+ "> HOW to reach it — host/port/name/auth-method/which-vault-holds-the-secret/the",
835
+ "> env-var NAME/the fetch + connect commands. It NEVER stores a secret VALUE.",
836
+ "> The value lives in the vault (`.env` / Vercel / Neon / Google Secret Manager)",
837
+ "> and is pulled at runtime via the env-var NAME. A missing row → HALT and",
838
+ "> document (detect → ask → record → proceed); never guess a connection string,",
839
+ "> never grep transcripts to rediscover.",
840
+ "",
841
+ ...headerLines(),
842
+ ...rows.map(rowToLine),
843
+ ENV_MARKER_END,
844
+ ];
845
+ return lines.join("\n");
846
+ }
847
+
848
+ // ─── Table parsing ───────────────────────────────────────────────────────────
849
+
850
+ function infraDocPath(projectDir) {
851
+ return path.join(projectDir, "docs", "infrastructure.md");
852
+ }
853
+
854
+ function extractBlock(content) {
855
+ if (!content.includes(ENV_MARKER_START) || !content.includes(ENV_MARKER_END)) {
856
+ return null;
857
+ }
858
+ const start = content.indexOf(ENV_MARKER_START);
859
+ const end = content.indexOf(ENV_MARKER_END);
860
+ return content.slice(start, end);
861
+ }
862
+
863
+ function splitRow(line) {
864
+ // Strip the leading/trailing pipe, split on unescaped pipes.
865
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
866
+ const cells = [];
867
+ let cur = "";
868
+ for (let i = 0; i < trimmed.length; i++) {
869
+ const ch = trimmed[i];
870
+ if (ch === "\\" && trimmed[i + 1] === "|") {
871
+ cur += "|";
872
+ i++;
873
+ } else if (ch === "|") {
874
+ cells.push(cur.trim());
875
+ cur = "";
876
+ } else {
877
+ cur += ch;
878
+ }
879
+ }
880
+ cells.push(cur.trim());
881
+ return cells;
882
+ }
883
+
884
+ // Parse the Environments table into an array of row objects keyed by column.
885
+ function parseRows(projectDir) {
886
+ const target = infraDocPath(projectDir);
887
+ if (!fs.existsSync(target)) return [];
888
+ const content = fs.readFileSync(target, "utf8");
889
+ const block = extractBlock(content);
890
+ if (!block) return [];
891
+
892
+ const rows = [];
893
+ const lines = block.split("\n");
894
+ for (const line of lines) {
895
+ if (!line.trim().startsWith("|")) continue;
896
+ const cells = splitRow(line);
897
+ // Skip the header row and the separator row.
898
+ if (cells[0] === "id") continue;
899
+ if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue;
900
+ const row = {};
901
+ ENV_COLUMNS.forEach((col, i) => {
902
+ const raw = cells[i] === undefined ? "" : cells[i];
903
+ row[col] = raw === "—" ? "" : raw;
904
+ });
905
+ rows.push(row);
906
+ }
907
+ return rows;
908
+ }
909
+
910
+ // ─── recordEnvironment — upsert by (scope, kind) ─────────────────────────────
911
+
912
+ function recordEnvironment(opts = {}) {
913
+ const {
914
+ projectDir,
915
+ scope,
916
+ kind,
917
+ host,
918
+ port,
919
+ name,
920
+ authMethod,
921
+ secretVault,
922
+ secretEnvVarName,
923
+ fetchCommand,
924
+ connectCommand,
925
+ gotchas,
926
+ } = opts;
927
+
928
+ if (!projectDir) throw new Error("recordEnvironment: projectDir is required");
929
+ if (!scope) throw new Error("recordEnvironment: scope is required");
930
+ if (!kind) throw new Error("recordEnvironment: kind is required");
931
+
932
+ // Per-project local-literal switch: relax the secret checks ONLY for a
933
+ // scope=local row in a project that opted in. staging/prod ALWAYS strict.
934
+ const { allowLocalLiteral } = readEnvRegistryConfig(projectDir);
935
+ const relaxSecrets = allowLocalLiteral && scope === "local";
936
+
937
+ // HARD CONSTRAINT: per-column allowlist + marker-smuggle + secret backstop.
938
+ // Reject the ENTIRE row before any coercion or disk write. (relaxSecrets skips
939
+ // ONLY the free-form secret-capable columns for an opted-in local row.)
940
+ validateRow(opts, relaxSecrets);
941
+
942
+ // Destructive-Action Guard: prod defaults read-only=YES unless explicitly set.
943
+ // Coerce any non-string/boolean input to a YES/NO string (never literal
944
+ // "false"/0/null leaking into the doc).
945
+ let readOnlyDefault = opts.readOnlyDefault;
946
+ if (readOnlyDefault === undefined || readOnlyDefault === null || readOnlyDefault === "") {
947
+ readOnlyDefault = scope === "prod" ? "YES" : "NO";
948
+ } else if (typeof readOnlyDefault === "boolean") {
949
+ readOnlyDefault = readOnlyDefault ? "YES" : "NO";
950
+ } else {
951
+ const s = String(readOnlyDefault).trim().toLowerCase();
952
+ readOnlyDefault = ["yes", "true", "1", "y"].includes(s) ? "YES" : "NO";
953
+ }
954
+
955
+ const recorded = new Date().toISOString();
956
+ const id = `${scope}-${kind}`;
957
+
958
+ const row = {
959
+ id,
960
+ scope,
961
+ kind,
962
+ host,
963
+ port,
964
+ "db/name": name,
965
+ "auth method": authMethod,
966
+ "secret vault": secretVault,
967
+ "secret env-var NAME": secretEnvVarName,
968
+ "fetch command": fetchCommand,
969
+ "connect command": connectCommand,
970
+ "access gotchas": gotchas,
971
+ "read-only default": readOnlyDefault,
972
+ recorded,
973
+ };
974
+
975
+ // (row is already fully validated above by validateRow — allowlist-per-column
976
+ // + marker-smuggle guard + entropy/prefix secret backstop.)
977
+ // Upsert by (scope, kind): replace the existing row with the same key, else
978
+ // append. This is what self-heals staleness (Aiven → Neon replaces the row).
979
+ const rows = parseRows(projectDir);
980
+ const key = (r) => `${r.scope} ${r.kind}`;
981
+ const thisKey = `${scope} ${kind}`;
982
+
983
+ // Detect a DIFFERENT host replacing the same (scope,kind) — surface it so a
984
+ // second real environment doesn't silently vanish into an upsert.
985
+ const prior = rows.find((r) => key(r) === thisKey);
986
+ const replaced = !!prior;
987
+ const oldHost = prior ? prior.host : undefined;
988
+ const newHost = optStr(host);
989
+ const warnedHostChange = replaced && !!oldHost && !!newHost && oldHost !== newHost;
990
+
991
+ const kept = rows.filter((r) => key(r) !== thisKey);
992
+ kept.push(row);
993
+
994
+ const block = buildTableBlock(kept);
995
+ const target = infraDocPath(projectDir);
996
+ upsertMarkedDocBlock(target, ENV_MARKER_START, ENV_MARKER_END, block);
997
+
998
+ const result = Object.assign({}, row, { replaced });
999
+ if (warnedHostChange) {
1000
+ result.warnedHostChange = true;
1001
+ result.oldHost = oldHost;
1002
+ result.newHost = newHost;
1003
+ }
1004
+ if (relaxSecrets) {
1005
+ result.localLiteralWarning = true;
1006
+ result.localLiteralNote =
1007
+ "docs/infrastructure.md is a COMMITTED file — a literal secret in this " +
1008
+ "local row enters git history. It is allowed only because this project set " +
1009
+ "allowLocalLiteral:true in .gsd-t/env-registry-config.json (local scope only).";
1010
+ }
1011
+ return result;
1012
+ }
1013
+
1014
+ // ─── lookupEnvironment — read-first (No-Re-Research) ─────────────────────────
1015
+ //
1016
+ // Returns the row for (scope, kind) or null. On null the CALLER HALTs and
1017
+ // documents — this module intentionally emits NO guessed connection string and
1018
+ // has NO transcript-grep rediscovery path. There is no fallback here by design.
1019
+
1020
+ function lookupEnvironment(projectDir, scope, kind) {
1021
+ const rows = parseRows(projectDir);
1022
+ const match = rows.find((r) => r.scope === scope && r.kind === kind);
1023
+ return match || null;
1024
+ }
1025
+
1026
+ // ─── Leak detection + remediation PROPOSAL (interactive capture trigger) ─────
1027
+ //
1028
+ // The silent guard REJECTS a command carrying a literal secret. But when a HUMAN
1029
+ // is present (the brownfield capture trigger / a provisioning task), a bare
1030
+ // rejection isn't enough — the user asked to be TOLD and OFFERED a fix: move a
1031
+ // (possibly rotated) secret into the vault and replace the literal with a $VAR.
1032
+ //
1033
+ // This is DETECTION + a structured PROPOSAL only. The actual rotate-vs-move
1034
+ // decision + vault write happen in the command-file workflow WITH the user (the
1035
+ // module never rotates or writes a vault by itself, and never prompts).
1036
+
1037
+ // Pull the leaked secret out of a command/URL so it can be named + reported.
1038
+ // Returns { leaked:true, secret, kind, rewriteHint } or { leaked:false }.
1039
+ function detectCommandLeak(command) {
1040
+ const s = optStr(command);
1041
+ if (s === "") return { leaked: false };
1042
+
1043
+ // 1. embedded credential in a URL: proto://user:PASSWORD@host
1044
+ const embedded = s.match(/([a-z][a-z0-9+.\-]*):\/\/([^\s:/@]+):((?!\$)[^\s:/@]+)@/i);
1045
+ if (embedded) {
1046
+ return {
1047
+ leaked: true,
1048
+ secret: embedded[3],
1049
+ kind: "url-embedded-credential",
1050
+ rewriteHint: s.replace(embedded[3], "$SECRET"),
1051
+ };
1052
+ }
1053
+
1054
+ // 2. a token the classifier rejects — walk tokens, find the offending literal.
1055
+ const reason = classifyCommand(s);
1056
+ if (!reason) return { leaked: false };
1057
+ // Extract the quoted-out bare literal the reason names, else flag the command.
1058
+ const m = reason.match(/value "([^"]+)"|argument "([^"]+)"/);
1059
+ const secret = m ? (m[1] || m[2]) : null;
1060
+ return {
1061
+ leaked: true,
1062
+ secret,
1063
+ kind: "inline-literal",
1064
+ rewriteHint: secret ? s.replace(secret, "$SECRET") : s,
1065
+ reason,
1066
+ };
1067
+ }
1068
+
1069
+ // Build a remediation proposal the workflow presents to the user. It does NOT
1070
+ // decide rotate-vs-move (the user does) and does NOT touch a vault. `envVarName`
1071
+ // is a suggested UPPER_SNAKE name for the moved secret.
1072
+ function proposeRemediation(projectDir, command, opts = {}) {
1073
+ const leak = detectCommandLeak(command);
1074
+ if (!leak.leaked) return { needed: false };
1075
+ const detected = projectDir ? detectEnvConfig(projectDir) : { vault: "local (.env)", fetchCommand: "read from .env" };
1076
+ const envVarName = opts.envVarName ||
1077
+ (leak.kind === "url-embedded-credential" ? "DATABASE_URL" : "DB_SECRET");
1078
+ const rewritten = leak.secret
1079
+ ? command.replace(leak.secret, "$" + envVarName)
1080
+ : command;
1081
+ return {
1082
+ needed: true,
1083
+ leakKind: leak.kind,
1084
+ detectedSecret: leak.secret, // shown to the user so they SEE what leaked
1085
+ vault: detected.vault,
1086
+ fetchCommand: detected.fetchCommand,
1087
+ suggestedEnvVarName: envVarName,
1088
+ rewrittenCommand: rewritten,
1089
+ // The two paths the workflow must ASK the user to choose between:
1090
+ choices: {
1091
+ rotate: "Generate/request a NEW secret (the old one is compromised — it " +
1092
+ "was about to enter a committed file / is already exposed), store the new " +
1093
+ "one in the vault, use $" + envVarName + " in the command.",
1094
+ move: "Move the EXISTING secret into the vault as-is and reference it as $" +
1095
+ envVarName + " (faster; does not un-expose it if already committed).",
1096
+ },
1097
+ note: "Ask the user rotate-vs-move; then store to the vault (directly where " +
1098
+ "possible, else instruct the user) and record the row with the $VAR command.",
1099
+ };
1100
+ }
1101
+
1102
+ // ─── detectEnvConfig — reuse the detectStack SHAPE (map only, never a value) ──
1103
+ //
1104
+ // Reads .env.example / .env var NAMES, connection-string shapes, and
1105
+ // vercel/neon/gcloud presence to PROPOSE a vault + fetch command. Records the
1106
+ // map only — it NEVER reads or emits a secret value.
1107
+
1108
+ function readEnvVarNames(projectDir) {
1109
+ const names = new Set();
1110
+ for (const f of [".env.example", ".env.sample", ".env.template"]) {
1111
+ const p = path.join(projectDir, f);
1112
+ if (!fs.existsSync(p)) continue;
1113
+ let content;
1114
+ try {
1115
+ content = fs.readFileSync(p, "utf8");
1116
+ } catch (_) {
1117
+ continue;
1118
+ }
1119
+ for (const line of content.split("\n")) {
1120
+ const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=/);
1121
+ if (m) names.add(m[1]);
1122
+ }
1123
+ }
1124
+ return Array.from(names);
1125
+ }
1126
+
1127
+ function fileExists(projectDir, rel) {
1128
+ return fs.existsSync(path.join(projectDir, rel));
1129
+ }
1130
+
1131
+ function readPkgDeps(projectDir) {
1132
+ const pkgPath = path.join(projectDir, "package.json");
1133
+ let pkg = {};
1134
+ try {
1135
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
1136
+ } catch (_) {
1137
+ return [];
1138
+ }
1139
+ const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
1140
+ return Object.keys(deps).map((d) => d.toLowerCase());
1141
+ }
1142
+
1143
+ function detectEnvConfig(projectDir) {
1144
+ if (!projectDir) throw new Error("detectEnvConfig: projectDir is required");
1145
+
1146
+ const secretEnvVarNames = readEnvVarNames(projectDir);
1147
+ const depNames = readPkgDeps(projectDir);
1148
+
1149
+ // Vault detection — convention-based, grounded in the 3 real inspected
1150
+ // projects. Precedence: an explicit platform marker wins over plain .env.
1151
+ const hasVercel = fileExists(projectDir, "vercel.json") || fileExists(projectDir, ".vercel");
1152
+ const hasGoogle =
1153
+ fileExists(projectDir, "cloudbuild.yaml") ||
1154
+ fileExists(projectDir, "cloudbuild.yml") ||
1155
+ fileExists(projectDir, ".gcloudignore");
1156
+ const hasNeon =
1157
+ depNames.some((d) => d === "@neondatabase/serverless" || d.includes("neon")) ||
1158
+ fileExists(projectDir, ".neon");
1159
+
1160
+ let vault = "local (.env)";
1161
+ let fetchCommand = secretEnvVarNames[0]
1162
+ ? `read $${secretEnvVarNames[0]} from .env`
1163
+ : "read from .env";
1164
+
1165
+ if (hasVercel) {
1166
+ vault = "Vercel";
1167
+ fetchCommand = "vercel env pull";
1168
+ } else if (hasGoogle) {
1169
+ vault = "Google Secret Manager";
1170
+ fetchCommand = "gcloud secrets versions access latest --secret=<name>";
1171
+ } else if (hasNeon) {
1172
+ vault = "Neon";
1173
+ fetchCommand = "neonctl connection-string";
1174
+ }
1175
+
1176
+ return {
1177
+ vault,
1178
+ fetchCommand,
1179
+ secretEnvVarNames,
1180
+ signals: { hasVercel, hasGoogle, hasNeon },
1181
+ detectedFrom: path.join(projectDir, "package.json"),
1182
+ };
1183
+ }
1184
+
1185
+ // ─── addPermissionEntry — broad-glob allow-entry per tool ────────────────────
1186
+ //
1187
+ // Reuses the guarded-write scaffold pattern from bin/gsd-t.js (invalid-JSON
1188
+ // guard + symlink guard + idempotent). Writes a BROAD GLOB per tool
1189
+ // (Bash(psql:*)), NOT the exact command string — the user-locked decision.
1190
+
1191
+ function isSymlink(filePath) {
1192
+ try {
1193
+ return fs.lstatSync(filePath).isSymbolicLink();
1194
+ } catch (_) {
1195
+ return false;
1196
+ }
1197
+ }
1198
+
1199
+ // Derive the tool from the connect command's first meaningful token, skipping
1200
+ // leading env-var assignments (FOO=bar psql ...) and a leading `sudo`.
1201
+ function deriveTool(connectCommand) {
1202
+ if (!connectCommand || typeof connectCommand !== "string") return null;
1203
+ const tokens = connectCommand.trim().split(/\s+/);
1204
+ let i = 0;
1205
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
1206
+ if (tokens[i] === "sudo") i++;
1207
+ const tool = tokens[i];
1208
+ if (!tool) return null;
1209
+ // Base name only (strip any path).
1210
+ const base = tool.split("/").pop();
1211
+ if (!base) return null;
1212
+ // Reject a tool token carrying shell metacharacters — emitting Bash(psql;:*)
1213
+ // (or worse) into the permission allowlist would be broken junk / an injection
1214
+ // vector. A real tool name is plain `[A-Za-z0-9._-]`. Anything else → null
1215
+ // (no permission entry written).
1216
+ if (!/^[A-Za-z0-9._-]+$/.test(base)) return null;
1217
+ return base;
1218
+ }
1219
+
1220
+ function addPermissionEntry(projectDir, connectCommand) {
1221
+ if (!projectDir) throw new Error("addPermissionEntry: projectDir is required");
1222
+ const tool = deriveTool(connectCommand);
1223
+ if (!tool) return { added: false, tool: null, entry: null, reason: "no tool derivable" };
1224
+
1225
+ const entry = `Bash(${tool}:*)`;
1226
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
1227
+
1228
+ let settings = {};
1229
+ if (fs.existsSync(settingsPath)) {
1230
+ try {
1231
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
1232
+ if (!settings || typeof settings !== "object") settings = {};
1233
+ } catch (_) {
1234
+ // invalid-JSON guard — do not clobber, report noop
1235
+ return { added: false, tool, entry, reason: "settings.json has invalid JSON" };
1236
+ }
1237
+ }
1238
+
1239
+ if (!settings.permissions || typeof settings.permissions !== "object") settings.permissions = {};
1240
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
1241
+
1242
+ if (settings.permissions.allow.includes(entry)) {
1243
+ return { added: false, tool, entry, reason: "already present" };
1244
+ }
1245
+
1246
+ if (isSymlink(settingsPath)) {
1247
+ return { added: false, tool, entry, reason: "settings.json is a symlink" };
1248
+ }
1249
+
1250
+ settings.permissions.allow.push(entry);
1251
+
1252
+ const dir = path.dirname(settingsPath);
1253
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1254
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
1255
+
1256
+ return { added: true, tool, entry };
1257
+ }
1258
+
1259
+ // ─── ensureEnvGitignored — auto-add + report (user-locked decision) ──────────
1260
+
1261
+ function ensureEnvGitignored(projectDir) {
1262
+ if (!projectDir) throw new Error("ensureEnvGitignored: projectDir is required");
1263
+ const gitignorePath = path.join(projectDir, ".gitignore");
1264
+
1265
+ if (isSymlink(gitignorePath)) {
1266
+ return { added: false, reason: ".gitignore is a symlink" };
1267
+ }
1268
+
1269
+ let content = "";
1270
+ if (fs.existsSync(gitignorePath)) content = fs.readFileSync(gitignorePath, "utf8");
1271
+
1272
+ const lines = content.split("\n").map((l) => l.trim());
1273
+ // A broad `.env*` rule (or `*.env`) already covers every variant.
1274
+ const hasBroadRule = lines.some((l) => l === ".env*" || l === "/.env*" || l === "*.env");
1275
+
1276
+ // Collect the env files actually present in the project — `.env`, `.env.local`,
1277
+ // `.env.production`, etc. Each MUST be gitignored; a bare `.env` rule does NOT
1278
+ // cover `.env.production`, so leaving it tracked would leak secrets.
1279
+ let present = [];
1280
+ try {
1281
+ present = fs
1282
+ .readdirSync(projectDir)
1283
+ .filter((f) => f === ".env" || /^\.env\.[^/\\]+$/.test(f));
1284
+ } catch (_) {
1285
+ present = [];
1286
+ }
1287
+ // Always ensure at least a bare `.env` intent even if none is present yet.
1288
+ const required = new Set([".env", ...present]);
1289
+
1290
+ const ignored = new Set(lines);
1291
+ const isCovered = (name) =>
1292
+ hasBroadRule || ignored.has(name) || ignored.has("/" + name);
1293
+
1294
+ const missing = [...required].filter((name) => !isCovered(name));
1295
+ if (missing.length === 0) return { added: false, reason: "already gitignored" };
1296
+
1297
+ // Prefer a single broad `.env*` rule when more than one variant is present or
1298
+ // missing — it future-proofs against new `.env.*` files.
1299
+ const additions =
1300
+ missing.length > 1 || present.some((f) => f !== ".env") ? [".env*"] : missing;
1301
+
1302
+ const next =
1303
+ content.replace(/\s*$/, "") +
1304
+ (content ? "\n" : "") +
1305
+ additions.join("\n") +
1306
+ "\n";
1307
+ fs.writeFileSync(gitignorePath, next, "utf8");
1308
+ return { added: true, rules: additions };
1309
+ }
1310
+
1311
+ module.exports = {
1312
+ recordEnvironment,
1313
+ lookupEnvironment,
1314
+ detectEnvConfig,
1315
+ detectCommandLeak,
1316
+ proposeRemediation,
1317
+ readEnvRegistryConfig,
1318
+ addPermissionEntry,
1319
+ ensureEnvGitignored,
1320
+ deriveTool,
1321
+ looksLikeInlineSecret,
1322
+ looksLikeSecretValue,
1323
+ isHostShape,
1324
+ isDbNameShape,
1325
+ isSafeBareArg,
1326
+ classifyCommandToken,
1327
+ classifyCommand,
1328
+ classifyFlagValue,
1329
+ isSafeNonSecretToken,
1330
+ isValueFlagAllowingBare,
1331
+ flagValueMatchesTypedShape,
1332
+ VALUE_FLAG_SHAPE,
1333
+ tokenizeCommand,
1334
+ AUTH_METHODS,
1335
+ CLI_WORDS,
1336
+ ENV_COLUMNS,
1337
+ ENV_MARKER_START,
1338
+ ENV_MARKER_END,
1339
+ };
1340
+
1341
+ // ─── CLI arm (so sandboxed workflows can call via Bash) ──────────────────────
1342
+
1343
+ if (require.main === module) {
1344
+ const args = process.argv.slice(2);
1345
+ const sub = args[0];
1346
+ const out = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
1347
+
1348
+ try {
1349
+ if (sub === "record") {
1350
+ // node gsd-t-env-registry.cjs record '<json-opts>'
1351
+ const opts = JSON.parse(args[1] || "{}");
1352
+ out({ ok: true, row: recordEnvironment(opts) });
1353
+ } else if (sub === "lookup") {
1354
+ // node gsd-t-env-registry.cjs lookup <projectDir> <scope> <kind>
1355
+ const row = lookupEnvironment(args[1], args[2], args[3]);
1356
+ out({ ok: true, found: !!row, row });
1357
+ } else if (sub === "detect") {
1358
+ // node gsd-t-env-registry.cjs detect <projectDir>
1359
+ out({ ok: true, detected: detectEnvConfig(args[1]) });
1360
+ } else if (sub === "add-permission") {
1361
+ // node gsd-t-env-registry.cjs add-permission <projectDir> '<connectCommand>'
1362
+ out({ ok: true, result: addPermissionEntry(args[1], args[2]) });
1363
+ } else if (sub === "ensure-gitignore") {
1364
+ // node gsd-t-env-registry.cjs ensure-gitignore <projectDir>
1365
+ out({ ok: true, result: ensureEnvGitignored(args[1]) });
1366
+ } else if (sub === "detect-leak") {
1367
+ // node gsd-t-env-registry.cjs detect-leak '<command>'
1368
+ out({ ok: true, result: detectCommandLeak(args[1]) });
1369
+ } else if (sub === "propose-remediation") {
1370
+ // node gsd-t-env-registry.cjs propose-remediation <projectDir> '<command>' [envVarName]
1371
+ out({ ok: true, result: proposeRemediation(args[1], args[2], args[3] ? { envVarName: args[3] } : {}) });
1372
+ } else {
1373
+ out({
1374
+ ok: false,
1375
+ error:
1376
+ "usage: env-registry <record|lookup|detect|detect-leak|propose-remediation|add-permission|ensure-gitignore> ...",
1377
+ });
1378
+ process.exit(2);
1379
+ }
1380
+ } catch (e) {
1381
+ out({ ok: false, error: e.message });
1382
+ process.exit(1);
1383
+ }
1384
+ }