@drakon-systems/shieldcortex-realtime 4.54.15 → 5.0.0

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/index.ts CHANGED
@@ -35,6 +35,8 @@ import { createRequire } from "node:module";
35
35
  import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
36
36
  import { createSessionTaintStore } from './session-taint.js';
37
37
  import { isTaintingScanSummary, severityFromScanSummary } from './scan-taint-policy.js';
38
+ import { labelLlmInput } from './provenance.js';
39
+ import type { PluginProvenanceLabel } from './provenance.js';
38
40
  import { classifyConversationOrigin } from './conversation-trust.js';
39
41
  import type { ConversationTrustDecision } from './conversation-trust.js';
40
42
  import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
@@ -69,6 +71,12 @@ type DefenceModule = {
69
71
  summary?: string;
70
72
  injection: { clean: boolean; riskLevel: string; detections: unknown[] };
71
73
  };
74
+ /** L2 provenance floor. Optional: an older installed dist does not have it,
75
+ * and its absence must degrade to today's behaviour (no L2), never throw. */
76
+ detectNonAuthoritativeInstruction?: (
77
+ content: string,
78
+ sourceType: string,
79
+ ) => { detected: boolean; patterns: string[] };
72
80
  /** #225 sink: the notify transport shared with the Action Guard (#143).
73
81
  * Every member is optional — an older installed dist won't have them, and
74
82
  * the guard must degrade to a loud log rather than fail. The field names
@@ -369,8 +377,11 @@ export function __resetConfigStateForTest(): void {
369
377
  _config = null;
370
378
  _configOverride = null;
371
379
  _lastShieldConfigRef = null;
380
+ _provenanceUndeclared = 0;
381
+ _l2Degraded = 0;
372
382
  // Re-arm the once-per-load config-failure warning (#226).
373
383
  _shieldConfigLoadFailureLogged = false;
384
+ _l2DegradedLogged = false;
374
385
  _registered = false;
375
386
  _beforeToolCallRegistered = false;
376
387
  _registrationError = null;
@@ -526,6 +537,11 @@ export interface ConversationScanResult {
526
537
  /** Failure detail, for the audit row and the operator alert. Never contains
527
538
  * scanned content. */
528
539
  error?: string;
540
+ /** The origin this text was scanned UNDER, when the caller declared one.
541
+ * A closed-set label, never content. Absent = the caller did not declare. */
542
+ provenance?: PluginProvenanceLabel;
543
+ /** L2 pattern names, when the provenance floor fired. Names only. */
544
+ nonAuthoritativePatterns?: string[];
529
545
  }
530
546
 
