@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.
- package/dist/index.js +440 -99
- package/dist/interceptor.js +193 -8
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +380 -31
- package/interceptor.ts +196 -9
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
'[
|
|
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
|
-
|
|
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.
|
|
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,
|