@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance labelling for the OpenClaw realtime `llm_input` hook.
|
|
3
|
+
*
|
|
4
|
+
* The hook used to hand every text to the scanner with no origin attached:
|
|
5
|
+
* the operator's own prompt and a web page that arrived inside a tool result
|
|
6
|
+
* were judged by the identical detector. That is the wall the L2 policy
|
|
7
|
+
* exists to break — the same sentence is an instruction from the operator and
|
|
8
|
+
* an injection from a fetched document, and only the ORIGIN separates them.
|
|
9
|
+
*
|
|
10
|
+
* Two rules govern everything here:
|
|
11
|
+
*
|
|
12
|
+
* 1. NEVER GUESS UPWARDS. A shape this file does not recognise is labelled
|
|
13
|
+
* `unknown`, which keeps exactly the pre-existing L1 path and is counted
|
|
14
|
+
* so an operator can see that their host's event shape is not being
|
|
15
|
+
* classified. Guessing `user` would silence L2 on real tool output;
|
|
16
|
+
* guessing `tool_result` would apply an aggressive policy to the
|
|
17
|
+
* operator's own words.
|
|
18
|
+
*
|
|
19
|
+
* 2. NEVER INVENT A DISTINCTION THE EVENT DOES NOT CARRY. The OpenClaw
|
|
20
|
+
* `llm_input` event exposes `prompt`, `systemPrompt`, `historyMessages`
|
|
21
|
+
* and counters — nothing in it says "this tool result was a fetched web
|
|
22
|
+
* page" or "this was a file read". So this file emits `tool_result` for
|
|
23
|
+
* all tool-origin content and never `web`/`document`. Those two labels
|
|
24
|
+
* remain reachable from ingresses that genuinely know (the `scan` CLI's
|
|
25
|
+
* --source), and adding them here later needs a host field to read, not
|
|
26
|
+
* a heuristic.
|
|
27
|
+
*
|
|
28
|
+
* Pure and synchronous: no I/O, no config read, no defence module. The plugin
|
|
29
|
+
* calls it once per hook invocation before any scanning happens.
|
|
30
|
+
*
|
|
31
|
+
* The label vocabulary MIRRORS `ProvenanceLabel` in the main package
|
|
32
|
+
* (src/defence/types.ts). It is spelled out rather than imported because this
|
|
33
|
+
* plugin compiles against its own rootDir and must build without the package
|
|
34
|
+
* source on disk. Only the subset this hook can honestly emit appears here —
|
|
35
|
+
* a label this file cannot justify is a label it must not produce.
|
|
36
|
+
*/
|
|
37
|
+
export const PLUGIN_PROVENANCE_LABELS = ['user', 'tool_result', 'unknown'];
|
|
38
|
+
/**
|
|
39
|
+
* How many history texts the hook looks at. Unchanged from the pre-provenance
|
|
40
|
+
* behaviour (`extractUserContent(...).slice(-5)`): labelling widens WHICH
|
|
41
|
+
* messages are eligible, not how many are scanned per turn.
|
|
42
|
+
*/
|
|
43
|
+
export const HISTORY_SCAN_LIMIT = 5;
|
|
44
|
+
/** Roles that carry tool output back to the model, across host encodings. */
|
|
45
|
+
const TOOL_ROLES = new Set(['tool', 'tool_result', 'function', 'tool-result']);
|
|
46
|
+
/** Roles that are the human speaking to the agent. */
|
|
47
|
+
const USER_ROLES = new Set(['user', 'human']);
|
|
48
|
+
function asRecord(value) {
|
|
49
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
50
|
+
? value
|
|
51
|
+
: null;
|
|
52
|
+
}
|
|
53
|
+
function blockText(block) {
|
|
54
|
+
const b = asRecord(block);
|
|
55
|
+
if (!b)
|
|
56
|
+
return typeof block === 'string' ? block : null;
|
|
57
|
+
if (typeof b.text === 'string')
|
|
58
|
+
return b.text;
|
|
59
|
+
if (typeof b.content === 'string')
|
|
60
|
+
return b.content;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/** A content block that IS a tool result, by any of the encodings in use. */
|
|
64
|
+
function isToolResultBlock(block) {
|
|
65
|
+
const b = asRecord(block);
|
|
66
|
+
if (!b)
|
|
67
|
+
return false;
|
|
68
|
+
if (typeof b.type === 'string' && TOOL_ROLES.has(b.type))
|
|
69
|
+
return true;
|
|
70
|
+
// Anthropic-shaped blocks correlate a result to its call; the presence of
|
|
71
|
+
// that correlation id is the structural tell, independent of `type`.
|
|
72
|
+
return typeof b.tool_use_id === 'string' || typeof b.toolUseId === 'string';
|
|
73
|
+
}
|
|
74
|
+
/** Nested tool-result content is flattened at most this deep. */
|
|
75
|
+
const MAX_NESTING = 3;
|
|
76
|
+
/**
|
|
77
|
+
* Pull every readable text out of one tool-result block, however the host
|
|
78
|
+
* nested it, and count the parts that carried none.
|
|
79
|
+
*
|
|
80
|
+
* Anthropic-shaped results are `{type:'tool_result', tool_use_id, content:[
|
|
81
|
+
* {type:'text', text}]}` — a content ARRAY, which the r1 `blockText` could
|
|
82
|
+
* not read, so the whole result was dropped before its tool-result structure
|
|
83
|
+
* was ever examined and no counter moved either. The established provenance
|
|
84
|
+
* is preserved through every child representation: once a block is
|
|
85
|
+
* tool-origin, everything inside it is too.
|
|
86
|
+
*/
|
|
87
|
+
function flattenToolResult(block, depth, out) {
|
|
88
|
+
const direct = blockText(block);
|
|
89
|
+
if (direct) {
|
|
90
|
+
out.push(direct);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const b = asRecord(block);
|
|
94
|
+
if (!b || depth >= MAX_NESTING)
|
|
95
|
+
return 1;
|
|
96
|
+
const inner = b.content ?? b.result ?? b.output;
|
|
97
|
+
if (Array.isArray(inner)) {
|
|
98
|
+
if (inner.length === 0)
|
|
99
|
+
return 1;
|
|
100
|
+
let unreadable = 0;
|
|
101
|
+
for (const child of inner)
|
|
102
|
+
unreadable += flattenToolResult(child, depth + 1, out);
|
|
103
|
+
return unreadable;
|
|
104
|
+
}
|
|
105
|
+
if (inner && typeof inner === 'object')
|
|
106
|
+
return flattenToolResult(inner, depth + 1, out);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Label one history message.
|
|
111
|
+
*
|
|
112
|
+
* A user-role message is NOT automatically `user`: on Anthropic-shaped
|
|
113
|
+
* histories a tool result is delivered as a user-role message whose content
|
|
114
|
+
* blocks are tool results. That is precisely the indirect-injection path, so
|
|
115
|
+
* the BLOCK decides, not the role.
|
|
116
|
+
*/
|
|
117
|
+
export function labelHistoryMessage(msg) {
|
|
118
|
+
const m = asRecord(msg);
|
|
119
|
+
if (!m)
|
|
120
|
+
return { inputs: [], unreadableToolBlocks: 0 };
|
|
121
|
+
const role = typeof m.role === 'string' ? m.role.toLowerCase() : '';
|
|
122
|
+
const roleLabel = TOOL_ROLES.has(role)
|
|
123
|
+
? 'tool_result'
|
|
124
|
+
: USER_ROLES.has(role)
|
|
125
|
+
? 'user'
|
|
126
|
+
: null;
|
|
127
|
+
if (typeof m.content === 'string') {
|
|
128
|
+
// A bare string carries no block structure. A TOOL role still decides —
|
|
129
|
+
// the host declared the origin and inheriting it can only tighten. A USER
|
|
130
|
+
// role does not: rule #1 forbids guessing upwards, and a host that
|
|
131
|
+
// flattens tool results into user-role strings would otherwise turn L2
|
|
132
|
+
// off by accident. `unknown` costs nothing here (both labels are L2-off)
|
|
133
|
+
// and buys the honesty counter, so the operator sees that this host's
|
|
134
|
+
// history shape is not being classified rather than being told it is.
|
|
135
|
+
const label = roleLabel === 'tool_result' ? 'tool_result' : 'unknown';
|
|
136
|
+
return {
|
|
137
|
+
inputs: m.content ? [{ text: m.content, label }] : [],
|
|
138
|
+
unreadableToolBlocks: 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(m.content)) {
|
|
142
|
+
const inputs = [];
|
|
143
|
+
let unreadableToolBlocks = 0;
|
|
144
|
+
for (const block of m.content) {
|
|
145
|
+
if (isToolResultBlock(block)) {
|
|
146
|
+
const texts = [];
|
|
147
|
+
unreadableToolBlocks += flattenToolResult(block, 0, texts);
|
|
148
|
+
for (const text of texts)
|
|
149
|
+
inputs.push({ text, label: 'tool_result' });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const text = blockText(block);
|
|
153
|
+
if (!text) {
|
|
154
|
+
// Only tool-origin loss is counted: an image block in a user turn is
|
|
155
|
+
// not a gap in this layer, it is a thing this layer never judged.
|
|
156
|
+
if (roleLabel === 'tool_result')
|
|
157
|
+
unreadableToolBlocks += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const b = asRecord(block);
|
|
161
|
+
const isText = b && typeof b.type === 'string' && b.type === 'text';
|
|
162
|
+
// Established untrusted provenance is INHERITED: a string (or any
|
|
163
|
+
// shape) inside a tool-role message is tool output whatever its own
|
|
164
|
+
// block type says. Under any other role only a text block may inherit;
|
|
165
|
+
// anything else is a shape this file cannot account for.
|
|
166
|
+
const label = roleLabel === 'tool_result'
|
|
167
|
+
? 'tool_result'
|
|
168
|
+
: isText && roleLabel
|
|
169
|
+
? roleLabel
|
|
170
|
+
: 'unknown';
|
|
171
|
+
inputs.push({ text, label });
|
|
172
|
+
}
|
|
173
|
+
return { inputs, unreadableToolBlocks };
|
|
174
|
+
}
|
|
175
|
+
return { inputs: [], unreadableToolBlocks: 0 };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Every text this hook will scan for one `llm_input` event, each with the
|
|
179
|
+
* origin it was declared under.
|
|
180
|
+
*
|
|
181
|
+
* The live `prompt` is the turn the host attributes to the sender, so it is
|
|
182
|
+
* `user` — the same judgement the conversation-trust layer already makes
|
|
183
|
+
* about a turn. History is labelled per message and bounded to the last
|
|
184
|
+
* {@link HISTORY_SCAN_LIMIT}, as before.
|
|
185
|
+
*/
|
|
186
|
+
export function labelLlmInput(event) {
|
|
187
|
+
const out = [];
|
|
188
|
+
if (typeof event?.prompt === 'string' && event.prompt) {
|
|
189
|
+
out.push({ text: event.prompt, label: 'user' });
|
|
190
|
+
}
|
|
191
|
+
const history = Array.isArray(event?.historyMessages) ? event.historyMessages : [];
|
|
192
|
+
const labelled = [];
|
|
193
|
+
let unreadableToolBlocks = 0;
|
|
194
|
+
for (const msg of history) {
|
|
195
|
+
const one = labelHistoryMessage(msg);
|
|
196
|
+
labelled.push(...one.inputs);
|
|
197
|
+
unreadableToolBlocks += one.unreadableToolBlocks;
|
|
198
|
+
}
|
|
199
|
+
out.push(...labelled.slice(-HISTORY_SCAN_LIMIT));
|
|
200
|
+
return { inputs: out, unreadableToolBlocks };
|
|
201
|
+
}
|