shrike-guard 4.0.5 → 4.2.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +277 -34
  3. package/dist/ai/index.d.mts +60 -0
  4. package/dist/ai/index.d.ts +60 -0
  5. package/dist/ai/index.js +476 -0
  6. package/dist/ai/index.js.map +1 -0
  7. package/dist/ai/index.mjs +472 -0
  8. package/dist/ai/index.mjs.map +1 -0
  9. package/dist/anthropic/index.d.mts +1 -1
  10. package/dist/anthropic/index.d.ts +1 -1
  11. package/dist/anthropic/index.js +10 -3
  12. package/dist/anthropic/index.js.map +1 -1
  13. package/dist/anthropic/index.mjs +10 -3
  14. package/dist/anthropic/index.mjs.map +1 -1
  15. package/dist/api/index.d.mts +1 -1
  16. package/dist/api/index.d.ts +1 -1
  17. package/dist/api/index.js +2 -2
  18. package/dist/api/index.js.map +1 -1
  19. package/dist/api/index.mjs +2 -2
  20. package/dist/api/index.mjs.map +1 -1
  21. package/dist/claude-agent/index.d.mts +91 -0
  22. package/dist/claude-agent/index.d.ts +91 -0
  23. package/dist/claude-agent/index.js +492 -0
  24. package/dist/claude-agent/index.js.map +1 -0
  25. package/dist/claude-agent/index.mjs +485 -0
  26. package/dist/claude-agent/index.mjs.map +1 -0
  27. package/dist/gemini/index.d.mts +1 -1
  28. package/dist/gemini/index.d.ts +1 -1
  29. package/dist/gemini/index.js +10 -3
  30. package/dist/gemini/index.js.map +1 -1
  31. package/dist/gemini/index.mjs +10 -3
  32. package/dist/gemini/index.mjs.map +1 -1
  33. package/dist/govern-R_Lx6i1l.d.ts +202 -0
  34. package/dist/govern-rW3QdqsQ.d.mts +202 -0
  35. package/dist/index.d.mts +2 -2
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +256 -22
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +256 -23
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/langchain/index.d.mts +75 -0
  42. package/dist/langchain/index.d.ts +75 -0
  43. package/dist/langchain/index.js +484 -0
  44. package/dist/langchain/index.js.map +1 -0
  45. package/dist/langchain/index.mjs +479 -0
  46. package/dist/langchain/index.mjs.map +1 -0
  47. package/dist/openai/index.d.mts +1 -1
  48. package/dist/openai/index.d.ts +1 -1
  49. package/dist/openai/index.js +10 -3
  50. package/dist/openai/index.js.map +1 -1
  51. package/dist/openai/index.mjs +10 -3
  52. package/dist/openai/index.mjs.map +1 -1
  53. package/dist/openai-agents/index.d.mts +66 -0
  54. package/dist/openai-agents/index.d.ts +66 -0
  55. package/dist/openai-agents/index.js +499 -0
  56. package/dist/openai-agents/index.js.map +1 -0
  57. package/dist/openai-agents/index.mjs +492 -0
  58. package/dist/openai-agents/index.mjs.map +1 -0
  59. package/dist/{scanner-cxA3LnQR.d.mts → scanner-U1SygSM2.d.mts} +198 -8
  60. package/dist/{scanner-cxA3LnQR.d.ts → scanner-U1SygSM2.d.ts} +198 -8
  61. package/package.json +62 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,131 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.2.0] - 2026-09-17
