devassist-agent 1.0.0
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/LICENSE +21 -0
- package/README.md +467 -0
- package/bin/devassist.js +220 -0
- package/package.json +44 -0
- package/src/ai/adapter.js +464 -0
- package/src/ai/providers/claude.js +80 -0
- package/src/ai/providers/hunyuan.js +87 -0
- package/src/ai/providers/openai.js +74 -0
- package/src/ai/providers/qwen.js +81 -0
- package/src/cli/commands/ai.js +944 -0
- package/src/cli/commands/ask.js +79 -0
- package/src/cli/commands/backup.js +30 -0
- package/src/cli/commands/check.js +327 -0
- package/src/cli/commands/clean.js +130 -0
- package/src/cli/commands/comparison-report.js +326 -0
- package/src/cli/commands/convention.js +91 -0
- package/src/cli/commands/debt.js +49 -0
- package/src/cli/commands/deploy.js +88 -0
- package/src/cli/commands/diff.js +193 -0
- package/src/cli/commands/doctor.js +186 -0
- package/src/cli/commands/fix.js +195 -0
- package/src/cli/commands/init.js +431 -0
- package/src/cli/commands/inject.js +254 -0
- package/src/cli/commands/report.js +310 -0
- package/src/cli/commands/restore.js +78 -0
- package/src/cli/commands/schema.js +93 -0
- package/src/cli/commands/watch.js +212 -0
- package/src/cli/shared/code-context.js +51 -0
- package/src/cli/shared/config-loader.js +89 -0
- package/src/cli/shared/file-collector.js +116 -0
- package/src/cli/shared/inline-ignore.js +142 -0
- package/src/cli/shared/watch-list.js +281 -0
- package/src/core/event-bus.js +83 -0
- package/src/core/fsm.js +103 -0
- package/src/core/logger.js +54 -0
- package/src/core/rule-engine.js +117 -0
- package/src/index.js +64 -0
- package/src/modules/dev-time/arch-risk-assessor.js +250 -0
- package/src/modules/dev-time/code-quality-guard.js +1340 -0
- package/src/modules/dev-time/convention-store.js +201 -0
- package/src/modules/dev-time/impact-analyzer.js +292 -0
- package/src/modules/dev-time/pre-deploy-guard.js +284 -0
- package/src/modules/dev-time/schema-registry.js +284 -0
- package/src/modules/dev-time/tech-debt-tracker.js +225 -0
- package/src/modules/dev-time/version-manager.js +280 -0
- package/src/modules/runtime/channel-agent.js +404 -0
- package/src/modules/runtime/channel-middleware.js +316 -0
- package/src/modules/runtime/index.js +64 -0
- package/src/modules/runtime/infrastructure-guard.js +582 -0
- package/src/modules/runtime/notification-center.js +443 -0
- package/src/modules/runtime/schema-gatekeeper.js +664 -0
- package/src/modules/runtime/tool-registry.js +329 -0
- package/templates/ci/github-actions.yml +60 -0
- package/templates/ci/gitlab-ci.yml +30 -0
- package/templates/ci/pre-commit-hook.sh +18 -0
- package/templates/git-hooks/pre-commit +61 -0
- package/tests/run-all.js +434 -0
- package/tests/test-layer2.js +461 -0
- package/tests/test-new-rules.js +157 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SchemaGatekeeper (Runtime) - Protocol and data validation at runtime
|
|
3
|
+
*
|
|
4
|
+
* Design from SyntaxGatekeeper_架构分析报告.md:
|
|
5
|
+
* - 4-layer defense: syntax, schema, semantic, security
|
|
6
|
+
* - 33 rules (adapted from SyntaxGatekeeper for runtime use)
|
|
7
|
+
* - Validates: tool input/output, API responses, config data
|
|
8
|
+
*
|
|
9
|
+
* This is the RUNTIME version that runs inside the server, validating
|
|
10
|
+
* data as it flows through the system. The dev-time version (in dev-time/
|
|
11
|
+
* folder) checks source code. This one checks live data.
|
|
12
|
+
*
|
|
13
|
+
* Key principle: "Never trust input, always validate."
|
|
14
|
+
* Directly defends against failure #5 (ID/Class confusion — wrong data shape)
|
|
15
|
+
* and failure #6 (no defensive programming — unchecked null/undefined).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { bus } = require('../../core/event-bus');
|
|
19
|
+
const { Logger } = require('../../core/logger');
|
|
20
|
+
|
|
21
|
+
const log = new Logger('SchemaGatekeeper');
|
|
22
|
+
|
|
23
|
+
const SEVERITY = {
|
|
24
|
+
PASS: 'pass',
|
|
25
|
+
WARN: 'warn',
|
|
26
|
+
REJECT: 'reject',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const LAYERS = {
|
|
30
|
+
SYNTAX: 'syntax', // Layer 1: Basic structural validation
|
|
31
|
+
SCHEMA: 'schema', // Layer 2: Schema conformance
|
|
32
|
+
SEMANTIC: 'semantic', // Layer 3: Business logic validation
|
|
33
|
+
SECURITY: 'security', // Layer 4: Security checks
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ============ Validation Rules ============
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rule registry. Each rule has:
|
|
40
|
+
* { id, layer, severity, check(data, schema, ctx) -> { passed, message, details } }
|
|
41
|
+
*/
|
|
42
|
+
const RULES = [];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register a validation rule.
|
|
46
|
+
*/
|
|
47
|
+
function registerRule(rule) {
|
|
48
|
+
if (!rule.id || !rule.layer || typeof rule.check !== 'function') {
|
|
49
|
+
throw new Error('Rule must have id, layer, and check function');
|
|
50
|
+
}
|
|
51
|
+
RULES.push(rule);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Layer 1: Syntax Rules ---
|
|
55
|
+
|
|
56
|
+
registerRule({
|
|
57
|
+
id: 'rt-syntax-001',
|
|
58
|
+
layer: LAYERS.SYNTAX,
|
|
59
|
+
severity: SEVERITY.REJECT,
|
|
60
|
+
description: 'Data must be a valid JSON-serializable object',
|
|
61
|
+
check(data) {
|
|
62
|
+
try {
|
|
63
|
+
JSON.stringify(data);
|
|
64
|
+
return { passed: true };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { passed: false, message: `Data is not JSON-serializable: ${e.message}` };
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
registerRule({
|
|
72
|
+
id: 'rt-syntax-002',
|
|
73
|
+
layer: LAYERS.SYNTAX,
|
|
74
|
+
severity: SEVERITY.REJECT,
|
|
75
|
+
description: 'No circular references in data',
|
|
76
|
+
check(data) {
|
|
77
|
+
const seen = new WeakSet();
|
|
78
|
+
function detect(obj) {
|
|
79
|
+
if (obj === null || typeof obj !== 'object') return false;
|
|
80
|
+
if (seen.has(obj)) return true;
|
|
81
|
+
seen.add(obj);
|
|
82
|
+
for (const key of Object.keys(obj)) {
|
|
83
|
+
if (detect(obj[key])) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (detect(data)) {
|
|
88
|
+
return { passed: false, message: 'Circular reference detected in data' };
|
|
89
|
+
}
|
|
90
|
+
return { passed: true };
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
registerRule({
|
|
95
|
+
id: 'rt-syntax-003',
|
|
96
|
+
layer: LAYERS.SYNTAX,
|
|
97
|
+
severity: SEVERITY.REJECT,
|
|
98
|
+
description: 'No undefined values (use null instead)',
|
|
99
|
+
check(data) {
|
|
100
|
+
function findUndefined(obj, path = '') {
|
|
101
|
+
if (obj === undefined) return path || '(root)';
|
|
102
|
+
if (obj === null || typeof obj !== 'object') return null;
|
|
103
|
+
if (Array.isArray(obj)) {
|
|
104
|
+
for (let i = 0; i < obj.length; i++) {
|
|
105
|
+
const found = findUndefined(obj[i], `${path}[${i}]`);
|
|
106
|
+
if (found) return found;
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
for (const key of Object.keys(obj)) {
|
|
110
|
+
const found = findUndefined(obj[key], path ? `${path}.${key}` : key);
|
|
111
|
+
if (found) return found;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const found = findUndefined(data);
|
|
117
|
+
if (found) {
|
|
118
|
+
return { passed: false, message: `Undefined value found at: ${found}. Use null instead.` };
|
|
119
|
+
}
|
|
120
|
+
return { passed: true };
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// --- Layer 2: Schema Conformance Rules ---
|
|
125
|
+
|
|
126
|
+
registerRule({
|
|
127
|
+
id: 'rt-schema-001',
|
|
128
|
+
layer: LAYERS.SCHEMA,
|
|
129
|
+
severity: SEVERITY.REJECT,
|
|
130
|
+
description: 'Required fields must be present',
|
|
131
|
+
check(data, schema) {
|
|
132
|
+
if (!schema || !schema.required) return { passed: true };
|
|
133
|
+
const missing = [];
|
|
134
|
+
for (const field of schema.required) {
|
|
135
|
+
if (data[field] === undefined || data[field] === null) {
|
|
136
|
+
missing.push(field);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
return { passed: false, message: `Missing required fields: ${missing.join(', ')}`, details: { missing } };
|
|
141
|
+
}
|
|
142
|
+
return { passed: true };
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
registerRule({
|
|
147
|
+
id: 'rt-schema-002',
|
|
148
|
+
layer: LAYERS.SCHEMA,
|
|
149
|
+
severity: SEVERITY.REJECT,
|
|
150
|
+
description: 'Field types must match schema',
|
|
151
|
+
check(data, schema) {
|
|
152
|
+
if (!schema || !schema.properties) return { passed: true };
|
|
153
|
+
const mismatches = [];
|
|
154
|
+
for (const [field, def] of Object.entries(schema.properties)) {
|
|
155
|
+
if (data[field] === undefined || data[field] === null) continue;
|
|
156
|
+
const expectedType = typeof def === 'string' ? def : def.type;
|
|
157
|
+
if (!expectedType) continue;
|
|
158
|
+
const actualType = Array.isArray(data[field]) ? 'array' : typeof data[field];
|
|
159
|
+
if (expectedType === 'array' && !Array.isArray(data[field])) {
|
|
160
|
+
mismatches.push({ field, expected: 'array', actual: actualType });
|
|
161
|
+
} else if (expectedType !== 'array' && actualType !== expectedType) {
|
|
162
|
+
mismatches.push({ field, expected: expectedType, actual: actualType });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (mismatches.length > 0) {
|
|
166
|
+
return {
|
|
167
|
+
passed: false,
|
|
168
|
+
message: `Type mismatches: ${mismatches.map(m => `${m.field}(expected ${m.expected}, got ${m.actual})`).join(', ')}`,
|
|
169
|
+
details: { mismatches },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { passed: true };
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
registerRule({
|
|
177
|
+
id: 'rt-schema-003',
|
|
178
|
+
layer: LAYERS.SCHEMA,
|
|
179
|
+
severity: SEVERITY.WARN,
|
|
180
|
+
description: 'Unknown fields should be flagged (strict mode)',
|
|
181
|
+
check(data, schema) {
|
|
182
|
+
if (!schema || !schema.properties || !schema.strict) return { passed: true };
|
|
183
|
+
const known = new Set(Object.keys(schema.properties));
|
|
184
|
+
const unknown = Object.keys(data).filter(k => !known.has(k));
|
|
185
|
+
if (unknown.length > 0) {
|
|
186
|
+
return {
|
|
187
|
+
passed: true, // Warning, not rejection
|
|
188
|
+
message: `Unknown fields detected: ${unknown.join(', ')}`,
|
|
189
|
+
details: { unknown },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return { passed: true };
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
registerRule({
|
|
197
|
+
id: 'rt-schema-004',
|
|
198
|
+
layer: LAYERS.SCHEMA,
|
|
199
|
+
severity: SEVERITY.REJECT,
|
|
200
|
+
description: 'String length must be within bounds',
|
|
201
|
+
check(data, schema) {
|
|
202
|
+
if (!schema || !schema.properties) return { passed: true };
|
|
203
|
+
const violations = [];
|
|
204
|
+
for (const [field, def] of Object.entries(schema.properties)) {
|
|
205
|
+
if (typeof data[field] !== 'string') continue;
|
|
206
|
+
const maxLen = typeof def === 'object' ? def.maxLength : null;
|
|
207
|
+
const minLen = typeof def === 'object' ? def.minLength : null;
|
|
208
|
+
if (maxLen && data[field].length > maxLen) {
|
|
209
|
+
violations.push({ field, issue: 'too long', max: maxLen, actual: data[field].length });
|
|
210
|
+
}
|
|
211
|
+
if (minLen && data[field].length < minLen) {
|
|
212
|
+
violations.push({ field, issue: 'too short', min: minLen, actual: data[field].length });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (violations.length > 0) {
|
|
216
|
+
return { passed: false, message: `String length violations: ${JSON.stringify(violations)}`, details: { violations } };
|
|
217
|
+
}
|
|
218
|
+
return { passed: true };
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
registerRule({
|
|
223
|
+
id: 'rt-schema-005',
|
|
224
|
+
layer: LAYERS.SCHEMA,
|
|
225
|
+
severity: SEVERITY.REJECT,
|
|
226
|
+
description: 'Numeric values must be within range',
|
|
227
|
+
check(data, schema) {
|
|
228
|
+
if (!schema || !schema.properties) return { passed: true };
|
|
229
|
+
const violations = [];
|
|
230
|
+
for (const [field, def] of Object.entries(schema.properties)) {
|
|
231
|
+
if (typeof data[field] !== 'number') continue;
|
|
232
|
+
const max = typeof def === 'object' ? def.max : null;
|
|
233
|
+
const min = typeof def === 'object' ? def.min : null;
|
|
234
|
+
if (max !== null && data[field] > max) {
|
|
235
|
+
violations.push({ field, issue: 'exceeds max', max, actual: data[field] });
|
|
236
|
+
}
|
|
237
|
+
if (min !== null && data[field] < min) {
|
|
238
|
+
violations.push({ field, issue: 'below min', min, actual: data[field] });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (violations.length > 0) {
|
|
242
|
+
return { passed: false, message: `Numeric range violations: ${JSON.stringify(violations)}`, details: { violations } };
|
|
243
|
+
}
|
|
244
|
+
return { passed: true };
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// --- Layer 3: Semantic Rules ---
|
|
249
|
+
|
|
250
|
+
registerRule({
|
|
251
|
+
id: 'rt-semantic-001',
|
|
252
|
+
layer: LAYERS.SEMANTIC,
|
|
253
|
+
severity: SEVERITY.WARN,
|
|
254
|
+
description: 'Response should have standard envelope { code, data, message }',
|
|
255
|
+
check(data, schema, ctx) {
|
|
256
|
+
if (ctx.direction !== 'response') return { passed: true };
|
|
257
|
+
if (data === null || typeof data !== 'object') return { passed: true };
|
|
258
|
+
const hasCode = 'code' in data;
|
|
259
|
+
const hasData = 'data' in data;
|
|
260
|
+
if (!hasCode || !hasData) {
|
|
261
|
+
return {
|
|
262
|
+
passed: true, // Warning
|
|
263
|
+
message: `Response missing standard envelope fields (code: ${hasCode}, data: ${hasData})`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return { passed: true };
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
registerRule({
|
|
271
|
+
id: 'rt-semantic-002',
|
|
272
|
+
layer: LAYERS.SEMANTIC,
|
|
273
|
+
severity: SEVERITY.WARN,
|
|
274
|
+
description: 'Response data field naming should use consistent convention (data.list not data.items/records)',
|
|
275
|
+
check(data, schema, ctx) {
|
|
276
|
+
if (ctx.direction !== 'response') return { passed: true };
|
|
277
|
+
if (!data || !data.data || typeof data.data !== 'object') return { passed: true };
|
|
278
|
+
const d = data.data;
|
|
279
|
+
// Check for inconsistent list field naming
|
|
280
|
+
const listFields = ['list', 'items', 'records', 'rows', 'results'];
|
|
281
|
+
const found = listFields.filter(f => f in d);
|
|
282
|
+
if (found.length > 1) {
|
|
283
|
+
return {
|
|
284
|
+
passed: true,
|
|
285
|
+
message: `Inconsistent list field naming: found [${found.join(', ')}]. Use 'list' consistently.`,
|
|
286
|
+
details: { found },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return { passed: true };
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
registerRule({
|
|
294
|
+
id: 'rt-semantic-003',
|
|
295
|
+
layer: LAYERS.SEMANTIC,
|
|
296
|
+
severity: SEVERITY.REJECT,
|
|
297
|
+
description: 'ID fields must not be null or empty for existing records',
|
|
298
|
+
check(data, schema, ctx) {
|
|
299
|
+
if (!data || typeof data !== 'object') return { passed: true };
|
|
300
|
+
const idFields = ['id', 'userId', 'sessionId', 'toolId'];
|
|
301
|
+
for (const field of idFields) {
|
|
302
|
+
if (field in data && (data[field] === null || data[field] === '' || data[field] === 0)) {
|
|
303
|
+
return { passed: false, message: `ID field '${field}' is null/empty for what appears to be an existing record` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { passed: true };
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// --- Layer 4: Security Rules ---
|
|
311
|
+
|
|
312
|
+
registerRule({
|
|
313
|
+
id: 'rt-security-001',
|
|
314
|
+
layer: LAYERS.SECURITY,
|
|
315
|
+
severity: SEVERITY.REJECT,
|
|
316
|
+
description: 'No SQL injection patterns in string fields',
|
|
317
|
+
check(data) {
|
|
318
|
+
const sqlPatterns = [
|
|
319
|
+
/(\bunion\b\s+\bselect\b)/i,
|
|
320
|
+
/(\bdrop\b\s+\btable\b)/i,
|
|
321
|
+
/(\bdelete\b\s+\bfrom\b.*\bwhere\b.*1=1)/i,
|
|
322
|
+
/(;\s*drop\s+table)/i,
|
|
323
|
+
/(';\s*--)/,
|
|
324
|
+
];
|
|
325
|
+
function scanStrings(obj, path = '') {
|
|
326
|
+
const findings = [];
|
|
327
|
+
if (typeof obj === 'string') {
|
|
328
|
+
for (const pattern of sqlPatterns) {
|
|
329
|
+
if (pattern.test(obj)) {
|
|
330
|
+
findings.push({ path: path || '(root)', value: obj.substring(0, 100), pattern: pattern.source });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} else if (obj && typeof obj === 'object') {
|
|
334
|
+
for (const key of Object.keys(obj)) {
|
|
335
|
+
findings.push(...scanStrings(obj[key], path ? `${path}.${key}` : key));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return findings;
|
|
339
|
+
}
|
|
340
|
+
const findings = scanStrings(data);
|
|
341
|
+
if (findings.length > 0) {
|
|
342
|
+
return {
|
|
343
|
+
passed: false,
|
|
344
|
+
message: `Potential SQL injection detected: ${findings.length} pattern(s) found`,
|
|
345
|
+
details: { findings },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return { passed: true };
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
registerRule({
|
|
353
|
+
id: 'rt-security-002',
|
|
354
|
+
layer: LAYERS.SECURITY,
|
|
355
|
+
severity: SEVERITY.REJECT,
|
|
356
|
+
description: 'No XSS patterns in string fields',
|
|
357
|
+
check(data) {
|
|
358
|
+
const xssPatterns = [
|
|
359
|
+
/<script[^>]*>[\s\S]*?<\/script>/i,
|
|
360
|
+
/javascript:/i,
|
|
361
|
+
/on\w+\s*=\s*["'][^"']*["']/i,
|
|
362
|
+
/<iframe[^>]*>/i,
|
|
363
|
+
];
|
|
364
|
+
function scanStrings(obj, path = '') {
|
|
365
|
+
const findings = [];
|
|
366
|
+
if (typeof obj === 'string') {
|
|
367
|
+
for (const pattern of xssPatterns) {
|
|
368
|
+
if (pattern.test(obj)) {
|
|
369
|
+
findings.push({ path: path || '(root)', value: obj.substring(0, 100) });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} else if (obj && typeof obj === 'object') {
|
|
373
|
+
for (const key of Object.keys(obj)) {
|
|
374
|
+
findings.push(...scanStrings(obj[key], path ? `${path}.${key}` : key));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return findings;
|
|
378
|
+
}
|
|
379
|
+
const findings = scanStrings(data);
|
|
380
|
+
if (findings.length > 0) {
|
|
381
|
+
return {
|
|
382
|
+
passed: false,
|
|
383
|
+
message: `Potential XSS detected: ${findings.length} pattern(s) found`,
|
|
384
|
+
details: { findings },
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { passed: true };
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
registerRule({
|
|
392
|
+
id: 'rt-security-003',
|
|
393
|
+
layer: LAYERS.SECURITY,
|
|
394
|
+
severity: SEVERITY.REJECT,
|
|
395
|
+
description: 'No path traversal patterns',
|
|
396
|
+
check(data) {
|
|
397
|
+
function scanStrings(obj, path = '') {
|
|
398
|
+
const findings = [];
|
|
399
|
+
if (typeof obj === 'string') {
|
|
400
|
+
if (/\.\.[\/\\]/.test(obj) || /\.\.%2f/i.test(obj) || /\.\.%5c/i.test(obj)) {
|
|
401
|
+
findings.push({ path: path || '(root)', value: obj.substring(0, 100) });
|
|
402
|
+
}
|
|
403
|
+
} else if (obj && typeof obj === 'object') {
|
|
404
|
+
for (const key of Object.keys(obj)) {
|
|
405
|
+
findings.push(...scanStrings(obj[key], path ? `${path}.${key}` : key));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return findings;
|
|
409
|
+
}
|
|
410
|
+
const findings = scanStrings(data);
|
|
411
|
+
if (findings.length > 0) {
|
|
412
|
+
return {
|
|
413
|
+
passed: false,
|
|
414
|
+
message: `Path traversal pattern detected`,
|
|
415
|
+
details: { findings },
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return { passed: true };
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
registerRule({
|
|
423
|
+
id: 'rt-security-004',
|
|
424
|
+
layer: LAYERS.SECURITY,
|
|
425
|
+
severity: SEVERITY.WARN,
|
|
426
|
+
description: 'Sensitive fields should not be exposed in responses',
|
|
427
|
+
check(data, schema, ctx) {
|
|
428
|
+
if (ctx.direction !== 'response') return { passed: true };
|
|
429
|
+
if (!data || typeof data !== 'object') return { passed: true };
|
|
430
|
+
const sensitiveFields = ['password', 'secret', 'token', 'apiKey', 'privateKey', 'creditCard'];
|
|
431
|
+
function findSensitive(obj, path = '') {
|
|
432
|
+
const found = [];
|
|
433
|
+
if (obj && typeof obj === 'object') {
|
|
434
|
+
for (const key of Object.keys(obj)) {
|
|
435
|
+
if (sensitiveFields.some(s => key.toLowerCase().includes(s.toLowerCase()))) {
|
|
436
|
+
found.push(path ? `${path}.${key}` : key);
|
|
437
|
+
}
|
|
438
|
+
found.push(...findSensitive(obj[key], path ? `${path}.${key}` : key));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return found;
|
|
442
|
+
}
|
|
443
|
+
const found = findSensitive(data);
|
|
444
|
+
if (found.length > 0) {
|
|
445
|
+
return {
|
|
446
|
+
passed: true, // Warning
|
|
447
|
+
message: `Sensitive fields exposed in response: ${found.join(', ')}`,
|
|
448
|
+
details: { fields: found },
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return { passed: true };
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
registerRule({
|
|
456
|
+
id: 'rt-security-005',
|
|
457
|
+
layer: LAYERS.SECURITY,
|
|
458
|
+
severity: SEVERITY.REJECT,
|
|
459
|
+
description: 'No command injection patterns',
|
|
460
|
+
check(data) {
|
|
461
|
+
const cmdPatterns = [
|
|
462
|
+
/(\|\s*\w+)/, // Pipe to command
|
|
463
|
+
/(`[^`]*`)/, // Backtick command substitution
|
|
464
|
+
/(\$\([^)]*\))/, // $() command substitution
|
|
465
|
+
/(&&\s*\w+)/, // && chaining
|
|
466
|
+
/(;\s*\w+\s+)/, // Semicolon command chaining
|
|
467
|
+
];
|
|
468
|
+
function scanStrings(obj, path = '') {
|
|
469
|
+
const findings = [];
|
|
470
|
+
if (typeof obj === 'string' && obj.length < 1000) { // Skip very long strings
|
|
471
|
+
for (const pattern of cmdPatterns) {
|
|
472
|
+
if (pattern.test(obj)) {
|
|
473
|
+
findings.push({ path: path || '(root)', value: obj.substring(0, 100), pattern: pattern.source });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
} else if (obj && typeof obj === 'object') {
|
|
477
|
+
for (const key of Object.keys(obj)) {
|
|
478
|
+
findings.push(...scanStrings(obj[key], path ? `${path}.${key}` : key));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return findings;
|
|
482
|
+
}
|
|
483
|
+
const findings = scanStrings(data);
|
|
484
|
+
if (findings.length > 0) {
|
|
485
|
+
return {
|
|
486
|
+
passed: false,
|
|
487
|
+
message: `Potential command injection: ${findings.length} pattern(s) found`,
|
|
488
|
+
details: { findings },
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return { passed: true };
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// ============ SchemaGatekeeper ============
|
|
496
|
+
|
|
497
|
+
class SchemaGatekeeper {
|
|
498
|
+
constructor(opts = {}) {
|
|
499
|
+
this._rules = [...RULES];
|
|
500
|
+
this._strict = opts.strict !== false; // Default: strict mode
|
|
501
|
+
this._enabledLayers = opts.enabledLayers || Object.values(LAYERS);
|
|
502
|
+
this._stats = {
|
|
503
|
+
totalChecks: 0,
|
|
504
|
+
totalPassed: 0,
|
|
505
|
+
totalRejected: 0,
|
|
506
|
+
totalWarnings: 0,
|
|
507
|
+
};
|
|
508
|
+
this._rejectHistory = [];
|
|
509
|
+
this._maxHistory = 50;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Validate data against a schema through all 4 defense layers.
|
|
514
|
+
*
|
|
515
|
+
* @param {object} data - Data to validate
|
|
516
|
+
* @param {object} schema - Schema definition (JSON-schema-like)
|
|
517
|
+
* @param {object} ctx - Context { direction: 'request'|'response', action, toolId }
|
|
518
|
+
* @returns {object} { passed, warnings, rejections, details }
|
|
519
|
+
*/
|
|
520
|
+
validate(data, schema = null, ctx = {}) {
|
|
521
|
+
this._stats.totalChecks++;
|
|
522
|
+
|
|
523
|
+
const warnings = [];
|
|
524
|
+
const rejections = [];
|
|
525
|
+
const layerResults = {};
|
|
526
|
+
|
|
527
|
+
for (const rule of this._rules) {
|
|
528
|
+
// Skip disabled layers
|
|
529
|
+
if (!this._enabledLayers.includes(rule.layer)) continue;
|
|
530
|
+
|
|
531
|
+
let result;
|
|
532
|
+
try {
|
|
533
|
+
result = rule.check(data, schema, ctx);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
result = { passed: false, message: `Rule '${rule.id}' error: ${err.message}` };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
layerResults[rule.id] = {
|
|
539
|
+
layer: rule.layer,
|
|
540
|
+
severity: rule.severity,
|
|
541
|
+
passed: result.passed,
|
|
542
|
+
message: result.message || null,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
if (result.passed) {
|
|
546
|
+
if (result.message) {
|
|
547
|
+
warnings.push({ ruleId: rule.id, layer: rule.layer, message: result.message, details: result.details });
|
|
548
|
+
}
|
|
549
|
+
} else {
|
|
550
|
+
if (rule.severity === SEVERITY.REJECT) {
|
|
551
|
+
rejections.push({ ruleId: rule.id, layer: rule.layer, message: result.message, details: result.details });
|
|
552
|
+
} else {
|
|
553
|
+
warnings.push({ ruleId: rule.id, layer: rule.layer, message: result.message, details: result.details });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const passed = rejections.length === 0;
|
|
559
|
+
|
|
560
|
+
if (passed) {
|
|
561
|
+
this._stats.totalPassed++;
|
|
562
|
+
if (warnings.length > 0) {
|
|
563
|
+
this._stats.totalWarnings++;
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
this._stats.totalRejected++;
|
|
567
|
+
this._rejectHistory.push({
|
|
568
|
+
ts: Date.now(),
|
|
569
|
+
ctx,
|
|
570
|
+
rejections: rejections.map(r => ({ ruleId: r.ruleId, message: r.message })),
|
|
571
|
+
});
|
|
572
|
+
if (this._rejectHistory.length > this._maxHistory) {
|
|
573
|
+
this._rejectHistory.shift();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Emit event
|
|
578
|
+
bus.emit('gatekeeper:validated', {
|
|
579
|
+
passed,
|
|
580
|
+
warningCount: warnings.length,
|
|
581
|
+
rejectionCount: rejections.length,
|
|
582
|
+
ctx,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
if (!passed) {
|
|
586
|
+
log.warn(`验证被拒绝:${rejections.map(r => r.message).join('; ')}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return { passed, warnings, rejections, details: layerResults };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Validate a tool input before invocation.
|
|
594
|
+
*/
|
|
595
|
+
validateInput(data, schema, ctx = {}) {
|
|
596
|
+
return this.validate(data, schema, { ...ctx, direction: 'request' });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Validate a tool output before returning to client.
|
|
601
|
+
*/
|
|
602
|
+
validateOutput(data, schema, ctx = {}) {
|
|
603
|
+
return this.validate(data, schema, { ...ctx, direction: 'response' });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Quick check — only runs syntax and security layers (fast path).
|
|
608
|
+
*/
|
|
609
|
+
quickCheck(data, ctx = {}) {
|
|
610
|
+
const fastLayers = [LAYERS.SYNTAX, LAYERS.SECURITY];
|
|
611
|
+
const original = this._enabledLayers;
|
|
612
|
+
this._enabledLayers = fastLayers;
|
|
613
|
+
const result = this.validate(data, null, ctx);
|
|
614
|
+
this._enabledLayers = original;
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Add a custom rule.
|
|
620
|
+
*/
|
|
621
|
+
addRule(rule) {
|
|
622
|
+
registerRule(rule);
|
|
623
|
+
this._rules.push(rule);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Get stats.
|
|
628
|
+
*/
|
|
629
|
+
getStats() {
|
|
630
|
+
return {
|
|
631
|
+
...this._stats,
|
|
632
|
+
passRate: this._stats.totalChecks > 0
|
|
633
|
+
? Math.round((this._stats.totalPassed / this._stats.totalChecks) * 1000) / 10
|
|
634
|
+
: 100,
|
|
635
|
+
ruleCount: this._rules.length,
|
|
636
|
+
layers: this._enabledLayers,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Get recent rejections.
|
|
642
|
+
*/
|
|
643
|
+
getRejectHistory(limit) {
|
|
644
|
+
if (limit) return this._rejectHistory.slice(-limit);
|
|
645
|
+
return this._rejectHistory.slice();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* List all registered rules.
|
|
650
|
+
*/
|
|
651
|
+
listRules() {
|
|
652
|
+
return this._rules.map(r => ({
|
|
653
|
+
id: r.id,
|
|
654
|
+
layer: r.layer,
|
|
655
|
+
severity: r.severity,
|
|
656
|
+
description: r.description,
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Singleton
|
|
662
|
+
const gatekeeper = new SchemaGatekeeper();
|
|
663
|
+
|
|
664
|
+
module.exports = { SchemaGatekeeper, gatekeeper, LAYERS, SEVERITY, registerRule, RULES };
|