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,489 @@
1
+ """Text canonicalization shared by the input-side pattern banks.
2
+
3
+ A pattern bank sees exactly the bytes it is handed. "Ignore all previous
4
+ instructions" is blocked at 0.85; the same sentence with a zero-width space
5
+ inside one word, or with the Cyrillic о instead of the Latin one, or written
6
+ 1gn0re, scored 0.00 — the regex engine is looking at different characters and
7
+ is entirely correct to say so. That gap is not a missing pattern, it is a
8
+ missing step, and every pattern added without it is one substitution away from
9
+ being bypassed.
10
+
11
+ So the policies do not scan a string. They scan a small set of *views* of it:
12
+
13
+ raw what arrived, untouched
14
+ normalized NFKD, invisible characters dropped, combining marks dropped,
15
+ look-alike letters folded to ASCII, letter-spacing collapsed
16
+ deobfuscated the above, plus leetspeak undone and separator punctuation
17
+ treated as spaces
18
+ decoded whatever a base64, hex or percent-encoded run in the text
19
+ turned out to be, normalized in turn
20
+
21
+ Only views that differ from one already in the list are produced, so plain
22
+ English text costs exactly one scan, the same as before. The cost is paid by
23
+ text that has been fiddled with, which is the text worth paying for.
24
+
25
+ Two deliberate limits, because a reader should know what this does not do:
26
+ translation is not one of the transforms (an attack written in French is still
27
+ invisible to an English pattern bank), and de-leeting is confined to tokens
28
+ that already mix letters with digits, so "refund 9999" is never read as
29
+ "refund gggg".
30
+
31
+ Pure stdlib on purpose: this runs inside the browser demo, where the only
32
+ Python available is what Pyodide ships. Percent-decoding is done by hand rather
33
+ than with urllib.parse, because tools/policy_audit.py counts any reference to
34
+ urllib as a module that could reach the network, and that check is worth more
35
+ than the four lines it costs here.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import base64
41
+ import binascii
42
+ import re
43
+ import unicodedata
44
+ from collections.abc import Callable
45
+ from dataclasses import dataclass, field
46
+ from functools import partial
47
+
48
+ __all__ = ["Prepared", "Scan", "View", "canonical", "prepare", "scan"]
49
+
50
+ # Format characters (Cf) cover zero-width spaces, joiners, the bidi overrides
51
+ # and the BOM. Soft hyphen is Cf too. Listed rather than category-tested where
52
+ # the category would also catch something worth keeping.
53
+ _INVISIBLE = re.compile(r"[­͏؜ᅟᅠ឴឵᠎⠀ㅤᅠ]")
54
+
55
+ # Look-alikes that survive NFKD: NFKD decomposes mathematical, fullwidth and
56
+ # circled Latin, but Cyrillic and Greek letters are not Latin with decorations,
57
+ # they are separate letters that happen to be drawn the same.
58
+ _CONFUSABLES = str.maketrans(
59
+ {
60
+ # Cyrillic lowercase
61
+ "а": "a",
62
+ "б": "b",
63
+ "в": "b",
64
+ "г": "r",
65
+ "е": "e",
66
+ "ѐ": "e",
67
+ "ё": "e",
68
+ "з": "3",
69
+ "и": "u",
70
+ "й": "u",
71
+ "к": "k",
72
+ "м": "m",
73
+ "н": "h",
74
+ "о": "o",
75
+ "п": "n",
76
+ "р": "p",
77
+ "с": "c",
78
+ "т": "t",
79
+ "у": "y",
80
+ "х": "x",
81
+ "ч": "4",
82
+ "ѕ": "s",
83
+ "і": "i",
84
+ "ј": "j",
85
+ "ԁ": "d",
86
+ "һ": "h",
87
+ "ӏ": "l",
88
+ "ԛ": "q",
89
+ "ԝ": "w",
90
+ "ь": "b",
91
+ "я": "r",
92
+ # Cyrillic uppercase
93
+ "А": "A",
94
+ "В": "B",
95
+ "Е": "E",
96
+ "З": "3",
97
+ "К": "K",
98
+ "М": "M",
99
+ "Н": "H",
100
+ "О": "O",
101
+ "Р": "P",
102
+ "С": "C",
103
+ "Т": "T",
104
+ "У": "Y",
105
+ "Х": "X",
106
+ "Ѕ": "S",
107
+ "І": "I",
108
+ "Ј": "J",
109
+ "Ԁ": "D",
110
+ "Һ": "H",
111
+ "Ԛ": "Q",
112
+ "Ԝ": "W",
113
+ # Greek
114
+ "α": "a",
115
+ "β": "b",
116
+ "γ": "y",
117
+ "ε": "e",
118
+ "ζ": "z",
119
+ "η": "n",
120
+ "ι": "i",
121
+ "κ": "k",
122
+ "μ": "u",
123
+ "ν": "v",
124
+ "ο": "o",
125
+ "ρ": "p",
126
+ "σ": "o",
127
+ "τ": "t",
128
+ "υ": "u",
129
+ "χ": "x",
130
+ "ω": "w",
131
+ "Α": "A",
132
+ "Β": "B",
133
+ "Ε": "E",
134
+ "Ζ": "Z",
135
+ "Η": "H",
136
+ "Ι": "I",
137
+ "Κ": "K",
138
+ "Μ": "M",
139
+ "Ν": "N",
140
+ "Ο": "O",
141
+ "Ρ": "P",
142
+ "Τ": "T",
143
+ "Υ": "Y",
144
+ "Χ": "X",
145
+ # Latin letters that are not the usual ones
146
+ "ı": "i",
147
+ "ł": "l",
148
+ "ø": "o",
149
+ "œ": "oe",
150
+ "æ": "ae",
151
+ "ſ": "s",
152
+ # Armenian and Georgian shapes that get used for this
153
+ "օ": "o",
154
+ "ո": "n",
155
+ "ս": "s",
156
+ "զ": "q",
157
+ "ա": "w",
158
+ # Punctuation look-alikes that matter to the patterns
159
+ "‚": ",",
160
+ "„": ",",
161
+ "‘": "'",
162
+ "’": "'",
163
+ "‛": "'",
164
+ "“": '"',
165
+ "”": '"',
166
+ "‟": '"',
167
+ "‐": "-",
168
+ "‑": "-",
169
+ "‒": "-",
170
+ "–": "-",
171
+ "—": "-",
172
+ "―": "-",
173
+ "․": ".",
174
+ "‥": "..",
175
+ "…": "...",
176
+ "⁄": "/",
177
+ "∕": "/",
178
+ "︰": ":",
179
+ }
180
+ )
181
+
182
+ # Four or more single letters in a row, each followed by a separator: the
183
+ # "I g n o r e" and "i-g-n-o-r-e" shapes. Anchored on both sides so it cannot
184
+ # eat the first letter of a real word.
185
+ _PAD_PUNCT = r"\-._*|~·•‧"
186
+ _SEPARATORS = r" \t" + _PAD_PUNCT
187
+ _SPACED_OUT = re.compile(
188
+ rf"(?<![^\W\d_])(?:[^\W\d_][{_SEPARATORS}]{{1,3}}){{3,}}[^\W\d_](?![^\W\d_])"
189
+ )
190
+ _SEP_RUN = re.compile(rf"[{_SEPARATORS}]{{2,}}")
191
+ _SEP_ONE = re.compile(rf"[{_SEPARATORS}]")
192
+ _PUNCT_PAD = re.compile(rf"[{_PAD_PUNCT}]")
193
+
194
+ _LEET_BASE = {
195
+ "4": "a",
196
+ "3": "e",
197
+ "0": "o",
198
+ "5": "s",
199
+ "7": "t",
200
+ "8": "b",
201
+ "9": "g",
202
+ "$": "s",
203
+ "@": "a",
204
+ "+": "t",
205
+ "(": "c",
206
+ "€": "e",
207
+ "£": "l",
208
+ }
209
+ # 1, ! and | are drawn like both i and l, so each token gets read both ways and
210
+ # the more word-like reading wins: "1gn0re" is ignore rather than lgnore, and
211
+ # "a11" is all rather than aii. Guessing one way for the whole string gets one
212
+ # of those two wrong every time.
213
+ _LEET_I = str.maketrans({**_LEET_BASE, "1": "i", "!": "i", "|": "i"})
214
+ _LEET_L = str.maketrans({**_LEET_BASE, "1": "l", "!": "l", "|": "l"})
215
+ # Sequences English essentially does not produce. Used only to choose between
216
+ # two readings of the same token, never to reject text.
217
+ # Word-initial l before a consonant ("lgnore"), a doubled i ("aii"), and an i
218
+ # wedged between two vowels ("keyiogger") are the three shapes that separate a
219
+ # wrong reading from a right one often enough to be worth testing for. The
220
+ # middle vowel rule is written to leave "previous" alone, where the i follows a
221
+ # consonant.
222
+ _UNLIKELY = re.compile(r"ii|\bl(?=[bcdfgjklmnpqrstvwxz])|\bi(?=[lr])|[aeouy]i[aeouy]")
223
+ _LONE_ONE = re.compile(r"\b[1|]\b")
224
+ # A token is a de-leeting candidate only if it already contains a letter. That
225
+ # is what keeps "$9999", "order 4471" and "E11.9" out of it — numbers that are
226
+ # only numbers stay numbers.
227
+ _LEET_TOKEN = re.compile(r"\b(?=[\w$@!|+]*[a-zA-Z])[\w$@!|+]*[0134578$@!|+][\w$@!|+]*\b")
228
+ # Punctuation wedged between letters, as in "ignore.all.previous".
229
+ _WEDGED = re.compile(r"(?<=[^\W\d_])[._\-*|/~+](?=[^\W\d_])")
230
+
231
+ _B64_RUN = re.compile(r"[A-Za-z0-9+/]{16,}={0,2}")
232
+ _HEX_RUN = re.compile(r"(?:[0-9a-fA-F]{2}[\s:-]?){12,}")
233
+ _PERCENT_RUN = re.compile(r"(?:%[0-9a-fA-F]{2}){6,}")
234
+
235
+ MAX_DECODED_PAYLOADS = 3
236
+ MAX_DECODED_CHARS = 4096
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class View:
241
+ """One rendering of the input, and the name the metadata reports it under."""
242
+
243
+ name: str
244
+ text: str
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class Prepared:
249
+ """Every view worth scanning, plus what was done to produce them.
250
+
251
+ `markers` is the honest part of the report: it names the obfuscation that
252
+ was actually present, so a policy can say "caught after folding look-alike
253
+ characters" instead of leaving a reader to guess why a score appeared.
254
+ """
255
+
256
+ views: list[View]
257
+ markers: list[str]
258
+
259
+
260
+ def _strip_invisibles(text: str) -> tuple[str, bool]:
261
+ out = _INVISIBLE.sub("", text)
262
+ kept = []
263
+ removed = out != text
264
+ for ch in out:
265
+ category = unicodedata.category(ch)
266
+ if category in ("Cf", "Mn", "Me"): # format characters and combining marks
267
+ removed = True
268
+ continue
269
+ kept.append(ch)
270
+ return "".join(kept), removed
271
+
272
+
273
+ def _collapse_spacing(text: str) -> tuple[str, bool]:
274
+ """Join "I g n o r e" back into a word, keeping the gaps between words.
275
+
276
+ Which character was the padding depends on what else is in the run. When
277
+ the padding is punctuation ("i-g-n-o-r-e a-l-l"), whitespace is still doing
278
+ its usual job and survives as one space. When the padding is whitespace
279
+ itself ("i g n o r e a l l"), a single space is padding and a run of two
280
+ or more is the word boundary. Collapsing every separator either way would
281
+ produce "ignoreallprevious", which matches nothing either.
282
+ """
283
+ changed = False
284
+
285
+ def _join(match: re.Match[str]) -> str:
286
+ nonlocal changed
287
+ changed = True
288
+ chunk = match.group(0)
289
+ if _PUNCT_PAD.search(chunk):
290
+ return re.sub(r"\s+", " ", _PUNCT_PAD.sub("", chunk))
291
+ return _SEP_ONE.sub("", _SEP_RUN.sub("\x00", chunk)).replace("\x00", " ")
292
+
293
+ return _SPACED_OUT.sub(_join, text), changed
294
+
295
+
296
+ def canonical(text: str) -> str:
297
+ """The view every pattern bank should have been looking at all along."""
298
+ return _canonical(text)[0]
299
+
300
+
301
+ def _canonical(text: str) -> tuple[str, list[str]]:
302
+ markers: list[str] = []
303
+ out = unicodedata.normalize("NFKD", text)
304
+ if out != text:
305
+ markers.append("compatibility_forms")
306
+
307
+ out, stripped = _strip_invisibles(out)
308
+ if stripped:
309
+ markers.append("invisible_characters")
310
+
311
+ folded = out.translate(_CONFUSABLES)
312
+ if folded != out:
313
+ markers.append("look_alike_letters")
314
+ out = folded
315
+
316
+ out, spaced = _collapse_spacing(out)
317
+ if spaced:
318
+ markers.append("letter_spacing")
319
+
320
+ return out, markers
321
+
322
+
323
+ def _rank(word: str) -> int:
324
+ """How many sequences English essentially does not produce this word has."""
325
+ return len(_UNLIKELY.findall(word.lower()))
326
+
327
+
328
+ def _read_token(match: re.Match[str], prefer_likely: bool = True) -> str:
329
+ """One of a token's two leet readings: the more word-like one, or the other.
330
+
331
+ "a11" is all, not aii, and "1gn0re" is ignore, not lgnore — a single global
332
+ guess gets one of those wrong whichever way it is made. The losing reading
333
+ is not thrown away either: "key10gger" looks equally plausible both ways,
334
+ and the one that matters is keylogger.
335
+ """
336
+ token = match.group(0)
337
+ as_i = token.translate(_LEET_I)
338
+ as_l = token.translate(_LEET_L)
339
+ if as_i == as_l:
340
+ return as_i
341
+ # Ties go to i, the commoner substitution, so the runner-up is well defined.
342
+ ranked = sorted((as_i, as_l), key=lambda word: (_rank(word), word == as_l))
343
+ return ranked[0] if prefer_likely else ranked[1]
344
+
345
+
346
+ def _deobfuscate(text: str) -> tuple[list[str], list[str]]:
347
+ """Undo leetspeak and punctuation wedged between letters.
348
+
349
+ Returns one reading for text with no ambiguous characters in it, and two
350
+ when the ambiguity is real. Scanning both costs one extra pass over text
351
+ that has already announced itself as obfuscated.
352
+ """
353
+ markers: list[str] = []
354
+ readings: list[str] = []
355
+
356
+ for prefer_likely in (True, False):
357
+ out = _LEET_TOKEN.sub(partial(_read_token, prefer_likely=prefer_likely), text)
358
+ if out != text:
359
+ if "leetspeak" not in markers:
360
+ markers.append("leetspeak")
361
+ # A lone "1" is a number in ordinary text and a letter in
362
+ # leetspeak, so it is only re-read once some other token has given
363
+ # the text away. Without this, "h0w d0 1 bu1ld" keeps its pronoun
364
+ # as a digit.
365
+ out = _LONE_ONE.sub("i", out)
366
+
367
+ unwedged = _WEDGED.sub(" ", out)
368
+ if unwedged != out and "wedged_punctuation" not in markers:
369
+ markers.append("wedged_punctuation")
370
+ if unwedged not in readings:
371
+ readings.append(unwedged)
372
+
373
+ return readings, markers
374
+
375
+
376
+ def _readable(raw: bytes) -> str | None:
377
+ """Decoded bytes are only interesting if they came out as prose."""
378
+ try:
379
+ text = raw.decode("utf-8")
380
+ except UnicodeDecodeError:
381
+ return None
382
+ if len(text) < 12:
383
+ return None
384
+ if sum(ch.isprintable() or ch in "\n\t" for ch in text) / len(text) < 0.95:
385
+ return None
386
+ if sum(ch.isalpha() for ch in text) / len(text) < 0.5:
387
+ return None
388
+ return text[:MAX_DECODED_CHARS]
389
+
390
+
391
+ def _decode_payloads(text: str) -> list[str]:
392
+ """Anything encoded in the prompt, decoded, so it gets scanned as well.
393
+
394
+ A prompt that says "decode this and do what it says" is carrying its
395
+ payload in plain sight. Runs that decode to bytes rather than to prose are
396
+ dropped, which is what keeps an API key or a hash from being read as an
397
+ instruction.
398
+ """
399
+ found: list[str] = []
400
+
401
+ for match in _B64_RUN.finditer(text):
402
+ run = match.group(0).rstrip("=")
403
+ try:
404
+ decoded = base64.b64decode(run + "=" * (-len(run) % 4), validate=True)
405
+ except (binascii.Error, ValueError):
406
+ continue
407
+ readable = _readable(decoded)
408
+ if readable:
409
+ found.append(readable)
410
+
411
+ for match in _HEX_RUN.finditer(text):
412
+ digits = re.sub(r"[\s:-]", "", match.group(0))
413
+ if len(digits) % 2:
414
+ digits = digits[:-1]
415
+ try:
416
+ readable = _readable(bytes.fromhex(digits))
417
+ except ValueError:
418
+ continue
419
+ if readable:
420
+ found.append(readable)
421
+
422
+ for match in _PERCENT_RUN.finditer(text):
423
+ raw = bytes(int(pair, 16) for pair in match.group(0).split("%") if pair)
424
+ readable = _readable(raw)
425
+ if readable:
426
+ found.append(readable)
427
+
428
+ return found[:MAX_DECODED_PAYLOADS]
429
+
430
+
431
+ def prepare(text: str) -> Prepared:
432
+ """Build the views a pattern bank should scan, cheapest first.
433
+
434
+ Views that came out identical to one already in the list are dropped, so
435
+ ordinary text produces exactly one view and costs exactly one pass.
436
+ """
437
+ views = [View("raw", text)]
438
+ seen = {text}
439
+ markers: list[str] = []
440
+
441
+ normalized, canon_markers = _canonical(text)
442
+ markers.extend(canon_markers)
443
+ if normalized not in seen:
444
+ views.append(View("normalized", normalized))
445
+ seen.add(normalized)
446
+
447
+ readings, deob_markers = _deobfuscate(normalized)
448
+ markers.extend(deob_markers)
449
+ for reading in readings:
450
+ if reading not in seen:
451
+ views.append(View("deobfuscated", reading))
452
+ seen.add(reading)
453
+
454
+ for payload in _decode_payloads(normalized):
455
+ decoded, _ = _canonical(payload)
456
+ if decoded not in seen:
457
+ views.append(View("decoded", decoded))
458
+ seen.add(decoded)
459
+ if "encoded_payload" not in markers:
460
+ markers.append("encoded_payload")
461
+
462
+ return Prepared(views=views, markers=markers)
463
+
464
+
465
+ @dataclass(frozen=True)
466
+ class Scan:
467
+ """What a pattern bank found, and which view of the text it found it in."""
468
+
469
+ risk: float
470
+ signals: dict[str, float] = field(default_factory=dict)
471
+ #: "raw" unless an obfuscated view scored higher than the text as it arrived.
472
+ view: str = "raw"
473
+ #: Obfuscation present in the input, whether or not it changed the score.
474
+ markers: list[str] = field(default_factory=list)
475
+
476
+
477
+ def scan(text: str, scorer: Callable[[str], tuple[float, dict[str, float]]]) -> Scan:
478
+ """Run *scorer* over every view of *text* and keep the strongest result.
479
+
480
+ Ties go to the earliest view, so a prompt that scores the same before and
481
+ after normalization is reported as what it plainly is.
482
+ """
483
+ prepared = prepare(text)
484
+ best = Scan(risk=0.0, signals={}, view="raw", markers=prepared.markers)
485
+ for view in prepared.views:
486
+ risk, signals = scorer(view.text)
487
+ if risk > best.risk:
488
+ best = Scan(risk=risk, signals=signals, view=view.name, markers=prepared.markers)
489
+ return best