@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.
@@ -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
- v = evaluateToolCall(context.toolName, context.arguments || {});
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.16",
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
- export type ToolGuardEvaluator = (toolName: string, args: Record<string, unknown>) => ToolGuardVerdictLike;
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
- v = evaluateToolCall(context.toolName, context.arguments || {});
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.16",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.47.16",
3
+ "version": "4.47.18",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",