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