@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 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
@@ -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
- { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
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 `Jarvis wants to ${phrase} (${verb}). Allow once is this call only.`;
515
+ return `${AGENT_SUBJECT} wants to ${phrase} (${verb}). Allow once is this call only.`;
449
516
  }
450
517
  if (isSchemaInvalid(v)) {
451
- return `Jarvis used ${toolName}, which ShieldCortex does not fully recognise yet. `
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 `Jarvis wants to use ${toolName}, and ShieldCortex rated this call ${v.severity}. `
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
- log.warn(`[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`);
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.0",
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
- { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
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 `Jarvis wants to ${phrase} (${verb}). Allow once is this call only.`;
857
+ return `${AGENT_SUBJECT} wants to ${phrase} (${verb}). Allow once is this call only.`;
786
858
  }
787
859
  if (isSchemaInvalid(v)) {
788
- return `Jarvis used ${toolName}, which ShieldCortex does not fully recognise yet. `
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 `Jarvis wants to use ${toolName}, and ShieldCortex rated this call ${v.severity}. `
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "5.2.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "5.2.0",
3
+ "version": "5.2.1",
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",