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