@tekyzinc/gsd-t 5.1.10 → 5.1.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.1.12] - 2026-07-13
6
+
7
+ ### Changed — Simply Stated now governs conversational narration (4th enforcement layer)
8
+
9
+ The v5.1.10 doctrine only gated formal deliverables (architectures/plans/findings) + code writes — it was blind to mid-work talk-to-the-user, which is exactly where a jargon-dense, buried-point, self-narrating reply slipped through even in a fresh session. Extended the doctrine to REPLIES: the every-turn Reader Contract (injected by `scripts/gsd-t-auto-route.js`) now carries the Simply-Stated rule + a real before/after example, with two conversational-specific rules — NO PREAMBLE and load-bearing-point-FIRST. This is the channel that reaches conversational output each turn (layers 2-3 do not).
10
+
11
+ - `scripts/gsd-t-auto-route.js`: Simply-Stated line + the egress-guard before/after added to `READER_CONTRACT`.
12
+ - `templates/CLAUDE-global.md` (+ `~/.claude/CLAUDE.md`): doctrine gains "§Applies to conversational narration too" + a 4th enforcement layer; Reader Contract prose mirrors the rule + example.
13
+
14
+ ## [5.1.11] - 2026-07-13
15
+
16
+ ### Fixed — paused M102 work leaked into the v5.1.10 package; removed
17
+
18
+ `npm publish` tarballs the WORKING TREE, not the committed tree — so v5.1.10 shipped the uncommitted, PAUSED M102 env-registry files (the known-leaky secret guard bins + a `templates/CLAUDE-global.md` "Environment Access" rule pointing at them). `update-all` re-propagated that rule into `~/.claude/CLAUDE.md`. This patch removes the M102 env-access rule from the template and publishes from a clean tree (the paused M102 build files are stashed during publish, so the package matches the committed state). The Simply Stated doctrine (v5.1.10) is unaffected and retained.
19
+
20
+ - `templates/CLAUDE-global.md`: removed the M102 "Environment Access — read-first" rule (M102 is paused; the rule pointed at an unfinished/leaky mechanism).
21
+ - Publish hygiene: the paused M102 working-tree files no longer ride into the released tarball.
22
+
5
23
  ## [5.1.10] - 2026-07-13
6
24
 
7
25
  ### Added — Simply Stated Doctrine (clarity as a defect gate, the verbose-virus cure)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.1.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.1.12** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -21,9 +21,6 @@
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const crypto = require("crypto");
24
- // M102 — the marker-block idempotent doc-writer was extracted here into the ONE
25
- // shared module both this scaffolder and bin/gsd-t-env-registry.cjs call.
26
- const { upsertMarkedDocBlock } = require("./gsd-t-doc-marker.cjs");
27
24
 
28
25
  const VALID_BACKENDS = ["db-table", "local-sqlite", "local-jsonl"];
29
26
 
@@ -185,9 +182,24 @@ function writeChoiceToProjectDocs(projectDir, envelope) {
185
182
  const targetPath = candidates.find((p) => fs.existsSync(p)) || candidates[0];
186
183
 
187
184
  const block = docBlock(envelope);
188
- // M102 delegate to the ONE shared marker-block writer (byte-identical logic
189
- // to the former inline replace/append; extracted so env-registry reuses it).
190
- return upsertMarkedDocBlock(targetPath, DOC_MARKER_START, DOC_MARKER_END, block);
185
+ let content = "";
186
+ if (fs.existsSync(targetPath)) {
187
+ content = fs.readFileSync(targetPath, "utf8");
188
+ } else {
189
+ const dir = path.dirname(targetPath);
190
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
191
+ }
192
+
193
+ if (content.includes(DOC_MARKER_START) && content.includes(DOC_MARKER_END)) {
194
+ const startIdx = content.indexOf(DOC_MARKER_START);
195
+ const endIdx = content.indexOf(DOC_MARKER_END) + DOC_MARKER_END.length;
196
+ content = content.slice(0, startIdx) + block + content.slice(endIdx);
197
+ } else {
198
+ content = content.replace(/\s*$/, "") + "\n\n" + block + "\n";
199
+ }
200
+
201
+ fs.writeFileSync(targetPath, content, "utf8");
202
+ return targetPath;
191
203
  }
192
204
 
193
205
  // ─── Seam envelope builder ──────────────────────────────────────────────────
