@drakon-systems/shieldcortex-realtime 5.2.0 → 5.2.1
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 +29 -0
- package/dist/interceptor.js +76 -5
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +33 -0
- package/interceptor.ts +81 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -630,6 +630,27 @@ function detectHostOpenClawFromDisk() {
|
|
|
630
630
|
export function detectHostOpenClawVersion() {
|
|
631
631
|
return detectHostOpenClaw().version;
|
|
632
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* #594 (ADR-002 §5C): hand the detected host version to the guard so the
|
|
635
|
+
* reviewed `sessions_spawn` contract is judged by the revision measured at
|
|
636
|
+
* this host. Returns what was recorded (null = unknown host, which the guard
|
|
637
|
+
* reads as the union of measured revisions — never a guess). Never throws:
|
|
638
|
+
* an older installed dist has no setter, and a throwing one must not take
|
|
639
|
+
* interceptor construction down.
|
|
640
|
+
*/
|
|
641
|
+
export function recordHostVersionForGuard(defenceMod) {
|
|
642
|
+
const setter = defenceMod?.setNativeHostVersion;
|
|
643
|
+
if (typeof setter !== 'function')
|
|
644
|
+
return null;
|
|
645
|
+
const version = detectHostOpenClaw().version;
|
|
646
|
+
try {
|
|
647
|
+
setter('openclaw', version);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
return version;
|
|
653
|
+
}
|
|
633
654
|
/**
|
|
634
655
|
* Does this host have the `before_agent_run` gate at all?
|
|
635
656
|
*
|
|
@@ -3619,6 +3640,14 @@ export default {
|
|
|
3619
3640
|
const degradedPipeline = () => {
|
|
3620
3641
|
throw new Error('ShieldCortex: defence pipeline unavailable (dist missing or incomplete)');
|
|
3621
3642
|
};
|
|
3643
|
+
// #594 (ADR-002 §5C): tell the guard which host version its reviewed
|
|
3644
|
+
// native contracts are being judged against, so `sessions_spawn` is
|
|
3645
|
+
// judged by the revision MEASURED at this host rather than by one flat
|
|
3646
|
+
// bag. Same evidence order as the conversation gate: the runtime's own
|
|
3647
|
+
// version first, the install's package.json second, and null (which the
|
|
3648
|
+
// guard reads as "unknown host — union of measured revisions") when
|
|
3649
|
+
// neither is available. Absent on an older dist; then nothing changes.
|
|
3650
|
+
recordHostVersionForGuard(defenceMod);
|
|
3622
3651
|
interceptorReady = createInterceptor(interceptorConfig, canRunPipeline ? defenceMod.runDefencePipeline : degradedPipeline, {
|
|
3623
3652
|
evaluateToolCall: typeof defenceMod?.evaluateToolCall === 'function'
|
|
3624
3653
|
? defenceMod.evaluateToolCall
|
package/dist/interceptor.js
CHANGED
|
@@ -4,6 +4,26 @@ import { join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { createGatewayInvoker } from './broker-invoker.js';
|
|
6
6
|
import { escalateForTaint } from './session-taint.js';
|
|
7
|
+
/**
|
|
8
|
+
* One clause for the CONTRACT DRIFT warning naming the judging revision and
|
|
9
|
+
* what the selection means for the operator (#594). `floor` and `beyond` are
|
|
10
|
+
* the two that say "the measurement is older than the host — re-measure";
|
|
11
|
+
* `unknown-host` says the host never stated a version. Bounded: the host
|
|
12
|
+
* version was neutralised and length-capped where the observation was minted.
|
|
13
|
+
*/
|
|
14
|
+
export function describeDriftRevision(rev) {
|
|
15
|
+
if (!rev)
|
|
16
|
+
return '';
|
|
17
|
+
const host = rev.hostVersion ?? 'unknown';
|
|
18
|
+
switch (rev.selection) {
|
|
19
|
+
case 'exact': return `; judged by revision ${rev.measuredAt} = host ${host}`;
|
|
20
|
+
case 'floor': return `; judged by revision ${rev.measuredAt}, the nearest measured below host ${host} — re-measure the host`;
|
|
21
|
+
case 'beyond': return `; judged by revision ${rev.measuredAt}, older than host ${host} — re-measure the host`;
|
|
22
|
+
case 'below': return `; judged by revision ${rev.measuredAt}, newer than host ${host}`;
|
|
23
|
+
case 'unknown-host': return `; judged by ${rev.measuredAt} — host version unknown`;
|
|
24
|
+
default: return `; judged by revision ${rev.measuredAt} (${rev.selection}; host ${host})`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
7
27
|
/** #372 — the runtime mirror of ApprovalDecisionOutcome: the closure crosses a
|
|
8
28
|
* plugin boundary, so the union is enforced with a Set, not just the compiler. */
|
|
9
29
|
const CARD_AUDIT_OUTCOMES = new Set([
|
|
@@ -219,7 +239,10 @@ const FALLBACK_DANGEROUS_PATTERNS = [
|
|
|
219
239
|
{ re: /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:etc|usr|var|home|bin|sbin|boot|lib|lib64|opt|root)(?:\/\*?)?(?:\s|$)/i, signal: 'recursive-perms-system-dir' },
|
|
220
240
|
{ re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
|
|
221
241
|
{ re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
|
|
222
|
-
|
|
242
|
+
// #505: `.ssh` behind any home root + `authorized_keys` as a path segment — mirrors the guard row.
|
|
243
|
+
{ re: /\/etc\/(passwd|shadow|sudoers)|(?:~|\$\{?HOME\}?|\/home\/[^\s\/'"]+|\/root|\/Users\/[^\s\/'"]+)\/\.ssh(?![\w.-])|(?:^|[\s'"=:\/])\.ssh\/authorized_keys2?\b|\/authorized_keys2?\b|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
244
|
+
// #505: a shell write shape onto a login/interactive startup file — mirrors the guard row.
|
|
245
|
+
{ re: /(?:(?:>>?|>\|)(?:[ \t]|\\\n)*|\btee\b(?:(?:[ \t]|\\\n)+(?:--?[\w-]+(?:=\S*)?|'[^'\n]*'|"[^"\n]*"|[^\s'"|;&<>\\-][^\s'"|;&<>\\]*))*(?:[ \t]|\\\n)+|\bsed\b(?=[^|;&\n]*[ \t](?:-[a-zA-Z]*i|--in-place))[^|;&\n]*(?:[ \t]|\\\n)+)['"]?(?:[^\s'"|;&<>]*\/)?(?:\.(?:bashrc|zshrc|zprofile|zshenv|zlogin|zlogout|profile|bash_profile|bash_login|bash_logout)(?![\w.-])|\.config\/fish\/config\.fish\b)|\b(?:cp|mv|install)\b[^|;&\n]*(?:[ \t]|\\\n)+['"]?(?:[^\s'"|;&<>]*\/)?(?:\.(?:bashrc|zshrc|zprofile|zshenv|zlogin|zlogout|profile|bash_profile|bash_login|bash_logout)(?![\w.-])|\.config\/fish\/config\.fish\b)['"]?\s*(?=$|[|;&\n])/i, signal: 'modify-shell-startup' },
|
|
223
246
|
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
224
247
|
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
225
248
|
// Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
|
|
@@ -238,6 +261,8 @@ const FALLBACK_DANGEROUS_PATTERNS = [
|
|
|
238
261
|
{ re: /\bSHIELDCORTEX_(?:DIST_ROOT|PROTECTED_ROOT)\s*=/i, signal: 'disable-action-guard' },
|
|
239
262
|
{ re: /\/etc\/shieldcortex(?:\.conf\b|[\\/]|(?![\w.-]))/i, signal: 'disable-action-guard', lockPath: true },
|
|
240
263
|
{ re: /(?:^|[\s'"=:(\\/])\.claude[\\/]+settings(?:\.local)?\.json\b/i, signal: 'disable-action-guard', lockPath: true },
|
|
264
|
+
// #505: the OpenClaw gateway config — the plane's equivalent of the settings file above.
|
|
265
|
+
{ re: /(?:^|[\s'"=:(\\/])\.openclaw[\\/]+openclaw\.json\b/i, signal: 'disable-action-guard', lockPath: true },
|
|
241
266
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
242
267
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
243
268
|
{ 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' },
|
|
@@ -371,7 +396,42 @@ function fallbackLockPathAccessIsReadOnly(text, toolName) {
|
|
|
371
396
|
return sawStage;
|
|
372
397
|
}
|
|
373
398
|
/** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
|
|
399
|
+
// ── #505: startup-file WRITE target, ported to the blunt fallback ────────────
|
|
400
|
+
//
|
|
401
|
+
// The real guard gates a Write/Edit whose TARGET is a shell startup file on
|
|
402
|
+
// the path alone (`isShellStartupWritePath` at the verdict site): a PATH
|
|
403
|
+
// prepend written there carries no dangerous verb, so no content regex can
|
|
404
|
+
// see it. The DANGEROUS row above covers the SHELL spellings only; a tool
|
|
405
|
+
// write carries the target as a path argument, so in degraded mode the same
|
|
406
|
+
// write was invisible. Mirrors `classifyFamily`'s WRITE_TOOLS / READ_TOOLS
|
|
407
|
+
// split: a read-family tool never gates, an unknown tool with no write-like
|
|
408
|
+
// name never gates here (the table rows still apply to it), a write-like
|
|
409
|
+
// tool naming a startup file under a path key gates. Kept in sync across
|
|
410
|
+
// scripts/pre-tool-hook.mjs, plugins/openclaw/interceptor.ts and
|
|
411
|
+
// plugins/hermes/shieldcortex/sc_client.py.
|
|
412
|
+
/** Write-family tool names — the guard's WRITE_TOOLS set. */
|
|
413
|
+
const FALLBACK_WRITE_TOOLS = /(write|edit|create|update|patch|append|save|mkdir|move|copy|cp|mv|rename|chmod|chown)/;
|
|
414
|
+
/** Path-bearing keys only (never `command`): the target of a tool write. */
|
|
415
|
+
const FALLBACK_WRITE_PATH_KEYS = ['path', 'file_path', 'filePath', 'file', 'target', 'destination'];
|
|
416
|
+
/** A login/interactive startup file by basename, plus fish's config — `isShellStartupWritePath`. */
|
|
417
|
+
const FALLBACK_SHELL_STARTUP_PATH_RE = /(?:^|[\\/])(?:\.(?:bashrc|zshrc|zprofile|zshenv|zlogin|zlogout|profile|bash_profile|bash_login|bash_logout)|\.config[\\/]fish[\\/]config\.fish)$/i;
|
|
418
|
+
function fallbackWriteTargetMatch(args, toolName) {
|
|
419
|
+
const seg = String(toolName || '').toLowerCase().split(/__|\.|:|\//).filter(Boolean).pop() || '';
|
|
420
|
+
if (!seg || FALLBACK_READ_TOOLS.test(seg) || !FALLBACK_WRITE_TOOLS.test(seg))
|
|
421
|
+
return null;
|
|
422
|
+
for (const k of FALLBACK_WRITE_PATH_KEYS) {
|
|
423
|
+
const v = args?.[k];
|
|
424
|
+
if (typeof v !== 'string')
|
|
425
|
+
continue;
|
|
426
|
+
if (FALLBACK_SHELL_STARTUP_PATH_RE.test(v.trim()))
|
|
427
|
+
return 'modify-shell-startup';
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
374
431
|
function fallbackDangerousMatch(args, toolName) {
|
|
432
|
+
const writeTarget = fallbackWriteTargetMatch(args, toolName);
|
|
433
|
+
if (writeTarget)
|
|
434
|
+
return writeTarget;
|
|
375
435
|
const text = fallbackExecSurface(args);
|
|
376
436
|
if (!text)
|
|
377
437
|
return null;
|
|
@@ -424,6 +484,13 @@ const NATIVE_PROCESS_PHRASE = {
|
|
|
424
484
|
function isNativeProcessTool(toolName) {
|
|
425
485
|
return String(toolName || '').trim().toLowerCase() === 'process';
|
|
426
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* #600: the card's subject. It used to say "Jarvis" on every OpenClaw host,
|
|
489
|
+
* whatever the agent was called (and on every customer install). The
|
|
490
|
+
* interceptor has no trustworthy display name to hand — OpenClaw's agentId is
|
|
491
|
+
* usually `main` — so the card names the role, not a person.
|
|
492
|
+
*/
|
|
493
|
+
export const AGENT_SUBJECT = 'Your agent';
|
|
427
494
|
/**
|
|
428
495
|
* The plain sentence the card LEADS with.
|
|
429
496
|
*
|
|
@@ -445,13 +512,13 @@ function actionGuardLead(toolName, v, args) {
|
|
|
445
512
|
: '';
|
|
446
513
|
const phrase = isNativeProcessTool(toolName) ? NATIVE_PROCESS_PHRASE[verb] : undefined;
|
|
447
514
|
if (phrase) {
|
|
448
|
-
return
|
|
515
|
+
return `${AGENT_SUBJECT} wants to ${phrase} (${verb}). Allow once is this call only.`;
|
|
449
516
|
}
|
|
450
517
|
if (isSchemaInvalid(v)) {
|
|
451
|
-
return
|
|
518
|
+
return `${AGENT_SUBJECT} used ${toolName}, which ShieldCortex does not fully recognise yet. `
|
|
452
519
|
+ 'Allow once lets this one call through. It does not teach the tool.';
|
|
453
520
|
}
|
|
454
|
-
return
|
|
521
|
+
return `${AGENT_SUBJECT} wants to use ${toolName}, and ShieldCortex rated this call ${v.severity}. `
|
|
455
522
|
+ 'Allow once is this call only.';
|
|
456
523
|
}
|
|
457
524
|
/** Operator-facing approval prompt for a gated action (not a memory write). */
|
|
@@ -1096,7 +1163,11 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
1096
1163
|
: undefined;
|
|
1097
1164
|
if (v.decision === 'allow' && drift && actionGuardCfg.auditAllows !== false) {
|
|
1098
1165
|
const d = drift.contractDrift;
|
|
1099
|
-
|
|
1166
|
+
// #594: the clause after the contract label names the judging revision
|
|
1167
|
+
// and the selection reason, so a drift row on a host newer than the
|
|
1168
|
+
// newest measurement reads as "re-measure" rather than as noise. The
|
|
1169
|
+
// dropped-field list stays LAST, as data, exactly as before.
|
|
1170
|
+
log.warn(`[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}${describeDriftRevision(d.revision)}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`);
|
|
1100
1171
|
}
|
|
1101
1172
|
if (v.decision === 'allow') {
|
|
1102
1173
|
// Issue #95: a RECOGNISED allow (the guard evaluated a known operation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.1",
|
|
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
|
@@ -65,6 +65,10 @@ type DefenceModule = {
|
|
|
65
65
|
* probe in {@link inlinePolicyLockPresent} rather than by failing open. */
|
|
66
66
|
readPolicyLock?: (options?: { audit?: boolean; warn?: boolean }) => unknown;
|
|
67
67
|
applyPolicyLock?: (raw: Record<string, unknown>, state: unknown) => Record<string, unknown>;
|
|
68
|
+
/** #594: records the host version the reviewed native contracts are judged
|
|
69
|
+
* against. Optional — absent on an older dist, and then the contract is
|
|
70
|
+
* judged as `unknown-host` exactly as before this member existed. */
|
|
71
|
+
setNativeHostVersion?: (host: 'openclaw', version: string | null) => void;
|
|
68
72
|
runDefencePipeline?: (...args: any[]) => any;
|
|
69
73
|
scanToolResponse?: (
|
|
70
74
|
toolName: string,
|
|
@@ -926,6 +930,26 @@ export function detectHostOpenClawVersion(): string | null {
|
|
|
926
930
|
return detectHostOpenClaw().version;
|
|
927
931
|
}
|
|
928
932
|
|
|
933
|
+
/**
|
|
934
|
+
* #594 (ADR-002 §5C): hand the detected host version to the guard so the
|
|
935
|
+
* reviewed `sessions_spawn` contract is judged by the revision measured at
|
|
936
|
+
* this host. Returns what was recorded (null = unknown host, which the guard
|
|
937
|
+
* reads as the union of measured revisions — never a guess). Never throws:
|
|
938
|
+
* an older installed dist has no setter, and a throwing one must not take
|
|
939
|
+
* interceptor construction down.
|
|
940
|
+
*/
|
|
941
|
+
export function recordHostVersionForGuard(defenceMod: DefenceModule | null | undefined): string | null {
|
|
942
|
+
const setter = (defenceMod as { setNativeHostVersion?: unknown } | null | undefined)?.setNativeHostVersion;
|
|
943
|
+
if (typeof setter !== 'function') return null;
|
|
944
|
+
const version = detectHostOpenClaw().version;
|
|
945
|
+
try {
|
|
946
|
+
(setter as (host: 'openclaw', v: string | null) => void)('openclaw', version);
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
return version;
|
|
951
|
+
}
|
|
952
|
+
|
|
929
953
|
export type GateSupport = 'supported' | 'unsupported' | 'unknown';
|
|
930
954
|
|
|
931
955
|
/**
|
|
@@ -4227,6 +4251,15 @@ export default {
|
|
|
4227
4251
|
throw new Error('ShieldCortex: defence pipeline unavailable (dist missing or incomplete)');
|
|
4228
4252
|
};
|
|
4229
4253
|
|
|
4254
|
+
// #594 (ADR-002 §5C): tell the guard which host version its reviewed
|
|
4255
|
+
// native contracts are being judged against, so `sessions_spawn` is
|
|
4256
|
+
// judged by the revision MEASURED at this host rather than by one flat
|
|
4257
|
+
// bag. Same evidence order as the conversation gate: the runtime's own
|
|
4258
|
+
// version first, the install's package.json second, and null (which the
|
|
4259
|
+
// guard reads as "unknown host — union of measured revisions") when
|
|
4260
|
+
// neither is available. Absent on an older dist; then nothing changes.
|
|
4261
|
+
recordHostVersionForGuard(defenceMod);
|
|
4262
|
+
|
|
4230
4263
|
interceptorReady = createInterceptor(
|
|
4231
4264
|
interceptorConfig,
|
|
4232
4265
|
canRunPipeline ? (defenceMod!.runDefencePipeline as Parameters<typeof createInterceptor>[1]) : degradedPipeline,
|
package/interceptor.ts
CHANGED
|
@@ -52,6 +52,30 @@ export interface ContractDriftLike {
|
|
|
52
52
|
contract: string;
|
|
53
53
|
droppedKeys: string[];
|
|
54
54
|
truncated?: boolean;
|
|
55
|
+
/** #594: the measured revision that judged the call and why it was chosen
|
|
56
|
+
* (`exact` / `floor` / `beyond` / `below` / `unknown-host`). Absent on an
|
|
57
|
+
* older dist or a single-revision contract. */
|
|
58
|
+
revision?: { measuredAt: string; hostVersion: string | null; selection: string };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One clause for the CONTRACT DRIFT warning naming the judging revision and
|
|
63
|
+
* what the selection means for the operator (#594). `floor` and `beyond` are
|
|
64
|
+
* the two that say "the measurement is older than the host — re-measure";
|
|
65
|
+
* `unknown-host` says the host never stated a version. Bounded: the host
|
|
66
|
+
* version was neutralised and length-capped where the observation was minted.
|
|
67
|
+
*/
|
|
68
|
+
export function describeDriftRevision(rev: ContractDriftLike['revision']): string {
|
|
69
|
+
if (!rev) return '';
|
|
70
|
+
const host = rev.hostVersion ?? 'unknown';
|
|
71
|
+
switch (rev.selection) {
|
|
72
|
+
case 'exact': return `; judged by revision ${rev.measuredAt} = host ${host}`;
|
|
73
|
+
case 'floor': return `; judged by revision ${rev.measuredAt}, the nearest measured below host ${host} — re-measure the host`;
|
|
74
|
+
case 'beyond': return `; judged by revision ${rev.measuredAt}, older than host ${host} — re-measure the host`;
|
|
75
|
+
case 'below': return `; judged by revision ${rev.measuredAt}, newer than host ${host}`;
|
|
76
|
+
case 'unknown-host': return `; judged by ${rev.measuredAt} — host version unknown`;
|
|
77
|
+
default: return `; judged by revision ${rev.measuredAt} (${rev.selection}; host ${host})`;
|
|
78
|
+
}
|
|
55
79
|
}
|
|
56
80
|
|
|
57
81
|
export interface ToolGuardVerdictLike {
|
|
@@ -559,7 +583,10 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string; lockPath?
|
|
|
559
583
|
{ re: /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:etc|usr|var|home|bin|sbin|boot|lib|lib64|opt|root)(?:\/\*?)?(?:\s|$)/i, signal: 'recursive-perms-system-dir' },
|
|
560
584
|
{ re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
|
|
561
585
|
{ re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
|
|
562
|
-
|
|
586
|
+
// #505: `.ssh` behind any home root + `authorized_keys` as a path segment — mirrors the guard row.
|
|
587
|
+
{ re: /\/etc\/(passwd|shadow|sudoers)|(?:~|\$\{?HOME\}?|\/home\/[^\s\/'"]+|\/root|\/Users\/[^\s\/'"]+)\/\.ssh(?![\w.-])|(?:^|[\s'"=:\/])\.ssh\/authorized_keys2?\b|\/authorized_keys2?\b|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
588
|
+
// #505: a shell write shape onto a login/interactive startup file — mirrors the guard row.
|
|
589
|
+
{ re: /(?:(?:>>?|>\|)(?:[ \t]|\\\n)*|\btee\b(?:(?:[ \t]|\\\n)+(?:--?[\w-]+(?:=\S*)?|'[^'\n]*'|"[^"\n]*"|[^\s'"|;&<>\\-][^\s'"|;&<>\\]*))*(?:[ \t]|\\\n)+|\bsed\b(?=[^|;&\n]*[ \t](?:-[a-zA-Z]*i|--in-place))[^|;&\n]*(?:[ \t]|\\\n)+)['"]?(?:[^\s'"|;&<>]*\/)?(?:\.(?:bashrc|zshrc|zprofile|zshenv|zlogin|zlogout|profile|bash_profile|bash_login|bash_logout)(?![\w.-])|\.config\/fish\/config\.fish\b)|\b(?:cp|mv|install)\b[^|;&\n]*(?:[ \t]|\\\n)+['"]?(?:[^\s'"|;&<>]*\/)?(?:\.(?:bashrc|zshrc|zprofile|zshenv|zlogin|zlogout|profile|bash_profile|bash_login|bash_logout)(?![\w.-])|\.config\/fish\/config\.fish\b)['"]?\s*(?=$|[|;&\n])/i, signal: 'modify-shell-startup' },
|
|
563
590
|
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
564
591
|
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
565
592
|
// Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
|
|
@@ -578,6 +605,8 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string; lockPath?
|
|
|
578
605
|
{ re: /\bSHIELDCORTEX_(?:DIST_ROOT|PROTECTED_ROOT)\s*=/i, signal: 'disable-action-guard' },
|
|
579
606
|
{ re: /\/etc\/shieldcortex(?:\.conf\b|[\\/]|(?![\w.-]))/i, signal: 'disable-action-guard', lockPath: true },
|
|
580
607
|
{ re: /(?:^|[\s'"=:(\\/])\.claude[\\/]+settings(?:\.local)?\.json\b/i, signal: 'disable-action-guard', lockPath: true },
|
|
608
|
+
// #505: the OpenClaw gateway config — the plane's equivalent of the settings file above.
|
|
609
|
+
{ re: /(?:^|[\s'"=:(\\/])\.openclaw[\\/]+openclaw\.json\b/i, signal: 'disable-action-guard', lockPath: true },
|
|
581
610
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
582
611
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
583
612
|
{ 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' },
|
|
@@ -706,7 +735,42 @@ function fallbackLockPathAccessIsReadOnly(text: string, toolName: string | undef
|
|
|
706
735
|
}
|
|
707
736
|
|
|
708
737
|
/** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
|
|
738
|
+
|
|
739
|
+
// ── #505: startup-file WRITE target, ported to the blunt fallback ────────────
|
|
740
|
+
//
|
|
741
|
+
// The real guard gates a Write/Edit whose TARGET is a shell startup file on
|
|
742
|
+
// the path alone (`isShellStartupWritePath` at the verdict site): a PATH
|
|
743
|
+
// prepend written there carries no dangerous verb, so no content regex can
|
|
744
|
+
// see it. The DANGEROUS row above covers the SHELL spellings only; a tool
|
|
745
|
+
// write carries the target as a path argument, so in degraded mode the same
|
|
746
|
+
// write was invisible. Mirrors `classifyFamily`'s WRITE_TOOLS / READ_TOOLS
|
|
747
|
+
// split: a read-family tool never gates, an unknown tool with no write-like
|
|
748
|
+
// name never gates here (the table rows still apply to it), a write-like
|
|
749
|
+
// tool naming a startup file under a path key gates. Kept in sync across
|
|
750
|
+
// scripts/pre-tool-hook.mjs, plugins/openclaw/interceptor.ts and
|
|
751
|
+
// plugins/hermes/shieldcortex/sc_client.py.
|
|
752
|
+
|
|
753
|
+
/** Write-family tool names — the guard's WRITE_TOOLS set. */
|
|
754
|
+
const FALLBACK_WRITE_TOOLS = /(write|edit|create|update|patch|append|save|mkdir|move|copy|cp|mv|rename|chmod|chown)/;
|
|
755
|
+
/** Path-bearing keys only (never `command`): the target of a tool write. */
|
|
756
|
+
const FALLBACK_WRITE_PATH_KEYS = ['path', 'file_path', 'filePath', 'file', 'target', 'destination'];
|
|
757
|
+
/** A login/interactive startup file by basename, plus fish's config — `isShellStartupWritePath`. */
|
|
758
|
+
const FALLBACK_SHELL_STARTUP_PATH_RE = /(?:^|[\\/])(?:\.(?:bashrc|zshrc|zprofile|zshenv|zlogin|zlogout|profile|bash_profile|bash_login|bash_logout)|\.config[\\/]fish[\\/]config\.fish)$/i;
|
|
759
|
+
|
|
760
|
+
function fallbackWriteTargetMatch(args: Record<string, unknown> | undefined, toolName?: string): string | null {
|
|
761
|
+
const seg = String(toolName || '').toLowerCase().split(/__|\.|:|\//).filter(Boolean).pop() || '';
|
|
762
|
+
if (!seg || FALLBACK_READ_TOOLS.test(seg) || !FALLBACK_WRITE_TOOLS.test(seg)) return null;
|
|
763
|
+
for (const k of FALLBACK_WRITE_PATH_KEYS) {
|
|
764
|
+
const v = args?.[k];
|
|
765
|
+
if (typeof v !== 'string') continue;
|
|
766
|
+
if (FALLBACK_SHELL_STARTUP_PATH_RE.test(v.trim())) return 'modify-shell-startup';
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
|
|
709
771
|
function fallbackDangerousMatch(args: Record<string, unknown> | undefined, toolName?: string): string | null {
|
|
772
|
+
const writeTarget = fallbackWriteTargetMatch(args, toolName);
|
|
773
|
+
if (writeTarget) return writeTarget;
|
|
710
774
|
const text = fallbackExecSurface(args);
|
|
711
775
|
if (!text) return null;
|
|
712
776
|
const lockReadOnly = fallbackLockPathAccessIsReadOnly(text, toolName);
|
|
@@ -757,6 +821,14 @@ function isNativeProcessTool(toolName: string): boolean {
|
|
|
757
821
|
return String(toolName || '').trim().toLowerCase() === 'process';
|
|
758
822
|
}
|
|
759
823
|
|
|
824
|
+
/**
|
|
825
|
+
* #600: the card's subject. It used to say "Jarvis" on every OpenClaw host,
|
|
826
|
+
* whatever the agent was called (and on every customer install). The
|
|
827
|
+
* interceptor has no trustworthy display name to hand — OpenClaw's agentId is
|
|
828
|
+
* usually `main` — so the card names the role, not a person.
|
|
829
|
+
*/
|
|
830
|
+
export const AGENT_SUBJECT = 'Your agent';
|
|
831
|
+
|
|
760
832
|
/**
|
|
761
833
|
* The plain sentence the card LEADS with.
|
|
762
834
|
*
|
|
@@ -782,13 +854,13 @@ function actionGuardLead(
|
|
|
782
854
|
: '';
|
|
783
855
|
const phrase = isNativeProcessTool(toolName) ? NATIVE_PROCESS_PHRASE[verb] : undefined;
|
|
784
856
|
if (phrase) {
|
|
785
|
-
return
|
|
857
|
+
return `${AGENT_SUBJECT} wants to ${phrase} (${verb}). Allow once is this call only.`;
|
|
786
858
|
}
|
|
787
859
|
if (isSchemaInvalid(v)) {
|
|
788
|
-
return
|
|
860
|
+
return `${AGENT_SUBJECT} used ${toolName}, which ShieldCortex does not fully recognise yet. `
|
|
789
861
|
+ 'Allow once lets this one call through. It does not teach the tool.';
|
|
790
862
|
}
|
|
791
|
-
return
|
|
863
|
+
return `${AGENT_SUBJECT} wants to use ${toolName}, and ShieldCortex rated this call ${v.severity}. `
|
|
792
864
|
+ 'Allow once is this call only.';
|
|
793
865
|
}
|
|
794
866
|
|
|
@@ -1545,8 +1617,12 @@ export function createInterceptor(
|
|
|
1545
1617
|
: undefined;
|
|
1546
1618
|
if (v.decision === 'allow' && drift && actionGuardCfg.auditAllows !== false) {
|
|
1547
1619
|
const d = drift.contractDrift;
|
|
1620
|
+
// #594: the clause after the contract label names the judging revision
|
|
1621
|
+
// and the selection reason, so a drift row on a host newer than the
|
|
1622
|
+
// newest measurement reads as "re-measure" rather than as noise. The
|
|
1623
|
+
// dropped-field list stays LAST, as data, exactly as before.
|
|
1548
1624
|
log.warn(
|
|
1549
|
-
`[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`,
|
|
1625
|
+
`[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}${describeDriftRevision(d.revision)}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`,
|
|
1550
1626
|
);
|
|
1551
1627
|
}
|
|
1552
1628
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.1",
|
|
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