@drakon-systems/shieldcortex-realtime 5.0.5 → 5.0.7

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.
@@ -224,6 +224,20 @@ const FALLBACK_DANGEROUS_PATTERNS = [
224
224
  { re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
225
225
  // Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
226
226
  { re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
227
+ // #500: outage fallback must gate self-disable / global uninstall / config.json writes.
228
+ { re: /--action-guard-(?:disable|advisory)\b|\biron-dome\s+deactivate\b/i, signal: 'disable-action-guard' },
229
+ { 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' },
230
+ { re: /\.shieldcortex[\\/]+config\.json\b/i, signal: 'touch-guard-config' },
231
+ // #501: the policy lock's own attack surface. The two environment seams that
232
+ // decide WHICH policy-lock reader runs and which root it reads; the protected
233
+ // root and its pointer; and `~/.claude/settings.json`, whose `env` stanza is
234
+ // the same-UID file that delivers those variables into the enforcing
235
+ // process. All at the `disable-action-guard` tier, because that is what they
236
+ // are. Kept byte-identical with the sibling table by the #501 drift test in
237
+ // enforcement-surface-parity.
238
+ { re: /\bSHIELDCORTEX_(?:DIST_ROOT|PROTECTED_ROOT)\s*=/i, signal: 'disable-action-guard' },
239
+ { re: /\/etc\/shieldcortex(?:\.conf\b|[\\/]|(?![\w.-]))/i, signal: 'disable-action-guard', lockPath: true },
240
+ { re: /(?:^|[\s'"=:(\\/])\.claude[\\/]+settings(?:\.local)?\.json\b/i, signal: 'disable-action-guard', lockPath: true },
227
241
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
228
242
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
229
243
  { 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' },
@@ -246,6 +260,18 @@ function fallbackExecSurface(args) {
246
260
  const v = args?.[k];
247
261
  if (typeof v === 'string' && v.length > 0)
248
262
  parts.push(v);
263
+ // #522 r7 FIND-4: an ARGV ARRAY under one of these keys is a real host
264
+ // shape, and the guard this fallback stands in for already reads it —
265
+ // `rawStringArgs` in tool-action-guard.ts joins string arrays, and the
266
+ // catastrophic tier blocks the array form of a recursive root delete.
267
+ // Skipping arrays here made the degraded scan strictly weaker than the
268
+ // evaluator it replaces, on exactly the tier documented as an
269
+ // unconditional deny.
270
+ else if (Array.isArray(v)) {
271
+ const joined = v.filter((e) => typeof e === 'string').join(' ');
272
+ if (joined.length > 0)
273
+ parts.push(joined);
274
+ }
249
275
  }
250
276
  return parts.join(' ').slice(0, FALLBACK_SCAN_CAP);
251
277
  }
@@ -255,12 +281,104 @@ function fallbackCatastrophicMatch(args) {
255
281
  return false;
256
282
  return FALLBACK_CATASTROPHIC_PATTERNS.some(re => re.test(text));
257
283
  }
284
+ // ── #522 G4: the lock-path READ carve-out, ported to the blunt fallback ──────
285
+ //
286
+ // `ee5c6ac1` gave the real guard a carve-out: pure inspection of the protected
287
+ // root or `.claude/settings(.local).json` is not an attempt on the floor, so
288
+ // `Read {file_path:<lock>}` and `cat|grep|jq|ls <lock>` allow while every write
289
+ // shape still gates. That carve-out lives in tool-action-guard.ts — the module
290
+ // that is MISSING in exactly this degraded mode. So a broken install on a
291
+ // locked host carded every settings/policy read, which is the UX the carve-out
292
+ // was written to stop. These mirror it, fail-closed, with no dependency on the
293
+ // dist.
294
+ //
295
+ // Scope: this drops ONLY the two lock-PATH rows below (tagged `lockPath`).
296
+ // The env-seam row, the #500 command shapes and every other signal are
297
+ // untouched, so `rm`, `tee`, `cp`, `sed -i`, a redirect and `chmod` onto those
298
+ // paths still gate here exactly as they did.
299
+ /** Read-family tools cannot write; mirrors `classifyFamily`'s READ_TOOLS. */
300
+ 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)$/;
301
+ /** Shell verbs that only OBSERVE — the guard's LOCK_READONLY_VERB_RE set. */
302
+ 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;
303
+ /** `git <sub>` stages that only read history / the working tree. */
304
+ const FALLBACK_GIT_READ_SUB_RE = /^(?:log|show|diff|status|blame|ls-files)$/i;
305
+ /**
306
+ * True when a `git` stage writes a file or runs a configured driver. Judged per
307
+ * TOKEN with quotes stripped, not against the raw spelling: a pattern that
308
+ * required whitespace immediately before `--` was defeated by an ordinary
309
+ * quoted argument (#522 r2). The short form is matched GLUED as well as bare
310
+ * (`-o<file>` is what parse-options accepts). Mirrors `gitStageWritesOrExecs`
311
+ * in src/defence/iron-dome/tool-action-guard.ts — keep the three in lockstep.
312
+ */
313
+ function fallbackGitStageWrites(stage) {
314
+ for (const raw of stage.split(/\s+/)) {
315
+ if (!raw)
316
+ continue;
317
+ const token = raw.replace(/['"]/g, '');
318
+ if (/^-o(?:$|[^-])/.test(token))
319
+ return true;
320
+ if (/^--(?:output|ext-diff)\b/i.test(token))
321
+ return true;
322
+ }
323
+ return false;
324
+ }
325
+ /** Any non-fd-dup redirect, glued or spaced — `echo x > <lock>` is a WRITE. */
326
+ const FALLBACK_REDIRECT_RE = />{1,2}\|?(?!&\d)/;
327
+ /** Nested execution keeps the gate; the verb whitelist cannot see inside it. */
328
+ const FALLBACK_NESTED_EXEC_RE = /\$\(|`|<\(|>\(|\beval\b|\bsource\b|\b\.\s+\/|\bfunction\b|[\w.-]+\s*\(\s*\)\s*\{/i;
329
+ /** Assigning an env seam decides WHICH reader runs — never a read. */
330
+ const FALLBACK_LOCK_ENV_SEAM_RE = /\bSHIELDCORTEX_(?:DIST_ROOT|PROTECTED_ROOT)\s*=/i;
331
+ /**
332
+ * True when the whole surface is pure inspection of a lock path. Fail-closed on
333
+ * an env-seam assignment, a redirect, nested execution, and any unknown verb in
334
+ * any stage of any statement — the same rule the real guard applies, with the
335
+ * statement split done conservatively (`&` and `|` both separate, so a
336
+ * pipeline stage or a backgrounded sibling must ALSO be a read).
337
+ */
338
+ function fallbackLockPathAccessIsReadOnly(text, toolName) {
339
+ if (!text)
340
+ return false;
341
+ if (FALLBACK_LOCK_ENV_SEAM_RE.test(text))
342
+ return false;
343
+ const seg = String(toolName || '').toLowerCase().split(/__|\.|:|\//).filter(Boolean).pop() || '';
344
+ if (seg && FALLBACK_READ_TOOLS.test(seg))
345
+ return true;
346
+ if (FALLBACK_REDIRECT_RE.test(text) || FALLBACK_NESTED_EXEC_RE.test(text))
347
+ return false;
348
+ let sawStage = false;
349
+ for (const raw of text.split(/[\n;&|]+/)) {
350
+ const stage = raw.trim();
351
+ if (!stage)
352
+ continue;
353
+ sawStage = true;
354
+ const toks = stage
355
+ .replace(/^(?:[A-Za-z_]\w*=\S*\s+)+/, '')
356
+ .replace(/^sudo\s+(?:-E\s+)?/, '')
357
+ .split(/\s+/);
358
+ const word = toks[0] || '';
359
+ const base = word.split('/').pop() || word;
360
+ if (/^git$/i.test(base)) {
361
+ const sub = toks.slice(1).find((t) => !t.startsWith('-')) || '';
362
+ if (!FALLBACK_GIT_READ_SUB_RE.test(sub))
363
+ return false;
364
+ if (fallbackGitStageWrites(stage))
365
+ return false;
366
+ continue;
367
+ }
368
+ if (!FALLBACK_LOCK_READ_VERB_RE.test(base))
369
+ return false;
370
+ }
371
+ return sawStage;
372
+ }
258
373
  /** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
259
- function fallbackDangerousMatch(args) {
374
+ function fallbackDangerousMatch(args, toolName) {
260
375
  const text = fallbackExecSurface(args);
261
376
  if (!text)
262
377
  return null;
263
- for (const { re, signal } of FALLBACK_DANGEROUS_PATTERNS) {
378
+ const lockReadOnly = fallbackLockPathAccessIsReadOnly(text, toolName);
379
+ for (const { re, signal, lockPath } of FALLBACK_DANGEROUS_PATTERNS) {
380
+ if (lockReadOnly && lockPath === true)
381
+ continue;
264
382
  if (re.test(text))
265
383
  return signal;
266
384
  }
@@ -279,10 +397,69 @@ export function summariseToolArgs(args) {
279
397
  }
280
398
  return parts.join(' ').slice(0, 160);
281
399
  }
400
+ /**
401
+ * #524 — the operator-facing half of the native `process` contract.
402
+ *
403
+ * DUPLICATED from `tool-action-guard.ts`'s `OPENCLAW_PROCESS_INSPECT` /
404
+ * `OPENCLAW_PROCESS_MUTATE` on purpose, the same discipline as
405
+ * `FALLBACK_CATASTROPHIC_PATTERNS` above: this file carries no compile-time
406
+ * dependency on the main package, and the card must still read in English when
407
+ * the guard is loaded through the injected-evaluator seam. Kept in sync there;
408
+ * a verb that drifts out of sync falls back to the generic lead below rather
409
+ * than inventing a sentence.
410
+ */
411
+ const NATIVE_PROCESS_PHRASE = {
412
+ list: 'see what commands are running',
413
+ poll: 'check a running command',
414
+ log: "read a running command's output",
415
+ kill: 'stop a running command',
416
+ write: 'type into a running command',
417
+ 'send-keys': 'press keys in a running command',
418
+ submit: 'submit input to a running command',
419
+ paste: 'paste text into a running command',
420
+ clear: "clear a running command's input",
421
+ remove: "remove a running command's session",
422
+ };
423
+ /** EXACT native spelling only — `mcp__openclaw__process` is not this contract. */
424
+ function isNativeProcessTool(toolName) {
425
+ return String(toolName || '').trim().toLowerCase() === 'process';
426
+ }
427
+ /**
428
+ * The plain sentence the card LEADS with.
429
+ *
430
+ * The operator's complaint was not that the card was wrong, it was that
431
+ * `invalid_tool_input / unknown field action` is not a question a person can
432
+ * answer. So the headline is what the agent is trying to do, in words, and the
433
+ * `Tool:`/`Action:`/`Signals:` block below it keeps the machine-readable
434
+ * detail — including for the secret-egress filter in `index.ts`, which forwards
435
+ * those label lines and drops everything else.
436
+ *
437
+ * Every sentence says what allow-once buys, because that is the other half of
438
+ * what went wrong: a Telegram allow-once looked like it taught the tool, and it
439
+ * did not. It never will — no card mints a standing grant.
440
+ */
441
+ function actionGuardLead(toolName, v, args) {
442
+ const rawAction = args?.action;
443
+ const verb = typeof rawAction === 'string'
444
+ ? rawAction.trim().toLowerCase().replace(/_/g, '-')
445
+ : '';
446
+ const phrase = isNativeProcessTool(toolName) ? NATIVE_PROCESS_PHRASE[verb] : undefined;
447
+ if (phrase) {
448
+ return `Jarvis wants to ${phrase} (${verb}). Allow once is this call only.`;
449
+ }
450
+ if (isSchemaInvalid(v)) {
451
+ return `Jarvis used ${toolName}, which ShieldCortex does not fully recognise yet. `
452
+ + 'Allow once lets this one call through. It does not teach the tool.';
453
+ }
454
+ return `Jarvis wants to use ${toolName}, and ShieldCortex rated this call ${v.severity}. `
455
+ + 'Allow once is this call only.';
456
+ }
282
457
  /** Operator-facing approval prompt for a gated action (not a memory write). */
283
- export function formatActionGuardPrompt(toolName, v) {
458
+ export function formatActionGuardPrompt(toolName, v, args) {
284
459
  return [
285
- '🛡️ ShieldCortex — Action Intercepted',
460
+ '🛡️ ShieldCortex needs a yes',
461
+ '',
462
+ actionGuardLead(toolName, v, args),
286
463
  '',
287
464
  `Tool: ${toolName}`,
288
465
  `Action: ${v.action}`,
@@ -290,7 +467,7 @@ export function formatActionGuardPrompt(toolName, v) {
290
467
  `Signals: ${v.signals.join(', ') || 'none'}`,
291
468
  `Reason: ${v.reason}`,
292
469
  '',
293
- '[Approve] [Deny]',
470
+ '[Allow once] [Deny]',
294
471
  ].join('\n');
295
472
  }
296
473
  /**
@@ -781,7 +958,7 @@ export function createInterceptor(config, pipeline, options) {
781
958
  }
782
959
  // 2. Dangerous — route through failurePolicy (the "can't obtain a verdict"
783
960
  // policy; a degraded guard is precisely that). enforce:false → advisory.
784
- const dangerousSignal = fallbackDangerousMatch(context.arguments);
961
+ const dangerousSignal = fallbackDangerousMatch(context.arguments, context.toolName);
785
962
  if (dangerousSignal) {
786
963
  const dBase = { ...degradedBase, severity: 'high', threats: ['fallback-scan', dangerousSignal], anomalyScore: 0.6 };
787
964
  if (!actionGuardCfg.enforce) {
@@ -789,7 +966,15 @@ export function createInterceptor(config, pipeline, options) {
789
966
  log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — advisory (enforce:false), allowing dangerous ${context.toolName} [${dangerousSignal}]`);
790
967
  return;
791
968
  }
792
- const failAction = config.failurePolicy.high;
969
+ // #522 G3: only an explicit, recognised `allow` permits a dangerous op
970
+ // through a degraded guard. `failurePolicy` arrives from config files
971
+ // including the unsigned same-UID `openclaw.json`, and the old
972
+ // `=== 'deny'` test made every OTHER value — a typo, a null, an object,
973
+ // anything a schema did not catch — fail OPEN on the one tier this
974
+ // branch exists to hold. On a locked host the value is pinned to `deny`
975
+ // upstream by the policy lock (`withGuardPosture`); this is the floor
976
+ // for the value that actually arrives.
977
+ const failAction = config.failurePolicy.high === 'allow' ? 'allow' : 'deny';
793
978
  emitAudit({ ...dBase, action: 'gate_degraded', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
794
979
  if (failAction === 'deny') {
795
980
  log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback matched a DANGEROUS op [${dangerousSignal}] — DENYING ${context.toolName} (fail-closed, failure policy: deny)`);
@@ -1050,7 +1235,7 @@ export function createInterceptor(config, pipeline, options) {
1050
1235
  }
1051
1236
  let approved;
1052
1237
  try {
1053
- approved = await withApprovalDeadline(context.requireApproval(formatActionGuardPrompt(context.toolName, v)), brokered ? brokerApprovalTimeoutMs(v.severity) : 0);
1238
+ approved = await withApprovalDeadline(context.requireApproval(formatActionGuardPrompt(context.toolName, v, context.arguments)), brokered ? brokerApprovalTimeoutMs(v.severity) : 0);
1054
1239
  }
1055
1240
  catch (err) {
1056
1241
  // #310: a minted approval card, not an error. Re-thrown untouched so the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "5.0.5",
3
+ "version": "5.0.7",
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,