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.
- openscript_sdk/__init__.py +217 -0
- openscript_sdk/contracts/__init__.py +82 -0
- openscript_sdk/contracts/interceptor.py +98 -0
- openscript_sdk/contracts/server_types.py +87 -0
- openscript_sdk/contracts/session_graph.py +123 -0
- openscript_sdk/contracts/types.py +76 -0
- openscript_sdk/events/__init__.py +0 -0
- openscript_sdk/events/approvals.py +309 -0
- openscript_sdk/events/models.py +26 -0
- openscript_sdk/events/store.py +79 -0
- openscript_sdk/events/writer.py +100 -0
- openscript_sdk/hooks/claude_code.py +479 -0
- openscript_sdk/hooks/claude_code_example.yaml +62 -0
- openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
- openscript_sdk/hooks/config.py +144 -0
- openscript_sdk/hooks/settings_example.json +28 -0
- openscript_sdk/hooks/settings_strict_example.json +28 -0
- openscript_sdk/integrations/langchain.py +32 -0
- openscript_sdk/integrations/langgraph.py +32 -0
- openscript_sdk/interceptors/base.py +15 -0
- openscript_sdk/interceptors/event_writer.py +131 -0
- openscript_sdk/interceptors/pii.py +168 -0
- openscript_sdk/interceptors/threat.py +674 -0
- openscript_sdk/logging.py +94 -0
- openscript_sdk/middleware/middleware.py +543 -0
- openscript_sdk/middleware/tool.py +247 -0
- openscript_sdk/observability/__init__.py +11 -0
- openscript_sdk/observability/metrics.py +105 -0
- openscript_sdk/observability/risk.py +54 -0
- openscript_sdk/observability/tracing.py +67 -0
- openscript_sdk/policies/compliance.py +261 -0
- openscript_sdk/policies/config.py +244 -0
- openscript_sdk/policies/harmful_request.py +617 -0
- openscript_sdk/policies/normalize.py +489 -0
- openscript_sdk/policies/output_schema.py +353 -0
- openscript_sdk/policies/policies_example.yaml +36 -0
- openscript_sdk/policies/redaction.py +359 -0
- openscript_sdk/policies/secrets.py +239 -0
- openscript_sdk/policies/semantic.py +348 -0
- openscript_sdk/policies/semantic_head.py +9 -0
- openscript_sdk/policies/session_taint.py +399 -0
- openscript_sdk/policies/taint_store.py +111 -0
- openscript_sdk/policies/tool_firewall.py +434 -0
- openscript_sdk/policies/tools_example.yaml +81 -0
- openscript_sdk/policies/toxicity.py +247 -0
- openscript_sdk/py.typed +0 -0
- openscript_sdk/server/__init__.py +1 -0
- openscript_sdk/server/app.py +153 -0
- openscript_sdk/server/auth.py +19 -0
- openscript_sdk/server/guard.py +126 -0
- openscript_sdk/server/guard_example.yaml +55 -0
- openscript_sdk/server/routers/__init__.py +0 -0
- openscript_sdk/server/routers/approvals.py +69 -0
- openscript_sdk/server/routers/guard.py +231 -0
- openscript_sdk/server/routers/sessions.py +80 -0
- openscript_sdk/server/routers/sse.py +69 -0
- openscript_sdk/server/routers/threat.py +119 -0
- openscript_sdk/server/static/dashboard/index.html +209 -0
- openscript_sdk/server/static/dashboard/session.html +339 -0
- openscript_sdk-0.1.0.dist-info/METADATA +225 -0
- openscript_sdk-0.1.0.dist-info/RECORD +65 -0
- openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
- openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
- 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)
|