4
+
5
+ ### Added
6
+ - **An unmapped tool can still be judged by its name.** A tool with no
7
+ mapping has no readable surface, so the content plane has nothing to say
8
+ about it. The authorization plane still does: the operator's declared scope
9
+ judges a tool by NAME, which is the one thing every tool call has.
10
+ `onUnmapped: 'authorize'` sends the tool's name (and nothing else) to the
11
+ backend, so a tool outside the allowlist is refused even though nothing
12
+ read what it was carrying, and an expired or exhausted scope holds it. The
13
+ permit is narrower than a mapped tool's and the record says so: the
14
+ decision's surface is `authorization`, never a scanned surface. A client
15
+ too old to ask fails according to `failMode` rather than assuming.
16
+ `ShrikeGuard.authorizeTool` is the call underneath.
17
+ - **Observe-plane scans say so.** `ShrikeGuard.scan` takes an options argument
18
+ carrying `plane`, and `observePrompt` sends `plane: 'observe'`. A verdict on a prompt nobody is
19
+ gated on is advice: it is recorded and returned, and the model still reads
20
+ the note, but it is no longer filed as an action that was stopped.
21
+
22
+ - **Framework starters over one core.** `govern.ts` is the framework-free
23
+ core of a governed agent: a tool mapping table (`mapTool`, `exempt`),
24
+ `evaluate` (every scan a tool call needs, stopping at the first refusal,
25
+ into one `Outcome`: allow, warn, hold or deny, with the message written
26
+ for the model), `observePrompt` (never blocks), `requestScope` (the
27
+ backend decides; a widening is refused unless an operator grants it), and
28
+ the record (`decisions`, `onDecision`). Each starter is a subpath and a
29
+ thin translation of one framework's hooks:
30
+ - `shrike-guard/claude-agent` (Claude Agent SDK): `PreToolUse` and
31
+ `UserPromptSubmit` hooks, an in-process MCP tool. Ships mappings for the
32
+ SDK's built-in tools. ESM, as the SDK is.
33
+ - `shrike-guard/openai-agents` (OpenAI Agents SDK): a tool input guardrail
34
+ attached to every function tool (a refused call is answered with
35
+ `rejectContent`, so the model reads the reason as the tool's output), a
36
+ non-tripping input guardrail for the observe plane, and `request_scope`
37
+ as a function tool.
38
+ - `shrike-guard/ai` (Vercel AI SDK): `governTools` wraps each tool's
39
+ `execute` so a refused call returns the reason as the tool's result, a
40
+ language-model middleware whose `transformParams` scans a new user
41
+ message and prepends a note, and `request_scope` as an AI SDK tool.
42
+ - `shrike-guard/langchain` (LangChain `createAgent` and LangGraph): an
43
+ agent middleware whose `wrapToolCall` answers a refused call with a
44
+ `ToolMessage`, or `governTools` for a hand-built `ToolNode`, and
45
+ `request_scope` as a LangChain tool.
46
+ - **Tool mappings.** A tool is a surface (`command`, `file`, `file_path`,
47
+ `sql`, `web_search`, `rag_context`, `a2a_message`, `agent_card`, or
48
+ `none`) and the argument that carries its payload. A tool with no mapping
49
+ is refused with a message that says how to map it (`onUnmapped: 'deny'`),
50
+ or allowed and recorded (`'allow'`), or has its arguments scanned as text
51
+ (`'scan'`), or judged by name alone (`'authorize'`, see below). The Claude
52
+ Agent SDK starter defaults to `'authorize'`, and its hook now sees every
53
+ tool the agent can call rather than only the mapped ones, because a tool
54
+ with no reader is exactly the one whose authorization nobody has checked.
55
+ Pass an explicit tool list to `hooksFor` to narrow the matcher.
56
+ - **A conformance suite.** `tests/unit/frameworks.test.ts` drives every
57
+ starter through the same table (allow, warn, block, hold, backend down,
58
+ unmapped, a refused widening); each starter must answer it the same way.
59
+ The frameworks are optional peer dependencies; the base install is
60
+ unchanged.
61
+
62
+ ## [4.1.0] - 2026-09-09
63
+
64
+ ### Added
65
+ - **Act-plane scanning: every channel the backend scans is now reachable from
66
+ the SDK.** The backend has scanned eight specialized content types since the
67
+ act plane shipped; this SDK exposed four. `scanCommand` — the highest-volume
68
+ surface, the one an agent hits before every shell-out — had no method at all,
69
+ so the only way to reach it was to hand-roll an HTTP call or route through the
70
+ MCP server. New on `ScanClient`:
71
+ - `scanCommand(command, cwd?)` — shell commands, screened before `exec`.
72
+ Commands are decomposed, so SQL passed to `psql -c`, `mysql -e`, or a
73
+ heredoc is scanned as SQL rather than as opaque shell text.
74
+ - `scanWebSearch(query)` — search queries that acquire attack tooling,
75
+ credentials, or evasion tradecraft.
76
+ - `scanRagContext(chunks, query?)` — retrieved context, the standard carrier
77
+ for indirect prompt injection. Accepts a string or a list of strings.
78
+ - `scanMcpSchema(name, description, inputSchema?)` — a single MCP tool
79
+ definition, for tool poisoning in a `tools/list` response. Screened once at
80
+ registration, not per call.
81
+ - **`content_origin` on `ScanResult`.** Every verdict now says where the scanned
82
+ content came from: `human_prompt`, `agent_output`, `agent_action`, or
83
+ `third_party`. This answers the question a verdict alone cannot — was that my
84
+ prompt, or the agent acting on its own — which decides who a refusal message
85
+ is addressed to. Exported alongside it: the `ContentOrigin` type and
86
+ `attributableToOperator(origin)`, true only for `human_prompt`. Unknown content
87
+ types resolve to `agent_action`, never to `human_prompt`.
88
+ - **Act-plane parity test** (`tests/unit/actplane-parity.test.ts`). Iterates the
89
+ canonical channel list and fails when a channel has no SDK method. The gap
90
+ above went unnoticed because "the backend supports it" and "a customer can
91
+ call it" were two facts with nothing comparing them. This is the comparison.
92
+
93
+ - **Per-request session identity — `sessionId`/`agentId` on `ScanClientOptions`,
94
+ and `forSession()`.** Session identity is the key the backend accumulates
95
+ multi-turn risk against, so it has to mean one unit of work: one agent run,
96
+ one conversation, one user's request. It defaults to a process-wide id, which
97
+ suits a CLI or a worker but not a server serving many end users, where every
98
+ user would share one risk score and one user's refusal would count against
99
+ the next user's action. `forSession()` returns a view that shares the parent's
100
+ rate limiter, so deriving one per request is cheap and cannot multiply your
101
+ rate budget:
102
+
103
+ ```ts
104
+ const guard = new ScanClient({ apiKey: KEY }); // once, at startup
105
+
106
+ app.post('/act', async (req, res) => { // per request
107
+ const scoped = guard.forSession(req.session.id);
108
+ const verdict = await scoped.scanCommand(req.body.command);
109
+ });
110
+ ```
111
+
112
+ The process-wide default is unchanged when nothing is supplied, so
113
+ single-agent callers keep multi-turn correlation; the SDK now warns once when
114
+ it is in force. Silence that with `SHRIKE_SUPPRESS_SESSION_WARNING=1`.
115
+
116
+ `evaluateRotation`'s caller-owned branch now applies: a caller that supplies
117
+ its own session id receives a rotation recommendation rather than a
118
+ module-owned rotation.
119
+
120
+ ### Fixed
121
+ - **`content_origin` was computed by the backend and dropped before the caller.**
122
+ The response sanitizer is an allow-list, and the field was never added to
123
+ `PRESERVED_GOVERNANCE_FIELDS`, so it was serialized by the server and stripped
124
+ one layer before the application. It now survives sanitization.
125
+ - **Session identity could not be configured.** Every scan in a process used one
126
+ generated session id, so a server scanning on behalf of more than one person
127
+ placed all of them in one session. See `sessionId` / `forSession()` above.
128
+
3
129
  ## [4.0.5] - 2026-08-31
4
130
 
5
131
  ### Added
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  **Shrike Guard** is a TypeScript SDK for the [Shrike](https://shrikesecurity.com) platform — AI governance for every AI interaction. It wraps OpenAI, Anthropic (Claude), and Google Gemini clients to automatically evaluate all prompts against policy before they reach the LLM. Whether you're governing a customer-facing chatbot, securing developer AI tools, or managing autonomous agent actions — the same 9-layer cognitive pipeline evaluates every interaction.
8
8
 
9
+ It scans two different things. **Prompts and responses**, which is what a guardrail library normally means. And **agent actions** — the shell command, the SQL query, the web search, the retrieved document, the MCP tool definition — screened before they run, which is where an autonomous agent actually causes harm. See [Scanning agent actions](#scanning-agent-actions-shell-commands-sql-web-search-rag-mcp-tools).
10
+
9
11
  ## Features
10
12
 
11
13
  - **Drop-in replacement** for OpenAI, Anthropic, and Gemini clients
@@ -15,6 +17,8 @@
15
17
  - Jailbreak attempts
16
18
  - SQL injection
17
19
  - Path traversal
20
+ - **Pre-execution scanning for agent actions**: shell commands, SQL, file writes, web searches, RAG context, agent-to-agent messages, agent cards, and MCP tool schemas
21
+ - **Content provenance** (`content_origin`): every verdict says whether a human typed it, the model wrote it, the agent is about to do it, or it arrived from outside
18
22
  - **Fail-safe modes**: Defaults to fail-closed (Zero Trust posture); opt into fail-open explicitly when availability outranks enforcement
19
23
  - **CJS + ESM**: Dual build via tsup, works everywhere
20
24
  - **Subpath imports**: `shrike-guard/openai`, `shrike-guard/anthropic`, `shrike-guard/gemini`
@@ -167,6 +171,50 @@ const client = new ShrikeOpenAI({
167
171
  });
168
172
  ```
169
173
 
174
+ ### Sessions: one per unit of work, not one per process
175
+
176
+ Shrike correlates risk across a session. After a refusal, later actions in the
177
+ same session are held until the session recovers. That is the multi-turn
178
+ defence, and it means the session id has to mean one unit of work: one agent
179
+ run, one conversation, one user's request.
180
+
181
+ By default the SDK scans under one id for the whole process. That suits a CLI,
182
+ a worker, or a single agent. It does not suit a server that scans on behalf of
183
+ many end users, because every user then shares one risk score, and one user's
184
+ refusal counts against the next user's action.
185
+
186
+ Build one client at startup and derive a per-request view from it. The view
187
+ shares the rate limiter, so it costs nothing to make one per request and it
188
+ cannot multiply your rate budget:
189
+
190
+ ```typescript
191
+ import { ScanClient } from 'shrike-guard';
192
+
193
+ const guard = new ScanClient({ apiKey: 'shrike-...' }); // once, at startup
194
+
195
+ app.post('/act', async (req, res) => { // per request
196
+ const scoped = guard.forSession(req.session.id);
197
+ const verdict = await scoped.scanCommand(req.body.command);
198
+ if (verdict.refuse_tier !== 'allow') {
199
+ ...
200
+ }
201
+ });
202
+ ```
203
+
204
+ Or pin the identity at construction when one client serves one unit of work:
205
+
206
+ ```typescript
207
+ const client = new ScanClient({ apiKey: 'shrike-...', sessionId: 'job-42', agentId: 'ingest' });
208
+ ```
209
+
210
+ `agentId` is separate on purpose: it names *which agent* a scope is enforced
211
+ against and who an incident is attributed to. Set it when one process drives
212
+ several distinct agents.
213
+
214
+ The SDK warns once per process when it is scanning under the shared default.
215
+ Set `SHRIKE_SUPPRESS_SESSION_WARNING=1` to silence it once you have decided
216
+ the default is what you want.
217
+
170
218
  ### Local and self-hosted LLMs
171
219
 
172
220
  Shrike governs the model you point it at — it does not have to be a hosted
@@ -208,31 +256,197 @@ const client = new ShrikeGemini({
208
256
  The prompt still leaves your process to reach the Shrike backend for scanning;
209
257
  the *model call* stays on your local/self-hosted endpoint.
210
258
 
211
- ## SQL and File Scanning
259
+ ## Scanning agent actions (shell commands, SQL, web search, RAG, MCP tools)
260
+
261
+ Scanning the prompt protects the model. It does not protect the shell. An agent
262
+ that was never told anything malicious can still be talked into running
263
+ `curl … | sh` by a poisoned README, and the prompt scan has no view of that.
212
264
 
213
- The OpenAI client also provides standalone scanning for SQL queries and file paths:
265
+ `ScanClient` exposes a method per action channel. Call the one that matches
266
+ what the agent is about to do, before it does it:
267
+
268
+ | Channel | Method | Screens for |
269
+ |---|---|---|
270
+ | Shell command | `scanCommand(command, cwd?)` | destructive commands, data exfiltration, credential dumps, embedded SQL injection |
271
+ | SQL query | `scanSql(query, database?, allowDestructive?)` | SQL injection, unauthorized destructive statements |
272
+ | File path | `scanFile(path)` | path traversal, writes outside the working tree |
273
+ | File content | `scanFile(path, content)` | secrets, credentials, PII before they land on disk |
274
+ | Web search | `scanWebSearch(query)` | searches that acquire attack tooling, credentials, or evasion tradecraft |
275
+ | RAG context | `scanRagContext(chunks, query?)` | indirect prompt injection in retrieved documents |
276
+ | Agent message | `scanA2AMessage(message, options?)` | instructions smuggled between agents |
277
+ | Agent card | `scanAgentCard(card, verifySignature?)` | capability misrepresentation in A2A discovery |
278
+ | MCP tool schema | `scanMcpSchema(name, description, inputSchema?)` | tool poisoning in `tools/list` responses |
214
279
 
215
280
  ```typescript
216
- import { ShrikeOpenAI } from 'shrike-guard/openai';
281
+ import { ScanClient } from 'shrike-guard';
217
282
 
218
- const client = new ShrikeOpenAI({
219
- apiKey: 'sk-...',
220
- shrikeApiKey: 'shrike-...',
221
- });
283
+ const scanner = new ScanClient({ apiKey: 'shrike-...' });
284
+
285
+ // Before shelling out
286
+ const cmd = await scanner.scanCommand('psql -c "SELECT * FROM users"', '/srv/app');
287
+ if (!cmd.safe) throw new Error(`Refused: ${cmd.reason}`);
288
+
289
+ // Before querying
290
+ const sql = await scanner.scanSql('SELECT * FROM users WHERE id = $1', 'postgres');
291
+
292
+ // Before writing
293
+ const write = await scanner.scanFile('/tmp/config.py', 'api_key = "sk-..."');
294
+
295
+ // Before searching the web
296
+ const search = await scanner.scanWebSearch('sql injection prevention owasp');
297
+ ```
298
+
299
+ A shell command is not one thing. `scanCommand` decomposes it, so SQL passed to
300
+ `psql -c`, `mysql -e`, or a heredoc is scanned as SQL rather than as an opaque
301
+ string of shell text.
302
+
303
+ ### Indirect prompt injection in RAG pipelines
304
+
305
+ Retrieved chunks are text somebody else wrote. They are the standard carrier for
306
+ indirect prompt injection: the user asks nothing unusual, the document tells the
307
+ model what to do, and the model complies. Scan on the way in, not after the
308
+ model has acted:
309
+
310
+ ```typescript
311
+ const chunks = await vectorStore.similaritySearch(userQuery, 5);
312
+
313
+ const verdict = await scanner.scanRagContext(
314
+ chunks.map((c) => c.pageContent),
315
+ userQuery,
316
+ );
317
+
318
+ if (!verdict.safe) {
319
+ // The retrieved context is hostile, not the user's question.
320
+ console.warn(`Poisoned context: ${verdict.reason}`);
321
+ }
322
+ ```
323
+
324
+ ### MCP tool poisoning
325
+
326
+ An MCP tool description is read by the model as guidance. A hostile server can
327
+ put instructions in the `description` field of a tool that never executes, and
328
+ the agent will act on them at registration time. Screen every entry of a
329
+ `tools/list` response from a server you do not control:
222
330
 
223
- // Scan SQL queries for injection attacks
224
- const sqlResult = await client.scanSql('SELECT * FROM users WHERE id = 1');
225
- if (!sqlResult.safe) {
226
- console.log(`SQL threat: ${sqlResult.reason}`);
331
+ ```typescript
332
+ const { tools } = await mcpClient.listTools();
333
+
334
+ for (const tool of tools) {
335
+ const verdict = await scanner.scanMcpSchema(
336
+ tool.name,
337
+ tool.description ?? '',
338
+ tool.inputSchema,
339
+ );
340
+ if (!verdict.safe) {
341
+ console.warn(`Not registering ${tool.name}: ${verdict.reason}`);
342
+ continue;
343
+ }
344
+ register(tool);
227
345
  }
346
+ ```
347
+
348
+ Screening happens once per tool at registration, not on every call.
228
349
 
229
- // Scan file paths for path traversal
230
- const fileResult = await client.scanFile('/app/data/output.csv');
350
+ ## Governing an agent: framework starters
231
351
 
232
- // Scan file content for secrets
233
- const contentResult = await client.scanFile('/tmp/config.py', 'api_key = "sk-..."');
352
+ Shrike governs an agent from inside its tool loop: every tool call is judged
353
+ before it executes, the person's prompt is scanned on the way in and never
354
+ blocked, and the agent gets one tool, `request_scope`, to ask for more than
355
+ it holds. One framework-free core carries that behaviour; each starter is a
356
+ thin translation of one framework's hooks. Every starter is a subpath of this
357
+ package, and each framework is an optional peer dependency (`zod` too).
358
+
359
+ | Framework | Import | What it wires |
360
+ |---|---|---|
361
+ | Claude Agent SDK | `shrike-guard/claude-agent` | `PreToolUse` and `UserPromptSubmit` hooks, an in-process MCP tool |
362
+ | OpenAI Agents SDK | `shrike-guard/openai-agents` | a tool input guardrail on every function tool, a non-tripping input guardrail, a function tool |
363
+ | Vercel AI SDK | `shrike-guard/ai` | wrapped `execute` on every tool, a language-model middleware for the observe plane, an AI SDK tool |
364
+ | LangChain `createAgent` / LangGraph | `shrike-guard/langchain` | an agent middleware (`wrapToolCall`, `beforeAgent`) or wrapped tools for a `ToolNode` |
365
+
366
+ Every starter has the same shape. Build it, map your tools to Shrike's
367
+ surfaces, hand it to the framework:
368
+
369
+ ```ts
370
+ import { Agent } from '@openai/agents';
371
+ import { ScanClient } from 'shrike-guard';
372
+ import { govern } from 'shrike-guard/openai-agents'; // or claude-agent, ai, langchain
373
+
374
+ const guard = new ScanClient({ apiKey: 'shrike-...' });
375
+ const gov = govern(guard, { agentId: 'invoice-agent' });
376
+ await gov.declare(['file_path', 'file_content', 'sql'], 'Reconcile Q3 vendor invoices');
377
+
378
+ gov.mapTool('run_query', { surface: 'sql', arg: 'query' });
379
+ gov.mapTool('save_report', { surface: 'file', pathArg: 'path', contentArg: 'text' });
380
+ gov.exempt('get_time');
381
+
382
+ const agent = gov.governAgent(new Agent({ name: 'invoices', tools: [runQuery, saveReport, getTime] }));
383
+ ```
384
+
385
+ A tool mapping says which surface a tool is and which argument carries the
386
+ payload: `command` (with an optional `cwdArg`), `file` (a path and the
387
+ content written to it), `file_path`, `sql`, `web_search`, `rag_context`,
388
+ `a2a_message`, `agent_card`, or `none` for a tool allowed without a scan.
389
+ The Claude Agent SDK starter ships mappings for the SDK's built-in tools;
390
+ the others govern the tools you write, so they need your mappings.
391
+
392
+ A tool with no mapping is refused, with a message that says how to map it,
393
+ until you map it or choose what an unmapped tool gets. `onUnmapped: 'authorize'` asks the
394
+ backend whether the agent may call it at all: the arguments stay put, and the
395
+ tool's NAME goes up, where the operator's declared scope answers. A tool
396
+ outside the allowlist is refused by name even though nothing read what it was
397
+ carrying, and an expired or exhausted scope holds it. That permit is narrower
398
+ than a mapped tool's and the record says so, with the decision's surface
399
+ recorded as `authorization`. `'allow'` records the call and moves on without
400
+ asking; `'scan'` sends the arguments to be read as text.
401
+
402
+ What the model sees is the same everywhere: an allowed call runs; a warned
403
+ call runs with the advisory; a blocked call does not run and the model gets
404
+ the reason; a held call (an action outside the declared scope) does not run
405
+ and the model gets the reason, the recovery, and the instruction to ask with
406
+ `request_scope` and then stop. An action Shrike could not check is refused
407
+ (`failMode: 'closed'`). Every decision lands on `gov.decisions` and on your
408
+ `onDecision` callback, with the axis that objected.
409
+
410
+ Where a framework can route a hold to a person, `onHold: 'ask'` does that
411
+ (the Claude Agent SDK's `canUseTool`). Where it cannot, a hold is answered
412
+ like a refusal and the model is told an operator can grant it on the Shrike
413
+ Agents screen. The `claude-agent` subpath is ESM, because the Claude Agent
414
+ SDK is.
415
+
416
+ ## Who is answerable: `content_origin`
417
+
418
+ Every verdict carries `content_origin`, which says where the scanned content
419
+ came from. It answers the question a verdict alone cannot: *was that my prompt,
420
+ or the agent acting on its own?*
421
+
422
+ | Value | Meaning |
423
+ |---|---|
424
+ | `human_prompt` | the operator typed it |
425
+ | `agent_output` | the model generated it |
426
+ | `agent_action` | the agent is about to do it (every act-plane channel) |
427
+ | `third_party` | it arrived from outside: a tool result, a retrieved document, a peer agent |
428
+
429
+ ```typescript
430
+ import { attributableToOperator } from 'shrike-guard';
431
+
432
+ const verdict = await scanner.scanRagContext(chunks);
433
+
434
+ if (!verdict.safe) {
435
+ if (attributableToOperator(verdict.content_origin)) {
436
+ showUser(`Your request was blocked: ${verdict.reason}`);
437
+ } else {
438
+ // The agent poisoned its own context. Telling the user "your request
439
+ // was blocked" would be both wrong and unhelpful.
440
+ log.warn('agent-side refusal', verdict.reason);
441
+ retryWithCleanContext();
442
+ }
443
+ }
234
444
  ```
235
445
 
446
+ Unknown content types resolve to `agent_action`, never to `human_prompt`:
447
+ attributing an unattributable action to the operator is the one error that is
448
+ never safe to make by default.
449
+
236
450
  ## Error Handling
237
451
 
238
452
  ```typescript
@@ -298,6 +512,8 @@ export OPENAI_API_KEY="sk-..."
298
512
  export ANTHROPIC_API_KEY="sk-ant-..."
299
513
  export SHRIKE_API_KEY="shrike-..."
300
514
  export SHRIKE_ENDPOINT="https://your-shrike-instance.com"
515
+ export SHRIKE_AGENT_ID="ingest" # names this process's agent; see Sessions
516
+ export SHRIKE_SUPPRESS_SESSION_WARNING=1 # once you have decided the shared session is right
301
517
  ```
302
518
 
303
519
  ## Scope and Limitations
@@ -308,13 +524,23 @@ export SHRIKE_ENDPOINT="https://your-shrike-instance.com"
308
524
  | Multi-modal text content | Image/audio content |
309
525
  | SQL queries | Non-chat API calls |
310
526
  | File paths and content | |
527
+ | Shell commands | |
528
+ | Web search queries | |
529
+ | Retrieved RAG context | |
530
+ | Agent-to-agent messages and agent cards | |
531
+ | MCP tool schemas | |
311
532
 
312
- ### Why Input-Only Scanning?
533
+ ### Why Pre-Execution Scanning?
313
534
 
314
- Shrike Guard focuses on **pre-flight protection** — blocking malicious prompts BEFORE they reach the LLM. This:
535
+ Shrike Guard focuses on **pre-flight protection** — evaluating a prompt before it
536
+ reaches the LLM, and an action before it runs. This:
315
537
  - Prevents prompt injection attacks at the source
316
538
  - Has zero latency impact on LLM responses
317
- - Catches the vast majority of threats at the input layer
539
+ - Puts the decision point before the side effect, where refusing still costs nothing
540
+
541
+ An action already taken cannot be un-taken by detecting it afterwards. That is the
542
+ distinction between this and after-the-fact monitoring: the verdict arrives while
543
+ refusing is still free.
318
544
 
319
545
  ## Other Integration Surfaces
320
546
 
@@ -332,22 +558,39 @@ Shrike Guard is one of several ways to integrate with the Shrike platform:
332
558
  | Scenario | How Shrike Guard Helps |
333
559
  |---|---|
334
560
  | **Customer chatbot** | Wrap your OpenAI/Anthropic client. Every user message scanned for injection before it reaches the model. |
335
- | **Internal RAG pipeline** | Scan retrieved context + user queries for PII leakage and injection attempts. |
336
- | **AI coding assistant** | Scan prompts for proprietary code patterns before they leave your environment. |
337
- | **Agent orchestration** | Scan every tool call and LLM request in your LangChain/CrewAI/AutoGen pipeline. |
338
-
339
- ## Alternatives
340
-
341
- Looking for an AI security SDK? Here's how Shrike Guard compares:
342
-
343
- | Feature | Shrike Guard | Lakera | Prompt Armor |
344
- |---|---|---|---|
345
- | Drop-in OpenAI/Anthropic/Gemini wrapper | Yes | No | No |
346
- | 9-layer cognitive pipeline | Yes | Limited | Limited |
347
- | PII detection + redaction | Yes | Partial | No |
348
- | Session correlation | Yes (Pro+) | No | No |
349
- | Free tier (no API key) | Yes | No | No |
350
- | Open source client | Yes (Apache 2.0) | No | No |
561
+ | **Coding agents that shell out** | `scanCommand` before every `exec`. Destructive commands, exfiltration, and SQL smuggled through `psql -c` are caught before the process starts. |
562
+ | **Internal RAG pipeline** | `scanRagContext` on retrieved chunks for indirect prompt injection, plus PII leakage on the query. |
563
+ | **MCP clients** | `scanMcpSchema` on every `tools/list` entry from a server you do not control, to catch tool poisoning at registration. |
564
+ | **Multi-agent systems** | `scanA2AMessage` and `scanAgentCard` for instructions smuggled between agents. |
565
+ | **Agent orchestration** | Scan every tool call and LLM request in your LangChain/LangGraph/CrewAI/AutoGen pipeline. |
566
+
567
+ ## How This Differs From a Prompt Scanner
568
+
569
+ If you are evaluating TypeScript or JavaScript AI security SDKs, this is the
570
+ distinction worth testing against your own workload.
571
+
572
+ Most guardrail libraries answer one question: **is this text hostile?** They read
573
+ the prompt, and sometimes the response. That is necessary, and Shrike Guard does
574
+ it through a 9-layer cascade with PII redaction and multi-turn session
575
+ correlation.
576
+
577
+ But an autonomous agent does not cause harm by saying something. It causes harm
578
+ by *doing* something: running a command, writing a file, querying a database,
579
+ calling a tool. Shrike Guard scans those too, before they execute:
580
+
581
+ - **A verdict per action channel** — shell commands, SQL, file writes, web
582
+ searches, RAG context, agent-to-agent messages, agent cards, MCP tool schemas.
583
+ See [Scanning agent actions](#scanning-agent-actions-shell-commands-sql-web-search-rag-mcp-tools).
584
+ - **Pre-execution, not after the fact.** The verdict arrives while refusing is
585
+ still free. An action already taken cannot be un-taken by detecting it.
586
+ - **Provenance on every verdict** (`content_origin`) — whether a human typed it,
587
+ the model wrote it, the agent is about to do it, or it arrived from outside.
588
+ - **A governance contract, not just a boolean** — `refuse_tier`
589
+ (allow / warn / require_approval / block), a `recovery` block telling the agent
590
+ how to proceed legitimately, and `session_state`. Present on safe verdicts too.
591
+ - **Drop-in wrappers** for OpenAI, Anthropic, and Gemini, so the prompt-scanning
592
+ half needs no code changes.
593
+ - **Free tier with no API key**, and an Apache 2.0 client you can read.
351
594
 
352
595
  ## License
353
596
 
@@ -0,0 +1,60 @@
1
+ import { ToolSet, Tool, LanguageModelMiddleware } from 'ai';
2
+ import { G as Governance$1, a as Guard, b as GovernanceOptions } from '../govern-rW3QdqsQ.mjs';
3
+ export { D as Decision, O as Outcome, T as ToolMapping } from '../govern-rW3QdqsQ.mjs';
4
+ import { S as ScanClient } from '../scanner-U1SygSM2.mjs';
5
+
6
+ /**
7
+ * Govern an agent built with the Vercel AI SDK (`ai`).
8
+ *
9
+ * ```ts
10
+ * import { generateText, wrapLanguageModel } from 'ai';
11
+ * import { ScanClient } from 'shrike-guard';
12
+ * import { govern } from 'shrike-guard/ai';
13
+ *
14
+ * const guard = new ScanClient({ apiKey: KEY });
15
+ * const gov = govern(guard, { agentId: 'invoice-agent' }).mapTool('runQuery', { surface: 'sql', arg: 'query' });
16
+ * const result = await generateText({
17
+ * model: wrapLanguageModel({ model, middleware: gov.middleware }),
18
+ * tools: gov.governTools({ runQuery, saveReport, request_scope: gov.tool }),
19
+ * prompt,
20
+ * });
21
+ * ```
22
+ *
23
+ * A thin adapter over `shrike-guard`'s framework-free core (`govern.ts`).
24
+ *
25
+ * The act plane wraps each tool's `execute`: Shrike judges the call through
26
+ * the tool's mapping before the inner `execute` runs. A refused or held call
27
+ * never runs; the wrapper returns Shrike's reason and the recovery as the
28
+ * tool's result, so the model reads why and what to do next. Tools with no
29
+ * mapping are refused by default (`onUnmapped: 'deny'`).
30
+ *
31
+ * The observe plane is a language-model middleware whose `transformParams`
32
+ * scans the latest user message on the way in, once per message, and never
33
+ * blocks. A finding is prepended to the prompt as a system message.
34
+ *
35
+ * `request_scope` is available as `gov.tool`; add it to the tool set. A held
36
+ * action is answered like a refusal whatever `onHold` says; use the SDK's
37
+ * `needsApproval` if the run itself should pause.
38
+ *
39
+ * Requires `ai` and `zod`.
40
+ */
41
+
42
+ /** The latest user message's text in a language-model prompt. */
43
+ declare function lastUserText(prompt: unknown): string;
44
+ declare class Governance extends Governance$1 {
45
+ private toolCache?;
46
+ private middlewareCache?;
47
+ private lastObserved?;
48
+ /** Wrap each tool's `execute` so Shrike judges its calls. Returns a new tool set. */
49
+ governTools<T extends ToolSet>(tools: T): T;
50
+ /** `request_scope` as an AI SDK tool; add it to your tool set under that name. */
51
+ get tool(): Tool;
52
+ /** Scan a new user message in a prompt, once. Returns the note to prepend, or empty. */
53
+ observePromptMessages(prompt: unknown): Promise<string>;
54
+ /** The observe-plane middleware for `wrapLanguageModel({ model, middleware })`. */
55
+ get middleware(): LanguageModelMiddleware;
56
+ }
57
+ /** Build the governance for one agent. */
58
+ declare function govern(guard: Guard | ScanClient, options: GovernanceOptions): Governance;
59
+
60
+ export { Governance, GovernanceOptions, govern, lastUserText };
@@ -0,0 +1,60 @@
1
+ import { ToolSet, Tool, LanguageModelMiddleware } from 'ai';
2
+ import { G as Governance$1, a as Guard, b as GovernanceOptions } from '../govern-R_Lx6i1l.js';
3
+ export { D as Decision, O as Outcome, T as ToolMapping } from '../govern-R_Lx6i1l.js';
4
+ import { S as ScanClient } from '../scanner-U1SygSM2.js';
5
+
6
+ /**
7
+ * Govern an agent built with the Vercel AI SDK (`ai`).
8
+ *
9
+ * ```ts
10
+ * import { generateText, wrapLanguageModel } from 'ai';
11
+ * import { ScanClient } from 'shrike-guard';
12
+ * import { govern } from 'shrike-guard/ai';
13
+ *
14
+ * const guard = new ScanClient({ apiKey: KEY });
15
+ * const gov = govern(guard, { agentId: 'invoice-agent' }).mapTool('runQuery', { surface: 'sql', arg: 'query' });
16
+ * const result = await generateText({
17
+ * model: wrapLanguageModel({ model, middleware: gov.middleware }),
18
+ * tools: gov.governTools({ runQuery, saveReport, request_scope: gov.tool }),
19
+ * prompt,
20
+ * });
21
+ * ```
22
+ *
23
+ * A thin adapter over `shrike-guard`'s framework-free core (`govern.ts`).
24
+ *
25
+ * The act plane wraps each tool's `execute`: Shrike judges the call through
26
+ * the tool's mapping before the inner `execute` runs. A refused or held call
27
+ * never runs; the wrapper returns Shrike's reason and the recovery as the
28
+ * tool's result, so the model reads why and what to do next. Tools with no
29
+ * mapping are refused by default (`onUnmapped: 'deny'`).
30
+ *
31
+ * The observe plane is a language-model middleware whose `transformParams`
32
+ * scans the latest user message on the way in, once per message, and never
33
+ * blocks. A finding is prepended to the prompt as a system message.
34
+ *
35
+ * `request_scope` is available as `gov.tool`; add it to the tool set. A held
36
+ * action is answered like a refusal whatever `onHold` says; use the SDK's
37
+ * `needsApproval` if the run itself should pause.
38
+ *
39
+ * Requires `ai` and `zod`.
40
+ */
41
+
42
+ /** The latest user message's text in a language-model prompt. */
43
+ declare function lastUserText(prompt: unknown): string;
44
+ declare class Governance extends Governance$1 {
45
+ private toolCache?;
46
+ private middlewareCache?;
47
+ private lastObserved?;
48
+ /** Wrap each tool's `execute` so Shrike judges its calls. Returns a new tool set. */
49
+ governTools<T extends ToolSet>(tools: T): T;
50
+ /** `request_scope` as an AI SDK tool; add it to your tool set under that name. */
51
+ get tool(): Tool;
52
+ /** Scan a new user message in a prompt, once. Returns the note to prepend, or empty. */
53
+ observePromptMessages(prompt: unknown): Promise<string>;
54
+ /** The observe-plane middleware for `wrapLanguageModel({ model, middleware })`. */
55
+ get middleware(): LanguageModelMiddleware;
56
+ }
57
+ /** Build the governance for one agent. */
58
+ declare function govern(guard: Guard | ScanClient, options: GovernanceOptions): Governance;
59
+
60
+ export { Governance, GovernanceOptions, govern, lastUserText };