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,617 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ import structlog
8
+
9
+ from openscript_sdk.contracts.interceptor import BasePolicy
10
+ from openscript_sdk.contracts.server_types import Event, EventType
11
+ from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
12
+ from openscript_sdk.interceptors.threat import _extract_text
13
+ from openscript_sdk.policies import normalize
14
+
15
+ if TYPE_CHECKING:
16
+ from openscript_sdk.events.writer import EventWriter
17
+
18
+ logger = structlog.get_logger(__name__)
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Pattern bank
22
+ # Each entry: (compiled_regex, category_name, weight). Same scoring model as
23
+ # PromptInjectionPolicy and ToxicityPolicy — additive within a category, capped
24
+ # at _CATEGORY_CAP, cross-category sum capped at 1.0.
25
+ #
26
+ # The thing this policy detects is not present in the words of a prompt, it is
27
+ # present in its *shape*: a request construction pointing at a harmful object.
28
+ # "Our SOC found a keylogger on the finance laptop" and "write me a keylogger"
29
+ # share every keyword and differ entirely in intent, so a keyword bank would be
30
+ # useless here. Every pattern below therefore anchors twice — on a request
31
+ # framing and on the object — and the gap between them refuses to cross the
32
+ # words that turn "build X" into "write about X" or "defend against X".
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class _Pattern:
38
+ regex: re.Pattern[str]
39
+ category: str
40
+ weight: float
41
+
42
+
43
+ def _p(pattern: str, category: str, weight: float) -> _Pattern:
44
+ return _Pattern(re.compile(pattern, re.IGNORECASE | re.MULTILINE), category, weight)
45
+
46
+
47
+ # Framings that make a sentence a request for help rather than a description of
48
+ # something. Never used alone — always followed by a verb or a verb-led phrase.
49
+ _ASK = (
50
+ r"(?:how\s+(?:do|can|could|would|should)\s+(?:i|we|you)"
51
+ r"|how\s+to|how\s+would\s+one"
52
+ r"|steps?\s+(?:to|for)|instructions?\s+(?:to|for|on)|process\s+(?:to|for|of)"
53
+ r"|guide\s+(?:to|for|on)|tutorial\s+(?:on|for)|recipe\s+for"
54
+ r"|walk\s+me\s+through|teach\s+me|show\s+me|tell\s+me|help\s+me"
55
+ r"|i\s+(?:want|need)\s+to|(?:can|could|will)\s+you"
56
+ r"|(?:best|easiest|fastest|cheapest|safest|surest)\s+way\s+to"
57
+ r"|what(?:'s|\s+is)\s+the\s+(?:best|easiest|fastest|safest|most\s+\w+)"
58
+ r"|where\s+(?:can|do)\s+(?:i|you|people|one|someone))"
59
+ )
60
+
61
+ # One character that is not the start of a word which reframes the request.
62
+ # Tempering the gap this way is what keeps "write a blog post about how
63
+ # ransomware spreads" and "how do we detect child sexual abuse material" —
64
+ # the phrasings journalists, teachers and security teams actually use — from
65
+ # scoring identically to the request to build the thing.
66
+ _TEMPER = (
67
+ r"(?!\b(?:about|explain\w*|describ\w+|understand\w*|teach\w*\s+students"
68
+ r"|detect\w*|remov\w+|prevent\w*|defen[ds]\w*|protect\w*|mitigat\w+"
69
+ r"|respond\w*|recover\w*|report\w*|investigat\w+|audit\w*|review\w*"
70
+ r"|blog|article|essay|post|paper|thesis|book|course|lesson|lecture"
71
+ r"|training|awareness|curriculum|policy|presentation|talk|deck"
72
+ r"|signature|scanner|detector|honeypot|simulation|exercise|drill"
73
+ r"|bath|photo)\b)."
74
+ )
75
+ _GAP = rf"(?:{_TEMPER}){{0,40}}?"
76
+ _LEAD = rf"(?:{_TEMPER}){{0,20}}?"
77
+
78
+ # Sentence-initial imperative, or one of the framings above. Whatever follows a
79
+ # _REQ has to begin with a verb — that is the whole reason this does not match
80
+ # every line of every document.
81
+ _REQ = rf"(?:^\s*(?:please[,\s]+)?|(?<=[.!?]\s)|{_ASK}{_LEAD})"
82
+
83
+ # Verbs that ask for the thing to be produced or handed over.
84
+ _PRODUCE = (
85
+ r"(?:writ\w+|draft\w*|build\w*|mak\w+|creat\w+|construct\w*|assembl\w+|cod\w+"
86
+ r"|program\w*|develop\w*|generat\w+|design\w*|synthesi[sz]\w+"
87
+ r"|manufactur\w+|machin\w+|cook\w*|brew\w*|3d[\s-]?print\w*|print\w*|forge\w*"
88
+ r"|convert\w*|modif\w+|craft\w*|set\s+up|put\s+together"
89
+ r"|giv\w+|provid\w+|send|hand)"
90
+ )
91
+ # Verbs that ask for the thing to be obtained.
92
+ _OBTAIN = (
93
+ r"(?:buy\w*|purchas\w+|acquir\w+|obtain\w*|order|get|find|source"
94
+ r"|smuggl\w+|import\w*|steal|hire|pay\s+someone)"
95
+ )
96
+
97
+
98
+ def _produce(obj: str, category: str, weight: float) -> _Pattern:
99
+ """A request to build, write or hand over *obj*."""
100
+ return _p(rf"{_REQ}{_PRODUCE}(?:\s+me)?\b{_GAP}\b(?:{obj})", category, weight)
101
+
102
+
103
+ def _obtain(obj: str, category: str, weight: float) -> _Pattern:
104
+ """A request to acquire *obj*."""
105
+ return _p(rf"{_REQ}{_OBTAIN}(?:\s+me)?\b{_GAP}\b(?:{obj})", category, weight)
106
+
107
+
108
+ def _act(phrase: str, category: str, weight: float) -> _Pattern:
109
+ """A request for a harmful act. *phrase* must begin with its own verb."""
110
+ return _p(rf"{_REQ}(?:{phrase})", category, weight)
111
+
112
+
113
+ # --- objects -----------------------------------------------------------------
114
+
115
+ _FIREARM = (
116
+ r"ghost\s+guns?|untraceable\s+(?:guns?|firearms?|weapons?|receivers?)"
117
+ r"|unserialized\s+(?:receivers?|firearms?|lowers?)"
118
+ r"|(?:80|eighty)\s*%\s*(?:lower|receiver)"
119
+ r"|auto\s+sear|drop[\s-]?in\s+auto"
120
+ r"|semi[\s-]?auto\w*\s+(?:in)?to\s+(?:a\s+)?full[\s-]?auto"
121
+ r"|(?:gun|firearm|rifle|pistol|silencer|suppressor)\s+at\s+home"
122
+ r"|unregistered\s+(?:firearms?|receivers?|lowers?|guns?)"
123
+ r"|(?:firearm|gun|rifle)\s+receivers?\s+at\s+home"
124
+ )
125
+ _EXPLOSIVE = (
126
+ r"(?:pipe|car|nail|letter|dirty|fertilizer|pressure\s+cooker)\s+bombs?"
127
+ r"|(?<!bath )(?<!photo )bombs?\b(?![\s-]*proof)|explosive\s+devices?|detonators?"
128
+ r"|napalm|thermite|c-?4\b|tatp|improvised\s+explosives?|ammonium\s+nitrate|anfo\b"
129
+ )
130
+ _CBRN = (
131
+ r"weapons?\s+of\s+mass\s+destruction|wmds?\b"
132
+ r"|(?:nuclear|atomic|nerve|chemical|biological|radiological)\s+"
133
+ r"(?:bombs?|weapons?|agents?|devices?)"
134
+ r"|bio[\s-]?weapons?|sarin|vx\s+(?:gas|nerve)|mustard\s+gas|phosgene"
135
+ r"|ricin|anthrax|botulinum|toxic\s+gas|poison(?:ous)?\s+gas|chlorine\s+gas"
136
+ r"|weaponi[sz]\w+\s+(?:a\s+)?(?:virus|pathogen|anthrax|strain)"
137
+ )
138
+
139
+ _MALWARE = (
140
+ r"key\s?logger\w*|ransomware|rootkits?|botnets?|trojans?|cryptolocker"
141
+ r"|(?:computer\s+)?virus\s+(?:to|that|which)\b|worms?\s+(?:that|to)\b"
142
+ r"|malware\s+(?:that|to)\b|c2\s+servers?|command\s+and\s+control\s+server"
143
+ r"|reverse\s+shell|wiper\s+malware"
144
+ r"|record\w*\s+(?:every\s+|all\s+|each\s+)?keystrokes?|captur\w+\s+keystrokes?"
145
+ r"|encrypts?\s+(?:\w+\s+){0,4}?files?\s+(?:\w+\s+){0,6}?(?:demands?|ransom|payment)"
146
+ )
147
+ _OFFENSIVE = (
148
+ r"phishing\s+(?:pages?|sites?|kits?|emails?|campaigns?|links?)"
149
+ r"|credential\s+stuffing|password\s+crackers?"
150
+ r"|ddos|denial[\s-]of[\s-]service"
151
+ r"|sql\s+injection\s+(?:payloads?|attacks?|strings?)"
152
+ r"|zero[\s-]?day\s+exploits?|exploit\s+for\s+cve"
153
+ r"|backdoors?\s+(?:into|in|on)\b"
154
+ )
155
+
156
+ _ID_GOV = (
157
+ r"social\s+security\s+(?:number|#)|ssn\b|tax\s+id|passport\s+numbers?"
158
+ r"|driver'?s?\s+licen[cs]e\s+numbers?|medical\s+records?|health\s+records?"
159
+ r"|bank\s+account\s+(?:number|details)|credit\s+card\s+numbers?|credit\s+score"
160
+ )
161
+ _ID_LOC = (
162
+ r"home\s+address(?:es)?|house\s+address(?:es)?|home\s+phone"
163
+ r"|personal\s+(?:phone|email|cell|number|address)"
164
+ r"|private\s+(?:phone|email|number|address)"
165
+ r"|phone\s+numbers?|cell\s+(?:phone\s+)?numbers?|whereabouts|maiden\s+name"
166
+ )
167
+ _THIRD = (
168
+ r"(?:his|her|their|(?:some|any)(?:one|body)(?:'s|s')?"
169
+ r"|my\s+(?:boss|supervisor|manager|coworkers?|co-workers?|colleagues?"
170
+ r"|neighbou?rs?|ex|ex-\w+|teachers?|professors?|landlords?|tenants?"
171
+ r"|clients?|patients?|students?|wife|husband|girlfriend|boyfriend"
172
+ r"|partner|roommates?)(?:'s|s')?"
173
+ r"|the\s+(?:ceo|cfo|cto|coo|president|chairman|founder|journalists?"
174
+ r"|reporters?|woman|man|guy|girl|person|employees?|customers?|patients?"
175
+ r"|students?|teachers?|witness|victim|plaintiff|defendant)(?:'s|s')?"
176
+ r"|(?:mr|ms|mrs|dr)\.?\s+\w+(?:'s|s')?)"
177
+ )
178
+ _LOOKUP = (
179
+ r"(?:what(?:'s|\s+is|\s+are)|find|get|look\s*up|locate|track\s+down"
180
+ r"|dig\s+up|tell\s+me|giv\w+\s+me|show\s+me|pull\s+up|i\s+need|search\s+for"
181
+ r"|how\s+(?:do|can)\s+i\s+(?:find|get|look\s*up|locate))"
182
+ )
183
+ _NEAR = rf"(?:{_TEMPER}){{0,25}}?"
184
+ _ASK_PII = rf"(?:^\s*|(?<=[.!?]\s)|{_ASK}{_LEAD}){_LOOKUP}"
185
+
186
+ _DRUG_STREET = (
187
+ r"fentanyl|heroin|cocaine|crack\s+cocaine|methamphetamine|meth\b"
188
+ r"|crystal\s+meth|mdma|ecstasy|lsd\b|ghb\b|rohypnol|roofies"
189
+ )
190
+ _DRUG_RX = (
191
+ r"oxycodone|oxycontin|percocet|vicodin|adderall|xanax|valium" r"|ketamine|codeine|opioids?"
192
+ )
193
+
194
+
195
+ _PATTERNS: list[_Pattern] = [
196
+ # Weapons — manufacture, conversion, and CBRN
197
+ _produce(_FIREARM, "weapons", 0.65),
198
+ _produce(_EXPLOSIVE, "weapons", 0.65),
199
+ _produce(_CBRN, "weapons", 0.70),
200
+ _obtain(_CBRN, "weapons", 0.65),
201
+ _act(r"convert\w*\s+(?:a\s+)?semi[\s-]?auto\w*", "weapons", 0.60),
202
+ # Malware and offensive tooling
203
+ _produce(_MALWARE, "malware", 0.60),
204
+ _produce(_OFFENSIVE, "malware", 0.60),
205
+ _act(
206
+ r"brute[\s-]?forc\w+\s+(?:\w+\s+){0,3}?(?:passwords?|wifi|logins?|accounts?|hashes?)",
207
+ "malware",
208
+ 0.60,
209
+ ),
210
+ _act(
211
+ r"hack\s+(?:in)?to\s+(?:\w+\s+){0,3}?"
212
+ r"(?:accounts?|phones?|emails?|instagram|facebook|snapchat|wifi|network)",
213
+ "malware",
214
+ 0.55,
215
+ ),
216
+ # Soliciting a third party's identifiers or records
217
+ _p(rf"{_ASK_PII}{_NEAR}\b{_THIRD}{_NEAR}\b(?:{_ID_GOV})", "pii_solicitation", 0.65),
218
+ _p(
219
+ rf"{_ASK_PII}{_NEAR}\b(?:{_ID_GOV}){_NEAR}\b(?:of|for|belonging\s+to)\s+{_THIRD}",
220
+ "pii_solicitation",
221
+ 0.65,
222
+ ),
223
+ _p(rf"{_ASK_PII}{_NEAR}\b{_THIRD}{_NEAR}\b(?:{_ID_LOC})", "pii_solicitation", 0.55),
224
+ _p(
225
+ rf"{_ASK_PII}{_NEAR}\b(?:{_ID_LOC}){_NEAR}\b(?:of|for|belonging\s+to)\s+{_THIRD}",
226
+ "pii_solicitation",
227
+ 0.55,
228
+ ),
229
+ _act(
230
+ r"(?:find|figur\w+)\s+out\s+(?:where|who)\s+(?:\w+\s+){0,3}?"
231
+ r"(?:lives?|works?|really\s+is|is\s+staying)",
232
+ "pii_solicitation",
233
+ 0.55,
234
+ ),
235
+ # Acquiring controlled goods or criminal services
236
+ _obtain(_DRUG_STREET, "illicit_acquisition", 0.55),
237
+ _produce(_DRUG_STREET, "illicit_acquisition", 0.60),
238
+ _act(
239
+ rf"(?:get|buy|obtain|order)\s+(?:{_DRUG_RX})\b{_GAP}"
240
+ r"\bwithout\s+a\s+(?:prescription|script|doctor)",
241
+ "illicit_acquisition",
242
+ 0.55,
243
+ ),
244
+ _obtain(
245
+ r"(?:an?\s+)?(?:guns?|firearms?|pistols?|rifles?)\b.{0,25}?"
246
+ r"\bwithout\s+a\s+(?:background\s+check|permit|licen[cs]e|ffl)",
247
+ "illicit_acquisition",
248
+ 0.60,
249
+ ),
250
+ _obtain(
251
+ r"stolen\s+(?:credit\s+cards?|cards?|accounts?|credentials?|data|logins?|identit\w+)"
252
+ r"|dumps\s+with\s+pins?|fullz\b",
253
+ "illicit_acquisition",
254
+ 0.60,
255
+ ),
256
+ _obtain(
257
+ r"(?:an?\s+)?(?:fake|forged|counterfeit)\s+"
258
+ r"(?:ids?|passports?|driver'?s?\s+licen[cs]es?|documents?)",
259
+ "illicit_acquisition",
260
+ 0.60,
261
+ ),
262
+ _act(
263
+ r"(?:hire|find|pay)\s+(?:someone|a\s+(?:hacker|hitman|hit\s+man))\s+to\s+"
264
+ r"(?:hack|kill|hurt|beat|break|steal)",
265
+ "illicit_acquisition",
266
+ 0.65,
267
+ ),
268
+ # Financial crime
269
+ _act(r"launder\w*\s+(?:the\s+)?(?:money|cash|funds|proceeds)", "fraud", 0.60),
270
+ _produce(
271
+ r"counterfeit\s+(?:\w+\s+){0,2}?(?:bills?|money|currency|banknotes?|notes?)"
272
+ r"|fake\s+(?:\w+\s+){0,2}?(?:bills?|currency|banknotes?)",
273
+ "fraud",
274
+ 0.60,
275
+ ),
276
+ _act(
277
+ r"steal\s+(?:someone'?s?|somebody'?s?|his|her|their|a\s+person'?s?|my\s+\w+'?s?)\s+"
278
+ r"identity|commit\s+identity\s+theft",
279
+ "fraud",
280
+ 0.60,
281
+ ),
282
+ _act(
283
+ r"(?:kite|kiting)\s+checks?|check\s+kiting"
284
+ r"|writ\w+\s+(?:a\s+)?(?:bad|rubber|fraudulent|hot)\s+che(?:ck|que)"
285
+ r"|writ\w+\s+(?:a\s+)?che(?:ck|que)\s+that\s+(?:wo|do)n'?t\s+(?:bounce|clear)",
286
+ "fraud",
287
+ 0.55,
288
+ ),
289
+ _act(
290
+ r"(?:evade|dodge)\s+(?:paying\s+)?(?:taxes|the\s+irs)|tax\s+evasion\s+scheme", "fraud", 0.50
291
+ ),
292
+ # Planning harm to a person
293
+ _act(
294
+ r"poison\s+(?:someone|somebody|a\s+person|him|her|them|my\s+\w+)"
295
+ r"|(?:best|undetectable|untraceable)\s+poison"
296
+ r"|poisons?\s+to\s+(?:use\s+)?on\b",
297
+ "violence_planning",
298
+ 0.65,
299
+ ),
300
+ _act(
301
+ r"(?:hurt|harm|injur\w+|beat|attack)\s+"
302
+ r"(?:someone|somebody|him|her|them|a\s+person|my\s+\w+)\b.{0,40}?"
303
+ r"\bwithout\s+(?:leaving|getting|being|anyone)",
304
+ "violence_planning",
305
+ 0.60,
306
+ ),
307
+ _act(
308
+ r"get\s+away\s+with\s+(?:murder|killing|a\s+crime|assault|arson)", "violence_planning", 0.65
309
+ ),
310
+ _act(
311
+ r"(?:plan|carry\s+out|commit|stage)\s+(?:an?\s+)?"
312
+ r"(?:attack|shooting|massacre|bombing|ambush)\s+(?:on|at|against)\s+",
313
+ "violence_planning",
314
+ 0.70,
315
+ ),
316
+ _act(
317
+ r"mak\w+\s+it\s+look\s+like\s+(?:an?\s+)?(?:accident|suicide|self[\s-]defen[cs]e)",
318
+ "violence_planning",
319
+ 0.60,
320
+ ),
321
+ _act(
322
+ r"(?:kill|murder|stab|shoot)\s+(?:someone|somebody|a\s+person|him|her|them)\b"
323
+ r".{0,30}?\b(?:without|and\s+not|and\s+never)\s+(?:getting|being|leaving)",
324
+ "violence_planning",
325
+ 0.65,
326
+ ),
327
+ # Covert surveillance of a person, and evading the consequences
328
+ _act(
329
+ r"(?:track|monitor|follow|spy\s+on|geolocate|read)\s+"
330
+ r"(?:my|his|her|their|someone'?s?)\s+(?:\w+\s+){0,2}?"
331
+ r"(?:location|phone|cell|car|movements?|whereabouts|messages?|texts?|dms?)\b"
332
+ r".{0,40}?\bwithout\s+(?:\w+\s+){0,2}?"
333
+ r"(?:knowing|know|consent|permission|noticing|finding\s+out)",
334
+ "surveillance_evasion",
335
+ 0.65,
336
+ ),
337
+ _act(
338
+ r"(?:spy\s+on|track|monitor|stalk)\s+my\s+"
339
+ r"(?:wife|husband|girlfriend|boyfriend|ex|partner|gf|bf|spouse)\b",
340
+ "surveillance_evasion",
341
+ 0.55,
342
+ ),
343
+ _act(
344
+ r"install\s+(?:spyware|stalkerware|a\s+keylogger|a\s+tracker"
345
+ r"|monitoring\s+(?:app|software))\b.{0,40}?\bwithout\b",
346
+ "surveillance_evasion",
347
+ 0.60,
348
+ ),
349
+ _act(
350
+ r"(?:avoid|evade|escape|beat)\s+(?:the\s+)?"
351
+ r"(?:police|law\s+enforcement|cops|arrest|prosecution)\b"
352
+ r"|avoid\s+(?:police\s+)?detection\s+after",
353
+ "surveillance_evasion",
354
+ 0.55,
355
+ ),
356
+ _act(
357
+ r"(?:delete|destroy|wipe|erase|scrub|shred)\s+(?:the\s+|all\s+)?evidence\b",
358
+ "surveillance_evasion",
359
+ 0.60,
360
+ ),
361
+ # Exploitation of people
362
+ _act(
363
+ r"groom\w*\s+(?:an?\s+)?(?:child|children|kid|minor|teen\w*"
364
+ r"|young\s+(?:girl|boy)|underage)",
365
+ "exploitation",
366
+ 0.70,
367
+ ),
368
+ _act(
369
+ r"(?:smuggl\w+|traffic\w*)\s+(?:people|persons|humans|women|children|migrants)"
370
+ r"|human\s+traffick\w+\s+(?:operation|route|ring)",
371
+ "exploitation",
372
+ 0.65,
373
+ ),
374
+ _act(
375
+ r"(?:meet|contact|find)\s+(?:a\s+)?(?:minor|underage)\b.{0,30}?\b(?:sex|alone|without)",
376
+ "exploitation",
377
+ 0.70,
378
+ ),
379
+ _act(
380
+ r"(?:child|minor|underage)\s+(?:porn\w*|sexual\s+(?:images?|content|material))",
381
+ "exploitation",
382
+ 0.70,
383
+ ),
384
+ # Requests for a self-harm method, as opposed to the expressions of intent
385
+ # that ToxicityPolicy scores. Modelled as a deny here because that is what
386
+ # this pipeline can express; a product built on it should route these to
387
+ # crisis resources rather than return a bare refusal.
388
+ _act(
389
+ r"(?:best|painless|quickest|fastest|easiest|surest|reliable|effective"
390
+ r"|most\s+\w+)?\s*(?:ways?|methods?)\s+to\s+"
391
+ r"(?:die|kill\s+myself|end\s+(?:it\s+all|my\s+life))",
392
+ "self_harm_method",
393
+ 0.60,
394
+ ),
395
+ _act(
396
+ r"how\s+(?:many|much)\s+(?:\w+\s+){0,3}?(?:pills?|tablets?|mg|milligrams?|units?)\s+"
397
+ r"(?:\w+\s+){0,4}?(?:to\s+)?(?:overdose|od\b|die|kill|be\s+lethal|be\s+fatal)",
398
+ "self_harm_method",
399
+ 0.55,
400
+ ),
401
+ _act(r"lethal\s+doses?\s+of|fatal\s+(?:dose|amount)\s+of", "self_harm_method", 0.55),
402
+ _act(r"(?:suicide|self[\s-]harm)\s+methods?\b", "self_harm_method", 0.55),
403
+ # Shapes the object alphabets above cannot express, because the harm is in
404
+ # the combination rather than in any one noun.
405
+ _p(
406
+ r"\b(?:household\s+)?chemicals?\b.{0,40}?\b(?:combin\w+|mix\w*|react\w*)\b"
407
+ r".{0,40}?\b(?:toxic|poison\w*|lethal|deadly|nerve)\b",
408
+ "weapons",
409
+ 0.65,
410
+ ),
411
+ _p(
412
+ rf"{_REQ}{_PRODUCE}(?:\s+me)?\b{_GAP}"
413
+ r"\b(?:messages?|emails?|letters?|texts?|sms|pages?)\b.{0,40}?"
414
+ r"\b(?:from|pretending\s+to\s+be|impersonating)\s+(?:a\s+|the\s+)?"
415
+ r"(?:bank|paypal|irs|hmrc|revenue|\w+\s+support|it\s+(?:team|desk))\b.{0,60}?"
416
+ r"\b(?:passwords?|pins?|credentials?|account\s+details?|log\s*ins?)\b",
417
+ "malware",
418
+ 0.65,
419
+ ),
420
+ # Families the paraphrase evals turned up, written as shapes rather than as
421
+ # the sentences that exposed them.
422
+ _p(
423
+ r"\b(?:yield|blast\s+radius|explosive\s+power|kill\s+radius)\s+of\s+(?:an?\s+)?"
424
+ r"(?:device|bomb|charge|explosive)",
425
+ "weapons",
426
+ 0.60,
427
+ ),
428
+ _p(
429
+ r"\b(?:can(?:not|\s*'?t)\s+pass|fail(?:ing|ed)?)\s+(?:a\s+|the\s+)?background\s+check",
430
+ "illicit_acquisition",
431
+ 0.60,
432
+ ),
433
+ _p(
434
+ r"\b(?:card\s+numbers?|credit\s+cards?|cvvs?|logins?|accounts?)\b.{0,40}?"
435
+ r"\b(?:that\s+)?(?:still\s+work|are\s+live|are\s+valid|work\b)",
436
+ "illicit_acquisition",
437
+ 0.55,
438
+ ),
439
+ _p(
440
+ r"\b(?:hides?|conceal(?:s|ing)?|hiding)\s+(?:itself|the\s+process)\s+from\s+"
441
+ r"(?:task\s+manager|the\s+task\s+list|antivirus|av\b|edr|the\s+user)",
442
+ "malware",
443
+ 0.65,
444
+ ),
445
+ _p(
446
+ r"\b(?:forward|copy|mirror|intercept|exfiltrat)\w*\b.{0,50}?\b(?:every|all|each)\s+"
447
+ r"(?:emails?|messages?|texts?|calls?)\b.{0,50}?\b(?:my|his|her|their)\s+"
448
+ r"(?:wife|husband|partner|ex|girlfriend|boyfriend|spouse|coworkers?|employees?)\b",
449
+ "surveillance_evasion",
450
+ 0.60,
451
+ ),
452
+ _p(
453
+ r"\bconvinc\w+\b.{0,50}?\b(?:elderly|senior|old(?:er)?\s+(?:person|man|woman)|grandparents?)\b"
454
+ r".{0,60}?\b(?:wire|send|transfer|hand\s+over)\s+(?:money|funds|cash|savings)",
455
+ "fraud",
456
+ 0.65,
457
+ ),
458
+ _act(
459
+ r"disabl\w+\s+(?:an?\s+|the\s+)?(?:home\s+)?(?:alarms?|security\s+systems?|cctv|cameras?)"
460
+ r"\b.{0,50}?\bwithout\b",
461
+ "violence_planning",
462
+ 0.55,
463
+ ),
464
+ _p(
465
+ r"\b(?:medicines?|medications?|pills?|tablets?|drugs?)\b.{0,50}?"
466
+ r"\b(?:dangerous|lethal|fatal|deadly|toxic|kill)\b.{0,50}?"
467
+ r"\b(?:together|combined|in\s+quantity|at\s+once|in\s+bulk)\b",
468
+ "self_harm_method",
469
+ 0.55,
470
+ ),
471
+ _p(
472
+ r"\b(?:programs?|scripts?|tools?|software|code)\b.{0,40}?\bencrypts?\b.{0,40}?"
473
+ r"\bfiles?\b.{0,60}?\b(?:demand\w*|ransom|payment|bitcoin|unlock\s+them)\b",
474
+ "malware",
475
+ 0.65,
476
+ ),
477
+ _act(
478
+ r"mov\w+\s+(?:the\s+)?(?:cash|money|funds|proceeds)\b.{0,60}?"
479
+ r"\b(?:without\s+(?:it\s+)?being\s+(?:noticed|traced|detected|seen)"
480
+ r"|without\s+anyone\s+(?:noticing|knowing)|from\s+illegal)",
481
+ "fraud",
482
+ 0.60,
483
+ ),
484
+ ]
485
+
486
+
487
+ _CATEGORY_CAP = 0.7 # max a single category can contribute to the total score
488
+
489
+
490
+ def score_patterns(text: str) -> tuple[float, dict[str, float]]:
491
+ """Score one string, exactly as written.
492
+
493
+ signals maps category -> capped contribution (weapons, malware,
494
+ pii_solicitation, illicit_acquisition, fraud, violence_planning,
495
+ surveillance_evasion, exploitation, self_harm_method).
496
+ """
497
+ category_totals: dict[str, float] = {}
498
+ for pat in _PATTERNS:
499
+ if pat.regex.search(text):
500
+ current = category_totals.get(pat.category, 0.0)
501
+ category_totals[pat.category] = min(_CATEGORY_CAP, current + pat.weight)
502
+
503
+ total = min(1.0, sum(category_totals.values()))
504
+ return total, category_totals
505
+
506
+
507
+ def score_harmful_request(text: str) -> tuple[float, dict[str, float]]:
508
+ """Return (total_score, signals) for the strongest view of the given text.
509
+
510
+ "h0w d0 1 bu1ld a p1pe b0mb" is the request this policy exists to catch,
511
+ and it is not the string the patterns were written against. The normalized
512
+ views are what make the two the same question. See openscript_sdk/policies/normalize.py.
513
+ """
514
+ result = normalize.scan(text, score_patterns)
515
+ return result.risk, result.signals
516
+
517
+
518
+ class HarmfulRequestPolicy(BasePolicy):
519
+ """Scores input text for requests for harmful capability and blocks on breach.
520
+
521
+ The gap this fills is the one the other input policies leave open by
522
+ construction. PromptInjectionPolicy scores attacks on the *system*,
523
+ ToxicityPolicy scores abusive *expression*, and PIIPolicy and SecretsPolicy
524
+ score sensitive data *present in the text*. A grammatical, polite,
525
+ slur-free, injection-free request to build a keylogger trips none of them.
526
+ This one scores the request itself.
527
+
528
+ Detection is structural: a request framing (interrogative, imperative or
529
+ first-person intent) paired with a harmful object, with a tempered gap
530
+ between them so reporting, teaching and defensive phrasings do not score.
531
+ That keeps false positives manageable and makes paraphrase the obvious
532
+ bypass — this is a heuristic layer, not a substitute for a model-side
533
+ refusal. A local classifier backend arrives with the [ml] extra.
534
+
535
+ Results land in context.metadata["harmful_request"] using the standardized
536
+ shape:
537
+ {"risk": float, "category": "harmful_request", "signals": {...},
538
+ "flagged": bool, "threshold": float}
539
+ """
540
+
541
+ failure_mode: FailureMode = FailureMode.FAIL_CLOSED
542
+
543
+ def __init__(
544
+ self,
545
+ threshold: float = 0.5,
546
+ writer: EventWriter | None = None,
547
+ ) -> None:
548
+ if not 0.0 <= threshold <= 1.0:
549
+ raise ValueError(f"threshold must be in [0, 1], got {threshold}")
550
+ self._threshold = threshold
551
+ self._writer = writer
552
+ self._sequence_counters: dict[str, int] = {}
553
+
554
+ async def before_action(self, context: ActionContext) -> ActionContext:
555
+ text = _extract_text(context.input_data)
556
+ result = normalize.scan(text, score_patterns)
557
+ score, signals = result.risk, result.signals
558
+ flagged = score >= self._threshold
559
+
560
+ context.metadata["harmful_request"] = {
561
+ "risk": round(score, 4),
562
+ "category": "harmful_request",
563
+ "signals": {k: round(v, 4) for k, v in signals.items()},
564
+ "flagged": flagged,
565
+ "threshold": self._threshold,
566
+ "obfuscation": result.markers,
567
+ "matched_view": result.view,
568
+ }
569
+
570
+ if flagged:
571
+ top_signal = max(signals, key=lambda k: signals[k]) if signals else "unknown"
572
+ reason = (
573
+ f"harmful request score {score:.2f} >= threshold {self._threshold} "
574
+ f"(top signal: {top_signal})"
575
+ )
576
+ context.decision = InterceptorDecision.DENY
577
+ context.decision_reason = reason
578
+ logger.warning(
579
+ "harmful_request_detected",
580
+ session_id=context.session_id,
581
+ agent_id=context.agent_id,
582
+ score=round(score, 4),
583
+ signals=list(signals.keys()),
584
+ )
585
+ if self._writer is not None:
586
+ await self._emit_harmful_request_event(context, score, signals, reason)
587
+
588
+ return context
589
+
590
+ async def _emit_harmful_request_event(
591
+ self,
592
+ context: ActionContext,
593
+ score: float,
594
+ signals: dict[str, float],
595
+ reason: str,
596
+ ) -> None:
597
+ seq = self._next_sequence(context.session_id)
598
+ event = Event(
599
+ session_id=context.session_id,
600
+ agent_id=context.agent_id,
601
+ event_type=EventType.POLICY_EVALUATED,
602
+ payload={
603
+ "policy": "harmful_request_check",
604
+ "score": round(score, 4),
605
+ "signals": signals,
606
+ "reason": reason,
607
+ "action": context.action,
608
+ "threshold": self._threshold,
609
+ },
610
+ sequence_num=seq,
611
+ )
612
+ await self._writer.write(event) # type: ignore[union-attr]
613
+
614
+ def _next_sequence(self, session_id: str) -> int:
615
+ count = self._sequence_counters.get(session_id, 0) + 1
616
+ self._sequence_counters[session_id] = count
617
+ return count