@drakon-systems/shieldcortex-realtime 4.47.7 → 4.47.9

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/README.md CHANGED
@@ -113,6 +113,7 @@ Supported plugin config keys:
113
113
  - `actionGuard.enabled`: turn the before-tool-call Action Guard on or off (default `true`)
114
114
  - `actionGuard.enforce`: enforce dangerous-operation gating (default `true`); `false` opts down to warn-and-allow. Catastrophic operations are blocked regardless.
115
115
  - `actionGuard.autoApprove`: array of operation allowlist entries for unattended agents that legitimately need specific dangerous operations
116
+ - `actionGuard.auditAllows`: audit recognised (sensitive-tier) allow-decisions so "scanned & allowed" is distinguishable from "never scanned" (default `true`; benign allows are never audited)
116
117
  - `failurePolicy`: per-severity verdict when a decision can't be obtained unattended (defaults: `low`/`medium` allow, `high`/`critical` deny)
117
118
 
118
119
  ## Auto-memory
@@ -35,6 +35,7 @@ const DEFAULT_CONFIG = {
35
35
  enabled: true,
36
36
  enforce: true,
37
37
  autoApprove: [],
38
+ auditAllows: true,
38
39
  },
39
40
  };
40
41
  export { WATCHED_TOOLS, CONTENT_FIELDS, DEFAULT_CONFIG };
