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,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
_configured = False
|
|
10
|
+
_lock = threading.Lock()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure_logging(*, json_output: bool | None = None) -> None:
|
|
14
|
+
"""Configure structlog for OpenScript.
|
|
15
|
+
|
|
16
|
+
Call this explicitly before creating ``OpenScriptMiddleware`` to control
|
|
17
|
+
output format and log level. If never called, the middleware auto-configures
|
|
18
|
+
with sensible defaults on first use (JSON when not a TTY, console otherwise).
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
json_output: Force JSON output. When *None* (default), use JSON if
|
|
22
|
+
``OPENSCRIPT_LOG_FORMAT=json`` or if stderr is not a TTY.
|
|
23
|
+
"""
|
|
24
|
+
global _configured
|
|
25
|
+
|
|
26
|
+
if json_output is None:
|
|
27
|
+
env_format = os.environ.get("OPENSCRIPT_LOG_FORMAT", "").lower()
|
|
28
|
+
json_output = env_format == "json" or not _is_tty()
|
|
29
|
+
|
|
30
|
+
log_level_name = os.environ.get("OPENSCRIPT_LOG_LEVEL", "INFO").upper()
|
|
31
|
+
log_level = getattr(logging, log_level_name, logging.INFO)
|
|
32
|
+
|
|
33
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
34
|
+
structlog.contextvars.merge_contextvars,
|
|
35
|
+
structlog.stdlib.add_log_level,
|
|
36
|
+
structlog.stdlib.add_logger_name,
|
|
37
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
38
|
+
structlog.processors.StackInfoRenderer(),
|
|
39
|
+
structlog.processors.UnicodeDecoder(),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
if json_output:
|
|
43
|
+
renderer: structlog.types.Processor = structlog.processors.JSONRenderer()
|
|
44
|
+
else:
|
|
45
|
+
renderer = structlog.dev.ConsoleRenderer()
|
|
46
|
+
|
|
47
|
+
structlog.configure(
|
|
48
|
+
processors=[
|
|
49
|
+
*shared_processors,
|
|
50
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
51
|
+
],
|
|
52
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
53
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
54
|
+
cache_logger_on_first_use=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
58
|
+
processors=[
|
|
59
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
60
|
+
renderer,
|
|
61
|
+
],
|
|
62
|
+
foreign_pre_chain=shared_processors,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
handler = logging.StreamHandler()
|
|
66
|
+
handler.setFormatter(formatter)
|
|
67
|
+
|
|
68
|
+
root = logging.getLogger()
|
|
69
|
+
root.handlers.clear()
|
|
70
|
+
root.addHandler(handler)
|
|
71
|
+
root.setLevel(log_level)
|
|
72
|
+
|
|
73
|
+
_configured = True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ensure_configured() -> None:
|
|
77
|
+
"""Auto-configure logging once if the caller hasn't done so already.
|
|
78
|
+
|
|
79
|
+
Thread-safe: concurrent calls block on the lock, and only the first one
|
|
80
|
+
actually configures.
|
|
81
|
+
"""
|
|
82
|
+
global _configured
|
|
83
|
+
if _configured:
|
|
84
|
+
return
|
|
85
|
+
with _lock:
|
|
86
|
+
if _configured:
|
|
87
|
+
return
|
|
88
|
+
configure_logging()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_tty() -> bool:
|
|
92
|
+
import sys
|
|
93
|
+
|
|
94
|
+
return hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
7
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any, NoReturn
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from openscript_sdk.contracts.interceptor import Policy
|
|
14
|
+
from openscript_sdk.contracts.server_types import Event, EventType
|
|
15
|
+
from openscript_sdk.contracts.types import (
|
|
16
|
+
ActionBlockedError,
|
|
17
|
+
ActionContext,
|
|
18
|
+
FailureMode,
|
|
19
|
+
InterceptorDecision,
|
|
20
|
+
)
|
|
21
|
+
from openscript_sdk.events.approvals import (
|
|
22
|
+
ApprovalRecord,
|
|
23
|
+
ApprovalStore,
|
|
24
|
+
InMemoryApprovalStore,
|
|
25
|
+
action_hash,
|
|
26
|
+
)
|
|
27
|
+
from openscript_sdk.interceptors.base import NoopInterceptor
|
|
28
|
+
from openscript_sdk.logging import _ensure_configured
|
|
29
|
+
from openscript_sdk.observability.metrics import MetricsRecorder
|
|
30
|
+
from openscript_sdk.observability.risk import RiskScorer
|
|
31
|
+
from openscript_sdk.observability.tracing import ActionTracer, tracer_from_env
|
|
32
|
+
|
|
33
|
+
logger = structlog.get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StreamOutputMode(StrEnum):
|
|
37
|
+
"""How stream() applies output policies to streamed chunks.
|
|
38
|
+
|
|
39
|
+
BUFFER (default): collect every chunk, run after_action policies over
|
|
40
|
+
the assembled output, then yield the (possibly redacted) result —
|
|
41
|
+
nothing reaches the caller unscanned, at the cost of time-to-first-token
|
|
42
|
+
(and, for text streams, chunk granularity: one combined chunk).
|
|
43
|
+
|
|
44
|
+
GUARDED: near-real-time streaming for TEXT chunks. Policies that expose
|
|
45
|
+
a stream_guard() (bounded pattern scanners: SecretsPolicy, PIIPolicy)
|
|
46
|
+
scan incrementally while the middleware holds back a tail window sized
|
|
47
|
+
to their longest pattern — matches within the bound are redacted before
|
|
48
|
+
release or abort the stream with nothing of the match emitted. Policies
|
|
49
|
+
without a guard (schema validation, grounding) run at stream end, where
|
|
50
|
+
a DENY still raises. Latency cost is one window (~a few dozen tokens),
|
|
51
|
+
not the whole response.
|
|
52
|
+
|
|
53
|
+
PASSTHROUGH: chunks reach the caller immediately and UNSCANNED — output
|
|
54
|
+
policies cannot redact or block what has already been delivered. They
|
|
55
|
+
still run post-hoc over the assembled output for metadata/audit, and a
|
|
56
|
+
post-hoc DENY still raises at stream end (too late to prevent the leak,
|
|
57
|
+
loud by design).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
BUFFER = "buffer"
|
|
61
|
+
GUARDED = "guarded"
|
|
62
|
+
PASSTHROUGH = "passthrough"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SecureAgent:
|
|
66
|
+
"""Orchestrates a list of Policies around agent actions.
|
|
67
|
+
|
|
68
|
+
Contains zero detection or security logic — all behavior is provided by
|
|
69
|
+
the registered Policy implementations.
|
|
70
|
+
|
|
71
|
+
When a policy returns REQUIRE_APPROVAL, a pending ApprovalRecord is
|
|
72
|
+
created in approval_store and ActionBlockedError carries its approval_id.
|
|
73
|
+
Once a human approves it, retry with invoke(..., approval_id=...): the
|
|
74
|
+
approval redeems (single-use, bound to the exact action+input hash) and
|
|
75
|
+
the gate is treated as ALLOW. The default in-memory store only works when
|
|
76
|
+
approvals are decided in this same process — pass a RedisApprovalStore
|
|
77
|
+
when they are decided via the server's /v1/approvals API.
|
|
78
|
+
|
|
79
|
+
stream_output selects how streamed output is protected (see
|
|
80
|
+
StreamOutputMode); the default buffers so output policies always see —
|
|
81
|
+
and can redact or block — the full response before the caller does.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
agent: Any,
|
|
87
|
+
policies: Sequence[Policy] | None = None,
|
|
88
|
+
*,
|
|
89
|
+
interceptors: Sequence[Policy] | None = None,
|
|
90
|
+
risk_scorer: RiskScorer | None = None,
|
|
91
|
+
approval_store: ApprovalStore | None = None,
|
|
92
|
+
writer: Any | None = None,
|
|
93
|
+
stream_output: StreamOutputMode | str = StreamOutputMode.BUFFER,
|
|
94
|
+
guard_window: int | None = None,
|
|
95
|
+
metrics: MetricsRecorder | None = None,
|
|
96
|
+
tracer: ActionTracer | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
_ensure_configured()
|
|
99
|
+
if interceptors is not None:
|
|
100
|
+
warnings.warn(
|
|
101
|
+
"interceptors= is deprecated; use policies= instead.",
|
|
102
|
+
DeprecationWarning,
|
|
103
|
+
stacklevel=2,
|
|
104
|
+
)
|
|
105
|
+
if policies is not None:
|
|
106
|
+
raise ValueError("Cannot specify both policies= and interceptors=.")
|
|
107
|
+
policies = interceptors
|
|
108
|
+
self._agent = agent
|
|
109
|
+
self._policies: Sequence[Policy] = policies or [NoopInterceptor()]
|
|
110
|
+
self._risk_scorer = risk_scorer or RiskScorer()
|
|
111
|
+
self._approval_store = approval_store or InMemoryApprovalStore()
|
|
112
|
+
self._writer = writer
|
|
113
|
+
self._stream_output = StreamOutputMode(stream_output)
|
|
114
|
+
# guarded-mode hold-back override; None derives it from the guards'
|
|
115
|
+
# max_match_len (the only size that preserves the zero-leak guarantee)
|
|
116
|
+
self._guard_window = guard_window
|
|
117
|
+
self._metrics = metrics
|
|
118
|
+
self._tracer = tracer if tracer is not None else tracer_from_env()
|
|
119
|
+
self._sequence_counters: dict[str, int] = {}
|
|
120
|
+
|
|
121
|
+
async def invoke(
|
|
122
|
+
self, input_data: dict[str, Any], *, approval_id: str = "", **kwargs: Any
|
|
123
|
+
) -> Any:
|
|
124
|
+
result, _ = await self.invoke_with_context(input_data, approval_id=approval_id, **kwargs)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
async def invoke_with_context(
|
|
128
|
+
self, input_data: dict[str, Any], *, approval_id: str = "", **kwargs: Any
|
|
129
|
+
) -> tuple[Any, ActionContext]:
|
|
130
|
+
"""Like invoke(), but also returns the final ActionContext so callers
|
|
131
|
+
can read risk_score, risk_categories, and per-policy metadata."""
|
|
132
|
+
context = ActionContext(
|
|
133
|
+
action="invoke",
|
|
134
|
+
agent_id=kwargs.get("agent_id", "default"),
|
|
135
|
+
session_id=kwargs.get("session_id", "default"),
|
|
136
|
+
input_data=input_data,
|
|
137
|
+
# A copy. Policies write their findings into metadata, and kwargs
|
|
138
|
+
# is what the agent is called with: sharing one dict handed the
|
|
139
|
+
# agent every finding as a keyword argument it never asked for,
|
|
140
|
+
# which an agent without **kwargs answers with a TypeError.
|
|
141
|
+
metadata=dict(kwargs),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
with self._trace(context):
|
|
145
|
+
context, granted = await self._run_before(context, approval_id)
|
|
146
|
+
|
|
147
|
+
# context.input_data, not input_data. A policy that redacts the
|
|
148
|
+
# input replaces it on the context, and the agent has to be given
|
|
149
|
+
# what the policies left, not what the caller sent.
|
|
150
|
+
result = await self._call_agent(context.input_data, **kwargs)
|
|
151
|
+
|
|
152
|
+
context.output_data = result if isinstance(result, dict) else {"output": result}
|
|
153
|
+
context = await self._run_after(context, approval_id, granted)
|
|
154
|
+
self._finalize(context)
|
|
155
|
+
|
|
156
|
+
# Return context.output_data so after_action policies (e.g. PIIPolicy)
|
|
157
|
+
# can modify the output that callers receive.
|
|
158
|
+
if isinstance(result, dict):
|
|
159
|
+
return context.output_data, context
|
|
160
|
+
return context.output_data.get("output", result), context
|
|
161
|
+
|
|
162
|
+
async def stream(
|
|
163
|
+
self,
|
|
164
|
+
input_data: dict[str, Any],
|
|
165
|
+
*,
|
|
166
|
+
approval_id: str = "",
|
|
167
|
+
stream_output: StreamOutputMode | str | None = None,
|
|
168
|
+
**kwargs: Any,
|
|
169
|
+
) -> AsyncGenerator[Any, None]:
|
|
170
|
+
"""Stream the agent's output through the policy pipeline.
|
|
171
|
+
|
|
172
|
+
stream_output overrides the constructor default per call. In BUFFER
|
|
173
|
+
mode (default) chunks are collected, after_action policies run over
|
|
174
|
+
the assembled output, and the scanned result is yielded — an
|
|
175
|
+
output-side DENY blocks before anything reaches the caller. In
|
|
176
|
+
PASSTHROUGH mode chunks are yielded unscanned as they arrive; output
|
|
177
|
+
policies run post-hoc for metadata/audit only.
|
|
178
|
+
"""
|
|
179
|
+
mode = StreamOutputMode(stream_output) if stream_output is not None else self._stream_output
|
|
180
|
+
context = ActionContext(
|
|
181
|
+
action="stream",
|
|
182
|
+
agent_id=kwargs.get("agent_id", "default"),
|
|
183
|
+
session_id=kwargs.get("session_id", "default"),
|
|
184
|
+
input_data=input_data,
|
|
185
|
+
metadata=dict(kwargs), # a copy, for the reason given in invoke_with_context
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
with self._trace(context):
|
|
189
|
+
context, granted = await self._run_before(context, approval_id)
|
|
190
|
+
|
|
191
|
+
# Every mode below streams from context.input_data: the input as
|
|
192
|
+
# the before phase left it, redactions included.
|
|
193
|
+
if mode == StreamOutputMode.GUARDED:
|
|
194
|
+
async for release in self._stream_guarded(context, kwargs, approval_id, granted):
|
|
195
|
+
yield release
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
chunks: list[Any] = []
|
|
199
|
+
if mode == StreamOutputMode.PASSTHROUGH:
|
|
200
|
+
async for chunk in self._stream_agent(context.input_data, **kwargs):
|
|
201
|
+
chunks.append(chunk)
|
|
202
|
+
yield chunk
|
|
203
|
+
# post-hoc only: metadata/audit and a (too-late but loud) DENY —
|
|
204
|
+
# redaction here mutates a copy the caller never sees
|
|
205
|
+
context.output_data = self._assemble_output(chunks)
|
|
206
|
+
context = await self._run_after(context, approval_id, granted)
|
|
207
|
+
self._finalize(context)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
# BUFFER: nothing reaches the caller until output policies have run
|
|
211
|
+
async for chunk in self._stream_agent(context.input_data, **kwargs):
|
|
212
|
+
chunks.append(chunk)
|
|
213
|
+
was_text = bool(chunks) and all(isinstance(c, str) for c in chunks)
|
|
214
|
+
context.output_data = self._assemble_output(chunks)
|
|
215
|
+
context = await self._run_after(context, approval_id, granted)
|
|
216
|
+
self._finalize(context)
|
|
217
|
+
|
|
218
|
+
if was_text:
|
|
219
|
+
yield context.output_data.get("output", "")
|
|
220
|
+
else:
|
|
221
|
+
for chunk in context.output_data.get("chunks", []):
|
|
222
|
+
yield chunk
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _assemble_output(chunks: list[Any]) -> dict[str, Any]:
|
|
226
|
+
"""Text streams join into {"output": str} so patterns spanning chunk
|
|
227
|
+
boundaries are caught; anything else stays per-chunk under "chunks"
|
|
228
|
+
(policies recurse into nested dicts/lists)."""
|
|
229
|
+
if chunks and all(isinstance(c, str) for c in chunks):
|
|
230
|
+
return {"output": "".join(chunks)}
|
|
231
|
+
return {"chunks": chunks}
|
|
232
|
+
|
|
233
|
+
async def _stream_guarded(
|
|
234
|
+
self,
|
|
235
|
+
context: ActionContext,
|
|
236
|
+
kwargs: dict[str, Any],
|
|
237
|
+
approval_id: str,
|
|
238
|
+
granted: bool,
|
|
239
|
+
) -> AsyncGenerator[str, None]:
|
|
240
|
+
"""Sliding-window guarded streaming over text chunks.
|
|
241
|
+
|
|
242
|
+
Everything except the last *window* characters is released after the
|
|
243
|
+
guards scan it. Because window >= every guard's max_match_len, a
|
|
244
|
+
match can never straddle the released/held boundary: any pattern
|
|
245
|
+
that could still complete with future text lies entirely in the held
|
|
246
|
+
tail, so redaction happens before delivery and a deny aborts with
|
|
247
|
+
nothing of the match emitted. Matches longer than the window (only
|
|
248
|
+
pathological cases; PEM blocks deny on their header) may partially
|
|
249
|
+
leak before detection.
|
|
250
|
+
"""
|
|
251
|
+
guards = []
|
|
252
|
+
for policy in self._policies:
|
|
253
|
+
factory = getattr(policy, "stream_guard", None)
|
|
254
|
+
guard = factory() if callable(factory) else None
|
|
255
|
+
if guard is not None:
|
|
256
|
+
guards.append(guard)
|
|
257
|
+
window = (
|
|
258
|
+
self._guard_window
|
|
259
|
+
if self._guard_window is not None
|
|
260
|
+
else max((g.max_match_len for g in guards), default=0)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
buffer = ""
|
|
264
|
+
emitted: list[str] = []
|
|
265
|
+
async for chunk in self._stream_agent(context.input_data, **kwargs):
|
|
266
|
+
if not isinstance(chunk, str):
|
|
267
|
+
raise TypeError(
|
|
268
|
+
"guarded stream mode requires text chunks; "
|
|
269
|
+
"use stream_output='buffer' for structured chunks"
|
|
270
|
+
)
|
|
271
|
+
buffer += chunk
|
|
272
|
+
buffer = await self._apply_guards(buffer, guards, context, final=False)
|
|
273
|
+
if len(buffer) > window:
|
|
274
|
+
release = buffer[: len(buffer) - window]
|
|
275
|
+
buffer = buffer[len(release) :]
|
|
276
|
+
emitted.append(release)
|
|
277
|
+
yield release
|
|
278
|
+
# final flush: matches deferred at the buffer edge are now definite
|
|
279
|
+
buffer = await self._apply_guards(buffer, guards, context, final=True)
|
|
280
|
+
if buffer:
|
|
281
|
+
emitted.append(buffer)
|
|
282
|
+
yield buffer
|
|
283
|
+
|
|
284
|
+
# policies without a guard (schema, grounding, audit) run at stream
|
|
285
|
+
# end over the released output; a DENY from them raises here
|
|
286
|
+
context.output_data = {"output": "".join(emitted)}
|
|
287
|
+
context = await self._run_after(context, approval_id, granted)
|
|
288
|
+
self._finalize(context)
|
|
289
|
+
|
|
290
|
+
async def _apply_guards(
|
|
291
|
+
self, text: str, guards: list[Any], context: ActionContext, final: bool
|
|
292
|
+
) -> str:
|
|
293
|
+
for guard in guards:
|
|
294
|
+
scan = guard.scan(text, final=final)
|
|
295
|
+
if scan.labels:
|
|
296
|
+
meta = context.metadata.setdefault(
|
|
297
|
+
"stream_guard",
|
|
298
|
+
{"risk": 0.0, "category": "stream_guard", "labels": [], "denied": False},
|
|
299
|
+
)
|
|
300
|
+
meta["labels"] = list(dict.fromkeys([*meta["labels"], *scan.labels]))
|
|
301
|
+
meta["risk"] = max(meta["risk"], scan.risk)
|
|
302
|
+
if scan.deny:
|
|
303
|
+
meta["denied"] = True
|
|
304
|
+
logger.warning(
|
|
305
|
+
"stream_guard_denied",
|
|
306
|
+
session_id=context.session_id,
|
|
307
|
+
guard=guard.name,
|
|
308
|
+
labels=scan.labels,
|
|
309
|
+
)
|
|
310
|
+
context.decision = InterceptorDecision.DENY
|
|
311
|
+
context.decision_reason = scan.reason
|
|
312
|
+
await self._block(context, guard.name, "stream")
|
|
313
|
+
text = scan.text
|
|
314
|
+
return text
|
|
315
|
+
|
|
316
|
+
async def _run_before(
|
|
317
|
+
self, context: ActionContext, approval_id: str = ""
|
|
318
|
+
) -> tuple[ActionContext, bool]:
|
|
319
|
+
"""Returns (context, approval_granted) — granted carries into the
|
|
320
|
+
after phase so one approved action doesn't re-block on output gates."""
|
|
321
|
+
granted = False
|
|
322
|
+
for policy in self._policies:
|
|
323
|
+
start = time.perf_counter()
|
|
324
|
+
try:
|
|
325
|
+
context = await policy.before_action(context)
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
self._observe_latency(policy, "before", start)
|
|
328
|
+
context = self._handle_failure(policy, exc, context, "before_action")
|
|
329
|
+
continue
|
|
330
|
+
self._observe_latency(policy, "before", start)
|
|
331
|
+
if context.decision == InterceptorDecision.REQUIRE_APPROVAL:
|
|
332
|
+
if granted or await self._redeem_approval(approval_id, context):
|
|
333
|
+
granted = True
|
|
334
|
+
context.decision = InterceptorDecision.ALLOW
|
|
335
|
+
context.decision_reason = ""
|
|
336
|
+
continue
|
|
337
|
+
await self._request_approval(context, type(policy).__name__)
|
|
338
|
+
if context.decision == InterceptorDecision.DENY:
|
|
339
|
+
await self._block(context, type(policy).__name__, "before")
|
|
340
|
+
return context, granted
|
|
341
|
+
|
|
342
|
+
async def _run_after(
|
|
343
|
+
self, context: ActionContext, approval_id: str = "", granted: bool = False
|
|
344
|
+
) -> ActionContext:
|
|
345
|
+
blocked_by = ""
|
|
346
|
+
for policy in self._policies:
|
|
347
|
+
start = time.perf_counter()
|
|
348
|
+
try:
|
|
349
|
+
context = await policy.after_action(context)
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
self._observe_latency(policy, "after", start)
|
|
352
|
+
context = self._handle_failure(policy, exc, context, "after_action")
|
|
353
|
+
continue
|
|
354
|
+
self._observe_latency(policy, "after", start)
|
|
355
|
+
if not blocked_by and context.decision != InterceptorDecision.ALLOW:
|
|
356
|
+
blocked_by = type(policy).__name__
|
|
357
|
+
# Unlike the before phase, all policies run to completion (so audit and
|
|
358
|
+
# metadata are complete) before an output-side DENY blocks the response.
|
|
359
|
+
if context.decision == InterceptorDecision.REQUIRE_APPROVAL:
|
|
360
|
+
if granted or await self._redeem_approval(approval_id, context):
|
|
361
|
+
context.decision = InterceptorDecision.ALLOW
|
|
362
|
+
context.decision_reason = ""
|
|
363
|
+
return context
|
|
364
|
+
await self._request_approval(context, blocked_by)
|
|
365
|
+
if context.decision == InterceptorDecision.DENY:
|
|
366
|
+
await self._block(context, blocked_by, "after")
|
|
367
|
+
return context
|
|
368
|
+
|
|
369
|
+
async def _block(
|
|
370
|
+
self, context: ActionContext, blocked_by: str, phase: str, approval_id: str = ""
|
|
371
|
+
) -> NoReturn:
|
|
372
|
+
"""The single exit for a refused action: aggregate, tell the observers, raise.
|
|
373
|
+
|
|
374
|
+
A deny in the before phase stops the loop, so every policy after the
|
|
375
|
+
one that denied never has its hooks called — and the README tells you
|
|
376
|
+
to put AuditPolicy last. Without this, a blocked prompt left no record
|
|
377
|
+
at all. Policies that define on_blocked() are told about the refusal
|
|
378
|
+
here, wherever they sit in the list and whichever phase it happened in.
|
|
379
|
+
The policies that were skipped stay skipped: nothing scores or mutates
|
|
380
|
+
a prompt that has already been refused.
|
|
381
|
+
"""
|
|
382
|
+
self._finalize(context)
|
|
383
|
+
for policy in self._policies:
|
|
384
|
+
hook = getattr(policy, "on_blocked", None)
|
|
385
|
+
if not callable(hook):
|
|
386
|
+
continue
|
|
387
|
+
try:
|
|
388
|
+
await hook(context, blocked_by=blocked_by, phase=phase, approval_id=approval_id)
|
|
389
|
+
except Exception as exc:
|
|
390
|
+
# The action is already refused. An observer that fails must
|
|
391
|
+
# not turn that refusal into a different error, whatever its
|
|
392
|
+
# failure mode says about the hooks that can still allow.
|
|
393
|
+
logger.warning(
|
|
394
|
+
"on_blocked_hook_failed",
|
|
395
|
+
interceptor=type(policy).__name__,
|
|
396
|
+
error=str(exc),
|
|
397
|
+
)
|
|
398
|
+
raise ActionBlockedError(
|
|
399
|
+
reason=context.decision_reason,
|
|
400
|
+
interceptor=blocked_by,
|
|
401
|
+
context=context,
|
|
402
|
+
risk_score=context.risk_score,
|
|
403
|
+
approval_id=approval_id,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
async def _redeem_approval(self, approval_id: str, context: ActionContext) -> bool:
|
|
407
|
+
if not approval_id:
|
|
408
|
+
return False
|
|
409
|
+
# A person's yes is to one call in one session. The hash binds the
|
|
410
|
+
# call; this binds where it was asked. Without it, an approval given
|
|
411
|
+
# for a call in one session ran the identical call in another,
|
|
412
|
+
# tainted one (docs/red-team.md, B1).
|
|
413
|
+
record = await self._approval_store.get(approval_id)
|
|
414
|
+
if record is None or (record.session_id, record.agent_id) != (
|
|
415
|
+
context.session_id,
|
|
416
|
+
context.agent_id,
|
|
417
|
+
):
|
|
418
|
+
return False
|
|
419
|
+
return await self._approval_store.consume(
|
|
420
|
+
approval_id, action_hash(context.action, context.input_data)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
async def _request_approval(self, context: ActionContext, interceptor_name: str) -> None:
|
|
424
|
+
"""Create a pending approval record, emit APPROVAL_REQUESTED, and
|
|
425
|
+
raise ActionBlockedError carrying the approval id."""
|
|
426
|
+
record = ApprovalRecord(
|
|
427
|
+
session_id=context.session_id,
|
|
428
|
+
agent_id=context.agent_id,
|
|
429
|
+
action=context.action,
|
|
430
|
+
reason=context.decision_reason,
|
|
431
|
+
action_hash=action_hash(context.action, context.input_data),
|
|
432
|
+
)
|
|
433
|
+
await self._approval_store.create(record)
|
|
434
|
+
logger.info(
|
|
435
|
+
"approval_requested",
|
|
436
|
+
approval_id=record.approval_id,
|
|
437
|
+
session_id=context.session_id,
|
|
438
|
+
interceptor=interceptor_name,
|
|
439
|
+
reason=context.decision_reason,
|
|
440
|
+
)
|
|
441
|
+
if self._writer is not None:
|
|
442
|
+
seq = self._next_sequence(context.session_id)
|
|
443
|
+
await self._writer.write(
|
|
444
|
+
Event(
|
|
445
|
+
session_id=context.session_id,
|
|
446
|
+
agent_id=context.agent_id,
|
|
447
|
+
event_type=EventType.APPROVAL_REQUESTED,
|
|
448
|
+
payload={
|
|
449
|
+
"approval_id": record.approval_id,
|
|
450
|
+
"action": context.action,
|
|
451
|
+
"reason": context.decision_reason,
|
|
452
|
+
"interceptor": interceptor_name,
|
|
453
|
+
"expires_at": record.expires_at.isoformat(),
|
|
454
|
+
},
|
|
455
|
+
sequence_num=seq,
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
phase = "after" if context.output_data else "before"
|
|
459
|
+
await self._block(context, interceptor_name, phase, approval_id=record.approval_id)
|
|
460
|
+
|
|
461
|
+
def _finalize(self, context: ActionContext) -> ActionContext:
|
|
462
|
+
"""Aggregate risk and record metrics — runs at every exit point
|
|
463
|
+
(allow, deny, approval-requested), so metrics see every action."""
|
|
464
|
+
self._risk_scorer.aggregate(context)
|
|
465
|
+
if self._metrics is not None:
|
|
466
|
+
self._metrics.record_action(context)
|
|
467
|
+
return context
|
|
468
|
+
|
|
469
|
+
def _trace(self, context: ActionContext) -> AbstractContextManager[Any]:
|
|
470
|
+
if self._tracer is None:
|
|
471
|
+
return nullcontext()
|
|
472
|
+
return self._tracer.span(context)
|
|
473
|
+
|
|
474
|
+
def _observe_latency(self, policy: Policy, phase: str, start: float) -> None:
|
|
475
|
+
if self._metrics is not None:
|
|
476
|
+
self._metrics.observe_policy_latency(
|
|
477
|
+
type(policy).__name__, phase, time.perf_counter() - start
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def _next_sequence(self, session_id: str) -> int:
|
|
481
|
+
count = self._sequence_counters.get(session_id, 0) + 1
|
|
482
|
+
self._sequence_counters[session_id] = count
|
|
483
|
+
return count
|
|
484
|
+
|
|
485
|
+
def _handle_failure(
|
|
486
|
+
self,
|
|
487
|
+
policy: Policy,
|
|
488
|
+
exc: Exception,
|
|
489
|
+
context: ActionContext,
|
|
490
|
+
phase: str,
|
|
491
|
+
) -> ActionContext:
|
|
492
|
+
interceptor_name = type(policy).__name__
|
|
493
|
+
log = logger.bind(interceptor=interceptor_name, phase=phase, error=str(exc))
|
|
494
|
+
|
|
495
|
+
match policy.failure_mode:
|
|
496
|
+
case FailureMode.FAIL_OPEN:
|
|
497
|
+
log.warning("interceptor_error_fail_open")
|
|
498
|
+
return context
|
|
499
|
+
case FailureMode.FAIL_CLOSED:
|
|
500
|
+
log.error("interceptor_error_fail_closed")
|
|
501
|
+
raise RuntimeError(
|
|
502
|
+
f"Action blocked: {interceptor_name} failed during {phase}"
|
|
503
|
+
) from exc
|
|
504
|
+
case FailureMode.FAIL_EXCEPTION:
|
|
505
|
+
log.error("interceptor_error_fail_exception")
|
|
506
|
+
raise
|
|
507
|
+
case _:
|
|
508
|
+
log.error("interceptor_error_unknown_failure_mode")
|
|
509
|
+
raise
|
|
510
|
+
|
|
511
|
+
async def _call_agent(self, input_data: dict[str, Any], **kwargs: Any) -> Any:
|
|
512
|
+
if hasattr(self._agent, "ainvoke"):
|
|
513
|
+
return await self._agent.ainvoke(input_data, **kwargs)
|
|
514
|
+
if hasattr(self._agent, "invoke"):
|
|
515
|
+
loop = asyncio.get_running_loop()
|
|
516
|
+
return await loop.run_in_executor(
|
|
517
|
+
None, lambda: self._agent.invoke(input_data, **kwargs)
|
|
518
|
+
)
|
|
519
|
+
raise TypeError(f"Agent {type(self._agent)} has no invoke or ainvoke method")
|
|
520
|
+
|
|
521
|
+
async def _stream_agent(
|
|
522
|
+
self, input_data: dict[str, Any], **kwargs: Any
|
|
523
|
+
) -> AsyncGenerator[Any, None]:
|
|
524
|
+
if hasattr(self._agent, "astream"):
|
|
525
|
+
async for chunk in self._agent.astream(input_data, **kwargs):
|
|
526
|
+
yield chunk
|
|
527
|
+
elif hasattr(self._agent, "stream"):
|
|
528
|
+
for chunk in self._agent.stream(input_data, **kwargs):
|
|
529
|
+
yield chunk
|
|
530
|
+
else:
|
|
531
|
+
raise TypeError(f"Agent {type(self._agent)} has no stream or astream method")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class OpenScriptMiddleware(SecureAgent):
|
|
535
|
+
"""Deprecated — use SecureAgent instead."""
|
|
536
|
+
|
|
537
|
+
def __init__(self, agent: Any, **kwargs: Any) -> None:
|
|
538
|
+
warnings.warn(
|
|
539
|
+
"OpenScriptMiddleware is deprecated; use SecureAgent instead.",
|
|
540
|
+
DeprecationWarning,
|
|
541
|
+
stacklevel=2,
|
|
542
|
+
)
|
|
543
|
+
super().__init__(agent, **kwargs)
|