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,353 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from enum import StrEnum
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import structlog
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ from openscript_sdk.contracts.interceptor import BasePolicy
12
+ from openscript_sdk.contracts.server_types import Event, EventType
13
+ from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
14
+ from openscript_sdk.policies.secrets import find_secrets
15
+
16
+ if TYPE_CHECKING:
17
+ from openscript_sdk.events.writer import EventWriter
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ class OnInvalid(StrEnum):
23
+ DENY = "deny"
24
+ ANNOTATE = "annotate"
25
+
26
+
27
+ class HallucinationMode(StrEnum):
28
+ KEYWORD = "keyword"
29
+ EMBEDDING = "embedding"
30
+
31
+
32
+ # Keys checked (in order) for a JSON-string payload when the output dict
33
+ # itself does not validate against the schema.
34
+ _JSON_CANDIDATE_KEYS = ("output", "result", "content", "text", "data")
35
+
36
+ # Grounding: content words are 4+ alphanumeric chars, minus common English
37
+ # words that carry no factual signal.
38
+ _WORD_RE = re.compile(r"[a-z0-9]{4,}")
39
+ _STOPWORDS = frozenset(
40
+ [
41
+ "about",
42
+ "above",
43
+ "after",
44
+ "again",
45
+ "also",
46
+ "because",
47
+ "been",
48
+ "before",
49
+ "being",
50
+ "below",
51
+ "between",
52
+ "both",
53
+ "cannot",
54
+ "could",
55
+ "does",
56
+ "doing",
57
+ "down",
58
+ "during",
59
+ "each",
60
+ "even",
61
+ "every",
62
+ "from",
63
+ "further",
64
+ "have",
65
+ "having",
66
+ "here",
67
+ "how",
68
+ "into",
69
+ "itself",
70
+ "just",
71
+ "like",
72
+ "made",
73
+ "make",
74
+ "many",
75
+ "more",
76
+ "most",
77
+ "much",
78
+ "must",
79
+ "only",
80
+ "other",
81
+ "over",
82
+ "same",
83
+ "shall",
84
+ "should",
85
+ "some",
86
+ "such",
87
+ "than",
88
+ "that",
89
+ "their",
90
+ "theirs",
91
+ "them",
92
+ "then",
93
+ "there",
94
+ "these",
95
+ "they",
96
+ "this",
97
+ "those",
98
+ "through",
99
+ "under",
100
+ "until",
101
+ "very",
102
+ "were",
103
+ "what",
104
+ "when",
105
+ "where",
106
+ "which",
107
+ "while",
108
+ "will",
109
+ "with",
110
+ "would",
111
+ "your",
112
+ "yours",
113
+ ]
114
+ )
115
+
116
+
117
+ def keyword_grounding_score(output_text: str, source_text: str) -> float:
118
+ """Fraction of output content words absent from the grounding source.
119
+
120
+ 0.0 = every content word appears in the source; 1.0 = none do.
121
+ """
122
+ terms = {w for w in _WORD_RE.findall(output_text.lower()) if w not in _STOPWORDS}
123
+ if not terms:
124
+ return 0.0
125
+ source_terms = set(_WORD_RE.findall(source_text.lower()))
126
+ missing = terms - source_terms
127
+ return len(missing) / len(terms)
128
+
129
+
130
+ def _flatten_text(obj: Any) -> str:
131
+ """Join all strings inside nested dicts/lists into one scannable text."""
132
+ if isinstance(obj, str):
133
+ return obj
134
+ if isinstance(obj, dict):
135
+ return " ".join(_flatten_text(v) for v in obj.values())
136
+ if isinstance(obj, list):
137
+ return " ".join(_flatten_text(item) for item in obj)
138
+ return ""
139
+
140
+
141
+ class OutputSchemaPolicy(BasePolicy):
142
+ """Validates agent output against a Pydantic schema and scans for
143
+ dangerous content and hallucination signals. Runs in after_action.
144
+
145
+ Checks, in order:
146
+ 1. Schema: ``model.model_validate(context.output_data)``; if the dict
147
+ itself doesn't validate, a JSON string under one of the keys
148
+ "output"/"result"/"content"/"text"/"data" is parsed (flagging
149
+ invalid JSON) and validated instead. Missing required fields are
150
+ reported separately from other validation errors.
151
+ 2. Dangerous content: secrets/internal-URL patterns from SecretsPolicy
152
+ found anywhere in the output (detection only — pair with
153
+ SecretsPolicy for redaction).
154
+ 3. Grounding: when a grounding source is available (the
155
+ ``grounding_source`` kwarg, or per-request via
156
+ ``context.metadata["grounding_source"]``), scores how much of the
157
+ output is unsupported by the source and annotates
158
+ ``context.metadata["hallucination"]``. Annotation only — it does
159
+ not block; RiskScorer aggregates it. ``mode="embedding"`` uses
160
+ sentence-transformers (``pip install -e '.[ml]'``) and falls
161
+ back to keyword scoring when unavailable.
162
+
163
+ Results land in ``context.metadata["output_validation"]`` using the
164
+ standardized shape (``risk``: dangerous content 0.8, schema errors or
165
+ missing fields 0.5, valid 0.0; ``category``: "output_validation").
166
+ Grounding results land in ``context.metadata["hallucination"]`` with
167
+ ``risk`` set to the grounding score. When invalid and
168
+ ``on_invalid=DENY``, sets ``decision=DENY``.
169
+ """
170
+
171
+ failure_mode: FailureMode = FailureMode.FAIL_OPEN
172
+
173
+ def __init__(
174
+ self,
175
+ model: type[BaseModel] | None = None,
176
+ on_invalid: OnInvalid | str = OnInvalid.DENY,
177
+ grounding_source: str | None = None,
178
+ hallucination_mode: HallucinationMode | str = HallucinationMode.KEYWORD,
179
+ hallucination_threshold: float = 0.5,
180
+ writer: EventWriter | None = None,
181
+ ) -> None:
182
+ if not 0.0 <= hallucination_threshold <= 1.0:
183
+ raise ValueError(
184
+ f"hallucination_threshold must be in [0, 1], got {hallucination_threshold}"
185
+ )
186
+ self._model = model
187
+ self._on_invalid = OnInvalid(on_invalid)
188
+ self._grounding_source = grounding_source
189
+ self._hallucination_mode = HallucinationMode(hallucination_mode)
190
+ self._hallucination_threshold = hallucination_threshold
191
+ self._writer = writer
192
+ self._embedder: Any = None
193
+ self._sequence_counters: dict[str, int] = {}
194
+
195
+ async def after_action(self, context: ActionContext) -> ActionContext:
196
+ errors: list[str] = []
197
+ missing_fields: list[str] = []
198
+ if self._model is not None:
199
+ errors, missing_fields = self._validate_schema(context.output_data)
200
+
201
+ output_text = _flatten_text(context.output_data)
202
+ dangerous = sorted({label for _, label in find_secrets(output_text)})
203
+
204
+ valid = not errors and not missing_fields and not dangerous
205
+ if dangerous:
206
+ risk = 0.8
207
+ elif errors or missing_fields:
208
+ risk = 0.5
209
+ else:
210
+ risk = 0.0
211
+ context.metadata["output_validation"] = {
212
+ "risk": risk,
213
+ "category": "output_validation",
214
+ "valid": valid,
215
+ "errors": errors,
216
+ "missing_fields": missing_fields,
217
+ "dangerous_content": dangerous,
218
+ "on_invalid": self._on_invalid.value,
219
+ }
220
+
221
+ source = context.metadata.get("grounding_source", self._grounding_source)
222
+ if source is not None:
223
+ self._check_grounding(context, output_text, str(source))
224
+
225
+ if not valid:
226
+ logger.warning(
227
+ "output_validation_failed",
228
+ session_id=context.session_id,
229
+ agent_id=context.agent_id,
230
+ errors=errors,
231
+ missing_fields=missing_fields,
232
+ dangerous_content=dangerous,
233
+ on_invalid=self._on_invalid.value,
234
+ )
235
+ if self._on_invalid == OnInvalid.DENY:
236
+ problems = errors + [f"missing field: {f}" for f in missing_fields]
237
+ if dangerous:
238
+ problems.append(f"dangerous content: {', '.join(dangerous)}")
239
+ context.decision = InterceptorDecision.DENY
240
+ context.decision_reason = f"Output validation failed: {'; '.join(problems[:3])}"
241
+ if self._writer is not None:
242
+ await self._emit_validation_event(context)
243
+
244
+ return context
245
+
246
+ # -- schema ------------------------------------------------------------
247
+
248
+ def _validate_schema(self, output_data: dict[str, Any]) -> tuple[list[str], list[str]]:
249
+ """Return (errors, missing_fields); both empty when the output validates."""
250
+ assert self._model is not None
251
+ try:
252
+ self._model.model_validate(output_data)
253
+ return [], []
254
+ except ValidationError as direct_err:
255
+ candidate = self._json_candidate(output_data)
256
+ if candidate is None:
257
+ return self._collect_errors(direct_err)
258
+ try:
259
+ parsed = json.loads(candidate)
260
+ except json.JSONDecodeError as exc:
261
+ return [f"output is not valid JSON: {exc.msg}"], []
262
+ try:
263
+ self._model.model_validate(parsed)
264
+ return [], []
265
+ except ValidationError as parsed_err:
266
+ return self._collect_errors(parsed_err)
267
+
268
+ @staticmethod
269
+ def _json_candidate(output_data: dict[str, Any]) -> str | None:
270
+ for key in _JSON_CANDIDATE_KEYS:
271
+ val = output_data.get(key)
272
+ if isinstance(val, str):
273
+ return val
274
+ return None
275
+
276
+ @staticmethod
277
+ def _collect_errors(err: ValidationError) -> tuple[list[str], list[str]]:
278
+ errors: list[str] = []
279
+ missing: list[str] = []
280
+ for detail in err.errors():
281
+ loc = ".".join(str(part) for part in detail["loc"]) or "<root>"
282
+ if detail["type"] == "missing":
283
+ missing.append(loc)
284
+ else:
285
+ errors.append(f"{loc}: {detail['msg']}")
286
+ return errors, missing
287
+
288
+ # -- grounding / hallucination ------------------------------------------
289
+
290
+ def _check_grounding(self, context: ActionContext, output_text: str, source: str) -> None:
291
+ mode = self._hallucination_mode
292
+ score: float | None = None
293
+ if mode == HallucinationMode.EMBEDDING:
294
+ score = self._embedding_score(output_text, source)
295
+ if score is None:
296
+ mode = HallucinationMode.KEYWORD
297
+ if score is None:
298
+ score = keyword_grounding_score(output_text, source)
299
+
300
+ flagged = score >= self._hallucination_threshold
301
+ context.metadata["hallucination"] = {
302
+ "risk": round(score, 4),
303
+ "category": "hallucination",
304
+ "mode": mode.value,
305
+ "flagged": flagged,
306
+ "threshold": self._hallucination_threshold,
307
+ }
308
+ if flagged:
309
+ logger.warning(
310
+ "hallucination_flagged",
311
+ session_id=context.session_id,
312
+ agent_id=context.agent_id,
313
+ score=round(score, 4),
314
+ mode=mode.value,
315
+ )
316
+
317
+ def _embedding_score(self, output_text: str, source: str) -> float | None:
318
+ """1 - cosine similarity via sentence-transformers; None when unavailable."""
319
+ try:
320
+ from sentence_transformers import SentenceTransformer, util
321
+ except ImportError:
322
+ logger.warning(
323
+ "embedding_backend_unavailable",
324
+ hint="pip install -e '.[ml]'; falling back to keyword grounding",
325
+ )
326
+ return None
327
+ if self._embedder is None:
328
+ self._embedder = SentenceTransformer("all-MiniLM-L6-v2")
329
+ embeddings = self._embedder.encode([output_text, source])
330
+ similarity = float(util.cos_sim(embeddings[0], embeddings[1]).item())
331
+ return max(0.0, 1.0 - similarity)
332
+
333
+ # -- events --------------------------------------------------------------
334
+
335
+ async def _emit_validation_event(self, context: ActionContext) -> None:
336
+ seq = self._next_sequence(context.session_id)
337
+ event = Event(
338
+ session_id=context.session_id,
339
+ agent_id=context.agent_id,
340
+ event_type=EventType.POLICY_EVALUATED,
341
+ payload={
342
+ "policy": "output_schema",
343
+ "output_validation": context.metadata["output_validation"],
344
+ "decision": context.decision.value,
345
+ },
346
+ sequence_num=seq,
347
+ )
348
+ await self._writer.write(event) # type: ignore[union-attr]
349
+
350
+ def _next_sequence(self, session_id: str) -> int:
351
+ count = self._sequence_counters.get(session_id, 0) + 1
352
+ self._sequence_counters[session_id] = count
353
+ return count
@@ -0,0 +1,36 @@
1
+ # Example policy-engine config for load_policies().
2
+ # Policies run in declaration order. Params are per-policy; omit a mapping
3
+ # (or leave it empty) to use defaults.
4
+ #
5
+ # from sdk import load_policies, SecureAgent
6
+ # agent = SecureAgent(agent, policies=load_policies("policies.yaml"))
7
+
8
+ policies:
9
+ prompt_injection:
10
+ threshold: 0.5 # block inputs scoring at or above this [0, 1]
11
+ # tool_output: annotate # around a tool: off | annotate | deny what it returned
12
+ toxicity:
13
+ threshold: 0.5 # abusive expression: violence, hate, harassment, self-harm
14
+ harmful_request:
15
+ threshold: 0.5 # requests for harmful capability: weapons, malware, doxxing, …
16
+ pii:
17
+ mode: redact # redact | deny
18
+ secrets:
19
+ mode: redact # redact | deny — also covers internal URLs
20
+ # Acts on tool calls only. In a SecureAgent's list it sees nothing and does
21
+ # nothing; give the policies to SecureTool to put it in front of a tool.
22
+ tool_firewall:
23
+ rules_path: openscript_sdk/policies/tools_example.yaml
24
+ # Also tool calls only. Once a session has run one of `sources`, every call
25
+ # to one of `sinks` waits for a person. Neither list has a default, and the
26
+ # one list of policies has to be shared by all of a session's tools: what a
27
+ # session has read is remembered on the policy instance.
28
+ # session_taint:
29
+ # sources: [fetch_issue, read_page] # or "*": every tool's result taints
30
+ # sinks: [send_email, run_shell] # or "*": every tool is held
31
+ # on_taint: require_approval # require_approval | deny
32
+ # output_schema:
33
+ # model: myapp.schemas:ReportModel # dotted path to a pydantic model
34
+ # on_invalid: deny # deny | annotate
35
+ # hallucination_mode: keyword # keyword | embedding (pip install -e ".[ml]")
36
+ # hallucination_threshold: 0.5