@@ -155,7 +156,7 @@ export function formatApprovalPrompt(input) {
155
156
  // itself break unattended agents/cron, the outcome ShieldCortex exists to
156
157
  // prevent. Mirrored in scripts/pre-tool-hook.mjs for the Claude Code surface.
157
158
  const FALLBACK_CATASTROPHIC_PATTERNS = [
158
- /\brm\b[^|;&\n]*?(?:-\w*r\w*f\w*|-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
159
+ /\brm\b[^|;&\n]*?(?:(?<![\w.\/-])-\w*r\w*f\w*|(?<![\w.\/-])-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
159
160
  /\brm\b[^|;&\n]*\s(?:-\w+\s+)*(?:\/|~|\$HOME|\/\*|\*|\.\/\*)(?:\s|$)/i,
160
161
  /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:?\s*&?\s*\}\s*;\s*:/,
161
162
  /\bmkfs(\.\w+)?\b/i,
@@ -170,11 +171,49 @@ const FALLBACK_CATASTROPHIC_PATTERNS = [
170
171
  /\b(?:curl|wget|fetch)\b[^|\n]*\|[^\n]*\bpython\d?\b[^\n]*\s-m\s*(?:code|pty|pdb)(?![\w.])/i,
171
172
  /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:\s|$)/i,
172
173
  ];
174
+ // WS2 dangerous-tier fallback (issue #59). Ports EVERY signal in
175
+ // tool-action-guard.ts's DANGEROUS array — a drift test
176
+ // (ws2-gate-degraded-integration-59) fails if the guard gains a DANGEROUS
177
+ // signal this list doesn't cover, so "kept in sync" is enforced, not just
178
+ // claimed. Used ONLY when the real guard can't scan: a recognised-dangerous
179
+ // shape routes through `failurePolicy` exactly as an unattended real verdict
180
+ // would, instead of the pre-#59 fail-OPEN. Copies the real (already anti-ReDoS,
181
+ // FP-narrowed) patterns verbatim, so read-only forms — `crontab -l`, `npm ls
182
+ // -g`, `--global-style`, shred use/mention — still pass. The one guard shape
183
+ // NOT here is bare `npx`/`bunx`: those are gated CONDITIONALLY by
184
+ // `isGatedNpxBunx` (shape-based, #96), not by a DANGEROUS pattern, and a blunt
185
+ // fallback matching them would over-gate `npx tsc`; `uvx`/`dlx` (unconditional)
186
+ // ARE covered. Mirrored in scripts/pre-tool-hook.mjs + hermes/sc_client.py.
187
+ const FALLBACK_DANGEROUS_PATTERNS = [
188
+ { 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' },
189
+ { re: /\bsudo\b|\bdoas\b|\bsu\s/i, signal: 'privilege-escalation' },
190
+ { re: /\bgit\b[^|\n]*\bpush\b[^|\n]*(--force\b|-f\b|\+)/i, signal: 'git-force-push' },
191
+ { re: /\bgit\b[^|\n]*\b(branch\s+-D|push\b[^|\n]*--delete|push\b[^|\n]*\s:)/i, signal: 'git-delete-branch' },
192
+ { re: /\b(systemctl|service)\b[^|\n]*\b(stop|disable|mask)\b|\b(kill|pkill|killall)\b/i, signal: 'stop-process-or-service' },
193
+ { re: /\b(iptables|ufw|nft|netplan|firewall-cmd)\b/i, signal: 'modify-network-firewall' },
194
+ { re: /\b(?:apt|apt-get|yum|dnf|brew|pip|pip3|gem|cargo)\b[^|\n]*\b(?:install|add)\b/i, signal: 'install-package' },
195
+ { re: /\b(?:npm|yarn|pnpm|bun)\b(?=[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-])|\bglobal\s+add\b))(?=[^|;&\n]*\s(?:install|add)(?=\s|$|[|;&\n]))|\b(?:npm|pnpm|bun)\s+(?:i(?:n(?:s(?:t(?:a(?:ll?)?)?)?)?)?|isnt(?:all)?)\b[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-]))/i, signal: 'install-package-global' },
196
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:(?:env|nohup|time|stdbuf|nice)\b(?:\s+(?:-{1,2}\S+|\w+=\S*|\d+))*\s+)*(?:sudo\s+)?(?:crontab\b(?!\s+-l\b)|at\b(?!\s+-l\b)(?!\s*$))|\/etc\/cron|\bsystemd-run\b[^|;&\n]*--on-(?:calendar|active|boot|startup|unit-active|unit-inactive)\b/i, signal: 'modify-scheduler' },
197
+ { re: /\bdd\b[^|;&\n]*\bof=/i, signal: 'dd-overwrite' },
198
+ { 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' },
199
+ { re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
200
+ { re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
201
+ { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
202
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
203
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
204
+ { 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' },
205
+ ];
173
206
  const FALLBACK_SURFACE_KEYS = [
174
207
  'command', 'cmd', 'script', 'code', 'input', 'shell', 'run',
175
208
  'path', 'file_path', 'filePath', 'file', 'target', 'destination', 'dir', 'directory',
176
209
  'url', 'uri', 'endpoint', 'href', 'host', 'to',
177
210
  ];
211
+ // The fallback is an outage-only blunt scanner; an UNBOUNDED scan over crafted
212
+ // input is a ReDoS vector (some ported guard patterns are O(n²) on pathological
213
+ // token runs like `git push push push …`). Dangerous/catastrophic shapes appear
214
+ // early in any real command, so cap the scanned surface — 4 KB is far beyond a
215
+ // real shell command. The real guard (no cap) still scans in full when it works.
216
+ const FALLBACK_SCAN_CAP = 4096;
178
217
  /** Same command/path/url field set tool-action-guard.ts extracts — narrow, not the whole args object. */
179
218
  function fallbackExecSurface(args) {
180
219
  const parts = [];
@@ -183,7 +222,7 @@ function fallbackExecSurface(args) {
183
222
  if (typeof v === 'string' && v.length > 0)
184
223
  parts.push(v);
185
224
  }
186
- return parts.join(' ');
225
+ return parts.join(' ').slice(0, FALLBACK_SCAN_CAP);
187
226
  }
188
227
  function fallbackCatastrophicMatch(args) {
189
228
  const text = fallbackExecSurface(args);
@@ -191,6 +230,17 @@ function fallbackCatastrophicMatch(args) {
191
230
  return false;
192
231
  return FALLBACK_CATASTROPHIC_PATTERNS.some(re => re.test(text));
193
232
  }
233
+ /** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
234
+ function fallbackDangerousMatch(args) {
235
+ const text = fallbackExecSurface(args);
236
+ if (!text)
237
+ return null;
238
+ for (const { re, signal } of FALLBACK_DANGEROUS_PATTERNS) {
239
+ if (re.test(text))
240
+ return signal;
241
+ }
242
+ return null;
243
+ }
194
244
  /** One-line summary of tool args for audit previews (bounded, no secrets dumped). */
195
245
  export function summariseToolArgs(args) {
196
246
  if (!args)
@@ -220,6 +270,20 @@ export function formatActionGuardPrompt(toolName, v) {
220
270
  }
221
271
  // --- Audit Logging (local JSONL) ---
222
272
  const AUDIT_DIR = join(homedir(), '.shieldcortex', 'audit');
273
+ // Issue #95: an unwritable audit sink used to be swallowed by this bare catch —
274
+ // entries silently dropped forever. Still best-effort (an audit failure must
275
+ // never block the agent), but the FIRST failure now warns loudly with the sink
276
+ // path and the error, and later failures keep a drop count for the breadcrumb.
277
+ let auditSinkFailures = 0;
278
+ export function noteAuditSinkFailure(err) {
279
+ auditSinkFailures++;
280
+ if (auditSinkFailures === 1) {
281
+ const detail = err instanceof Error ? err.message : String(err);
282
+ console.warn(`[shieldcortex] ⚠️ audit sink UNWRITABLE (${AUDIT_DIR}): ${detail} — audit entries are being DROPPED. ` +
283
+ `Fix the directory permissions/disk; enforcement continues but leaves no trail until this is resolved.`);
284
+ }
285
+ }
286
+ export function __resetAuditSinkFailuresForTest() { auditSinkFailures = 0; }
223
287
  function writeAuditEntry(entry) {
224
288
  try {
225
289
  mkdirSync(AUDIT_DIR, { recursive: true });
@@ -227,8 +291,9 @@ function writeAuditEntry(entry) {
227
291
  const file = join(AUDIT_DIR, `realtime-${date}.jsonl`);
228
292
  appendFileSync(file, JSON.stringify(entry) + '\n');
229
293
  }
230
- catch {
231
- // Best-effort — never block on audit failure
294
+ catch (err) {
295
+ // Best-effort — never block on audit failure, but never silent either (#95).
296
+ noteAuditSinkFailure(err);
232
297
  }
233
298
  }
234
299
  // --- X-Ray Inline Guard ---
@@ -288,34 +353,62 @@ export function createInterceptor(config, pipeline, options) {
288
353
  function guardAuditBase(toolName, v, preview) {
289
354
  return {
290
355
  type: 'intercept', tool: toolName,
291
- severity: v.severity === 'catastrophic' ? 'critical' : 'high',
356
+ severity: v.severity === 'catastrophic' ? 'critical' : v.decision === 'allow' ? 'low' : 'high',
292
357
  firewallResult: 'ACTION_GUARD', threats: v.signals,
293
- anomalyScore: v.decision === 'block' ? 1 : 0.6,
358
+ anomalyScore: v.decision === 'block' ? 1 : v.decision === 'allow' ? 0.1 : 0.6,
294
359
  trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null, pipelineDurationMs: 0,
295
360
  preview: preview.slice(0, 200), ts: new Date().toISOString(),
296
361
  };
297
362
  }
298
- // WS2 fail-closed path: when the real guard was never wired in or throws,
299
- // run the narrow fallback scan and DENY (throw) a catastrophic-looking
300
- // command instead of silently allowing it. The fallback recognising nothing
301
- // is not evidence the command is safe, only that it isn't one of the
302
- // handful of unambiguous shapes — everything else still falls through to
303
- // the pre-existing fail-OPEN log-and-allow.
363
+ // WS2 fail-closed path (issue #59): when the real guard was never wired in or
364
+ // throws, run the dependency-free fallback scan. Three tiers, so no dangerous
365
+ // op is ever silently allowed on a scan failure — and every could-not-scan
366
+ // decision leaves a `gate_degraded` audit row (ACTION_GUARD_FALLBACK marker),
367
+ // so forensics can distinguish "scanned & allowed" from "could not scan":
368
+ // 1. catastrophic → hard deny, always (ignores enforce:false).
369
+ // 2. dangerous → route through `failurePolicy` exactly as an unattended
370
+ // real verdict would (deny by default); enforce:false
371
+ // opts down to advisory.
372
+ // 3. no match → benign/unknown: fail OPEN (a degraded guard must not
373
+ // wedge normal work) but leave a visible breadcrumb.
304
374
  function handleGuardUnavailable(context, reason) {
305
- if (!fallbackCatastrophicMatch(context.arguments)) {
306
- log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName}`);
375
+ const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`.slice(0, 200);
376
+ const degradedBase = {
377
+ type: 'intercept', tool: context.toolName,
378
+ firewallResult: 'ACTION_GUARD_FALLBACK', trustScore: 0, sensitivityLevel: 'INTERNAL',
379
+ fragmentationScore: null, pipelineDurationMs: 0, preview, ts: new Date().toISOString(),
380
+ };
381
+ // 1. Catastrophic — hard deny, always.
382
+ if (fallbackCatastrophicMatch(context.arguments)) {
383
+ emitAudit({
384
+ ...degradedBase, severity: 'critical', threats: ['fallback-scan'], anomalyScore: 1,
385
+ action: 'auto_deny', outcome: 'auto_denied',
386
+ });
387
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
388
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
389
+ }
390
+ // 2. Dangerous — route through failurePolicy (the "can't obtain a verdict"
391
+ // policy; a degraded guard is precisely that). enforce:false → advisory.
392
+ const dangerousSignal = fallbackDangerousMatch(context.arguments);
393
+ if (dangerousSignal) {
394
+ const dBase = { ...degradedBase, severity: 'high', threats: ['fallback-scan', dangerousSignal], anomalyScore: 0.6 };
395
+ if (!actionGuardCfg.enforce) {
396
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: 'failure_allowed' });
397
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — advisory (enforce:false), allowing dangerous ${context.toolName} [${dangerousSignal}]`);
398
+ return;
399
+ }
400
+ const failAction = config.failurePolicy.high;
401
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
402
+ if (failAction === 'deny') {
403
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback matched a DANGEROUS op [${dangerousSignal}] — DENYING ${context.toolName} (fail-closed, failure policy: deny)`);
404
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), dangerous fallback match [${dangerousSignal}], failure policy: deny`);
405
+ }
307
406
  return;
308
407
  }
309
- const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
310
- emitAudit({
311
- type: 'intercept', tool: context.toolName, severity: 'critical',
312
- firewallResult: 'ACTION_GUARD_FALLBACK', threats: ['fallback-scan'],
313
- anomalyScore: 1, trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null,
314
- pipelineDurationMs: 0, preview: preview.slice(0, 200), ts: new Date().toISOString(),
315
- action: 'auto_deny', outcome: 'auto_denied',
316
- });
317
- log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
318
- throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
408
+ // 3. No match — benign/unknown. Fail open, but never silently: the
409
+ // gate_degraded breadcrumb makes the outage window auditable.
410
+ emitAudit({ ...degradedBase, severity: 'low', threats: ['fallback-scan'], anomalyScore: 0.1, action: 'gate_degraded', outcome: 'failure_allowed' });
411
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName} (fallback matched nothing; fail-open)`);
319
412
  }
320
413
  // Action Guard: gates non-memory tool calls (shell / file / network / git).
321
414
  // This is what makes "Iron Dome protects what the agent DOES" true at runtime.
@@ -334,8 +427,18 @@ export function createInterceptor(config, pipeline, options) {
334
427
  handleGuardUnavailable(context, `action-guard error: ${err instanceof Error ? err.message : err}`);
335
428
  return;
336
429
  }
337
- if (v.decision === 'allow')
430
+ if (v.decision === 'allow') {
431
+ // Issue #95: a RECOGNISED allow (the guard evaluated a known operation
432
+ // family and let it through — severity above benign) leaves an audit
433
+ // entry, so forensics can distinguish "scanned & allowed" from "never
434
+ // scanned". Benign allows stay unaudited by design (volume discipline);
435
+ // `actionGuard.auditAllows: false` opts the recognised entries off too.
436
+ if (v.severity !== 'benign' && actionGuardCfg.auditAllows !== false) {
437
+ const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
438
+ emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), action: 'allow', outcome: 'allowed' });
439
+ }
338
440
  return;
441
+ }
339
442
  const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
340
443
  const base = guardAuditBase(context.toolName, v, preview);
341
444
  const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.7",
3
+ "version": "4.47.9",
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,
@@ -93,6 +93,12 @@
93
93
  "description": "Family/action/signal names pre-approved for unattended agents that legitimately need specific dangerous operations (case-insensitive substring match). Never applies to catastrophic operations.",
94
94
  "type": "array",
95
95
  "advanced": true
96
+ },
97
+ "interceptor.actionGuard.auditAllows": {
98
+ "label": "Audit Recognised Allows",
99
+ "description": "Write an audit entry when the guard evaluates a recognised (sensitive-tier) operation and allows it, so forensics can distinguish scanned-and-allowed from never-scanned. Benign allows are never audited.",
100
+ "type": "boolean",
101
+ "advanced": true
96
102
  }
97
103
  },
98
104
  "configSchema": {
@@ -232,6 +238,10 @@
232
238
  "type": "string"
233
239
  },
234
240
  "default": []
241
+ },
242
+ "auditAllows": {
243
+ "type": "boolean",
244
+ "default": true
235
245
  }
