@drakon-systems/shieldcortex-realtime 4.47.32 → 4.47.34

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
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.32",
3
+ "version": "4.47.31",
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/index.ts CHANGED
@@ -248,6 +248,9 @@ interface InterceptorUserConfig {
248
248
  * single place that knows which values would loosen an invariant. Off
249
249
  * unless `enabled: true`. */
250
250
  broker?: Record<string, unknown>;
251
+ /** Reviewed-script allowlist (#189). Passed through RAW; validated
252
+ * entry-by-entry inside createReviewedScriptCheck. */
253
+ reviewedScripts?: unknown[];
251
254
  };
252
255
  }
253
256
 
@@ -390,6 +393,22 @@ const INTERCEPTOR_JSON_SCHEMA = {
390
393
  model: { type: "string" },
391
394
  },
392
395
  },
396
+ // #189. Each entry pins one script by absolute path + content hash;
397
+ // createReviewedScriptCheck has the last word on every field.
398
+ reviewedScripts: {
399
+ type: "array",
400
+ items: {
401
+ type: "object",
402
+ additionalProperties: false,
403
+ properties: {
404
+ path: { type: "string" },
405
+ sha256: { type: "string" },
406
+ note: { type: "string" },
407
+ addedAt: { type: "number" },
408
+ },
409
+ required: ["path", "sha256"],
410
+ },
411
+ },
393
412
  },
394
413
  },
395
414
  },
@@ -606,6 +625,11 @@ function normaliseActionGuardBlock(
606
625
  if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
607
626
  guard.broker = rawGuard.broker as Record<string, unknown>;
608
627
  }
628
+ // #189: same passthrough discipline — createReviewedScriptCheck is the
629
+ // boundary that shape-validates each entry.
630
+ if (Array.isArray(rawGuard.reviewedScripts)) {
631
+ guard.reviewedScripts = [...rawGuard.reviewedScripts];
632
+ }
609
633
  return Object.keys(guard).length > 0 ? guard : undefined;
610
634
  }
611
635
 
package/interceptor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdirSync, appendFileSync, readFileSync, statSync } from 'node:fs';
2
+ import { mkdirSync, appendFileSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { join, isAbsolute, resolve as resolvePath } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { createGatewayInvoker, type BrokerInvokerContext, type ModelInvokerLike } from './broker-invoker.js';
@@ -35,6 +35,11 @@ export interface ActionGuardConfig {
35
35
  * the broker — this plugin never interprets it, so a hostile value cannot be
36
36
  * laundered by travelling through here. Absent/disabled = today's behaviour. */
37
37
  broker?: Record<string, unknown>;
38
+ /** RAW reviewed-script allowlist (#189), same passthrough discipline: shape
39
+ * validation lives in `normaliseReviewedScripts` in the main package (and in
40
+ * this plugin's duplicated `createReviewedScriptCheck` — see the build-
41
+ * boundary note at that function). Absent/malformed = nothing is exempt. */
42
+ reviewedScripts?: unknown[];
38
43
  }
39
44
 
40
45
  /** Structural shape of a Tool Action Guard verdict (kept local to avoid a
@@ -47,6 +52,10 @@ export interface ToolGuardVerdictLike {
47
52
  action: string;
48
53
  reason: string;
49
54
  signals: string[];
55
+ /** Rule → matched-span evidence behind `signals` (issue #192). */
56
+ matches?: Array<{ signal: string; span: string }>;
57
+ /** Files the reviewed-script allowlist exempted from folding (#189). */
58
+ reviewedScripts?: string[];
50
59
  }
51
60
  /** Optional 4th-parameter seam on the real evaluator (issue #4): the guard core
52
61
  * stays pure/synchronous and asks the CALLER to resolve an invoked script's
@@ -54,6 +63,9 @@ export interface ToolGuardVerdictLike {
54
63
  * command. Structurally typed, like ToolGuardVerdictLike above. */
55
64
  export interface ToolGuardEvaluatorOptions {
56
65
  resolveScriptSource?: (scriptPath: string) => string | null;
66
+ /** #189: answers whether a human pinned this exact path + content as
67
+ * reviewed. An evaluator that predates the seam simply ignores it. */
68
+ isReviewedScript?: (scriptPath: string, source: string) => boolean;
57
69
  }
58
70
  export type ToolGuardEvaluator = (
59
71
  toolName: string,
@@ -175,6 +187,12 @@ export interface InterceptAuditEntry {
175
187
  * calls the broker judged, so "was a model consulted, and what did it say?"
176
188
  * is answerable from the audit stream alone. Absent = the broker never ran. */
177
189
  broker?: BrokerAuditLike;
190
+ /** Rule → matched-span evidence behind `threats` (issue #192). Absent when
191
+ * no pattern produced a span. `secret-egress` never contributes one — the
192
+ * span would be the secret. */
193
+ matches?: Array<{ signal: string; span: string }>;
194
+ /** Files the reviewed-script allowlist exempted from folding (#189). */
195
+ reviewedScripts?: string[];
178
196
  }
179
197
 
180
198
  const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'] as const;
@@ -637,6 +655,52 @@ export function createScriptSourceResolver(cwd?: string): (scriptPath: string) =
637
655
  };
