@hegemonart/get-design-done 1.33.0 → 1.33.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +25 -0
  4. package/package.json +3 -1
  5. package/reference/gdd-runtime-audit.md +111 -0
  6. package/reference/gdd-threat-model.md +336 -0
  7. package/reference/registry.json +14 -0
  8. package/scripts/lib/peer-cli/acp-client.cjs +9 -1
  9. package/scripts/lib/peer-cli/asp-client.cjs +10 -1
  10. package/scripts/lib/peer-cli/sanitize-env.cjs +198 -0
  11. package/scripts/lib/redact.cjs +20 -1
  12. package/scripts/lib/transports/ws.cjs +67 -3
  13. package/sdk/mcp/gdd-state/schemas/add_blocker.schema.json +2 -0
  14. package/sdk/mcp/gdd-state/schemas/add_decision.schema.json +1 -0
  15. package/sdk/mcp/gdd-state/schemas/add_must_have.schema.json +1 -0
  16. package/sdk/mcp/gdd-state/schemas/checkpoint.schema.json +1 -0
  17. package/sdk/mcp/gdd-state/schemas/frontmatter_update.schema.json +1 -1
  18. package/sdk/mcp/gdd-state/schemas/get.schema.json +2 -1
  19. package/sdk/mcp/gdd-state/schemas/probe_connections.schema.json +2 -0
  20. package/sdk/mcp/gdd-state/schemas/resolve_blocker.schema.json +1 -0
  21. package/sdk/mcp/gdd-state/server.js +137 -48
  22. package/sdk/mcp/gdd-state/tools/add_blocker.ts +2 -0
  23. package/sdk/mcp/gdd-state/tools/add_decision.ts +2 -0
  24. package/sdk/mcp/gdd-state/tools/add_must_have.ts +2 -0
  25. package/sdk/mcp/gdd-state/tools/checkpoint.ts +2 -0
  26. package/sdk/mcp/gdd-state/tools/frontmatter_update.ts +2 -0
  27. package/sdk/mcp/gdd-state/tools/get.ts +2 -0
  28. package/sdk/mcp/gdd-state/tools/probe_connections.ts +2 -0
  29. package/sdk/mcp/gdd-state/tools/resolve_blocker.ts +2 -0
  30. package/sdk/mcp/gdd-state/tools/set_status.ts +2 -0
  31. package/sdk/mcp/gdd-state/tools/shared.ts +117 -7
  32. package/sdk/mcp/gdd-state/tools/transition_stage.ts +2 -0
  33. package/sdk/mcp/gdd-state/tools/update_progress.ts +2 -0
