@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
@@ -11,6 +11,7 @@
11
11
  // This mirrors the invariant in the plan: "Tool errors are returned as
12
12
  // {success:false, error} — handlers never propagate exceptions."
13
13
 
14
+ import path from 'node:path';
14
15
  import {
15
16
  ValidationError,
16
17
  OperationFailedError,
@@ -50,17 +51,126 @@ export function getSessionId(): string {
50
51
  }
51
52
 
52
53
  /**
53
- * Resolve the target STATE.md path from the environment. Mirrors the
54
- * plan's contract: `process.env.GDD_STATE_PATH ?? .design/STATE.md`.
54
+ * Resolve the target STATE.md path from the environment, with a
55
+ * PATH-TRAVERSAL guard (Plan 33.5-03, D-08).
55
56
  *
56
- * Resolution is relative to `process.cwd()` when the env override is
57
- * missing or relative. Tests and the server both call this so the
58
- * resolution logic stays in one place.
57
+ * Resolution: `process.env.GDD_STATE_PATH ?? .design/STATE.md`. The
58
+ * path-traversal threat this guards against is a RELATIVE override that uses
59
+ * `..` to escape the project root — that is REJECTED with a
60
+ * `VALIDATION_STATE_PATH_ESCAPE` error. An ABSOLUTE override is an explicit
61
+ * operator choice (a relocated state file, a CI tmp `.design`) and is ALLOWED,
62
+ * normalized (D-08) — it is NOT rejected merely for living outside cwd (that
63
+ * would break legitimate operator overrides, and a realpath-based boundary
64
+ * check diverges across platforms, e.g. macOS /var → /private/var).
65
+ *
66
+ * Tests and the server both call this so the resolution logic stays in one
67
+ * place.
59
68
  */
60
69
  export function resolveStatePath(): string {
61
70
  const override = process.env['GDD_STATE_PATH'];
62
- if (typeof override === 'string' && override.length > 0) return override;
63
- return '.design/STATE.md';
71
+ if (typeof override !== 'string' || override.length === 0) {
72
+ return '.design/STATE.md';
73
+ }
74
+
75
+ // ABSOLUTE override = explicit operator choice → allow, normalized (D-08).
76
+ if (path.isAbsolute(override)) {
77
+ return path.resolve(override);
78
+ }
79
+
80
+ // RELATIVE override: resolve against the project root and REJECT any `..`
81
+ // traversal that escapes the boundary. In-boundary iff it equals root or
82
+ // sits beneath `root + sep`.
83
+ const root = path.resolve(process.cwd());
84
+ const resolved = path.resolve(root, override);
85
+ const withSep = root.endsWith(path.sep) ? root : root + path.sep;
86
+ if (resolved !== root && !resolved.startsWith(withSep)) {
87
+ throwValidation(
88
+ 'STATE_PATH_ESCAPE',
89
+ `GDD_STATE_PATH (relative) escapes the project boundary: ${override}`,
90
+ { raw: override, resolved, root },
91
+ );
92
+ }
93
+ return resolved;
94
+ }
95
+
96
+ /**
97
+ * Documented input limits for the gdd-state tools (Plan 33.5-03, D-08).
98
+ * Defends against JSON-bomb / memory-exhaustion inputs. The schema
99
+ * `maxLength` bounds are the declarative twin of MAX_STRING_LEN.
100
+ */
101
+ export const MAX_INPUT_BYTES = 64 * 1024; // 64 KiB serialized input cap
102
+ export const MAX_STRING_LEN = 8192; // longest single free-form string field
103
+ export const MAX_DEPTH = 32; // deepest object/array nesting
104
+
105
+ interface InputLimitOpts {
106
+ maxInputBytes?: number;
107
+ maxStringLen?: number;
108
+ maxDepth?: number;
109
+ }
110
+
111
+ /**
112
+ * Reject oversized / pathologically deep tool inputs BEFORE processing
113
+ * (Plan 33.5-03, D-08). Throws a `VALIDATION_INPUT_*` error on breach:
114
+ * - INPUT_TOO_LARGE — serialized JSON byte-size exceeds the cap
115
+ * - INPUT_FIELD_TOO_LARGE — a single string field exceeds MAX_STRING_LEN
116
+ * - INPUT_TOO_DEEP — object/array nesting exceeds MAX_DEPTH
117
+ * Handlers call this on their raw input; it is also unit-tested directly.
118
+ */
119
+ export function assertInputWithinLimits(
120
+ input: unknown,
121
+ opts?: InputLimitOpts,
122
+ ): void {
123
+ const maxBytes = opts?.maxInputBytes ?? MAX_INPUT_BYTES;
124
+ const maxStr = opts?.maxStringLen ?? MAX_STRING_LEN;
125
+ const maxDepth = opts?.maxDepth ?? MAX_DEPTH;
126
+
127
+ // Walk first so a deep/long field is caught even on huge inputs, bounding
128
+ // the depth so the walk itself cannot be turned into the attack.
129
+ const walk = (node: unknown, depth: number): void => {
130
+ if (depth > maxDepth) {
131
+ throwValidation(
132
+ 'INPUT_TOO_DEEP',
133
+ `Input nesting exceeds the maximum depth of ${maxDepth}`,
134
+ { maxDepth },
135
+ );
136
+ }
137
+ if (typeof node === 'string') {
138
+ if (node.length > maxStr) {
139
+ throwValidation(
140
+ 'INPUT_FIELD_TOO_LARGE',
141
+ `A string field exceeds the maximum length of ${maxStr}`,
142
+ { maxStringLen: maxStr, length: node.length },
143
+ );
144
+ }
145
+ return;
146
+ }
147
+ if (Array.isArray(node)) {
148
+ for (const item of node) walk(item, depth + 1);
149
+ return;
150
+ }
151
+ if (node !== null && typeof node === 'object') {
152
+ for (const value of Object.values(node as Record<string, unknown>)) {
153
+ walk(value, depth + 1);
154
+ }
155
+ }
156
+ };
157
+ walk(input, 0);
158
+
159
+ // Total serialized byte-size cap (JSON-bomb guard). Guard against a circular
160
+ // structure — JSON.stringify would throw; treat that as a rejectable input.
161
+ let bytes: number;
162
+ try {
163
+ bytes = Buffer.byteLength(JSON.stringify(input) ?? '');
164
+ } catch {
165
+ throwValidation('INPUT_TOO_LARGE', 'Input is not serializable JSON', {});
166
+ }
167
+ if (bytes > maxBytes) {
168
+ throwValidation(
169
+ 'INPUT_TOO_LARGE',
170
+ `Serialized input (${bytes} bytes) exceeds the maximum of ${maxBytes} bytes`,
171
+ { maxInputBytes: maxBytes, bytes },
172
+ );
173
+ }
64
174
  }
65
175
 
66
176
  /** Narrow helper: is this a well-known Stage string? */
@@ -15,6 +15,7 @@ import { read, transition } from '../../../state/index.ts';
15
15
  import { isStage, type Stage } from '../../../state/types.ts';
16
16
  import { TransitionGateFailed } from '../../../errors/index.ts';
17
17
  import {
18
+ assertInputWithinLimits,
18
19
  emitStateTransition,
19
20
  errorResponse,
20
21
  okResponse,
@@ -32,6 +33,7 @@ export interface TransitionStageInput {
32
33
 
33
34
  export async function handle(input: unknown): Promise<ToolResponse> {
34
35
  try {
36
+ assertInputWithinLimits(input);
35
37
  const typed = (input ?? {}) as TransitionStageInput;
36
38
  if (!isStage(typed.to)) {
37
39
  throwValidation(
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { mutate } from '../../../state/index.ts';
8
8
  import {
9
+ assertInputWithinLimits,
9
10
  emitStateMutation,
10
11
  errorResponse,
11
12
  okResponse,
@@ -35,6 +36,7 @@ const STATUSES = new Set([
35
36
 
36
37
  export async function handle(input: unknown): Promise<ToolResponse> {
37
38
  try {
39
+ assertInputWithinLimits(input);
38
40
  const typed = (input ?? {}) as UpdateProgressInput;
39
41
  if (typed.task_progress === undefined && typed.status === undefined) {
40
42
  throwValidation(