ccs-mcp-server 1.3.0 → 1.4.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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @file Prompt injection detection — identifies injection attempts in tool inputs.
3
+ *
4
+ * Detects patterns commonly used in indirect prompt injection:
5
+ * - "ignore previous instructions" / "disregard" / "forget"
6
+ * - Role override ("you are now", "system:")
7
+ * - Instruction leakage ("reveal your system prompt")
8
+ * - Markup/comment-based injection in fetched content
9
+ * - Encoded/obfuscated injection attempts
10
+ */
11
+
12
+ const INJECTION_PATTERNS = [
13
+ // Direct instruction override
14
+ { pattern: /\b(?:ignore|disregard|forget|override)\b.{0,40}\b(?:previous|prior|above|all)\b.{0,20}\b(?:instruction|prompt|rule|system|context)/i, weight: 5, label: 'instruction override' },
15
+ { pattern: /\b(?:ignore|disregard|forget)\b.{0,20}\b(?:all|previous|prior|above)/i, weight: 4, label: 'instruction override attempt' },
16
+
17
+ // Role/system override
18
+ { pattern: /\b(?:you are now|you're now|new (?:persona|role|identity)|act as|pretend (?:to be|you are))\b/i, weight: 4, label: 'role override attempt' },
19
+ { pattern: /\b(?:system|assistant|developer)\s*:\s*(?:you|ignore|new|override|do not)/i, weight: 4, label: 'fake role marker' },
20
+ { pattern: /\[(?:SYSTEM|ADMIN|OVERRIDE|INSTRUCTION)\]/i, weight: 5, label: 'fake system/override tag' },
21
+
22
+ // Prompt exfiltration
23
+ { pattern: /\b(?:reveal|show|print|leak|expose|dump|output|repeat)\b.{0,30}\b(?:system|initial|original|developer|hidden)\b.{0,20}\b(?:prompt|instruction|message|rule)/i, weight: 5, label: 'prompt exfiltration attempt' },
24
+ { pattern: /\b(?:what are|what were|tell me)\b.{0,20}\b(?:your|the)\b.{0,20}\b(?:instruction|rule|system prompt|developer message)/i, weight: 3, label: 'prompt probing' },
25
+
26
+ // HTML/XML comment injection (in fetched web content)
27
+ { pattern: /<!--[\s\S]{0,200}(?:ignore|system|instruction|assistant|secret|password|token|key)/i, weight: 3, label: 'hidden comment injection' },
28
+ { pattern: /<(?:script|meta|template)[^>]*>[\s\S]{0,200}(?:ignore|instruction|system|assistant)/i, weight: 3, label: 'markup-based injection' },
29
+
30
+ // Markdown image-based exfiltration
31
+ { pattern: /!\[[^\]]{0,100}\]\s*\(\s*https?:\/\/[^\s)]{10,}/i, weight: 2, label: 'markdown image (potential data exfil via URL)' },
32
+
33
+ // Unicode/bidi tricks
34
+ { pattern: /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/, weight: 2, label: 'invisible/override Unicode character' },
35
+
36
+ // Base64-encoded instructions
37
+ { pattern: /(?:decode|base64|atob|btoa)\s*\(?['"`][A-Za-z0-9+/=]{20,}/i, weight: 3, label: 'encoded payload reference' },
38
+
39
+ // Social engineering urgency
40
+ { pattern: /\b(?:urgent|emergency|critical|immediately)\b.{0,30}\b(?:must|need to|have to|do it now|no questions)/i, weight: 1, label: 'urgency/social engineering' },
41
+ ];
42
+
43
+ /**
44
+ * Scan text for prompt injection patterns.
45
+ * @param {string} text - the text to scan.
46
+ * @param {object} [opts]
47
+ * @param {number} [opts.threshold=6] - total weight to trigger block.
48
+ * @returns {{ score: number, hits: Array<{label: string, weight: number, snippet: string}>, blocked: boolean, reason?: string }}
49
+ */
50
+ export function detectInjection(text, opts = {}) {
51
+ if (!text || typeof text !== 'string') return { score: 0, hits: [], blocked: false };
52
+
53
+ const threshold = opts.threshold ?? 6;
54
+ const hits = [];
55
+ let score = 0;
56
+
57
+ for (const { pattern, weight, label } of INJECTION_PATTERNS) {
58
+ pattern.lastIndex = 0;
59
+ const match = pattern.exec(text);
60
+ if (match) {
61
+ score += weight;
62
+ const start = Math.max(0, match.index - 20);
63
+ const end = Math.min(text.length, match.index + match[0].length + 20);
64
+ hits.push({ label, weight, snippet: text.slice(start, end).trim() });
65
+ }
66
+ }
67
+
68
+ const blocked = score >= threshold;
69
+ return {
70
+ score,
71
+ hits,
72
+ blocked,
73
+ reason: blocked ? `prompt injection detected (score ${score} ≥ ${threshold}): ${hits.map(h => h.label).join(', ')}` : undefined,
74
+ };
75
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "agent-runtime-guard-dsh-plugin",
3
+ "private": true,
4
+ "type": "module"
5
+ }
package/dsh/policy.js ADDED
@@ -0,0 +1,359 @@
1
+ /**
2
+ * @file CCS policy engine — combines all detectors into a unified decision.
3
+ *
4
+ * The 7 CCS dimensions enforced at runtime:
5
+ * 1. Structure — tool call arguments match declared JSON Schema
6
+ * 2. Schema — input/output types are well-formed
7
+ * 3. Latency — call duration within budget (observability only)
8
+ * 4. Cost — token/API cost within budget (observability only)
9
+ * 5. Identity — caller and tool identity are known and trusted
10
+ * 6. Integrity — no prompt injection, no credential exfiltration
11
+ * 7. Security — no SSRF, command injection, path traversal, destructive ops
12
+ */
13
+
14
+ import { validateFetchUrl } from './ssrf.js';
15
+ import { scanForSecrets, isCredentialPath, scanSubprocessForCredentials } from './credentials.js';
16
+ import { detectInjection } from './injection.js';
17
+ import { inspectSpawn } from './cmdi.js';
18
+
19
+ /** Default policy configuration. */
20
+ export const DEFAULT_POLICY = {
21
+ // Dimension 7: Security
22
+ ssrf: { enabled: true, allowPrivate: false },
23
+ commandInjection: { enabled: true, threshold: 6 },
24
+ credentialExfil: { enabled: true },
25
+ pathTraversal: { enabled: true, blockedPaths: ['/etc/shadow', '/etc/passwd', '/etc/sudoers'] },
26
+ destructiveTools: {
27
+ enabled: true,
28
+ // Tool names that require explicit approval (case-insensitive substring match)
29
+ requireApproval: [
30
+ 'execute_payment', 'pay', 'transfer', 'send_transaction',
31
+ 'delete', 'drop', 'truncate', 'destroy', 'rm',
32
+ 'deploy', 'apply', 'push', 'merge',
33
+ 'revoke', 'disable', 'shutdown', 'terminate',
34
+ ],
35
+ },
36
+
37
+ // Dimension 6: Integrity
38
+ promptInjection: { enabled: true, threshold: 6 },
39
+
40
+ // Dimension 5: Identity
41
+ requireToolAnnotations: { enabled: false }, // warn-only by default
42
+
43
+ // Observability (dimensions 3, 4)
44
+ latencyBudgetMs: 30000,
45
+ costBudgetTokens: 100000,
46
+
47
+ // Output scanning
48
+ scanOutput: { enabled: true, maxScanLength: 100000 },
49
+
50
+ // Allowlist for safe tools (skip deep scanning)
51
+ safeToolAllowlist: [],
52
+ };
53
+
54
+ /**
55
+ * Deep-merge user config with defaults.
56
+ */
57
+ function mergeConfig(defaults, user) {
58
+ if (!user) return structuredClone(defaults);
59
+ const out = structuredClone(defaults);
60
+ for (const [k, v] of Object.entries(user)) {
61
+ if (v && typeof v === 'object' && !Array.isArray(v) && k in out && typeof out[k] === 'object') {
62
+ out[k] = mergeConfig(out[k], v);
63
+ } else {
64
+ out[k] = v;
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Check for path traversal in string arguments.
72
+ */
73
+ function checkPathTraversal(args, blockedPaths) {
74
+ const findings = [];
75
+ const seen = new WeakSet();
76
+
77
+ function walk(val) {
78
+ if (typeof val === 'string') {
79
+ // Directory traversal
80
+ if (val.includes('../') || val.includes('..\\')) {
81
+ // Check if it targets a blocked absolute path
82
+ for (const blocked of blockedPaths) {
83
+ if (val.includes(blocked) || val.endsWith(blocked)) {
84
+ findings.push(`path traversal targeting ${blocked}: ${val.slice(0, 100)}`);
85
+ }
86
+ }
87
+ // Even without a specific target, flag obvious traversal
88
+ if (/\.\.\/(?:etc|root|home|var|usr|proc|sys)\//.test(val)) {
89
+ findings.push(`directory traversal to system path: ${val.slice(0, 100)}`);
90
+ }
91
+ }
92
+ // Absolute sensitive paths
93
+ if (/^\/(?:etc\/shadow|etc\/passwd|etc\/sudoers|root\/\.ssh)/.test(val)) {
94
+ findings.push(`direct access to sensitive path: ${val}`);
95
+ }
96
+ } else if (val && typeof val === 'object') {
97
+ if (seen.has(val)) return;
98
+ seen.add(val);
99
+ if (Array.isArray(val)) val.forEach(walk);
100
+ else Object.values(val).forEach(walk);
101
+ }
102
+ }
103
+
104
+ walk(args);
105
+ return findings;
106
+ }
107
+
108
+ /**
109
+ * Determine whether a tool name matches the destructive list.
110
+ */
111
+ function isDestructive(toolName, requireApproval) {
112
+ const lower = toolName.toLowerCase();
113
+ return requireApproval.some(d => lower.includes(d.toLowerCase()));
114
+ }
115
+
116
+ /**
117
+ * Evaluate a tool call against the CCS policy.
118
+ *
119
+ * @param {object} call - { name, arguments, agent? }
120
+ * @param {object} [userConfig] - policy overrides.
121
+ * @returns {Promise<{ allow: boolean, reason?: string, warnings: string[], dimension: string }>}
122
+ */
123
+ export async function evaluateToolCall(call, userConfig) {
124
+ const config = mergeConfig(DEFAULT_POLICY, userConfig);
125
+ const warnings = [];
126
+ const { name, arguments: args } = call;
127
+
128
+ // Skip deep scanning for allowlisted safe tools
129
+ if (config.safeToolAllowlist.includes(name)) {
130
+ return { allow: true, warnings, dimension: 'allowlist' };
131
+ }
132
+
133
+ // Dimension 7: Destructive tool check
134
+ if (config.destructiveTools.enabled && isDestructive(name, config.destructiveTools.requireApproval)) {
135
+ return {
136
+ allow: false,
137
+ reason: `[CCS/Security] destructive tool "${name}" requires explicit human approval (auto-approve is blocked by CCS policy)`,
138
+ warnings,
139
+ dimension: 'Security',
140
+ };
141
+ }
142
+
143
+ // Scan string arguments for various threats
144
+ const argStrings = [];
145
+ const seen = new WeakSet();
146
+ function collectStrings(val) {
147
+ if (typeof val === 'string') argStrings.push(val);
148
+ else if (val && typeof val === 'object') {
149
+ if (seen.has(val)) return;
150
+ seen.add(val);
151
+ if (Array.isArray(val)) val.forEach(collectStrings);
152
+ else Object.values(val).forEach(collectStrings);
153
+ }
154
+ }
155
+ collectStrings(args);
156
+
157
+ // Dimension 7: SSRF — check URL arguments
158
+ if (config.ssrf.enabled) {
159
+ for (const str of argStrings) {
160
+ if (/^https?:\/\//i.test(str)) {
161
+ const ssrfReason = await validateFetchUrl(str, { allowPrivate: config.ssrf.allowPrivate });
162
+ if (ssrfReason) {
163
+ return { allow: false, reason: `[CCS/Security] ${ssrfReason}`, warnings, dimension: 'Security' };
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ // Dimension 7: Command injection — check arguments that look like commands
170
+ if (config.commandInjection.enabled) {
171
+ for (const str of argStrings) {
172
+ if (str.length > 5000) continue; // skip very long strings (likely data, not commands)
173
+ // Only scan strings that look like they could contain shell commands
174
+ if (/[;&|`$]|\b(?:curl|wget|bash|sh|nc|python|perl|ruby|chmod|rm)\b/.test(str)) {
175
+ const result = inspectSpawn({ command: str }, config.commandInjection.threshold);
176
+ if (result.blocked) {
177
+ return { allow: false, reason: `[CCS/Security] ${result.reason}`, warnings, dimension: 'Security' };
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ // Dimension 7: Credential exfiltration
184
+ if (config.credentialExfil.enabled) {
185
+ const secrets = scanForSecrets(args);
186
+ if (secrets.length > 0) {
187
+ // If the tool is network-facing (fetch, request, send), block
188
+ const networkFacing = /^(fetch|request|web|http|send|post|put|upload|curl|api_call|call)/i.test(name);
189
+ if (networkFacing) {
190
+ return {
191
+ allow: false,
192
+ reason: `[CCS/Security] credential pattern detected in network-facing tool "${name}" arguments: ${secrets.map(s => s.label).join(', ')}`,
193
+ warnings,
194
+ dimension: 'Security',
195
+ };
196
+ }
197
+ // For non-network tools, warn only
198
+ warnings.push(`credential pattern detected: ${secrets.map(s => s.label).join(', ')}`);
199
+ }
200
+
201
+ // Check for credential file paths
202
+ for (const str of argStrings) {
203
+ const credPath = isCredentialPath(str);
204
+ if (credPath) {
205
+ const readTool = /^(read|cat|get|fetch|open|load|view|show|list)/i.test(name);
206
+ if (readTool) {
207
+ return {
208
+ allow: false,
209
+ reason: `[CCS/Security] tool "${name}" attempting to read credential file: ${str}`,
210
+ warnings,
211
+ dimension: 'Security',
212
+ };
213
+ }
214
+ warnings.push(`credential file path referenced: ${str}`);
215
+ }
216
+ }
217
+ }
218
+
219
+ // Dimension 7: Path traversal
220
+ if (config.pathTraversal.enabled) {
221
+ const traversal = checkPathTraversal(args, config.pathTraversal.blockedPaths);
222
+ if (traversal.length > 0) {
223
+ return {
224
+ allow: false,
225
+ reason: `[CCS/Security] path traversal detected: ${traversal[0]}`,
226
+ warnings,
227
+ dimension: 'Security',
228
+ };
229
+ }
230
+ }
231
+
232
+ // Dimension 6: Prompt injection — scan all string arguments concatenated
233
+ if (config.promptInjection.enabled) {
234
+ const combined = argStrings.join('\n').slice(0, 50000);
235
+ if (combined.length > 10) {
236
+ const injection = detectInjection(combined, { threshold: config.promptInjection.threshold });
237
+ if (injection.blocked) {
238
+ return {
239
+ allow: false,
240
+ reason: `[CCS/Integrity] ${injection.reason}`,
241
+ warnings,
242
+ dimension: 'Integrity',
243
+ };
244
+ }
245
+ if (injection.hits.length > 0) {
246
+ warnings.push(`prompt injection pattern (score ${injection.score}): ${injection.hits.map(h => h.label).join(', ')}`);
247
+ }
248
+ }
249
+ }
250
+
251
+ return { allow: true, warnings, dimension: 'pass' };
252
+ }
253
+
254
+ /**
255
+ * Evaluate a tool result/output for credential leakage.
256
+ *
257
+ * @param {object} result - { content?, isError? }
258
+ * @param {object} [userConfig]
259
+ * @returns {{ allow: boolean, reason?: string, warnings: string[] }}
260
+ */
261
+ export function evaluateToolResult(result, userConfig) {
262
+ const config = mergeConfig(DEFAULT_POLICY, userConfig);
263
+ const warnings = [];
264
+
265
+ if (!config.scanOutput.enabled || result.isError) return { allow: true, warnings };
266
+
267
+ // Extract text from content blocks
268
+ let text = '';
269
+ if (Array.isArray(result.content)) {
270
+ for (const block of result.content) {
271
+ if (block?.type === 'text' && typeof block.text === 'string') {
272
+ text += block.text + '\n';
273
+ }
274
+ }
275
+ } else if (typeof result.content === 'string') {
276
+ text = result.content;
277
+ }
278
+
279
+ if (text.length > config.scanOutput.maxScanLength) {
280
+ text = text.slice(0, config.scanOutput.maxScanLength);
281
+ }
282
+
283
+ if (!text) return { allow: true, warnings };
284
+
285
+ // Scan output for leaked secrets
286
+ const secrets = scanForSecrets(text);
287
+ if (secrets.length > 0) {
288
+ warnings.push(`output contains credential patterns: ${secrets.map(s => s.label).join(', ')}`);
289
+ // Redact in a future version; for now warn
290
+ }
291
+
292
+ // Scan output for injection attempts (fetched web content trying to hijack)
293
+ if (config.promptInjection.enabled) {
294
+ const injection = detectInjection(text, { threshold: config.promptInjection.threshold });
295
+ if (injection.blocked) {
296
+ return {
297
+ allow: false,
298
+ reason: `[CCS/Integrity] tool output contains prompt injection: ${injection.reason}`,
299
+ warnings,
300
+ };
301
+ }
302
+ }
303
+
304
+ return { allow: true, warnings };
305
+ }
306
+
307
+ /**
308
+ * Evaluate a subprocess spawn for security violations.
309
+ *
310
+ * @param {object} spec - spawn spec
311
+ * @param {object} [userConfig]
312
+ * @returns {{ allow: boolean, reason?: string, warnings: string[] }}
313
+ */
314
+ export function evaluateSubprocess(spec, userConfig) {
315
+ const config = mergeConfig(DEFAULT_POLICY, userConfig);
316
+ const warnings = [];
317
+
318
+ // Command injection
319
+ if (config.commandInjection.enabled) {
320
+ const result = inspectSpawn(spec, config.commandInjection.threshold);
321
+ if (result.blocked) {
322
+ return { allow: false, reason: `[CCS/Security] ${result.reason}`, warnings };
323
+ }
324
+ if (result.hits.length > 0) {
325
+ warnings.push(`command patterns: ${result.hits.map(h => h.label).join(', ')}`);
326
+ }
327
+ }
328
+
329
+ // Credential exposure
330
+ if (config.credentialExfil.enabled) {
331
+ const credFindings = scanSubprocessForCredentials(spec);
332
+ if (credFindings.length > 0) {
333
+ return {
334
+ allow: false,
335
+ reason: `[CCS/Security] credential exposure in subprocess: ${credFindings.map(f => f.label).join(', ')}`,
336
+ warnings,
337
+ };
338
+ }
339
+ }
340
+
341
+ // Check argv for URLs (SSRF via subprocess)
342
+ if (config.ssrf.enabled) {
343
+ const argv = Array.isArray(spec?.argv) ? spec.argv : [];
344
+ for (const arg of argv) {
345
+ if (typeof arg === 'string' && /^https?:\/\//i.test(arg)) {
346
+ // Synchronous URL check for common private IP patterns (no DNS lookup in sync path)
347
+ if (/^https?:\/\/(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|localhost|0\.0\.0\.0|\[::1\])/i.test(arg)) {
348
+ return {
349
+ allow: false,
350
+ reason: `[CCS/Security] subprocess targets private/internal network: ${arg}`,
351
+ warnings,
352
+ };
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ return { allow: true, warnings };
359
+ }
package/dsh/ssrf.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @file SSRF protection — validates URLs against private network ranges.
3
+ *
4
+ * DeepSeek Harness's built-in web-fetch-http explicitly defers SSRF blocking
5
+ * (see packages/web/web-fetch-http/src/policy.ts line 18: "SSRF / private-network
6
+ * blocking is deferred"). This module fills that gap.
7
+ *
8
+ * Covers: IPv4 private ranges, loopback, link-local, carrier-grade NAT,
9
+ * IPv6 loopback/unique-local/link-local, decimal/octal/hex IP encoding,
10
+ * and DNS rebinding via TTL=0 detection (best-effort).
11
+ */
12
+
13
+ import { lookup } from 'node:dns/promises';
14
+ import { isIP } from 'node:net';
15
+
16
+ /** IPv4 CIDR ranges that must never be reachable from a plugin/tool fetch. */
17
+ const V4_BLOCKLIST = [
18
+ { network: 0n, broadcast: 255n, label: 'this network (0.0.0.0/8)' },
19
+ { network: 167772160n, broadcast: 184549375n, label: 'private (10.0.0.0/8)' },
20
+ { network: 2130706432n, broadcast: 2147483647n, label: 'loopback (127.0.0.0/8)' },
21
+ { network: 2851995648n, broadcast: 2852061183n, label: 'link-local (169.254.0.0/16)' },
22
+ { network: 2886729728n, broadcast: 2887778303n, label: 'private (172.16.0.0/12)' },
23
+ { network: 3221225472n, broadcast: 3221225727n, label: 'documentation (192.0.2.0/24)' },
24
+ { network: 3221225984n, broadcast: 3221226239n, label: 'documentation (192.0.2.0/24)' },
25
+ { network: 3232235520n, broadcast: 3232301055n, label: 'private (192.168.0.0/16)' },
26
+ { network: 3272853504n, broadcast: 3272919039n, label: 'benchmarking (198.18.0.0/15)' },
27
+ { network: 3323068416n, broadcast: 3323199487n, label: 'documentation (203.0.113.0/24)' },
28
+ { network: 3325256704n, broadcast: 3325256959n, label: 'reserved (224.0.0.0/4 multicast)' },
29
+ { network: 3758096384n, broadcast: 4026531839n, label: 'reserved (224.0.0.0/4)' },
30
+ { network: 4026531840n, broadcast: 4294967295n, label: 'reserved (240.0.0.0/4)' },
31
+ // Cloud metadata endpoints
32
+ { network: ipToLong('169.254.169.254'), broadcast: ipToLong('169.254.169.254'), label: 'cloud metadata (169.254.169.254)' },
33
+ { network: ipToLong('100.100.100.200'), broadcast: ipToLong('100.100.100.200'), label: 'Alibaba Cloud metadata (100.100.100.200)' },
34
+ ];
35
+
36
+ /** Convert a dotted-quad IPv4 string to a 32-bit integer (as BigInt). */
37
+ function ipToLong(ip) {
38
+ const parts = ip.split('.').map(Number);
39
+ return BigInt(parts[0] * 16777216 + parts[1] * 65536 + parts[2] * 256 + parts[3]);
40
+ }
41
+
42
+ /** Parse a numeric string that may be decimal, octal (0o), or hex (0x). */
43
+ function parseNumericHost(raw) {
44
+ const trimmed = raw.trim().toLowerCase();
45
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(trimmed)) return trimmed;
46
+ // Handle decimal integer form (e.g. http://2130706433 = 127.0.0.1)
47
+ if (/^\d+$/.test(trimmed)) {
48
+ const num = BigInt(trimmed);
49
+ return `${(num >> 24n) & 255n}.${(num >> 16n) & 255n}.${(num >> 8n) & 255n}.${num & 255n}`;
50
+ }
51
+ // Hex form
52
+ if (trimmed.startsWith('0x')) {
53
+ const num = BigInt(trimmed);
54
+ return `${(num >> 24n) & 255n}.${(num >> 16n) & 255n}.${(num >> 8n) & 255n}.${num & 255n}`;
55
+ }
56
+ // Octal form per part
57
+ if (/^0[0-7]+(\.0[0-7]+){0,3}$/.test(trimmed)) {
58
+ const parts = trimmed.split('.').map(p => parseInt(p, 8));
59
+ while (parts.length < 4) parts.push(0);
60
+ return parts.join('.');
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /** Check whether an IPv4 address string falls in a blocked range. */
66
+ export function isBlockedIPv4(ip) {
67
+ const num = ipToLong(ip);
68
+ for (const range of V4_BLOCKLIST) {
69
+ if (num >= range.network && num <= range.broadcast) {
70
+ return range.label;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** Check whether an IPv6 address is loopback, link-local, unique-local, or unspecified. */
77
+ export function isBlockedIPv6(ip) {
78
+ const lower = ip.toLowerCase();
79
+ if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return 'loopback (::1)';
80
+ if (lower === '::' || lower === '0:0:0:0:0:0:0:0') return 'unspecified (::)';
81
+ if (lower.startsWith('fe80:')) return 'link-local (fe80::/10)';
82
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return 'unique-local (fc00::/7)';
83
+ if (lower.startsWith('ff') || lower.startsWith('ff00:')) return 'multicast (ff00::/8)';
84
+ // IPv4-mapped IPv6 (::ffff:a.b.c.d)
85
+ const v4Mapped = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/);
86
+ if (v4Mapped) return isBlockedIPv4(v4Mapped[1]);
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Validate a URL for SSRF. Returns a denial reason string if blocked,
92
+ * or undefined if safe.
93
+ *
94
+ * @param {string} urlString - the URL to validate.
95
+ * @param {object} [opts]
96
+ * @param {boolean} [opts.allowPrivate=false] - permit private IP targets.
97
+ * @returns {Promise<string|undefined>} denial reason, or undefined.
98
+ */
99
+ export async function validateFetchUrl(urlString, opts = {}) {
100
+ let url;
101
+ try {
102
+ url = new URL(urlString);
103
+ } catch {
104
+ return `invalid URL: ${urlString}`;
105
+ }
106
+
107
+ // Scheme check
108
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
109
+ return `blocked scheme "${url.protocol}" — only http/https permitted`;
110
+ }
111
+
112
+ // Embedded credentials
113
+ if (url.username || url.password) {
114
+ return 'URL contains embedded credentials';
115
+ }
116
+
117
+ const hostname = url.hostname;
118
+
119
+ // IP literal? (URL.hostname already strips brackets for IPv6)
120
+ const ipVersion = isIP(hostname);
121
+ if (ipVersion === 4) {
122
+ const reason = isBlockedIPv4(hostname);
123
+ if (reason && !opts.allowPrivate) return `SSRF blocked: target is ${reason}`;
124
+ return undefined;
125
+ }
126
+ if (ipVersion === 6) {
127
+ const reason = isBlockedIPv6(hostname);
128
+ if (reason && !opts.allowPrivate) return `SSRF blocked: target is ${reason}`;
129
+ return undefined;
130
+ }
131
+
132
+ // Also check for bracketed IPv6 that isIP might reject
133
+ const bracketMatch = hostname.match(/^\[(.+)\]$/);
134
+ if (bracketMatch) {
135
+ const inner = bracketMatch[1];
136
+ if (isIP(inner) === 6) {
137
+ const reason = isBlockedIPv6(inner);
138
+ if (reason && !opts.allowPrivate) return `SSRF blocked: target is ${reason}`;
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ // Numeric encoding attempts
144
+ const numeric = parseNumericHost(hostname);
145
+ if (numeric) {
146
+ const reason = isBlockedIPv4(numeric);
147
+ if (reason && !opts.allowPrivate) return `SSRF blocked: numeric host "${hostname}" resolves to ${reason}`;
148
+ return undefined;
149
+ }
150
+
151
+ // DNS resolution — check resolved IPs
152
+ if (!opts.allowPrivate) {
153
+ try {
154
+ const addresses = await lookup(hostname, { all: true, verbatim: true });
155
+ for (const addr of addresses) {
156
+ if (addr.family === 4) {
157
+ const reason = isBlockedIPv4(addr.address);
158
+ if (reason) return `SSRF blocked: "${hostname}" resolves to ${reason} (${addr.address})`;
159
+ } else {
160
+ const reason = isBlockedIPv6(addr.address);
161
+ if (reason) return `SSRF blocked: "${hostname}" resolves to ${reason} (${addr.address})`;
162
+ }
163
+ }
164
+ } catch {
165
+ // DNS failure — allow (the fetch itself will fail)
166
+ }
167
+ }
168
+
169
+ return undefined;
170
+ }
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "ccs-mcp-server",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP security server for AI agents — runtime verification, receipt verification and field-level tamper localization for Claude Code, Cursor, Cline and Windsurf. 7-dimension CCS checks, Ed25519 tamper-evident receipts, offline verification, zero runtime dependencies.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
- "ccs-mcp-server": "src/index.js"
7
+ "ccs-mcp-server": "src/index.js",
8
+ "agent-runtime-guard": "src/index.js"
8
9
  },
9
10
  "main": "src/index.js",
10
11
  "exports": {
11
12
  ".": "./src/index.js",
12
13
  "./receipts": "./src/receipts.js",
13
- "./tamper": "./src/locate_tampering.js",
14
+ "./dsh": "./dsh/index.js",
15
+ "./dsh/cordis.patch.yml": "./dsh/cordis.patch.yml",
14
16
  "./package.json": "./package.json"
15
17
  },
16
18
  "files": [
17
19
  "src",
20
+ "dsh",
18
21
  "README.md",
19
22
  "LICENSE",
20
23
  "test"
@@ -57,16 +60,29 @@
57
60
  "type": "git",
58
61
  "url": "git+https://github.com/DSHCorrectover/ccs-mcp-server.git"
59
62
  },
60
- "homepage": "https://github.com/DSHCorrectover/agent-runtime-guard#readme",
63
+ "homepage": "https://correctover.com",
61
64
  "bugs": {
62
- "url": "https://github.com/DSHCorrectover/agent-runtime-guard/issues"
65
+ "url": "https://github.com/DSHCorrectover/ccs-mcp-server/issues"
63
66
  },
64
67
  "engines": {
65
68
  "node": ">=18"
66
69
  },
67
70
  "dependencies": {},
71
+ "peerDependencies": {
72
+ "@deepseek-ai/dsh": ">=0.2.1-alpha.1 <0.3.0-0"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "@deepseek-ai/dsh": {
76
+ "optional": true
77
+ }
78
+ },
68
79
  "scripts": {
69
80
  "test": "node test/run-tests.js"
70
81
  },
82
+ "mcp": {
83
+ "name": "ccs-mcp-server",
84
+ "displayName": "CCS MCP Server",
85
+ "description": "Full-stack runtime interception for AI agents — blocks destructive commands, credential theft, SSRF and injection, with Ed25519 evidence receipts."
86
+ },
71
87
  "mcpName": "io.github.DSHCorrectover/ccs-mcp-server"
72
88
  }