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/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';