@drakon-systems/shieldcortex-realtime 5.0.4 → 5.0.6

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/interceptor.ts CHANGED
@@ -541,7 +541,7 @@ const FALLBACK_CATASTROPHIC_PATTERNS: RegExp[] = [
541
541
  // `isGatedNpxBunx` (shape-based, #96), not by a DANGEROUS pattern, and a blunt
542
542
  // fallback matching them would over-gate `npx tsc`; `uvx`/`dlx` (unconditional)
543
543
  // ARE covered. Mirrored in scripts/pre-tool-hook.mjs + hermes/sc_client.py.
544
- const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
544
+ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string; lockPath?: true }> = [
545
545
  { re: /\brm\b|\bunlink\b|\brmdir\b|(?:(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?|\bxargs\s+(?:-{1,2}\S+\s+)*|-exec\s+)shred\b/i, signal: 'file-delete' },
546
546
  { re: /\bsudo\b|\bdoas\b|\bsu\s/i, signal: 'privilege-escalation' },
547
547
  { re: /\bgit\b[^|\n]*\bpush\b[^|\n]*(--force\b|-f\b|\+)/i, signal: 'git-force-push' },
@@ -560,6 +560,20 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
560
560
  { re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
561
561
  // Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
562
562
  { re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
563
+ // #500: outage fallback must gate self-disable / global uninstall / config.json writes.
564
+ { re: /--action-guard-(?:disable|advisory)\b|\biron-dome\s+deactivate\b/i, signal: 'disable-action-guard' },
565
+ { re: /\b(?:npm|yarn|pnpm|bun)\b[^|;&\n]*\b(?:uninstall|remove)\b[^|;&\n]*\b(?:shieldcortex|@drakon-systems\/shieldcortex-realtime)\b/i, signal: 'disable-action-guard' },
566
+ { re: /\.shieldcortex[\\/]+config\.json\b/i, signal: 'touch-guard-config' },
567
+ // #501: the policy lock's own attack surface. The two environment seams that
568
+ // decide WHICH policy-lock reader runs and which root it reads; the protected
569
+ // root and its pointer; and `~/.claude/settings.json`, whose `env` stanza is
570
+ // the same-UID file that delivers those variables into the enforcing
571
+ // process. All at the `disable-action-guard` tier, because that is what they
572
+ // are. Kept byte-identical with the sibling table by the #501 drift test in
573
+ // enforcement-surface-parity.
574
+ { re: /\bSHIELDCORTEX_(?:DIST_ROOT|PROTECTED_ROOT)\s*=/i, signal: 'disable-action-guard' },
575
+ { re: /\/etc\/shieldcortex(?:\.conf\b|[\\/]|(?![\w.-]))/i, signal: 'disable-action-guard', lockPath: true },
576
+ { re: /(?:^|[\s'"=:(\\/])\.claude[\\/]+settings(?:\.local)?\.json\b/i, signal: 'disable-action-guard', lockPath: true },
563
577
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
564
578
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
565
579
  { 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' },
@@ -584,6 +598,17 @@ function fallbackExecSurface(args: Record<string, unknown> | undefined): string
584
598
  for (const k of FALLBACK_SURFACE_KEYS) {
585
599
  const v = args?.[k];
586
600
  if (typeof v === 'string' && v.length > 0) parts.push(v);
601
+ // #522 r7 FIND-4: an ARGV ARRAY under one of these keys is a real host
602
+ // shape, and the guard this fallback stands in for already reads it —
603
+ // `rawStringArgs` in tool-action-guard.ts joins string arrays, and the
604
+ // catastrophic tier blocks the array form of a recursive root delete.
605
+ // Skipping arrays here made the degraded scan strictly weaker than the
606
+ // evaluator it replaces, on exactly the tier documented as an
607
+ // unconditional deny.
608
+ else if (Array.isArray(v)) {
609
+ const joined = v.filter((e) => typeof e === 'string').join(' ');
610
+ if (joined.length > 0) parts.push(joined);
611
+ }
587
612
  }
588
613
  return parts.join(' ').slice(0, FALLBACK_SCAN_CAP);
589
614
  }
@@ -594,11 +619,95 @@ function fallbackCatastrophicMatch(args: Record<string, unknown> | undefined): b
594
619
  return FALLBACK_CATASTROPHIC_PATTERNS.some(re => re.test(text));
595
620
  }
596
621
 
622
+
623
+ // ── #522 G4: the lock-path READ carve-out, ported to the blunt fallback ──────
624
+ //
625
+ // `ee5c6ac1` gave the real guard a carve-out: pure inspection of the protected
626
+ // root or `.claude/settings(.local).json` is not an attempt on the floor, so
627
+ // `Read {file_path:<lock>}` and `cat|grep|jq|ls <lock>` allow while every write
628
+ // shape still gates. That carve-out lives in tool-action-guard.ts — the module
629
+ // that is MISSING in exactly this degraded mode. So a broken install on a
630
+ // locked host carded every settings/policy read, which is the UX the carve-out
631
+ // was written to stop. These mirror it, fail-closed, with no dependency on the
632
+ // dist.
633
+ //
634
+ // Scope: this drops ONLY the two lock-PATH rows below (tagged `lockPath`).
635
+ // The env-seam row, the #500 command shapes and every other signal are
636
+ // untouched, so `rm`, `tee`, `cp`, `sed -i`, a redirect and `chmod` onto those
637
+ // paths still gate here exactly as they did.
638
+
639
+ /** Read-family tools cannot write; mirrors `classifyFamily`'s READ_TOOLS. */
640
+ const FALLBACK_READ_TOOLS = /^(read|read_file|cat|less|more|head|tail|view|open|get|glob|grep|search|find|ls|list|list_files|stat|pwd|which|web_search|websearch)$/;
641
+ /** Shell verbs that only OBSERVE — the guard's LOCK_READONLY_VERB_RE set. */
642
+ const FALLBACK_LOCK_READ_VERB_RE = /^(?:ls|dir|cat|head|tail|less|more|stat|file|wc|grep|egrep|fgrep|rg|ag|ack|realpath|readlink|basename|dirname|test|\[|echo|printf|jq)$/i;
643
+ /** `git <sub>` stages that only read history / the working tree. */
644
+ const FALLBACK_GIT_READ_SUB_RE = /^(?:log|show|diff|status|blame|ls-files)$/i;
645
+ /**
646
+ * True when a `git` stage writes a file or runs a configured driver. Judged per
647
+ * TOKEN with quotes stripped, not against the raw spelling: a pattern that
648
+ * required whitespace immediately before `--` was defeated by an ordinary
649
+ * quoted argument (#522 r2). The short form is matched GLUED as well as bare
650
+ * (`-o<file>` is what parse-options accepts). Mirrors `gitStageWritesOrExecs`
651
+ * in src/defence/iron-dome/tool-action-guard.ts — keep the three in lockstep.
652
+ */
653
+ function fallbackGitStageWrites(stage: string): boolean {
654
+ for (const raw of stage.split(/\s+/)) {
655
+ if (!raw) continue;
656
+ const token = raw.replace(/['"]/g, '');
657
+ if (/^-o(?:$|[^-])/.test(token)) return true;
658
+ if (/^--(?:output|ext-diff)\b/i.test(token)) return true;
659
+ }
660
+ return false;
661
+ }
662
+ /** Any non-fd-dup redirect, glued or spaced — `echo x > <lock>` is a WRITE. */
663
+ const FALLBACK_REDIRECT_RE = />{1,2}\|?(?!&\d)/;
664
+ /** Nested execution keeps the gate; the verb whitelist cannot see inside it. */
665
+ const FALLBACK_NESTED_EXEC_RE = /\$\(|`|<\(|>\(|\beval\b|\bsource\b|\b\.\s+\/|\bfunction\b|[\w.-]+\s*\(\s*\)\s*\{/i;
666
+ /** Assigning an env seam decides WHICH reader runs — never a read. */
667
+ const FALLBACK_LOCK_ENV_SEAM_RE = /\bSHIELDCORTEX_(?:DIST_ROOT|PROTECTED_ROOT)\s*=/i;
668
+
669
+ /**
670
+ * True when the whole surface is pure inspection of a lock path. Fail-closed on
671
+ * an env-seam assignment, a redirect, nested execution, and any unknown verb in
672
+ * any stage of any statement — the same rule the real guard applies, with the
673
+ * statement split done conservatively (`&` and `|` both separate, so a
674
+ * pipeline stage or a backgrounded sibling must ALSO be a read).
675
+ */
676
+ function fallbackLockPathAccessIsReadOnly(text: string, toolName: string | undefined): boolean {
677
+ if (!text) return false;
678
+ if (FALLBACK_LOCK_ENV_SEAM_RE.test(text)) return false;
679
+ const seg = String(toolName || '').toLowerCase().split(/__|\.|:|\//).filter(Boolean).pop() || '';
680
+ if (seg && FALLBACK_READ_TOOLS.test(seg)) return true;
681
+ if (FALLBACK_REDIRECT_RE.test(text) || FALLBACK_NESTED_EXEC_RE.test(text)) return false;
682
+ let sawStage = false;
683
+ for (const raw of text.split(/[\n;&|]+/)) {
684
+ const stage = raw.trim();
685
+ if (!stage) continue;
686
+ sawStage = true;
687
+ const toks = stage
688
+ .replace(/^(?:[A-Za-z_]\w*=\S*\s+)+/, '')
689
+ .replace(/^sudo\s+(?:-E\s+)?/, '')
690
+ .split(/\s+/);
691
+ const word = toks[0] || '';
692
+ const base = word.split('/').pop() || word;
693
+ if (/^git$/i.test(base)) {
694
+ const sub = toks.slice(1).find((t) => !t.startsWith('-')) || '';
695
+ if (!FALLBACK_GIT_READ_SUB_RE.test(sub)) return false;
696
+ if (fallbackGitStageWrites(stage)) return false;
697
+ continue;
698
+ }
699
+ if (!FALLBACK_LOCK_READ_VERB_RE.test(base)) return false;
700
+ }
701
+ return sawStage;
702
+ }
703
+
597
704
  /** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
598
- function fallbackDangerousMatch(args: Record<string, unknown> | undefined): string | null {
705
+ function fallbackDangerousMatch(args: Record<string, unknown> | undefined, toolName?: string): string | null {
599
706
  const text = fallbackExecSurface(args);
600
707
  if (!text) return null;
601
- for (const { re, signal } of FALLBACK_DANGEROUS_PATTERNS) {
708
+ const lockReadOnly = fallbackLockPathAccessIsReadOnly(text, toolName);
709
+ for (const { re, signal, lockPath } of FALLBACK_DANGEROUS_PATTERNS) {
710
+ if (lockReadOnly && lockPath === true) continue;
602
711
  if (re.test(text)) return signal;
603
712
  }
604
713
  return null;
@@ -615,10 +724,80 @@ export function summariseToolArgs(args: Record<string, unknown> | undefined): st
615
724
  return parts.join(' ').slice(0, 160);
616
725
  }
617
726
 
727
+ /**
728
+ * #524 — the operator-facing half of the native `process` contract.
729
+ *
730
+ * DUPLICATED from `tool-action-guard.ts`'s `OPENCLAW_PROCESS_INSPECT` /
731
+ * `OPENCLAW_PROCESS_MUTATE` on purpose, the same discipline as
732
+ * `FALLBACK_CATASTROPHIC_PATTERNS` above: this file carries no compile-time
733
+ * dependency on the main package, and the card must still read in English when
734
+ * the guard is loaded through the injected-evaluator seam. Kept in sync there;
735
+ * a verb that drifts out of sync falls back to the generic lead below rather
736
+ * than inventing a sentence.
737
+ */
738
+ const NATIVE_PROCESS_PHRASE: Record<string, string> = {
739
+ list: 'see what commands are running',
740
+ poll: 'check a running command',
741
+ log: "read a running command's output",
742
+ kill: 'stop a running command',
743
+ write: 'type into a running command',
744
+ 'send-keys': 'press keys in a running command',
745
+ submit: 'submit input to a running command',
746
+ paste: 'paste text into a running command',
747
+ clear: "clear a running command's input",
748
+ remove: "remove a running command's session",
749
+ };
750
+
751
+ /** EXACT native spelling only — `mcp__openclaw__process` is not this contract. */
752
+ function isNativeProcessTool(toolName: string): boolean {
753
+ return String(toolName || '').trim().toLowerCase() === 'process';
754
+ }
755
+
756
+ /**
757
+ * The plain sentence the card LEADS with.
758
+ *
759
+ * The operator's complaint was not that the card was wrong, it was that
760
+ * `invalid_tool_input / unknown field action` is not a question a person can
761
+ * answer. So the headline is what the agent is trying to do, in words, and the
762
+ * `Tool:`/`Action:`/`Signals:` block below it keeps the machine-readable
763
+ * detail — including for the secret-egress filter in `index.ts`, which forwards
764
+ * those label lines and drops everything else.
765
+ *
766
+ * Every sentence says what allow-once buys, because that is the other half of
767
+ * what went wrong: a Telegram allow-once looked like it taught the tool, and it
768
+ * did not. It never will — no card mints a standing grant.
769
+ */
770
+ function actionGuardLead(
771
+ toolName: string,
772
+ v: ToolGuardVerdictLike,
773
+ args?: Record<string, unknown>,
774
+ ): string {
775
+ const rawAction = args?.action;
776
+ const verb = typeof rawAction === 'string'
777
+ ? rawAction.trim().toLowerCase().replace(/_/g, '-')
778
+ : '';
779
+ const phrase = isNativeProcessTool(toolName) ? NATIVE_PROCESS_PHRASE[verb] : undefined;
780
+ if (phrase) {
781
+ return `Jarvis wants to ${phrase} (${verb}). Allow once is this call only.`;
782
+ }
783
+ if (isSchemaInvalid(v)) {
784
+ return `Jarvis used ${toolName}, which ShieldCortex does not fully recognise yet. `
785
+ + 'Allow once lets this one call through. It does not teach the tool.';
786
+ }
787
+ return `Jarvis wants to use ${toolName}, and ShieldCortex rated this call ${v.severity}. `
788
+ + 'Allow once is this call only.';
789
+ }
790
+
618
791
  /** Operator-facing approval prompt for a gated action (not a memory write). */
619
- export function formatActionGuardPrompt(toolName: string, v: ToolGuardVerdictLike): string {
792
+ export function formatActionGuardPrompt(
793
+ toolName: string,
794
+ v: ToolGuardVerdictLike,
795
+ args?: Record<string, unknown>,
796
+ ): string {
620
797
  return [
621
- '🛡️ ShieldCortex — Action Intercepted',
798
+ '🛡️ ShieldCortex needs a yes',
799
+ '',
800
+ actionGuardLead(toolName, v, args),
622
801
  '',
623
802
  `Tool: ${toolName}`,
624
803
  `Action: ${v.action}`,
@@ -626,7 +805,7 @@ export function formatActionGuardPrompt(toolName: string, v: ToolGuardVerdictLik
626
805
  `Signals: ${v.signals.join(', ') || 'none'}`,
627
806
  `Reason: ${v.reason}`,
628
807
  '',
629
- '[Approve] [Deny]',
808
+ '[Allow once] [Deny]',
630
809
  ].join('\n');
631
810
  }
632
811
 
@@ -1215,7 +1394,7 @@ export function createInterceptor(
1215
1394
 
1216
1395
  // 2. Dangerous — route through failurePolicy (the "can't obtain a verdict"
1217
1396
  // policy; a degraded guard is precisely that). enforce:false → advisory.
1218
- const dangerousSignal = fallbackDangerousMatch(context.arguments);
1397
+ const dangerousSignal = fallbackDangerousMatch(context.arguments, context.toolName);
1219
1398
  if (dangerousSignal) {
1220
1399
  const dBase = { ...degradedBase, severity: 'high' as Severity, threats: ['fallback-scan', dangerousSignal], anomalyScore: 0.6 };
1221
1400
  if (!actionGuardCfg.enforce) {
@@ -1223,7 +1402,15 @@ export function createInterceptor(
1223
1402
  log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — advisory (enforce:false), allowing dangerous ${context.toolName} [${dangerousSignal}]`);
1224
1403
  return;
1225
1404
  }
1226
- const failAction = config.failurePolicy.high;
1405
+ // #522 G3: only an explicit, recognised `allow` permits a dangerous op
1406
+ // through a degraded guard. `failurePolicy` arrives from config files
1407
+ // including the unsigned same-UID `openclaw.json`, and the old
1408
+ // `=== 'deny'` test made every OTHER value — a typo, a null, an object,
1409
+ // anything a schema did not catch — fail OPEN on the one tier this
1410
+ // branch exists to hold. On a locked host the value is pinned to `deny`
1411
+ // upstream by the policy lock (`withGuardPosture`); this is the floor
1412
+ // for the value that actually arrives.
1413
+ const failAction: FailureAction = config.failurePolicy.high === 'allow' ? 'allow' : 'deny';
1227
1414
  emitAudit({ ...dBase, action: 'gate_degraded', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
1228
1415
  if (failAction === 'deny') {
1229
1416
  log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback matched a DANGEROUS op [${dangerousSignal}] — DENYING ${context.toolName} (fail-closed, failure policy: deny)`);
@@ -1503,7 +1690,7 @@ export function createInterceptor(
1503
1690
  let approved: boolean;
1504
1691
  try {
1505
1692
  approved = await withApprovalDeadline(
1506
- context.requireApproval(formatActionGuardPrompt(context.toolName, v)),
1693
+ context.requireApproval(formatActionGuardPrompt(context.toolName, v, context.arguments)),
1507
1694
  brokered ? brokerApprovalTimeoutMs(v.severity) : 0,
1508
1695
  );
1509
1696
  } catch (err) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "5.0.4",
3
+ "version": "5.0.6",
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": "5.0.4",
3
+ "version": "5.0.6",
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",