@drakon-systems/shieldcortex-realtime 4.47.33 → 4.47.35
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/index.js +21 -0
- package/dist/interceptor.js +60 -1
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +24 -0
- package/interceptor.ts +74 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -274,6 +274,22 @@ const INTERCEPTOR_JSON_SCHEMA = {
|
|
|
274
274
|
model: { type: "string" },
|
|
275
275
|
},
|
|
276
276
|
},
|
|
277
|
+
// #189. Each entry pins one script by absolute path + content hash;
|
|
278
|
+
// createReviewedScriptCheck has the last word on every field.
|
|
279
|
+
reviewedScripts: {
|
|
280
|
+
type: "array",
|
|
281
|
+
items: {
|
|
282
|
+
type: "object",
|
|
283
|
+
additionalProperties: false,
|
|
284
|
+
properties: {
|
|
285
|
+
path: { type: "string" },
|
|
286
|
+
sha256: { type: "string" },
|
|
287
|
+
note: { type: "string" },
|
|
288
|
+
addedAt: { type: "number" },
|
|
289
|
+
},
|
|
290
|
+
required: ["path", "sha256"],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
277
293
|
},
|
|
278
294
|
},
|
|
279
295
|
},
|
|
@@ -485,6 +501,11 @@ function normaliseActionGuardBlock(raw, dropped, pathPrefix) {
|
|
|
485
501
|
if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
|
|
486
502
|
guard.broker = rawGuard.broker;
|
|
487
503
|
}
|
|
504
|
+
// #189: same passthrough discipline — createReviewedScriptCheck is the
|
|
505
|
+
// boundary that shape-validates each entry.
|
|
506
|
+
if (Array.isArray(rawGuard.reviewedScripts)) {
|
|
507
|
+
guard.reviewedScripts = [...rawGuard.reviewedScripts];
|
|
508
|
+
}
|
|
488
509
|
return Object.keys(guard).length > 0 ? guard : undefined;
|
|
489
510
|
}
|
|
490
511
|
function normaliseInterceptorConfig(raw, dropped) {
|
package/dist/interceptor.js
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 } from './broker-invoker.js';
|
|
@@ -390,6 +390,56 @@ export function createScriptSourceResolver(cwd) {
|
|
|
390
390
|
}
|
|
391
391
|
};
|
|
392
392
|
}
|
|
393
|
+
// #189 reviewed-script allowlist — DUPLICATED from
|
|
394
|
+
// src/defence/iron-dome/reviewed-scripts.ts for the same build-boundary reason
|
|
395
|
+
// as the resolver above (TS6059), and held to it by the same parity test
|
|
396
|
+
// (src/__tests__/enforcement-surface-parity.test.ts). Same rails, same
|
|
397
|
+
// answers, or the drift test goes red.
|
|
398
|
+
const REVIEWED_MAX_ENTRIES = 200;
|
|
399
|
+
const REVIEWED_MAX_PATH_LENGTH = 1_024;
|
|
400
|
+
const REVIEWED_SHA256_RE = /^[0-9a-f]{64}$/;
|
|
401
|
+
export function createReviewedScriptCheck(rawEntries, cwd) {
|
|
402
|
+
if (!Array.isArray(rawEntries) || rawEntries.length === 0)
|
|
403
|
+
return () => false;
|
|
404
|
+
const byCanonical = new Map(); // canonical path → sha256
|
|
405
|
+
for (const item of rawEntries.slice(0, REVIEWED_MAX_ENTRIES)) {
|
|
406
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item))
|
|
407
|
+
continue;
|
|
408
|
+
const rec = item;
|
|
409
|
+
if (typeof rec.path !== 'string' || typeof rec.sha256 !== 'string')
|
|
410
|
+
continue;
|
|
411
|
+
const path = rec.path.trim();
|
|
412
|
+
const sha256 = rec.sha256.trim().toLowerCase();
|
|
413
|
+
if (!path || path.length > REVIEWED_MAX_PATH_LENGTH || !isAbsolute(path))
|
|
414
|
+
continue;
|
|
415
|
+
if (!REVIEWED_SHA256_RE.test(sha256))
|
|
416
|
+
continue;
|
|
417
|
+
try {
|
|
418
|
+
byCanonical.set(realpathSync(path), sha256);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
/* pinned file missing — entry cannot match anything */
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (byCanonical.size === 0)
|
|
425
|
+
return () => false;
|
|
426
|
+
const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
|
|
427
|
+
return (scriptPath, source) => {
|
|
428
|
+
try {
|
|
429
|
+
if (!scriptPath || typeof scriptPath !== 'string' || typeof source !== 'string')
|
|
430
|
+
return false;
|
|
431
|
+
const expanded = scriptPath.startsWith('~/') ? join(homedir(), scriptPath.slice(2)) : scriptPath;
|
|
432
|
+
const full = isAbsolute(expanded) ? expanded : resolvePath(base, expanded);
|
|
433
|
+
const expected = byCanonical.get(realpathSync(full));
|
|
434
|
+
if (!expected)
|
|
435
|
+
return false;
|
|
436
|
+
return createHash('sha256').update(source, 'utf8').digest('hex') === expected;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
393
443
|
/** Raised when the operator never answered the approval card (#143). Distinct
|
|
394
444
|
* from a transport error, because the two have opposite handling: an error
|
|
395
445
|
* routes to failurePolicy, a timeout routes to the broker's asymmetric rule. */
|
|
@@ -469,6 +519,10 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
469
519
|
anomalyScore: v.decision === 'block' ? 1 : v.decision === 'allow' ? 0.1 : 0.6,
|
|
470
520
|
trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null, pipelineDurationMs: 0,
|
|
471
521
|
preview: preview.slice(0, 200), ts: new Date().toISOString(),
|
|
522
|
+
// #192: the durable record keeps the evidence, not just the rule names.
|
|
523
|
+
...(v.matches && v.matches.length > 0 ? { matches: v.matches } : {}),
|
|
524
|
+
// #189: an allow that leaned on the reviewed-script allowlist says so.
|
|
525
|
+
...(v.reviewedScripts && v.reviewedScripts.length > 0 ? { reviewedScripts: v.reviewedScripts } : {}),
|
|
472
526
|
};
|
|
473
527
|
}
|
|
474
528
|
// ── Approval broker (#143) ────────────────────────────────────────────────
|
|
@@ -628,6 +682,11 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
628
682
|
// resolver. An evaluator that predates the seam simply ignores it.
|
|
629
683
|
v = evaluateToolCall(context.toolName, context.arguments || {}, undefined, {
|
|
630
684
|
resolveScriptSource: createScriptSourceResolver(toolCallCwd(context)),
|
|
685
|
+
// #189: same allowlist, same predicate semantics as the hook surface —
|
|
686
|
+
// config is RAW here, validated inside createReviewedScriptCheck.
|
|
687
|
+
...(actionGuardCfg.reviewedScripts && actionGuardCfg.reviewedScripts.length > 0
|
|
688
|
+
? { isReviewedScript: createReviewedScriptCheck(actionGuardCfg.reviewedScripts, toolCallCwd(context)) }
|
|
689
|
+
: {}),
|
|
631
690
|
});
|
|
632
691
|
}
|
|
633
692
|
catch (err) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.35",
|
|
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}`);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.35",
|
|
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