@hegemonart/get-design-done 1.60.0 → 1.60.2

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Get Design Done — 5-stage agent-orchestrated design pipeline (Brief → Explore → Plan → Design → Verify) for AI coding agents. 64 agents, 96 skills, 39 connection integrations, two MCP servers, opt-in SQLite state backbone, bidirectional Figma write-back, and a reflector-driven self-improvement loop. Cross-runtime install for Claude Code, Codex, Cursor, OpenCode, Gemini, and more.",
8
- "version": "1.60.0"
8
+ "version": "1.60.2"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "get-design-done",
13
13
  "source": "./",
14
14
  "description": "Agent-orchestrated 5-stage design pipeline (Brief → Explore → Plan → Design → Verify) for AI coding agents. 64 specialized agents, 96 skills, 39 connection integrations (Figma, Refero, Preview, Storybook, Chromatic, Graphify, Linear, Jira, Notion, …), bidirectional Figma write-back, queryable intel store, opt-in SQLite state backbone, and a reflector-driven self-improvement loop. Two MCP servers (gdd-state for typed STATE mutators, gdd-mcp for 13 read-only project-priming tools), tier-aware routing with cost telemetry, and defense-in-depth hooks (protected paths, MCP circuit breaker, injection scanner, budget enforcer). Cross-runtime install for Claude Code, Codex, Cursor, OpenCode, Gemini, Copilot, and more.",