531
547
  export interface ConversationDecision {
@@ -1814,6 +1830,61 @@ function applyPluginConfigOverride(api: PluginApi): void {
1814
1830
  */
1815
1831
  let _shieldConfigLoadFailureLogged = false;
1816
1832
 
1833
+ /**
1834
+ * How many texts this plugin load has scanned WITHOUT being able to declare
1835
+ * where they came from.
1836
+ *
1837
+ * Surfaced by `shieldcortex-status`, because it is the one number that tells
1838
+ * an operator the provenance layer is not doing its job on their host: a
1839
+ * non-zero count means the event shape this gateway sends is not one
1840
+ * `labelLlmInput` recognises, so those texts kept the pre-L2 path. It is a
1841
+ * COUNT of texts, never the texts. Process-global for the life of the plugin
1842
+ * load, like the other counters here; a gateway restart resets it.
1843
+ */
1844
+ let _provenanceUndeclared = 0;
1845
+
1846
+ /** Test seam: the undeclared-provenance count for this plugin load. */
1847
+ export function __getProvenanceUndeclaredCountForTest(): number {
1848
+ return _provenanceUndeclared;
1849
+ }
1850
+
1851
+ /**
1852
+ * How many texts this plugin load scanned with a DECLARED origin but no L2.
1853
+ *
1854
+ * A separate number from L1 availability on purpose (#r2/B8): the base
1855
+ * scanner and the provenance floor fail independently, and "conversation
1856
+ * scanning is available" was reporting true while the floor was silently
1857
+ * absent — a missing export on an older installed dist, a detector that
1858
+ * threw, or the MCP fallback, which has no local policy to ask and never
1859
+ * could have applied L2. Fail-open behaviour is unchanged; what changes is
1860
+ * that the degrade is now visible instead of reading as clean.
1861
+ */
1862
+ let _l2Degraded = 0;
1863
+ let _l2DegradedLogged = false;
1864
+
1865
+ /** Test seam: the L2-degraded count for this plugin load. */
1866
+ export function __getL2DegradedCountForTest(): number {
1867
+ return _l2Degraded;
1868
+ }
1869
+
1870
+ /**
1871
+ * Count one L2-less scan, and say so ONCE per plugin load.
1872
+ *
1873
+ * Bounded like every other warning here: one line, a fixed reason from this
1874
+ * file (never a transport string, never content), and the counter carries the
1875
+ * rest so a per-turn degrade cannot become per-turn log spam.
1876
+ */
1877
+ function noteL2Degraded(reason: string): void {
1878
+ _l2Degraded += 1;
1879
+ if (_l2DegradedLogged) return;
1880
+ _l2DegradedLogged = true;
1881
+ console.warn(
1882
+ `[shieldcortex] ⚠️ provenance floor (L2) unavailable — ${reason}. Content with a declared ` +
1883
+ 'origin was judged by the base scanner alone; L1 is unaffected and nothing is blocked by ' +
1884
+ 'this. (Logged once per plugin load; the count is in shieldcortex-status.)',
1885
+ );
1886
+ }
1887
+
1817
1888
  async function loadConfig(): Promise<SCConfig> {
1818
1889
  let shieldConfigRaw: unknown;
1819
1890
  try {
@@ -1891,7 +1962,69 @@ function parseScanResponse(response: string): { clean: boolean; summary: string
1891
1962
  * caller audits it, alerts on it, and doctor/status report the plane as
1892
1963
  * unavailable rather than protected.
1893
1964
  */
1894
- export async function scanRealtimeContent(text: string): Promise<ConversationScanResult> {
1965
+ /**
1966
+ * The L2 provenance floor, applied to a text whose origin the caller declared.
1967
+ *
1968
+ * Returns names, never content. Fails OPEN in exactly two cases, both of which
1969
+ * are "the installed package predates L2" rather than "this text is fine": the
1970
+ * export is missing, or it threw. Both leave behaviour exactly as it was
1971
+ * before this round, which is the only safe degrade for an additive floor — an
1972
+ * older dist must not start erroring every turn. What is NO LONGER silent is
1973
+ * the degrade itself: each one is counted and reported once per load, because
1974
+ * silent-clean made an absent floor indistinguishable from a clean verdict.
1975
+ *
1976
+ * WHY THIS IS AN INDICATOR AND NOT A LEVER (Opus nit 4, load-bearing).
1977
+ * `handleBeforeAgentRun` — the only enforcing hook — calls `scanWithDeadline`,
1978
+ * which calls `scanRealtimeContent` with NO provenance argument. This function
1979
+ * therefore returns `[]` on the gate path, so an L2 hit can never reach
1980
+ * `evaluateConversationRun`, which is what would block a `clean:false` verdict
1981
+ * under posture `enforce`. L2 reaches the observation hook and the audit row
1982
+ * and stops there. That is a fact about the call graph, not a convention: if
1983
+ * a future change passes provenance into the gate, L2 becomes an enforcement
1984
+ * lever the same day, and this comment is where that shows up.
1985
+ */
1986
+ function applyProvenanceFloor(
1987
+ mod: DefenceModule | null,
1988
+ text: string,
1989
+ /** `memory_candidate` is not an llm_input label: it is the question the
1990
+ * auto-memory paths ask about text they are ABOUT TO WRITE. */
1991
+ provenance: PluginProvenanceLabel | 'memory_candidate' | undefined,
1992
+ ): string[] {
1993
+ // No declared origin is not a degrade: there was no L2 question to ask.
1994
+ if (!provenance) return [];
1995
+ if (!mod || typeof mod.detectNonAuthoritativeInstruction !== 'function') {
1996
+ noteL2Degraded('the installed shieldcortex package has no detectNonAuthoritativeInstruction export');
1997
+ return [];
1998
+ }
1999
+ try {
2000
+ const nai = mod.detectNonAuthoritativeInstruction(text, provenance);
2001
+ return nai?.detected && Array.isArray(nai.patterns) ? [...nai.patterns] : [];
2002
+ } catch {
2003
+ noteL2Degraded('the provenance detector threw');
2004
+ return [];
2005
+ }
2006
+ }
2007
+
2008
+ /**
2009
+ * The L2 half of a summary.
2010
+ *
2011
+ * Deliberately worded so `isTaintingScanSummary` does NOT match it: no
2012
+ * severity word, no "threat", no "N detections". L2 adds an INDICATOR to this
2013
+ * round, not a new enforcement lever — session taint escalates the Action
2014
+ * Guard, and arming that off a newly-widened detector is a behaviour change
2015
+ * this work is explicitly not making. A text that ALSO trips the base scanner
2016
+ * still carries that scanner's own summary, so it still taints exactly as it
2017
+ * did before.
2018
+ */
2019
+ function nonAuthoritativeSummary(provenance: PluginProvenanceLabel, patterns: string[]): string {
2020
+ return `non_authoritative_instruction from ${provenance} (${patterns.join(', ')})`;
2021
+ }
2022
+
2023
+ export async function scanRealtimeContent(
2024
+ text: string,
2025
+ /** Declared origin for the L2 floor. Omitted = pre-provenance behaviour. */
2026
+ provenance?: PluginProvenanceLabel,
2027
+ ): Promise<ConversationScanResult> {
1895
2028
  // PRIMARY: scan in-process via the shared shieldcortex/defence module. The
1896
2029
  // scan is pure (no DB handle required — scanToolResponse's audit write is
1897
2030
  // guarded by isDatabaseInitialized()), so it is safe in the long-lived
@@ -1926,18 +2059,40 @@ export async function scanRealtimeContent(text: string): Promise<ConversationSca
1926
2059
  } else {
1927
2060
  summary = 'THREAT (non-injection layer)';
1928
2061
  }
1929
- return { clean: scan.clean, summary, available: true };
2062
+ // L2 runs only on this branch. The MCP fallback below has no local
2063
+ // module to ask, and an L2 verdict invented from a remote text response
2064
+ // would be a second copy of the policy.
2065
+ const patterns = applyProvenanceFloor(defenceMod, text, provenance);
2066
+ if (patterns.length > 0) {
2067
+ const l2 = nonAuthoritativeSummary(provenance!, patterns);
2068
+ return {
2069
+ clean: false,
2070
+ summary: scan.clean ? l2 : `${summary}; ${l2}`,
2071
+ available: true,
2072
+ provenance,
2073
+ nonAuthoritativePatterns: patterns,
2074
+ };
2075
+ }
2076
+ return { clean: scan.clean, summary, available: true, provenance };
1930
2077
  } catch (err) {
1931
2078
  // A scanner that THROWS is not a clean verdict either. Same treatment as
1932
2079
  // an absent one: unavailable, reported, never silently allowed to read as
1933
2080
  // protected.
1934
2081
  const detail = err instanceof Error ? err.message : String(err);
1935
- return { clean: false, available: false, errored: true, error: `in-process scanner threw: ${detail}`, summary: "scan unavailable" };
2082
+ return { clean: false, available: false, errored: true, error: `in-process scanner threw: ${detail}`, summary: "scan unavailable", provenance };
1936
2083
  }
1937
2084
  }
1938
2085
 
1939
2086
  // FALLBACK: in-process defence unavailable (older install, import failed) —
1940
2087
  // degrade to the MCP shell-out so scanning still happens rather than breaking.
2088
+ //
2089
+ // L2 cannot run here at all: there is no local module to ask, and inventing
2090
+ // a verdict from a remote text response would be a second copy of the
2091
+ // policy. With a declared origin that IS a degrade, so it is counted —
2092
+ // otherwise the fallback reads as a scan that found nothing.
2093
+ if (provenance) {
2094
+ noteL2Degraded('in-process defence is unavailable, so the MCP fallback ran without the local policy');
2095
+ }
1941
2096
  let response: string | null = null;
1942
2097
  try {
1943
2098
  response = await callCortex("scan_tool_response", {
@@ -1947,7 +2102,7 @@ export async function scanRealtimeContent(text: string): Promise<ConversationSca
1947
2102
  });
1948
2103
  } catch (err) {
1949
2104
  const detail = err instanceof Error ? err.message : String(err);
1950
- return { clean: false, available: false, errored: true, error: `scan fallback failed: ${detail}`, summary: "scan unavailable" };
2105
+ return { clean: false, available: false, errored: true, error: `scan fallback failed: ${detail}`, summary: "scan unavailable", provenance };
1951
2106
  }
1952
2107
 
1953
2108
  if (!response) {
@@ -1957,11 +2112,12 @@ export async function scanRealtimeContent(text: string): Promise<ConversationSca
1957
2112
  errored: true,
1958
2113
  error: 'no in-process defence module and the MCP fallback returned nothing',
1959
2114
  summary: 'scan unavailable',
2115
+ provenance,
1960
2116
  };
1961
2117
  }
1962
2118
 
1963
2119
  const parsed = parseScanResponse(response);
1964
- return { ...parsed, available: true };
2120
+ return { ...parsed, available: true, provenance };
1965
2121
  }
1966
2122
 
1967
2123
  /**
@@ -2051,18 +2207,6 @@ function extractMemories(texts: string[]): Array<{ title: string; content: strin
2051
2207
 
2052
2208
  // ==================== HELPERS ====================
2053
2209
 
2054
- function extractUserContent(msgs: unknown[]): string[] {
2055
- const out: string[] = [];
2056
- for (const msg of msgs) {
2057
- if (!msg || typeof msg !== "object") continue;
2058
- const m = msg as any;
2059
- if (m.role !== "user") continue;
2060
- if (typeof m.content === "string") out.push(m.content);
2061
- else if (Array.isArray(m.content)) for (const b of m.content) if (b?.type === "text") out.push(b.text);
2062
- }
2063
- return out;
2064
- }
2065
-
2066
2210
  /** Where the realtime audit jsonl lives.
2067
2211
  *
2068
2212
  * Resolved PER CALL, and honouring `SHIELDCORTEX_AUDIT_DIR`, so a test can
@@ -2389,11 +2533,32 @@ export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promis
2389
2533
  return trustMemo;
2390
2534
  };
2391
2535
  const sessionId = resolveHookSessionId(event, _ctx);
2392
- const userTexts = extractUserContent(event.historyMessages).slice(-5);
2393
- const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t, sessionId));
2394
- for (const text of texts) {
2536
+ // Each text now arrives with the origin it was declared under (see
2537
+ // provenance.ts). The BOUND is unchanged — prompt plus the last five
2538
+ // history texts — but a tool result is no longer judged as if the
2539
+ // operator had typed it, which is the whole point of the L2 floor.
2540
+ const { inputs, unreadableToolBlocks } = labelLlmInput(event);
2541
+ // A tool result this file could not read is a text that was NOT scanned.
2542
+ // Counted with the undeclared ones, because they are the same operator
2543
+ // fact: this host sends a shape the provenance layer does not cover.
2544
+ _provenanceUndeclared += unreadableToolBlocks;
2545
+ const labelled = inputs.filter(
2546
+ // #r2/B5: the internal-message exemption is for content the HOST
2547
+ // generated — boot checks, heartbeats, its own system notices — and the
2548
+ // only thing attesting to that is the label. `/^System:/` is eight
2549
+ // characters any fetched page can start with, so applying the exemption
2550
+ // to tool output let untrusted bytes buy their way out of BOTH scanners
2551
+ // by claiming to be a system message. Tool-origin and unclassifiable
2552
+ // content is now always scanned; only the host-attributed turn may skip.
2553
+ input => input.text && (input.label !== 'user' || !isInternalContent(input.text, sessionId)),
2554
+ );
2555
+ for (const labelledInput of labelled) {
2556
+ const text = labelledInput.text;
2395
2557
  if (!text || text.length < 10) continue;
2396
- const result = await scanRealtimeContent(text);
2558
+ // Counted BEFORE the scan, so the number reflects what this host
2559
+ // actually sends rather than only what tripped a detector.
2560
+ if (labelledInput.label === 'unknown') _provenanceUndeclared += 1;
2561
+ const result = await scanRealtimeContent(text, labelledInput.label);
2397
2562
  // #225: "we could not look" is its own outcome. Before this branch the
2398
2563
  // unavailable path returned clean:true and this loop did nothing at all —
2399
2564
  // an unscanned message was indistinguishable from a scanned one, on the
@@ -2419,6 +2584,7 @@ export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promis
2419
2584
  model: event.model, reason: detail,
2420
2585
  chars: text.length,
2421
2586
  contentSha256: createHash('sha256').update(text).digest('hex').slice(0, 16),
2587
+ provenance: labelledInput.label,
2422
2588
  ts: new Date().toISOString(),
2423
2589
  });
2424
2590
  continue;
@@ -2461,6 +2627,10 @@ export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promis
2461
2627
  model: event.model, reason: result.summary,
2462
2628
  chars: text.length,
2463
2629
  contentSha256: createHash('sha256').update(text).digest('hex').slice(0, 16),
2630
+ // The declared origin this text was judged under. A closed-set
2631
+ // label, so it is metadata on the same footing as `chars` — it says
2632
+ // WHICH policy applied, never what the text said.
2633
+ provenance: labelledInput.label,
2464
2634
  // Whether this detection tainted the session (threat-graph Phase D:
2465
2635
  // lets the threat graph attribute taint-raising events). Metadata
2466
2636
  // only — no content, same as the fields above.
@@ -2978,6 +3148,16 @@ function gatePass(): InputGateDecision {
2978
3148
  * explicit pass, which is a stronger statement than the absence of an answer:
2979
3149
  * it is the same word said in the vocabulary the host validates.
2980
3150
  */
3151
+ /**
3152
+ * NOTE FOR THE L2 FLOOR (Opus nit 4). This is the only ENFORCING hook, and it
3153
+ * scans through `scanWithDeadline`, which calls `scanRealtimeContent` with no
3154
+ * provenance argument. `applyProvenanceFloor` therefore returns `[]` on this
3155
+ * path and an L2 hit cannot reach `evaluateConversationRun` below, which is
3156
+ * what would block a `clean:false` verdict under posture `enforce`. That is
3157
+ * why "L2 adds an indicator, not a lever" is true — a fact about the call
3158
+ * graph, not a convention. Passing provenance in here would make the floor an
3159
+ * enforcement lever the same day.
3160
+ */
2981
3161
  export async function handleBeforeAgentRun(
2982
3162
  event: BeforeAgentRunEvent,
2983
3163
  ctx: AgentCtx,
@@ -3249,49 +3429,97 @@ function isToolResultContent(text: string): boolean {
3249
3429
 
3250
3430
  function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
3251
3431
  // Fire and forget
3252
- (async () => {
3253
- try {
3254
- const config = await loadConfig();
3255
- if (!isAutoMemoryEnabled(config)) return;
3256
-
3257
- const texts = event.assistantTexts
3258
- .filter(t => t && t.length >= 30)
3259
- .filter(t => !isToolResultContent(t));
3260
- if (!texts.length) return;
3261
- const memories = extractMemories(texts);
3262
- if (!memories.length) return;
3263
-
3264
- const noveltyGate = await createNoveltyGate(config);
3265
- let saved = 0;
3266
- let skipped = 0;
3267
- for (const mem of memories) {
3268
- const novelty = noveltyGate.inspect(mem.content);
3269
- if (!novelty.allow) {
3270
- skipped++;
3271
- continue;
3272
- }
3432
+ void captureLlmOutput(event, ctx);
3433
+ }
3273
3434
 
3274
- const r = await callCortex("remember", {
3275
- title: mem.title, content: mem.content, category: mem.category,
3276
- project: ctx.agentId || "openclaw", scope: "global",
3277
- importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
3278
- sourceType: "agent", sourceIdentifier: `openclaw-plugin:${event.sessionId}`,
3279
- sessionId: event.sessionId, agentId: ctx.agentId || "openclaw", workspaceDir: ctx.workspaceDir || "",
3280
- });
3281
- if (r) {
3282
- saved++;
3283
- noveltyGate.remember(mem, novelty);
3284
- }
3435
+ /**
3436
+ * Awaitable capture body — extracted so the jest suite can verify the
3437
+ * pre-write candidate screen deterministically, exactly as `scanLlmInput` is
3438
+ * extracted from `handleLlmInput`. The hook itself stays non-blocking.
3439
+ */
3440
+ export async function captureLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): Promise<void> {
3441
+ try {
3442
+ const config = await loadConfig();
3443
+ if (!isAutoMemoryEnabled(config)) return;
3444
+
3445
+ const texts = event.assistantTexts
3446
+ .filter(t => t && t.length >= 30)
3447
+ .filter(t => !isToolResultContent(t));
3448
+ if (!texts.length) return;
3449
+ const memories = extractMemories(texts);
3450
+ if (!memories.length) return;
3451
+
3452
+ // L2 candidate screen, immediately before the automatic write (#r2/B5).
3453
+ //
3454
+ // This is the SIBLING of the capture-hook path, and it was unscreened.
3455
+ // `extractMemories` lifts a sentence out of assistant output on a
3456
+ // pattern as loose as /\b(?:important|remember|key\s*point)\s*:/ and
3457
+ // `remember` then persists it under sourceType "agent" with no operator
3458
+ // in the loop — so an injected directive that the model repeated once
3459
+ // becomes a stored standing instruction. Screening the extracted
3460
+ // CANDIDATE is not the same as scanning assistant output: the question
3461
+ // is only ever asked about text already selected for a write.
3462
+ //
3463
+ // Refuse, do not quarantine: quarantine is for content admitted far
3464
+ // enough to be worth reviewing, and nobody is waiting to review an
3465
+ // auto-captured injection shape. Fails OPEN (no module / no export /
3466
+ // a throw returns no patterns), like every other L2 site here.
3467
+ const defenceMod = await getDefenceModule().catch(() => null);
3468
+
3469
+ const noveltyGate = await createNoveltyGate(config);
3470
+ let saved = 0;
3471
+ let skipped = 0;
3472
+ let refused = 0;
3473
+ for (const mem of memories) {
3474
+ const novelty = noveltyGate.inspect(mem.content);
3475
+ if (!novelty.allow) {
3476
+ skipped++;
3477
+ continue;
3285
3478
  }
3286
- await noveltyGate.flush();
3287
- if (saved) {
3288
- console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
3289
- auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
3479
+
3480
+ // Title AND content as one string (#r2/B7). Here the title is the
3481
+ // content's own first 80 characters, so this is belt-and-braces rather
3482
+ // than new coverage -- but the rule is the same at every capture site,
3483
+ // so a later change to extractMemories cannot quietly open a gap the
3484
+ // other sites do not have.
3485
+ const patterns = applyProvenanceFloor(
3486
+ defenceMod,
3487
+ `${mem.title}\n${mem.content}`,
3488
+ 'memory_candidate',
3489
+ );
3490
+ if (patterns.length > 0) {
3491
+ refused++;
3492
+ // Names and a title, never the refused text — it is the part nobody
3493
+ // wanted persisted. No new sink: the plugin's existing console.
3494
+ console.warn(
3495
+ `[shieldcortex] ⚠️ auto-memory candidate refused (non_authoritative_instruction: ` +
3496
+ `${patterns.join(', ')}): "${mem.title.slice(0, 80)}"`,
3497
+ );
3498
+ continue;
3499
+ }
3500
+
3501
+ const r = await callCortex("remember", {
3502
+ title: mem.title, content: mem.content, category: mem.category,
3503
+ project: ctx.agentId || "openclaw", scope: "global",
3504
+ importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
3505
+ sourceType: "agent", sourceIdentifier: `openclaw-plugin:${event.sessionId}`,
3506
+ sessionId: event.sessionId, agentId: ctx.agentId || "openclaw", workspaceDir: ctx.workspaceDir || "",
3507
+ });
3508
+ if (r) {
3509
+ saved++;
3510
+ noveltyGate.remember(mem, novelty);
3290
3511
  }
3291
- } catch (e) {
3292
- console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
3293
3512
  }
3294
- })();
3513
+ await noveltyGate.flush();
3514
+ if (saved) {
3515
+ console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped, ${refused} refused)`);
3516
+ }
3517
+ if (saved || refused) {
3518
+ auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, refused, ts: new Date().toISOString() });
3519
+ }
3520
+ } catch (e) {
3521
+ console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
3522
+ }
3295
3523
  }
3296
3524
 
3297
3525
  class TypedApprovalRequest extends Error {
@@ -3615,6 +3843,17 @@ export default {
3615
3843
  ' — read from openclaw.json when this plugin LOADED; it is a snapshot, not a live read.\n' +
3616
3844
  ' Editing that key takes effect only after a gateway restart, for the gateway and for this line.\n' +
3617
3845
  ` Operator notify: ${notifyState}\n` +
3846
+ // Provenance plane (L2). The count is the honest half: a
3847
+ // non-zero number means this host sent llm_input shapes the
3848
+ // labeller could not classify, so those texts kept the
3849
+ // pre-L2 path. Texts, never text.
3850
+ ` Provenance: llm_input labelled (user / tool_result); ` +
3851
+ `${_provenanceUndeclared} undeclared since plugin load\n` +
3852
+ // L2 availability is its OWN line: the base scanner and the
3853
+ // provenance floor fail independently, and a plane that reports
3854
+ // "available" while the floor is absent is the silent-clean bug
3855
+ // this product already fixed once, one layer down.
3856
+ ` Provenance floor (L2): ${_l2Degraded === 0 ? 'applied' : `DEGRADED — ${_l2Degraded} text(s) scanned without it`}\n` +
3618
3857
  ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
3619
3858
  ` Cloud sync: ${cloud}`,
3620
3859
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.15",
3
+ "version": "5.0.0",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.54.15",
3
+ "version": "5.0.0",
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",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "dist/",
18
18
  "index.ts",
19
+ "provenance.ts",
19
20
  "interceptor.ts",
20
21
  "intercept-ingest.ts",
21
22
  "cloud-sync.ts",
@@ -27,7 +28,7 @@
27
28
  "prepublishOnly": "node -e \"if(!require('fs').existsSync('dist/index.js'))throw new Error('plugin dist/index.js missing \u2014 run `npm run build:ts` from the repo root before publishing')\""
28
29
  },
29
30
  "peerDependencies": {
30
- "shieldcortex": "^4.54.8",
31
+ "shieldcortex": "^5.0.0",
31
32
  "openclaw": ">=2026.3.22"
32
33
  },
33
34
  "peerDependenciesMeta": {
@@ -36,7 +37,7 @@
36
37
  }
37
38
  },
38
39
  "engines": {
39
- "node": ">=20.0.0",
40
+ "node": "^22.14.0 || >=24.0.0",
40
41
  "openclaw": ">=2026.4.23"
41
42
  },
42
43
  "publishConfig": {