@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/dist/index.js +440 -99
- package/dist/interceptor.js +193 -8
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +380 -31
- package/interceptor.ts +196 -9
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
792
|
+
export function formatActionGuardPrompt(
|
|
793
|
+
toolName: string,
|
|
794
|
+
v: ToolGuardVerdictLike,
|
|
795
|
+
args?: Record<string, unknown>,
|
|
796
|
+
): string {
|
|
620
797
|
return [
|
|
621
|
-
'🛡️ ShieldCortex
|
|
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
|
-
'[
|
|
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
|
-
|
|
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) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "5.0.
|
|
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