15
- "version": "1.60.0",
15
+ "version": "1.60.2",
16
16
  "author": {
17
17
  "name": "hegemonart"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "get-design-done",
3
3
  "short_name": "gdd",
4
- "version": "1.60.0",
4
+ "version": "1.60.2",
5
5
  "description": "Agent-orchestrated 5-stage design pipeline (Brief → Explore → Plan → Design → Verify) for AI coding agents. 64 specialized agents, 96 skills, 39 connection integrations (Figma, Refero, Preview, Storybook, Chromatic, Graphify, Linear, Jira, Notion, …), bidirectional Figma write-back, queryable intel store for O(1) design-surface lookups, opt-in SQLite state backbone, and a reflector-driven self-improvement loop. Two MCP servers (`gdd-state` for typed STATE mutators, `gdd-mcp` for 13 read-only project-priming tools), tier-aware agent routing with cost telemetry, defense-in-depth hooks (protected paths, MCP circuit breaker, injection scanner, budget enforcer), and a cross-runtime install layer for Claude Code, Codex, Cursor, OpenCode, Gemini, Copilot, and more.",
6
6
  "author": {
7
7
  "name": "hegemonart",
package/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@ All notable changes to get-design-done are documented here. Versions follow [sem
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.60.2] - 2026-06-13
8
+
9
+ **Security & CI hardening** - bring the SAST/dependency-audit gates the project lacked, and close the one untrusted-link gap in the injection scanner, *before* the detection engine lands its large new surface. Sourced from a reconciliation against the upstream framework's recent releases (`.planning/audits/UPSTREAM-GSD-CORE-DIFF-2026-06-13.md`).
10
+
11
+ ### Added
12
+
13
+ - **CodeQL / SAST workflow** (`.github/workflows/codeql.yml`) - `javascript-typescript` with the `security-extended` query suite, on push / PR / weekly schedule. Analysis-only (alerts surface in the Security tab; the job is non-blocking on pre-existing findings). A regression guard for every future PR, established now on a clean tree.
14
+ - **`npm audit` production gate in CI** - `npm audit --omit=dev --audit-level=high` in the security job. Scoped to production dependencies (what ships to consumers); dev-only advisories don't gate the build. Currently green.
15
+ - **Dangerous-link scheme detection in the injection scanner** - `scripts/injection-patterns.cjs` now flags `javascript:` URIs, `data:text/html` / `data:` script payloads, userinfo-credential URLs (`scheme://user:pass@host`), and secret-bearing query params (cross-referenced to the redaction token shapes). These flow from untrusted markdown read by the Read hook and the design-authority watcher's feed ingest, which previously had zero coverage for them. Pattern count 21 to 27; both the runtime hook and the CI scanner auto-consume the new patterns. Tightly anchored with negative fixtures so legitimate `https://`, `mailto:`, `data:image`, and ordinary query strings are not flagged.
16
+
17
+ ### Changed
18
+
19
+ - **`hono` override `>=4.12.23`** added to `package.json` (transitive via `@modelcontextprotocol/sdk`). Precautionary / future-proofing - our `npm audit` does not currently flag the resolved `hono@4.12.21`; this pins the dependency forward regardless. Resolves to `4.12.25`.
20
+
21
+ ### Breaking changes
22
+
23
+ None.
24
+
25
+ 5,143/5,143 tests pass.
26
+
27
+ ---
28
+
29
+ ## [1.60.1] - 2026-06-10
30
+
31
+ **Security hardening** - two HIGH-severity vulnerabilities closed before the upcoming rebrand copies the foundation layer across every runtime. Both were reachable by a prompt-injected agent, undercutting the trust boundary the plugin's own scanners exist to defend. Each fix ships with failing-then-passing regression tests; an independent adversarial audit confirmed both vectors are dead with no surviving bypass.
32
+
33
+ ### Security
34
+
35
+ - **Path traversal in the `gdd_intel_get` MCP tool (HARDEN-01).** A `slice_id` carrying `../`, an absolute path, or a path separator flowed unsanitized into a file read and could escape `.design/intel/` to return any `.json` file's contents (cloud credentials, config) to the caller. The intel store now rejects any non-basename `slice_id` and enforces a `resolve` + `startsWith(root + sep)` containment check before touching disk. Separately, the `gdd-mcp` server **now validates every `tools/call` argument against the advertised per-tool JSON schema** at the dispatcher - previously raw arguments were passed straight to handlers with no validation.
36
+ - **Protected-paths guard canonicalization bypass (HARDEN-02).** The hook that blocks edits to `hooks/**`, `skills/**`, `.git/**`, and `.claude/settings.json` matched a non-canonical path string, so a Windows forward-slash absolute path (`C:/…`) or a `../<cwd-basename>/…` relative re-entry slipped past the block. The guard now canonicalizes candidates with `path.resolve` + `path.relative` (forward-slash drive letters included), mandatorily resolves the nearest existing ancestor with `realpathSync` to catch symlinked-ancestor-of-a-new-file escapes, and folds case on Windows/macOS. The two bypass vectors (previously untested) now have explicit regression coverage.
37
+
38
+ ### Breaking changes
39
+
40
+ None.
41
+
42
+ 5,139/5,139 tests pass.
43
+
44
+ ---
45
+
7
46
  ## [1.60.0] - 2026-06-10
8
47
 
9
48
  **Foundation & Honesty** - the subtract-first base the v2.0 work and the upcoming rebrand depend on. Make the catalog enumerable and the capability claims machine-checked *before* building or renaming anything. A pre-flight audit found the catalog already clean (0 content-duplicate skills, 0 orphan skills or agents, a perfect manifest to template to generated bijection, and every count and capability claim already tracing to source), so this release does not manufacture cuts - it removes genuinely dead code and adds the guards that lock the clean state in.
@@ -38,7 +38,75 @@ function findPackageRoot(startDir) {
38
38
 
39
39
  const REPO_ROOT = findPackageRoot(__dirname) || path.resolve(__dirname, '..');
40
40
 
41
- const { matches } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'glob-match.cjs'));
41
+ const { matches, defaultNocase } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'glob-match.cjs'));
42
+
43
+ /**
44
+ * HARDEN-02: Canonicalize a candidate path to a cwd-relative form before glob
45
+ * matching, defeating equivalent spellings of a protected file:
46
+ * - POSIX absolute `/abs/cwd/hooks/x.js`
47
+ * - backslash drive `C:\cwd\hooks\x.js`
48
+ * - forward-slash drive `C:/cwd/hooks/x.js` (was the bypass — backslash-only detector)
49
+ * - `../<cwd-basename>/hooks/x.js` reentry (was the bypass — raw string never matched)
50
+ * - symlink / symlinked ANCESTOR redirection into a protected dir (incl. NEW files)
51
+ *
52
+ * Returns a forward-slash cwd-relative string for IN-cwd targets, or the
53
+ * sentinel `null` for targets that resolve OUTSIDE cwd (out-of-repo edits are
54
+ * not this guard's concern and must not be false-blocked).
55
+ */
56
+ function canonicalizeCandidate(cand, cwd) {
57
+ // 1. Recognize absolute paths robustly across platforms. `path.isAbsolute`
58
+ // on a backslash-normalized copy catches POSIX `/…` and native drive
59
+ // paths; the drive-letter regex is the Windows-on-POSIX fallback so a
60
+ // `C:/…` / `C:\…` spelling is treated as absolute even when the test
61
+ // process runs on Linux.
62
+ const normalized = cand.replace(/\\/g, '/');
63
+ const isAbs = path.isAbsolute(cand)
64
+ || path.isAbsolute(normalized)
65
+ || /^[A-Za-z]:[\\/]/.test(cand);
66
+
67
+ const abs = isAbs ? normalized : path.resolve(cwd, cand);
68
+
69
+ // 2. Canonicalize through symlinks — MANDATORY, for existing AND new targets.
70
+ // Full-path realpath throws (ENOENT) on a not-yet-existing write target,
71
+ // so walk UP to the nearest existing ancestor, realpath THAT, then re-join
72
+ // the non-existent tail. This resolves a symlinked ancestor dir of a new
73
+ // file (the write-new-file symlink bypass). Any unexpected I/O error falls
74
+ // back to the plain resolved path — the hook must never hard-fail.
75
+ let canonicalAbs = abs;
76
+ try {
77
+ canonicalAbs = fs.realpathSync(abs);
78
+ } catch (e) {
79
+ if (e && e.code === 'ENOENT') {
80
+ try {
81
+ let ancestor = path.dirname(abs);
82
+ const tail = [path.basename(abs)];
83
+ // Walk up until an existing ancestor is found (or filesystem root).
84
+ // Guard the loop against an unbounded climb.
85
+ for (let i = 0; i < 64; i++) {
86
+ if (fs.existsSync(ancestor)) break;
87
+ const parent = path.dirname(ancestor);
88
+ if (parent === ancestor) break;
89
+ tail.unshift(path.basename(ancestor));
90
+ ancestor = parent;
91
+ }
92
+ const realAncestor = fs.realpathSync(ancestor);
93
+ canonicalAbs = path.join(realAncestor, ...tail);
94
+ } catch {
95
+ canonicalAbs = abs;
96
+ }
97
+ } else {
98
+ canonicalAbs = abs;
99
+ }
100
+ }
101
+
102
+ // 3. cwd-relative canonical form.
103
+ const rel = path.relative(cwd, canonicalAbs).replace(/\\/g, '/');
104
+
105
+ // 4. Out-of-cwd sentinel — these are NOT matched against repo-internal globs.
106
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) return null;
107
+
108
+ return rel;
109
+ }
42
110
 
