@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/README.md +1 -1
- package/dist/index.js +268 -66
- package/dist/openclaw.plugin.json +1 -1
- package/dist/provenance.js +201 -0
- package/index.ts +299 -60
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -3
- package/provenance.ts +227 -0
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** —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
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
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
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
|
-
|
|
2761
|
-
|
|
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": "
|
|
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,
|