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,309 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from dataclasses import asdict, dataclass, field, replace
7
+ from datetime import UTC, datetime, timedelta
8
+ from enum import StrEnum
9
+ from typing import Any, Protocol
10
+
11
+ import structlog
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+ DEFAULT_TTL_SECONDS = 3600 # approvals expire back to blocked after 1h
16
+
17
+
18
+ class ApprovalStatus(StrEnum):
19
+ PENDING = "pending"
20
+ APPROVED = "approved"
21
+ DENIED = "denied"
22
+
23
+
24
+ def _typed(value: Any) -> Any:
25
+ """*value* as plain JSON in which every piece says what type it was.
26
+
27
+ Exact types, not isinstance: an IntEnum member is not the int it compares
28
+ equal to, and should not be approved as if it were. A list and a tuple are
29
+ deliberately the same thing here; nothing can be done with one that cannot
30
+ be done with the other.
31
+ """
32
+ kind = type(value)
33
+ if value is None:
34
+ return ["none"]
35
+ if kind is bool:
36
+ return ["bool", value]
37
+ if kind is int:
38
+ return ["int", str(value)]
39
+ if kind is float:
40
+ return ["float", repr(value)]
41
+ if kind is str:
42
+ return ["str", value]
43
+ if kind in (bytes, bytearray):
44
+ return ["bytes", bytes(value).hex()]
45
+ if kind in (list, tuple):
46
+ return ["list", [_typed(item) for item in value]]
47
+ if kind in (set, frozenset):
48
+ return ["set", sorted((_typed(item) for item in value), key=_order)]
49
+ if kind is dict:
50
+ pairs = ([_typed(key), _typed(item)] for key, item in value.items())
51
+ return ["dict", sorted(pairs, key=_order)]
52
+ # Anything else is named by its type as well as its text, so a Decimal, a
53
+ # UUID or a date cannot stand in for the string that spells it.
54
+ return ["object", f"{kind.__module__}.{kind.__qualname__}", str(value)]
55
+
56
+
57
+ def _order(encoded: Any) -> str:
58
+ return json.dumps(encoded)
59
+
60
+
61
+ def action_hash(action: str, input_data: dict[str, Any]) -> str:
62
+ """Canonical fingerprint of (action, input) for approval binding.
63
+
64
+ An approval is redeemable only against the exact action it was requested
65
+ for — a matching hash proves the retried call carries the same action
66
+ name and input/args, so a granted approval can't be replayed against a
67
+ different tool call.
68
+
69
+ "The same" means the same values of the same types. This used to be
70
+ json.dumps(default=str), and str() forgets what it was given: Decimal("10")
71
+ and the text "10" fingerprinted alike, as did {1: x} and {"1": x}. An
72
+ approval for one was an approval for the other.
73
+ """
74
+ canonical = json.dumps(["action", action, _typed(input_data)], separators=(",", ":"))
75
+ return hashlib.sha256(canonical.encode()).hexdigest()
76
+
77
+
78
+ def _new_expiry(ttl_seconds: int = DEFAULT_TTL_SECONDS) -> datetime:
79
+ return datetime.now(UTC) + timedelta(seconds=ttl_seconds)
80
+
81
+
82
+ @dataclass
83
+ class ApprovalRecord:
84
+ session_id: str
85
+ agent_id: str
86
+ action: str
87
+ reason: str
88
+ action_hash: str
89
+ approval_id: str = field(default_factory=lambda: uuid.uuid4().hex)
90
+ status: ApprovalStatus = ApprovalStatus.PENDING
91
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
92
+ expires_at: datetime = field(default_factory=_new_expiry)
93
+ decided_by: str = ""
94
+ #: What the person deciding said about it. A denial with no reason sends
95
+ #: the requester back to guess; this is where the reason travels.
96
+ decision_note: str = ""
97
+
98
+ @property
99
+ def expired(self) -> bool:
100
+ return datetime.now(UTC) >= self.expires_at
101
+
102
+ def to_dict(self) -> dict[str, Any]:
103
+ data = asdict(self)
104
+ data["status"] = self.status.value
105
+ data["created_at"] = self.created_at.isoformat()
106
+ data["expires_at"] = self.expires_at.isoformat()
107
+ return data
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: dict[str, Any]) -> ApprovalRecord:
111
+ return cls(
112
+ session_id=data["session_id"],
113
+ agent_id=data["agent_id"],
114
+ action=data["action"],
115
+ reason=data["reason"],
116
+ action_hash=data["action_hash"],
117
+ approval_id=data["approval_id"],
118
+ status=ApprovalStatus(data["status"]),
119
+ created_at=datetime.fromisoformat(data["created_at"]),
120
+ expires_at=datetime.fromisoformat(data["expires_at"]),
121
+ decided_by=data.get("decided_by", ""),
122
+ # Absent from records written before the field existed.
123
+ decision_note=data.get("decision_note", ""),
124
+ )
125
+
126
+
127
+ class ApprovalStore(Protocol):
128
+ """Storage for pending/decided approval records.
129
+
130
+ Expired records behave as denied everywhere: they drop out of
131
+ list_pending, can no longer be decided, and never redeem.
132
+ """
133
+
134
+ async def create(self, record: ApprovalRecord) -> None: ...
135
+
136
+ async def get(self, approval_id: str) -> ApprovalRecord | None: ...
137
+
138
+ async def list_pending(self) -> list[ApprovalRecord]: ...
139
+
140
+ async def decide(
141
+ self, approval_id: str, approved: bool, decided_by: str = "", note: str = ""
142
+ ) -> ApprovalRecord | None:
143
+ """Resolve a pending record. Returns the updated record, or None when
144
+ the id is unknown. Raises ValueError when the record is no longer
145
+ pending (already decided or expired). *note* is the decider's reason,
146
+ kept on the record as ``decision_note``."""
147
+ ...
148
+
149
+ async def consume(self, approval_id: str, expected_hash: str) -> bool:
150
+ """Single-use redemption: True iff the record exists, is approved,
151
+ is not expired, and its action_hash matches — the record is deleted
152
+ so it can never redeem twice."""
153
+ ...
154
+
155
+
156
+ class InMemoryApprovalStore:
157
+ """Dict-backed store for SINGLE-PROCESS use and tests only.
158
+
159
+ SecureAgent runs in the caller's process while /v1/approvals runs in the
160
+ server — an in-memory store is never visible to both. Use
161
+ RedisApprovalStore whenever approvals are decided over the HTTP API.
162
+ """
163
+
164
+ def __init__(self) -> None:
165
+ self._records: dict[str, ApprovalRecord] = {}
166
+
167
+ async def create(self, record: ApprovalRecord) -> None:
168
+ self._records[record.approval_id] = replace(record)
169
+
170
+ async def get(self, approval_id: str) -> ApprovalRecord | None:
171
+ record = self._records.get(approval_id)
172
+ return replace(record) if record is not None else None
173
+
174
+ async def list_pending(self) -> list[ApprovalRecord]:
175
+ self._purge_expired()
176
+ return [replace(r) for r in self._records.values() if r.status == ApprovalStatus.PENDING]
177
+
178
+ async def decide(
179
+ self, approval_id: str, approved: bool, decided_by: str = "", note: str = ""
180
+ ) -> ApprovalRecord | None:
181
+ record = self._records.get(approval_id)
182
+ if record is None:
183
+ return None
184
+ if record.status != ApprovalStatus.PENDING or record.expired:
185
+ raise ValueError(f"approval '{approval_id}' is not pending")
186
+ record.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.DENIED
187
+ record.decided_by = decided_by
188
+ record.decision_note = note
189
+ return replace(record)
190
+
191
+ async def consume(self, approval_id: str, expected_hash: str) -> bool:
192
+ record = self._records.get(approval_id)
193
+ if (
194
+ record is None
195
+ or record.status != ApprovalStatus.APPROVED
196
+ or record.expired
197
+ or record.action_hash != expected_hash
198
+ ):
199
+ return False
200
+ del self._records[approval_id]
201
+ return True
202
+
203
+ def _purge_expired(self) -> None:
204
+ expired = [aid for aid, r in self._records.items() if r.expired]
205
+ for aid in expired:
206
+ del self._records[aid]
207
+
208
+
209
+ class RedisApprovalStore:
210
+ """Redis-backed store — required whenever SecureAgent and the server run
211
+ in separate processes (i.e. any real deployment of the approvals API).
212
+
213
+ Records live at {prefix}:{approval_id} as JSON with a Redis TTL matching
214
+ expires_at; pending ids are tracked in the {prefix}:pending set. Single-use
215
+ redemption relies on DELETE's atomicity: only the caller whose DELETE
216
+ returns 1 wins a concurrent redeem race. A decision is made under WATCH,
217
+ so of two people deciding the same request at once, one is told it was
218
+ already decided.
219
+ """
220
+
221
+ def __init__(
222
+ self,
223
+ url: str = "redis://localhost:6379/0",
224
+ *,
225
+ key_prefix: str = "openscript:approval",
226
+ ) -> None:
227
+ import redis.asyncio as aioredis
228
+
229
+ self._redis = aioredis.from_url(url, decode_responses=True)
230
+ self._prefix = key_prefix
231
+ self._pending_key = f"{key_prefix}:pending"
232
+
233
+ def _key(self, approval_id: str) -> str:
234
+ return f"{self._prefix}:{approval_id}"
235
+
236
+ @staticmethod
237
+ def _ttl_seconds(record: ApprovalRecord) -> int:
238
+ return max(1, int((record.expires_at - datetime.now(UTC)).total_seconds()))
239
+
240
+ async def create(self, record: ApprovalRecord) -> None:
241
+ await self._redis.set(
242
+ self._key(record.approval_id),
243
+ json.dumps(record.to_dict()),
244
+ ex=self._ttl_seconds(record),
245
+ )
246
+ await self._redis.sadd(self._pending_key, record.approval_id)
247
+
248
+ async def get(self, approval_id: str) -> ApprovalRecord | None:
249
+ raw = await self._redis.get(self._key(approval_id))
250
+ return ApprovalRecord.from_dict(json.loads(raw)) if raw else None
251
+
252
+ async def list_pending(self) -> list[ApprovalRecord]:
253
+ pending: list[ApprovalRecord] = []
254
+ for approval_id in await self._redis.smembers(self._pending_key):
255
+ record = await self.get(str(approval_id))
256
+ if record is None or record.status != ApprovalStatus.PENDING:
257
+ # expired (key TTL fired) or already decided — clean the set
258
+ await self._redis.srem(self._pending_key, approval_id)
259
+ continue
260
+ pending.append(record)
261
+ return pending
262
+
263
+ async def decide(
264
+ self, approval_id: str, approved: bool, decided_by: str = "", note: str = ""
265
+ ) -> ApprovalRecord | None:
266
+ from redis.exceptions import WatchError
267
+
268
+ key = self._key(approval_id)
269
+ # Read, check and write as one step. Without the WATCH, two people
270
+ # deciding at the same moment both read "pending", both write, and both
271
+ # are told it worked — an approval and a denial of the same request,
272
+ # with whichever landed second quietly winning.
273
+ # Typed Any: redis-py leaves multi() unannotated, and whether that is
274
+ # an error depends on whether its types are installed where mypy runs.
275
+ transaction: Any = self._redis.pipeline(transaction=True)
276
+ async with transaction as pipe:
277
+ while True:
278
+ try:
279
+ await pipe.watch(key)
280
+ raw = await pipe.get(key)
281
+ record = ApprovalRecord.from_dict(json.loads(raw)) if raw else None
282
+ if record is None:
283
+ return None
284
+ if record.status != ApprovalStatus.PENDING or record.expired:
285
+ raise ValueError(f"approval '{approval_id}' is not pending")
286
+ record.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.DENIED
287
+ record.decided_by = decided_by
288
+ record.decision_note = note
289
+ pipe.multi()
290
+ pipe.set(key, json.dumps(record.to_dict()), ex=self._ttl_seconds(record))
291
+ pipe.srem(self._pending_key, approval_id)
292
+ await pipe.execute()
293
+ return record
294
+ except WatchError:
295
+ # Someone else wrote the record first. Read it again: it is
296
+ # no longer pending, and the check above says so.
297
+ continue
298
+
299
+ async def consume(self, approval_id: str, expected_hash: str) -> bool:
300
+ record = await self.get(approval_id)
301
+ if (
302
+ record is None
303
+ or record.status != ApprovalStatus.APPROVED
304
+ or record.expired
305
+ or record.action_hash != expected_hash
306
+ ):
307
+ return False
308
+ # DELETE returning 1 is the atomic single-use gate under concurrency
309
+ return await self._redis.delete(self._key(approval_id)) == 1
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from sqlalchemy import DateTime, Index, Integer, String
7
+ from sqlalchemy.dialects.postgresql import JSONB
8
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
9
+
10
+
11
+ class Base(DeclarativeBase):
12
+ pass
13
+
14
+
15
+ class EventRow(Base):
16
+ __tablename__ = "events"
17
+
18
+ id: Mapped[str] = mapped_column(String, primary_key=True)
19
+ session_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
20
+ agent_id: Mapped[str] = mapped_column(String, nullable=False)
21
+ event_type: Mapped[str] = mapped_column(String, nullable=False)
22
+ payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default="{}")
23
+ timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False)
24
+ sequence_num: Mapped[int] = mapped_column(Integer, nullable=False)
25
+
26
+ __table_args__ = (Index("ix_events_session_sequence", "session_id", "sequence_num"),)
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+
5
+ import structlog
6
+ from sqlalchemy import and_, select
7
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
8
+
9
+ from openscript_sdk.contracts.server_types import Event, EventType
10
+ from openscript_sdk.events.models import EventRow
11
+
12
+ logger = structlog.get_logger(__name__)
13
+
14
+
15
+ class EventStore:
16
+ """Read/write interface for the append-only events table."""
17
+
18
+ def __init__(self, engine: AsyncEngine) -> None:
19
+ self._engine = engine
20
+ self._session_factory = async_sessionmaker(
21
+ engine, class_=AsyncSession, expire_on_commit=False
22
+ )
23
+
24
+ async def insert_events(self, events: list[Event]) -> None:
25
+ async with self._session_factory() as session:
26
+ for event in events:
27
+ row = EventRow(
28
+ id=event.id,
29
+ session_id=event.session_id,
30
+ agent_id=event.agent_id,
31
+ event_type=event.event_type.value,
32
+ payload=event.payload,
33
+ timestamp=event.timestamp,
34
+ sequence_num=event.sequence_num,
35
+ )
36
+ session.add(row)
37
+ await session.commit()
38
+ logger.debug("events_inserted", count=len(events))
39
+
40
+ async def get_events(
41
+ self,
42
+ session_id: str,
43
+ event_types: list[EventType] | None = None,
44
+ after_sequence: int | None = None,
45
+ limit: int = 1000,
46
+ ) -> list[Event]:
47
+ async with self._session_factory() as session:
48
+ conditions = [EventRow.session_id == session_id]
49
+ if event_types:
50
+ conditions.append(EventRow.event_type.in_([et.value for et in event_types]))
51
+ if after_sequence is not None:
52
+ conditions.append(EventRow.sequence_num > after_sequence)
53
+
54
+ stmt = (
55
+ select(EventRow)
56
+ .where(and_(*conditions))
57
+ .order_by(EventRow.sequence_num)
58
+ .limit(limit)
59
+ )
60
+ result = await session.execute(stmt)
61
+ rows = result.scalars().all()
62
+
63
+ return [
64
+ Event(
65
+ id=row.id,
66
+ session_id=row.session_id,
67
+ agent_id=row.agent_id,
68
+ event_type=EventType(row.event_type),
69
+ payload=row.payload,
70
+ timestamp=row.timestamp,
71
+ sequence_num=row.sequence_num,
72
+ )
73
+ for row in rows
74
+ ]
75
+
76
+ async def stream_events(self, session_id: str) -> AsyncIterator[Event]:
77
+ events = await self.get_events(session_id=session_id)
78
+ for event in events:
79
+ yield event
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from typing import Protocol
6
+
7
+ import structlog
8
+
9
+ from openscript_sdk.contracts.server_types import Event
10
+
11
+ logger = structlog.get_logger(__name__)
12
+
13
+
14
+ class EventSink(Protocol):
15
+ """Anything that can persist a batch of events."""
16
+
17
+ async def insert_events(self, events: list[Event]) -> None: ...
18
+
19
+
20
+ class EventWriter:
21
+ """Async bounded queue that drains events to a sink in batches.
22
+
23
+ Uses DROP_OLDEST policy when the queue is full — the newest event
24
+ always wins. Every drop is logged with a running counter so ops
25
+ can alert on sustained back-pressure.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ store: EventSink,
31
+ max_queue_size: int = 10_000,
32
+ batch_size: int = 100,
33
+ flush_interval: float = 0.1,
34
+ ) -> None:
35
+ self._store = store
36
+ self._queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=max_queue_size)
37
+ self._batch_size = batch_size
38
+ self._flush_interval = flush_interval
39
+ self._events_dropped_count: int = 0
40
+ self._task: asyncio.Task[None] | None = None
41
+ self._running = False
42
+
43
+ @property
44
+ def events_dropped_count(self) -> int:
45
+ return self._events_dropped_count
46
+
47
+ @property
48
+ def queue_size(self) -> int:
49
+ return self._queue.qsize()
50
+
51
+ async def start(self) -> None:
52
+ self._running = True
53
+ self._task = asyncio.create_task(self._drain_loop())
54
+
55
+ async def stop(self) -> None:
56
+ self._running = False
57
+ if self._task is not None:
58
+ await self._task
59
+ self._task = None
60
+
61
+ async def write(self, event: Event) -> None:
62
+ try:
63
+ self._queue.put_nowait(event)
64
+ except asyncio.QueueFull:
65
+ with contextlib.suppress(asyncio.QueueEmpty):
66
+ self._queue.get_nowait()
67
+ self._events_dropped_count += 1
68
+ logger.warning(
69
+ "event_queue_full_drop_oldest",
70
+ events_dropped_count=self._events_dropped_count,
71
+ session_id=event.session_id,
72
+ )
73
+ self._queue.put_nowait(event)
74
+
75
+ async def _drain_loop(self) -> None:
76
+ while self._running or not self._queue.empty():
77
+ batch: list[Event] = []
78
+
79
+ try:
80
+ event = await asyncio.wait_for(self._queue.get(), timeout=self._flush_interval)
81
+ batch.append(event)
82
+ except TimeoutError:
83
+ if not self._running:
84
+ break
85
+ continue
86
+
87
+ while len(batch) < self._batch_size:
88
+ try:
89
+ batch.append(self._queue.get_nowait())
90
+ except asyncio.QueueEmpty:
91
+ break
92
+
93
+ if batch:
94
+ try:
95
+ await self._store.insert_events(batch)
96
+ except Exception:
97
+ logger.exception(
98
+ "event_writer_flush_failed",
99
+ batch_size=len(batch),
100
+ )