638
656
  }
639
657
 
658
+ // #189 reviewed-script allowlist — DUPLICATED from
659
+ // src/defence/iron-dome/reviewed-scripts.ts for the same build-boundary reason
660
+ // as the resolver above (TS6059), and held to it by the same parity test
661
+ // (src/__tests__/enforcement-surface-parity.test.ts). Same rails, same
662
+ // answers, or the drift test goes red.
663
+ const REVIEWED_MAX_ENTRIES = 200;
664
+ const REVIEWED_MAX_PATH_LENGTH = 1_024;
665
+ const REVIEWED_SHA256_RE = /^[0-9a-f]{64}$/;
666
+
667
+ export function createReviewedScriptCheck(
668
+ rawEntries: unknown,
669
+ cwd?: string,
670
+ ): (scriptPath: string, source: string) => boolean {
671
+ if (!Array.isArray(rawEntries) || rawEntries.length === 0) return () => false;
672
+ const byCanonical = new Map<string, string>(); // canonical path → sha256
673
+ for (const item of rawEntries.slice(0, REVIEWED_MAX_ENTRIES)) {
674
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) continue;
675
+ const rec = item as Record<string, unknown>;
676
+ if (typeof rec.path !== 'string' || typeof rec.sha256 !== 'string') continue;
677
+ const path = rec.path.trim();
678
+ const sha256 = rec.sha256.trim().toLowerCase();
679
+ if (!path || path.length > REVIEWED_MAX_PATH_LENGTH || !isAbsolute(path)) continue;
680
+ if (!REVIEWED_SHA256_RE.test(sha256)) continue;
681
+ try {
682
+ byCanonical.set(realpathSync(path), sha256);
683
+ } catch {
684
+ /* pinned file missing — entry cannot match anything */
685
+ }
686
+ }
687
+ if (byCanonical.size === 0) return () => false;
688
+
689
+ const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
690
+ return (scriptPath: string, source: string): boolean => {
691
+ try {
692
+ if (!scriptPath || typeof scriptPath !== 'string' || typeof source !== 'string') return false;
693
+ const expanded = scriptPath.startsWith('~/') ? join(homedir(), scriptPath.slice(2)) : scriptPath;
694
+ const full = isAbsolute(expanded) ? expanded : resolvePath(base, expanded);
695
+ const expected = byCanonical.get(realpathSync(full));
696
+ if (!expected) return false;
697
+ return createHash('sha256').update(source, 'utf8').digest('hex') === expected;
698
+ } catch {
699
+ return false;
700
+ }
701
+ };
702
+ }
703
+
640
704
  /** Raised when the operator never answered the approval card (#143). Distinct
641
705
  * from a transport error, because the two have opposite handling: an error
642
706
  * routes to failurePolicy, a timeout routes to the broker's asymmetric rule. */
@@ -738,6 +802,10 @@ export function createInterceptor(
738
802
  anomalyScore: v.decision === 'block' ? 1 : v.decision === 'allow' ? 0.1 : 0.6,
739
803
  trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null, pipelineDurationMs: 0,
740
804
  preview: preview.slice(0, 200), ts: new Date().toISOString(),
805
+ // #192: the durable record keeps the evidence, not just the rule names.
806
+ ...(v.matches && v.matches.length > 0 ? { matches: v.matches } : {}),
807
+ // #189: an allow that leaned on the reviewed-script allowlist says so.
808
+ ...(v.reviewedScripts && v.reviewedScripts.length > 0 ? { reviewedScripts: v.reviewedScripts } : {}),
741
809
  };
742
810
  }
743
811
 
@@ -906,6 +974,11 @@ export function createInterceptor(
906
974
  // resolver. An evaluator that predates the seam simply ignores it.
907
975
  v = evaluateToolCall(context.toolName, context.arguments || {}, undefined, {
908
976
  resolveScriptSource: createScriptSourceResolver(toolCallCwd(context)),
977
+ // #189: same allowlist, same predicate semantics as the hook surface —
978
+ // config is RAW here, validated inside createReviewedScriptCheck.
979
+ ...(actionGuardCfg.reviewedScripts && actionGuardCfg.reviewedScripts.length > 0
980
+ ? { isReviewedScript: createReviewedScriptCheck(actionGuardCfg.reviewedScripts, toolCallCwd(context)) }
981
+ : {}),
909
982
  });
910
983
  } catch (err) {
911
984
  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.32",
3
+ "version": "4.47.34",
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.32",
3
+ "version": "4.47.34",
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",