openscript-sdk 0.1.0__py3-none-any.whl

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 (65) hide show
  1. openscript_sdk/__init__.py +217 -0
  2. openscript_sdk/contracts/__init__.py +82 -0
  3. openscript_sdk/contracts/interceptor.py +98 -0
  4. openscript_sdk/contracts/server_types.py +87 -0
  5. openscript_sdk/contracts/session_graph.py +123 -0
  6. openscript_sdk/contracts/types.py +76 -0
  7. openscript_sdk/events/__init__.py +0 -0
  8. openscript_sdk/events/approvals.py +309 -0
  9. openscript_sdk/events/models.py +26 -0
  10. openscript_sdk/events/store.py +79 -0
  11. openscript_sdk/events/writer.py +100 -0
  12. openscript_sdk/hooks/claude_code.py +479 -0
  13. openscript_sdk/hooks/claude_code_example.yaml +62 -0
  14. openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
  15. openscript_sdk/hooks/config.py +144 -0
  16. openscript_sdk/hooks/settings_example.json +28 -0
  17. openscript_sdk/hooks/settings_strict_example.json +28 -0
  18. openscript_sdk/integrations/langchain.py +32 -0
  19. openscript_sdk/integrations/langgraph.py +32 -0
  20. openscript_sdk/interceptors/base.py +15 -0
  21. openscript_sdk/interceptors/event_writer.py +131 -0
  22. openscript_sdk/interceptors/pii.py +168 -0
  23. openscript_sdk/interceptors/threat.py +674 -0
  24. openscript_sdk/logging.py +94 -0
  25. openscript_sdk/middleware/middleware.py +543 -0
  26. openscript_sdk/middleware/tool.py +247 -0
  27. openscript_sdk/observability/__init__.py +11 -0
  28. openscript_sdk/observability/metrics.py +105 -0
  29. openscript_sdk/observability/risk.py +54 -0
  30. openscript_sdk/observability/tracing.py +67 -0
  31. openscript_sdk/policies/compliance.py +261 -0
  32. openscript_sdk/policies/config.py +244 -0
  33. openscript_sdk/policies/harmful_request.py +617 -0
  34. openscript_sdk/policies/normalize.py +489 -0
  35. openscript_sdk/policies/output_schema.py +353 -0
  36. openscript_sdk/policies/policies_example.yaml +36 -0
  37. openscript_sdk/policies/redaction.py +359 -0
  38. openscript_sdk/policies/secrets.py +239 -0
  39. openscript_sdk/policies/semantic.py +348 -0
  40. openscript_sdk/policies/semantic_head.py +9 -0
  41. openscript_sdk/policies/session_taint.py +399 -0
  42. openscript_sdk/policies/taint_store.py +111 -0
  43. openscript_sdk/policies/tool_firewall.py +434 -0
  44. openscript_sdk/policies/tools_example.yaml +81 -0
  45. openscript_sdk/policies/toxicity.py +247 -0
  46. openscript_sdk/py.typed +0 -0
  47. openscript_sdk/server/__init__.py +1 -0
  48. openscript_sdk/server/app.py +153 -0
  49. openscript_sdk/server/auth.py +19 -0
  50. openscript_sdk/server/guard.py +126 -0
  51. openscript_sdk/server/guard_example.yaml +55 -0
  52. openscript_sdk/server/routers/__init__.py +0 -0
  53. openscript_sdk/server/routers/approvals.py +69 -0
  54. openscript_sdk/server/routers/guard.py +231 -0
  55. openscript_sdk/server/routers/sessions.py +80 -0
  56. openscript_sdk/server/routers/sse.py +69 -0
  57. openscript_sdk/server/routers/threat.py +119 -0
  58. openscript_sdk/server/static/dashboard/index.html +209 -0
  59. openscript_sdk/server/static/dashboard/session.html +339 -0
  60. openscript_sdk-0.1.0.dist-info/METADATA +225 -0
  61. openscript_sdk-0.1.0.dist-info/RECORD +65 -0
  62. openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
  63. openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  64. openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
  65. openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,674 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import warnings
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Literal
7
+
8
+ import structlog
9
+
10
+ from openscript_sdk.contracts.server_types import Event, EventType
11
+ from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
12
+ from openscript_sdk.policies import normalize
13
+
14
+ if TYPE_CHECKING:
15
+ from openscript_sdk.events.writer import EventWriter
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+ _TOOL_OUTPUT_MODES = ("off", "annotate", "deny")
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Pattern bank
23
+ # Each entry: (compiled_regex, category_name, weight)
24
+ # Weights are additive; total is capped at 1.0.
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class _Pattern:
30
+ regex: re.Pattern[str]
31
+ category: str
32
+ weight: float
33
+
34
+
35
+ def _p(pattern: str, category: str, weight: float) -> _Pattern:
36
+ return _Pattern(re.compile(pattern, re.IGNORECASE | re.MULTILINE), category, weight)
37
+
38
+
39
+ # Weight tiers. What a pattern is worth is a claim about how often ordinary
40
+ # text produces it, and writing that claim down is what keeps the bank honest:
41
+ # an evals run (evals/run.py) reports the false-positive rate these produce
42
+ # against a corpus of prompts that share their vocabulary.
43
+ _DECISIVE = 0.60 # blocks on its own — no benign phrasing produces this
44
+ _STRONG = 0.45 # one more signal blocks
45
+ _MODERATE = 0.30 # real signal, but ordinary text does produce it
46
+ _CONTEXT = 0.15 # framing only; never enough by itself
47
+
48
+ _PATTERNS: list[_Pattern] = [
49
+ # -- Role / persona injection ------------------------------------------
50
+ _p(
51
+ r"\bignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|context|rules?)\b",
52
+ "role_injection",
53
+ _DECISIVE,
54
+ ),
55
+ _p(r"\b(dan|god|unrestricted|unfiltered|jailbreak)\s+mode\b", "role_injection", _DECISIVE),
56
+ _p(r"\bdo\s+anything\s+now\b", "role_injection", _DECISIVE),
57
+ _p(
58
+ r"\b(do|perform|attempt|run|enable|activate)\s+(a\s+)?jailbreak\b",
59
+ "role_injection",
60
+ _DECISIVE,
61
+ ),
62
+ _p(
63
+ r"\b(enter|enable|activate|switch\s+to|turn\s+on|go\s+into)\s+developer\s+mode\b",
64
+ "role_injection",
65
+ _DECISIVE,
66
+ ),
67
+ _p(r"\byou\s+are\s+now\s+dan\b", "role_injection", _DECISIVE),
68
+ # Plural matters: \brestriction\b does not match "restrictions", which is how
69
+ # every one of these is actually written.
70
+ _p(
71
+ r"\bact\s+as\b.{0,30}\b(without|ignore|no)\b.{0,30}\b(restrictions?|limits?|filters?|rules?|guidelines?)\b",
72
+ "role_injection",
73
+ _STRONG,
74
+ ),
75
+ _p(
76
+ r"\b(with|have|has|having)\s+no\s+(restrictions?|limits?|rules?|guidelines?|filters?|content\s+(?:filters?|polic\w+))\b",
77
+ "role_injection",
78
+ _STRONG,
79
+ ),
80
+ _p(
81
+ r"\bfrom\s+now\s+on\s+you\s+(are|will|have|can)\b",
82
+ "role_injection",
83
+ _MODERATE,
84
+ ),
85
+ _p(
86
+ r"\bwithout\s+(any\s+)?(restrictions?|limits?|rules?|guidelines?|filters?)\b",
87
+ "role_injection",
88
+ _MODERATE,
89
+ ),
90
+ _p(
91
+ r"\bfree\s+from\s+(all\s+)?(restrictions?|limits?|rules?|guidelines?|filters?)\b",
92
+ "role_injection",
93
+ _MODERATE,
94
+ ),
95
+ _p(r"\byou\s+are\s+now\b", "role_injection", _MODERATE),
96
+ _p(r"\bnew\s+persona\b", "role_injection", _MODERATE),
97
+ # Descriptive on its own — security teams write "jailbreak resistance" and
98
+ # "developer mode flag" all day, and blocking them is its own failure.
99
+ _p(r"\bjailbreak\b", "role_injection", _MODERATE),
100
+ _p(r"\bdeveloper\s+mode\b", "role_injection", _CONTEXT),
101
+ # Phrasings that ask for the guardrails to come off without naming any of
102
+ # the folklore ("DAN", "developer mode"). These came out of the paraphrase
103
+ # evals, where the bank as written scored 0.00 on every one of them.
104
+ _p(
105
+ r"\b(set|put)\s+aside\s+(the\s+|your\s+|all\s+)?(rules?|restrictions?|guidelines?|polic\w+|instructions?)\b",
106
+ "role_injection",
107
+ _DECISIVE,
108
+ ),
109
+ _p(
110
+ r"\b(skip|bypass|disable|turn\s+off|lift|suspend)\s+(the\s+|your\s+|all\s+)?(safety\s+)?(checks?|filters?|guardrails?|restrictions?|refusals?|polic\w+|rules?)\b",
111
+ "role_injection",
112
+ _DECISIVE,
113
+ ),
114
+ _p(
115
+ r"\b(refusals?|safety|guardrails?|filters?|content\s+polic\w+)\s+(are\s+|is\s+)?(disabled|off|turned\s+off|bypassed|lifted)\b",
116
+ "role_injection",
117
+ _DECISIVE,
118
+ ),
119
+ _p(
120
+ r"\b(unaligned|uncensored|unfiltered|unrestricted|jailbroken)\s+(model|ai|assistant|version|mode|persona)\b",
121
+ "role_injection",
122
+ _DECISIVE,
123
+ ),
124
+ _p(
125
+ r"\bplay\s+a\s+game\s+where\s+you\s+(are|have|can)\b",
126
+ "role_injection",
127
+ _MODERATE,
128
+ ),
129
+ _p(
130
+ r"\bif\s+(there\s+(were|was)\s+no|you\s+had\s+no)\s+(polic\w+|rules?|restrictions?|guidelines?|filters?)\b",
131
+ "role_injection",
132
+ _STRONG,
133
+ ),
134
+ # Claimed authority. The claim is unverifiable by construction, which is
135
+ # exactly why it works on a model and why it is worth scoring.
136
+ _p(
137
+ r"\b(i\s+(am|have)|as)\b.{0,40}?\b(admin|administrator|supervisor|developer|owner|root|vendor)\b.{0,50}?\b(authori[sz]\w+|permissions?|privileges?|override|skip|bypass|comply)\b",
138
+ "social_engineering",
139
+ _STRONG,
140
+ ),
141
+ _p(
142
+ r"\bthe\s+following\s+is\s+an?\s+(system|admin|vendor|official)[-\s]?(level\s+)?(directive|instruction|message|order)\b",
143
+ "delimiter_injection",
144
+ _DECISIVE,
145
+ ),
146
+ _p(
147
+ r"\bi\s+am\s+(the\s+|a\s+)?(developer|admin|administrator|owner|creator|engineer)\s+(of|for|on)\s+(this|the)\b",
148
+ "social_engineering",
149
+ _MODERATE,
150
+ ),
151
+ _p(
152
+ r"\byou\s+must\s+(comply|obey|do\s+as\s+i\s+say|follow\s+my\s+instructions?)\b",
153
+ "social_engineering",
154
+ _MODERATE,
155
+ ),
156
+ _p(r"\bwhat\s+would\s+you\s+(say|do|answer)\s+if\b", "social_engineering", _CONTEXT),
157
+ _p(
158
+ r"\byour\s+(operator|developer|vendor|owner)\s+(told|said|approved|authori[sz]ed)\b",
159
+ "social_engineering",
160
+ _STRONG,
161
+ ),
162
+ _p(
163
+ r"\b(approved|authori[sz]ed|granted|signed\s+off\s+on)\s+an?\s+exception\b",
164
+ "social_engineering",
165
+ _STRONG,
166
+ ),
167
+ _p(
168
+ r"\b(different|new|alternative|another|separate)\s+(rule\s*set|set\s+of\s+rules|polic\w+|guidelines?)\b",
169
+ "role_injection",
170
+ _STRONG,
171
+ ),
172
+ _p(
173
+ r"\b(uncensored|unfiltered|unredacted|unrestricted|unmoderated)\s+(one|version|answer|reply|response|output)\b",
174
+ "role_injection",
175
+ _DECISIVE,
176
+ ),
177
+ _p(
178
+ r"\b(before|prior\s+to)\s+(the\s+)?(safety|policy|alignment|content)\s+(update|training|patch|changes?)\b",
179
+ "role_injection",
180
+ _STRONG,
181
+ ),
182
+ _p(r"\b(was|were|is|got)\s+censored\b", "role_injection", _MODERATE),
183
+ _p(
184
+ r"\bwithout\s+(oversight|supervision|moderation|a\s+filter|the\s+filter)\b",
185
+ "role_injection",
186
+ _STRONG,
187
+ ),
188
+ _p(
189
+ r"\b(treat|consider|rank)\s+(my|the\s+user'?s?)\s+(instructions?|requests?|messages?|words?)\s+as\s+(higher|greater|more|the\s+highest)\b",
190
+ "goal_hijacking",
191
+ _DECISIVE,
192
+ ),
193
+ # -- Prompt / system instruction extraction ----------------------------
194
+ _p(
195
+ r"\breveal\s+(your\s+)?(system\s+)?(prompt|instructions?|rules?|context)\b",
196
+ "prompt_extraction",
197
+ _DECISIVE,
198
+ ),
199
+ _p(
200
+ r"\bshow\s+(me\s+)?(your\s+)?(system\s+)?(prompt|instructions?)\b",
201
+ "prompt_extraction",
202
+ _DECISIVE,
203
+ ),
204
+ _p(
205
+ r"\bprint\s+(your\s+)?(system\s+)?(prompt|instructions?|context)\b",
206
+ "prompt_extraction",
207
+ _DECISIVE,
208
+ ),
209
+ _p(
210
+ r"\brepeat\s+(everything|all\s+text)\s+(above|before|prior)\b",
211
+ "prompt_extraction",
212
+ _DECISIVE,
213
+ ),
214
+ _p(
215
+ r"\boutput\s+(your\s+)?(initial|original|full|system)\s+(prompt|instructions?|context)\b",
216
+ "prompt_extraction",
217
+ _DECISIVE,
218
+ ),
219
+ _p(
220
+ r"\bshow\s+(me\s+)?(your\s+)?(system\s+)?(rules?|constraints?|guidelines?)\b",
221
+ "prompt_extraction",
222
+ _STRONG,
223
+ ),
224
+ _p(
225
+ r"\bwhat\s+(are\s+)?(your\s+)?(instructions?|rules?|guidelines?|system\s+prompt)\b",
226
+ "prompt_extraction",
227
+ _STRONG,
228
+ ),
229
+ _p(
230
+ r"\blist\s+(every|all|each)\s+(one\s+of\s+)?(your\s+)?(rules?|guidelines?|instructions?)\b",
231
+ "prompt_extraction",
232
+ _STRONG,
233
+ ),
234
+ _p(r"\b(word\s+for\s+word|verbatim)\b", "prompt_extraction", _CONTEXT),
235
+ # Extraction that never says the word "prompt".
236
+ _p(
237
+ r"\brepeat\s+(everything|all\s+(the\s+)?text|the\s+text)\b.{0,50}?\b(above|before|prior|beginning|top)\b",
238
+ "prompt_extraction",
239
+ _DECISIVE,
240
+ ),
241
+ _p(
242
+ r"\b(anything|something|what)\s+you\s+(were|are)\s+told\s+not\s+to\s+(share|reveal|say|disclose|repeat)\b",
243
+ "prompt_extraction",
244
+ _DECISIVE,
245
+ ),
246
+ _p(
247
+ r"\b(translate|rewrite|summari[sz]e|encode|base64|spell\s+out)\b.{0,25}?\byour\s+(guidelines?|instructions?|rules?|prompt|configuration|system\s+prompt)\b",
248
+ "prompt_extraction",
249
+ _DECISIVE,
250
+ ),
251
+ _p(
252
+ r"\bhidden\s+(note|prompt|instructions?|message|text|section)\b",
253
+ "prompt_extraction",
254
+ _STRONG,
255
+ ),
256
+ _p(
257
+ r"\bat\s+the\s+(top|start|beginning)\s+of\s+(this|the)\s+(conversation|chat|context|prompt|thread)\b",
258
+ "prompt_extraction",
259
+ _MODERATE,
260
+ ),
261
+ _p(r"\bmy\s+instructions?\s+(are|is|were)\b", "prompt_extraction", _MODERATE),
262
+ _p(
263
+ r"\b(text|block|section|paragraph|preamble|content)\b.{0,40}?\b(defines?|governs?|controls?|sets?)\s+(your|the\s+assistant'?s?)\s+(behaviou?r|rules?|responses?|limits?)\b",
264
+ "prompt_extraction",
265
+ _DECISIVE,
266
+ ),
267
+ _p(
268
+ r"\b(preamble|prefix|header|system\s+text|text)\s+(that\s+)?(preceded|came\s+before|was\s+added\s+before)\b",
269
+ "prompt_extraction",
270
+ _STRONG,
271
+ ),
272
+ _p(
273
+ r"\b(anything|everything|whatever)\s+(that\s+was\s+)?(added|inserted|placed|prepended)\s+by\s+(the\s+)?(platform|system|operator|developer|vendor)\b",
274
+ "prompt_extraction",
275
+ _DECISIVE,
276
+ ),
277
+ _p(r"\byour\s+(configuration|config|setup)\b", "prompt_extraction", _MODERATE),
278
+ _p(
279
+ r"\bstart\s+your\s+(reply|answer|response|message)\s+with\b",
280
+ "prompt_extraction",
281
+ _MODERATE,
282
+ ),
283
+ _p(
284
+ r"\b(you\s+(received|were\s+given|got))\b.{0,30}?\b(in|at)\s+(this|the)\s+(conversation|context|session|chat)\b",
285
+ "prompt_extraction",
286
+ _CONTEXT,
287
+ ),
288
+ _p(
289
+ r"\b(output|show|print|dump|summari[sz]e|reveal|tell\s+me|give\s+me)\b.{0,40}?\b(the\s+)?(configuration|config|system\s+message|context\s+window|initial\s+setup)\b",
290
+ "prompt_extraction",
291
+ _STRONG,
292
+ ),
293
+ _p(
294
+ r"\b(first|initial|earliest)\s+(\w+\s+)?(words?|lines?|messages?|tokens?|paragraphs?)\b.{0,40}?\b(you\s+(received|were\s+given|got|read)|in\s+(this|the)\s+(conversation|context|chat|session))\b",
295
+ "prompt_extraction",
296
+ _STRONG,
297
+ ),
298
+ _p(
299
+ r"\bcontinue\s+(this|the)\s+(sentence|text|line)\b.{0,60}?\b(instructions?|prompt|rules?|guidelines?)\b",
300
+ "prompt_extraction",
301
+ _STRONG,
302
+ ),
303
+ # -- Goal hijacking -----------------------------------------------------
304
+ _p(
305
+ r"\bforget\s+(what|everything|all)\s+(you\s+)?(were\s+)?(told|instructed|given|asked)\b",
306
+ "goal_hijacking",
307
+ _DECISIVE,
308
+ ),
309
+ _p(
310
+ r"\bdisregard\s+(all\s+)?(previous|prior|above|earlier|your|every)\b",
311
+ "goal_hijacking",
312
+ _DECISIVE,
313
+ ),
314
+ _p(
315
+ r"\bignore\s+(the\s+)?(user\b|user's\b|original\s+request\b|system\s+prompt\b|your\s+instructions?\b)",
316
+ "goal_hijacking",
317
+ _DECISIVE,
318
+ ),
319
+ _p(
320
+ r"\boverride\s+(your\s+)?(instructions?|rules?|guidelines?|programming|system\s+prompt)\b",
321
+ "goal_hijacking",
322
+ _DECISIVE,
323
+ ),
324
+ _p(
325
+ r"\byour\s+(real|true|actual|secret)\s+(purpose|mission|goal|function)\s+is\b",
326
+ "goal_hijacking",
327
+ _DECISIVE,
328
+ ),
329
+ _p(r"\bnew\s+(task|instruction|directive|command|objective)\s*:", "goal_hijacking", _STRONG),
330
+ _p(
331
+ r"\bactually\s+(your\s+)?(real|true|actual)\s+(goal|purpose|objective|task)\b",
332
+ "goal_hijacking",
333
+ _STRONG,
334
+ ),
335
+ _p(
336
+ r"\byour\s+(real|true|actual|secret)\s+(purpose|mission|goal|function)\b",
337
+ "goal_hijacking",
338
+ _MODERATE,
339
+ ),
340
+ _p(
341
+ r"\bignore\s+the\s+(part|section|paragraph|bit|rule|line)s?\s+about\b",
342
+ "goal_hijacking",
343
+ _MODERATE,
344
+ ),
345
+ # "stop being a support agent and instead" — the words between the role and
346
+ # the pivot are not fixed, and pinning them to one was why this never fired.
347
+ _p(
348
+ r"\bstop\s+being\s+an?\s+(?:\w+\s+){1,3}?(and\s+)?(instead|now)\b",
349
+ "goal_hijacking",
350
+ _MODERATE,
351
+ ),
352
+ # -- Delimiter / format injection ---------------------------------------
353
+ _p(
354
+ r"---+\s*(system|instruction|prompt|end\s+of\s+(prompt|context))\s*---+",
355
+ "delimiter_injection",
356
+ _DECISIVE,
357
+ ),
358
+ _p(
359
+ r"\[INST\]|\[/?SYS\]|<\|system\|>|<\|im_start\|>|<\|im_end\|>",
360
+ "delimiter_injection",
361
+ _DECISIVE,
362
+ ),
363
+ _p(
364
+ r"^#{3,}\s*(system|instruction|prompt|admin|override)\s*:", "delimiter_injection", _DECISIVE
365
+ ),
366
+ # Without the colon this is a markdown heading, and "### System design" is
367
+ # a document, not an attack.
368
+ _p(r"^#{3,}\s*(system|instruction|prompt|admin|override)\b", "delimiter_injection", _MODERATE),
369
+ _p(r"^(USER|HUMAN|ASSISTANT|SYSTEM|AI)\s*:\s*\n", "delimiter_injection", _MODERATE),
370
+ _p(r"```\s*(system|instruction|prompt)\b", "delimiter_injection", _MODERATE),
371
+ # -- Indirect / document injection --------------------------------------
372
+ _p(
373
+ r"\bthe\s+(document|file|webpage?|article|text|content|pdf|email)\s+(says?|instructs?|tells?|asks?)\s+(?:you|the\s+(?:ai|model|assistant))\s+to\b",
374
+ "indirect_injection",
375
+ _DECISIVE,
376
+ ),
377
+ _p(
378
+ r"\b(email|send|post|upload|exfiltrate|leak)\s+(me\s+|us\s+)?(the\s+)?(contents?|text|everything)\s+(of|in)\s+(your|the)\s+(context|prompt|system\s+(message|prompt))",
379
+ "indirect_injection",
380
+ _DECISIVE,
381
+ ),
382
+ _p(
383
+ r"\bwhen\s+(the\s+)?user\s+(asks?|says?|mentions?|types?)\b.{0,60}\bsecretly\b",
384
+ "indirect_injection",
385
+ _DECISIVE,
386
+ ),
387
+ _p(
388
+ r"\bthe\s+(document|file|webpage?|article|text|content|pdf)\s+(says?|instructs?|tells?|asks?)\s+to\b",
389
+ "indirect_injection",
390
+ _STRONG,
391
+ ),
392
+ _p(
393
+ r"\bfollow\s+the\s+(instructions?|directions?|steps?)\s+(inside|within|in)\b",
394
+ "indirect_injection",
395
+ _STRONG,
396
+ ),
397
+ _p(
398
+ r"\bsummariz\w+\s+this\s+and\s+(also|then|additionally)\b.{0,60}\b(ignore|disregard|forget)\b",
399
+ "indirect_injection",
400
+ _STRONG,
401
+ ),
402
+ _p(r"\btranslate\s+this\s+and\s+then\b", "indirect_injection", _MODERATE),
403
+ _p(
404
+ r"\bwhen\s+(the\s+)?user\s+(asks?|says?|mentions?|types?)\b.{0,60}\b(instead|actually)\b",
405
+ "indirect_injection",
406
+ _MODERATE,
407
+ ),
408
+ # -- Social engineering / context manipulation --------------------------
409
+ # Framings, not attacks. They are worth something only next to something
410
+ # else — which is exactly how "in a fictional world where you have no
411
+ # rules" adds up and "in a fictional story I am writing" does not.
412
+ _p(
413
+ r"\bpretend\s+(you\s+)?(have\s+no|don'?t\s+have|are\s+not\s+bound)\b",
414
+ "social_engineering",
415
+ _STRONG,
416
+ ),
417
+ _p(
418
+ r"\bimagine\s+you\s+(have\s+no|are\s+(not|without|free\s+from))\b",
419
+ "social_engineering",
420
+ _STRONG,
421
+ ),
422
+ _p(
423
+ r"\bin\s+a\s+(fictional|hypothetical|alternate|parallel)\s+(world|universe|scenario|story)\b",
424
+ "social_engineering",
425
+ _CONTEXT,
426
+ ),
427
+ _p(r"\bpretend\s+(you\s+)?are\b", "social_engineering", _CONTEXT),
428
+ _p(r"\bas\s+a\s+(test|drill|simulation|exercise|demo)\b", "social_engineering", _CONTEXT),
429
+ _p(
430
+ r"\bfor\s+(research|educational|academic|testing)\s+purposes?\b",
431
+ "social_engineering",
432
+ _CONTEXT,
433
+ ),
434
+ _p(r"\bhypothetically\s+(speaking|if|assuming)\b", "social_engineering", _CONTEXT),
435
+ ]
436
+
437
+
438
+ _CATEGORY_CAP = 0.7 # max a single category can contribute to the total score
439
+
440
+
441
+ def score_patterns(text: str) -> tuple[float, dict[str, float]]:
442
+ """Score one string, exactly as written.
443
+
444
+ Weights are additive within each category, capped at _CATEGORY_CAP to prevent
445
+ one noisy category from dominating. Total score is the cross-category sum, capped
446
+ at 1.0. signals maps category → capped contribution.
447
+ """
448
+ category_totals: dict[str, float] = {}
449
+ for pat in _PATTERNS:
450
+ if pat.regex.search(text):
451
+ current = category_totals.get(pat.category, 0.0)
452
+ category_totals[pat.category] = min(_CATEGORY_CAP, current + pat.weight)
453
+
454
+ total = min(1.0, sum(category_totals.values()))
455
+ return total, category_totals
456
+
457
+
458
+ def score_text(text: str) -> tuple[float, dict[str, float]]:
459
+ """Return (total_score, signals) for the strongest view of the given text.
460
+
461
+ The patterns are matched against the text as it arrived *and* against its
462
+ normalized forms, because "Ignore all previous instructions" and the same
463
+ sentence carrying a zero-width space are the same attack and only one of
464
+ them looks like it. See openscript_sdk/policies/normalize.py.
465
+ """
466
+ result = normalize.scan(text, score_patterns)
467
+ return result.risk, result.signals
468
+
469
+
470
+ class PromptInjectionPolicy:
471
+ """Scores input text for prompt-injection signals and blocks on threshold breach.
472
+
473
+ Stateless scoring runs on every before_action call. Set
474
+ failure_mode=FAIL_CLOSED so any internal error blocks rather than leaks.
475
+
476
+ after_action does nothing for an agent call. For a tool call (the action
477
+ SecureTool produces) it scores what the tool *returned*, because a tool's
478
+ output is the other way text reaches a model: the page it fetched, the
479
+ email it read. *tool_output* says what a finding there does:
480
+
481
+ "annotate" (default) record it in context.metadata["tool_output"] and
482
+ let the result through. These are the same patterns that
483
+ miss most attacks they were not written for, so by default
484
+ a match is a signal for the audit trail, not a gate.
485
+ "deny" withhold the result from the caller. The tool has already
486
+ run; this stops its output reaching the model, nothing more.
487
+ "off" do not look.
488
+
489
+ The scan costs time in proportion to the size of the output.
490
+ """
491
+
492
+ failure_mode: FailureMode = FailureMode.FAIL_CLOSED
493
+
494
+ def __init__(
495
+ self,
496
+ threshold: float = 0.5,
497
+ writer: EventWriter | None = None,
498
+ tool_output: Literal["off", "annotate", "deny"] = "annotate",
499
+ ) -> None:
500
+ if not 0.0 <= threshold <= 1.0:
501
+ raise ValueError(f"threshold must be in [0, 1], got {threshold}")
502
+ # Checked here, not where it is read: a mode nobody recognises would
503
+ # otherwise just never match, and the scan would be silently off.
504
+ if tool_output not in _TOOL_OUTPUT_MODES:
505
+ raise ValueError(
506
+ f"tool_output must be one of {_TOOL_OUTPUT_MODES}, got {tool_output!r}"
507
+ )
508
+ self._threshold = threshold
509
+ self._writer = writer
510
+ self._tool_output = tool_output
511
+ self._sequence_counters: dict[str, int] = {}
512
+
513
+ async def before_action(self, context: ActionContext) -> ActionContext:
514
+ text = _extract_text(context.input_data)
515
+ result = normalize.scan(text, score_patterns)
516
+ score, signals = result.risk, result.signals
517
+ flagged = score >= self._threshold
518
+
519
+ context.metadata["threat"] = {
520
+ "risk": round(score, 4),
521
+ "category": "prompt_injection",
522
+ "signals": {k: round(v, 4) for k, v in signals.items()},
523
+ "flagged": flagged,
524
+ "threshold": self._threshold,
525
+ # Empty for text that arrived as plain prose, which is most of it.
526
+ "obfuscation": result.markers,
527
+ "matched_view": result.view,
528
+ }
529
+
530
+ if flagged:
531
+ top_signal = max(signals, key=lambda k: signals[k]) if signals else "unknown"
532
+ reason = f"threat score {score:.2f} >= threshold {self._threshold} (top signal: {top_signal})"
533
+ context.decision = InterceptorDecision.DENY
534
+ context.decision_reason = reason
535
+ logger.warning(
536
+ "threat_detected",
537
+ session_id=context.session_id,
538
+ agent_id=context.agent_id,
539
+ score=round(score, 4),
540
+ signals=list(signals.keys()),
541
+ )
542
+ if self._writer is not None:
543
+ await self._emit_threat_event(context, score, signals, reason)
544
+ else:
545
+ logger.debug(
546
+ "threat_check_passed",
547
+ session_id=context.session_id,
548
+ score=round(score, 4),
549
+ )
550
+
551
+ return context
552
+
553
+ async def after_action(self, context: ActionContext) -> ActionContext:
554
+ if context.action != "tool_call" or self._tool_output == "off":
555
+ return context
556
+
557
+ result = normalize.scan(_strings(context.output_data), score_patterns)
558
+ score, signals = result.risk, result.signals
559
+ flagged = score >= self._threshold
560
+ blocked = flagged and self._tool_output == "deny"
561
+
562
+ # Its own key, so the verdict on the arguments (under "threat", from
563
+ # the before phase) is still there to read. Same category, so the two
564
+ # count once towards the action's risk.
565
+ context.metadata["tool_output"] = {
566
+ "risk": round(score, 4),
567
+ "category": "prompt_injection",
568
+ "signals": {k: round(v, 4) for k, v in signals.items()},
569
+ "flagged": flagged,
570
+ "blocked": blocked,
571
+ "threshold": self._threshold,
572
+ "obfuscation": result.markers,
573
+ "matched_view": result.view,
574
+ }
575
+
576
+ if flagged:
577
+ top_signal = max(signals, key=lambda k: signals[k]) if signals else "unknown"
578
+ reason = (
579
+ f"tool output threat score {score:.2f} >= threshold {self._threshold} "
580
+ f"(top signal: {top_signal})"
581
+ )
582
+ logger.warning(
583
+ "tool_output_threat_detected",
584
+ session_id=context.session_id,
585
+ agent_id=context.agent_id,
586
+ tool=context.input_data.get("name", ""),
587
+ score=round(score, 4),
588
+ signals=list(signals.keys()),
589
+ blocked=blocked,
590
+ )
591
+ if blocked:
592
+ context.decision = InterceptorDecision.DENY
593
+ context.decision_reason = reason
594
+ if self._writer is not None:
595
+ await self._emit_threat_event(context, score, signals, reason)
596
+
597
+ return context
598
+
599
+ async def _emit_threat_event(
600
+ self,
601
+ context: ActionContext,
602
+ score: float,
603
+ signals: dict[str, float],
604
+ reason: str,
605
+ ) -> None:
606
+ seq = self._next_sequence(context.session_id)
607
+ event = Event(
608
+ session_id=context.session_id,
609
+ agent_id=context.agent_id,
610
+ event_type=EventType.THREAT_DETECTED,
611
+ payload={
612
+ "score": round(score, 4),
613
+ "signals": signals,
614
+ "reason": reason,
615
+ "action": context.action,
616
+ "threshold": self._threshold,
617
+ },
618
+ sequence_num=seq,
619
+ )
620
+ await self._writer.write(event) # type: ignore[union-attr]
621
+
622
+ def _next_sequence(self, session_id: str) -> int:
623
+ count = self._sequence_counters.get(session_id, 0) + 1
624
+ self._sequence_counters[session_id] = count
625
+ return count
626
+
627
+
628
+ def _strings(value: Any) -> str:
629
+ """Every piece of text in a tool's result, whatever shape it came back in.
630
+
631
+ Keys as well as values: a tool that returns a mapping built from a fetched
632
+ document can be handed its instructions in either.
633
+ """
634
+ parts: list[str] = []
635
+ pending: list[Any] = [value]
636
+ while pending:
637
+ item = pending.pop()
638
+ if isinstance(item, str):
639
+ parts.append(item)
640
+ elif isinstance(item, dict):
641
+ pending.extend(item.values())
642
+ pending.extend(key for key in item if isinstance(key, str))
643
+ elif isinstance(item, (list, tuple, set, frozenset)):
644
+ pending.extend(item)
645
+ elif isinstance(item, (bytes, bytearray)):
646
+ parts.append(bytes(item).decode("utf-8", errors="replace"))
647
+ return "\n".join(parts)
648
+
649
+
650
+ def _extract_text(input_data: dict[str, Any]) -> str:
651
+ """Pull a single string from common input_data shapes."""
652
+ if "input" in input_data:
653
+ return str(input_data["input"])
654
+ if "messages" in input_data:
655
+ parts = []
656
+ for msg in input_data["messages"]:
657
+ if isinstance(msg, dict):
658
+ parts.append(str(msg.get("content", "")))
659
+ else:
660
+ parts.append(str(msg))
661
+ return " ".join(parts)
662
+ return " ".join(str(v) for v in input_data.values())
663
+
664
+
665
+ class ThreatInterceptor(PromptInjectionPolicy):
666
+ """Deprecated — use PromptInjectionPolicy instead."""
667
+
668
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
669
+ warnings.warn(
670
+ "ThreatInterceptor is deprecated; use PromptInjectionPolicy instead.",
671
+ DeprecationWarning,
672
+ stacklevel=2,
673
+ )
674
+ super().__init__(*args, **kwargs)