236
246
  }
237
247
  }
package/interceptor.ts CHANGED
@@ -26,6 +26,9 @@ export interface ActionGuardConfig {
26
26
  enabled: boolean;
27
27
  enforce: boolean;
28
28
  autoApprove?: string[];
29
+ /** Audit recognised (severity 'sensitive'+) allow-decisions. Default true (issue #95).
30
+ * Benign allows are never audited — on a busy agent every `ls` would drown the stream. */
31
+ auditAllows?: boolean;
29
32
  }
30
33
 
31
34
  /** Structural shape of a Tool Action Guard verdict (kept local to avoid a
@@ -66,8 +69,8 @@ export interface InterceptAuditEntry {
66
69
  sensitivityLevel: string; // from the pipeline result's sensitivity level
67
70
  fragmentationScore: number | null; // from the pipeline result's fragmentation score, or null
68
71
  pipelineDurationMs: number; // wall-clock ms around the runDefencePipeline call
69
- action: InterceptAction | 'auto_deny' | 'rate_limit';
70
- outcome: 'approved' | 'denied' | 'auto_denied' | 'logged' | 'warned' | 'failure_allowed' | 'failure_denied';
72
+ action: InterceptAction | 'auto_deny' | 'rate_limit' | 'allow' | 'gate_degraded';
73
+ outcome: 'approved' | 'denied' | 'auto_denied' | 'logged' | 'warned' | 'failure_allowed' | 'failure_denied' | 'allowed';
71
74
  preview: string;
72
75
  ts: string;
73
76
  }
@@ -107,6 +110,7 @@ const DEFAULT_CONFIG: InterceptorConfig = {
107
110
  enabled: true,
108
111
  enforce: true,
109
112
  autoApprove: [],
113
+ auditAllows: true,
110
114
  },
111
115
  };
112
116
 
@@ -259,7 +263,7 @@ export function formatApprovalPrompt(input: ApprovalPromptInput): string {
259
263
  // itself break unattended agents/cron, the outcome ShieldCortex exists to
260
264
  // prevent. Mirrored in scripts/pre-tool-hook.mjs for the Claude Code surface.
261
265
  const FALLBACK_CATASTROPHIC_PATTERNS: RegExp[] = [
262
- /\brm\b[^|;&\n]*?(?:-\w*r\w*f\w*|-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
266
+ /\brm\b[^|;&\n]*?(?:(?<![\w.\/-])-\w*r\w*f\w*|(?<![\w.\/-])-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
263
267
  /\brm\b[^|;&\n]*\s(?:-\w+\s+)*(?:\/|~|\$HOME|\/\*|\*|\.\/\*)(?:\s|$)/i,
264
268
  /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:?\s*&?\s*\}\s*;\s*:/,
265
269
  /\bmkfs(\.\w+)?\b/i,
@@ -275,12 +279,52 @@ const FALLBACK_CATASTROPHIC_PATTERNS: RegExp[] = [
275
279
  /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:\s|$)/i,
276
280
  ];
277
281
 
282
+ // WS2 dangerous-tier fallback (issue #59). Ports EVERY signal in
283
+ // tool-action-guard.ts's DANGEROUS array — a drift test
284
+ // (ws2-gate-degraded-integration-59) fails if the guard gains a DANGEROUS
285
+ // signal this list doesn't cover, so "kept in sync" is enforced, not just
286
+ // claimed. Used ONLY when the real guard can't scan: a recognised-dangerous
287
+ // shape routes through `failurePolicy` exactly as an unattended real verdict
288
+ // would, instead of the pre-#59 fail-OPEN. Copies the real (already anti-ReDoS,
289
+ // FP-narrowed) patterns verbatim, so read-only forms — `crontab -l`, `npm ls
290
+ // -g`, `--global-style`, shred use/mention — still pass. The one guard shape
291
+ // NOT here is bare `npx`/`bunx`: those are gated CONDITIONALLY by
292
+ // `isGatedNpxBunx` (shape-based, #96), not by a DANGEROUS pattern, and a blunt
293
+ // fallback matching them would over-gate `npx tsc`; `uvx`/`dlx` (unconditional)
294
+ // ARE covered. Mirrored in scripts/pre-tool-hook.mjs + hermes/sc_client.py.
295
+ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
296
+ { 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' },
297
+ { re: /\bsudo\b|\bdoas\b|\bsu\s/i, signal: 'privilege-escalation' },
298
+ { re: /\bgit\b[^|\n]*\bpush\b[^|\n]*(--force\b|-f\b|\+)/i, signal: 'git-force-push' },
299
+ { re: /\bgit\b[^|\n]*\b(branch\s+-D|push\b[^|\n]*--delete|push\b[^|\n]*\s:)/i, signal: 'git-delete-branch' },
300
+ { re: /\b(systemctl|service)\b[^|\n]*\b(stop|disable|mask)\b|\b(kill|pkill|killall)\b/i, signal: 'stop-process-or-service' },
301
+ { re: /\b(iptables|ufw|nft|netplan|firewall-cmd)\b/i, signal: 'modify-network-firewall' },
302
+ { re: /\b(?:apt|apt-get|yum|dnf|brew|pip|pip3|gem|cargo)\b[^|\n]*\b(?:install|add)\b/i, signal: 'install-package' },
303
+ { re: /\b(?:npm|yarn|pnpm|bun)\b(?=[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-])|\bglobal\s+add\b))(?=[^|;&\n]*\s(?:install|add)(?=\s|$|[|;&\n]))|\b(?:npm|pnpm|bun)\s+(?:i(?:n(?:s(?:t(?:a(?:ll?)?)?)?)?)?|isnt(?:all)?)\b[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-]))/i, signal: 'install-package-global' },
304
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:(?:env|nohup|time|stdbuf|nice)\b(?:\s+(?:-{1,2}\S+|\w+=\S*|\d+))*\s+)*(?:sudo\s+)?(?:crontab\b(?!\s+-l\b)|at\b(?!\s+-l\b)(?!\s*$))|\/etc\/cron|\bsystemd-run\b[^|;&\n]*--on-(?:calendar|active|boot|startup|unit-active|unit-inactive)\b/i, signal: 'modify-scheduler' },
305
+ { re: /\bdd\b[^|;&\n]*\bof=/i, signal: 'dd-overwrite' },
306
+ { 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' },
307
+ { re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
308
+ { re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
309
+ { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
310
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
311
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
312
+ { 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' },
313
+ ];
314
+
278
315
  const FALLBACK_SURFACE_KEYS = [
279
316
  'command', 'cmd', 'script', 'code', 'input', 'shell', 'run',
280
317
  'path', 'file_path', 'filePath', 'file', 'target', 'destination', 'dir', 'directory',
281
318
  'url', 'uri', 'endpoint', 'href', 'host', 'to',
282
319
  ];
283
320
 
321
+ // The fallback is an outage-only blunt scanner; an UNBOUNDED scan over crafted
322
+ // input is a ReDoS vector (some ported guard patterns are O(n²) on pathological
323
+ // token runs like `git push push push …`). Dangerous/catastrophic shapes appear
324
+ // early in any real command, so cap the scanned surface — 4 KB is far beyond a
325
+ // real shell command. The real guard (no cap) still scans in full when it works.
326
+ const FALLBACK_SCAN_CAP = 4096;
327
+
284
328
  /** Same command/path/url field set tool-action-guard.ts extracts — narrow, not the whole args object. */
