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