@drakon-systems/shieldcortex-realtime 4.47.16 → 4.47.18
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/dist/interceptor.js +56 -3
- package/dist/openclaw.plugin.json +1 -1
- package/interceptor.ts +70 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/interceptor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, appendFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { mkdirSync, appendFileSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'];
|
|
6
6
|
const CONTENT_FIELDS = {
|
|
@@ -199,6 +199,8 @@ const FALLBACK_DANGEROUS_PATTERNS = [
|
|
|
199
199
|
{ re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
|
|
200
200
|
{ re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
|
|
201
201
|
{ re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
202
|
+
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
203
|
+
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
202
204
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
203
205
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
204
206
|
{ re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
|
|
@@ -339,6 +341,52 @@ function xrayMemoryGuard(content, title) {
|
|
|
339
341
|
const riskLevel = score >= 80 ? 'SAFE' : score >= 60 ? 'LOW' : score >= 40 ? 'MEDIUM' : score >= 20 ? 'HIGH' : 'CRITICAL';
|
|
340
342
|
return { allowed: score >= 60, findings, riskLevel };
|
|
341
343
|
}
|
|
344
|
+
// ── Script source resolution (issue #4) ─────────────────────────────────────
|
|
345
|
+
// `bash deploy.sh` used to bypass EVERY Action Guard rule, because the guard
|
|
346
|
+
// only ever scanned the command string and never opened the file it pointed at.
|
|
347
|
+
// The guard core stays pure (doctor/self-check drive it with synthetic commands
|
|
348
|
+
// whose paths do not exist); this is the fs-backed half, wired in here.
|
|
349
|
+
//
|
|
350
|
+
// Zeroth law — this must never hang, block or crash the host gateway:
|
|
351
|
+
// * `statSync` first: only a REGULAR file is read, so a FIFO/socket/device
|
|
352
|
+
// (a `read` on which could block forever) is skipped, not opened;
|
|
353
|
+
// * `/proc`, `/sys`, `/dev` are never touched;
|
|
354
|
+
// * anything over the size cap is refused (the guard then records it as
|
|
355
|
+
// `opaque-script-invocation` rather than pretending it was scanned);
|
|
356
|
+
// * every error returns `null`. Nothing escapes.
|
|
357
|
+
const MAX_SCRIPT_SOURCE_BYTES = 262_144; // 256KB — matches the guard core's cap
|
|
358
|
+
const UNREADABLE_PATH_PREFIX = /^\/(?:proc|sys|dev)\//;
|
|
359
|
+
export function createScriptSourceResolver(cwd) {
|
|
360
|
+
const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
|
|
361
|
+
return (scriptPath) => {
|
|
362
|
+
try {
|
|
363
|
+
if (!scriptPath || typeof scriptPath !== 'string')
|
|
364
|
+
return null;
|
|
365
|
+
const expanded = scriptPath.startsWith('~/') ? join(homedir(), scriptPath.slice(2)) : scriptPath;
|
|
366
|
+
const full = isAbsolute(expanded) ? expanded : resolvePath(base, expanded);
|
|
367
|
+
if (UNREADABLE_PATH_PREFIX.test(full))
|
|
368
|
+
return null;
|
|
369
|
+
const st = statSync(full);
|
|
370
|
+
if (!st.isFile() || st.size > MAX_SCRIPT_SOURCE_BYTES)
|
|
371
|
+
return null;
|
|
372
|
+
return readFileSync(full, 'utf8');
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null; // missing, unreadable, anything — stay silent, stay alive
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/** The cwd a tool call runs in, if the gateway or the call itself names one. */
|
|
380
|
+
function toolCallCwd(context) {
|
|
381
|
+
if (typeof context.cwd === 'string' && context.cwd)
|
|
382
|
+
return context.cwd;
|
|
383
|
+
for (const k of ['cwd', 'workdir', 'working_directory', 'workingDirectory', 'directory']) {
|
|
384
|
+
const v = context.arguments?.[k];
|
|
385
|
+
if (typeof v === 'string' && v)
|
|
386
|
+
return v;
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
342
390
|
export function createInterceptor(config, pipeline, options) {
|
|
343
391
|
const denyCache = new DenyCache();
|
|
344
392
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
@@ -421,7 +469,12 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
421
469
|
}
|
|
422
470
|
let v;
|
|
423
471
|
try {
|
|
424
|
-
|
|
472
|
+
// 4th arg (issue #4): lets the pure guard core scan the CONTENTS of a
|
|
473
|
+
// script the command invokes (`bash deploy.sh`) through this fs-backed
|
|
474
|
+
// resolver. An evaluator that predates the seam simply ignores it.
|
|
475
|
+
v = evaluateToolCall(context.toolName, context.arguments || {}, undefined, {
|
|
476
|
+
resolveScriptSource: createScriptSourceResolver(toolCallCwd(context)),
|
|
477
|
+
});
|
|
425
478
|
}
|
|
426
479
|
catch (err) {
|
|
427
480
|
handleGuardUnavailable(context, `action-guard error: ${err instanceof Error ? err.message : err}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.18",
|
|
4
4
|
"name": "ShieldCortex Real-time Scanner",
|
|
5
5
|
"description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
|
|
6
6
|
"kind": null,
|
package/interceptor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, appendFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { mkdirSync, appendFileSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
|
|
6
6
|
export type Severity = 'low' | 'medium' | 'high' | 'critical';
|
|
@@ -42,7 +42,19 @@ export interface ToolGuardVerdictLike {
|
|
|
42
42
|
reason: string;
|
|
43
43
|
signals: string[];
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
/** Optional 4th-parameter seam on the real evaluator (issue #4): the guard core
|
|
46
|
+
* stays pure/synchronous and asks the CALLER to resolve an invoked script's
|
|
47
|
+
* source, so `bash deploy.sh` is scanned by the same rules as the inline
|
|
48
|
+
* command. Structurally typed, like ToolGuardVerdictLike above. */
|
|
49
|
+
export interface ToolGuardEvaluatorOptions {
|
|
50
|
+
resolveScriptSource?: (scriptPath: string) => string | null;
|
|
51
|
+
}
|
|
52
|
+
export type ToolGuardEvaluator = (
|
|
53
|
+
toolName: string,
|
|
54
|
+
args: Record<string, unknown>,
|
|
55
|
+
config?: unknown,
|
|
56
|
+
options?: ToolGuardEvaluatorOptions,
|
|
57
|
+
) => ToolGuardVerdictLike;
|
|
46
58
|
|
|
47
59
|
export interface InterceptorConfig {
|
|
48
60
|
enabled: boolean;
|
|
@@ -56,6 +68,10 @@ export interface ToolCallContext {
|
|
|
56
68
|
toolName: string;
|
|
57
69
|
arguments: Record<string, unknown>;
|
|
58
70
|
requireApproval?: (message: string) => Promise<boolean>;
|
|
71
|
+
/** Working directory the tool call runs in, when the gateway supplies one —
|
|
72
|
+
* used to resolve a relative script path (issue #4). Falls back to the
|
|
73
|
+
* call's own `cwd` argument, then `process.cwd()`. */
|
|
74
|
+
cwd?: string;
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
export interface InterceptAuditEntry {
|
|
@@ -307,6 +323,8 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
|
|
|
307
323
|
{ re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
|
|
308
324
|
{ re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
|
|
309
325
|
{ re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
326
|
+
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
327
|
+
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
310
328
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
311
329
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
312
330
|
{ re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
|
|
@@ -487,6 +505,49 @@ type PipelineRunner = (content: string, title: string, source: { type: string; i
|
|
|
487
505
|
auditId: number;
|
|
488
506
|
};
|
|
489
507
|
|
|
508
|
+
// ── Script source resolution (issue #4) ─────────────────────────────────────
|
|
509
|
+
// `bash deploy.sh` used to bypass EVERY Action Guard rule, because the guard
|
|
510
|
+
// only ever scanned the command string and never opened the file it pointed at.
|
|
511
|
+
// The guard core stays pure (doctor/self-check drive it with synthetic commands
|
|
512
|
+
// whose paths do not exist); this is the fs-backed half, wired in here.
|
|
513
|
+
//
|
|
514
|
+
// Zeroth law — this must never hang, block or crash the host gateway:
|
|
515
|
+
// * `statSync` first: only a REGULAR file is read, so a FIFO/socket/device
|
|
516
|
+
// (a `read` on which could block forever) is skipped, not opened;
|
|
517
|
+
// * `/proc`, `/sys`, `/dev` are never touched;
|
|
518
|
+
// * anything over the size cap is refused (the guard then records it as
|
|
519
|
+
// `opaque-script-invocation` rather than pretending it was scanned);
|
|
520
|
+
// * every error returns `null`. Nothing escapes.
|
|
521
|
+
const MAX_SCRIPT_SOURCE_BYTES = 262_144; // 256KB — matches the guard core's cap
|
|
522
|
+
const UNREADABLE_PATH_PREFIX = /^\/(?:proc|sys|dev)\//;
|
|
523
|
+
|
|
524
|
+
export function createScriptSourceResolver(cwd?: string): (scriptPath: string) => string | null {
|
|
525
|
+
const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
|
|
526
|
+
return (scriptPath: string): string | null => {
|
|
527
|
+
try {
|
|
528
|
+
if (!scriptPath || typeof scriptPath !== 'string') return null;
|
|
529
|
+
const expanded = scriptPath.startsWith('~/') ? join(homedir(), scriptPath.slice(2)) : scriptPath;
|
|
530
|
+
const full = isAbsolute(expanded) ? expanded : resolvePath(base, expanded);
|
|
531
|
+
if (UNREADABLE_PATH_PREFIX.test(full)) return null;
|
|
532
|
+
const st = statSync(full);
|
|
533
|
+
if (!st.isFile() || st.size > MAX_SCRIPT_SOURCE_BYTES) return null;
|
|
534
|
+
return readFileSync(full, 'utf8');
|
|
535
|
+
} catch {
|
|
536
|
+
return null; // missing, unreadable, anything — stay silent, stay alive
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The cwd a tool call runs in, if the gateway or the call itself names one. */
|
|
542
|
+
function toolCallCwd(context: ToolCallContext): string | undefined {
|
|
543
|
+
if (typeof context.cwd === 'string' && context.cwd) return context.cwd;
|
|
544
|
+
for (const k of ['cwd', 'workdir', 'working_directory', 'workingDirectory', 'directory']) {
|
|
545
|
+
const v = context.arguments?.[k];
|
|
546
|
+
if (typeof v === 'string' && v) return v;
|
|
547
|
+
}
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
490
551
|
interface InterceptorOptions {
|
|
491
552
|
maxPromptsPerMinute?: number;
|
|
492
553
|
onAuditEntry?: (entry: InterceptAuditEntry) => void;
|
|
@@ -591,7 +652,12 @@ export function createInterceptor(
|
|
|
591
652
|
|
|
592
653
|
let v: ToolGuardVerdictLike;
|
|
593
654
|
try {
|
|
594
|
-
|
|
655
|
+
// 4th arg (issue #4): lets the pure guard core scan the CONTENTS of a
|
|
656
|
+
// script the command invokes (`bash deploy.sh`) through this fs-backed
|
|
657
|
+
// resolver. An evaluator that predates the seam simply ignores it.
|
|
658
|
+
v = evaluateToolCall(context.toolName, context.arguments || {}, undefined, {
|
|
659
|
+
resolveScriptSource: createScriptSourceResolver(toolCallCwd(context)),
|
|
660
|
+
});
|
|
595
661
|
} catch (err) {
|
|
596
662
|
handleGuardUnavailable(context, `action-guard error: ${err instanceof Error ? err.message : err}`);
|
|
597
663
|
return;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.18",
|
|
4
4
|
"name": "ShieldCortex Real-time Scanner",
|
|
5
5
|
"description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
|
|
6
6
|
"kind": null,
|
package/package.json
CHANGED