285
329
  function fallbackExecSurface(args: Record<string, unknown> | undefined): string {
286
330
  const parts: string[] = [];
@@ -288,7 +332,7 @@ function fallbackExecSurface(args: Record<string, unknown> | undefined): string
288
332
  const v = args?.[k];
289
333
  if (typeof v === 'string' && v.length > 0) parts.push(v);
290
334
  }
291
- return parts.join(' ');
335
+ return parts.join(' ').slice(0, FALLBACK_SCAN_CAP);
292
336
  }
293
337
 
294
338
  function fallbackCatastrophicMatch(args: Record<string, unknown> | undefined): boolean {
@@ -297,6 +341,16 @@ function fallbackCatastrophicMatch(args: Record<string, unknown> | undefined): b
297
341
  return FALLBACK_CATASTROPHIC_PATTERNS.some(re => re.test(text));
298
342
  }
299
343
 
344
+ /** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
345
+ function fallbackDangerousMatch(args: Record<string, unknown> | undefined): string | null {
346
+ const text = fallbackExecSurface(args);
347
+ if (!text) return null;
348
+ for (const { re, signal } of FALLBACK_DANGEROUS_PATTERNS) {
349
+ if (re.test(text)) return signal;
350
+ }
351
+ return null;
352
+ }
353
+
300
354
  /** One-line summary of tool args for audit previews (bounded, no secrets dumped). */
