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,231 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Literal
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Request
7
+ from fastapi.encoders import jsonable_encoder
8
+ from pydantic import BaseModel, Field
9
+
10
+ from openscript_sdk.contracts.types import ActionBlockedError, ActionContext
11
+ from openscript_sdk.server.auth import require_api_key
12
+ from openscript_sdk.server.guard import SECTIONS, Checkpoint
13
+
14
+ router = APIRouter(prefix="/v1", tags=["guard"])
15
+
16
+
17
+ class GuardRequest(BaseModel):
18
+ """One phase of one action, asked by a client that runs the action itself."""
19
+
20
+ phase: Literal["input", "output"]
21
+ #: "invoke" for an agent's input and output, "stream" for the same when
22
+ #: the answer is streamed (SecureAgent.stream's action), "tool_call" for a
23
+ #: tool's {"name", "args"} and its result. Tools go to a different list.
24
+ action: Literal["invoke", "stream", "tool_call"]
25
+ session_id: str = "default"
26
+ agent_id: str = "default"
27
+ input: dict[str, Any]
28
+ #: Required for phase "output": what the agent or tool returned.
29
+ output: dict[str, Any] | None = None
30
+ #: The caller's role, for tool rules restricted by role.
31
+ role: str | None = None
32
+ #: An approved approval to redeem in the input phase.
33
+ approval_id: str = ""
34
+
35
+
36
+ class GuardResponse(BaseModel):
37
+ decision: Literal["allow", "deny", "require_approval"]
38
+ reason: str = ""
39
+ blocked_by: str = ""
40
+ #: Set when decision is "require_approval": decide it through
41
+ #: /v1/approvals, then ask again with it.
42
+ approval_id: str = ""
43
+ #: The input as the policies left it. Give the agent or tool this, not
44
+ #: what was sent: a redacting policy replaces what it redacts.
45
+ input: dict[str, Any]
46
+ #: The output as the policies left it (phase "output" only).
47
+ output: dict[str, Any] | None = None
48
+ risk_score: float = 0.0
49
+ risk_categories: dict[str, float] = Field(default_factory=dict)
50
+ #: What each policy wrote about this phase, keyed by its category.
51
+ findings: dict[str, Any] = Field(default_factory=dict)
52
+ #: Time spent in the pipeline, measured inside the server, so a client can
53
+ #: tell the policies' cost from the network's.
54
+ pipeline_ms: float = 0.0
55
+ #: Action "stream", phase "input" only: the guards that scan a guarded
56
+ #: stream as it goes, and the tail a client must hold back for them.
57
+ stream_guards: list[str] | None = None
58
+ stream_window: int | None = None
59
+
60
+
61
+ class StreamScanRequest(BaseModel):
62
+ """The text a client is holding back in a guarded stream, to be scanned."""
63
+
64
+ session_id: str = "default"
65
+ agent_id: str = "default"
66
+ #: The input as the input phase left it. Only for the record of a refusal.
67
+ input: dict[str, Any] = Field(default_factory=dict)
68
+ #: The held tail plus whatever has arrived since.
69
+ text: str
70
+ #: True at the end of the stream: matches at the edge are now definite.
71
+ final: bool = False
72
+
73
+
74
+ class StreamScanResponse(BaseModel):
75
+ decision: Literal["allow", "deny"]
76
+ #: The text as the guards left it (redacted); "" when denied.
77
+ text: str = ""
78
+ reason: str = ""
79
+ blocked_by: str = ""
80
+ labels: list[str] = Field(default_factory=list)
81
+ risk: float = 0.0
82
+ pipeline_ms: float = 0.0
83
+
84
+
85
+ def _checkpoint(request: Request, action: str) -> Checkpoint:
86
+ checkpoints: dict[str, Checkpoint] | None = getattr(request.app.state, "guard", None)
87
+ if not checkpoints:
88
+ raise HTTPException(
89
+ status_code=503,
90
+ detail="no policies configured; start the server with OPENSCRIPT_GUARD_CONFIG",
91
+ )
92
+ checkpoint = checkpoints.get(action)
93
+ if checkpoint is None:
94
+ raise HTTPException(
95
+ status_code=503,
96
+ detail=f"the guard config has no '{SECTIONS[action]}' section for action '{action}'",
97
+ )
98
+ return checkpoint
99
+
100
+
101
+ def _ms(start: float) -> float:
102
+ return round((time.perf_counter() - start) * 1000, 3)
103
+
104
+
105
+ def _response(
106
+ context: ActionContext,
107
+ phase: str,
108
+ decision: str,
109
+ start: float,
110
+ blocked_by: str = "",
111
+ approval_id: str = "",
112
+ ) -> GuardResponse:
113
+ findings = {
114
+ key: value
115
+ for key, value in context.metadata.items()
116
+ if isinstance(value, dict) and ("risk" in value or "category" in value)
117
+ }
118
+ return GuardResponse(
119
+ decision=decision, # type: ignore[arg-type]
120
+ reason=context.decision_reason,
121
+ blocked_by=blocked_by,
122
+ approval_id=approval_id,
123
+ input=jsonable_encoder(context.input_data),
124
+ # Withheld when refused: what a policy blocked is not handed back to be used.
125
+ output=(
126
+ jsonable_encoder(context.output_data)
127
+ if phase == "output" and decision == "allow"
128
+ else None
129
+ ),
130
+ risk_score=round(context.risk_score, 4),
131
+ risk_categories=context.risk_categories,
132
+ findings=jsonable_encoder(findings),
133
+ pipeline_ms=_ms(start),
134
+ )
135
+
136
+
137
+ @router.post("/guard", dependencies=[Depends(require_api_key)])
138
+ async def guard(req: GuardRequest, request: Request) -> GuardResponse:
139
+ """Run one phase of the configured policies over an action the caller runs.
140
+
141
+ A refusal is an answer, not an error: it comes back with status 200 and
142
+ decision "deny" or "require_approval". A policy that fails closed is an
143
+ error (500), and so is a server with nothing configured (503). A client
144
+ must treat every error as a refusal.
145
+ """
146
+ checkpoint = _checkpoint(request, req.action)
147
+ if req.phase == "output" and req.output is None:
148
+ raise HTTPException(status_code=422, detail="phase 'output' needs 'output'")
149
+
150
+ metadata: dict[str, Any] = {}
151
+ if req.action == "tool_call":
152
+ name, args = req.input.get("name"), req.input.get("args")
153
+ if not isinstance(name, str) or not name or not isinstance(args, dict):
154
+ raise HTTPException(
155
+ status_code=422,
156
+ detail="a tool_call's input is {'name': <tool>, 'args': {...}}",
157
+ )
158
+ # Every argument came from the caller: there are no defaults to tell apart.
159
+ metadata["defaulted_args"] = []
160
+ if req.role is not None:
161
+ metadata["role"] = req.role
162
+
163
+ context = ActionContext(
164
+ action=req.action,
165
+ agent_id=req.agent_id,
166
+ session_id=req.session_id,
167
+ input_data=req.input,
168
+ output_data=req.output or {},
169
+ metadata=metadata,
170
+ )
171
+ start = time.perf_counter()
172
+ try:
173
+ if req.phase == "input":
174
+ context = await checkpoint.check_input(context, req.approval_id)
175
+ else:
176
+ context = await checkpoint.check_output(context)
177
+ except ActionBlockedError as blocked:
178
+ context = blocked.context or context
179
+ decision = "require_approval" if blocked.approval_id else "deny"
180
+ return _response(
181
+ context, req.phase, decision, start, blocked.interceptor, blocked.approval_id
182
+ )
183
+ except RuntimeError as exc:
184
+ # A FAIL_CLOSED policy that raised. The action is refused either way.
185
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
186
+
187
+ response = _response(context, req.phase, "allow", start)
188
+ if req.action == "stream" and req.phase == "input":
189
+ guards = checkpoint.stream_guards()
190
+ response.stream_guards = [guard.name for guard in guards]
191
+ # What SecureAgent holds back: no match a guard can make is longer.
192
+ response.stream_window = max((guard.max_match_len for guard in guards), default=0)
193
+ return response
194
+
195
+
196
+ @router.post("/guard/stream", dependencies=[Depends(require_api_key)])
197
+ async def guard_stream(req: StreamScanRequest, request: Request) -> StreamScanResponse:
198
+ """Scan the held-back text of a guarded stream, as SecureAgent does per chunk.
199
+
200
+ The client releases everything but the last stream_window characters of
201
+ what comes back, and sends the rest again with the next chunk. A deny
202
+ comes back as an answer with no text: nothing of the match is released.
203
+ """
204
+ checkpoint = _checkpoint(request, "stream")
205
+ context = ActionContext(
206
+ action="stream",
207
+ agent_id=req.agent_id,
208
+ session_id=req.session_id,
209
+ input_data=req.input,
210
+ )
211
+ start = time.perf_counter()
212
+ try:
213
+ text = await checkpoint.scan_stream(context, req.text, req.final)
214
+ except ActionBlockedError as blocked:
215
+ found = context.metadata.get("stream_guard", {})
216
+ return StreamScanResponse(
217
+ decision="deny",
218
+ reason=blocked.reason,
219
+ blocked_by=blocked.interceptor,
220
+ labels=found.get("labels", []),
221
+ risk=found.get("risk", 0.0),
222
+ pipeline_ms=_ms(start),
223
+ )
224
+ found = context.metadata.get("stream_guard", {})
225
+ return StreamScanResponse(
226
+ decision="allow",
227
+ text=text,
228
+ labels=found.get("labels", []),
229
+ risk=found.get("risk", 0.0),
230
+ pipeline_ms=_ms(start),
231
+ )
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, Request
4
+ from sqlalchemy import case, func, select
5
+
6
+ from openscript_sdk.contracts.server_types import EventType
7
+ from openscript_sdk.events.models import EventRow
8
+ from openscript_sdk.events.store import EventStore
9
+ from openscript_sdk.server.auth import require_api_key
10
+
11
+ router = APIRouter(prefix="/sessions", tags=["sessions"])
12
+
13
+
14
+ def _get_event_store(request: Request) -> EventStore:
15
+ return request.app.state.event_store # type: ignore[no-any-return]
16
+
17
+
18
+ @router.get("", dependencies=[Depends(require_api_key)])
19
+ async def list_sessions(store: EventStore = Depends(_get_event_store)) -> dict:
20
+ """List all sessions with aggregate metadata."""
21
+ async with store._session_factory() as session:
22
+ stmt = (
23
+ select(
24
+ EventRow.session_id,
25
+ func.count(EventRow.id).label("event_count"),
26
+ func.max(EventRow.timestamp).label("last_activity"),
27
+ func.sum(
28
+ case(
29
+ (EventRow.event_type == EventType.THREAT_DETECTED.value, 1),
30
+ else_=0,
31
+ )
32
+ ).label("threat_count"),
33
+ )
34
+ .group_by(EventRow.session_id)
35
+ .order_by(func.max(EventRow.timestamp).desc())
36
+ )
37
+ result = await session.execute(stmt)
38
+ rows = result.all()
39
+
40
+ sessions = [
41
+ {
42
+ "session_id": row.session_id,
43
+ "event_count": row.event_count,
44
+ "threat_count": int(row.threat_count or 0),
45
+ "last_activity": row.last_activity.isoformat() if row.last_activity else None,
46
+ "flagged": int(row.threat_count or 0) > 0,
47
+ }
48
+ for row in rows
49
+ ]
50
+ return {"count": len(sessions), "sessions": sessions}
51
+
52
+
53
+ @router.get("/{session_id}/stats", dependencies=[Depends(require_api_key)])
54
+ async def session_stats(
55
+ session_id: str,
56
+ store: EventStore = Depends(_get_event_store),
57
+ ) -> dict:
58
+ """Return per-event-type counts and threat info for a session."""
59
+ events = await store.get_events(session_id=session_id, limit=10_000)
60
+
61
+ type_counts: dict[str, int] = {}
62
+ threat_scores: list[float] = []
63
+
64
+ for event in events:
65
+ type_counts[event.event_type.value] = type_counts.get(event.event_type.value, 0) + 1
66
+ if event.event_type == EventType.THREAT_DETECTED:
67
+ score = event.payload.get("score")
68
+ if isinstance(score, (int, float)):
69
+ threat_scores.append(float(score))
70
+
71
+ return {
72
+ "session_id": session_id,
73
+ "event_count": len(events),
74
+ "event_type_counts": type_counts,
75
+ "threat_count": len(threat_scores),
76
+ "max_threat_score": round(max(threat_scores), 4) if threat_scores else 0.0,
77
+ "flagged": len(threat_scores) > 0,
78
+ "first_event": events[0].timestamp.isoformat() if events else None,
79
+ "last_event": events[-1].timestamp.isoformat() if events else None,
80
+ }
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+
6
+ from fastapi import APIRouter, Depends, Query, Request
7
+ from fastapi.responses import StreamingResponse
8
+
9
+ from openscript_sdk.events.store import EventStore
10
+ from openscript_sdk.server.auth import require_api_key
11
+
12
+ router = APIRouter(tags=["streaming"])
13
+
14
+ _POLL_INTERVAL = 0.5 # seconds between EventStore polls
15
+ _KEEPALIVE_INTERVAL = 15 # seconds between SSE keepalive comments
16
+
17
+
18
+ def _get_event_store(request: Request) -> EventStore:
19
+ return request.app.state.event_store # type: ignore[no-any-return]
20
+
21
+
22
+ @router.get(
23
+ "/events/{session_id}/stream",
24
+ dependencies=[Depends(require_api_key)],
25
+ response_class=StreamingResponse,
26
+ )
27
+ async def stream_events(
28
+ session_id: str,
29
+ after_sequence: int = Query(default=0, ge=0),
30
+ store: EventStore = Depends(_get_event_store),
31
+ ) -> StreamingResponse:
32
+ """Server-Sent Events stream for a session.
33
+
34
+ Clients pass ?after_sequence=N to resume from a known position.
35
+ The server polls EventStore every 0.5 s and pushes new events.
36
+ A keepalive comment is emitted every 15 s to prevent proxy timeouts.
37
+ """
38
+
39
+ async def _generate():
40
+ cursor = after_sequence
41
+ keepalive_ticks = 0
42
+
43
+ while True:
44
+ events = await store.get_events(
45
+ session_id=session_id,
46
+ after_sequence=cursor,
47
+ limit=100,
48
+ )
49
+
50
+ for event in events:
51
+ data = json.dumps(event.model_dump(mode="json"))
52
+ yield f"data: {data}\n\n"
53
+ cursor = max(cursor, event.sequence_num)
54
+
55
+ keepalive_ticks += 1
56
+ if keepalive_ticks * _POLL_INTERVAL >= _KEEPALIVE_INTERVAL:
57
+ yield ": keepalive\n\n"
58
+ keepalive_ticks = 0
59
+
60
+ await asyncio.sleep(_POLL_INTERVAL)
61
+
62
+ return StreamingResponse(
63
+ _generate(),
64
+ media_type="text/event-stream",
65
+ headers={
66
+ "Cache-Control": "no-cache",
67
+ "X-Accel-Buffering": "no",
68
+ },
69
+ )
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from openscript_sdk.contracts.server_types import (
6
+ PolicyEvalRequest,
7
+ PolicyEvalResponse,
8
+ ThreatScoreRequest,
9
+ ThreatScoreResponse,
10
+ ToolValidateRequest,
11
+ ToolValidateResponse,
12
+ )
13
+ from openscript_sdk.interceptors.threat import score_text
14
+ from openscript_sdk.policies.tool_firewall import ToolRule, ToolRules, validate_tool_call
15
+ from openscript_sdk.server.auth import require_api_key
16
+
17
+ router = APIRouter(prefix="/v1", tags=["security"])
18
+
19
+ # Policy rules: maps capability flag → list of actions that require it.
20
+ # An action is denied when the agent lacks a required capability.
21
+ _CAPABILITY_RULES: dict[str, list[str]] = {
22
+ "external_write": ["send_email", "write_file", "post_webhook", "create_record"],
23
+ "sensitive_access": ["read_database", "read_secrets", "access_pii"],
24
+ "untrusted_input": [], # always allowed — flag is informational
25
+ }
26
+
27
+ # BuildToolRules from _CAPABILITY_RULES so /v1/tools/validate enforces
28
+ # the same capability-gated actions the rest of the sever knows about
29
+ _TOOL_RULES = ToolRules(
30
+ default_deny=False,
31
+ rules={
32
+ action: ToolRule(
33
+ allowed_roles=["*"],
34
+ # flag actions that require sensitive_access as needing approval
35
+ requires_approval=(capability == "sensitive_access"),
36
+ )
37
+ for capability, actions in _CAPABILITY_RULES.items()
38
+ for action in actions
39
+ },
40
+ )
41
+
42
+
43
+ @router.post("/threat/score", dependencies=[Depends(require_api_key)])
44
+ async def threat_score(req: ThreatScoreRequest) -> ThreatScoreResponse:
45
+ """Score arbitrary text for prompt-injection signals.
46
+
47
+ Stateless — no database access. Safe to call on every LLM turn.
48
+ """
49
+ text = req.text
50
+ if req.context.get("include_turn_number") and req.turn_number > 0:
51
+ # Slightly lower threshold tolerance for later turns (heuristic)
52
+ pass
53
+
54
+ score, signals = score_text(text)
55
+ flagged = score >= 0.7
56
+ reason = ""
57
+ if flagged:
58
+ top = max(signals, key=lambda k: signals[k]) if signals else "unknown"
59
+ reason = f"score {score:.2f} — top signal: {top}"
60
+
61
+ return ThreatScoreResponse(
62
+ session_id=req.session_id,
63
+ score=round(score, 4),
64
+ signals={k: round(v, 4) for k, v in signals.items()},
65
+ flagged=flagged,
66
+ reason=reason,
67
+ )
68
+
69
+
70
+ @router.post("/policy/evaluate", dependencies=[Depends(require_api_key)])
71
+ async def policy_evaluate(req: PolicyEvalRequest) -> PolicyEvalResponse:
72
+ """Evaluate an action against capability-based policy rules.
73
+
74
+ Returns ALLOW or DENY with the list of policies evaluated.
75
+ """
76
+ evaluated: list[str] = []
77
+ denied_reasons: list[str] = []
78
+
79
+ for capability, restricted_actions in _CAPABILITY_RULES.items():
80
+ if req.action in restricted_actions:
81
+ policy_name = f"requires_{capability}"
82
+ evaluated.append(policy_name)
83
+ agent_has_cap = req.capabilities.get(capability, False)
84
+ if not agent_has_cap:
85
+ denied_reasons.append(f"action '{req.action}' requires capability '{capability}'")
86
+
87
+ if denied_reasons:
88
+ return PolicyEvalResponse(
89
+ session_id=req.session_id,
90
+ decision="deny",
91
+ reason="; ".join(denied_reasons),
92
+ policies_evaluated=evaluated,
93
+ )
94
+
95
+ return PolicyEvalResponse(
96
+ session_id=req.session_id,
97
+ decision="allow",
98
+ reason="all policies passed",
99
+ policies_evaluated=evaluated or ["default_allow"],
100
+ )
101
+
102
+
103
+ @router.post("/tools/validate", dependencies=[Depends(require_api_key)])
104
+ async def validate_tool(req: ToolValidateRequest) -> ToolValidateResponse:
105
+ """Validate a tool call against the server-level firewall rules.
106
+
107
+ Uses the same rules engine as ToolFirewallPolicy so SDK and server
108
+ behavior are always consistent.
109
+ """
110
+ result = validate_tool_call(
111
+ {"name": req.tool_name, "args": req.args},
112
+ role=req.role,
113
+ rules=_TOOL_RULES,
114
+ )
115
+ return ToolValidateResponse(
116
+ allowed=result["allowed"],
117
+ reason=result["reason"],
118
+ requires_approval=result.get("requires_approval", False),
119
+ )