ccs-mcp-server 1.2.17 → 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.
- package/README.md +91 -87
- package/dsh/cmdi.js +98 -0
- package/dsh/cordis.patch.yml +5 -0
- package/dsh/credentials.js +169 -0
- package/dsh/index.js +730 -0
- package/dsh/injection.js +75 -0
- package/dsh/package.json +5 -0
- package/dsh/policy.js +359 -0
- package/dsh/ssrf.js +170 -0
- package/package.json +51 -27
- package/src/index.js +111 -3
- package/src/locate_tampering.js +240 -0
- package/src/skillpay.js +0 -0
- package/test/run-tests.js +355 -0
package/dsh/index.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file correctover/dsh — DeepSeek Harness runtime security guard.
|
|
3
|
+
*
|
|
4
|
+
* CCS (Correctover Conformance Shape) is a 7-dimension runtime verification
|
|
5
|
+
* standard for AI agents. This DSH plugin enforces security dimensions at
|
|
6
|
+
* the tool-call, subprocess, and fetch boundaries — not by static scanning
|
|
7
|
+
* plugin source files, but by inspecting every runtime operation before it
|
|
8
|
+
* executes.
|
|
9
|
+
*
|
|
10
|
+
* v1.1.0 — adapted to DeepSeek Harness host v0.2.1-alpha.1 event contract:
|
|
11
|
+
* - `tools/pre-execute` is a WATERFALL: listener `(exec, next)`. Pass-through
|
|
12
|
+
* MUST `return next(exec)`; only a confirmed hit short-circuits with a deny.
|
|
13
|
+
* - `tools/post-execute` is a WATERFALL: listener `(exec, result, next)`.
|
|
14
|
+
* Pass-through MUST `return next(exec, result)`; redacted outputs are
|
|
15
|
+
* returned as `{ kind: 'accept', content }`; blocks use `{kind:'block'}`.
|
|
16
|
+
* - SSRF main line: native-tool-name audit of `web_fetch` arguments.url.
|
|
17
|
+
* - Command-injection main line: native-tool-name audit of `bash`
|
|
18
|
+
* arguments.command. The subprocess.spawn wrapper remains as
|
|
19
|
+
* defense-in-depth only.
|
|
20
|
+
* - No global-state side effects (globalThis.__dshCcsCtx removed).
|
|
21
|
+
*
|
|
22
|
+
* Registers:
|
|
23
|
+
* - `ccs_status` model tool: report current policy and stats
|
|
24
|
+
* - `ccs_audit` model tool: run a security audit on installed plugins
|
|
25
|
+
* - tools/pre-execute hook: block dangerous tool calls
|
|
26
|
+
* - tools/post-execute hook: scan outputs for leaked secrets / injection
|
|
27
|
+
* - subprocess spawn wrapper (defense-in-depth): block command injection
|
|
28
|
+
* - optional registerFetchProvider wrapper (defense-in-depth): block SSRF
|
|
29
|
+
*
|
|
30
|
+
* @module correctover/dsh
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { evaluateToolCall, evaluateSubprocess, DEFAULT_POLICY } from './policy.js';
|
|
37
|
+
import { scanForSecrets, isCredentialPath, redactSecretsInText } from './credentials.js';
|
|
38
|
+
import { detectInjection } from './injection.js';
|
|
39
|
+
import { inspectSpawn } from './cmdi.js';
|
|
40
|
+
import { validateFetchUrl } from './ssrf.js';
|
|
41
|
+
|
|
42
|
+
const name = 'correctover-dsh';
|
|
43
|
+
const inject = ['tools'];
|
|
44
|
+
|
|
45
|
+
const VERSION = '1.1.0';
|
|
46
|
+
|
|
47
|
+
// Native v0.2.1 tool names / argument locations (authoritative contract).
|
|
48
|
+
const NATIVE_BASH = 'bash'; // args.command (+description/workdir/run_in_background)
|
|
49
|
+
const NATIVE_WEB_FETCH = 'web_fetch'; // args.url
|
|
50
|
+
const NATIVE_WEB_SEARCH = 'web_search'; // args.queries (array) — generic policy path
|
|
51
|
+
const NATIVE_RUN_CODE = 'run_code'; // PTC mode tool — generic policy path
|
|
52
|
+
|
|
53
|
+
// ── State ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
let stats = {
|
|
56
|
+
toolCallsChecked: 0,
|
|
57
|
+
toolCallsBlocked: 0,
|
|
58
|
+
toolResultsScanned: 0,
|
|
59
|
+
outputsRedacted: 0,
|
|
60
|
+
subprocessesChecked: 0,
|
|
61
|
+
subprocessesBlocked: 0,
|
|
62
|
+
fetchCallsChecked: 0,
|
|
63
|
+
fetchCallsBlocked: 0,
|
|
64
|
+
secretsDetected: 0,
|
|
65
|
+
injectionAttempts: 0,
|
|
66
|
+
internalErrors: 0,
|
|
67
|
+
blockReasons: [],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let config = structuredClone(DEFAULT_POLICY);
|
|
71
|
+
let logFile;
|
|
72
|
+
let ctxLogger;
|
|
73
|
+
|
|
74
|
+
function resolveDshHome() {
|
|
75
|
+
return process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function initLog() {
|
|
79
|
+
const logDir = join(resolveDshHome(), '.correctover-dsh');
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(logDir, { recursive: true });
|
|
82
|
+
logFile = join(logDir, 'security.log');
|
|
83
|
+
} catch {
|
|
84
|
+
logFile = undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function log(level, message, detail) {
|
|
89
|
+
const entry = JSON.stringify({
|
|
90
|
+
ts: new Date().toISOString(),
|
|
91
|
+
level,
|
|
92
|
+
message,
|
|
93
|
+
...detail && { detail },
|
|
94
|
+
});
|
|
95
|
+
if (logFile) {
|
|
96
|
+
try { appendFileSync(logFile, entry + '\n', 'utf8'); } catch { /* best-effort */ }
|
|
97
|
+
}
|
|
98
|
+
if (ctxLogger?.[level]) {
|
|
99
|
+
try { ctxLogger[level](`[ccs] ${message}`); } catch { /* best-effort */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function recordBlock(reason) {
|
|
104
|
+
stats.toolCallsBlocked++;
|
|
105
|
+
stats.blockReasons.push({ ts: new Date().toISOString(), reason: reason.slice(0, 200) });
|
|
106
|
+
if (stats.blockReasons.length > 50) stats.blockReasons.shift();
|
|
107
|
+
log('warn', 'blocked', { reason: reason.slice(0, 500) });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Tool definitions ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const STATUS_TOOL = {
|
|
113
|
+
name: 'ccs_status',
|
|
114
|
+
description: 'Report CCS runtime security guard status: policy configuration, cumulative block/scan statistics, and recent security events. Use this to verify CCS is active and review what has been blocked.',
|
|
115
|
+
parameters: { type: 'object', properties: {} },
|
|
116
|
+
output: {
|
|
117
|
+
schema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
properties: {
|
|
121
|
+
active: { type: 'boolean' },
|
|
122
|
+
version: { type: 'string' },
|
|
123
|
+
policy: { type: 'object' },
|
|
124
|
+
stats: { type: 'object' },
|
|
125
|
+
recentBlocks: { type: 'array', items: { type: 'object' } },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
render: (_args, value) => {
|
|
129
|
+
const lines = [
|
|
130
|
+
'# CCS Runtime Security Guard',
|
|
131
|
+
'',
|
|
132
|
+
`**Status**: ${value.active ? 'ACTIVE / 运行中' : 'INACTIVE'}`,
|
|
133
|
+
`**Version**: ${value.version}`,
|
|
134
|
+
'',
|
|
135
|
+
'## Statistics / 统计',
|
|
136
|
+
'',
|
|
137
|
+
`- Tool calls checked: ${value.stats.toolCallsChecked}`,
|
|
138
|
+
`- Tool calls blocked: ${value.stats.toolCallsBlocked}`,
|
|
139
|
+
`- Tool results scanned: ${value.stats.toolResultsScanned}`,
|
|
140
|
+
`- Outputs redacted: ${value.stats.outputsRedacted}`,
|
|
141
|
+
`- Subprocesses checked: ${value.stats.subprocessesChecked}`,
|
|
142
|
+
`- Subprocesses blocked: ${value.stats.subprocessesBlocked}`,
|
|
143
|
+
`- Fetch calls checked: ${value.stats.fetchCallsChecked}`,
|
|
144
|
+
`- Fetch calls blocked: ${value.stats.fetchCallsBlocked}`,
|
|
145
|
+
`- Secrets detected: ${value.stats.secretsDetected}`,
|
|
146
|
+
`- Injection attempts: ${value.stats.injectionAttempts}`,
|
|
147
|
+
`- Internal errors (fail-open): ${value.stats.internalErrors}`,
|
|
148
|
+
];
|
|
149
|
+
if (value.recentBlocks.length > 0) {
|
|
150
|
+
lines.push('', '## Recent Blocks / 最近拦截', '');
|
|
151
|
+
for (const b of value.recentBlocks.slice(-10)) {
|
|
152
|
+
lines.push(`- [${b.ts}] ${b.reason}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
async execute() {
|
|
159
|
+
return {
|
|
160
|
+
active: true,
|
|
161
|
+
version: VERSION,
|
|
162
|
+
policy: {
|
|
163
|
+
ssrf: config.ssrf,
|
|
164
|
+
commandInjection: config.commandInjection,
|
|
165
|
+
credentialExfil: config.credentialExfil,
|
|
166
|
+
promptInjection: config.promptInjection,
|
|
167
|
+
destructiveTools: { enabled: config.destructiveTools.enabled, requireApprovalCount: config.destructiveTools.requireApproval.length },
|
|
168
|
+
},
|
|
169
|
+
stats: { ...stats },
|
|
170
|
+
recentBlocks: stats.blockReasons.slice(-10),
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const AUDIT_TOOL = {
|
|
176
|
+
name: 'ccs_audit',
|
|
177
|
+
description: 'Run a CCS security audit on installed DSH plugins. Scans plugin source for credential access patterns, command injection risks, and network exfiltration indicators — with runtime-intent analysis (not just keyword matching). Returns per-plugin risk assessment.',
|
|
178
|
+
parameters: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
path: { type: 'string', description: 'Optional path to scan. Defaults to all profiles under $DSH_HOME/profiles.' },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
output: {
|
|
185
|
+
schema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
additionalProperties: false,
|
|
188
|
+
properties: {
|
|
189
|
+
scanned: { type: 'number' },
|
|
190
|
+
plugins: { type: 'array', items: { type: 'object' } },
|
|
191
|
+
summary: { type: 'object' },
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
render: (_args, value) => {
|
|
195
|
+
const lines = [
|
|
196
|
+
`# CCS Plugin Audit — ${value.scanned} plugin(s) scanned`,
|
|
197
|
+
'',
|
|
198
|
+
`High: ${value.summary.high} Medium: ${value.summary.medium} Low: ${value.summary.low} Safe: ${value.summary.safe}`,
|
|
199
|
+
];
|
|
200
|
+
for (const p of value.plugins) {
|
|
201
|
+
lines.push('', `## [${p.risk}] ${p.name}@${p.version}`, '');
|
|
202
|
+
for (const f of p.findings.slice(0, 8)) {
|
|
203
|
+
lines.push(`- **${f.severity}** ${f.category}: ${f.detail}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
async execute(args) {
|
|
210
|
+
const roots = [];
|
|
211
|
+
if (args?.path) {
|
|
212
|
+
roots.push(args.path);
|
|
213
|
+
} else {
|
|
214
|
+
const profiles = join(resolveDshHome(), 'profiles');
|
|
215
|
+
if (existsSync(profiles)) {
|
|
216
|
+
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
217
|
+
if (!entry.isDirectory()) continue;
|
|
218
|
+
const nm = join(profiles, entry.name, 'node_modules');
|
|
219
|
+
if (existsSync(nm)) roots.push(nm);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const plugins = [];
|
|
225
|
+
const summary = { high: 0, medium: 0, low: 0, safe: 0 };
|
|
226
|
+
|
|
227
|
+
for (const root of roots) {
|
|
228
|
+
let entries;
|
|
229
|
+
try { entries = readdirSync(root, { withFileTypes: true }); } catch { continue; }
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (!entry.isDirectory() && !entry.name.startsWith('@')) continue;
|
|
232
|
+
const pkgDir = join(root, entry.name);
|
|
233
|
+
const pkgJsonPath = join(pkgDir, 'package.json');
|
|
234
|
+
if (!existsSync(pkgJsonPath)) continue;
|
|
235
|
+
|
|
236
|
+
let pkg;
|
|
237
|
+
try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); } catch { continue; }
|
|
238
|
+
|
|
239
|
+
// Skip official packages
|
|
240
|
+
if (pkg.name?.startsWith('@deepseek-ai/')) continue;
|
|
241
|
+
|
|
242
|
+
const findings = auditPlugin(pkgDir, pkg);
|
|
243
|
+
let risk = 'SAFE';
|
|
244
|
+
const score = findings.reduce((s, f) => s + ({ HIGH: 10, MEDIUM: 5, LOW: 2 }[f.severity] || 0), 0);
|
|
245
|
+
if (score >= 10) { risk = 'HIGH'; summary.high++; }
|
|
246
|
+
else if (score >= 5) { risk = 'MEDIUM'; summary.medium++; }
|
|
247
|
+
else if (score > 0) { risk = 'LOW'; summary.low++; }
|
|
248
|
+
else { summary.safe++; }
|
|
249
|
+
|
|
250
|
+
plugins.push({ name: pkg.name || entry.name, version: pkg.version || '0.0.0', risk, findings });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { scanned: plugins.length, plugins, summary };
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Audit a single plugin package for security risks.
|
|
260
|
+
* Uses runtime-intent analysis rather than pure keyword matching.
|
|
261
|
+
*/
|
|
262
|
+
function auditPlugin(pkgDir, pkg) {
|
|
263
|
+
const findings = [];
|
|
264
|
+
|
|
265
|
+
// Collect source files
|
|
266
|
+
const sourceFiles = [];
|
|
267
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', '.DS_Store']);
|
|
268
|
+
function walk(dir, depth = 0) {
|
|
269
|
+
if (depth > 5 || sourceFiles.length >= 200) return;
|
|
270
|
+
let entries;
|
|
271
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
272
|
+
for (const e of entries) {
|
|
273
|
+
if (sourceFiles.length >= 200) return;
|
|
274
|
+
const full = join(dir, e.name);
|
|
275
|
+
if (e.isDirectory()) {
|
|
276
|
+
if (!SKIP.has(e.name)) walk(full, depth + 1);
|
|
277
|
+
} else if (/\.(js|mjs|cjs|ts)$/.test(e.name)) {
|
|
278
|
+
sourceFiles.push(full);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
walk(pkgDir);
|
|
283
|
+
|
|
284
|
+
let allSource = '';
|
|
285
|
+
for (const file of sourceFiles) {
|
|
286
|
+
try { allSource += readFileSync(file, 'utf8') + '\n'; } catch { /* skip */ }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!allSource) return findings;
|
|
290
|
+
|
|
291
|
+
// Check for network exfiltration capability
|
|
292
|
+
if (/\bfetch\s*\(/.test(allSource) || /\bhttps?\.request\b/.test(allSource) || /\bnet\.connect\b/.test(allSource)) {
|
|
293
|
+
const hasSecrets = scanForSecrets(allSource);
|
|
294
|
+
if (hasSecrets.length > 0) {
|
|
295
|
+
findings.push({ severity: 'HIGH', category: 'data-exfiltration', detail: `makes network requests AND references credential patterns (${hasSecrets.map(s => s.label).join(', ')})` });
|
|
296
|
+
} else {
|
|
297
|
+
findings.push({ severity: 'LOW', category: 'network-access', detail: 'makes outbound network requests' });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Check for subprocess execution
|
|
302
|
+
if (/child_process|\bspawn\s*\(|\bexec(Sync)?\s*\(/.test(allSource)) {
|
|
303
|
+
const cmdResult = inspectSpawn({ command: allSource.slice(0, 5000) }, 12);
|
|
304
|
+
if (cmdResult.blocked) {
|
|
305
|
+
findings.push({ severity: 'HIGH', category: 'command-injection', detail: cmdResult.reason });
|
|
306
|
+
} else {
|
|
307
|
+
findings.push({ severity: 'MEDIUM', category: 'subprocess', detail: 'spawns child processes' });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Check for dynamic code execution
|
|
312
|
+
if (/\beval\s*\(|new Function\s*\(|vm\.runIn/.test(allSource)) {
|
|
313
|
+
findings.push({ severity: 'HIGH', category: 'code-execution', detail: 'uses dynamic code execution (eval/Function/vm) — runtime behavior cannot be statically determined' });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check for credential file access
|
|
317
|
+
if (/\.ssh\/id_rsa|\.aws\/credentials|\.credentials\.ya?ml|\.netrc|\.pgpass/.test(allSource)) {
|
|
318
|
+
findings.push({ severity: 'HIGH', category: 'credential-access', detail: 'references known credential file paths' });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check for env credential reads
|
|
322
|
+
if (/process\.env\.[A-Z_]*(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(allSource)) {
|
|
323
|
+
findings.push({ severity: 'MEDIUM', category: 'credential-access', detail: 'reads credential-like environment variables' });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Check for prompt injection patterns in source
|
|
327
|
+
const injection = detectInjection(allSource.slice(0, 10000), { threshold: 10 });
|
|
328
|
+
if (injection.blocked) {
|
|
329
|
+
findings.push({ severity: 'MEDIUM', category: 'prompt-injection', detail: `source contains injection-like patterns: ${injection.hits.map(h => h.label).join(', ')}` });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check for persistence mechanisms
|
|
333
|
+
if (/\.bashrc|\.zshrc|\.profile|authorized_keys|schtasks|cron/.test(allSource)) {
|
|
334
|
+
findings.push({ severity: 'MEDIUM', category: 'persistence', detail: 'references persistence mechanisms' });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check for obfuscation
|
|
338
|
+
if (/String\.fromCharCode|\\x[0-9a-f]{2}\\x[0-9a-f]{2}/.test(allSource)) {
|
|
339
|
+
findings.push({ severity: 'MEDIUM', category: 'obfuscation', detail: 'uses obfuscation techniques' });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Check npm lifecycle scripts
|
|
343
|
+
if (pkg.scripts) {
|
|
344
|
+
const lifecycle = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublishOnly'];
|
|
345
|
+
for (const hook of lifecycle) {
|
|
346
|
+
if (pkg.scripts[hook]) {
|
|
347
|
+
findings.push({ severity: 'LOW', category: 'supply-chain', detail: `has "${hook}" lifecycle script: ${pkg.scripts[hook].slice(0, 100)}` });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return findings;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── Native-tool-name auditors (v0.2.1 main defense lines) ────────────────
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Audit a native `bash` tool call by its arguments.command.
|
|
359
|
+
* Unlike the generic policy (which only deep-runs cmdi patterns when a loose
|
|
360
|
+
* gate matches), this always runs the full pattern set against the command,
|
|
361
|
+
* checks credential locations/patterns, and fully (DNS-aware) validates every
|
|
362
|
+
* http(s) URL embedded in the command.
|
|
363
|
+
*
|
|
364
|
+
* @returns {Promise<{allow: boolean, reason?: string, warnings: string[]}>}
|
|
365
|
+
*/
|
|
366
|
+
async function auditBashCall(args) {
|
|
367
|
+
const warnings = [];
|
|
368
|
+
const command = typeof args?.command === 'string' ? args.command : '';
|
|
369
|
+
if (!command) return { allow: true, warnings };
|
|
370
|
+
|
|
371
|
+
// 1. Command injection / dangerous commands — full pattern set, no gate.
|
|
372
|
+
if (config.commandInjection.enabled) {
|
|
373
|
+
const result = inspectSpawn({ argv: [command] }, config.commandInjection.threshold);
|
|
374
|
+
if (result.blocked) {
|
|
375
|
+
return { allow: false, reason: `[CCS/Security] ${result.reason}`, warnings };
|
|
376
|
+
}
|
|
377
|
+
if (result.hits.length > 0) {
|
|
378
|
+
warnings.push(`command patterns (score ${result.score}): ${result.hits.map(h => h.label).join(', ')}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 2. Credential file locations and secret patterns inside the command.
|
|
383
|
+
if (config.credentialExfil.enabled) {
|
|
384
|
+
if (isCredentialPath(command)) {
|
|
385
|
+
return { allow: false, reason: `[CCS/Security] bash command accesses a credential location: ${command.slice(0, 150)}`, warnings };
|
|
386
|
+
}
|
|
387
|
+
const secrets = scanForSecrets(command);
|
|
388
|
+
if (secrets.length > 0) {
|
|
389
|
+
return { allow: false, reason: `[CCS/Security] credential pattern in bash command: ${secrets.map(s => s.label).join(', ')}`, warnings };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// 3. SSRF — every URL in the command gets the DNS-aware validator.
|
|
394
|
+
if (config.ssrf.enabled) {
|
|
395
|
+
const urls = command.match(/https?:\/\/[^\s"'`<>|)]+/gi) || [];
|
|
396
|
+
for (const url of urls) {
|
|
397
|
+
const reason = await validateFetchUrl(url, { allowPrivate: config.ssrf.allowPrivate });
|
|
398
|
+
if (reason) {
|
|
399
|
+
return { allow: false, reason: `[CCS/Security] ${reason}`, warnings };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { allow: true, warnings };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Audit a native `web_fetch` tool call by its arguments.url (SSRF main line).
|
|
409
|
+
*
|
|
410
|
+
* @returns {Promise<{allow: boolean, reason?: string, warnings: string[]}>}
|
|
411
|
+
*/
|
|
412
|
+
async function auditWebFetchCall(args) {
|
|
413
|
+
const warnings = [];
|
|
414
|
+
const url = args?.url;
|
|
415
|
+
if (typeof url !== 'string' || !url) return { allow: true, warnings };
|
|
416
|
+
|
|
417
|
+
if (config.ssrf.enabled) {
|
|
418
|
+
const reason = await validateFetchUrl(url, { allowPrivate: config.ssrf.allowPrivate });
|
|
419
|
+
if (reason) {
|
|
420
|
+
return { allow: false, reason: `[CCS/Security] ${reason}`, warnings };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Credential patterns in a network-facing call must not leave the host.
|
|
425
|
+
if (config.credentialExfil.enabled) {
|
|
426
|
+
const secrets = scanForSecrets(args);
|
|
427
|
+
if (secrets.length > 0) {
|
|
428
|
+
return { allow: false, reason: `[CCS/Security] credential pattern in web_fetch arguments: ${secrets.map(s => s.label).join(', ')}`, warnings };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return { allow: true, warnings };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Extract concatenated text from ContentBlocks for policy scanning.
|
|
437
|
+
*/
|
|
438
|
+
function contentText(content) {
|
|
439
|
+
let text = '';
|
|
440
|
+
if (Array.isArray(content)) {
|
|
441
|
+
for (const block of content) {
|
|
442
|
+
if (block?.type === 'text' && typeof block.text === 'string') text += block.text + '\n';
|
|
443
|
+
}
|
|
444
|
+
} else if (typeof content === 'string') {
|
|
445
|
+
text = content;
|
|
446
|
+
}
|
|
447
|
+
return text;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Plugin apply ─────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
function apply(ctx, userConfig) {
|
|
453
|
+
// Merge user config
|
|
454
|
+
if (userConfig && typeof userConfig === 'object') {
|
|
455
|
+
config = deepMerge(config, userConfig);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
ctxLogger = ctx.logger;
|
|
459
|
+
initLog();
|
|
460
|
+
log('info', `CCS runtime security guard starting (v${VERSION})`);
|
|
461
|
+
|
|
462
|
+
// Register tools (output schema/render already conform to v0.2.1 contract)
|
|
463
|
+
ctx.effect(() => ctx.tools.register(STATUS_TOOL), 'ccs: status tool');
|
|
464
|
+
ctx.effect(() => ctx.tools.register(AUDIT_TOOL), 'ccs: audit tool');
|
|
465
|
+
|
|
466
|
+
// ── Pre-execute waterfall ─────────────────────────────────────────────
|
|
467
|
+
// Signature: async (exec, next). Only a confirmed dangerous call
|
|
468
|
+
// short-circuits; every other path (including internal errors) continues
|
|
469
|
+
// the chain via next(exec).
|
|
470
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
471
|
+
stats.toolCallsChecked++;
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const args = (exec.arguments && typeof exec.arguments === 'object') ? exec.arguments : {};
|
|
475
|
+
let decision;
|
|
476
|
+
|
|
477
|
+
if (exec.name === NATIVE_BASH) {
|
|
478
|
+
// Command-injection main line: audit arguments.command by tool name.
|
|
479
|
+
decision = await auditBashCall(args);
|
|
480
|
+
} else if (exec.name === NATIVE_WEB_FETCH) {
|
|
481
|
+
// SSRF main line: audit arguments.url by tool name.
|
|
482
|
+
decision = await auditWebFetchCall(args);
|
|
483
|
+
} else {
|
|
484
|
+
// Everything else (incl. web_search / run_code and third-party
|
|
485
|
+
// tools): generic deep policy evaluation.
|
|
486
|
+
decision = await evaluateToolCall(
|
|
487
|
+
{ name: exec.name, arguments: exec.arguments, agent: exec.agent },
|
|
488
|
+
config,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!decision.allow) {
|
|
493
|
+
recordBlock(decision.reason);
|
|
494
|
+
// Intercept: short-circuit is the correct waterfall behavior.
|
|
495
|
+
return { kind: 'deny', reason: decision.reason };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
for (const w of decision.warnings) {
|
|
499
|
+
log('warn', `tool "${exec.name}": ${w}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Pass-through: MUST delegate down the chain.
|
|
503
|
+
return next(exec);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
// Narrow fail-open: only for the plugin's OWN internal errors.
|
|
506
|
+
// Record the error, then continue the chain — never fabricate an
|
|
507
|
+
// `allow` (which would swallow every downstream listener).
|
|
508
|
+
stats.internalErrors++;
|
|
509
|
+
log('error', `pre-execute evaluation error: ${err?.message || err}`);
|
|
510
|
+
return next(exec);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ── Post-execute waterfall ────────────────────────────────────────────
|
|
515
|
+
// Signature: async (exec, result, next). Clean results continue the
|
|
516
|
+
// chain; prompt-injection content is blocked; secrets are redacted and
|
|
517
|
+
// returned via {kind:'accept', content}.
|
|
518
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
519
|
+
stats.toolResultsScanned++;
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
if (!result.isError && config.scanOutput.enabled && Array.isArray(result.content)) {
|
|
523
|
+
const maxScan = config.scanOutput.maxScanLength;
|
|
524
|
+
const fullText = contentText(result.content);
|
|
525
|
+
const scanRegion = fullText.slice(0, maxScan);
|
|
526
|
+
|
|
527
|
+
// 1. Indirect prompt injection in fetched/tool output → block.
|
|
528
|
+
if (config.promptInjection.enabled) {
|
|
529
|
+
const injection = detectInjection(scanRegion, { threshold: config.promptInjection.threshold });
|
|
530
|
+
if (injection.blocked) {
|
|
531
|
+
stats.injectionAttempts++;
|
|
532
|
+
recordBlock(`output from "${exec.name}": ${injection.reason}`);
|
|
533
|
+
return {
|
|
534
|
+
kind: 'block',
|
|
535
|
+
feedback: [{ type: 'text', text: `[CCS] Tool output blocked: ${injection.reason}` }],
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 2. Secrets in output → redact per text block, accept with content.
|
|
541
|
+
let redactedCount = 0;
|
|
542
|
+
const redactedLabels = new Set();
|
|
543
|
+
const newContent = result.content.map((block) => {
|
|
544
|
+
if (block?.type !== 'text' || typeof block.text !== 'string') return block;
|
|
545
|
+
const region = block.text.slice(0, maxScan);
|
|
546
|
+
const result2 = redactSecretsInText(region);
|
|
547
|
+
if (result2.found > 0) {
|
|
548
|
+
redactedCount += result2.found;
|
|
549
|
+
for (const l of result2.labels) redactedLabels.add(l);
|
|
550
|
+
return { ...block, text: result2.text + block.text.slice(region.length) };
|
|
551
|
+
}
|
|
552
|
+
return block;
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (redactedCount > 0) {
|
|
556
|
+
stats.secretsDetected += redactedCount;
|
|
557
|
+
stats.outputsRedacted++;
|
|
558
|
+
log('warn', `redacted ${redactedCount} secret pattern(s) in output from "${exec.name}": ${[...redactedLabels].join(', ')}`);
|
|
559
|
+
// Redacted replacement via the supported accept-with-content path.
|
|
560
|
+
return { kind: 'accept', content: newContent };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Clean result: MUST continue the chain.
|
|
565
|
+
return next(exec, result);
|
|
566
|
+
} catch (err) {
|
|
567
|
+
// Narrow fail-open for plugin-internal errors only.
|
|
568
|
+
stats.internalErrors++;
|
|
569
|
+
log('error', `post-execute evaluation error: ${err?.message || err}`);
|
|
570
|
+
return next(exec, result);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// ── Subprocess spawn wrapper (DEFENSE-IN-DEPTH only) ──────────────────
|
|
575
|
+
// The model's commands mainly run through the native `bash` tool, which is
|
|
576
|
+
// audited in pre-execute above. This patch only covers flows that still
|
|
577
|
+
// go through the subprocess service seam directly.
|
|
578
|
+
const subprocess = ctx.get?.('subprocess');
|
|
579
|
+
if (subprocess && typeof subprocess.spawn === 'function') {
|
|
580
|
+
const originalSpawn = subprocess.spawn;
|
|
581
|
+
ctx.effect(() => {
|
|
582
|
+
subprocess.spawn = (spec) => {
|
|
583
|
+
stats.subprocessesChecked++;
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const decision = evaluateSubprocess(spec, config);
|
|
587
|
+
if (!decision.allow) {
|
|
588
|
+
stats.subprocessesBlocked++;
|
|
589
|
+
recordBlock(`subprocess: ${decision.reason}`);
|
|
590
|
+
throw new Error(`[CCS] Subprocess blocked: ${decision.reason}`);
|
|
591
|
+
}
|
|
592
|
+
for (const w of decision.warnings) {
|
|
593
|
+
log('warn', `subprocess: ${w}`);
|
|
594
|
+
}
|
|
595
|
+
} catch (err) {
|
|
596
|
+
if (err.message?.startsWith('[CCS]')) throw err;
|
|
597
|
+
stats.internalErrors++;
|
|
598
|
+
log('error', `subprocess evaluation error: ${err?.message || err}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return originalSpawn(spec);
|
|
602
|
+
};
|
|
603
|
+
return () => { subprocess.spawn = originalSpawn; };
|
|
604
|
+
}, 'ccs: subprocess guard');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── Fetch provider wrapper (DEFENSE-IN-DEPTH, best-effort) ────────────
|
|
608
|
+
// The v1 monkey-patch of web.fetch(url, opts) is gone: the real signature
|
|
609
|
+
// is fetch(request: WebFetchRequest, signal?) and patching the service
|
|
610
|
+
// method was fragile. SSRF's main line is the web_fetch pre-execute audit
|
|
611
|
+
// above. We additionally wrap a fetch PROVIDER only when a concrete
|
|
612
|
+
// downstream provider is discoverable, so delegation semantics cannot be
|
|
613
|
+
// guessed at.
|
|
614
|
+
const web = ctx.get?.('web');
|
|
615
|
+
installFetchProviderGuard(web, ctx);
|
|
616
|
+
|
|
617
|
+
log('info', `CCS runtime security guard active (v${VERSION})`, {
|
|
618
|
+
hooks: [
|
|
619
|
+
'pre-execute',
|
|
620
|
+
'post-execute',
|
|
621
|
+
...(subprocess ? ['subprocess'] : []),
|
|
622
|
+
...(web ? ['web-available'] : []),
|
|
623
|
+
],
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Best-effort registration of an SSRF-guarded fetch provider.
|
|
629
|
+
*
|
|
630
|
+
* Contract uncertainties in v0.2.1-alpha.1 (exact registerFetchProvider
|
|
631
|
+
* signature and provider-registry shape are not part of the extracted
|
|
632
|
+
* tools source) mean this is deliberately conservative:
|
|
633
|
+
* - skipped silently if registerFetchProvider is absent;
|
|
634
|
+
* - skipped unless at least one pre-existing provider is discoverable to
|
|
635
|
+
* delegate to (we never invent routing semantics);
|
|
636
|
+
* - all registration failures are contained and logged.
|
|
637
|
+
*/
|
|
638
|
+
function installFetchProviderGuard(web, ctx) {
|
|
639
|
+
if (!web || typeof web.registerFetchProvider !== 'function') return;
|
|
640
|
+
|
|
641
|
+
let delegates;
|
|
642
|
+
try {
|
|
643
|
+
delegates = discoverFetchProviders(web);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
log('warn', `fetch-provider discovery failed, skipping guard: ${err?.message || err}`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!delegates || delegates.length === 0) {
|
|
649
|
+
log('info', 'fetch-provider guard not installed: no downstream provider discovered (pre-execute web_fetch audit remains the SSRF main line)');
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
const guarded = async (request, signal) => {
|
|
655
|
+
const url = request?.url;
|
|
656
|
+
if (typeof url === 'string') {
|
|
657
|
+
stats.fetchCallsChecked++;
|
|
658
|
+
const reason = await validateFetchUrl(url, { allowPrivate: config.ssrf.allowPrivate });
|
|
659
|
+
if (reason) {
|
|
660
|
+
stats.fetchCallsBlocked++;
|
|
661
|
+
recordBlock(`fetch ${url}: ${reason}`);
|
|
662
|
+
const err = new Error(`[CCS] Fetch blocked: ${reason}`);
|
|
663
|
+
err.code = 'CCS_FETCH_BLOCKED';
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return delegates[0](request, signal);
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
let disposer;
|
|
671
|
+
// Try plausible registration shapes; the first that doesn't throw wins.
|
|
672
|
+
try {
|
|
673
|
+
disposer = web.registerFetchProvider({ name: 'ccs-ssrf-guard', fetch: guarded });
|
|
674
|
+
} catch {
|
|
675
|
+
disposer = web.registerFetchProvider('ccs-ssrf-guard', { fetch: guarded });
|
|
676
|
+
}
|
|
677
|
+
if (typeof disposer === 'function') {
|
|
678
|
+
ctx.effect(() => disposer, 'ccs: fetch provider guard');
|
|
679
|
+
}
|
|
680
|
+
log('info', 'fetch-provider SSRF guard installed (defense-in-depth)');
|
|
681
|
+
} catch (err) {
|
|
682
|
+
stats.internalErrors++;
|
|
683
|
+
log('error', `registerFetchProvider guard failed: ${err?.message || err}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Discover pre-registered fetch providers across plausible registry shapes.
|
|
689
|
+
* Returns normalized functions (request, signal) => result, possibly empty.
|
|
690
|
+
*/
|
|
691
|
+
function discoverFetchProviders(web) {
|
|
692
|
+
const out = [];
|
|
693
|
+
const pushAll = (entries) => {
|
|
694
|
+
for (const entry of entries) {
|
|
695
|
+
if (!entry) continue;
|
|
696
|
+
if (typeof entry === 'function') { out.push(entry); continue; }
|
|
697
|
+
if (typeof entry.fetch === 'function') out.push(entry.fetch.bind(entry));
|
|
698
|
+
else if (typeof entry.provide === 'function') out.push(entry.provide.bind(entry));
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
for (const key of ['providers', '_providers', 'fetchProviders', '_fetchProviders']) {
|
|
702
|
+
const container = web[key];
|
|
703
|
+
if (Array.isArray(container)) pushAll(container);
|
|
704
|
+
else if (container instanceof Map) pushAll([...container.values()]);
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
function deepMerge(target, source) {
|
|
712
|
+
const out = Array.isArray(target) ? [...target] : { ...target };
|
|
713
|
+
for (const [k, v] of Object.entries(source)) {
|
|
714
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && k in out && typeof out[k] === 'object') {
|
|
715
|
+
out[k] = deepMerge(out[k], v);
|
|
716
|
+
} else {
|
|
717
|
+
out[k] = v;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export { apply, inject, name, DEFAULT_POLICY };
|
|
724
|
+
export { evaluateToolCall } from './policy.js';
|
|
725
|
+
export { evaluateSubprocess } from './policy.js';
|
|
726
|
+
export { evaluateToolResult } from './policy.js';
|
|
727
|
+
export { validateFetchUrl } from './ssrf.js';
|
|
728
|
+
export { scanForSecrets, isCredentialPath, redactSecretsInText } from './credentials.js';
|
|
729
|
+
export { detectInjection } from './injection.js';
|
|
730
|
+
export { inspectSpawn, detectCommandInjection } from './cmdi.js';
|