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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +467 -0
  3. package/bin/devassist.js +220 -0
  4. package/package.json +44 -0
  5. package/src/ai/adapter.js +464 -0
  6. package/src/ai/providers/claude.js +80 -0
  7. package/src/ai/providers/hunyuan.js +87 -0
  8. package/src/ai/providers/openai.js +74 -0
  9. package/src/ai/providers/qwen.js +81 -0
  10. package/src/cli/commands/ai.js +944 -0
  11. package/src/cli/commands/ask.js +79 -0
  12. package/src/cli/commands/backup.js +30 -0
  13. package/src/cli/commands/check.js +327 -0
  14. package/src/cli/commands/clean.js +130 -0
  15. package/src/cli/commands/comparison-report.js +326 -0
  16. package/src/cli/commands/convention.js +91 -0
  17. package/src/cli/commands/debt.js +49 -0
  18. package/src/cli/commands/deploy.js +88 -0
  19. package/src/cli/commands/diff.js +193 -0
  20. package/src/cli/commands/doctor.js +186 -0
  21. package/src/cli/commands/fix.js +195 -0
  22. package/src/cli/commands/init.js +431 -0
  23. package/src/cli/commands/inject.js +254 -0
  24. package/src/cli/commands/report.js +310 -0
  25. package/src/cli/commands/restore.js +78 -0
  26. package/src/cli/commands/schema.js +93 -0
  27. package/src/cli/commands/watch.js +212 -0
  28. package/src/cli/shared/code-context.js +51 -0
  29. package/src/cli/shared/config-loader.js +89 -0
  30. package/src/cli/shared/file-collector.js +116 -0
  31. package/src/cli/shared/inline-ignore.js +142 -0
  32. package/src/cli/shared/watch-list.js +281 -0
  33. package/src/core/event-bus.js +83 -0
  34. package/src/core/fsm.js +103 -0
  35. package/src/core/logger.js +54 -0
  36. package/src/core/rule-engine.js +117 -0
  37. package/src/index.js +64 -0
  38. package/src/modules/dev-time/arch-risk-assessor.js +250 -0
  39. package/src/modules/dev-time/code-quality-guard.js +1340 -0
  40. package/src/modules/dev-time/convention-store.js +201 -0
  41. package/src/modules/dev-time/impact-analyzer.js +292 -0
  42. package/src/modules/dev-time/pre-deploy-guard.js +284 -0
  43. package/src/modules/dev-time/schema-registry.js +284 -0
  44. package/src/modules/dev-time/tech-debt-tracker.js +225 -0
  45. package/src/modules/dev-time/version-manager.js +280 -0
  46. package/src/modules/runtime/channel-agent.js +404 -0
  47. package/src/modules/runtime/channel-middleware.js +316 -0
  48. package/src/modules/runtime/index.js +64 -0
  49. package/src/modules/runtime/infrastructure-guard.js +582 -0
  50. package/src/modules/runtime/notification-center.js +443 -0
  51. package/src/modules/runtime/schema-gatekeeper.js +664 -0
  52. package/src/modules/runtime/tool-registry.js +329 -0
  53. package/templates/ci/github-actions.yml +60 -0
  54. package/templates/ci/gitlab-ci.yml +30 -0
  55. package/templates/ci/pre-commit-hook.sh +18 -0
  56. package/templates/git-hooks/pre-commit +61 -0
  57. package/tests/run-all.js +434 -0
  58. package/tests/test-layer2.js +461 -0
  59. 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 };