@hegemonart/get-design-done 1.60.0 → 1.60.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/hooks/gdd-protected-paths.js +73 -5
- package/package.json +1 -1
- package/scripts/lib/glob-match.cjs +22 -5
- package/scripts/lib/intel-store/index.cjs +0 -0
- package/sdk/mcp/gdd-mcp/server.js +79 -1
- package/sdk/mcp/gdd-mcp/server.ts +53 -0
|
@@ -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.
|
|
8
|
+
"version": "1.60.1"
|
|
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.
|
|
15
|
+
"version": "1.60.1",
|
|
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.
|
|
4
|
+
"version": "1.60.1",
|
|
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,23 @@ All notable changes to get-design-done are documented here. Versions follow [sem
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.60.1] - 2026-06-10
|
|
8
|
+
|
|
9
|
+
**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.
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
### Breaking changes
|
|
17
|
+
|
|
18
|
+
None.
|
|
19
|
+
|
|
20
|
+
5,139/5,139 tests pass.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
7
24
|
## [1.60.0] - 2026-06-10
|
|
8
25
|
|
|
9
26
|
**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
|
|
252
|
-
|
|
253
|
-
|
|
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.
|
|
3
|
+
"version": "1.60.1",
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 ?? {});
|