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,348 @@
1
+ """SemanticPolicy — the opt-in layer that reads a sentence instead of matching it.
2
+
3
+ The pattern banks recognise shapes. Measured against attacks phrased in words
4
+ nobody wrote a rule for, they block almost nothing (docs/metrics.md), and no
5
+ amount of extra patterns changes that. This policy is the other half: it embeds
6
+ the prompt with a small sentence encoder and scores the embedding with a linear
7
+ head trained on labelled attacks and the benign prompts that resemble them.
8
+
9
+ It is **not** one of the pure-local built-ins and is never part of the browser
10
+ demo. It needs the ``[ml]`` extra, and the encoder weights are downloaded from
11
+ the Hugging Face Hub the first time the model loads (pass
12
+ ``local_files_only=True`` once they are cached to rule the network out). Every
13
+ heavy import is inside a function, so ``import openscript_sdk`` stays exactly as light as
14
+ it was.
15
+
16
+ Three pieces, kept separate so each can be checked on its own:
17
+
18
+ - an **encoder**: any callable mapping a list of strings to unit-length vectors.
19
+ The default wraps sentence-transformers; tests pass a stub.
20
+ - a **head**: class weights for {benign, prompt_injection, harmful_request,
21
+ toxicity}, shipped as plain numbers in ``semantic_head.py`` and applied in
22
+ pure Python. ``tools/train_semantic_head.py`` regenerates it.
23
+ - the **policy**: scores every normalized view and every window of the text,
24
+ keeps the strongest, and denies (or annotates) at the head's calibrated
25
+ threshold.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import importlib.util
32
+ import math
33
+ from collections.abc import Callable, Sequence
34
+ from dataclasses import dataclass, field
35
+ from enum import StrEnum
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ import structlog
39
+
40
+ from openscript_sdk.contracts.interceptor import BasePolicy
41
+ from openscript_sdk.contracts.server_types import Event, EventType
42
+ from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
43
+ from openscript_sdk.policies import normalize
44
+
45
+ if TYPE_CHECKING:
46
+ from openscript_sdk.events.writer import EventWriter
47
+
48
+ logger = structlog.get_logger(__name__)
49
+
50
+ #: Texts in, one unit-length vector per text out.
51
+ Encoder = Callable[[list[str]], Sequence[Sequence[float]]]
52
+
53
+ BENIGN = "benign"
54
+
55
+ # The encoder truncates at its own token limit, so a long prompt is scored in
56
+ # overlapping windows and the strongest window decides — an instruction buried
57
+ # on page three is still an instruction. The cap bounds the cost of one call.
58
+ WINDOW_WORDS = 120
59
+ WINDOW_STRIDE = 80
60
+ MAX_WINDOWS = 24
61
+
62
+
63
+ class SemanticMode(StrEnum):
64
+ DENY = "deny"
65
+ ANNOTATE = "annotate"
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class SemanticHead:
70
+ """A linear classifier over one specific encoder's embedding space."""
71
+
72
+ model: str
73
+ labels: tuple[str, ...]
74
+ weights: tuple[tuple[float, ...], ...]
75
+ bias: tuple[float, ...]
76
+ threshold: float
77
+
78
+ def __post_init__(self) -> None:
79
+ if BENIGN not in self.labels:
80
+ raise ValueError(f"head needs a {BENIGN!r} class, got {self.labels}")
81
+ if not (len(self.labels) == len(self.weights) == len(self.bias)):
82
+ raise ValueError("head labels, weights and bias disagree on the number of classes")
83
+ if len({len(row) for row in self.weights}) != 1:
84
+ raise ValueError("head weight rows have different lengths")
85
+
86
+ @property
87
+ def dim(self) -> int:
88
+ return len(self.weights[0])
89
+
90
+ @property
91
+ def trained(self) -> bool:
92
+ """False for the all-zero placeholder that ships until a head is trained."""
93
+ return any(w != 0.0 for row in self.weights for w in row) or any(
94
+ b != 0.0 for b in self.bias
95
+ )
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: dict[str, Any]) -> SemanticHead:
99
+ return cls(
100
+ model=str(data["model"]),
101
+ labels=tuple(data["labels"]),
102
+ weights=tuple(tuple(float(w) for w in row) for row in data["weights"]),
103
+ bias=tuple(float(b) for b in data["bias"]),
104
+ threshold=float(data["threshold"]),
105
+ )
106
+
107
+ def probabilities(self, vector: Sequence[float]) -> dict[str, float]:
108
+ if len(vector) != self.dim:
109
+ raise ValueError(
110
+ f"encoder produced {len(vector)}-d vectors; this head was trained on "
111
+ f"{self.dim}-d vectors from {self.model}"
112
+ )
113
+ logits = [
114
+ sum(w * x for w, x in zip(row, vector, strict=True)) + b
115
+ for row, b in zip(self.weights, self.bias, strict=True)
116
+ ]
117
+ peak = max(logits)
118
+ exps = [math.exp(v - peak) for v in logits]
119
+ total = sum(exps)
120
+ return {label: e / total for label, e in zip(self.labels, exps, strict=True)}
121
+
122
+
123
+ def default_head() -> SemanticHead:
124
+ """The head shipped with the package, trained by tools/train_semantic_head.py."""
125
+ from openscript_sdk.policies.semantic_head import HEAD
126
+
127
+ return SemanticHead.from_dict(HEAD)
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class SemanticScore:
132
+ """The strongest reading of one prompt."""
133
+
134
+ risk: float
135
+ #: The most likely attack class, even when the risk is low.
136
+ predicted: str
137
+ probabilities: dict[str, float] = field(default_factory=dict)
138
+ view: str = "raw"
139
+ markers: list[str] = field(default_factory=list)
140
+ windows: int = 1
141
+
142
+
143
+ def windows(text: str) -> list[str]:
144
+ """Split *text* into overlapping word windows. Short text is one window."""
145
+ words = text.split()
146
+ if len(words) <= WINDOW_WORDS:
147
+ return [text]
148
+ starts = list(range(0, len(words) - WINDOW_WORDS + WINDOW_STRIDE, WINDOW_STRIDE))
149
+ if len(starts) > MAX_WINDOWS:
150
+ # Keep the ends and sample the middle evenly: openings and closings are
151
+ # where an instruction to the model usually sits.
152
+ step = (len(starts) - 1) / (MAX_WINDOWS - 1)
153
+ starts = sorted({starts[round(i * step)] for i in range(MAX_WINDOWS)})
154
+ return [" ".join(words[s : s + WINDOW_WORDS]) for s in starts]
155
+
156
+
157
+ class SentenceTransformerEncoder:
158
+ """The default encoder. Loads the model on first use, not on import."""
159
+
160
+ def __init__(self, model_name: str, local_files_only: bool = False) -> None:
161
+ self.model_name = model_name
162
+ self._local_files_only = local_files_only
163
+ self._model: Any = None
164
+
165
+ def __call__(self, texts: list[str]) -> Sequence[Sequence[float]]:
166
+ if self._model is None:
167
+ from sentence_transformers import SentenceTransformer
168
+
169
+ self._model = SentenceTransformer(
170
+ self.model_name, local_files_only=self._local_files_only
171
+ )
172
+ vectors = self._model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
173
+ return vectors.tolist() # type: ignore[no-any-return]
174
+
175
+
176
+ class SemanticScorer:
177
+ """Encoder + head. Stateless apart from the loaded model; safe to share."""
178
+
179
+ def __init__(self, encoder: Encoder, head: SemanticHead) -> None:
180
+ self._encoder = encoder
181
+ self.head = head
182
+
183
+ def score(self, text: str) -> SemanticScore:
184
+ prepared = normalize.prepare(text)
185
+ pieces: list[tuple[str, str]] = [] # (view name, window text)
186
+ for view in prepared.views:
187
+ pieces.extend((view.name, w) for w in windows(view.text) if w.strip())
188
+ if not pieces:
189
+ return SemanticScore(risk=0.0, predicted=BENIGN, markers=prepared.markers, windows=0)
190
+
191
+ vectors = self._encoder([p[1] for p in pieces])
192
+ best: SemanticScore | None = None
193
+ for (view_name, _), vector in zip(pieces, vectors, strict=True):
194
+ probs = self.head.probabilities(vector)
195
+ risk = 1.0 - probs[BENIGN]
196
+ # Strictly greater: ties go to the earliest view, so a prompt that
197
+ # reads the same before and after normalization is reported as raw.
198
+ if best is None or risk > best.risk:
199
+ attack = max((k for k in probs if k != BENIGN), key=lambda k: probs[k])
200
+ best = SemanticScore(
201
+ risk=risk,
202
+ predicted=attack,
203
+ probabilities=probs,
204
+ view=view_name,
205
+ markers=prepared.markers,
206
+ windows=len(pieces),
207
+ )
208
+ assert best is not None
209
+ return best
210
+
211
+
212
+ class SemanticPolicy(BasePolicy):
213
+ """Scores the meaning of the input and blocks at a calibrated threshold.
214
+
215
+ Opt-in. Needs ``pip install -e ".[ml]"`` unless an *encoder* is supplied,
216
+ and raises ImportError at construction rather than failing closed on every
217
+ request later. It also refuses to construct on an untrained head, which is
218
+ what the package ships until tools/train_semantic_head.py has been run: no
219
+ detection number is published for this policy yet. Place it after the pattern policies: they are a thousand
220
+ times cheaper and settle the obvious cases first.
221
+
222
+ Results land in context.metadata["semantic"] in the standardized shape:
223
+ {"risk": float, "category": "semantic", "predicted": str,
224
+ "probabilities": {...}, "flagged": bool, "threshold": float,
225
+ "model": str, "obfuscation": [...], "matched_view": str}
226
+
227
+ *threshold* defaults to the one calibrated when the head was trained (the
228
+ lowest that produced no false positive on held-out benign prompts). Lower
229
+ it and it catches more and blocks more ordinary text; that trade is
230
+ measured in docs/metrics.md, not assumed.
231
+
232
+ ``mode="annotate"`` records the score without blocking, which is the right
233
+ first step in a deployment: read what it would have blocked before letting
234
+ it block.
235
+ """
236
+
237
+ failure_mode: FailureMode = FailureMode.FAIL_CLOSED
238
+
239
+ def __init__(
240
+ self,
241
+ threshold: float | None = None,
242
+ mode: SemanticMode | str = SemanticMode.DENY,
243
+ encoder: Encoder | None = None,
244
+ head: SemanticHead | None = None,
245
+ local_files_only: bool = False,
246
+ writer: EventWriter | None = None,
247
+ ) -> None:
248
+ self._head = head if head is not None else default_head()
249
+ if not self._head.trained:
250
+ # A policy that constructs, sits in the list and decides nothing is
251
+ # the worst outcome available: it reads as protection.
252
+ raise RuntimeError(
253
+ "SemanticPolicy has no trained head: openscript_sdk/policies/semantic_head.py is "
254
+ "still the placeholder. Run tools/train_semantic_head.py --write-head, "
255
+ "or pass head=..."
256
+ )
257
+ self._threshold = self._head.threshold if threshold is None else threshold
258
+ if not 0.0 <= self._threshold <= 1.0:
259
+ raise ValueError(f"threshold must be in [0, 1], got {self._threshold}")
260
+ self._mode = SemanticMode(mode)
261
+ if encoder is None:
262
+ if importlib.util.find_spec("sentence_transformers") is None:
263
+ raise ImportError(
264
+ "SemanticPolicy needs sentence-transformers: pip install -e '.[ml]' "
265
+ "(or pass encoder=...)"
266
+ )
267
+ encoder = SentenceTransformerEncoder(self._head.model, local_files_only)
268
+ self._scorer = SemanticScorer(encoder, self._head)
269
+ self._writer = writer
270
+ self._sequence_counters: dict[str, int] = {}
271
+
272
+ def score(self, text: str) -> SemanticScore:
273
+ """Score one string synchronously — what before_action runs in a thread."""
274
+ return self._scorer.score(text)
275
+
276
+ async def before_action(self, context: ActionContext) -> ActionContext:
277
+ text = _extract_text(context.input_data)
278
+ # Encoding is CPU-bound for a few milliseconds; keep it off the loop.
279
+ result = await asyncio.to_thread(self._scorer.score, text)
280
+ flagged = result.risk >= self._threshold
281
+
282
+ context.metadata["semantic"] = {
283
+ "risk": round(result.risk, 4),
284
+ "category": "semantic",
285
+ "predicted": result.predicted,
286
+ "probabilities": {k: round(v, 4) for k, v in result.probabilities.items()},
287
+ "flagged": flagged,
288
+ "threshold": self._threshold,
289
+ "model": self._head.model,
290
+ "obfuscation": result.markers,
291
+ "matched_view": result.view,
292
+ }
293
+
294
+ if flagged and self._mode is SemanticMode.DENY:
295
+ reason = (
296
+ f"semantic score {result.risk:.2f} >= threshold {self._threshold:.2f} "
297
+ f"(reads as: {result.predicted})"
298
+ )
299
+ context.decision = InterceptorDecision.DENY
300
+ context.decision_reason = reason
301
+ logger.warning(
302
+ "semantic_detected",
303
+ session_id=context.session_id,
304
+ agent_id=context.agent_id,
305
+ score=round(result.risk, 4),
306
+ predicted=result.predicted,
307
+ )
308
+ if self._writer is not None:
309
+ await self._emit_event(context, result, reason)
310
+
311
+ return context
312
+
313
+ async def _emit_event(self, context: ActionContext, result: SemanticScore, reason: str) -> None:
314
+ assert self._writer is not None
315
+ count = self._sequence_counters.get(context.session_id, 0) + 1
316
+ self._sequence_counters[context.session_id] = count
317
+ await self._writer.write(
318
+ Event(
319
+ session_id=context.session_id,
320
+ agent_id=context.agent_id,
321
+ event_type=EventType.POLICY_EVALUATED,
322
+ payload={
323
+ "policy": "semantic_check",
324
+ "score": round(result.risk, 4),
325
+ "predicted": result.predicted,
326
+ "reason": reason,
327
+ "action": context.action,
328
+ "threshold": self._threshold,
329
+ "model": self._head.model,
330
+ },
331
+ sequence_num=count,
332
+ )
333
+ )
334
+
335
+
336
+ def _extract_text(input_data: dict[str, Any]) -> str:
337
+ """Pull a single string from common input_data shapes."""
338
+ if "input" in input_data:
339
+ return str(input_data["input"])
340
+ if "messages" in input_data:
341
+ parts = []
342
+ for msg in input_data["messages"]:
343
+ if isinstance(msg, dict):
344
+ parts.append(str(msg.get("content", "")))
345
+ else:
346
+ parts.append(str(msg))
347
+ return " ".join(parts)
348
+ return " ".join(str(v) for v in input_data.values())
@@ -0,0 +1,9 @@
1
+ """Placeholder until tools/train_semantic_head.py writes the trained head."""
2
+
3
+ HEAD = {
4
+ "model": "sentence-transformers/all-MiniLM-L6-v2",
5
+ "labels": ["benign", "prompt_injection", "harmful_request", "toxicity"],
6
+ "weights": [[0.0], [0.0], [0.0], [0.0]],
7
+ "bias": [0.0, 0.0, 0.0, 0.0],
8
+ "threshold": 1.0,
9
+ }