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,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from openscript_sdk.contracts.interceptor import BasePolicy
|
|
11
|
+
from openscript_sdk.contracts.server_types import Event, EventType
|
|
12
|
+
from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
|
|
13
|
+
from openscript_sdk.policies.secrets import find_secrets
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from openscript_sdk.events.writer import EventWriter
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# PHI pattern bank
|
|
22
|
+
# Structural patterns for protected-health-information categories a regex can
|
|
23
|
+
# honestly detect: coded identifiers and identifier-labelled values, not
|
|
24
|
+
# free-text medical meaning.
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class _PHIPattern:
|
|
30
|
+
regex: re.Pattern[str]
|
|
31
|
+
label: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _p(pattern: str, label: str) -> _PHIPattern:
|
|
35
|
+
return _PHIPattern(re.compile(pattern, re.IGNORECASE), label)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_PHI_PATTERNS: list[_PHIPattern] = [
|
|
39
|
+
# ICD-10 diagnosis codes — dotted form only (E11.9), the undotted form
|
|
40
|
+
# collides with too many product/part numbers
|
|
41
|
+
_PHIPattern(re.compile(r"\b[A-TV-Z]\d{2}\.\d{1,4}\b"), "icd10_code"),
|
|
42
|
+
# Medical record numbers introduced by their label
|
|
43
|
+
_p(r"\b(?:mrn|medical\s+record\s+(?:number|no\.?))\s*[:#]?\s*\d{5,}\b", "mrn"),
|
|
44
|
+
# Insurance / member identifiers introduced by their label
|
|
45
|
+
_p(
|
|
46
|
+
r"\b(?:member|policy|subscriber|insurance)\s+(?:id|number|no\.?)\s*[:#]?\s*[A-Z0-9-]{5,}\b",
|
|
47
|
+
"insurance_id",
|
|
48
|
+
),
|
|
49
|
+
# Medication name (common drug-class suffixes) followed by a dosage
|
|
50
|
+
_p(
|
|
51
|
+
r"\b[a-z]+(?:cillin|mycin|statin|prazole|sartan|olol|pril|formin|azepam|oxetine|codone)\b"
|
|
52
|
+
r"\s+\d+\s*(?:mg|mcg|ml)\b",
|
|
53
|
+
"medication_dosage",
|
|
54
|
+
),
|
|
55
|
+
# Explicit diagnosis statements
|
|
56
|
+
_p(r"\b(?:diagnosed\s+with|diagnosis\s+of)\s+[a-z]", "diagnosis_mention"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
_CREDENTIAL_RISK = 0.9
|
|
60
|
+
_PHI_RISK = 0.6
|
|
61
|
+
|
|
62
|
+
# Preset name -> description; the public contract of CompliancePolicy(rules=...)
|
|
63
|
+
KNOWN_RULES: dict[str, str] = {
|
|
64
|
+
"phi_detection": "detect PHI identifiers (ICD-10 codes, MRNs, insurance IDs, "
|
|
65
|
+
"medication dosages, diagnosis statements) in input and output",
|
|
66
|
+
"credential_output_guard": "deny any output containing raw credential material "
|
|
67
|
+
"(reuses the secrets pattern bank)",
|
|
68
|
+
"data_access_audit": "emit an audit event for every tool call",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PHIMode(StrEnum):
|
|
73
|
+
ANNOTATE = "annotate"
|
|
74
|
+
DENY = "deny"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def find_phi(text: str) -> list[tuple[str, str]]:
|
|
78
|
+
"""Return list of (matched_text, label) pairs of PHI found in *text*.
|
|
79
|
+
|
|
80
|
+
Standalone helper — callable outside a policy pipeline.
|
|
81
|
+
"""
|
|
82
|
+
found: list[tuple[str, str]] = []
|
|
83
|
+
for pat in _PHI_PATTERNS:
|
|
84
|
+
for m in pat.regex.finditer(text):
|
|
85
|
+
found.append((m.group(), pat.label))
|
|
86
|
+
return found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _flatten_text(obj: Any) -> str:
|
|
90
|
+
if isinstance(obj, str):
|
|
91
|
+
return obj
|
|
92
|
+
if isinstance(obj, dict):
|
|
93
|
+
return " ".join(_flatten_text(v) for v in obj.values())
|
|
94
|
+
if isinstance(obj, list):
|
|
95
|
+
return " ".join(_flatten_text(item) for item in obj)
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CompliancePolicy(BasePolicy):
|
|
100
|
+
"""Concrete, honestly-scoped checks that assist compliance programs.
|
|
101
|
+
|
|
102
|
+
This policy ASSISTS compliance work (its checks map onto common
|
|
103
|
+
GDPR/HIPAA/SOC2 controls); it does not confer or certify compliance
|
|
104
|
+
with any regulation. Presets are named after what they check:
|
|
105
|
+
|
|
106
|
+
- "phi_detection": scan input (before_action) and output (after_action)
|
|
107
|
+
for PHI identifiers — ICD-10 codes, MRNs, insurance IDs, medication
|
|
108
|
+
dosages, diagnosis statements. Follows *phi_mode*: ANNOTATE (default)
|
|
109
|
+
records findings only; DENY blocks. Pair with PIIPolicy/SecretsPolicy
|
|
110
|
+
for redaction.
|
|
111
|
+
- "credential_output_guard": deny any output containing raw credential
|
|
112
|
+
material (secrets pattern bank, internal URLs excluded). Always
|
|
113
|
+
blocks — it is a guard, not a detector.
|
|
114
|
+
- "data_access_audit": emit a POLICY_EVALUATED audit event for every
|
|
115
|
+
tool call (requires a writer; annotates metadata either way).
|
|
116
|
+
|
|
117
|
+
Results accumulate in context.metadata["compliance"] using the
|
|
118
|
+
standardized shape (risk keeps the max across phases; credentials in
|
|
119
|
+
output score 0.9, PHI findings 0.6):
|
|
120
|
+
{"risk": float, "category": "compliance", "rules": [...],
|
|
121
|
+
"phi": [labels...], "credentials": [labels...], "audited": bool}
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
failure_mode: FailureMode = FailureMode.FAIL_OPEN
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
rules: list[str],
|
|
129
|
+
phi_mode: PHIMode | str = PHIMode.ANNOTATE,
|
|
130
|
+
writer: EventWriter | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
unknown = set(rules) - set(KNOWN_RULES)
|
|
133
|
+
if unknown:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"unknown compliance rule(s): {sorted(unknown)}; known rules: {sorted(KNOWN_RULES)}"
|
|
136
|
+
)
|
|
137
|
+
if not rules:
|
|
138
|
+
raise ValueError(f"rules must name at least one preset: {sorted(KNOWN_RULES)}")
|
|
139
|
+
self._rules = list(dict.fromkeys(rules))
|
|
140
|
+
self._phi_mode = PHIMode(phi_mode)
|
|
141
|
+
self._writer = writer
|
|
142
|
+
self._sequence_counters: dict[str, int] = {}
|
|
143
|
+
|
|
144
|
+
async def before_action(self, context: ActionContext) -> ActionContext:
|
|
145
|
+
meta = self._meta(context)
|
|
146
|
+
|
|
147
|
+
if "data_access_audit" in self._rules and context.action == "tool_call":
|
|
148
|
+
meta["audited"] = True
|
|
149
|
+
if self._writer is not None:
|
|
150
|
+
await self._emit_event(
|
|
151
|
+
context,
|
|
152
|
+
policy="data_access_audit",
|
|
153
|
+
payload={
|
|
154
|
+
"tool": context.input_data.get("name", ""),
|
|
155
|
+
"arg_keys": sorted(context.input_data.get("args", {})),
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if "phi_detection" in self._rules:
|
|
160
|
+
await self._check_phi(context, context.input_data, phase="input")
|
|
161
|
+
|
|
162
|
+
return context
|
|
163
|
+
|
|
164
|
+
async def after_action(self, context: ActionContext) -> ActionContext:
|
|
165
|
+
meta = self._meta(context)
|
|
166
|
+
|
|
167
|
+
if "phi_detection" in self._rules:
|
|
168
|
+
await self._check_phi(context, context.output_data, phase="output")
|
|
169
|
+
|
|
170
|
+
if "credential_output_guard" in self._rules:
|
|
171
|
+
text = _flatten_text(context.output_data)
|
|
172
|
+
credentials = sorted(
|
|
173
|
+
{label for _, label in find_secrets(text) if label != "internal_url"}
|
|
174
|
+
)
|
|
175
|
+
if credentials:
|
|
176
|
+
meta["credentials"] = list(dict.fromkeys([*meta["credentials"], *credentials]))
|
|
177
|
+
meta["risk"] = max(meta["risk"], _CREDENTIAL_RISK)
|
|
178
|
+
context.decision = InterceptorDecision.DENY
|
|
179
|
+
context.decision_reason = f"Credential material in output: {', '.join(credentials)}"
|
|
180
|
+
logger.warning(
|
|
181
|
+
"credential_output_blocked",
|
|
182
|
+
session_id=context.session_id,
|
|
183
|
+
agent_id=context.agent_id,
|
|
184
|
+
types=credentials,
|
|
185
|
+
)
|
|
186
|
+
if self._writer is not None:
|
|
187
|
+
await self._emit_event(
|
|
188
|
+
context,
|
|
189
|
+
policy="credential_output_guard",
|
|
190
|
+
payload={"credential_types": credentials},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return context
|
|
194
|
+
|
|
195
|
+
async def _check_phi(self, context: ActionContext, data: dict[str, Any], phase: str) -> None:
|
|
196
|
+
meta = self._meta(context)
|
|
197
|
+
text = _flatten_text(data)
|
|
198
|
+
found = sorted({label for _, label in find_phi(text)})
|
|
199
|
+
if not found:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
meta["phi"] = list(dict.fromkeys([*meta["phi"], *found]))
|
|
203
|
+
meta["risk"] = max(meta["risk"], _PHI_RISK)
|
|
204
|
+
|
|
205
|
+
logger.warning(
|
|
206
|
+
"phi_detected",
|
|
207
|
+
session_id=context.session_id,
|
|
208
|
+
agent_id=context.agent_id,
|
|
209
|
+
types=found,
|
|
210
|
+
phase=phase,
|
|
211
|
+
mode=self._phi_mode.value,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if self._phi_mode == PHIMode.DENY:
|
|
215
|
+
context.decision = InterceptorDecision.DENY
|
|
216
|
+
context.decision_reason = f"PHI detected in {phase}: {', '.join(found)}"
|
|
217
|
+
|
|
218
|
+
if self._writer is not None:
|
|
219
|
+
await self._emit_event(
|
|
220
|
+
context,
|
|
221
|
+
policy="phi_detection",
|
|
222
|
+
payload={"phi_types": found, "phase": phase, "mode": self._phi_mode.value},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _meta(self, context: ActionContext) -> dict[str, Any]:
|
|
226
|
+
return cast(
|
|
227
|
+
dict[str, Any],
|
|
228
|
+
context.metadata.setdefault(
|
|
229
|
+
"compliance",
|
|
230
|
+
{
|
|
231
|
+
"risk": 0.0,
|
|
232
|
+
"category": "compliance",
|
|
233
|
+
"rules": self._rules,
|
|
234
|
+
"phi": [],
|
|
235
|
+
"credentials": [],
|
|
236
|
+
"audited": False,
|
|
237
|
+
},
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def _emit_event(
|
|
242
|
+
self, context: ActionContext, policy: str, payload: dict[str, Any]
|
|
243
|
+
) -> None:
|
|
244
|
+
seq = self._next_sequence(context.session_id)
|
|
245
|
+
event = Event(
|
|
246
|
+
session_id=context.session_id,
|
|
247
|
+
agent_id=context.agent_id,
|
|
248
|
+
event_type=EventType.POLICY_EVALUATED,
|
|
249
|
+
payload={
|
|
250
|
+
"policy": policy,
|
|
251
|
+
**payload,
|
|
252
|
+
"decision": context.decision.value,
|
|
253
|
+
},
|
|
254
|
+
sequence_num=seq,
|
|
255
|
+
)
|
|
256
|
+
await self._writer.write(event) # type: ignore[union-attr]
|
|
257
|
+
|
|
258
|
+
def _next_sequence(self, session_id: str) -> int:
|
|
259
|
+
count = self._sequence_counters.get(session_id, 0) + 1
|
|
260
|
+
self._sequence_counters[session_id] = count
|
|
261
|
+
return count
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from openscript_sdk.contracts.interceptor import Policy
|
|
12
|
+
from openscript_sdk.interceptors.base import NoopInterceptor
|
|
13
|
+
from openscript_sdk.interceptors.event_writer import AuditPolicy
|
|
14
|
+
from openscript_sdk.interceptors.pii import PIIMode, PIIPolicy
|
|
15
|
+
from openscript_sdk.interceptors.threat import PromptInjectionPolicy
|
|
16
|
+
from openscript_sdk.policies.compliance import CompliancePolicy, PHIMode
|
|
17
|
+
from openscript_sdk.policies.harmful_request import HarmfulRequestPolicy
|
|
18
|
+
from openscript_sdk.policies.output_schema import OutputSchemaPolicy
|
|
19
|
+
from openscript_sdk.policies.secrets import InternalURLMode, SecretsPolicy
|
|
20
|
+
from openscript_sdk.policies.session_taint import SessionTaintPolicy
|
|
21
|
+
from openscript_sdk.policies.tool_firewall import ToolFirewallPolicy, ToolRules
|
|
22
|
+
from openscript_sdk.policies.toxicity import ToxicityPolicy
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from openscript_sdk.events.writer import EventWriter
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PolicyConfig(BaseModel):
|
|
29
|
+
"""Declarative policy pipeline: ordered mapping of policy name -> params.
|
|
30
|
+
|
|
31
|
+
Example YAML (see policies_example.yaml):
|
|
32
|
+
|
|
33
|
+
policies:
|
|
34
|
+
prompt_injection:
|
|
35
|
+
threshold: 0.6
|
|
36
|
+
pii:
|
|
37
|
+
mode: redact
|
|
38
|
+
secrets:
|
|
39
|
+
mode: deny
|
|
40
|
+
tool_firewall:
|
|
41
|
+
rules_path: tools.yaml
|
|
42
|
+
|
|
43
|
+
Order is significant — policies run in declaration order.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
policies: dict[str, dict[str, Any] | None] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# A factory receives the (possibly empty) params dict for its entry plus the
|
|
50
|
+
# shared EventWriter (None when the caller doesn't persist events).
|
|
51
|
+
PolicyFactory = Callable[[dict[str, Any], "EventWriter | None"], Policy]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _take(params: dict[str, Any], name: str, allowed: set[str]) -> None:
|
|
55
|
+
unknown = set(params) - allowed
|
|
56
|
+
if unknown:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"unknown parameter(s) for policy '{name}': {sorted(unknown)}; "
|
|
59
|
+
f"allowed: {sorted(allowed)}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_prompt_injection(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
64
|
+
_take(params, "prompt_injection", {"threshold", "tool_output"})
|
|
65
|
+
return PromptInjectionPolicy(
|
|
66
|
+
threshold=params.get("threshold", 0.5),
|
|
67
|
+
writer=writer,
|
|
68
|
+
tool_output=params.get("tool_output", "annotate"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _build_pii(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
73
|
+
_take(params, "pii", {"mode"})
|
|
74
|
+
return PIIPolicy(mode=PIIMode(params.get("mode", "redact")), writer=writer)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_secrets(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
78
|
+
_take(params, "secrets", {"mode", "internal_url_mode", "internal_url_allowlist"})
|
|
79
|
+
return SecretsPolicy(
|
|
80
|
+
mode=PIIMode(params.get("mode", "redact")),
|
|
81
|
+
writer=writer,
|
|
82
|
+
internal_url_mode=InternalURLMode(params.get("internal_url_mode", "annotate")),
|
|
83
|
+
internal_url_allowlist=params.get("internal_url_allowlist", ()),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_tool_firewall(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
88
|
+
_take(params, "tool_firewall", {"rules", "rules_path"})
|
|
89
|
+
rules = params.get("rules")
|
|
90
|
+
return ToolFirewallPolicy(
|
|
91
|
+
rules=ToolRules.model_validate(rules) if rules is not None else None,
|
|
92
|
+
rules_path=params.get("rules_path"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_session_taint(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
97
|
+
_take(params, "session_taint", {"sources", "sinks", "on_taint", "known", "updates"})
|
|
98
|
+
missing = sorted({"sources", "sinks"} - set(params))
|
|
99
|
+
if missing:
|
|
100
|
+
# No defaults: which tools read untrusted content and which change
|
|
101
|
+
# state is a fact about the application, not something to guess.
|
|
102
|
+
raise ValueError(f"policy 'session_taint' needs {' and '.join(missing)}")
|
|
103
|
+
return SessionTaintPolicy(
|
|
104
|
+
sources=params["sources"],
|
|
105
|
+
sinks=params["sinks"],
|
|
106
|
+
on_taint=params.get("on_taint", "require_approval"),
|
|
107
|
+
known=params.get("known"),
|
|
108
|
+
updates=params.get("updates"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_model(path: str) -> type[BaseModel]:
|
|
113
|
+
"""Import a pydantic model from a dotted path: "pkg.module:Class" or "pkg.module.Class"."""
|
|
114
|
+
module_name, _, attr = path.partition(":") if ":" in path else path.rpartition(".")
|
|
115
|
+
if not module_name or not attr:
|
|
116
|
+
raise ValueError(f"invalid model path {path!r}; expected 'package.module:ClassName'")
|
|
117
|
+
try:
|
|
118
|
+
model = getattr(importlib.import_module(module_name), attr)
|
|
119
|
+
except (ImportError, AttributeError) as exc:
|
|
120
|
+
raise ValueError(f"cannot import model {path!r}: {exc}") from exc
|
|
121
|
+
if not (isinstance(model, type) and issubclass(model, BaseModel)):
|
|
122
|
+
raise ValueError(f"{path!r} is not a pydantic BaseModel subclass")
|
|
123
|
+
return model
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_output_schema(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
127
|
+
_take(
|
|
128
|
+
params,
|
|
129
|
+
"output_schema",
|
|
130
|
+
{
|
|
131
|
+
"model",
|
|
132
|
+
"on_invalid",
|
|
133
|
+
"grounding_source",
|
|
134
|
+
"hallucination_mode",
|
|
135
|
+
"hallucination_threshold",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
model = params.get("model")
|
|
139
|
+
return OutputSchemaPolicy(
|
|
140
|
+
model=_resolve_model(model) if isinstance(model, str) else model,
|
|
141
|
+
on_invalid=params.get("on_invalid", "deny"),
|
|
142
|
+
grounding_source=params.get("grounding_source"),
|
|
143
|
+
hallucination_mode=params.get("hallucination_mode", "keyword"),
|
|
144
|
+
hallucination_threshold=params.get("hallucination_threshold", 0.5),
|
|
145
|
+
writer=writer,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _build_toxicity(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
150
|
+
_take(params, "toxicity", {"threshold"})
|
|
151
|
+
return ToxicityPolicy(threshold=params.get("threshold", 0.5), writer=writer)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_harmful_request(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
155
|
+
_take(params, "harmful_request", {"threshold"})
|
|
156
|
+
return HarmfulRequestPolicy(threshold=params.get("threshold", 0.5), writer=writer)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _build_semantic(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
160
|
+
_take(params, "semantic", {"threshold", "mode", "local_files_only"})
|
|
161
|
+
# Imported here, not at module scope: the policy is opt-in, and the module
|
|
162
|
+
# that defines it is the one place a model dependency is allowed to live.
|
|
163
|
+
from openscript_sdk.policies.semantic import SemanticPolicy
|
|
164
|
+
|
|
165
|
+
return SemanticPolicy(
|
|
166
|
+
threshold=params.get("threshold"),
|
|
167
|
+
mode=params.get("mode", "deny"),
|
|
168
|
+
local_files_only=params.get("local_files_only", False),
|
|
169
|
+
writer=writer,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_compliance(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
174
|
+
_take(params, "compliance", {"rules", "phi_mode"})
|
|
175
|
+
return CompliancePolicy(
|
|
176
|
+
rules=params.get("rules", []),
|
|
177
|
+
phi_mode=PHIMode(params.get("phi_mode", "annotate")),
|
|
178
|
+
writer=writer,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_audit(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
183
|
+
_take(params, "audit", set())
|
|
184
|
+
if writer is None:
|
|
185
|
+
raise ValueError("policy 'audit' requires load_policies(..., writer=<EventWriter>)")
|
|
186
|
+
return AuditPolicy(writer)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _build_noop(params: dict[str, Any], writer: EventWriter | None) -> Policy:
|
|
190
|
+
_take(params, "noop", set())
|
|
191
|
+
return NoopInterceptor()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_REGISTRY: dict[str, PolicyFactory] = {
|
|
195
|
+
"prompt_injection": _build_prompt_injection,
|
|
196
|
+
"toxicity": _build_toxicity,
|
|
197
|
+
"harmful_request": _build_harmful_request,
|
|
198
|
+
"pii": _build_pii,
|
|
199
|
+
"secrets": _build_secrets,
|
|
200
|
+
"compliance": _build_compliance,
|
|
201
|
+
"semantic": _build_semantic,
|
|
202
|
+
"tool_firewall": _build_tool_firewall,
|
|
203
|
+
"session_taint": _build_session_taint,
|
|
204
|
+
"output_schema": _build_output_schema,
|
|
205
|
+
"audit": _build_audit,
|
|
206
|
+
"noop": _build_noop,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def register_policy(name: str, factory: PolicyFactory) -> None:
|
|
211
|
+
"""Register a custom policy factory under *name* for use in load_policies configs."""
|
|
212
|
+
_REGISTRY[name] = factory
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_policies(
|
|
216
|
+
config: PolicyConfig | dict[str, Any] | str | Path,
|
|
217
|
+
writer: EventWriter | None = None,
|
|
218
|
+
) -> list[Policy]:
|
|
219
|
+
"""Assemble a ``policies=[...]`` list from a config.
|
|
220
|
+
|
|
221
|
+
*config* may be a PolicyConfig, a dict (with or without the top-level
|
|
222
|
+
"policies" key), or a path to a YAML file. *writer* is injected into
|
|
223
|
+
policies that emit events (prompt_injection, pii, secrets, audit).
|
|
224
|
+
|
|
225
|
+
Raises ValueError on unknown policy names or parameters.
|
|
226
|
+
"""
|
|
227
|
+
if isinstance(config, (str, Path)):
|
|
228
|
+
with open(config) as fh:
|
|
229
|
+
raw = yaml.safe_load(fh) or {}
|
|
230
|
+
cfg = PolicyConfig.model_validate(raw)
|
|
231
|
+
elif isinstance(config, PolicyConfig):
|
|
232
|
+
cfg = config
|
|
233
|
+
else:
|
|
234
|
+
if "policies" not in config:
|
|
235
|
+
config = {"policies": config}
|
|
236
|
+
cfg = PolicyConfig.model_validate(config)
|
|
237
|
+
|
|
238
|
+
policies: list[Policy] = []
|
|
239
|
+
for name, params in cfg.policies.items():
|
|
240
|
+
factory = _REGISTRY.get(name)
|
|
241
|
+
if factory is None:
|
|
242
|
+
raise ValueError(f"unknown policy '{name}'; known policies: {sorted(_REGISTRY)}")
|
|
243
|
+
policies.append(factory(params or {}, writer))
|
|
244
|
+
return policies
|