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,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
|