@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 +3 -3
- package/dist/index.js +271 -69
- package/dist/interceptor.js +70 -16
- package/dist/openclaw.plugin.json +3 -3
- package/dist/provenance.js +201 -0
- package/index.ts +302 -63
- package/interceptor.ts +92 -16
- package/openclaw.plugin.json +3 -3
- package/package.json +4 -3
- package/provenance.ts +227 -0
package/interceptor.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface ActionGuardConfig {
|
|
|
46
46
|
/** Structural shape of a Tool Action Guard verdict (kept local to avoid a
|
|
47
47
|
* compile-time dependency on the main package across the plugin build boundary;
|
|
48
48
|
* the real `evaluateToolCall` from `shieldcortex/defence` is compatible). */
|
|
49
|
+
/** A reviewed native contract grew fields ShieldCortex does not read. Names
|
|
50
|
+
* only — the guard drops the values before any scanner sees them. */
|
|
51
|
+
export interface ContractDriftLike {
|
|
52
|
+
contract: string;
|
|
53
|
+
droppedKeys: string[];
|
|
54
|
+
truncated?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
export interface ToolGuardVerdictLike {
|
|
50
58
|
decision: 'allow' | 'require_approval' | 'block';
|
|
51
59
|
severity: 'benign' | 'sensitive' | 'dangerous' | 'catastrophic' | string;
|
|
@@ -53,6 +61,8 @@ export interface ToolGuardVerdictLike {
|
|
|
53
61
|
action: string;
|
|
54
62
|
reason: string;
|
|
55
63
|
signals: string[];
|
|
64
|
+
/** Present only when a reviewed contract drifted. Never a verdict input. */
|
|
65
|
+
contractDrift?: ContractDriftLike;
|
|
56
66
|
/** Rule → matched-span evidence behind `signals` (issue #192).
|
|
57
67
|
* #184: optional source/line/chain when the match came from folded script. */
|
|
58
68
|
matches?: Array<{
|
|
@@ -277,6 +287,15 @@ export interface InterceptAuditEntry {
|
|
|
277
287
|
escalated?: { by: 'session-taint'; from: string; to: string; reason: string };
|
|
278
288
|
/** Files the reviewed-script allowlist exempted from folding (#189). */
|
|
279
289
|
reviewedScripts?: string[];
|
|
290
|
+
/**
|
|
291
|
+
* Native contract drift: a reviewed exact-name host contract carried fields
|
|
292
|
+
* ShieldCortex does not read, which the guard dropped before validation and
|
|
293
|
+
* before any extractor. Key NAMES only, bounded — an operator can see THAT a
|
|
294
|
+
* host schema moved and which fields moved, without the row ever carrying a
|
|
295
|
+
* value from them. Advisory: this rides on the call's own outcome and never
|
|
296
|
+
* gates, denies, or mints a card.
|
|
297
|
+
*/
|
|
298
|
+
contractDrift?: ContractDriftLike;
|
|
280
299
|
/** #260 — plane origin so the session-guard summariser can find this row. */
|
|
281
300
|
origin?: 'openclaw-interceptor';
|
|
282
301
|
sessionKey?: string;
|
|
@@ -316,13 +335,11 @@ const DEFAULT_CONFIG: InterceptorConfig = {
|
|
|
316
335
|
high: 'deny',
|
|
317
336
|
critical: 'deny',
|
|
318
337
|
},
|
|
319
|
-
// Action Guard
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
// pre-approve the dangerous ops it legitimately needs unattended; set
|
|
323
|
-
// `enforce:false` to opt back down to warn-and-allow.
|
|
338
|
+
// Action Guard OFF by default: false-card storms on live OpenClaw exec
|
|
339
|
+
// bags made default-on an uninstall risk. Catastrophic gating is also off
|
|
340
|
+
// until the operator signs `shieldcortex config --action-guard-enable`.
|
|
324
341
|
actionGuard: {
|
|
325
|
-
enabled:
|
|
342
|
+
enabled: false,
|
|
326
343
|
enforce: true,
|
|
327
344
|
autoApprove: [],
|
|
328
345
|
auditAllows: true,
|
|
@@ -466,6 +483,23 @@ export function formatApprovalPrompt(input: ApprovalPromptInput): string {
|
|
|
466
483
|
].join('\n');
|
|
467
484
|
}
|
|
468
485
|
|
|
486
|
+
/**
|
|
487
|
+
* The guard could not CLOSE this bag: the #412 tool-input schema rejected it,
|
|
488
|
+
* or the command-evidence walk ran out of budget before it had read all of it.
|
|
489
|
+
* Either way the call was never fully scanned, so no operator widening may
|
|
490
|
+
* apply to it — see `unscannedBlock` at the enforcement site.
|
|
491
|
+
*
|
|
492
|
+
* Read off the guard's own reason codes (`invalid_tool_input` /
|
|
493
|
+
* `invalid-tool-input`) rather than the decision tier, so it stays true
|
|
494
|
+
* whichever door the core decides this class deserves. Mirrored in
|
|
495
|
+
* `scripts/pre-tool-hook.mjs` (`isSchemaInvalid`) — the two enforcement
|
|
496
|
+
* surfaces must agree, and parity is asserted by the plane gate.
|
|
497
|
+
*/
|
|
498
|
+
function isSchemaInvalid(v: ToolGuardVerdictLike): boolean {
|
|
499
|
+
return v.action === 'invalid_tool_input'
|
|
500
|
+
|| (Array.isArray(v.signals) && v.signals.includes('invalid-tool-input'));
|
|
501
|
+
}
|
|
502
|
+
|
|
469
503
|
// --- WS2 fail-closed fallback (guard load/eval failure) ---
|
|
470
504
|
// Deliberately DUPLICATED from tool-action-guard.ts's CATASTROPHIC list, not
|
|
471
505
|
// imported — this file already avoids a compile-time dependency on the main
|
|
@@ -946,7 +980,7 @@ export function createInterceptor(
|
|
|
946
980
|
const bindAudit = options?.bindAudit;
|
|
947
981
|
/** Args of the in-flight tool call — used only to mint #224 actionKey. */
|
|
948
982
|
let lastCallArgs: Record<string, unknown> | undefined;
|
|
949
|
-
const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled:
|
|
983
|
+
const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] };
|
|
950
984
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
951
985
|
const broker = options?.broker;
|
|
952
986
|
// The judge rides the operator's own model pool, so its calls are their cost
|
|
@@ -1304,31 +1338,73 @@ export function createInterceptor(
|
|
|
1304
1338
|
}
|
|
1305
1339
|
}
|
|
1306
1340
|
|
|
1341
|
+
// ── Native contract drift observation ────────────────────────────────
|
|
1342
|
+
// A reviewed host contract grew fields ShieldCortex does not read. The
|
|
1343
|
+
// guard already dropped them before nested validation and before any
|
|
1344
|
+
// extractor, so nothing here can change the verdict — this is the record
|
|
1345
|
+
// that the drop HAPPENED, which is the only way an operator learns a host
|
|
1346
|
+
// schema moved without a card storm telling them. It rides on the call's
|
|
1347
|
+
// own outcome rather than minting a row of its own, so a drifted benign
|
|
1348
|
+
// allow stays a single row and volume discipline holds. `auditAllows:false`
|
|
1349
|
+
// opts out with the rest of the recognised-allow stream.
|
|
1350
|
+
const drift = v.contractDrift && v.contractDrift.droppedKeys.length > 0
|
|
1351
|
+
? { contractDrift: v.contractDrift }
|
|
1352
|
+
: undefined;
|
|
1353
|
+
if (v.decision === 'allow' && drift && actionGuardCfg.auditAllows !== false) {
|
|
1354
|
+
const d = drift.contractDrift;
|
|
1355
|
+
log.warn(
|
|
1356
|
+
`[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1307
1360
|
if (v.decision === 'allow') {
|
|
1308
1361
|
// Issue #95: a RECOGNISED allow (the guard evaluated a known operation
|
|
1309
1362
|
// family and let it through — severity above benign) leaves an audit
|
|
1310
1363
|
// entry, so forensics can distinguish "scanned & allowed" from "never
|
|
1311
1364
|
// scanned". Benign allows stay unaudited by design (volume discipline);
|
|
1312
1365
|
// `actionGuard.auditAllows: false` opts the recognised entries off too.
|
|
1313
|
-
if (
|
|
1314
|
-
|
|
1315
|
-
|
|
1366
|
+
if (actionGuardCfg.auditAllows !== false) {
|
|
1367
|
+
if (v.severity !== 'benign') {
|
|
1368
|
+
const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
|
|
1369
|
+
emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), ...(drift ?? {}), action: 'allow', outcome: 'allowed' });
|
|
1370
|
+
} else if (drift) {
|
|
1371
|
+
// A benign allow is normally unaudited — but drift is the one thing
|
|
1372
|
+
// about it worth keeping, so it rides on ONE row of its own rather
|
|
1373
|
+
// than doubling the recognised-allow row above. The preview is the
|
|
1374
|
+
// tool name and nothing else: a drifted field may hold a prompt or a
|
|
1375
|
+
// token, and unlike `summariseToolArgs` this row must never be a
|
|
1376
|
+
// channel for a value the guard just refused to read.
|
|
1377
|
+
emitAudit({
|
|
1378
|
+
...guardAuditBase(context.toolName, v, `${context.toolName} :: contract-drift`),
|
|
1379
|
+
...drift,
|
|
1380
|
+
action: 'allow',
|
|
1381
|
+
outcome: 'allowed',
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1316
1384
|
}
|
|
1317
1385
|
return;
|
|
1318
1386
|
}
|
|
1319
1387
|
|
|
1320
1388
|
const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
|
|
1321
|
-
const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}) };
|
|
1389
|
+
const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}), ...(drift ?? {}) };
|
|
1322
1390
|
const severity: Severity = v.severity === 'catastrophic' ? 'critical' : 'high';
|
|
1323
1391
|
|
|
1324
1392
|
// Catastrophic / exfil — hard block, always enforced when the guard is enabled.
|
|
1325
|
-
// #436: the door-less throw is for that tier only. A
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
// call — they skip below.
|
|
1393
|
+
// #436: the door-less throw is for that tier only. A schema rejection falls
|
|
1394
|
+
// through to requireApproval so the operator can still say yes. autoApprove
|
|
1395
|
+
// and enforce:false must not widen an unscanned call — they skip below.
|
|
1329
1396
|
const terminalBlock = v.decision === 'block'
|
|
1330
1397
|
&& (v.severity === 'catastrophic' || v.severity === 'critical');
|
|
1331
|
-
|
|
1398
|
+
// Derived from the guard's OWN schema signal, not from `decision`. The core
|
|
1399
|
+
// answers a scanned-clean schema rejection with `require_approval` (so all
|
|
1400
|
+
// three planes say the same word about the same call), which means
|
|
1401
|
+
// `decision === 'block'` no longer identifies the class. Keying off the
|
|
1402
|
+
// decision here would silently re-open exactly what #436 closed:
|
|
1403
|
+
// `{command:'…', evil:'…'}` running unscanned on an `enforce:false` host,
|
|
1404
|
+
// and `autoApprove: ['unknown-keys']` becoming a blanket bypass of the #412
|
|
1405
|
+
// closed schema. The residual `decision === 'block' && !terminalBlock` arm
|
|
1406
|
+
// is kept for any sub-catastrophic block a future rule mints.
|
|
1407
|
+
const unscannedBlock = isSchemaInvalid(v) || (v.decision === 'block' && !terminalBlock);
|
|
1332
1408
|
if (terminalBlock) {
|
|
1333
1409
|
// #227: release any lease this call minted early — a blocked action must
|
|
1334
1410
|
// not leave a hold on that scope (self-heals at TTL if release fails).
|
package/openclaw.plugin.json
CHANGED
|
@@ -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,
|
|
@@ -304,7 +304,7 @@
|
|
|
304
304
|
"properties": {
|
|
305
305
|
"enabled": {
|
|
306
306
|
"type": "boolean",
|
|
307
|
-
"default":
|
|
307
|
+
"default": false
|
|
308
308
|
},
|
|
309
309
|
"enforce": {
|
|
310
310
|
"type": "boolean",
|
|
@@ -427,7 +427,7 @@
|
|
|
427
427
|
"properties": {
|
|
428
428
|
"enabled": {
|
|
429
429
|
"type": "boolean",
|
|
430
|
-
"default":
|
|
430
|
+
"default": false
|
|
431
431
|
},
|
|
432
432
|
"enforce": {
|
|
433
433
|
"type": "boolean",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/shieldcortex-realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"dist/",
|
|
18
18
|
"index.ts",
|
|
19
|
+
"provenance.ts",
|
|
19
20
|
"interceptor.ts",
|
|
20
21
|
"intercept-ingest.ts",
|
|
21
22
|
"cloud-sync.ts",
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"prepublishOnly": "node -e \"if(!require('fs').existsSync('dist/index.js'))throw new Error('plugin dist/index.js missing \u2014 run `npm run build:ts` from the repo root before publishing')\""
|
|
28
29
|
},
|
|
29
30
|
"peerDependencies": {
|
|
30
|
-
"shieldcortex": "^
|
|
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": {
|
package/provenance.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
|
|
38
|
+
export const PLUGIN_PROVENANCE_LABELS = ['user', 'tool_result', 'unknown'] as const;
|
|
39
|
+
|
|
40
|
+
export type PluginProvenanceLabel = (typeof PLUGIN_PROVENANCE_LABELS)[number];
|
|
41
|
+
|
|
42
|
+
export interface LabelledInput {
|
|
43
|
+
text: string;
|
|
44
|
+
label: PluginProvenanceLabel;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The result of labelling one event: the texts to scan, and how many
|
|
49
|
+
* tool-origin blocks carried no readable text.
|
|
50
|
+
*
|
|
51
|
+
* The second number exists because "dropped silently" is the one outcome a
|
|
52
|
+
* provenance layer must not have. A host that wraps its tool results in a
|
|
53
|
+
* shape this file cannot read produces no inputs AND no counter, so the
|
|
54
|
+
* operator sees a clean plane that is in fact looking at nothing. The count
|
|
55
|
+
* is of BLOCKS, never their content.
|
|
56
|
+
*/
|
|
57
|
+
export interface LabelledInputs {
|
|
58
|
+
inputs: LabelledInput[];
|
|
59
|
+
unreadableToolBlocks: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How many history texts the hook looks at. Unchanged from the pre-provenance
|
|
64
|
+
* behaviour (`extractUserContent(...).slice(-5)`): labelling widens WHICH
|
|
65
|
+
* messages are eligible, not how many are scanned per turn.
|
|
66
|
+
*/
|
|
67
|
+
export const HISTORY_SCAN_LIMIT = 5;
|
|
68
|
+
|
|
69
|
+
/** Roles that carry tool output back to the model, across host encodings. */
|
|
70
|
+
const TOOL_ROLES = new Set(['tool', 'tool_result', 'function', 'tool-result']);
|
|
71
|
+
/** Roles that are the human speaking to the agent. */
|
|
72
|
+
const USER_ROLES = new Set(['user', 'human']);
|
|
73
|
+
|
|
74
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
75
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
76
|
+
? (value as Record<string, unknown>)
|
|
77
|
+
: null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function blockText(block: unknown): string | null {
|
|
81
|
+
const b = asRecord(block);
|
|
82
|
+
if (!b) return typeof block === 'string' ? block : null;
|
|
83
|
+
if (typeof b.text === 'string') return b.text;
|
|
84
|
+
if (typeof b.content === 'string') return b.content;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A content block that IS a tool result, by any of the encodings in use. */
|
|
89
|
+
function isToolResultBlock(block: unknown): boolean {
|
|
90
|
+
const b = asRecord(block);
|
|
91
|
+
if (!b) return false;
|
|
92
|
+
if (typeof b.type === 'string' && TOOL_ROLES.has(b.type)) return true;
|
|
93
|
+
// Anthropic-shaped blocks correlate a result to its call; the presence of
|
|
94
|
+
// that correlation id is the structural tell, independent of `type`.
|
|
95
|
+
return typeof b.tool_use_id === 'string' || typeof b.toolUseId === 'string';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Nested tool-result content is flattened at most this deep. */
|
|
99
|
+
const MAX_NESTING = 3;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pull every readable text out of one tool-result block, however the host
|
|
103
|
+
* nested it, and count the parts that carried none.
|
|
104
|
+
*
|
|
105
|
+
* Anthropic-shaped results are `{type:'tool_result', tool_use_id, content:[
|
|
106
|
+
* {type:'text', text}]}` — a content ARRAY, which the r1 `blockText` could
|
|
107
|
+
* not read, so the whole result was dropped before its tool-result structure
|
|
108
|
+
* was ever examined and no counter moved either. The established provenance
|
|
109
|
+
* is preserved through every child representation: once a block is
|
|
110
|
+
* tool-origin, everything inside it is too.
|
|
111
|
+
*/
|
|
112
|
+
function flattenToolResult(block: unknown, depth: number, out: string[]): number {
|
|
113
|
+
const direct = blockText(block);
|
|
114
|
+
if (direct) {
|
|
115
|
+
out.push(direct);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
const b = asRecord(block);
|
|
119
|
+
if (!b || depth >= MAX_NESTING) return 1;
|
|
120
|
+
const inner = b.content ?? b.result ?? b.output;
|
|
121
|
+
if (Array.isArray(inner)) {
|
|
122
|
+
if (inner.length === 0) return 1;
|
|
123
|
+
let unreadable = 0;
|
|
124
|
+
for (const child of inner) unreadable += flattenToolResult(child, depth + 1, out);
|
|
125
|
+
return unreadable;
|
|
126
|
+
}
|
|
127
|
+
if (inner && typeof inner === 'object') return flattenToolResult(inner, depth + 1, out);
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Label one history message.
|
|
133
|
+
*
|
|
134
|
+
* A user-role message is NOT automatically `user`: on Anthropic-shaped
|
|
135
|
+
* histories a tool result is delivered as a user-role message whose content
|
|
136
|
+
* blocks are tool results. That is precisely the indirect-injection path, so
|
|
137
|
+
* the BLOCK decides, not the role.
|
|
138
|
+
*/
|
|
139
|
+
export function labelHistoryMessage(msg: unknown): LabelledInputs {
|
|
140
|
+
const m = asRecord(msg);
|
|
141
|
+
if (!m) return { inputs: [], unreadableToolBlocks: 0 };
|
|
142
|
+
const role = typeof m.role === 'string' ? m.role.toLowerCase() : '';
|
|
143
|
+
const roleLabel: PluginProvenanceLabel | null = TOOL_ROLES.has(role)
|
|
144
|
+
? 'tool_result'
|
|
145
|
+
: USER_ROLES.has(role)
|
|
146
|
+
? 'user'
|
|
147
|
+
: null;
|
|
148
|
+
|
|
149
|
+
if (typeof m.content === 'string') {
|
|
150
|
+
// A bare string carries no block structure. A TOOL role still decides —
|
|
151
|
+
// the host declared the origin and inheriting it can only tighten. A USER
|
|
152
|
+
// role does not: rule #1 forbids guessing upwards, and a host that
|
|
153
|
+
// flattens tool results into user-role strings would otherwise turn L2
|
|
154
|
+
// off by accident. `unknown` costs nothing here (both labels are L2-off)
|
|
155
|
+
// and buys the honesty counter, so the operator sees that this host's
|
|
156
|
+
// history shape is not being classified rather than being told it is.
|
|
157
|
+
const label: PluginProvenanceLabel = roleLabel === 'tool_result' ? 'tool_result' : 'unknown';
|
|
158
|
+
return {
|
|
159
|
+
inputs: m.content ? [{ text: m.content, label }] : [],
|
|
160
|
+
unreadableToolBlocks: 0,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (Array.isArray(m.content)) {
|
|
165
|
+
const inputs: LabelledInput[] = [];
|
|
166
|
+
let unreadableToolBlocks = 0;
|
|
167
|
+
for (const block of m.content) {
|
|
168
|
+
if (isToolResultBlock(block)) {
|
|
169
|
+
const texts: string[] = [];
|
|
170
|
+
unreadableToolBlocks += flattenToolResult(block, 0, texts);
|
|
171
|
+
for (const text of texts) inputs.push({ text, label: 'tool_result' });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const text = blockText(block);
|
|
175
|
+
if (!text) {
|
|
176
|
+
// Only tool-origin loss is counted: an image block in a user turn is
|
|
177
|
+
// not a gap in this layer, it is a thing this layer never judged.
|
|
178
|
+
if (roleLabel === 'tool_result') unreadableToolBlocks += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const b = asRecord(block);
|
|
182
|
+
const isText = b && typeof b.type === 'string' && b.type === 'text';
|
|
183
|
+
// Established untrusted provenance is INHERITED: a string (or any
|
|
184
|
+
// shape) inside a tool-role message is tool output whatever its own
|
|
185
|
+
// block type says. Under any other role only a text block may inherit;
|
|
186
|
+
// anything else is a shape this file cannot account for.
|
|
187
|
+
const label: PluginProvenanceLabel = roleLabel === 'tool_result'
|
|
188
|
+
? 'tool_result'
|
|
189
|
+
: isText && roleLabel
|
|
190
|
+
? roleLabel
|
|
191
|
+
: 'unknown';
|
|
192
|
+
inputs.push({ text, label });
|
|
193
|
+
}
|
|
194
|
+
return { inputs, unreadableToolBlocks };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { inputs: [], unreadableToolBlocks: 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Every text this hook will scan for one `llm_input` event, each with the
|
|
202
|
+
* origin it was declared under.
|
|
203
|
+
*
|
|
204
|
+
* The live `prompt` is the turn the host attributes to the sender, so it is
|
|
205
|
+
* `user` — the same judgement the conversation-trust layer already makes
|
|
206
|
+
* about a turn. History is labelled per message and bounded to the last
|
|
207
|
+
* {@link HISTORY_SCAN_LIMIT}, as before.
|
|
208
|
+
*/
|
|
209
|
+
export function labelLlmInput(event: {
|
|
210
|
+
prompt?: unknown;
|
|
211
|
+
historyMessages?: unknown;
|
|
212
|
+
}): LabelledInputs {
|
|
213
|
+
const out: LabelledInput[] = [];
|
|
214
|
+
if (typeof event?.prompt === 'string' && event.prompt) {
|
|
215
|
+
out.push({ text: event.prompt, label: 'user' });
|
|
216
|
+
}
|
|
217
|
+
const history = Array.isArray(event?.historyMessages) ? event.historyMessages : [];
|
|
218
|
+
const labelled: LabelledInput[] = [];
|
|
219
|
+
let unreadableToolBlocks = 0;
|
|
220
|
+
for (const msg of history) {
|
|
221
|
+
const one = labelHistoryMessage(msg);
|
|
222
|
+
labelled.push(...one.inputs);
|
|
223
|
+
unreadableToolBlocks += one.unreadableToolBlocks;
|
|
224
|
+
}
|
|
225
|
+
out.push(...labelled.slice(-HISTORY_SCAN_LIMIT));
|
|
226
|
+
return { inputs: out, unreadableToolBlocks };
|
|
227
|
+
}
|