@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 +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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
3253
|
-
|
|
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
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
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
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
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
|
};
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/shieldcortex-realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
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": "^
|
|
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": ">=
|
|
40
|
+
"node": "^22.14.0 || >=24.0.0",
|
|
40
41
|
"openclaw": ">=2026.4.23"
|
|
41
42
|
},
|
|
42
43
|
"publishConfig": {
|