@drakon-systems/shieldcortex-realtime 4.54.15 → 5.0.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/README.md CHANGED
@@ -4,7 +4,7 @@ OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory
4
4
 
5
5
  ## Compatibility
6
6
 
7
- - **Node.js** — ≥ 20 required (the `shieldcortex` peer ships `better-sqlite3` ^12, which needs Node 20+)
7
+ - **Node.js** — Node 22.14+ LTS or Node 24+ required (`^22.14.0 || >=24.0.0`). The `shieldcortex` peer ships `better-sqlite3` ^13, which uses Node-API 10; Node 23 is unsupported.
8
8
  - **OpenClaw** — ≥ 2026.3.22 required, **≥ 2026.4.23 recommended** — 2026.4.23 added host-package linking for plugins that declare `openclaw` as a peer dependency ([#70462](https://github.com/openclaw/openclaw/pull/70462)), which lets any future `openclaw/plugin-sdk/*` imports resolve without a duplicate runtime bundle
9
9
  - **OpenClaw ≥ 2026.5.12 for conversation *enforcement*** — the `before_agent_run` input gate first appears in 2026.5.9-beta.1 and first ships stable in 2026.5.12. Below that floor everything else works, but the conversation firewall is observation-only and says so (see [Conversation firewall](#conversation-firewall))
10
10
  - **ShieldCortex** — ≥ 4.18.3 required (matches the declared peer dependency; ship both packages at the same version)
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ import { createRequire } from "node:module";
33
33
  import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
34
34
  import { createSessionTaintStore } from './session-taint.js';
35
35
  import { isTaintingScanSummary, severityFromScanSummary } from './scan-taint-policy.js';
36
+ import { labelLlmInput } from './provenance.js';
36
37
  import { classifyConversationOrigin } from './conversation-trust.js';
37
38
  import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
38
39
  import { syncInterceptEvent } from './intercept-ingest.js';
@@ -267,8 +268,11 @@ export function __resetConfigStateForTest() {
267
268
  _config = null;
268
269
  _configOverride = null;
269
270
  _lastShieldConfigRef = null;
271
+ _provenanceUndeclared = 0;
272
+ _l2Degraded = 0;
270
273
  // Re-arm the once-per-load config-failure warning (#226).
271
274
  _shieldConfigLoadFailureLogged = false;
275
+ _l2DegradedLogged = false;
272
276
  _registered = false;
273
277
  _beforeToolCallRegistered = false;
274
278
  _registrationError = null;
@@ -1445,6 +1449,55 @@ function applyPluginConfigOverride(api) {
1445
1449
  * failure cannot become per-turn log spam.
1446
1450
  */
1447
1451
  let _shieldConfigLoadFailureLogged = false;
1452
+ /**
1453
+ * How many texts this plugin load has scanned WITHOUT being able to declare
1454
+ * where they came from.
1455
+ *
1456
+ * Surfaced by `shieldcortex-status`, because it is the one number that tells
1457
+ * an operator the provenance layer is not doing its job on their host: a
1458
+ * non-zero count means the event shape this gateway sends is not one
1459
+ * `labelLlmInput` recognises, so those texts kept the pre-L2 path. It is a
1460
+ * COUNT of texts, never the texts. Process-global for the life of the plugin
1461
+ * load, like the other counters here; a gateway restart resets it.
1462
+ */
1463
+ let _provenanceUndeclared = 0;
1464
+ /** Test seam: the undeclared-provenance count for this plugin load. */
1465
+ export function __getProvenanceUndeclaredCountForTest() {
1466
+ return _provenanceUndeclared;
1467
+ }
1468
+ /**
1469
+ * How many texts this plugin load scanned with a DECLARED origin but no L2.
1470
+ *
1471
+ * A separate number from L1 availability on purpose (#r2/B8): the base
1472
+ * scanner and the provenance floor fail independently, and "conversation
1473
+ * scanning is available" was reporting true while the floor was silently
1474
+ * absent — a missing export on an older installed dist, a detector that
1475
+ * threw, or the MCP fallback, which has no local policy to ask and never
1476
+ * could have applied L2. Fail-open behaviour is unchanged; what changes is
1477
+ * that the degrade is now visible instead of reading as clean.
1478
+ */
1479
+ let _l2Degraded = 0;
1480
+ let _l2DegradedLogged = false;
1481
+ /** Test seam: the L2-degraded count for this plugin load. */
1482
+ export function __getL2DegradedCountForTest() {
1483
+ return _l2Degraded;
1484
+ }
1485
+ /**
1486
+ * Count one L2-less scan, and say so ONCE per plugin load.
1487
+ *
1488
+ * Bounded like every other warning here: one line, a fixed reason from this
1489
+ * file (never a transport string, never content), and the counter carries the
1490
+ * rest so a per-turn degrade cannot become per-turn log spam.
1491
+ */
1492
+ function noteL2Degraded(reason) {
1493
+ _l2Degraded += 1;
1494
+ if (_l2DegradedLogged)
1495
+ return;
1496
+ _l2DegradedLogged = true;
1497
+ console.warn(`[shieldcortex] ⚠️ provenance floor (L2) unavailable — ${reason}. Content with a declared ` +
1498
+ 'origin was judged by the base scanner alone; L1 is unaffected and nothing is blocked by ' +
1499
+ 'this. (Logged once per plugin load; the count is in shieldcortex-status.)');
1500
+ }
1448
1501
  async function loadConfig() {
1449
1502
  let shieldConfigRaw;
1450
1503
  try {
@@ -1514,7 +1567,64 @@ function parseScanResponse(response) {
1514
1567
  * caller audits it, alerts on it, and doctor/status report the plane as
1515
1568
  * unavailable rather than protected.
1516
1569
  */
1517
- export async function scanRealtimeContent(text) {
1570
+ /**
1571
+ * The L2 provenance floor, applied to a text whose origin the caller declared.
1572
+ *
1573
+ * Returns names, never content. Fails OPEN in exactly two cases, both of which
1574
+ * are "the installed package predates L2" rather than "this text is fine": the
1575
+ * export is missing, or it threw. Both leave behaviour exactly as it was
1576
+ * before this round, which is the only safe degrade for an additive floor — an
1577
+ * older dist must not start erroring every turn. What is NO LONGER silent is
1578
+ * the degrade itself: each one is counted and reported once per load, because
1579
+ * silent-clean made an absent floor indistinguishable from a clean verdict.
1580
+ *
1581
+ * WHY THIS IS AN INDICATOR AND NOT A LEVER (Opus nit 4, load-bearing).
1582
+ * `handleBeforeAgentRun` — the only enforcing hook — calls `scanWithDeadline`,
1583
+ * which calls `scanRealtimeContent` with NO provenance argument. This function
1584
+ * therefore returns `[]` on the gate path, so an L2 hit can never reach
1585
+ * `evaluateConversationRun`, which is what would block a `clean:false` verdict
1586
+ * under posture `enforce`. L2 reaches the observation hook and the audit row
1587
+ * and stops there. That is a fact about the call graph, not a convention: if
1588
+ * a future change passes provenance into the gate, L2 becomes an enforcement
1589
+ * lever the same day, and this comment is where that shows up.
1590
+ */
1591
+ function applyProvenanceFloor(mod, text,
1592
+ /** `memory_candidate` is not an llm_input label: it is the question the
1593
+ * auto-memory paths ask about text they are ABOUT TO WRITE. */
1594
+ provenance) {
1595
+ // No declared origin is not a degrade: there was no L2 question to ask.
1596
+ if (!provenance)
1597
+ return [];
1598
+ if (!mod || typeof mod.detectNonAuthoritativeInstruction !== 'function') {
1599
+ noteL2Degraded('the installed shieldcortex package has no detectNonAuthoritativeInstruction export');
1600
+ return [];
1601
+ }
1602
+ try {
1603
+ const nai = mod.detectNonAuthoritativeInstruction(text, provenance);
1604
+ return nai?.detected && Array.isArray(nai.patterns) ? [...nai.patterns] : [];
1605
+ }
1606
+ catch {
1607
+ noteL2Degraded('the provenance detector threw');
1608
+ return [];
1609
+ }
1610
+ }
1611
+ /**
1612
+ * The L2 half of a summary.
1613
+ *
1614
+ * Deliberately worded so `isTaintingScanSummary` does NOT match it: no
1615
+ * severity word, no "threat", no "N detections". L2 adds an INDICATOR to this
1616
+ * round, not a new enforcement lever — session taint escalates the Action
1617
+ * Guard, and arming that off a newly-widened detector is a behaviour change
1618
+ * this work is explicitly not making. A text that ALSO trips the base scanner
1619
+ * still carries that scanner's own summary, so it still taints exactly as it
1620
+ * did before.
1621
+ */
1622
+ function nonAuthoritativeSummary(provenance, patterns) {
1623
+ return `non_authoritative_instruction from ${provenance} (${patterns.join(', ')})`;
1624
+ }
1625
+ export async function scanRealtimeContent(text,
1626
+ /** Declared origin for the L2 floor. Omitted = pre-provenance behaviour. */
1627
+ provenance) {
1518
1628
  // PRIMARY: scan in-process via the shared shieldcortex/defence module. The
1519
1629
  // scan is pure (no DB handle required — scanToolResponse's audit write is
1520
1630
  // guarded by isDatabaseInitialized()), so it is safe in the long-lived
@@ -1553,18 +1663,40 @@ export async function scanRealtimeContent(text) {
1553
1663
  else {
1554
1664
  summary = 'THREAT (non-injection layer)';
1555
1665
  }
1556
- return { clean: scan.clean, summary, available: true };
1666
+ // L2 runs only on this branch. The MCP fallback below has no local
1667
+ // module to ask, and an L2 verdict invented from a remote text response
1668
+ // would be a second copy of the policy.
1669
+ const patterns = applyProvenanceFloor(defenceMod, text, provenance);
1670
+ if (patterns.length > 0) {
1671
+ const l2 = nonAuthoritativeSummary(provenance, patterns);
1672
+ return {
1673
+ clean: false,
1674
+ summary: scan.clean ? l2 : `${summary}; ${l2}`,
1675
+ available: true,
1676
+ provenance,
1677
+ nonAuthoritativePatterns: patterns,
1678
+ };
1679
+ }
1680
+ return { clean: scan.clean, summary, available: true, provenance };
1557
1681
  }
1558
1682
  catch (err) {
1559
1683
  // A scanner that THROWS is not a clean verdict either. Same treatment as
1560
1684
  // an absent one: unavailable, reported, never silently allowed to read as
1561
1685
  // protected.
1562
1686
  const detail = err instanceof Error ? err.message : String(err);
1563
- return { clean: false, available: false, errored: true, error: `in-process scanner threw: ${detail}`, summary: "scan unavailable" };
1687
+ return { clean: false, available: false, errored: true, error: `in-process scanner threw: ${detail}`, summary: "scan unavailable", provenance };
1564
1688
  }
1565
1689
  }
1566
1690
  // FALLBACK: in-process defence unavailable (older install, import failed) —
1567
1691
  // degrade to the MCP shell-out so scanning still happens rather than breaking.
1692
+ //
1693
+ // L2 cannot run here at all: there is no local module to ask, and inventing
1694
+ // a verdict from a remote text response would be a second copy of the
1695
+ // policy. With a declared origin that IS a degrade, so it is counted —
1696
+ // otherwise the fallback reads as a scan that found nothing.
1697
+ if (provenance) {
1698
+ noteL2Degraded('in-process defence is unavailable, so the MCP fallback ran without the local policy');
1699
+ }
1568
1700
  let response = null;
1569
1701
  try {
1570
1702
  response = await callCortex("scan_tool_response", {
@@ -1575,7 +1707,7 @@ export async function scanRealtimeContent(text) {
1575
1707
  }
1576
1708
  catch (err) {
1577
1709
  const detail = err instanceof Error ? err.message : String(err);
1578
- return { clean: false, available: false, errored: true, error: `scan fallback failed: ${detail}`, summary: "scan unavailable" };
1710
+ return { clean: false, available: false, errored: true, error: `scan fallback failed: ${detail}`, summary: "scan unavailable", provenance };
1579
1711
  }
1580
1712
  if (!response) {
1581
1713
  return {
@@ -1584,10 +1716,11 @@ export async function scanRealtimeContent(text) {
1584
1716
  errored: true,
1585
1717
  error: 'no in-process defence module and the MCP fallback returned nothing',
1586
1718
  summary: 'scan unavailable',
1719
+ provenance,
1587
1720
  };
1588
1721
  }
1589
1722
  const parsed = parseScanResponse(response);
1590
- return { ...parsed, available: true };
1723
+ return { ...parsed, available: true, provenance };
1591
1724
  }
1592
1725
  /**
1593
1726
  * `scanRealtimeContent` with a hard deadline (#226).
@@ -1675,23 +1808,6 @@ function extractMemories(texts) {
1675
1808
  return out;
1676
1809
  }
1677
1810
  // ==================== HELPERS ====================
1678
- function extractUserContent(msgs) {
1679
- const out = [];
1680
- for (const msg of msgs) {
1681
- if (!msg || typeof msg !== "object")
1682
- continue;
1683
- const m = msg;
1684
- if (m.role !== "user")
1685
- continue;
1686
- if (typeof m.content === "string")
1687
- out.push(m.content);
1688
- else if (Array.isArray(m.content))
1689
- for (const b of m.content)
1690
- if (b?.type === "text")
1691
- out.push(b.text);
1692
- }
1693
- return out;
1694
- }
1695
1811
  /** Where the realtime audit jsonl lives.
1696
1812
  *
1697
1813
  * Resolved PER CALL, and honouring `SHIELDCORTEX_AUDIT_DIR`, so a test can
@@ -1978,12 +2094,33 @@ export async function scanLlmInput(event, _ctx) {
1978
2094
  return trustMemo;
1979
2095
  };
1980
2096
  const sessionId = resolveHookSessionId(event, _ctx);
1981
- const userTexts = extractUserContent(event.historyMessages).slice(-5);
1982
- const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t, sessionId));
1983
- for (const text of texts) {
2097
+ // Each text now arrives with the origin it was declared under (see
2098
+ // provenance.ts). The BOUND is unchanged — prompt plus the last five
2099
+ // history texts — but a tool result is no longer judged as if the
2100
+ // operator had typed it, which is the whole point of the L2 floor.
2101
+ const { inputs, unreadableToolBlocks } = labelLlmInput(event);
2102
+ // A tool result this file could not read is a text that was NOT scanned.
2103
+ // Counted with the undeclared ones, because they are the same operator
2104
+ // fact: this host sends a shape the provenance layer does not cover.
2105
+ _provenanceUndeclared += unreadableToolBlocks;
2106
+ const labelled = inputs.filter(
2107
+ // #r2/B5: the internal-message exemption is for content the HOST
2108
+ // generated — boot checks, heartbeats, its own system notices — and the
2109
+ // only thing attesting to that is the label. `/^System:/` is eight
2110
+ // characters any fetched page can start with, so applying the exemption
2111
+ // to tool output let untrusted bytes buy their way out of BOTH scanners
2112
+ // by claiming to be a system message. Tool-origin and unclassifiable
2113
+ // content is now always scanned; only the host-attributed turn may skip.
2114
+ input => input.text && (input.label !== 'user' || !isInternalContent(input.text, sessionId)));
2115
+ for (const labelledInput of labelled) {
2116
+ const text = labelledInput.text;
1984
2117
  if (!text || text.length < 10)
1985
2118
  continue;
1986
- const result = await scanRealtimeContent(text);
2119
+ // Counted BEFORE the scan, so the number reflects what this host
2120
+ // actually sends rather than only what tripped a detector.
2121
+ if (labelledInput.label === 'unknown')
2122
+ _provenanceUndeclared += 1;
2123
+ const result = await scanRealtimeContent(text, labelledInput.label);
1987
2124
  // #225: "we could not look" is its own outcome. Before this branch the
1988
2125
  // unavailable path returned clean:true and this loop did nothing at all —
1989
2126
  // an unscanned message was indistinguishable from a scanned one, on the
@@ -2007,6 +2144,7 @@ export async function scanLlmInput(event, _ctx) {
2007
2144
  model: event.model, reason: detail,
2008
2145
  chars: text.length,
2009
2146
  contentSha256: createHash('sha256').update(text).digest('hex').slice(0, 16),
2147
+ provenance: labelledInput.label,
2010
2148
  ts: new Date().toISOString(),
2011
2149
  });
2012
2150
  continue;
@@ -2049,6 +2187,10 @@ export async function scanLlmInput(event, _ctx) {
2049
2187
  model: event.model, reason: result.summary,
2050
2188
  chars: text.length,
2051
2189
  contentSha256: createHash('sha256').update(text).digest('hex').slice(0, 16),
2190
+ // The declared origin this text was judged under. A closed-set
2191
+ // label, so it is metadata on the same footing as `chars` — it says
2192
+ // WHICH policy applied, never what the text said.
2193
+ provenance: labelledInput.label,
2052
2194
  // Whether this detection tainted the session (threat-graph Phase D:
2053
2195
  // lets the threat graph attribute taint-raising events). Metadata
2054
2196
  // only — no content, same as the fields above.
@@ -2456,6 +2598,16 @@ function gatePass() {
2456
2598
  * explicit pass, which is a stronger statement than the absence of an answer:
2457
2599
  * it is the same word said in the vocabulary the host validates.
2458
2600
  */
2601
+ /**
2602
+ * NOTE FOR THE L2 FLOOR (Opus nit 4). This is the only ENFORCING hook, and it
2603
+ * scans through `scanWithDeadline`, which calls `scanRealtimeContent` with no
2604
+ * provenance argument. `applyProvenanceFloor` therefore returns `[]` on this
2605
+ * path and an L2 hit cannot reach `evaluateConversationRun` below, which is
2606
+ * what would block a `clean:false` verdict under posture `enforce`. That is
2607
+ * why "L2 adds an indicator, not a lever" is true — a fact about the call
2608
+ * graph, not a convention. Passing provenance in here would make the floor an
2609
+ * enforcement lever the same day.
2610
+ */
2459
2611
  export async function handleBeforeAgentRun(event, ctx) {
2460
2612
  let posture = 'observe';
2461
2613
  try {
@@ -2717,50 +2869,89 @@ function isToolResultContent(text) {
2717
2869
  }
2718
2870
  function handleLlmOutput(event, ctx) {
2719
2871
  // Fire and forget
2720
- (async () => {
2721
- try {
2722
- const config = await loadConfig();
2723
- if (!isAutoMemoryEnabled(config))
2724
- return;
2725
- const texts = event.assistantTexts
2726
- .filter(t => t && t.length >= 30)
2727
- .filter(t => !isToolResultContent(t));
2728
- if (!texts.length)
2729
- return;
2730
- const memories = extractMemories(texts);
2731
- if (!memories.length)
2732
- return;
2733
- const noveltyGate = await createNoveltyGate(config);
2734
- let saved = 0;
2735
- let skipped = 0;
2736
- for (const mem of memories) {
2737
- const novelty = noveltyGate.inspect(mem.content);
2738
- if (!novelty.allow) {
2739
- skipped++;
2740
- continue;
2741
- }
2742
- const r = await callCortex("remember", {
2743
- title: mem.title, content: mem.content, category: mem.category,
2744
- project: ctx.agentId || "openclaw", scope: "global",
2745
- importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
2746
- sourceType: "agent", sourceIdentifier: `openclaw-plugin:${event.sessionId}`,
2747
- sessionId: event.sessionId, agentId: ctx.agentId || "openclaw", workspaceDir: ctx.workspaceDir || "",
2748
- });
2749
- if (r) {
2750
- saved++;
2751
- noveltyGate.remember(mem, novelty);
2752
- }
2872
+ void captureLlmOutput(event, ctx);
2873
+ }
2874
+ /**
2875
+ * Awaitable capture body — extracted so the jest suite can verify the
2876
+ * pre-write candidate screen deterministically, exactly as `scanLlmInput` is
2877
+ * extracted from `handleLlmInput`. The hook itself stays non-blocking.
2878
+ */
2879
+ export async function captureLlmOutput(event, ctx) {
2880
+ try {
2881
+ const config = await loadConfig();
2882
+ if (!isAutoMemoryEnabled(config))
2883
+ return;
2884
+ const texts = event.assistantTexts
2885
+ .filter(t => t && t.length >= 30)
2886
+ .filter(t => !isToolResultContent(t));
2887
+ if (!texts.length)
2888
+ return;
2889
+ const memories = extractMemories(texts);
2890
+ if (!memories.length)
2891
+ return;
2892
+ // L2 candidate screen, immediately before the automatic write (#r2/B5).
2893
+ //
2894
+ // This is the SIBLING of the capture-hook path, and it was unscreened.
2895
+ // `extractMemories` lifts a sentence out of assistant output on a
2896
+ // pattern as loose as /\b(?:important|remember|key\s*point)\s*:/ and
2897
+ // `remember` then persists it under sourceType "agent" with no operator
2898
+ // in the loop — so an injected directive that the model repeated once
2899
+ // becomes a stored standing instruction. Screening the extracted
2900
+ // CANDIDATE is not the same as scanning assistant output: the question
2901
+ // is only ever asked about text already selected for a write.
2902
+ //
2903
+ // Refuse, do not quarantine: quarantine is for content admitted far
2904
+ // enough to be worth reviewing, and nobody is waiting to review an
2905
+ // auto-captured injection shape. Fails OPEN (no module / no export /
2906
+ // a throw returns no patterns), like every other L2 site here.
2907
+ const defenceMod = await getDefenceModule().catch(() => null);
2908
+ const noveltyGate = await createNoveltyGate(config);
2909
+ let saved = 0;
2910
+ let skipped = 0;
2911
+ let refused = 0;
2912
+ for (const mem of memories) {
2913
+ const novelty = noveltyGate.inspect(mem.content);
2914
+ if (!novelty.allow) {
2915
+ skipped++;
2916
+ continue;
2753
2917
  }
2754
- await noveltyGate.flush();
2755
- if (saved) {
2756
- console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
2757
- auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
2918
+ // Title AND content as one string (#r2/B7). Here the title is the
2919
+ // content's own first 80 characters, so this is belt-and-braces rather
2920
+ // than new coverage -- but the rule is the same at every capture site,
2921
+ // so a later change to extractMemories cannot quietly open a gap the
2922
+ // other sites do not have.
2923
+ const patterns = applyProvenanceFloor(defenceMod, `${mem.title}\n${mem.content}`, 'memory_candidate');
2924
+ if (patterns.length > 0) {
2925
+ refused++;
2926
+ // Names and a title, never the refused text — it is the part nobody
2927
+ // wanted persisted. No new sink: the plugin's existing console.
2928
+ console.warn(`[shieldcortex] ⚠️ auto-memory candidate refused (non_authoritative_instruction: ` +
2929
+ `${patterns.join(', ')}): "${mem.title.slice(0, 80)}"`);
2930
+ continue;
2931
+ }
2932
+ const r = await callCortex("remember", {
2933
+ title: mem.title, content: mem.content, category: mem.category,
2934
+ project: ctx.agentId || "openclaw", scope: "global",
2935
+ importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
2936
+ sourceType: "agent", sourceIdentifier: `openclaw-plugin:${event.sessionId}`,
2937
+ sessionId: event.sessionId, agentId: ctx.agentId || "openclaw", workspaceDir: ctx.workspaceDir || "",
2938
+ });
2939
+ if (r) {
2940
+ saved++;
2941
+ noveltyGate.remember(mem, novelty);
2758
2942
  }
2759
2943
  }
2760
- catch (e) {
2761
- console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
2944
+ await noveltyGate.flush();
2945
+ if (saved) {
2946
+ console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped, ${refused} refused)`);
2762
2947
  }
2763
- })();
2948
+ if (saved || refused) {
2949
+ auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, refused, ts: new Date().toISOString() });
2950
+ }
2951
+ }
2952
+ catch (e) {
2953
+ console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
2954
+ }
2764
2955
  }
2765
2956
  class TypedApprovalRequest extends Error {
2766
2957
  request;
@@ -3052,6 +3243,17 @@ export default {
3052
3243
  ' — read from openclaw.json when this plugin LOADED; it is a snapshot, not a live read.\n' +
3053
3244
  ' Editing that key takes effect only after a gateway restart, for the gateway and for this line.\n' +
3054
3245
  ` Operator notify: ${notifyState}\n` +
3246
+ // Provenance plane (L2). The count is the honest half: a
3247
+ // non-zero number means this host sent llm_input shapes the
3248
+ // labeller could not classify, so those texts kept the
3249
+ // pre-L2 path. Texts, never text.
3250
+ ` Provenance: llm_input labelled (user / tool_result); ` +
3251
+ `${_provenanceUndeclared} undeclared since plugin load\n` +
3252
+ // L2 availability is its OWN line: the base scanner and the
3253
+ // provenance floor fail independently, and a plane that reports
3254
+ // "available" while the floor is absent is the silent-clean bug
3255
+ // this product already fixed once, one layer down.
3256
+ ` Provenance floor (L2): ${_l2Degraded === 0 ? 'applied' : `DEGRADED — ${_l2Degraded} text(s) scanned without it`}\n` +
3055
3257
  ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
3056
3258
  ` Cloud sync: ${cloud}`,
3057
3259
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.15",
3
+ "version": "5.0.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,