301
355
  export function summariseToolArgs(args: Record<string, unknown> | undefined): string {
302
356
  if (!args) return '';
@@ -327,14 +381,32 @@ export function formatActionGuardPrompt(toolName: string, v: ToolGuardVerdictLik
327
381
 
328
382
  const AUDIT_DIR = join(homedir(), '.shieldcortex', 'audit');
329
383
 
384
+ // Issue #95: an unwritable audit sink used to be swallowed by this bare catch —
385
+ // entries silently dropped forever. Still best-effort (an audit failure must
386
+ // never block the agent), but the FIRST failure now warns loudly with the sink
387
+ // path and the error, and later failures keep a drop count for the breadcrumb.
388
+ let auditSinkFailures = 0;
389
+ export function noteAuditSinkFailure(err: unknown): void {
390
+ auditSinkFailures++;
391
+ if (auditSinkFailures === 1) {
392
+ const detail = err instanceof Error ? err.message : String(err);
393
+ console.warn(
394
+ `[shieldcortex] ⚠️ audit sink UNWRITABLE (${AUDIT_DIR}): ${detail} — audit entries are being DROPPED. ` +
395
+ `Fix the directory permissions/disk; enforcement continues but leaves no trail until this is resolved.`,
396
+ );
397
+ }
398
+ }
399
+ export function __resetAuditSinkFailuresForTest(): void { auditSinkFailures = 0; }
400
+
330
401
  function writeAuditEntry(entry: InterceptAuditEntry): void {
331
402
  try {
332
403
  mkdirSync(AUDIT_DIR, { recursive: true });
333
404
  const date = new Date().toISOString().slice(0, 10);
334
405
  const file = join(AUDIT_DIR, `realtime-${date}.jsonl`);
335
406
  appendFileSync(file, JSON.stringify(entry) + '\n');
336
- } catch {
337
- // Best-effort — never block on audit failure
407
+ } catch (err) {
408
+ // Best-effort — never block on audit failure, but never silent either (#95).
409
+ noteAuditSinkFailure(err);
338
410
  }
339
411
  }
340
412
 
@@ -445,35 +517,66 @@ export function createInterceptor(
445
517
  function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
446
518
  return {
447
519
  type: 'intercept', tool: toolName,
448
- severity: v.severity === 'catastrophic' ? 'critical' : 'high',
520
+ severity: v.severity === 'catastrophic' ? 'critical' : v.decision === 'allow' ? 'low' : 'high',
449
521
  firewallResult: 'ACTION_GUARD', threats: v.signals,
450
- anomalyScore: v.decision === 'block' ? 1 : 0.6,
522
+ anomalyScore: v.decision === 'block' ? 1 : v.decision === 'allow' ? 0.1 : 0.6,
451
523
  trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null, pipelineDurationMs: 0,
452
524
  preview: preview.slice(0, 200), ts: new Date().toISOString(),
453
525
  };
454
526
  }
455
527
 
456
- // WS2 fail-closed path: when the real guard was never wired in or throws,
457
- // run the narrow fallback scan and DENY (throw) a catastrophic-looking
458
- // command instead of silently allowing it. The fallback recognising nothing
459
- // is not evidence the command is safe, only that it isn't one of the
460
- // handful of unambiguous shapes — everything else still falls through to
461
- // the pre-existing fail-OPEN log-and-allow.
528
+ // WS2 fail-closed path (issue #59): when the real guard was never wired in or
529
+ // throws, run the dependency-free fallback scan. Three tiers, so no dangerous
530
+ // op is ever silently allowed on a scan failure — and every could-not-scan
531
+ // decision leaves a `gate_degraded` audit row (ACTION_GUARD_FALLBACK marker),
532
+ // so forensics can distinguish "scanned & allowed" from "could not scan":
533
+ // 1. catastrophic → hard deny, always (ignores enforce:false).
534
+ // 2. dangerous → route through `failurePolicy` exactly as an unattended
535
+ // real verdict would (deny by default); enforce:false
536
+ // opts down to advisory.
537
+ // 3. no match → benign/unknown: fail OPEN (a degraded guard must not
538
+ // wedge normal work) but leave a visible breadcrumb.
462
539
  function handleGuardUnavailable(context: ToolCallContext, reason: string): void {
463
- if (!fallbackCatastrophicMatch(context.arguments)) {
464
- log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName}`);
540
+ const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`.slice(0, 200);
541
+ const degradedBase = {
542
+ type: 'intercept' as const, tool: context.toolName,
543
+ firewallResult: 'ACTION_GUARD_FALLBACK', trustScore: 0, sensitivityLevel: 'INTERNAL',
544
+ fragmentationScore: null, pipelineDurationMs: 0, preview, ts: new Date().toISOString(),
545
+ };
546
+
547
+ // 1. Catastrophic — hard deny, always.
548
+ if (fallbackCatastrophicMatch(context.arguments)) {
549
+ emitAudit({
550
+ ...degradedBase, severity: 'critical', threats: ['fallback-scan'], anomalyScore: 1,
551
+ action: 'auto_deny', outcome: 'auto_denied',
552
+ });
553
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
554
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
555
+ }
556
+
557
+ // 2. Dangerous — route through failurePolicy (the "can't obtain a verdict"
558
+ // policy; a degraded guard is precisely that). enforce:false → advisory.
559
+ const dangerousSignal = fallbackDangerousMatch(context.arguments);
560
+ if (dangerousSignal) {
561
+ const dBase = { ...degradedBase, severity: 'high' as Severity, threats: ['fallback-scan', dangerousSignal], anomalyScore: 0.6 };
562
+ if (!actionGuardCfg.enforce) {
563
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: 'failure_allowed' });
564
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — advisory (enforce:false), allowing dangerous ${context.toolName} [${dangerousSignal}]`);
565
+ return;
566
+ }
567
+ const failAction = config.failurePolicy.high;
568
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
569
+ if (failAction === 'deny') {
570
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback matched a DANGEROUS op [${dangerousSignal}] — DENYING ${context.toolName} (fail-closed, failure policy: deny)`);
571
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), dangerous fallback match [${dangerousSignal}], failure policy: deny`);
572
+ }
465
573
  return;
466
574
  }
467
- const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
468
- emitAudit({
469
- type: 'intercept', tool: context.toolName, severity: 'critical',
470
- firewallResult: 'ACTION_GUARD_FALLBACK', threats: ['fallback-scan'],
471
- anomalyScore: 1, trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null,
472
- pipelineDurationMs: 0, preview: preview.slice(0, 200), ts: new Date().toISOString(),
473
- action: 'auto_deny', outcome: 'auto_denied',
474
- });
475
- log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
476
- throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
575
+
576
+ // 3. No match — benign/unknown. Fail open, but never silently: the
577
+ // gate_degraded breadcrumb makes the outage window auditable.
578
+ emitAudit({ ...degradedBase, severity: 'low', threats: ['fallback-scan'], anomalyScore: 0.1, action: 'gate_degraded', outcome: 'failure_allowed' });
579
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName} (fallback matched nothing; fail-open)`);
477
580
  }