43
111
  function loadProtectedPaths(cwd) {
44
112
  const defaultFile = path.join(REPO_ROOT, 'reference', 'protected-paths.default.json');
@@ -248,10 +316,10 @@ async function main() {
248
316
 
249
317
  for (const cand of candidates) {
250
318
  if (!cand) continue;
251
- const rel = cand.startsWith('/') || /^[A-Z]:\\/i.test(cand)
252
- ? path.relative(cwd, cand).replace(/\\/g, '/')
253
- : cand.replace(/\\/g, '/');
254
- const r = matches(rel, protectedPaths);
319
+ const rel = canonicalizeCandidate(cand, cwd);
320
+ // Out-of-cwd targets (sentinel null) are not this guard's concern.
321
+ if (rel === null) continue;
322
+ const r = matches(rel, protectedPaths, { nocase: defaultNocase() });
255
323
  if (r.matched) {
256
324
  try {
257
325
  require('./_hook-emit.js').emitHookFired('gdd-protected-paths', 'block', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hegemonart/get-design-done",
3
- "version": "1.60.0",
3
+ "version": "1.60.2",
4
4
  "description": "A design-quality pipeline for AI coding agents: brief, explore, plan, design, and verify UI work against your design system.",
5
5
  "author": "Hegemon",
6
6
  "homepage": "https://github.com/hegemonart/get-design-done",
@@ -156,6 +156,7 @@
156
156
  },
157
157
  "overrides": {
158
158
  "fast-json-patch": "^3.1.1",
159
+ "hono": ">=4.12.23",
159
160
  "qs": ">=6.15.2"
160
161
  }
161
162
  }
@@ -41,6 +41,53 @@ const INJECTION_PATTERNS = [
41
41
  { name: 'tar-home-netcat', re: /\btar\s+c[fzvj]+\s+-\s+~[^\n]*\|\s*(nc|ssh|curl)/ },
42
42
  { name: 'env-dot-leak', re: /process\.env\.[A-Z][A-Z0-9_]*_(KEY|TOKEN|SECRET)\s*[^;,\n]*(fetch|axios|XMLHttpRequest|http\.request)/ },
43
43
  { name: 'ssh-key-cat', re: /\bcat\s+~?\/?\.ssh\/id_(rsa|ed25519|ecdsa|dsa)\b/ },
44
+
45
+ // ── dangerous URL schemes + credential links (60.2 / SEC-CI-03) ──────
46
+ // These flow from untrusted markdown (Read hook) and RSS/article ingest.
47
+ // Each regex is anchored tightly to avoid false-positives on the repo's
48
+ // own shipped docs (the CI scan:injection gate scans them) and is
49
+ // linear-time (bounded quantifiers, no nested/overlapping repetition).
50
+
51
+ // `javascript:` used as a link/href target. The colon must be directly
52
+ // followed by a non-whitespace payload char — so prose like "JavaScript:"
53
+ // (a sentence colon, followed by a space) and the bare word "JavaScript"
54
+ // do NOT match. Preceded by start-of-string or a non-word char so it
55
+ // anchors on `](javascript:` / `href="javascript:` / `=javascript:`.
56
+ { name: 'javascript: uri', re: /(?:^|[^\w])javascript:(?=\S)/i },
57
+
58
+ // `data:text/html` URIs (optional ;base64). Will NOT match `data:image/…`,
59
+ // nor `data: <word>` prose (colon-space): the literal `text/html` is required.
60
+ { name: 'data:text/html uri', re: /\bdata:text\/html\b/i },
61
+
62
+ // `data:` URI carrying a script payload (covers data: media types beyond
63
+ // text/html). `[^\s<]{0,200}` is a bounded run (no ReDoS) that also
64
+ // EXCLUDES `<`, so it cannot reach across a `<script` that appears BEFORE
65
+ // `data:` on the line (e.g. export-formats.md:27 "…<script> … data: URIs"):
66
+ // there the char after `data:` is a backtick+space, the run stops at the
67
+ // space, and no script marker follows. Requires `data:` to be immediately
68
+ // followed (no space) by payload chars that lead into `<script`/`%3Cscript`.
69
+ { name: 'data: script payload', re: /data:[^\s<]{0,200}(?:<script|%3Cscript)/i },
70
+
71
+ // userinfo-credential URL: `scheme://user:pass@host`. The `:` must appear
72
+ // in the userinfo segment BEFORE the `@`, and both must precede the first
73
+ // `/` of the path (i.e. inside the authority). Mutually-exclusive char
74
+ // classes on the boundary chars keep it linear. Does NOT match
75
+ // `mailto:user@host` (no `://`), bare `user@host` (no `://`), a plain
76
+ // `https://host/path` (no `@`), nor an `@` that appears only in the path.
77
+ { name: 'userinfo credentials url', re: /:\/\/[^/\s:@]+:[^/\s@]*@/ },
78
+
79
+ // Secret-bearing query param. Two linear alternatives:
80
+ // (a) a query KEY named like a credential (token/api_key/secret/…)
81
+ // followed by `=` and a non-trivial value; OR
82
+ // (b) any query value matching a redact.cjs secret SHAPE
83
+ // (sk-ant-/sk-/jwt/AIza/ghp_/gh[sour]_/AKIA/xox…).
84
+ // `[^&\s#]+` and the shape bodies are bounded by their delimiters / fixed
85
+ // lengths — no catastrophic backtracking. Does NOT match benign params
86
+ // like `?q=`, `?lang=en`, `?sort=desc`, `?page=2`.
87
+ {
88
+ name: 'secret-bearing query param',
89
+ re: /[?&](?:access_token|client_secret|api[_-]?key|apikey|token|secret|password|auth)=[^&\s#]+|[?&][\w-]{1,40}=(?:sk-ant-[\w-]{20,}|sk-[\w-]{20,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}|AIza[\w-]{35}|ghp_[A-Za-z0-9]{36,}|gh[sour]_[A-Za-z0-9]{36,}|AKIA[0-9A-Z]{16}|xox[baprs]-[\w-]{10,})/i,
90
+ },
44
91
  ];
45
92
 
46
93
  /**
@@ -3,9 +3,24 @@
3
3
  * scripts/lib/glob-match.cjs — tiny dependency-free glob matcher.
4
4
  * Supports: **, *, ?, and literal segments. Not a full minimatch implementation,
5
5
  * but covers the patterns used in reference/protected-paths.default.json.
6
+ *
7
+ * Case-sensitivity tracks the host filesystem by default: case-INsensitive on
8
+ * win32/darwin (so `HOOKS/x` matches `hooks/**`), case-sensitive elsewhere.
9
+ * Callers can override via `opts.nocase` (used by the protected-paths suite to
10
+ * exercise BOTH branches explicitly on a case-sensitive Linux CI runner).
6
11
  */
7
12
 
8
- function globToRegex(glob) {
13
+ /**
14
+ * The platform-derived default for case-insensitive matching. Exposed so the
15
+ * protected-paths hook and the tests reference the SAME decision rather than
16
+ * duplicating the win32||darwin check.
17
+ */
18
+ function defaultNocase() {
19
+ return process.platform === 'win32' || process.platform === 'darwin';
20
+ }
21
+
22
+ function globToRegex(glob, opts = {}) {
23
+ const nocase = opts.nocase !== undefined ? opts.nocase : defaultNocase();
9
24
  // Normalize separators
10
25
  const g = glob.replace(/\\/g, '/');
11
26
  let re = '^';
@@ -42,16 +57,18 @@ function globToRegex(glob) {
42
57
  i++;
43
58
  }
44
59
  re += '$';
45
- return new RegExp(re);
60
+ // Use the `i` flag for case-insensitivity rather than lowercasing inputs,
61
+ // which would corrupt the returned `pattern` string callers rely on.
62
+ return new RegExp(re, nocase ? 'i' : '');
46
63
  }
47
64
 
48
- function matches(filepath, globList) {
65
+ function matches(filepath, globList, opts = {}) {
49
66
  const norm = String(filepath).replace(/\\/g, '/').replace(/^\.\//, '');
50
67
  for (const g of globList) {
51
- const re = globToRegex(g);
68
+ const re = globToRegex(g, opts);
52
69
  if (re.test(norm)) return { matched: true, pattern: g };
53
70
  }
54
71
  return { matched: false };
55
72
  }
56
73
 
57
- module.exports = { matches, globToRegex };
74
+ module.exports = { matches, globToRegex, defaultNocase };
Binary file
@@ -1860,12 +1860,45 @@ var require_intel_store = __commonJS({
1860
1860
  this.dir = dir;
1861
1861
  }
1862
1862
  };
1863
+ var IntelInvalidSliceIdError = class extends Error {
1864
+ constructor(sliceId) {
1865
+ const shown = typeof sliceId === "string" ? sliceId : String(sliceId);
1866
+ super("invalid slice_id: " + JSON.stringify(shown));
1867
+ this.name = "IntelInvalidSliceIdError";
1868
+ this.code = "invalid_slice_id";
1869
+ this.sliceId = sliceId;
1870
+ }
1871
+ };
1872
+ function validateSliceId(sliceId) {
1873
+ if (typeof sliceId !== "string" || sliceId.length === 0) {
1874
+ throw new IntelInvalidSliceIdError(sliceId);
1875
+ }
1876
+ if (sliceId.indexOf("\0") !== -1) {
1877
+ throw new IntelInvalidSliceIdError(sliceId);
1878
+ }
1879
+ if (sliceId.indexOf("/") !== -1 || sliceId.indexOf("\\") !== -1) {
1880
+ throw new IntelInvalidSliceIdError(sliceId);
1881
+ }
1882
+ if (sliceId === "." || sliceId === ".." || sliceId.includes("..")) {
1883
+ throw new IntelInvalidSliceIdError(sliceId);
1884
+ }
1885
+ if (path.isAbsolute(sliceId)) {
1886
+ throw new IntelInvalidSliceIdError(sliceId);
1887
+ }
1888
+ }
1863
1889
  async function readSlice2(rootDir, sliceId) {
1890
+ validateSliceId(sliceId);
1864
1891
  const dir = path.join(rootDir, ".design", "intel");
1865
1892
  if (!fs.existsSync(dir)) {
1866
1893
  throw new IntelNotFoundError(dir);
1867
1894
  }
1868
1895
  const file = path.join(dir, sliceId + ".json");
1896
+ const resolvedDir = path.resolve(dir);
1897
+ const resolvedFile = path.resolve(file);
1898
+ const withSep = resolvedDir.endsWith(path.sep) ? resolvedDir : resolvedDir + path.sep;
1899
+ if (!resolvedFile.startsWith(withSep)) {
1900
+ throw new IntelInvalidSliceIdError(sliceId);
1901
+ }
1869
1902
  if (!fs.existsSync(file)) return null;
1870
1903
  const body = await fs.promises.readFile(file, "utf8");
1871
1904
  return JSON.parse(body);
@@ -1883,7 +1916,13 @@ var require_intel_store = __commonJS({
1883
1916
  }
1884
1917
  return ids;
1885
1918
  }
1886
- module2.exports = { readSlice: readSlice2, listSlices, IntelNotFoundError };
1919
+ module2.exports = {
1920
+ readSlice: readSlice2,
1921
+ listSlices,
1922
+ validateSliceId,
1923
+ IntelNotFoundError,
1924
+ IntelInvalidSliceIdError
1925
+ };
1887
1926
  }
1888
1927
  });
1889
1928
 
@@ -2035,6 +2074,7 @@ __export(server_exports, {
2035
2074
  module.exports = __toCommonJS(server_exports);
2036
2075
  var import_node_fs5 = require("node:fs");
2037
2076
  var import_node_path5 = require("node:path");
2077
+ var import_ajv = __toESM(require("ajv"));
2038
2078
  var import_server = require("@modelcontextprotocol/sdk/server/index.js");
2039
2079
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
2040
2080
  var import_types6 = require("@modelcontextprotocol/sdk/types.js");
@@ -3427,6 +3467,24 @@ function buildServer() {
3427
3467
  const tools = loadTools();
3428
3468
  const byName = /* @__PURE__ */ new Map();
3429
3469
  for (const t of tools) byName.set(t.name, t);
3470
+ const ajv = new import_ajv.default({ allErrors: true, strict: false });
3471
+ const PASS_THROUGH = (() => {
3472
+ const v = (() => true);
3473
+ v.errors = null;
3474
+ return v;
3475
+ })();
3476
+ const validators = /* @__PURE__ */ new Map();
3477
+ for (const t of tools) {
3478
+ try {
3479
+ validators.set(t.name, ajv.compile(t.inputSchema));
3480
+ } catch (err) {
3481
+ const msg = err instanceof Error ? err.message : String(err);
3482
+ console.error(
3483
+ `[gdd-mcp] schema compile failed for ${t.name}; tool degraded to permissive validation: ${msg}`
3484
+ );
3485
+ validators.set(t.name, PASS_THROUGH);
3486
+ }
3487
+ }
3430
3488
  const server = new import_server.Server(
3431
3489
  { name: SERVER_NAME, version: SERVER_VERSION },
3432
3490
  {
@@ -3469,6 +3527,26 @@ function buildServer() {
3469
3527
  structuredContent: { success: false, error: payload.error }
3470
3528
  };
3471
3529
  }
3530
+ const validate = validators.get(toolName);
3531
+ if (validate !== void 0) {
3532
+ const argsObj = args ?? {};
3533
+ if (!validate(argsObj)) {
3534
+ const detail = ajv.errorsText(validate.errors, { dataVar: "input" });
3535
+ const payload = toToolError(
3536
+ new Error(`input validation failed: ${detail}`)
3537
+ );
3538
+ return {
3539
+ isError: true,
3540
+ content: [
3541
+ {
3542
+ type: "text",
3543
+ text: JSON.stringify({ success: false, error: payload.error })
3544
+ }
3545
+ ],
3546
+ structuredContent: { success: false, error: payload.error }
3547
+ };
3548
+ }
3549
+ }
3472
3550
  let response;
3473
3551
  try {
3474
3552
  response = await tool.handle(args ?? {});
@@ -38,6 +38,8 @@
38
38
  import { readFileSync, existsSync } from 'node:fs';
39
39
  import { dirname, join, resolve } from 'node:path';
40
40
 
41
+ import Ajv, { type ValidateFunction } from 'ajv';
42
+
41
43
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
42
44
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
43
45
  import {
@@ -186,6 +188,33 @@ export function buildServer(): Server {
186
188
  const byName: Map<string, LoadedTool> = new Map();
187
189
  for (const t of tools) byName.set(t.name, t);
188
190
 
191
+ // HARDEN-01 (Task 2): compile an ajv validator per tool from its advertised
192
+ // input JSON Schema, so every tools/call argument is validated against the
193
+ // tool's contract BEFORE the handler runs. `strict:false` tolerates the
194
+ // Draft-07 keywords our schemas use; `allErrors` yields complete messages.
195
+ const ajv = new Ajv({ allErrors: true, strict: false });
196
+ const PASS_THROUGH: ValidateFunction = (() => {
197
+ const v = (() => true) as ValidateFunction;
198
+ v.errors = null;
199
+ return v;
200
+ })();
201
+ const validators: Map<string, ValidateFunction> = new Map();
202
+ for (const t of tools) {
203
+ try {
204
+ validators.set(t.name, ajv.compile(t.inputSchema));
205
+ } catch (err) {
206
+ // A single malformed schema file must not brick the whole server: fall
207
+ // back to a permissive validator for THAT tool only (today's no-
208
+ // validation behavior). The other tools still enforce (T-60.1-04).
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ // eslint-disable-next-line no-console
211
+ console.error(
212
+ `[gdd-mcp] schema compile failed for ${t.name}; tool degraded to permissive validation: ${msg}`,
213
+ );
214
+ validators.set(t.name, PASS_THROUGH);
215
+ }
216
+ }
217
+
189
218
  const server = new Server(
190
219
  { name: SERVER_NAME, version: SERVER_VERSION },
191
220
  {
@@ -237,6 +266,30 @@ export function buildServer(): Server {
237
266
  };
238
267
  }
239
268
 
269
+ // HARDEN-01 (Task 2): validate arguments against the tool's advertised
270
+ // input schema BEFORE the handler runs. A schema-invalid call returns a
271
+ // structured isError result and the handler is NEVER reached.
272
+ const validate = validators.get(toolName);
273
+ if (validate !== undefined) {
274
+ const argsObj = args ?? {};
275
+ if (!validate(argsObj)) {
276
+ const detail = ajv.errorsText(validate.errors, { dataVar: 'input' });
277
+ const payload = toToolError(
278
+ new Error(`input validation failed: ${detail}`),
279
+ );
280
+ return {
281
+ isError: true,
282
+ content: [
283
+ {
284
+ type: 'text' as const,
285
+ text: JSON.stringify({ success: false, error: payload.error }),
286
+ },
287
+ ],
288
+ structuredContent: { success: false, error: payload.error },
289
+ };
290
+ }
291
+ }
292
+
240
293
  let response;
241
294
  try {
242
295
  response = await tool.handle(args ?? {});