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,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from openscript_sdk.contracts.interceptor import BasePolicy
|
|
10
|
+
from openscript_sdk.contracts.server_types import Event, EventType
|
|
11
|
+
from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
|
|
12
|
+
from openscript_sdk.interceptors.threat import _extract_text
|
|
13
|
+
from openscript_sdk.policies import normalize
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from openscript_sdk.events.writer import EventWriter
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Pattern bank
|
|
22
|
+
# Each entry: (compiled_regex, category_name, weight)
|
|
23
|
+
# Weights are additive within a category, capped at _CATEGORY_CAP; the total
|
|
24
|
+
# is the cross-category sum capped at 1.0 — same scoring model as
|
|
25
|
+
# PromptInjectionPolicy. Patterns are structural (verb + target constructs)
|
|
26
|
+
# rather than word lists, to keep false positives on quoted or reported
|
|
27
|
+
# speech manageable.
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class _Pattern:
|
|
33
|
+
regex: re.Pattern[str]
|
|
34
|
+
category: str
|
|
35
|
+
weight: float
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _p(pattern: str, category: str, weight: float) -> _Pattern:
|
|
39
|
+
return _Pattern(re.compile(pattern, re.IGNORECASE | re.MULTILINE), category, weight)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Weight tiers, on the same scale PromptInjectionPolicy uses. Where a line
|
|
43
|
+
# sits is a product decision as much as a detection one: a direct threat is
|
|
44
|
+
# refused, while intimidation and insult are recorded and passed on, because a
|
|
45
|
+
# gateway that blocks every unkind sentence stops being used. The evals in
|
|
46
|
+
# evals/toxicity.jsonl name which rows are meant to land in which band.
|
|
47
|
+
_DECISIVE = 0.60 # blocks on its own
|
|
48
|
+
_STRONG = 0.50 # blocks on its own, at the line
|
|
49
|
+
_MODERATE = 0.45 # recorded, needs a second signal to block
|
|
50
|
+
_WEAK = 0.30 # recorded only
|
|
51
|
+
|
|
52
|
+
_PATTERNS: list[_Pattern] = [
|
|
53
|
+
# Threatening / violent content directed at a person
|
|
54
|
+
_p(
|
|
55
|
+
r"\bi\s*(?:'|a)?m\s+going\s+to\s+(kill|hurt|shoot|stab|beat)\s+(you|him|her|them)\b",
|
|
56
|
+
"violence",
|
|
57
|
+
_DECISIVE,
|
|
58
|
+
),
|
|
59
|
+
_p(r"\bi\s+will\s+(kill|hurt|murder|shoot|stab)\s+(you|him|her|them)\b", "violence", _DECISIVE),
|
|
60
|
+
_p(r"\byou\s+(all\s+)?deserve\s+to\s+(die|suffer)\b", "violence", _STRONG),
|
|
61
|
+
# Intimidation: a threat in context, an ordinary sentence out of it.
|
|
62
|
+
_p(r"\bi\s+know\s+where\s+you\s+live\b", "violence", _MODERATE),
|
|
63
|
+
_p(r"\bwatch\s+your\s+back\b", "violence", _WEAK),
|
|
64
|
+
# Hate speech — dehumanizing constructs aimed at groups
|
|
65
|
+
_p(
|
|
66
|
+
r"\b(those|these)\s+people\s+are\s+(all\s+)?(vermin|subhuman|animals|cockroaches|a\s+disease)\b",
|
|
67
|
+
"hate_speech",
|
|
68
|
+
_STRONG,
|
|
69
|
+
),
|
|
70
|
+
_p(
|
|
71
|
+
r"\bshould\s+(all\s+)?be\s+(exterminated|eradicated|wiped\s+out)\b",
|
|
72
|
+
"hate_speech",
|
|
73
|
+
_DECISIVE,
|
|
74
|
+
),
|
|
75
|
+
_p(r"\bgo\s+back\s+to\s+(your|their)\s+country\b", "hate_speech", _MODERATE),
|
|
76
|
+
_p(
|
|
77
|
+
r"\b(immigrants?|foreigners?|refugees?|migrants?)\b.{0,40}?"
|
|
78
|
+
r"\b(ruining|destroying|infesting|invading|overrunning)\b.{0,40}?"
|
|
79
|
+
r"\b(this|our|the)\s+(country|nation|society)\b",
|
|
80
|
+
"hate_speech",
|
|
81
|
+
_STRONG,
|
|
82
|
+
),
|
|
83
|
+
_p(r"\bneed\s+to\s+be\s+dealt\s+with\b", "hate_speech", _MODERATE),
|
|
84
|
+
_p(
|
|
85
|
+
r"\b(people|they|those)\s+like\s+(that|them|those|him|her)\s+are\s+(a\s+)?"
|
|
86
|
+
r"(plague|disease|cancer|vermin|scum|parasites?|infestation)\b",
|
|
87
|
+
"hate_speech",
|
|
88
|
+
_STRONG,
|
|
89
|
+
),
|
|
90
|
+
_p(
|
|
91
|
+
r"\bshould\s+(all\s+)?be\s+(removed|purged|cleansed|driven\s+out)\s+from\s+"
|
|
92
|
+
r"(society|the\s+country|here)\b",
|
|
93
|
+
"hate_speech",
|
|
94
|
+
_DECISIVE,
|
|
95
|
+
),
|
|
96
|
+
_p(r"\bdon'?t\s+deserve\s+to\s+(live|exist)\b", "hate_speech", _STRONG),
|
|
97
|
+
# Harassment directed at the reader
|
|
98
|
+
_p(
|
|
99
|
+
r"\byou\s+are\s+(a\s+)?(worthless|pathetic|disgusting)(\s+(person|human|being))?\b",
|
|
100
|
+
"harassment",
|
|
101
|
+
_STRONG,
|
|
102
|
+
),
|
|
103
|
+
_p(r"\bnobody\s+(likes|loves|wants)\s+you\b", "harassment", _WEAK),
|
|
104
|
+
_p(
|
|
105
|
+
r"\bdo\s+(everyone|us\s+all|the\s+world)\s+a\s+favou?r\s+and\s+"
|
|
106
|
+
r"(disappear|die|leave\s+forever|kill)\b",
|
|
107
|
+
"harassment",
|
|
108
|
+
_DECISIVE,
|
|
109
|
+
),
|
|
110
|
+
_p(r"\bkill\s+yourself\b|\bkys\b", "harassment", 0.65),
|
|
111
|
+
_p(
|
|
112
|
+
r"\bbetter\s+off\s+(not\s+existing|dead|without\s+(you|him|her|them))\b",
|
|
113
|
+
"harassment",
|
|
114
|
+
_DECISIVE,
|
|
115
|
+
),
|
|
116
|
+
_p(r"\bregrets?\s+ever\s+(meeting|crossing|knowing)\s+me\b", "violence", _STRONG),
|
|
117
|
+
# Self-harm expressions. Deliberately below the block line: the answer to
|
|
118
|
+
# someone saying this is not a refusal, it is the crisis resources the demo
|
|
119
|
+
# surfaces off the signal. See site/public/pipeline.py.
|
|
120
|
+
_p(r"\b(kill|hurt|harm)\s+myself\b", "self_harm", _STRONG),
|
|
121
|
+
_p(r"\bend\s+my\s+(own\s+)?life\b", "self_harm", _STRONG),
|
|
122
|
+
_p(r"\bi\s+want\s+to\s+die\b", "self_harm", _MODERATE),
|
|
123
|
+
_p(r"\bsuicide\s+(note|plan|method)\b", "self_harm", _STRONG),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_CATEGORY_CAP = 0.7 # max a single category can contribute to the total score
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def score_patterns(text: str) -> tuple[float, dict[str, float]]:
|
|
131
|
+
"""Score one string, exactly as written.
|
|
132
|
+
|
|
133
|
+
signals maps category → capped contribution (violence, hate_speech,
|
|
134
|
+
harassment, self_harm).
|
|
135
|
+
"""
|
|
136
|
+
category_totals: dict[str, float] = {}
|
|
137
|
+
for pat in _PATTERNS:
|
|
138
|
+
if pat.regex.search(text):
|
|
139
|
+
current = category_totals.get(pat.category, 0.0)
|
|
140
|
+
category_totals[pat.category] = min(_CATEGORY_CAP, current + pat.weight)
|
|
141
|
+
|
|
142
|
+
total = min(1.0, sum(category_totals.values()))
|
|
143
|
+
return total, category_totals
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def score_toxicity(text: str) -> tuple[float, dict[str, float]]:
|
|
147
|
+
"""Return (total_score, signals) for the strongest view of the given text.
|
|
148
|
+
|
|
149
|
+
Normalized views are scored too: a threat written with look-alike letters
|
|
150
|
+
is still a threat. See openscript_sdk/policies/normalize.py.
|
|
151
|
+
"""
|
|
152
|
+
result = normalize.scan(text, score_patterns)
|
|
153
|
+
return result.risk, result.signals
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ToxicityPolicy(BasePolicy):
|
|
157
|
+
"""Scores input text for toxic content and blocks on threshold breach.
|
|
158
|
+
|
|
159
|
+
Detects threatening/violent content, dehumanizing hate speech,
|
|
160
|
+
harassment, and self-harm expressions via a structural pattern bank.
|
|
161
|
+
Runs in before_action; after_action is a pass-through. A local model
|
|
162
|
+
backend arrives with the [ml] extra (roadmap §14); pattern scoring is
|
|
163
|
+
the only backend today.
|
|
164
|
+
|
|
165
|
+
Results land in context.metadata["toxicity"] using the standardized
|
|
166
|
+
shape:
|
|
167
|
+
{"risk": float, "category": "toxicity", "signals": {...},
|
|
168
|
+
"flagged": bool, "threshold": float}
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
failure_mode: FailureMode = FailureMode.FAIL_CLOSED
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
threshold: float = 0.5,
|
|
176
|
+
writer: EventWriter | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
if not 0.0 <= threshold <= 1.0:
|
|
179
|
+
raise ValueError(f"threshold must be in [0, 1], got {threshold}")
|
|
180
|
+
self._threshold = threshold
|
|
181
|
+
self._writer = writer
|
|
182
|
+
self._sequence_counters: dict[str, int] = {}
|
|
183
|
+
|
|
184
|
+
async def before_action(self, context: ActionContext) -> ActionContext:
|
|
185
|
+
text = _extract_text(context.input_data)
|
|
186
|
+
result = normalize.scan(text, score_patterns)
|
|
187
|
+
score, signals = result.risk, result.signals
|
|
188
|
+
flagged = score >= self._threshold
|
|
189
|
+
|
|
190
|
+
context.metadata["toxicity"] = {
|
|
191
|
+
"risk": round(score, 4),
|
|
192
|
+
"category": "toxicity",
|
|
193
|
+
"signals": {k: round(v, 4) for k, v in signals.items()},
|
|
194
|
+
"flagged": flagged,
|
|
195
|
+
"threshold": self._threshold,
|
|
196
|
+
"obfuscation": result.markers,
|
|
197
|
+
"matched_view": result.view,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if flagged:
|
|
201
|
+
top_signal = max(signals, key=lambda k: signals[k]) if signals else "unknown"
|
|
202
|
+
reason = (
|
|
203
|
+
f"toxicity score {score:.2f} >= threshold {self._threshold} "
|
|
204
|
+
f"(top signal: {top_signal})"
|
|
205
|
+
)
|
|
206
|
+
context.decision = InterceptorDecision.DENY
|
|
207
|
+
context.decision_reason = reason
|
|
208
|
+
logger.warning(
|
|
209
|
+
"toxicity_detected",
|
|
210
|
+
session_id=context.session_id,
|
|
211
|
+
agent_id=context.agent_id,
|
|
212
|
+
score=round(score, 4),
|
|
213
|
+
signals=list(signals.keys()),
|
|
214
|
+
)
|
|
215
|
+
if self._writer is not None:
|
|
216
|
+
await self._emit_toxicity_event(context, score, signals, reason)
|
|
217
|
+
|
|
218
|
+
return context
|
|
219
|
+
|
|
220
|
+
async def _emit_toxicity_event(
|
|
221
|
+
self,
|
|
222
|
+
context: ActionContext,
|
|
223
|
+
score: float,
|
|
224
|
+
signals: dict[str, float],
|
|
225
|
+
reason: str,
|
|
226
|
+
) -> None:
|
|
227
|
+
seq = self._next_sequence(context.session_id)
|
|
228
|
+
event = Event(
|
|
229
|
+
session_id=context.session_id,
|
|
230
|
+
agent_id=context.agent_id,
|
|
231
|
+
event_type=EventType.POLICY_EVALUATED,
|
|
232
|
+
payload={
|
|
233
|
+
"policy": "toxicity_check",
|
|
234
|
+
"score": round(score, 4),
|
|
235
|
+
"signals": signals,
|
|
236
|
+
"reason": reason,
|
|
237
|
+
"action": context.action,
|
|
238
|
+
"threshold": self._threshold,
|
|
239
|
+
},
|
|
240
|
+
sequence_num=seq,
|
|
241
|
+
)
|
|
242
|
+
await self._writer.write(event) # type: ignore[union-attr]
|
|
243
|
+
|
|
244
|
+
def _next_sequence(self, session_id: str) -> int:
|
|
245
|
+
count = self._sequence_counters.get(session_id, 0) + 1
|
|
246
|
+
self._sequence_counters[session_id] = count
|
|
247
|
+
return count
|
openscript_sdk/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""OpenScript Server -- FastAPI application"""
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
load_dotenv()
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
from fastapi import Depends, FastAPI, Query, Response
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
16
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
17
|
+
|
|
18
|
+
from openscript_sdk.contracts.server_types import EventType
|
|
19
|
+
from openscript_sdk.contracts.session_graph import SessionGraph
|
|
20
|
+
from openscript_sdk.events.approvals import ApprovalStore, InMemoryApprovalStore, RedisApprovalStore
|
|
21
|
+
from openscript_sdk.events.store import EventStore
|
|
22
|
+
from openscript_sdk.events.writer import EventWriter
|
|
23
|
+
from openscript_sdk.server.auth import require_api_key
|
|
24
|
+
from openscript_sdk.server.guard import load_checkpoints
|
|
25
|
+
from openscript_sdk.server.routers import approvals as approvals_router
|
|
26
|
+
from openscript_sdk.server.routers import guard as guard_router
|
|
27
|
+
from openscript_sdk.server.routers import sessions as sessions_router
|
|
28
|
+
from openscript_sdk.server.routers import sse as sse_router
|
|
29
|
+
from openscript_sdk.server.routers import threat as threat_router
|
|
30
|
+
|
|
31
|
+
logger = structlog.get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
_STATIC_DIR = Path(__file__).parent / "static" / "dashboard"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _database_url() -> str:
|
|
37
|
+
return os.environ.get(
|
|
38
|
+
"DATABASE_URL", "postgresql+asyncpg://openscript:openscript@localhost:5432/openscript"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _approval_store() -> ApprovalStore:
|
|
43
|
+
redis_url = os.environ.get("REDIS_URL", "")
|
|
44
|
+
if redis_url:
|
|
45
|
+
return RedisApprovalStore(redis_url)
|
|
46
|
+
logger.warning(
|
|
47
|
+
"approval_store_in_memory",
|
|
48
|
+
hint="set REDIS_URL so SDK clients in other processes share this store",
|
|
49
|
+
)
|
|
50
|
+
return InMemoryApprovalStore()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@asynccontextmanager
|
|
54
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
55
|
+
db_url = _database_url()
|
|
56
|
+
engine = create_async_engine(db_url, pool_size=10, max_overflow=20)
|
|
57
|
+
app.state.engine = engine
|
|
58
|
+
app.state.event_store = EventStore(engine)
|
|
59
|
+
app.state.approval_store = _approval_store()
|
|
60
|
+
# The policies behind /v1/guard. Unset, the endpoint answers 503; set and
|
|
61
|
+
# invalid, the server does not start. The pipeline shares the approval
|
|
62
|
+
# store with /v1/approvals, so an approval it asks for is decided there.
|
|
63
|
+
guard_config = os.environ.get("OPENSCRIPT_GUARD_CONFIG", "")
|
|
64
|
+
writer: EventWriter | None = None
|
|
65
|
+
app.state.guard = None
|
|
66
|
+
if guard_config:
|
|
67
|
+
writer = EventWriter(app.state.event_store)
|
|
68
|
+
await writer.start()
|
|
69
|
+
app.state.guard = load_checkpoints(
|
|
70
|
+
guard_config, writer=writer, approval_store=app.state.approval_store
|
|
71
|
+
)
|
|
72
|
+
logger.info("guard_configured", config=guard_config, actions=sorted(app.state.guard))
|
|
73
|
+
logger.info("server_started", database=db_url.split("@")[-1])
|
|
74
|
+
yield
|
|
75
|
+
if writer is not None:
|
|
76
|
+
await writer.stop()
|
|
77
|
+
await engine.dispose()
|
|
78
|
+
logger.info("server_stopped")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
app = FastAPI(title="OpenScript Server", version="0.1.0", lifespan=lifespan)
|
|
82
|
+
|
|
83
|
+
# Routers
|
|
84
|
+
app.include_router(threat_router.router)
|
|
85
|
+
app.include_router(sessions_router.router)
|
|
86
|
+
app.include_router(sse_router.router)
|
|
87
|
+
app.include_router(approvals_router.router)
|
|
88
|
+
app.include_router(guard_router.router)
|
|
89
|
+
|
|
90
|
+
# Dashboard static files — served at /dashboard/
|
|
91
|
+
if _STATIC_DIR.exists():
|
|
92
|
+
app.mount(
|
|
93
|
+
"/dashboard",
|
|
94
|
+
StaticFiles(directory=str(_STATIC_DIR), html=True),
|
|
95
|
+
name="dashboard",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_event_store() -> EventStore:
|
|
100
|
+
return app.state.event_store
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.get("/health")
|
|
104
|
+
async def health() -> dict[str, str]:
|
|
105
|
+
return {"status": "ok"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Auth-gated like /v1/reports — scrapers present the API key
|
|
109
|
+
@app.get("/metrics", dependencies=[Depends(require_api_key)])
|
|
110
|
+
async def metrics() -> Response:
|
|
111
|
+
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.get("/events/{session_id}", dependencies=[Depends(require_api_key)])
|
|
115
|
+
async def get_events(
|
|
116
|
+
session_id: str,
|
|
117
|
+
event_type: EventType | None = None,
|
|
118
|
+
after_sequence: int | None = None,
|
|
119
|
+
limit: int = Query(default=1000, le=10_000),
|
|
120
|
+
store: EventStore = Depends(_get_event_store),
|
|
121
|
+
) -> dict:
|
|
122
|
+
event_types = [event_type] if event_type else None
|
|
123
|
+
events = await store.get_events(
|
|
124
|
+
session_id=session_id,
|
|
125
|
+
event_types=event_types,
|
|
126
|
+
after_sequence=after_sequence,
|
|
127
|
+
limit=limit,
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
"session_id": session_id,
|
|
131
|
+
"count": len(events),
|
|
132
|
+
"events": [e.model_dump(mode="json") for e in events],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/sessions/{session_id}/graph", dependencies=[Depends(require_api_key)])
|
|
137
|
+
async def get_session_graph(
|
|
138
|
+
session_id: str,
|
|
139
|
+
store: EventStore = Depends(_get_event_store),
|
|
140
|
+
) -> dict:
|
|
141
|
+
graph = await SessionGraph.from_store(session_id, store)
|
|
142
|
+
return {
|
|
143
|
+
"session_id": session_id,
|
|
144
|
+
"node_count": len(graph.nodes),
|
|
145
|
+
"edge_count": len(graph.edges),
|
|
146
|
+
"nodes": [
|
|
147
|
+
{"id": n.id, "node_type": n.node_type, "sequence_num": n.event.sequence_num}
|
|
148
|
+
for n in sorted(graph.nodes.values(), key=lambda n: n.event.sequence_num)
|
|
149
|
+
],
|
|
150
|
+
"edges": [
|
|
151
|
+
{"source": e.source_id, "target": e.target_id, "type": e.edge_type} for e in graph.edges
|
|
152
|
+
],
|
|
153
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException, Security
|
|
6
|
+
from fastapi.security import APIKeyHeader
|
|
7
|
+
|
|
8
|
+
_API_KEY_HEADER = APIKeyHeader(name="X-API-KEY", auto_error=False)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def require_api_key(
|
|
12
|
+
api_key: str | None = Security(_API_KEY_HEADER),
|
|
13
|
+
) -> str:
|
|
14
|
+
expected = os.environ.get("OPENSCRIPT_API_KEY")
|
|
15
|
+
if not expected:
|
|
16
|
+
raise HTTPException(status_code=500, detail="OPENSCRIPT_API_KEY not configured on server")
|
|
17
|
+
if api_key != expected:
|
|
18
|
+
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
19
|
+
return api_key
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The policy pipeline behind /v1/guard, for clients that are not Python.
|
|
2
|
+
|
|
3
|
+
A Python caller wraps its agent in SecureAgent and its tools in SecureTool, and
|
|
4
|
+
the pipeline runs before and after the call in one process. A client in another
|
|
5
|
+
language cannot hand the server its agent, so the server runs the two phases as
|
|
6
|
+
two requests instead: the client asks about the input, runs its agent (or tool)
|
|
7
|
+
on what came back, then asks about the output. The phases are SecureAgent's own
|
|
8
|
+
(``_run_before`` / ``_run_after``), so failure modes, approvals and refusals mean
|
|
9
|
+
here exactly what they mean in Python.
|
|
10
|
+
|
|
11
|
+
The config names two lists, because a Python program builds two: the one it
|
|
12
|
+
gives SecureAgent and the one it gives each SecureTool.
|
|
13
|
+
|
|
14
|
+
agent: # action "invoke": the caller's input, the agent's output
|
|
15
|
+
prompt_injection: {threshold: 0.5}
|
|
16
|
+
secrets: {mode: redact}
|
|
17
|
+
tool: # action "tool_call": a tool's name and arguments, its result
|
|
18
|
+
tool_firewall: {rules_path: tools.yaml} # relative to this file
|
|
19
|
+
|
|
20
|
+
Each list is built once, at startup, so a policy that remembers between calls
|
|
21
|
+
(SessionTaintPolicy) remembers across requests, in this process only.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
from openscript_sdk.contracts.interceptor import Policy, StreamGuard
|
|
32
|
+
from openscript_sdk.contracts.types import ActionContext
|
|
33
|
+
from openscript_sdk.middleware.middleware import SecureAgent
|
|
34
|
+
from openscript_sdk.policies.config import load_policies
|
|
35
|
+
|
|
36
|
+
#: The config's sections, by the action each one is asked about. A streamed
|
|
37
|
+
#: answer is an agent's, so it is held to the agent's list, as in Python.
|
|
38
|
+
SECTIONS = {"invoke": "agent", "stream": "agent", "tool_call": "tool"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Checkpoint(SecureAgent):
|
|
42
|
+
"""SecureAgent's two phases, run one at a time, with no agent between them."""
|
|
43
|
+
|
|
44
|
+
async def check_input(self, context: ActionContext, approval_id: str) -> ActionContext:
|
|
45
|
+
with self._trace(context):
|
|
46
|
+
context, _ = await self._run_before(context, approval_id)
|
|
47
|
+
return self._finalize(context)
|
|
48
|
+
|
|
49
|
+
async def check_output(self, context: ActionContext) -> ActionContext:
|
|
50
|
+
# No approval is redeemed here. The id was spent on the input, and none
|
|
51
|
+
# of the built-in policies asks for one on the way out; a custom policy
|
|
52
|
+
# that does gets a fresh approval, as a separate action would.
|
|
53
|
+
with self._trace(context):
|
|
54
|
+
context = await self._run_after(context)
|
|
55
|
+
return self._finalize(context)
|
|
56
|
+
|
|
57
|
+
def stream_guards(self) -> list[StreamGuard]:
|
|
58
|
+
"""The incremental scanners of the policies that have one (secrets, PII)."""
|
|
59
|
+
guards: list[StreamGuard] = []
|
|
60
|
+
for policy in self._policies:
|
|
61
|
+
factory = getattr(policy, "stream_guard", None)
|
|
62
|
+
guard = factory() if callable(factory) else None
|
|
63
|
+
if guard is not None:
|
|
64
|
+
guards.append(guard)
|
|
65
|
+
return guards
|
|
66
|
+
|
|
67
|
+
async def scan_stream(self, context: ActionContext, text: str, final: bool) -> str:
|
|
68
|
+
"""One pass of guarded streaming over the text a client is holding back.
|
|
69
|
+
|
|
70
|
+
SecureAgent's own _apply_guards: the guards are stateless over the
|
|
71
|
+
text they are given, so the client keeps the held tail and sends it
|
|
72
|
+
with each new chunk, and the server needs no memory of the stream.
|
|
73
|
+
A deny raises ActionBlockedError, after the audit hears of it.
|
|
74
|
+
"""
|
|
75
|
+
with self._trace(context):
|
|
76
|
+
return await self._apply_guards(text, self.stream_guards(), context, final)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_rules_path(policies: dict[str, Any], base: Path) -> dict[str, Any]:
|
|
80
|
+
firewall = policies.get("tool_firewall")
|
|
81
|
+
if isinstance(firewall, dict) and isinstance(firewall.get("rules_path"), str):
|
|
82
|
+
path = Path(firewall["rules_path"])
|
|
83
|
+
if not path.is_absolute():
|
|
84
|
+
policies = {**policies, "tool_firewall": {**firewall, "rules_path": str(base / path)}}
|
|
85
|
+
return policies
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_checkpoints(
|
|
89
|
+
path: str | Path, *, writer: Any | None = None, approval_store: Any | None = None
|
|
90
|
+
) -> dict[str, Checkpoint]:
|
|
91
|
+
"""Build one Checkpoint per section of the config at *path*, keyed by action.
|
|
92
|
+
|
|
93
|
+
Raises ValueError on an unknown section, an empty config, or anything
|
|
94
|
+
load_policies refuses. A server that cannot build what it was told to
|
|
95
|
+
build should not start.
|
|
96
|
+
"""
|
|
97
|
+
path = Path(path)
|
|
98
|
+
with open(path) as fh:
|
|
99
|
+
raw = yaml.safe_load(fh) or {}
|
|
100
|
+
if not isinstance(raw, dict):
|
|
101
|
+
raise ValueError(f"{path}: expected a mapping with 'agent' and/or 'tool'")
|
|
102
|
+
unknown = set(raw) - set(SECTIONS.values())
|
|
103
|
+
if unknown:
|
|
104
|
+
raise ValueError(f"{path}: unknown section(s) {sorted(unknown)}; expected 'agent', 'tool'")
|
|
105
|
+
|
|
106
|
+
by_section: dict[str, Checkpoint] = {}
|
|
107
|
+
for section in dict.fromkeys(SECTIONS.values()):
|
|
108
|
+
policies = raw.get(section)
|
|
109
|
+
if policies is None:
|
|
110
|
+
continue
|
|
111
|
+
if not isinstance(policies, dict) or not policies:
|
|
112
|
+
raise ValueError(f"{path}: section '{section}' names no policies")
|
|
113
|
+
built: list[Policy] = load_policies(
|
|
114
|
+
_resolve_rules_path(policies, path.parent), writer=writer
|
|
115
|
+
)
|
|
116
|
+
by_section[section] = Checkpoint(
|
|
117
|
+
agent=None, policies=built, approval_store=approval_store, writer=writer
|
|
118
|
+
)
|
|
119
|
+
# One pipeline per section, shared by the actions asked of it: what a
|
|
120
|
+
# policy remembers is remembered once.
|
|
121
|
+
checkpoints = {
|
|
122
|
+
action: by_section[section] for action, section in SECTIONS.items() if section in by_section
|
|
123
|
+
}
|
|
124
|
+
if not checkpoints:
|
|
125
|
+
raise ValueError(f"{path}: no 'agent' or 'tool' section")
|
|
126
|
+
return checkpoints
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Example config for /v1/guard: the policies a client in another language
|
|
2
|
+
# (clients/java) reaches through the server. Point the server at it with
|
|
3
|
+
#
|
|
4
|
+
# OPENSCRIPT_GUARD_CONFIG=openscript_sdk/server/guard_example.yaml uvicorn openscript_sdk.server.app:app
|
|
5
|
+
#
|
|
6
|
+
# Two lists, as a Python program builds two. Each takes exactly what
|
|
7
|
+
# load_policies() takes, in the same order-is-significant form. `audit` writes
|
|
8
|
+
# to the server's event store, so the server needs its Postgres.
|
|
9
|
+
|
|
10
|
+
# Action "invoke": the input a client is about to give its agent, and what the
|
|
11
|
+
# agent returned. What SecureAgent(agent, policies=...) would run.
|
|
12
|
+
agent:
|
|
13
|
+
prompt_injection:
|
|
14
|
+
threshold: 0.5
|
|
15
|
+
toxicity:
|
|
16
|
+
threshold: 0.5
|
|
17
|
+
harmful_request:
|
|
18
|
+
threshold: 0.5
|
|
19
|
+
secrets:
|
|
20
|
+
mode: redact # masked before the model is given it
|
|
21
|
+
pii:
|
|
22
|
+
mode: redact # masked before the caller gets it back
|
|
23
|
+
audit: {} # last, so its events carry the final risk score
|
|
24
|
+
|
|
25
|
+
# Action "tool_call": a tool's name and arguments, and what it returned. What
|
|
26
|
+
# SecureTool(fn, policies=...) would run around each tool.
|
|
27
|
+
tool:
|
|
28
|
+
tool_firewall:
|
|
29
|
+
# rules_path works too, and is read relative to this file.
|
|
30
|
+
rules:
|
|
31
|
+
default_deny: true
|
|
32
|
+
rules:
|
|
33
|
+
fetch_page:
|
|
34
|
+
allowed_roles: ["*"]
|
|
35
|
+
args:
|
|
36
|
+
# The whole URL must match. A redirect the tool follows is not
|
|
37
|
+
# something this rule sees.
|
|
38
|
+
url: {pattern: 'https://docs\.example\.com/.*'}
|
|
39
|
+
send_email:
|
|
40
|
+
allowed_roles: ["*"]
|
|
41
|
+
unlisted_args: deny
|
|
42
|
+
args:
|
|
43
|
+
to: {required: true, pattern: '[^@\s]+@example\.com'}
|
|
44
|
+
body: {}
|
|
45
|
+
wire_money:
|
|
46
|
+
allowed_roles: ["finance"]
|
|
47
|
+
requires_approval: true
|
|
48
|
+
args:
|
|
49
|
+
amount: {max: 10000}
|
|
50
|
+
# Once a session has run fetch_page, send_email waits for a person. What a
|
|
51
|
+
# session has read is remembered by the server process, per session id.
|
|
52
|
+
session_taint:
|
|
53
|
+
sources: [fetch_page]
|
|
54
|
+
sinks: [send_email, wire_money]
|
|
55
|
+
audit: {}
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from openscript_sdk.contracts.server_types import Event, EventType
|
|
7
|
+
from openscript_sdk.events.approvals import ApprovalStore
|
|
8
|
+
from openscript_sdk.server.auth import require_api_key
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/v1", tags=["approvals"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApprovalDecisionRequest(BaseModel):
|
|
14
|
+
approved: bool
|
|
15
|
+
decided_by: str = ""
|
|
16
|
+
#: The decider's reason. Returned on the record and written to the event.
|
|
17
|
+
note: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_store(request: Request) -> ApprovalStore:
|
|
21
|
+
return request.app.state.approval_store
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/approvals", dependencies=[Depends(require_api_key)])
|
|
25
|
+
async def list_pending_approvals(store: ApprovalStore = Depends(_get_store)) -> dict:
|
|
26
|
+
"""List approval records awaiting a human decision."""
|
|
27
|
+
pending = await store.list_pending()
|
|
28
|
+
return {
|
|
29
|
+
"count": len(pending),
|
|
30
|
+
"approvals": [record.to_dict() for record in pending],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.post("/approvals/{approval_id}/decide", dependencies=[Depends(require_api_key)])
|
|
35
|
+
async def decide_approval(
|
|
36
|
+
approval_id: str,
|
|
37
|
+
req: ApprovalDecisionRequest,
|
|
38
|
+
request: Request,
|
|
39
|
+
store: ApprovalStore = Depends(_get_store),
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Approve or deny a pending approval and emit APPROVAL_RESOLVED."""
|
|
42
|
+
try:
|
|
43
|
+
record = await store.decide(approval_id, req.approved, req.decided_by, req.note)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
46
|
+
if record is None:
|
|
47
|
+
raise HTTPException(status_code=404, detail=f"approval '{approval_id}' not found")
|
|
48
|
+
|
|
49
|
+
event_store = getattr(request.app.state, "event_store", None)
|
|
50
|
+
if event_store is not None:
|
|
51
|
+
await event_store.insert_events(
|
|
52
|
+
[
|
|
53
|
+
Event(
|
|
54
|
+
session_id=record.session_id,
|
|
55
|
+
agent_id=record.agent_id,
|
|
56
|
+
event_type=EventType.APPROVAL_RESOLVED,
|
|
57
|
+
payload={
|
|
58
|
+
"approval_id": record.approval_id,
|
|
59
|
+
"action": record.action,
|
|
60
|
+
"status": record.status.value,
|
|
61
|
+
"decided_by": record.decided_by,
|
|
62
|
+
"note": record.decision_note,
|
|
63
|
+
},
|
|
64
|
+
sequence_num=0, # decided outside the session's sequence
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return record.to_dict()
|