@drakon-systems/shieldcortex-realtime 4.47.38 → 4.47.39
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 +179 -3
- package/dist/conversation-access.js +8 -0
- package/dist/conversation-trust.js +17 -4
- package/dist/gateway-notify-channel.js +56 -0
- package/dist/index.js +1808 -105
- package/dist/interceptor.js +15 -7
- package/dist/openclaw.plugin.json +291 -2
- package/index.ts +2133 -107
- package/interceptor.ts +15 -7
- package/openclaw.plugin.json +291 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory
|
|
|
6
6
|
|
|
7
7
|
- **Node.js** — ≥ 20 required (the `shieldcortex` peer ships `better-sqlite3` ^12, which needs Node 20+)
|
|
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
|
+
- **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))
|
|
9
10
|
- **ShieldCortex** — ≥ 4.18.3 required (matches the declared peer dependency; ship both packages at the same version)
|
|
10
11
|
|
|
11
12
|
OpenClaw is declared as an **optional** peer dependency, so installs on older OpenClaw keep working but miss the linking benefit.
|
|
@@ -27,10 +28,11 @@ The defensive root `openclaw.plugin.json` is kept for one release on the main pa
|
|
|
27
28
|
|
|
28
29
|
| Hook | Action |
|
|
29
30
|
|------|--------|
|
|
30
|
-
| `llm_input` | Scans prompts and history through the ShieldCortex defence pipeline. Threats are
|
|
31
|
+
| `llm_input` | **Observation only.** Scans prompts and history through the ShieldCortex defence pipeline. OpenClaw classifies this hook under "conversation observation": it has no blocking contract, so a detection here cannot stop the turn. Threats are audited, alerted, and can forward to ShieldCortex Cloud. |
|
|
32
|
+
| `before_agent_run` | **The conversation firewall's enforcement point.** The documented input gate — it is awaited and its result decides whether the run proceeds. Behaviour is set by `interceptor.conversation.posture` (see [Conversation firewall](#conversation-firewall)). |
|
|
31
33
|
| `llm_output` | Extracts high-signal memories from assistant replies and writes them into ShieldCortex with novelty filtering and dedupe. |
|
|
32
34
|
| `before_tool_call` | Runs the Action Guard before tools execute. Catastrophic shell/file/network/git actions are always blocked. Recognised-dangerous actions are **enforced by default**: attended sessions get an approval prompt, unattended sessions fail closed per `failurePolicy`. Set `actionGuard.enforce: false` to opt down to warn-and-allow, or pre-approve specific operations with `actionGuard.autoApprove`. |
|
|
33
|
-
| `session_end` | Resets the interceptor's per-session caches. |
|
|
35
|
+
| `session_end` | Resets the interceptor's per-session caches and releases that session's scan-unavailable alert window. Registered even when `interceptor.enabled` is `false`, because the conversation gate keeps per-session state regardless. |
|
|
34
36
|
| `/shieldcortex-status` | Slash command reporting the plugin's runtime state. |
|
|
35
37
|
|
|
36
38
|
The scanning and memory paths are fire-and-forget: they do not stall the OpenClaw turn loop if ShieldCortex is unavailable. The Action Guard is the deliberate exception — it gates tool calls inline, and since 4.47.5 a guard that fails to load falls back to a dependency-free scanner that still denies unambiguous catastrophic operations (fail-closed) rather than allowing everything.
|
|
@@ -110,11 +112,158 @@ Supported plugin config keys:
|
|
|
110
112
|
- `openclawAutoMemoryDedupe`: enable or disable duplicate suppression
|
|
111
113
|
- `openclawAutoMemoryNoveltyThreshold`: dedupe similarity threshold, `0.6` to `0.99`
|
|
112
114
|
- `openclawAutoMemoryMaxRecent`: dedupe cache size, `50` to `1000`
|
|
115
|
+
- `interceptor.conversation.posture`: what the conversation firewall does with a detection — `off` / `observe` (default) / `enforce`. See [Conversation firewall](#conversation-firewall).
|
|
116
|
+
- `interceptor.failurePolicy`: per-severity verdict when a decision can't be obtained unattended (defaults: `low`/`medium` allow, `high`/`critical` deny)
|
|
117
|
+
|
|
118
|
+
### Where the Action Guard block goes
|
|
119
|
+
|
|
120
|
+
`actionGuard` is a **top-level** key of the plugin `config` object. That is the
|
|
121
|
+
canonical location and the one to write:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{ "config": { "actionGuard": {
|
|
125
|
+
"enabled": true,
|
|
126
|
+
"enforce": true,
|
|
127
|
+
"notify": { "enabled": true, "webhookUrl": "https://hook.example/shieldcortex" }
|
|
128
|
+
} } }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`interceptor.actionGuard` is a **deprecated alias**, still accepted so configs
|
|
132
|
+
written before the top-level key existed keep their posture. Both locations
|
|
133
|
+
validate against the plugin's schema and the manifest's `configSchema`, and both
|
|
134
|
+
are read by the parser; on a conflicting key the top-level value wins, per key,
|
|
135
|
+
and anything the top-level block does not mention is filled in from the alias.
|
|
136
|
+
Everything below is relative to whichever of the two you use:
|
|
137
|
+
|
|
113
138
|
- `actionGuard.enabled`: turn the before-tool-call Action Guard on or off (default `true`)
|
|
114
139
|
- `actionGuard.enforce`: enforce dangerous-operation gating (default `true`); `false` opts down to warn-and-allow. Catastrophic operations are blocked regardless.
|
|
115
140
|
- `actionGuard.autoApprove`: array of operation allowlist entries for unattended agents that legitimately need specific dangerous operations
|
|
116
141
|
- `actionGuard.auditAllows`: audit recognised (sensitive-tier) allow-decisions so "scanned & allowed" is distinguishable from "never scanned" (default `true`; benign allows are never audited)
|
|
117
|
-
- `
|
|
142
|
+
- `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.
|
|
143
|
+
|
|
144
|
+
## Conversation firewall
|
|
145
|
+
|
|
146
|
+
Three postures, set at `interceptor.conversation.posture`:
|
|
147
|
+
|
|
148
|
+
| Posture | Behaviour |
|
|
149
|
+
|---------|-----------|
|
|
150
|
+
| `off` | The conversation is not scanned at all — on **either** hook. No scanner runs, no audit row is written, nothing is forwarded. |
|
|
151
|
+
| `observe` | **Default.** Scan, audit, and alert the operator — but never stop the turn. |
|
|
152
|
+
| `enforce` | Additionally block the run via `before_agent_run` when the verdict is dirty. |
|
|
153
|
+
|
|
154
|
+
`off` governs the observation hook too. It is read before any scanner, any
|
|
155
|
+
audit write and any cloud call on both `llm_input` and `before_agent_run`, so
|
|
156
|
+
switching it off costs one config read per turn and produces no record of the
|
|
157
|
+
conversation anywhere.
|
|
158
|
+
|
|
159
|
+
`observe` is the default deliberately: it is exactly what shipped before the posture existed, now *named* instead of implied to be protection, and the guard's false-positive rate is still unmeasured ([#182](https://github.com/drakon-systems/shieldcortex/issues/182)). An unmeasured blocker in front of every turn would be a worse incident than the one this fixes. An unrecognised value resolves **down** to `observe`, never up.
|
|
160
|
+
|
|
161
|
+
### Two things gate it, and both are reported honestly
|
|
162
|
+
|
|
163
|
+
1. **Operator consent — `hooks.allowConversationAccess`.** OpenClaw refuses *every* conversation hook for a non-bundled plugin unless the host config carries the grant. `llm_input` and `llm_output` are on that list in every build; `before_agent_run` joins it in 2026.5.9-beta.1, so on a current host the grant also gates the firewall's enforcement point. The host config needs:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{ "plugins": { "entries": { "shieldcortex-realtime": {
|
|
167
|
+
"enabled": true,
|
|
168
|
+
"hooks": { "allowConversationAccess": true }
|
|
169
|
+
} } } }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This is a per-box **consent grant**: it authorises a plugin to read every conversation on the host. The plugin itself never writes it, and a plain install never adds it — `/shieldcortex-status` and `shieldcortex doctor` report its absence as *"conversation scanning INACTIVE: conversation access not granted"* rather than quietly claiming protection. Without it, registration succeeds and the hooks are dropped by the host with a diagnostic; the registration line is intent, not acceptance.
|
|
173
|
+
|
|
174
|
+
To have the installer write it, say so explicitly:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
shieldcortex openclaw install --allow-conversation-access
|
|
178
|
+
# or, for automation:
|
|
179
|
+
SHIELDCORTEX_ALLOW_CONVERSATION_ACCESS=1 shieldcortex openclaw install
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
That works for **every** install mode — the native `openclaw plugins
|
|
183
|
+
install` route as well as the local-copy fallback — and the installer states
|
|
184
|
+
the resulting grant state either way, read back off the config rather than
|
|
185
|
+
restated from the flag. `shieldcortex repair` writes it too, but only when
|
|
186
|
+
the same consent is given on that run: repairing an install is not consent to
|
|
187
|
+
widen what it may read.
|
|
188
|
+
|
|
189
|
+
`/shieldcortex-status` reports the grant as a **plugin-load snapshot**: the
|
|
190
|
+
plugin reads it once, when it loads, and never re-reads it. Editing the key
|
|
191
|
+
takes effect — for the gateway and for that line — only after a gateway
|
|
192
|
+
restart.
|
|
193
|
+
|
|
194
|
+
2. **Host build — the gate has a floor.** `before_agent_run` first appears in OpenClaw **2026.5.9-beta.1** and first ships stable in **2026.5.12** (2026.5.7 has no such hook). Below that floor the host silently drops the registration, so the plugin reports the plane as observation-only rather than claiming enforcement. It detects this from the installed host's own shipped hook declarations first, and from its version only as a fallback; an install it cannot read is reported as *unknown*, never as supported. Everything else in this plugin still works at the base engine floor.
|
|
195
|
+
|
|
196
|
+
### When a detection fires
|
|
197
|
+
|
|
198
|
+
The order matters, and it is evidence first:
|
|
199
|
+
|
|
200
|
+
1. **The decision row is written locally, before anything leaves the box.** It
|
|
201
|
+
carries the outcome (`blocked` / `observed` / `unavailable`), the posture, a
|
|
202
|
+
stable `eventId`, and a content digest and length — **never the prompt text**,
|
|
203
|
+
which on this path is hostile input by assumption. A hung notification
|
|
204
|
+
channel, a crash, or a gateway restart mid-alert can no longer take the
|
|
205
|
+
record of a block with it.
|
|
206
|
+
2. **Then the operator is alerted**, through the same transport the Action Guard
|
|
207
|
+
uses (`actionGuard.notify`): the gateway's own channel where the runtime
|
|
208
|
+
provides that seam, otherwise the configured webhook — which is what carries
|
|
209
|
+
alerts today, since no OpenClaw build we have inspected exposes such a seam.
|
|
210
|
+
Conversation alerts are a distinct event (`conversation_threat`) and carry
|
|
211
|
+
**no Approve/Deny controls** — there is no held call behind them. The wait is
|
|
212
|
+
bounded well under the hook's own timeout.
|
|
213
|
+
3. **Then a second row records what happened to the alert**: `type:
|
|
214
|
+
"notification_delivery"`, keyed to the decision by the same `eventId`, with
|
|
215
|
+
`configured` / `delivered` / `via` / `detail`. "Nobody is configured" is
|
|
216
|
+
reported as such, never as delivered, and the channel is named without its
|
|
217
|
+
credentials.
|
|
218
|
+
|
|
219
|
+
**Scanner failures fail open, loudly.** If the scanner cannot run, the turn
|
|
220
|
+
proceeds *unscanned* and is reported as `unavailable` — it is never recorded or
|
|
221
|
+
rendered as clean.
|
|
222
|
+
|
|
223
|
+
**The scan is bounded at 5 seconds.** `before_agent_run` is awaited by the
|
|
224
|
+
gateway, so the user's turn waits on it, and the scanner's fallback path boots
|
|
225
|
+
an MCP server through `npx`, which can take upwards of 15 s cold — on exactly
|
|
226
|
+
the hosts where the in-process defence module failed to load, i.e. the ones
|
|
227
|
+
already degraded. Past the deadline the scan is treated as unavailable: the turn
|
|
228
|
+
proceeds, and the audit row and alert say so. The deadline message names the
|
|
229
|
+
deadline and nothing else — never the prompt.
|
|
230
|
+
|
|
231
|
+
**Repeated unavailability alerts are rate limited per session; the audit is
|
|
232
|
+
not.** An unavailable scanner is usually a missing or broken install, so it
|
|
233
|
+
recurs on *every* turn. The first occurrence in a session alerts immediately;
|
|
234
|
+
after that, at most one alert per 5 minutes **for that session**, and the alert
|
|
235
|
+
that ends a quiet spell reports how many occurrences it covers. The window is
|
|
236
|
+
per session on purpose: a gateway multiplexes many concurrent sessions, and one
|
|
237
|
+
session's repeating failure must not silence the *first* report from another.
|
|
238
|
+
Each session's window is released at `session_end` (registered whether or not
|
|
239
|
+
the Action Guard interceptor is enabled), and the tracking map is bounded so a
|
|
240
|
+
host that never emits `session_end` cannot grow it without limit. Every
|
|
241
|
+
occurrence still writes its own audit row, carrying `unavailableCount`,
|
|
242
|
+
`alertSuppressed`, and `alertSuppressedSinceLastAlert` — so a gap in the alert
|
|
243
|
+
stream can never be mistaken for a gap in the failures. A suppressed occurrence
|
|
244
|
+
writes no delivery row, because no delivery was attempted.
|
|
245
|
+
|
|
246
|
+
**A failure detail is redacted everywhere, not just on disk.** Scanner and
|
|
247
|
+
transport errors are free to quote the endpoint they could not reach, and such a
|
|
248
|
+
URL routinely carries its credential in the path. Every http(s) URL in a reason
|
|
249
|
+
or detail is reduced to its origin before it is written to the audit row, sent
|
|
250
|
+
in the operator alert, returned as a block reason, or printed to the console —
|
|
251
|
+
a gateway's stdout is shipped to a log aggregator as often as the audit file is
|
|
252
|
+
synced, so none of the three is the "ephemeral" one.
|
|
253
|
+
|
|
254
|
+
**A shield config that will not load degrades into the same unavailable path.**
|
|
255
|
+
If the ShieldCortex runtime cannot be resolved or `~/.shieldcortex/config.json`
|
|
256
|
+
cannot be read, the plugin does not go quiet: it warns once per plugin load
|
|
257
|
+
(bounded and redacted, and it never claims the shield config loaded), falls back
|
|
258
|
+
to the `openclaw.json` plugin config so the posture still resolves, and the scan
|
|
259
|
+
then reports `unavailable` — producing the ordinary audit row and operator alert
|
|
260
|
+
above. The turn still proceeds.
|
|
261
|
+
|
|
262
|
+
**An audit write that fails is reported, never assumed.** If the decision row
|
|
263
|
+
cannot be persisted (unwritable audit directory, full disk), the decision itself
|
|
264
|
+
is unchanged, the failure is logged loudly to stderr, and the operator alert
|
|
265
|
+
carries `auditPersistence=failed` so it is clear the alert is the only record of
|
|
266
|
+
the event.
|
|
118
267
|
|
|
119
268
|
## Auto-memory
|
|
120
269
|
|
|
@@ -160,3 +309,30 @@ Realtime events are written to:
|
|
|
160
309
|
```
|
|
161
310
|
|
|
162
311
|
Each line is a JSON object with input-scan, threat, and output-memory activity.
|
|
312
|
+
|
|
313
|
+
**No row on the conversation path carries prompt text.** That holds for the
|
|
314
|
+
`llm_input` observation rows as well as the `before_agent_run` gate rows: both
|
|
315
|
+
record `chars` and a `contentSha256` prefix instead, which is enough to
|
|
316
|
+
correlate the two rows for the same message without storing the message. (Up to
|
|
317
|
+
4.47.35 the `llm_input` threat row carried a 100-character `preview` of the
|
|
318
|
+
prompt — the exact text that had just tripped an injection detector — into a log
|
|
319
|
+
that syncs.)
|
|
320
|
+
|
|
321
|
+
A conversation-firewall detection writes **two** rows, both tagged
|
|
322
|
+
`hook: "before_agent_run"` and joined by a shared `eventId`:
|
|
323
|
+
|
|
324
|
+
| Row | Fields |
|
|
325
|
+
|-----|--------|
|
|
326
|
+
| the decision (`type: "threat"` or `"scan_unavailable"`) | `outcome` (`blocked` / `observed` / `unavailable`), `posture`, the scanner `verdict`, `chars`, a `contentSha256` prefix, and `notifyPending` |
|
|
327
|
+
| the delivery (`type: "notification_delivery"`) | `configured`, `delivered`, `via` (channel name only), `detail` |
|
|
328
|
+
|
|
329
|
+
The decision row is written **before** any notification is attempted, so a
|
|
330
|
+
transport that hangs or a process that dies cannot erase the record of a block.
|
|
331
|
+
The delivery row is appended afterwards, when its outcome is actually known —
|
|
332
|
+
there is no field anywhere that claims an alert was delivered before a transport
|
|
333
|
+
said so. A row where `type` is `scan_unavailable` means the turn ran **without
|
|
334
|
+
being scanned**; it is not a clean verdict.
|
|
335
|
+
|
|
336
|
+
Set `SHIELDCORTEX_AUDIT_DIR` to write these rows somewhere other than
|
|
337
|
+
`~/.shieldcortex/audit` (used by the test suite so a test run can never append
|
|
338
|
+
to a real host's security log).
|
|
@@ -51,6 +51,12 @@ export function readConversationAccess(home, pluginId) {
|
|
|
51
51
|
/**
|
|
52
52
|
* The hooks this plugin can honestly claim at startup. Conversation hooks are
|
|
53
53
|
* only listed when the host will actually keep them.
|
|
54
|
+
*
|
|
55
|
+
* `before_agent_run` — the conversation firewall's enforcement point (#226) —
|
|
56
|
+
* is a conversation hook too from OpenClaw 2026.5.9-beta.1, so it is listed
|
|
57
|
+
* only when the grant is present AND registration was attempted this session.
|
|
58
|
+
* Claiming it on an ungranted host would recreate the exact bug this function
|
|
59
|
+
* exists to remove, one hook further along.
|
|
54
60
|
*/
|
|
55
61
|
export function describeRegisteredHooks(opts) {
|
|
56
62
|
const live = [];
|
|
@@ -58,6 +64,8 @@ export function describeRegisteredHooks(opts) {
|
|
|
58
64
|
live.push('llm_input', 'llm_output');
|
|
59
65
|
if (opts.beforeToolCallRegistered)
|
|
60
66
|
live.push('before_tool_call');
|
|
67
|
+
if (opts.access.granted && opts.beforeAgentRunRequested)
|
|
68
|
+
live.push('before_agent_run');
|
|
61
69
|
live.push('/shieldcortex-status');
|
|
62
70
|
let line = live.join(' + ');
|
|
63
71
|
if (!opts.access.granted) {
|
|
@@ -28,10 +28,23 @@
|
|
|
28
28
|
* the same principle applied to the conversation path.
|
|
29
29
|
*
|
|
30
30
|
* IMPORTANT — trust gates the CONSEQUENCE, not the detection. Content is always
|
|
31
|
-
* scanned and a detection is always audited, whatever its origin;
|
|
32
|
-
* only
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* scanned and a detection is always audited and alerted, whatever its origin;
|
|
32
|
+
* trust decides only what that detection is allowed to DO. There are two such
|
|
33
|
+
* consequences and this module governs both:
|
|
34
|
+
*
|
|
35
|
+
* - escalation — tainting the session so the Action Guard tightens (#233)
|
|
36
|
+
* - enforcement — blocking the turn outright via `before_agent_run` (#226)
|
|
37
|
+
*
|
|
38
|
+
* The second is why this matters more than it looks. Under `enforce`, a block
|
|
39
|
+
* does not merely warn the owner: OpenClaw replaces the message and does not
|
|
40
|
+
* retain the original, so a false positive on the owner's own typing DESTROYS
|
|
41
|
+
* what they wrote. "Paste a web page into Telegram and lose it" is the single
|
|
42
|
+
* worst outcome this system can produce, aimed at the one participant whose
|
|
43
|
+
* input is an instruction rather than an attack.
|
|
44
|
+
*
|
|
45
|
+
* Skipping the scan would trade away visibility, which is what got us into #225
|
|
46
|
+
* in the first place. This keeps the operator's false-alarm cost at zero without
|
|
47
|
+
* going blind.
|
|
35
48
|
*/
|
|
36
49
|
export function classifyConversationOrigin(input) {
|
|
37
50
|
// Strict `=== true`. A missing or non-boolean flag is NOT the owner: on a host
|
|
@@ -70,9 +70,65 @@ function renderText(n) {
|
|
|
70
70
|
lines.push(`[Deny] shieldcortex deny ${n.shortHash}`);
|
|
71
71
|
return lines.join('\n').slice(0, 4_000);
|
|
72
72
|
}
|
|
73
|
+
export function isConversationThreatLike(n) {
|
|
74
|
+
return n?.event === 'conversation_threat';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* #225's rendering. Kept in sync BY HAND with
|
|
78
|
+
* `formatConversationThreatNotification` in operator-notify.ts, for the same
|
|
79
|
+
* cross-boundary reason as `renderText` above, and pinned by this file's tests.
|
|
80
|
+
*
|
|
81
|
+
* Says what happened to the turn FIRST, and offers no affordance: there is no
|
|
82
|
+
* held call behind a conversation alert, so an Approve line here would be a
|
|
83
|
+
* button wired to nothing.
|
|
84
|
+
*/
|
|
85
|
+
function renderThreatText(n) {
|
|
86
|
+
const headline = n.outcome === 'blocked'
|
|
87
|
+
? '🛡️ ShieldCortex — conversation BLOCKED: this turn did NOT reach the model'
|
|
88
|
+
: n.outcome === 'unavailable'
|
|
89
|
+
? '🛡️ ShieldCortex — conversation NOT SCANNED: the scanner was unavailable, the turn ran unscanned'
|
|
90
|
+
: '🛡️ ShieldCortex — conversation threat detected: the turn RAN (observe posture, nothing was blocked)';
|
|
91
|
+
const lines = [
|
|
92
|
+
headline,
|
|
93
|
+
'',
|
|
94
|
+
`Verdict: ${n.summary}`,
|
|
95
|
+
`Posture: ${n.posture}`,
|
|
96
|
+
`Outcome: ${n.outcome}`,
|
|
97
|
+
`Detail: ${n.reason}`,
|
|
98
|
+
];
|
|
99
|
+
if (n.sessionId)
|
|
100
|
+
lines.push(`Session: ${n.sessionId}`);
|
|
101
|
+
if (n.model)
|
|
102
|
+
lines.push(`Model: ${n.model}`);
|
|
103
|
+
if (n.host)
|
|
104
|
+
lines.push(`Host: ${n.host}`);
|
|
105
|
+
lines.push(`At: ${n.detectedAt}`);
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push(n.outcome === 'observed'
|
|
108
|
+
? 'Nothing was blocked. To make this posture stop the turn, set interceptor.conversation.posture=enforce.'
|
|
109
|
+
: n.outcome === 'unavailable'
|
|
110
|
+
? 'The turn was NOT scanned. Check the ShieldCortex install on this host — this is an unprotected turn, not a clean one.'
|
|
111
|
+
: 'The turn was refused before it reached the model. No action is pending.');
|
|
112
|
+
lines.push('The prompt itself is deliberately NOT included in this alert.');
|
|
113
|
+
return lines.join('\n').slice(0, 4_000);
|
|
114
|
+
}
|
|
73
115
|
function buildMessage(n) {
|
|
116
|
+
if (isConversationThreatLike(n)) {
|
|
117
|
+
return {
|
|
118
|
+
text: renderThreatText(n),
|
|
119
|
+
event: 'conversation_threat',
|
|
120
|
+
outcome: n.outcome,
|
|
121
|
+
posture: n.posture,
|
|
122
|
+
summary: n.summary,
|
|
123
|
+
severity: n.outcome === 'blocked' ? 'blocked' : n.outcome === 'unavailable' ? 'unavailable' : 'observed',
|
|
124
|
+
...(n.sessionId ? { sessionId: n.sessionId } : {}),
|
|
125
|
+
...(n.model ? { model: n.model } : {}),
|
|
126
|
+
...(n.host ? { host: n.host } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
74
129
|
return {
|
|
75
130
|
text: renderText(n),
|
|
131
|
+
event: n.event === 'denied_no_prompt_surface' ? 'denied_no_prompt_surface' : 'approval_requested',
|
|
76
132
|
hash: n.hash,
|
|
77
133
|
shortHash: n.shortHash,
|
|
78
134
|
tool: n.tool,
|