@drakon-systems/shieldcortex-realtime 4.54.14 → 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/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)
@@ -136,8 +136,8 @@ are read by the parser; on a conflicting key the top-level value wins, per key,
136
136
  and anything the top-level block does not mention is filled in from the alias.
137
137
  Everything below is relative to whichever of the two you use:
138
138
 
139
- - `actionGuard.enabled`: turn the before-tool-call Action Guard on or off (default `true`)
140
- - `actionGuard.enforce`: enforce dangerous-operation gating (default `true`); `false` opts down to warn-and-allow. Catastrophic operations are blocked regardless.
139
+ - `actionGuard.enabled`: turn the before-tool-call Action Guard on or off (default `false`; unsigned configs leave Guard off)
140
+ - `actionGuard.enforce`: enforce dangerous-operation gating (default `true` when Guard is on); `false` opts down to warn-and-allow. Catastrophic operations are blocked only while Guard is enabled.
141
141
  - `actionGuard.autoApprove`: array of operation allowlist entries for unattended agents that legitimately need specific dangerous operations
142
142
  - `actionGuard.auditAllows`: audit recognised (sensitive-tier) allow-decisions so "scanned & allowed" is distinguishable from "never scanned" (default `true`; benign allows are never audited)
143
143
  - `actionGuard.notify`: operator-notification transport (`enabled`, `webhookUrl`, `webhookSecret`, `openclaw`, `timeoutMs`). Off unless `enabled` is exactly `true`. Used both for held tool calls and for conversation-firewall detections.
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;
@@ -821,7 +825,7 @@ const PLUGIN_CONFIG_UI_HINTS = {
821
825
  },
822
826
  "interceptor.actionGuard.enabled": {
823
827
  label: "Action Guard",
824
- help: "Gate dangerous shell/file/network/git tool calls before they execute. Catastrophic operations are always blocked while enabled.",
828
+ help: "Gate dangerous shell/file/network/git tool calls before they execute. Off by default. Catastrophic operations are blocked only while this is enabled.",
825
829
  },
826
830
  "interceptor.actionGuard.enforce": {
827
831
  label: "Enforce Action Guard",
@@ -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;
@@ -3003,7 +3194,7 @@ export default {
3003
3194
  // so the status line reflects what before_tool_call will actually do.
3004
3195
  const rawInterceptor = cfg.interceptor;
3005
3196
  const guardCfg = {
3006
- ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }),
3197
+ ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }),
3007
3198
  ...(rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.actionGuard ?? {} : {}),
3008
3199
  };
3009
3200
  const interceptorOn = (rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.enabled : undefined) ?? DEFAULT_INTERCEPTOR_CONFIG.enabled;
@@ -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
  };
@@ -3079,7 +3281,7 @@ export default {
3079
3281
  enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
3080
3282
  severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
3081
3283
  failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
3082
- actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3284
+ actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3083
3285
  } : {}),
3084
3286
  logger: { info: api.logger?.info ?? console.log, warn: api.logger?.warn ?? console.warn },
3085
3287
  };
@@ -33,13 +33,11 @@ const DEFAULT_CONFIG = {
33
33
  high: 'deny',
34
34
  critical: 'deny',
35
35
  },
36
- // Action Guard on by default: catastrophic ops are blocked out of the box;
37
- // recognised-dangerous ops are ENFORCED by default (P1/WS1) — attended → prompt,
38
- // unattended → fail closed on failurePolicy. Populate `autoApprove` per agent to
39
- // pre-approve the dangerous ops it legitimately needs unattended; set
40
- // `enforce:false` to opt back down to warn-and-allow.
36
+ // Action Guard OFF by default: false-card storms on live OpenClaw exec
37
+ // bags made default-on an uninstall risk. Catastrophic gating is also off
38
+ // until the operator signs `shieldcortex config --action-guard-enable`.
41
39
  actionGuard: {
42
- enabled: true,
40
+ enabled: false,
43
41
  enforce: true,
44
42
  autoApprove: [],
45
43
  auditAllows: true,
@@ -151,6 +149,22 @@ export function formatApprovalPrompt(input) {
151
149
  '[Approve] [Deny]',
152
150
  ].join('\n');
153
151
  }
152
+ /**
153
+ * The guard could not CLOSE this bag: the #412 tool-input schema rejected it,
154
+ * or the command-evidence walk ran out of budget before it had read all of it.
155
+ * Either way the call was never fully scanned, so no operator widening may
156
+ * apply to it — see `unscannedBlock` at the enforcement site.
157
+ *
158
+ * Read off the guard's own reason codes (`invalid_tool_input` /
159
+ * `invalid-tool-input`) rather than the decision tier, so it stays true
160
+ * whichever door the core decides this class deserves. Mirrored in
161
+ * `scripts/pre-tool-hook.mjs` (`isSchemaInvalid`) — the two enforcement
162
+ * surfaces must agree, and parity is asserted by the plane gate.
163
+ */
164
+ function isSchemaInvalid(v) {
165
+ return v.action === 'invalid_tool_input'
166
+ || (Array.isArray(v.signals) && v.signals.includes('invalid-tool-input'));
167
+ }
154
168
  // --- WS2 fail-closed fallback (guard load/eval failure) ---