@@ -307,8 +307,6 @@ function _detectDefaultTrack2(projectDir, notes) {
307
307
 
308
308
  plan.push({ id: 'logging-envelope', cmd: 'node', args: [path.join(__dirname, 'gsd-t-logging-envelope-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // M100 D3: structural trace+audit envelope gate, FAIL-CLOSED
309
309
 
310
- plan.push({ id: 'env-registry', cmd: 'node', args: [path.join(__dirname, 'gsd-t-env-registry-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // M102 D3: no-secret-in-registry + rule-without-table gate, FAIL-CLOSED
311
-
312
310
  // secrets — gitleaks (PATH detection deferred to runtime)
313
311
  if (_hasOnPath('gitleaks')) {
314
312
  plan.push({
package/bin/gsd-t.js CHANGED
@@ -2999,16 +2999,6 @@ const PROJECT_BIN_TOOLS = [
2999
2999
  // ship to every project or the consumers can't classify. Same propagation class as
3000
3000
  // gsd-t-graph-store-resolver.cjs above. [[project_global_bin_propagation_gap]]
3001
3001
  "gsd-t-graph-availability.cjs",
3002
- // M102 — Environment Registry. The shared marker-block doc-writer + the
3003
- // registry helper (record/lookup/detect/add-permission/ensure-gitignore).
3004
- // Sandboxed workflows (init/populate + provisioning execute/quick tasks) call
3005
- // the registry via project-local Bash, and the registry `require`s the shared
3006
- // writer at load — BOTH must ship or the registry throws / the writer is a
3007
- // second copy. Same propagation class as the graph tools above.
3008
- // [[project_global_bin_propagation_gap]]
3009
- // Plus the verify-gate lint (gsd-t-verify-gate.cjs invokes it via __dirname,
3010
- // so it must sit alongside the gate in the project's bin/).
3011
- "gsd-t-doc-marker.cjs", "gsd-t-env-registry.cjs", "gsd-t-env-registry-check.cjs",
3012
3002
  ];
3013
3003
 
3014
3004
  // Files that older versions of this installer copied into project bin/ but
@@ -296,12 +296,6 @@ docs/
296
296
 
297
297
  These are the living documents that persist across milestones and keep institutional knowledge alive. The `infrastructure.md` is especially important — it captures the exact commands for provisioning cloud resources, setting up databases, managing secrets, and deploying, so this knowledge doesn't get lost between sessions.
298
298
 
299
- The `infrastructure.md` template ships with a marker-delimited `## Environments` table (`<!-- gsd-t-env-registry:start -->` / `:end`) — the committed, **secret-free** connection registry (M102). It is created empty by init and populated by the two triggers:
300
- - **Greenfield (record-at-create):** whenever this project later BUILDS/PROVISIONS/CONFIGURES an environment (a local test DB, a remote server, credentials, or hands the user setup instructions), record the map row in the SAME pass:
301
- `node bin/gsd-t-env-registry.cjs record '{"projectDir":".","scope":"local","kind":"postgres","host":"localhost","port":"5432","name":"appdb","authMethod":"password","secretVault":"local (.env)","secretEnvVarName":"DATABASE_URL","fetchCommand":"read $DATABASE_URL from .env","connectCommand":"psql \"$DATABASE_URL\""}'`
302
- Records only the MAP + vault pointer + env-var NAME + fetch/connect commands — **never a secret VALUE** (a literal password/token in a command is REJECTED). `scope=prod` defaults `read-only=YES`.
303
- - Also run `node bin/gsd-t-env-registry.cjs ensure-gitignore .` so `.env` is gitignored (auto-add + report; secret-leak precheck).
304
-
305
299
  ## Step 8: Ensure README.md Exists
306
300
 
307
301
  If no `README.md` exists, create one with:
@@ -29,11 +29,6 @@ Scan this codebase and populate the GSD-T documentation. Analyze the actual code
29
29
  - List Credentials and Secrets from .env.example (local) and any secret manager configs
30
30
  - Document Deployment from CI/CD configs, Dockerfiles, cloud configs
31
31
  - Document Logging and Monitoring from any logging setup or dashboard configs
32
- - **Back-fill the `## Environments` registry (M102 — brownfield capture).** For an existing project whose non-local environments are undocumented, back-fill the map-only Environments table WITHOUT ever reading or writing a secret VALUE:
33
- 1. `node bin/gsd-t-env-registry.cjs detect .` → proposes the vault + fetch command from `.env.example` var NAMES, connection-string SHAPES, and `vercel.json`/`.vercel` (Vercel) · `cloudbuild.yaml`/`.gcloudignore` (Google Secret Manager) · neon dep (Neon) · plain `.env` (local). Map only — never a value.
34
- 2. Confirm/fill gaps with the human, then `node bin/gsd-t-env-registry.cjs record '<json>'` per environment (upsert by `(scope, kind)` — a re-provision replaces the stale row).
35
- 3. `node bin/gsd-t-env-registry.cjs ensure-gitignore .` — auto-add `.env` to `.gitignore` if missing + report.
36
- - **No-Fallback-Ever:** a genuinely undocumented environment reached for the first time HALTs → detect → ask → record → `add-permission` → proceed. NEVER guess a connection string, NEVER grep transcripts to rediscover.
37
32
 
38
33
  ## Graph Structural Slice — who-imports + cluster (M94-D10)
39
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.1.10",
3
+ "version": "5.1.12",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -35,10 +35,12 @@ const READER_CONTRACT = [
35
35
  "• Exception — when you're about to CHANGE code/files: state intent in one line first, so the user can stop a wrong direction.",
36
36
  "• Gloss every code/jargon term in plain words on first use. No bare IDs or acronyms the reader must decode.",
37
37
  "• Bullets/tables over paragraphs. Cut hedging and meta-commentary. Expand only if asked.",
38
+ "• SIMPLY STATED (applies to conversational narration too, not just formal deliverables). Every word load-bearing; logic in a straight line; the load-bearing point FIRST, not buried after justification. If you can't state it cleanly, the THINKING isn't done — re-think, don't re-word (a muddled sentence is a muddled understanding, and that ships bugs). Do NOT narrate the explanation (\"it matters that I say why…\") — just give it. Do NOT reach for a clever phrase that obscures (\"a fallback in reverse\") when a plain one is clearer. \"Too sophisticated to simplify\" is banned.",
38
39
  "EXAMPLES (before → after):",
39
40
  "• \"That's a great question, and it touches on something subtle. Let me look into how the cache works before I answer…\" → \"The cache lives in memory, cleared on restart.\"",
40
41
  "• \"There are a few moving parts here. First, I want to make sure I understand the goal, because X has a gotcha…\" → \"Set X in .env. Gotcha: also add the localhost redirect URI or it rejects.\"",
41
42
  "• \"Good catch — I conflated two things. Here's the honest correction: the files actually stack rather than overwrite…\" → \"You're right — files stack, they don't overwrite.\"",
43
+ "• (jargon + buried point + self-narration) \"The incomplete-identity quarantine at egress-client.ts:217 is now unreachable for seller chatter — but I'm keeping it, and it matters that I say why rather than quietly deleting it… Removing it would be a fallback in reverse…\" → \"Keeping a safety check that no longer trips — turning it into a loud alarm. Its bug is fixed so it never fires now, but deleting it would drop protection against a future malformed record (and another code path still uses it). So it stays, now as a loud 'this should never happen' alarm instead of a silent skip.\"",
42
44
  ].join("\n");
43
45
 
44
46
  /**
@@ -99,25 +99,6 @@ NEED TO UNDERSTAND SOMETHING?
99
99
  └── Not documented? → Research, then DOCUMENT IT
100
100
  ```
101
101
 
102
- ### Environment Access — read-first, HALT-and-document (M102)
103
-
104
- **EXTENDS No-Re-Research; obeys No-Fallback-Ever.** Before reaching ANY non-local environment (staging/prod DB, remote server, hosted API, SSH host), the connection MAP is read from the committed `## Environments` table in `docs/infrastructure.md` — NEVER re-discovered.
105
-
106
- ```
107
- NEED TO REACH A NON-LOCAL ENVIRONMENT?
108
- ├── lookupEnvironment(scope, kind) FIRST → bin/gsd-t-env-registry.cjs lookup
109
- ├── Row exists → use its connect command; the secret is pulled at runtime via
110
- │ the recorded env-var NAME from its vault (never a value in the doc).
111
- │ scope=prod + read-only=YES + a WRITE op → HALT, confirm with human.
112
- └── Row MISSING → HALT the connect. Do NOT guess a connection string. Do NOT grep
113
- transcripts to rediscover. The brownfield path:
114
- detectEnvConfig → ask human to confirm/fill → recordEnvironment
115
- → addPermissionEntry → THEN proceed. First need = last rediscovery.
116
- ```
117
-
118
- - The registry stores only the MAP + which vault holds the secret + the env-var NAME + the fetch/connect commands — **never a secret VALUE**.
119
- - **Zero fallback.** A missing row resolves by DOCUMENTING (detect → ask → record → proceed), never by a guessed connstring or a transcript re-grep. That HALT-then-document is NOT a fallback.
120
- - Greenfield: when GSD-T builds/provisions an env, it records the row in the SAME pass (`recordEnvironment`) — so staleness self-heals (a re-provision upserts by `(scope, kind)` and replaces the stale row).
121
102
 
122
103
 
123
104
  # Versioning
@@ -503,7 +484,9 @@ See memory pointer: `feedback_auto_research_external_gaps`.
503
484
 
504
485
  **The HALT (the teeth):** a deliverable whose simply-stated lead cannot be written cleanly is NOT done — stop, surface it, re-think the muddled part. Do not ship the verbose version with an apology; the verbose version is evidence the design has a gap.
505
486
 
506
- **§Enforcement (three layers):** (1) this doctrine = the definition; (2) the **Architect's Oversight Write/Edit trigger** carries a Simply-Stated line; (3) the **plan/milestone Six-Stage Pass + `/gsd-t-architect` gain a Simply-Stated gate** the pass does not complete until its finding/plan has a clean simply-stated lead, and the validation protocols flag a deliverable that lacks one. The simply-stated lead is a REQUIRED ARTIFACT (like the pseudocode file), not advice.
487
+ **§Applies to conversational narration too, not just formal deliverables.** The doctrine governs REPLIES and mid-work talk-to-the-user, not only architectures/plans/findings. Two extra rules there: **NO PREAMBLE** (no throat-clearing, no "let me…", no narrating-the-explanation like "it matters that I say why…" give the point, not a description of the point), and **load-bearing point FIRST** (never buried after justification clauses). No clever phrase that obscures where a plain one is clearer.
488
+
489
+ **§Enforcement (four layers):** (1) this doctrine = the definition; (2) the **Architect's Oversight Write/Edit trigger** carries a Simply-Stated line; (3) the **plan/milestone Six-Stage Pass + `/gsd-t-architect` gain a Simply-Stated gate** — the pass does not complete until its finding/plan has a clean simply-stated lead, and the validation protocols flag a deliverable that lacks one (a REQUIRED ARTIFACT like the pseudocode file, not advice); (4) the **every-turn Reader Contract** (injected by the UserPromptSubmit hook `scripts/gsd-t-auto-route.js`) carries the Simply-Stated rule + a before/after example, so it governs conversational output each reply — the channel that reaches mid-work narration, which layers 2-3 do not.
507
490
 
508
491
  ### Phase Flow
509
492
  - Upon completing a phase, automatically proceed to the next phase
@@ -68,46 +68,6 @@ cp .env.example .env
68
68
  {command}
69
69
  ```
70
70
 
71
- <!-- gsd-t-env-registry:start -->
72
- ## Environments
73
-
74
- > **Map, not secrets.** This table records only WHERE an environment lives and
75
- > HOW to reach it — host/port/name/auth-method/which-vault-holds-the-secret/the
76
- > env-var NAME/the fetch + connect commands. It NEVER stores a secret VALUE.
77
- > The value lives in the vault (`.env` / Vercel / Neon / Google Secret Manager)
78
- > and is pulled at runtime via the env-var NAME. A missing row → HALT and
79
- > document (detect → ask → record → proceed); never guess a connection string,
80
- > never grep transcripts to rediscover.
81
-
82
- | id | scope | kind | host | port | db/name | auth method | secret vault | secret env-var NAME | fetch command | connect command | access gotchas | read-only default | recorded |
83
- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
84
- <!-- gsd-t-env-registry:end -->
85
-
86
- <!--
87
- Column notes:
88
- scope = local | staging | prod
89
- kind = postgres | mysql | redis | http-api | ssh-host | ...
90
- secret vault = local (.env) | Vercel | Neon | Google Secret Manager | ...
91
- secret env-var NAME = e.g. DATABASE_URL_PROD — the NAME only, NEVER the value
92
- host = POSITIVE shape: localhost | IPv4 | dotted DNS hostname |
93
- short lowercase service name (letters+hyphen, no digits, e.g. db, postgres)
94
- db/name = short lowercase snake identifier (<=16, e.g. binvoice_prod, analytics)
95
- auth method = ENUMERATED — password | iam | oauth | oauth2 | service-account |
96
- ssh-key | api-key | none | scram-sha-256 | md5 | trust | token | key | ...
97
- fetch command = how to pull the secret from its vault (e.g. `vercel env pull`)
98
- POSITIVE allowlist: a token is accepted ONLY if it IS a $VAR/${VAR} ref,
99
- a flag, a hostname/IP, a .env dotfile, or a curated CLI/db word.
100
- Any OTHER bare literal is REJECTED (move it to an env var, reference as $VAR).
101
- connect command = references the env-var by name: `psql "$DATABASE_URL_PROD"` (same allowlist)
102
- access gotchas = ENUMERATED — vpn | ip-allowlist | ssh-tunnel | bastion | none,
103
- optionally `via <hostname>` (e.g. `ssh-tunnel via bastion.example.com`).
104
- Free prose is FORBIDDEN (nowhere for a secret to hide).
105
- read-only default = YES for scope=prod unless a human explicitly recorded write-ok
106
- There is deliberately NO secret-value column — a row is structurally incapable
107
- of holding a secret. Populated by `gsd-t-env-registry` (record-at-create +
108
- capture-on-first-need).
109
- -->
110
-
111
71
  ## Credentials & Secrets
112
72
 
113
73
  ### Local (.env)
@@ -1,65 +0,0 @@
1
- "use strict";
2
-
3
- // bin/gsd-t-doc-marker.cjs
4
- //
5
- // M102 — the ONE shared marker-block idempotent doc-writer. Extracted from the
6
- // M100 logging scaffolder's private `writeChoiceToProjectDocs` (so there is a
7
- // single writer, not two copies): both bin/gsd-t-logging-scaffolder.cjs and
8
- // bin/gsd-t-env-registry.cjs call this.
9
- //
10
- // upsertMarkedDocBlock(targetPath, startMarker, endMarker, block):
11
- // - If targetPath contains BOTH markers, the region between them (inclusive)
12
- // is REPLACED with `block` (idempotent re-write).
13
- // - Otherwise `block` is appended to the end of the file (creating the file
14
- // and any missing parent dirs).
15
- // - `block` MUST already begin with startMarker and end with endMarker — the
16
- // caller composes the full block (same contract the M100 writer used).
17
- //
18
- // Zero external npm runtime deps — fs/path only. Symlink-guarded: refuses to
19
- // write through a symlinked target (defense-in-depth, matches the installer's
20
- // settings-writer scaffold).
21
-
22
- const fs = require("fs");
23
- const path = require("path");
24
-
25
- function isSymlink(filePath) {
26
- try {
27
- return fs.lstatSync(filePath).isSymbolicLink();
28
- } catch (_) {
29
- return false;
30
- }
31
- }
32
-
33
- // Replace the [startMarker … endMarker] region with `block`, or append it.
34
- // Returns the written targetPath. Idempotent: a second call with the same
35
- // block leaves the file byte-identical.
36
- function upsertMarkedDocBlock(targetPath, startMarker, endMarker, block) {
37
- if (!targetPath) throw new Error("upsertMarkedDocBlock: targetPath is required");
38
- if (!startMarker || !endMarker) throw new Error("upsertMarkedDocBlock: start/end markers are required");
39
- if (typeof block !== "string") throw new Error("upsertMarkedDocBlock: block must be a string");
40
-
41
- if (isSymlink(targetPath)) {
42
- throw new Error(`upsertMarkedDocBlock: refusing to write through symlinked target: ${targetPath}`);
43
- }
44
-
45
- let content = "";
46
- if (fs.existsSync(targetPath)) {
47
- content = fs.readFileSync(targetPath, "utf8");
48
- } else {
49
- const dir = path.dirname(targetPath);
50
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
51
- }
52
-
53
- if (content.includes(startMarker) && content.includes(endMarker)) {
54
- const startIdx = content.indexOf(startMarker);
55
- const endIdx = content.indexOf(endMarker) + endMarker.length;
56
- content = content.slice(0, startIdx) + block + content.slice(endIdx);
57
- } else {
58
- content = content.replace(/\s*$/, "") + "\n\n" + block + "\n";
59
- }
60
-
61
- fs.writeFileSync(targetPath, content, "utf8");
62
- return targetPath;
63
- }
64
-
65
- module.exports = { upsertMarkedDocBlock };
@@ -1,318 +0,0 @@
1
- "use strict";
2
-
3
- // bin/gsd-t-env-registry-check.cjs
4
- //
5
- // M102 D3 — the deterministic verify-gate lint for the Environment Registry.
6
- // FAIL-CLOSED. Two failure conditions (spec D3):
7
- // (a) any Environments row cell is NOT the POSITIVE shape its column is
8
- // supposed to hold (a value that is not a real hostname / not the enum /
9
- // not a $VAR / not a curated command token = a probable inline secret);
10
- // (b) the env-access DOC RULE is present in the project's CLAUDE.md but the
11
- // `## Environments` table MARKERS are absent from docs/infrastructure.md
12
- // (rule promises a registry the doc doesn't provide).
13
- //
14
- // A project that has neither the table nor the rule is a NO-OP PASS (it simply
15
- // hasn't adopted M102 yet) — distinguishable from a wired-but-broken FAIL.
16
- //
17
- // Zero external npm runtime deps — fs/path only.
18
- //
19
- // PRIMARY GUARD = POSITIVE PER-COLUMN SHAPE (re-derived here, NOT a call into
20
- // the writer). The gate RE-IMPLEMENTS the positive shapes so a writer bug can
21
- // never silently disable it. It maps each cell to its column and requires the
22
- // cell to BE the shape that column holds:
23
- // - host → localhost / IPv4 / dotted-DNS / short lowercase service label
24
- // - db/name → short lowercase snake identifier (≤16, no digit→letter)
25
- // - auth method → enumerated auth-method name
26
- // - fetch/connect command → every token is $VAR / flag / hostname / curated word
27
- // - access gotchas → enumerated (vpn|ip-allowlist|ssh-tunnel|bastion|none) + via host
28
- // - secret vault → enumerated vault name
29
- // - secret env-var NAME → UPPER_SNAKE
30
- // A cell that is NOT its column's positive shape → FAIL.
31
- //
32
- // BACKSTOP (extra layer, applied to EVERY cell regardless of column): the
33
- // imported known-prefix/JWT/base64/hex `looksLikeSecretValue` + the gate's OWN
34
- // embedded-credential regex. These are NOT the primary guard — the positive
35
- // per-column shape is. A secret that somehow matched a positive shape (it can't
36
- // by construction) would still trip these.
37
-
38
- const fs = require("fs");
39
- const path = require("path");
40
-
41
- const {
42
- ENV_MARKER_START,
43
- ENV_MARKER_END,
44
- ENV_COLUMNS,
45
- looksLikeSecretValue,
46
- } = require("./gsd-t-env-registry.cjs");
47
-
48
- // The gate ALSO carries its own inline embedded-credential detector so it is
49
- // not solely dependent on the imported symbol — a proto://user:pw@ literal in
50
- // ANY cell fails independently of the writer's classification.
51
- const GATE_EMBEDDED_CRED = /[a-z][a-z0-9+.\-]*:\/\/[^\s:/@]+:(?!\$)[^\s:/@]+@/i;
52
-
53
- // ─── Gate's OWN re-implemented POSITIVE shapes (independent of the writer) ────
54
- //
55
- // Deliberately re-declared here (not imported) so the gate is a genuinely
56
- // independent implementation — a bug in the writer's shapes cannot disable the
57
- // gate's.
58
-
59
- const GATE_DOTTED_HOSTNAME =
60
- /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
61
- const GATE_IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
62
- const GATE_BARE_HOST_LABEL = /^[a-z][a-z-]{0,15}$/;
63
- const GATE_VAR_REF = /^["']?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?["']?$/;
64
- const GATE_UPPER_SNAKE = /^[A-Z][A-Z0-9_]*$/;
65
- // An ISO-8601 timestamp (the `recorded` column) is structural, not a secret.
66
- const GATE_ISO_TS = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
67
-
68
- const GATE_AUTH_METHODS = new Set([
69
- "password", "iam", "oauth", "oauth2", "service-account", "ssh-key",
70
- "api-key", "none", "scram-sha-256", "md5", "trust", "token", "key",
71
- "cert", "mtls", "kerberos", "ldap",
72
- ]);
73
- const GATE_VAULTS = new Set([
74
- "local", "local (.env)", ".env", "env",
75
- "vercel", "neon", "gcp-secret-manager", "google secret manager",
76
- "aws-secrets-manager", "aws secrets manager", "doppler", "1password",
77
- "hashicorp-vault", "vault", "azure-key-vault", "infisical",
78
- ]);
79
- const GATE_GOTCHA_ENUM = new Set(["vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none"]);
80
- const GATE_CLI_WORDS = new Set([
81
- "psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
82
- "pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
83
- "neonctl", "vercel", "gcloud", "aws", "az", "doppler", "supabase",
84
- "flyctl", "fly", "heroku", "railway", "wrangler", "turso", "op", "infisical",
85
- "kubectl", "helm", "terraform", "vault",
86
- "ssh", "scp", "sftp", "curl", "wget", "ldapsearch", "nc", "openssl", "rsync",
87
- "env", "pull", "push", "list", "get", "set", "secrets", "versions",
88
- "access", "version", "exec", "run", "connect", "login", "logout",
89
- "connection-string", "db-url", "database-url", "redis-url",
90
- "admin", "default", "latest", "read", "write", "describe", "show",
91
- "from", "cat", "source", "printenv", "dotenv",
92
- ]);
93
- const GATE_DOTFILE_TOKEN = /^\.[a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*$/;
94
-
95
- function gateIsDbNameShape(s) {
96
- if (typeof s !== "string" || s.length === 0 || s.length > 16) return false;
97
- if (!/^[a-z][a-z0-9_]*$/.test(s)) return false;
98
- if (/[0-9][a-z]/.test(s)) return false;
99
- return true;
100
- }
101
- function gateIsHostShape(s) {
102
- if (s === "localhost") return true;
103
- if (GATE_IPV4.test(s)) return true;
104
- if (GATE_DOTTED_HOSTNAME.test(s)) return true;
105
- if (GATE_BARE_HOST_LABEL.test(s)) return true;
106
- return false;
107
- }
108
- function gateIsVarRef(tok) {
109
- const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
110
- return GATE_VAR_REF.test(tok) || /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(bare);
111
- }
112
- // A single command token is on the positive allowlist?
113
- function gateCommandTokenOk(tok) {
114
- const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
115
- if (bare === "") return true;
116
- if (gateIsVarRef(tok)) return true;
117
- if (/^--?[A-Za-z][A-Za-z0-9-]*$/.test(bare)) return true; // bare flag
118
- if (/^--?[A-Za-z][A-Za-z0-9-]*=/.test(bare)) {
119
- const value = bare.slice(bare.indexOf("=") + 1);
120
- if (value === "") return true;
121
- return gateCommandTokenOk(value);
122
- }
123
- if (gateIsHostShape(bare)) return true;
124
- if (GATE_DOTFILE_TOKEN.test(bare)) return true;
125
- if (GATE_CLI_WORDS.has(bare.toLowerCase())) return true;
126
- if (gateIsDbNameShape(bare)) return true;
127
- return false;
128
- }
129
- function gateTokenizeCommand(s) {
130
- const tokens = [];
131
- const re = /"[^"]*"|'[^']*'|\S+/g;
132
- let m;
133
- while ((m = re.exec(s)) !== null) tokens.push(m[0]);
134
- return tokens;
135
- }
136
- function gateCommandOk(cell) {
137
- for (const tok of gateTokenizeCommand(cell)) {
138
- if (!gateCommandTokenOk(tok)) return false;
139
- }
140
- return true;
141
- }
142
- function gateGotchasOk(cell) {
143
- const tokens = cell.split(/[,\s]+/).filter(Boolean);
144
- for (let i = 0; i < tokens.length; i++) {
145
- const t = tokens[i].toLowerCase();
146
- if (GATE_GOTCHA_ENUM.has(t)) continue;
147
- if (t === "via") {
148
- const next = tokens[i + 1];
149
- if (!next || !gateIsHostShape(next)) return false;
150
- i++;
151
- continue;
152
- }
153
- if (gateIsHostShape(tokens[i])) continue;
154
- return false;
155
- }
156
- return true;
157
- }
158
-
159
- // The BACKSTOP leak test — the known-prefix/JWT/base64/hex detector + the
160
- // embedded-cred regex. Applied to EVERY cell as an extra layer.
161
- function cellHitsBackstop(cell) {
162
- if (typeof cell !== "string" || !cell) return false;
163
- if (looksLikeSecretValue(cell)) return true;
164
- if (GATE_EMBEDDED_CRED.test(cell)) return true;
165
- return false;
166
- }
167
-
168
- // PRIMARY GUARD: is this cell the POSITIVE shape its column is supposed to hold?
169
- // Returns true if the cell is VALID for its column. An empty cell / placeholder
170
- // (`—`) is always valid. `col` is the column NAME.
171
- function cellMatchesColumnShape(col, cell) {
172
- if (typeof cell !== "string") return true;
173
- const s = cell.trim();
174
- if (s === "" || s === "—") return true;
175
- switch (col) {
176
- case "id":
177
- // <scope>-<kind> — lowercase identifier with a hyphen.
178
- return /^[a-z0-9][a-z0-9-]*$/.test(s);
179
- case "scope":
180
- return s === "local" || s === "staging" || s === "prod";
181
- case "kind":
182
- return /^[a-z0-9][a-z0-9-]*$/.test(s);
183
- case "host":
184
- return gateIsHostShape(s.replace(/:\d+$/, ""));
185
- case "port":
186
- return /^\d+$/.test(s);
187
- case "db/name":
188
- return gateIsDbNameShape(s);
189
- case "auth method":
190
- return GATE_AUTH_METHODS.has(s.toLowerCase());
191
- case "secret vault":
192
- return GATE_VAULTS.has(s.toLowerCase());
193
- case "secret env-var NAME":
194
- return GATE_UPPER_SNAKE.test(s);
195
- case "fetch command":
196
- case "connect command":
197
- return gateCommandOk(s);
198
- case "access gotchas":
199
- return gateGotchasOk(s);
200
- case "read-only default":
201
- return s === "YES" || s === "NO";
202
- case "recorded":
203
- return GATE_ISO_TS.test(s);
204
- default:
205
- // Unknown column — fall back to refusing anything the backstop flags.
206
- return !cellHitsBackstop(s);
207
- }
208
- }
209
-
210
- // The gate's leak test for a cell in a KNOWN column: FAIL if it is NOT the
211
- // column's positive shape OR (backstop) it hits the known-prefix/embedded-cred
212
- // detector. The positive shape is the PRIMARY guard.
213
- function cellLeaks(col, cell) {
214
- if (typeof cell !== "string" || !cell) return false;
215
- if (!cellMatchesColumnShape(col, cell)) return true; // primary: wrong shape
216
- if (cellHitsBackstop(cell)) return true; // backstop: extra layer
217
- return false;
218
- }
219
-
220
- function readSafe(p) {
221
- try {
222
- return fs.readFileSync(p, "utf8");
223
- } catch (_) {
224
- return null;
225
- }
226
- }
227
-
228
- function splitRow(line) {
229
- const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
230
- const cells = [];
231
- let cur = "";
232
- for (let i = 0; i < trimmed.length; i++) {
233
- const ch = trimmed[i];
234
- if (ch === "\\" && trimmed[i + 1] === "|") {
235
- cur += "|";
236
- i++;
237
- } else if (ch === "|") {
238
- cells.push(cur.trim());
239
- cur = "";
240
- } else {
241
- cur += ch;
242
- }
243
- }
244
- cells.push(cur.trim());
245
- return cells;
246
- }
247
-
248
- function check(projectDir) {
249
- const infraPath = path.join(projectDir, "docs", "infrastructure.md");
250
- const claudePath = path.join(projectDir, "CLAUDE.md");
251
-
252
- const infra = readSafe(infraPath) || "";
253
- const claude = readSafe(claudePath) || "";
254
-
255
- const hasMarkers = infra.includes(ENV_MARKER_START) && infra.includes(ENV_MARKER_END);
256
- // The env-access rule is identified by its stable marker phrase.
257
- const hasRule = /Environment Access — read-first, HALT-and-document/.test(claude);
258
-
259
- const failures = [];
260
-
261
- // (b) rule present but table markers absent.
262
- if (hasRule && !hasMarkers) {
263
- failures.push(
264
- "env-access rule is present in CLAUDE.md but the `## Environments` table markers are absent from docs/infrastructure.md"
265
- );
266
- }
267
-
268
- // (a) secret-shaped value in any row cell.
269
- if (hasMarkers) {
270
- const start = infra.indexOf(ENV_MARKER_START);
271
- const end = infra.indexOf(ENV_MARKER_END);
272
- const block = infra.slice(start, end);
273
- const lines = block.split("\n");
274
- for (const line of lines) {
275
- if (!line.trim().startsWith("|")) continue;
276
- const cells = splitRow(line);
277
- if (cells[0] === "id") continue; // header
278
- if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue; // separator
279
- for (let i = 0; i < cells.length; i++) {
280
- const col = ENV_COLUMNS[i] || `col${i}`;
281
- if (cellLeaks(col, cells[i])) {
282
- failures.push(
283
- `Environments row cell (${col}) contains a secret-shaped literal value: "${cells[i]}" — record the env-var NAME and a $VAR reference, never a literal secret`
284
- );
285
- }
286
- }
287
- }
288
- }
289
-
290
- return {
291
- ok: failures.length === 0,
292
- check: "env-registry",
293
- hasMarkers,
294
- hasRule,
295
- failures,
296
- note:
297
- !hasMarkers && !hasRule
298
- ? "no-op PASS: project has not adopted the M102 Environments registry (no table, no rule)"
299
- : undefined,
300
- };
301
- }
302
-
303
- function parseArgs(argv) {
304
- const out = { projectDir: "." };
305
- for (let i = 0; i < argv.length; i++) {
306
- if (argv[i] === "--project") out.projectDir = argv[++i] || ".";
307
- }
308
- return out;
309
- }
310
-
311
- module.exports = { check };
312
-
313
- if (require.main === module) {
314
- const { projectDir } = parseArgs(process.argv.slice(2));
315
- const result = check(projectDir);
316
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
317
- process.exit(result.ok ? 0 : 1);
318
- }