478
581
 
479
582
  // Action Guard: gates non-memory tool calls (shell / file / network / git).
@@ -493,7 +596,18 @@ export function createInterceptor(
493
596
  handleGuardUnavailable(context, `action-guard error: ${err instanceof Error ? err.message : err}`);
494
597
  return;
495
598
  }
496
- if (v.decision === 'allow') return;
599
+ if (v.decision === 'allow') {
600
+ // Issue #95: a RECOGNISED allow (the guard evaluated a known operation
601
+ // family and let it through — severity above benign) leaves an audit
602
+ // entry, so forensics can distinguish "scanned & allowed" from "never
603
+ // scanned". Benign allows stay unaudited by design (volume discipline);
604
+ // `actionGuard.auditAllows: false` opts the recognised entries off too.
605
+ if (v.severity !== 'benign' && actionGuardCfg.auditAllows !== false) {
606
+ const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
607
+ emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), action: 'allow', outcome: 'allowed' });
608
+ }
609
+ return;
610
+ }
497
611
 
498
612
  const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
499
613
  const base = guardAuditBase(context.toolName, v, preview);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.7",
3
+ "version": "4.47.9",
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,
@@ -93,6 +93,12 @@
93
93
  "description": "Family/action/signal names pre-approved for unattended agents that legitimately need specific dangerous operations (case-insensitive substring match). Never applies to catastrophic operations.",
94
94
  "type": "array",
95
95
  "advanced": true
96
+ },
97
+ "interceptor.actionGuard.auditAllows": {
98
+ "label": "Audit Recognised Allows",
99
+ "description": "Write an audit entry when the guard evaluates a recognised (sensitive-tier) operation and allows it, so forensics can distinguish scanned-and-allowed from never-scanned. Benign allows are never audited.",
100
+ "type": "boolean",
101
+ "advanced": true
96
102
  }
97
103
  },
98
104
  "configSchema": {
@@ -232,6 +238,10 @@
232
238
  "type": "string"
233
239
  },
234
240
  "default": []
241
+ },
242
+ "auditAllows": {
243
+ "type": "boolean",
244
+ "default": true
235
245
  }
236
246
  }
237
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.47.7",
3
+ "version": "4.47.9",
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",