@@ -0,0 +1,198 @@
1
+ // scripts/lib/peer-cli/sanitize-env.cjs
2
+ //
3
+ // Plan 33.5-04 — peer-CLI environment sandbox (SC#4; CONTEXT D-03).
4
+ //
5
+ // ============================================================================
6
+ // WHY THIS EXISTS
7
+ // ============================================================================
8
+ //
9
+ // The two peer-CLI clients (acp-client.cjs / asp-client.cjs) spawn external
10
+ // peer binaries (Gemini / Cursor / Copilot / Qwen / Codex) over stdio. Before
11
+ // this module, both clients defaulted the child's environment to the FULL
12
+ // `process.env` whenever the caller did not supply `opts.env` (acp line ~102,
13
+ // asp line ~122). That leaks GDD's own secrets — ANTHROPIC_API_KEY, GH_TOKEN,
14
+ // any GDD_* var — into every spawned peer, even though peers authenticate with
15
+ // their OWN logged-in credentials and have no need for GDD's keys.
16
+ //
17
+ // D-03 (locked) makes the sandbox ALLOWLIST-FORWARD / DEFAULT-DENY: the child
18
+ // env is built from (a) an OS-essential baseline (just enough for a binary to
19
+ // launch on Windows + macOS + Linux) PLUS (b) an explicit caller allowlist read
20
+ // from `.design/config.json#peer_cli.env_allowlist`. Everything else is dropped.
21
+ // GDD secrets and any secret-shaped var are NEVER forwarded unless the operator
22
+ // explicitly allowlists them — a one-line escape hatch for the rare peer that
23
+ // genuinely needs an inherited provider key.
24
+ //
25
+ // No new runtime dependency (D-12): plain JS + a defensive config read that
26
+ // mirrors registry.cjs's `readEnabledPeers` idiom.
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ // ── OS-essential baseline ────────────────────────────────────────────────────
34
+ //
35
+ // Exact variable names a child process generally needs to *launch* and behave
36
+ // correctly across Windows + POSIX. Kept deliberately pragmatic: anything not
37
+ // here (and not explicitly allowlisted) is dropped. The test only pins that
38
+ // PATH + HOME survive, so this set can evolve without breaking the contract.
39
+
40
+ const BASELINE = Object.freeze([
41
+ // PATH resolution (Windows uses `Path`; PATHEXT picks executable suffixes).
42
+ 'PATH',
43
+ 'Path',
44
+ 'PATHEXT',
45
+ // Home / profile (POSIX HOME; Windows USERPROFILE + HOMEDRIVE/HOMEPATH).
46
+ 'HOME',
47
+ 'USERPROFILE',
48
+ 'HOMEDRIVE',
49
+ 'HOMEPATH',
50
+ // System roots (Windows).
51
+ 'SystemRoot',
52
+ 'windir',
53
+ 'SystemDrive',
54
+ // Temp dirs (cross-platform variants).
55
+ 'TEMP',
56
+ 'TMP',
57
+ 'TMPDIR',
58
+ // Locale / shell.
59
+ 'LANG',
60
+ 'SHELL',
61
+ // Windows command interpreter + platform descriptors.
62
+ 'COMSPEC',
63
+ 'OS',
64
+ 'NUMBER_OF_PROCESSORS',
65
+ 'PROCESSOR_ARCHITECTURE',
66
+ ]);
67
+
68
+ // Documented baseline PREFIXES — any var whose name starts with one of these is
69
+ // treated as baseline (locale family + Node runtime knobs like NODE_OPTIONS).
70
+ const BASELINE_PREFIXES = Object.freeze(['LC_', 'NODE_']);
71
+
72
+ // ── Secret matchers (extra guard on the baseline) ─────────────────────────────
73
+ //
74
+ // SECRET_NAME — exact GDD-held secret variable names that must never leak.
75
+ // SECRET_PREFIX — any GDD_* var is GDD-internal and never forwarded.
76
+ // SECRET_SHAPE — generic secret-shaped suffixes; catches third-party keys a
77
+ // future baseline addition might otherwise let through.
78
+ //
79
+ // All three are overridden ONLY by an explicit entry in opts.allowlist
80
+ // (explicit allowlist WINS — see sanitizeEnv below).
81
+
82
+ const SECRET_NAME = Object.freeze([
83
+ 'ANTHROPIC_API_KEY',
84
+ 'GH_TOKEN',
85
+ 'GITHUB_TOKEN',
86
+ ]);
87
+
88
+ const SECRET_PREFIX = Object.freeze(['GDD_']);
89
+
90
+ const SECRET_SHAPE = /(_KEY|_TOKEN|_SECRET|_PASSWORD|_AUTH)$/i;
91
+
92
+ // ── Helpers ───────────────────────────────────────────────────────────────────
93
+
94
+ function isBaseline(key) {
95
+ if (BASELINE.includes(key)) return true;
96
+ for (const pfx of BASELINE_PREFIXES) {
97
+ if (key.startsWith(pfx)) return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ function isSecret(key) {
103
+ if (SECRET_NAME.includes(key)) return true;
104
+ for (const pfx of SECRET_PREFIX) {
105
+ if (key.startsWith(pfx)) return true;
106
+ }
107
+ return SECRET_SHAPE.test(key);
108
+ }
109
+
110
+ /**
111
+ * Defensively read `<cwd>/.design/config.json` and extract
112
+ * `peer_cli.env_allowlist` (a string[]). Returns [] on ANY failure path
113
+ * (file missing, unparsable, wrong shape) — never throws. Mirrors
114
+ * registry.cjs's `readEnabledPeers` idiom so both share a defensive reader.
115
+ *
116
+ * @param {string} [cwd] defaults to process.cwd()
117
+ * @returns {string[]} allowlisted env var names (deduped); empty by default
118
+ */
119
+ function readPeerCliAllowlist(cwd) {
120
+ const root = typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd();
121
+ const cfgPath = path.join(root, '.design', 'config.json');
122
+ let raw;
123
+ try {
124
+ raw = fs.readFileSync(cfgPath, 'utf8');
125
+ } catch {
126
+ return [];
127
+ }
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(raw);
131
+ } catch {
132
+ return [];
133
+ }
134
+ const peerCli = parsed && typeof parsed === 'object' ? parsed.peer_cli : null;
135
+ const list = peerCli && Array.isArray(peerCli.env_allowlist) ? peerCli.env_allowlist : [];
136
+ const out = [];
137
+ const seen = new Set();
138
+ for (const item of list) {
139
+ if (typeof item !== 'string' || item.length === 0) continue;
140
+ if (seen.has(item)) continue;
141
+ seen.add(item);
142
+ out.push(item);
143
+ }
144
+ return out;
145
+ }
146
+
147
+ // ── sanitizeEnv ─────────────────────────────────────────────────────────────--
148
+
149
+ /**
150
+ * Build a sanitized child environment (allowlist-forward / default-deny).
151
+ *
152
+ * For each KEY in sourceEnv, forward it iff:
153
+ * - KEY is explicitly in opts.allowlist (explicit allowlist WINS — even over
154
+ * the secret filters), OR
155
+ * - KEY is in the OS-essential BASELINE (exact name or a documented prefix)
156
+ * AND KEY is NOT a GDD secret / secret-shaped var.
157
+ *
158
+ * Everything else is dropped. Pure: never mutates the input.
159
+ *
160
+ * @param {Record<string,string>} [sourceEnv=process.env]
161
+ * @param {{ allowlist?: string[] }} [opts]
162
+ * @returns {Record<string,string>}
163
+ */
164
+ function sanitizeEnv(sourceEnv, opts) {
165
+ const src = sourceEnv && typeof sourceEnv === 'object' ? sourceEnv : process.env;
166
+ const o = opts && typeof opts === 'object' ? opts : {};
167
+ const allowlist = Array.isArray(o.allowlist) ? new Set(o.allowlist) : new Set();
168
+
169
+ const result = {};
170
+ for (const key of Object.keys(src)) {
171
+ const value = src[key];
172
+ // A value that is not a string (e.g. inherited prototype noise) is skipped;
173
+ // child env entries must be strings.
174
+ if (typeof value !== 'string') continue;
175
+
176
+ // Explicit allowlist wins over everything, including the secret filters.
177
+ if (allowlist.has(key)) {
178
+ result[key] = value;
179
+ continue;
180
+ }
181
+ // Otherwise the key must be baseline AND not secret-shaped.
182
+ if (isBaseline(key) && !isSecret(key)) {
183
+ result[key] = value;
184
+ }
185
+ // Default-deny: anything else is dropped.
186
+ }
187
+ return result;
188
+ }
189
+
190
+ module.exports = {
191
+ sanitizeEnv,
192
+ readPeerCliAllowlist,
193
+ BASELINE,
194
+ BASELINE_PREFIXES,
195
+ SECRET_NAME,
196
+ SECRET_PREFIX,
197
+ SECRET_SHAPE,
198
+ };
@@ -45,11 +45,30 @@ const PATTERNS = [
45
45
  type: 'slack',
46
46
  re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
47
47
  },
48
- // GitHub personal access token.
48
+ // GitHub personal access token (classic).
49
49
  {
50
50
  type: 'github_pat',
51
51
  re: /\bghp_[A-Za-z0-9]{36,}\b/g,
52
52
  },
53
+ // Google / Gemini / GCP API key (AIza…). Distinct shape — no collision
54
+ // with any existing pattern; placed with the specific patterns (D-07, 33.5-05).
55
+ {
56
+ type: 'gemini',
57
+ re: /\bAIza[0-9A-Za-z_\-]{35}\b/g,
58
+ },
59
+ // GitHub fine-grained PAT (github_pat_…). Distinct prefix from classic
60
+ // `ghp_` — both coexist (D-07, 33.5-05).
61
+ {
62
+ type: 'github_pat_fine_grained',
63
+ re: /\bgithub_pat_[0-9A-Za-z_]{22,}\b/g,
64
+ },
65
+ // GitHub server/oauth/user/refresh tokens (ghs_/gho_/ghu_/ghr_). The
66
+ // `[sour]` class excludes `p`, so this never collides with `ghp_` above
67
+ // (D-07, 33.5-05).
68
+ {
69
+ type: 'github_token',
70
+ re: /\bgh[sour]_[A-Za-z0-9]{36,}\b/g,
71
+ },
53
72
  // AWS access key ID.
54
73
  {
55
74
  type: 'aws',
@@ -24,7 +24,9 @@
24
24
  'use strict';
25
25
 
26
26
  const http = require('node:http');
27
+ const crypto = require('node:crypto');
27
28
  const { readFileSync, existsSync } = require('node:fs');
29
+ const path = require('node:path');
28
30
  const { probeOptional } = require('../probe-optional.cjs');
29
31
 
30
32
  const ws = probeOptional('ws');
@@ -56,16 +58,62 @@ function* readEventsSync(path) {
56
58
  }
57
59
  }
58
60
 
61
+ /**
62
+ * Defensively read `.design/config.json`. Returns the parsed object or `{}`
63
+ * on ANY failure (missing file, bad JSON, read error) — NEVER throws. The
64
+ * transport must still start when no config is present, so this mirrors the
65
+ * house defensive-fs idiom.
66
+ *
67
+ * @returns {Record<string, any>}
68
+ */
69
+ function readDesignConfig() {
70
+ try {
71
+ const cfgPath = path.join(process.cwd(), '.design', 'config.json');
72
+ if (!existsSync(cfgPath)) return {};
73
+ const parsed = JSON.parse(readFileSync(cfgPath, 'utf8'));
74
+ return parsed && typeof parsed === 'object' ? parsed : {};
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Resolve the bind host once, before listen (D-04). Order:
82
+ * opts.host → env GDD_WS_BIND_HOST → .design/config.json#event_stream.bind_host → '127.0.0.1'
83
+ * The DEFAULT (no opt, no env, no config) is loopback only — remote bind is
84
+ * an explicit operator opt-in.
85
+ *
86
+ * @param {{ host?: unknown }} opts
87
+ * @returns {string}
88
+ */
89
+ function resolveBindHost(opts) {
90
+ if (typeof opts.host === 'string' && opts.host.trim()) {
91
+ return opts.host.trim();
92
+ }
93
+ const envHost = process.env['GDD_WS_BIND_HOST'];
94
+ if (typeof envHost === 'string' && envHost.trim()) {
95
+ return envHost.trim();
96
+ }
97
+ const cfg = readDesignConfig();
98
+ const cfgHost =
99
+ cfg && cfg.event_stream && typeof cfg.event_stream.bind_host === 'string'
100
+ ? cfg.event_stream.bind_host.trim()
101
+ : '';
102
+ if (cfgHost) return cfgHost;
103
+ return '127.0.0.1';
104
+ }
105
+
59
106
  /**
60
107
  * Start the WebSocket server. Returns a handle with `close()`.
61
108
  *
62
109
  * @param {{
63
110
  * port: number,
64
111
  * token: string,
112
+ * host?: string,
65
113
  * tailFrom?: string,
66
114
  * subscribe?: (handler: (ev: unknown) => void) => () => void,
67
115
  * }} opts
68
- * @returns {Promise<{close: () => void, port: number}>}
116
+ * @returns {Promise<{close: () => void, port: number, host: string}>}
69
117
  */
70
118
  async function startServer(opts) {
71
119
  if (typeof opts.port !== 'number' || !Number.isFinite(opts.port)) {
@@ -75,6 +123,9 @@ async function startServer(opts) {
75
123
  throw new TypeError('startServer: token (string, ≥8 chars) required');
76
124
  }
77
125
 
126
+ // Resolve the bind host once (D-04): default 127.0.0.1 (loopback only).
127
+ const host = resolveBindHost(opts);
128
+
78
129
  const httpServer = http.createServer((_req, res) => {
79
130
  res.statusCode = 426; // Upgrade Required
80
131
  res.setHeader('Content-Type', 'text/plain');
@@ -109,7 +160,16 @@ async function startServer(opts) {
109
160
 
110
161
  httpServer.on('upgrade', (req, socket, head) => {
111
162
  const auth = req.headers['authorization'];
112
- if (!auth || auth !== `Bearer ${opts.token}`) {
163
+ const expected = `Bearer ${opts.token}`;
164
+ // Constant-time compare (D-04, D-12 node:crypto built-in). The length
165
+ // pre-check is REQUIRED — timingSafeEqual throws on a length mismatch —
166
+ // and is acceptable here because the secret is the TOKEN bytes, not its
167
+ // length. A missing/short/mismatched token still yields the 401 close.
168
+ const ok =
169
+ typeof auth === 'string' &&
170
+ Buffer.byteLength(auth) === Buffer.byteLength(expected) &&
171
+ crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(expected));
172
+ if (!ok) {
113
173
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
114
174
  socket.destroy();
115
175
  return;
@@ -142,12 +202,16 @@ async function startServer(opts) {
142
202
 
143
203
  await new Promise((resolve, reject) => {
144
204
  httpServer.once('error', reject);
145
- httpServer.listen(opts.port, () => resolve(undefined));
205
+ httpServer.listen(opts.port, host, () => resolve(undefined));
146
206
  });
147
207
 
148
208
  const addr = httpServer.address();
149
209
  return {
150
210
  port: typeof addr === 'object' && addr ? addr.port : opts.port,
211
+ host:
212
+ typeof addr === 'object' && addr && typeof addr.address === 'string'
213
+ ? addr.address
214
+ : host,
151
215
  close() {
152
216
  try {
153
217
  unsub();
@@ -15,10 +15,12 @@
15
15
  "text": {
16
16
  "type": "string",
17
17
  "minLength": 1,
18
+ "maxLength": 8192,
18
19
  "description": "Human-readable blocker description."
19
20
  },
20
21
  "stage": {
21
22
  "type": "string",
23
+ "maxLength": 64,
22
24
  "description": "Optional. Defaults to <position>.stage."
23
25
  },
24
26
  "date": {
@@ -15,6 +15,7 @@
15
15
  "text": {
16
16
  "type": "string",
17
17
  "minLength": 1,
18
+ "maxLength": 8192,
18
19
  "description": "Human-readable decision text."
19
20
  },
20
21
  "status": {
@@ -15,6 +15,7 @@
15
15
  "text": {
16
16
  "type": "string",
17
17
  "minLength": 1,
18
+ "maxLength": 8192,
18
19
  "description": "Human-readable must-have criterion."
19
20
  },
20
21
  "status": {
@@ -14,6 +14,7 @@
14
14
  "label": {
15
15
  "type": "string",
16
16
  "minLength": 1,
17
+ "maxLength": 256,
17
18
  "description": "Optional label for the timestamp entry key. When absent the key is `<stage>_checkpoint_at`."
18
19
  }
19
20
  }
@@ -17,7 +17,7 @@
17
17
  "minProperties": 1,
18
18
  "additionalProperties": {
19
19
  "oneOf": [
20
- { "type": "string" },
20
+ { "type": "string", "maxLength": 8192 },
21
21
  { "type": "number" },
22
22
  { "type": "boolean" }
23
23
  ]
@@ -13,7 +13,8 @@
13
13
  "properties": {
14
14
  "fields": {
15
15
  "type": "array",
16
- "items": { "type": "string", "minLength": 1 },
16
+ "maxItems": 64,
17
+ "items": { "type": "string", "minLength": 1, "maxLength": 256 },
17
18
  "description": "Optional projection. When present, limit data.state to these top-level keys. Unknown keys are ignored (no error)."
18
19
  }
19
20
  }
@@ -15,6 +15,7 @@
15
15
  "probe_results": {
16
16
  "type": "array",
17
17
  "minItems": 1,
18
+ "maxItems": 256,
18
19
  "items": {
19
20
  "type": "object",
20
21
  "additionalProperties": false,
@@ -23,6 +24,7 @@
23
24
  "name": {
24
25
  "type": "string",
25
26
  "minLength": 1,
27
+ "maxLength": 256,
26
28
  "description": "Connection name (e.g. \"figma\", \"refero\")."
27
29
  },
28
30
  "status": {
@@ -23,6 +23,7 @@
23
23
  "text": {
24
24
  "type": "string",
25
25
  "minLength": 1,
26
+ "maxLength": 8192,
26
27
  "description": "Exact text match against blocker.text. First matching row is removed. Mutually exclusive with index."
27
28
  }
28
29
  }