155
169
  // Deliberately DUPLICATED from tool-action-guard.ts's CATASTROPHIC list, not
156
170
  // imported — this file already avoids a compile-time dependency on the main
@@ -534,7 +548,7 @@ export function createInterceptor(config, pipeline, options) {
534
548
  const bindAudit = options?.bindAudit;
535
549
  /** Args of the in-flight tool call — used only to mint #224 actionKey. */
536
550
  let lastCallArgs;
537
- const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
551
+ const actionGuardCfg = config.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] };
538
552
  const evaluateToolCall = options?.evaluateToolCall;
539
553
  const broker = options?.broker;
540
554
  // The judge rides the operator's own model pool, so its calls are their cost
@@ -881,29 +895,69 @@ export function createInterceptor(config, pipeline, options) {
881
895
  v = { ...v, decision: esc.decision, reason: `${v.reason} — ESCALATED by tainted session: ${taint.reason}` };
882
896
  }
883
897
  }
898
+ // ── Native contract drift observation ────────────────────────────────
899
+ // A reviewed host contract grew fields ShieldCortex does not read. The
900
+ // guard already dropped them before nested validation and before any
901
+ // extractor, so nothing here can change the verdict — this is the record
902
+ // that the drop HAPPENED, which is the only way an operator learns a host
903
+ // schema moved without a card storm telling them. It rides on the call's
904
+ // own outcome rather than minting a row of its own, so a drifted benign
905
+ // allow stays a single row and volume discipline holds. `auditAllows:false`
906
+ // opts out with the rest of the recognised-allow stream.
907
+ const drift = v.contractDrift && v.contractDrift.droppedKeys.length > 0
908
+ ? { contractDrift: v.contractDrift }
909
+ : undefined;
910
+ if (v.decision === 'allow' && drift && actionGuardCfg.auditAllows !== false) {
911
+ const d = drift.contractDrift;
912
+ log.warn(`[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`);
913
+ }
884
914
  if (v.decision === 'allow') {
885
915
  // Issue #95: a RECOGNISED allow (the guard evaluated a known operation
886
916
  // family and let it through — severity above benign) leaves an audit
887
917
  // entry, so forensics can distinguish "scanned & allowed" from "never
888
918
  // scanned". Benign allows stay unaudited by design (volume discipline);
889
919
  // `actionGuard.auditAllows: false` opts the recognised entries off too.
890
- if (v.severity !== 'benign' && actionGuardCfg.auditAllows !== false) {
891
- const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
892
- emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), action: 'allow', outcome: 'allowed' });
920
+ if (actionGuardCfg.auditAllows !== false) {
921
+ if (v.severity !== 'benign') {
922
+ const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
923
+ emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), ...(drift ?? {}), action: 'allow', outcome: 'allowed' });
924
+ }
925
+ else if (drift) {
926
+ // A benign allow is normally unaudited — but drift is the one thing
927
+ // about it worth keeping, so it rides on ONE row of its own rather
928
+ // than doubling the recognised-allow row above. The preview is the
929
+ // tool name and nothing else: a drifted field may hold a prompt or a
930
+ // token, and unlike `summariseToolArgs` this row must never be a
931
+ // channel for a value the guard just refused to read.
932
+ emitAudit({
933
+ ...guardAuditBase(context.toolName, v, `${context.toolName} :: contract-drift`),
934
+ ...drift,
935
+ action: 'allow',
936
+ outcome: 'allowed',
937
+ });
938
+ }
893
939
  }
894
940
  return;
895
941
  }
896
942
  const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
897
- const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}) };
943
+ const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}), ...(drift ?? {}) };
898
944
  const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
899
945
  // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
900
- // #436: the door-less throw is for that tier only. A sub-catastrophic `block`
901
- // (schema rejection) falls through to requireApproval so the operator can
902
- // still say yes. autoApprove and enforce:false must not widen an unscanned
903
- // call — they skip below.
946
+ // #436: the door-less throw is for that tier only. A schema rejection falls
947
+ // through to requireApproval so the operator can still say yes. autoApprove
948
+ // and enforce:false must not widen an unscanned call — they skip below.
904
949
  const terminalBlock = v.decision === 'block'
905
950
  && (v.severity === 'catastrophic' || v.severity === 'critical');
906
- const unscannedBlock = v.decision === 'block' && !terminalBlock;
951
+ // Derived from the guard's OWN schema signal, not from `decision`. The core
952
+ // answers a scanned-clean schema rejection with `require_approval` (so all
953
+ // three planes say the same word about the same call), which means
954
+ // `decision === 'block'` no longer identifies the class. Keying off the
955
+ // decision here would silently re-open exactly what #436 closed:
956
+ // `{command:'…', evil:'…'}` running unscanned on an `enforce:false` host,
957
+ // and `autoApprove: ['unknown-keys']` becoming a blanket bypass of the #412
958
+ // closed schema. The residual `decision === 'block' && !terminalBlock` arm
959
+ // is kept for any sub-catastrophic block a future rule mints.
960
+ const unscannedBlock = isSchemaInvalid(v) || (v.decision === 'block' && !terminalBlock);
907
961
  if (terminalBlock) {
908
962
  // #227: release any lease this call minted early — a blocked action must
909
963
  // not leave a hold on that scope (self-heals at TTL if release fails).