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