clinicsentry 0.3.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 (75) hide show
  1. clinicsentry/__init__.py +32 -0
  2. clinicsentry/adapters/__init__.py +27 -0
  3. clinicsentry/adapters/a2a.py +60 -0
  4. clinicsentry/adapters/base.py +158 -0
  5. clinicsentry/adapters/claude_sdk.py +71 -0
  6. clinicsentry/adapters/crewai.py +89 -0
  7. clinicsentry/adapters/google_adk.py +100 -0
  8. clinicsentry/adapters/langgraph.py +66 -0
  9. clinicsentry/adapters/mcp_proxy.py +92 -0
  10. clinicsentry/adapters/openai_agents.py +92 -0
  11. clinicsentry/audit/__init__.py +24 -0
  12. clinicsentry/audit/backend.py +220 -0
  13. clinicsentry/audit/chain.py +137 -0
  14. clinicsentry/audit/migrations/__init__.py +16 -0
  15. clinicsentry/audit/migrations/alembic.ini +38 -0
  16. clinicsentry/audit/migrations/env.py +54 -0
  17. clinicsentry/audit/migrations/runner.py +43 -0
  18. clinicsentry/audit/migrations/script.py.mako +24 -0
  19. clinicsentry/audit/migrations/versions/0001_initial_audit_events.py +66 -0
  20. clinicsentry/audit/otel.py +74 -0
  21. clinicsentry/audit/pdf_report.py +127 -0
  22. clinicsentry/audit/postgres.py +188 -0
  23. clinicsentry/audit/report.py +191 -0
  24. clinicsentry/audit/s3.py +169 -0
  25. clinicsentry/cli.py +303 -0
  26. clinicsentry/compliance/__init__.py +34 -0
  27. clinicsentry/compliance/engine.py +442 -0
  28. clinicsentry/compliance/rules/eu_ai_act.yaml +21 -0
  29. clinicsentry/compliance/rules/fda_tplc.yaml +21 -0
  30. clinicsentry/compliance/rules/hipaa.yaml +33 -0
  31. clinicsentry/compliance/rules/iec62304.yaml +21 -0
  32. clinicsentry/dashboard/__init__.py +21 -0
  33. clinicsentry/dashboard/app.py +242 -0
  34. clinicsentry/errors.py +176 -0
  35. clinicsentry/escalation/__init__.py +36 -0
  36. clinicsentry/escalation/channels.py +228 -0
  37. clinicsentry/escalation/confidence.py +178 -0
  38. clinicsentry/escalation/extra_signals.py +216 -0
  39. clinicsentry/escalation/router.py +222 -0
  40. clinicsentry/escalation/temperature_scaling.py +209 -0
  41. clinicsentry/guard.py +279 -0
  42. clinicsentry/meddevice/__init__.py +51 -0
  43. clinicsentry/meddevice/cia.py +125 -0
  44. clinicsentry/meddevice/clinician_auth.py +115 -0
  45. clinicsentry/meddevice/cloud_kms.py +216 -0
  46. clinicsentry/meddevice/http_kms.py +144 -0
  47. clinicsentry/meddevice/iec62304_v2.py +64 -0
  48. clinicsentry/meddevice/keys.py +152 -0
  49. clinicsentry/meddevice/mode.py +208 -0
  50. clinicsentry/observability/__init__.py +16 -0
  51. clinicsentry/observability/logging.py +68 -0
  52. clinicsentry/observability/metrics.py +82 -0
  53. clinicsentry/observability/tracing.py +30 -0
  54. clinicsentry/performance.py +130 -0
  55. clinicsentry/phi/__init__.py +34 -0
  56. clinicsentry/phi/adversarial.py +220 -0
  57. clinicsentry/phi/detectors.py +198 -0
  58. clinicsentry/phi/firewall.py +296 -0
  59. clinicsentry/phi/medical_ner.py +110 -0
  60. clinicsentry/phi/minimum_necessary.py +100 -0
  61. clinicsentry/phi/multilingual.py +100 -0
  62. clinicsentry/phi/ocr.py +58 -0
  63. clinicsentry/phi/parsers.py +149 -0
  64. clinicsentry/phi/pipeline.py +93 -0
  65. clinicsentry/phi/propagation.py +51 -0
  66. clinicsentry/phi/redaction.py +105 -0
  67. clinicsentry/policy.py +366 -0
  68. clinicsentry/py.typed +0 -0
  69. clinicsentry/types.py +203 -0
  70. clinicsentry-0.3.0.dist-info/METADATA +303 -0
  71. clinicsentry-0.3.0.dist-info/RECORD +75 -0
  72. clinicsentry-0.3.0.dist-info/WHEEL +4 -0
  73. clinicsentry-0.3.0.dist-info/entry_points.txt +2 -0
  74. clinicsentry-0.3.0.dist-info/licenses/LICENSE +201 -0
  75. clinicsentry-0.3.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,32 @@
1
+ """ClinicSentry: framework-agnostic compliance middleware for clinical AI agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _dist_version
5
+
6
+ from clinicsentry.guard import ClinicSentry
7
+ from clinicsentry.phi.minimum_necessary import minimum_necessary
8
+ from clinicsentry.types import (
9
+ AuditEvent,
10
+ AuditEventType,
11
+ ClinicalRiskTier,
12
+ EscalationDecision,
13
+ PHITag,
14
+ RegulatoryReport,
15
+ )
16
+
17
+ __all__ = [
18
+ "ClinicSentry",
19
+ "AuditEvent",
20
+ "AuditEventType",
21
+ "ClinicalRiskTier",
22
+ "EscalationDecision",
23
+ "PHITag",
24
+ "RegulatoryReport",
25
+ "minimum_necessary",
26
+ ]
27
+
28
+ # Single-sourced from pyproject.toml via installed package metadata.
29
+ try:
30
+ __version__ = _dist_version("clinicsentry")
31
+ except PackageNotFoundError: # pragma: no cover - source tree without install
32
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,27 @@
1
+ """Framework adapters.
2
+
3
+ All adapters subclass :class:`AgentFrameworkAdapter` and are functional even if
4
+ the corresponding upstream SDK is not installed — only the ``wrap`` /
5
+ ``install`` methods raise :class:`ImportError` in that case.
6
+ """
7
+
8
+ from clinicsentry.adapters.a2a import A2AInterceptor
9
+ from clinicsentry.adapters.base import AgentFrameworkAdapter, GenericAdapter
10
+ from clinicsentry.adapters.claude_sdk import ClaudeSDKAdapter
11
+ from clinicsentry.adapters.crewai import CrewAIAdapter
12
+ from clinicsentry.adapters.google_adk import GoogleADKAdapter
13
+ from clinicsentry.adapters.langgraph import LangGraphAdapter
14
+ from clinicsentry.adapters.mcp_proxy import MCPProxyAdapter
15
+ from clinicsentry.adapters.openai_agents import OpenAIAgentsAdapter
16
+
17
+ __all__ = [
18
+ "AgentFrameworkAdapter",
19
+ "GenericAdapter",
20
+ "A2AInterceptor",
21
+ "ClaudeSDKAdapter",
22
+ "CrewAIAdapter",
23
+ "GoogleADKAdapter",
24
+ "LangGraphAdapter",
25
+ "MCPProxyAdapter",
26
+ "OpenAIAgentsAdapter",
27
+ ]
@@ -0,0 +1,60 @@
1
+ """Agent-to-Agent (A2A) interceptor.
2
+
3
+ A2A is Google's open protocol for inter-agent communication. We intercept
4
+ messages crossing an A2A boundary, scan their payloads, and update the
5
+ propagation graph so cross-agent PHI flow is traceable in the audit report.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from clinicsentry.adapters.base import GenericAdapter
13
+ from clinicsentry.types import AuditEvent, AuditEventType
14
+
15
+ __all__ = ["A2AInterceptor"]
16
+
17
+
18
+ class A2AInterceptor(GenericAdapter):
19
+ """Session-tagging layer for A2A protocol messages."""
20
+
21
+ framework_name = "a2a"
22
+
23
+ async def on_message(
24
+ self,
25
+ *,
26
+ from_agent: str,
27
+ to_agent: str,
28
+ message: dict[str, Any],
29
+ session_correlation_id: str | None = None,
30
+ ) -> dict[str, Any]:
31
+ """Scan an A2A message, propagate PHI tags, audit, and return the redacted payload.
32
+
33
+ Args:
34
+ from_agent: agent id of the sender.
35
+ to_agent: agent id of the receiver.
36
+ message: the A2A message payload.
37
+ session_correlation_id: optional cross-session correlation token.
38
+
39
+ Returns:
40
+ The redacted message ready to forward.
41
+ """
42
+ scan = self.guard.firewall.scan(message, origin_agent=from_agent)
43
+ for tag in scan.tags:
44
+ self.guard.firewall.propagation.propagate(tag.tag_id, from_agent, to_agent)
45
+ self.guard.emit_event(
46
+ AuditEvent(
47
+ event_type=AuditEventType.A2A_MESSAGE,
48
+ session_id=self.guard.session_id,
49
+ sequence_number=0,
50
+ agent_framework=self.framework_name,
51
+ agent_id=from_agent,
52
+ redacted_output={
53
+ "to": to_agent,
54
+ "message": scan.redacted,
55
+ "correlation_id": session_correlation_id,
56
+ },
57
+ phi_tags_detected=[t.tag_id for t in scan.tags],
58
+ )
59
+ )
60
+ return scan.redacted # type: ignore[no-any-return]
@@ -0,0 +1,158 @@
1
+ """Adapter ABC + a generic in-process adapter usable for testing.
2
+
3
+ The README §10 abstract interface is preserved verbatim. The default
4
+ ``GenericAdapter`` implementation routes all interception points through the
5
+ configured :class:`ClinicSentry` instance and is sufficient for
6
+ framework-agnostic test coverage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from abc import ABC, abstractmethod
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from clinicsentry.types import AuditEvent, AuditEventType
17
+
18
+ __all__ = [
19
+ "AgentFrameworkAdapter",
20
+ "GenericAdapter",
21
+ ]
22
+
23
+ if TYPE_CHECKING:
24
+ from clinicsentry.guard import ClinicSentry
25
+
26
+
27
+ class AgentFrameworkAdapter(ABC):
28
+ """Adapter contract every framework integration must implement."""
29
+
30
+ framework_name: str = "generic"
31
+
32
+ def __init__(self, guard: ClinicSentry) -> None:
33
+ self.guard = guard
34
+
35
+ @abstractmethod
36
+ async def intercept_before_llm(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
37
+ """Sanitize messages before they reach the LLM."""
38
+
39
+ @abstractmethod
40
+ async def intercept_after_llm(self, response: dict[str, Any]) -> dict[str, Any]:
41
+ """Sanitize an LLM response before it returns to the agent."""
42
+
43
+ @abstractmethod
44
+ async def intercept_tool_call(
45
+ self, tool_name: str, arguments: dict[str, Any]
46
+ ) -> dict[str, Any]:
47
+ """Sanitize tool arguments and apply minimum-necessary."""
48
+
49
+ @abstractmethod
50
+ async def intercept_tool_result(self, tool_name: str, result: dict[str, Any]) -> dict[str, Any]:
51
+ """Sanitize tool results before they enter the agent context."""
52
+
53
+ @abstractmethod
54
+ async def intercept_agent_message(
55
+ self, from_agent: str, to_agent: str, message: dict[str, Any]
56
+ ) -> dict[str, Any]:
57
+ """Sanitize inter-agent messages."""
58
+
59
+
60
+ def _hash(value: Any) -> str:
61
+ """Stable SHA-256 hash for arbitrary JSON-able value."""
62
+ return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
63
+
64
+
65
+ class GenericAdapter(AgentFrameworkAdapter):
66
+ """Default adapter wiring all interception points to the firewall + audit."""
67
+
68
+ framework_name = "generic"
69
+
70
+ async def intercept_before_llm(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
71
+ """Scan and audit each message."""
72
+ scan = self.guard.firewall.scan(messages, origin_agent=self.framework_name)
73
+ self.guard.emit_event(
74
+ AuditEvent(
75
+ event_type=AuditEventType.AGENT_LLM_CALL,
76
+ session_id=self.guard.session_id,
77
+ sequence_number=0,
78
+ agent_framework=self.framework_name,
79
+ input_hash=_hash(messages),
80
+ redacted_input={"messages": scan.redacted},
81
+ phi_tags_detected=[t.tag_id for t in scan.tags],
82
+ )
83
+ )
84
+ return scan.redacted # type: ignore[no-any-return]
85
+
86
+ async def intercept_after_llm(self, response: dict[str, Any]) -> dict[str, Any]:
87
+ """Scan and audit the LLM response."""
88
+ scan = self.guard.firewall.scan(response, origin_agent=self.framework_name)
89
+ self.guard.emit_event(
90
+ AuditEvent(
91
+ event_type=AuditEventType.AGENT_LLM_RESPONSE,
92
+ session_id=self.guard.session_id,
93
+ sequence_number=0,
94
+ agent_framework=self.framework_name,
95
+ output_hash=_hash(response),
96
+ redacted_output={"response": scan.redacted},
97
+ phi_tags_detected=[t.tag_id for t in scan.tags],
98
+ )
99
+ )
100
+ return scan.redacted # type: ignore[no-any-return]
101
+
102
+ async def intercept_tool_call(
103
+ self, tool_name: str, arguments: dict[str, Any]
104
+ ) -> dict[str, Any]:
105
+ """Scan tool args; minimum-necessary is applied at decoration time."""
106
+ self.guard.meddevice.assert_running()
107
+ scan = self.guard.firewall.scan(arguments, origin_agent=tool_name)
108
+ self.guard.emit_event(
109
+ AuditEvent(
110
+ event_type=AuditEventType.TOOL_CALL,
111
+ session_id=self.guard.session_id,
112
+ sequence_number=0,
113
+ agent_framework=self.framework_name,
114
+ agent_id=tool_name,
115
+ input_hash=_hash(arguments),
116
+ redacted_input={"tool_name": tool_name, "arguments": scan.redacted},
117
+ phi_tags_detected=[t.tag_id for t in scan.tags],
118
+ )
119
+ )
120
+ return scan.redacted # type: ignore[no-any-return]
121
+
122
+ async def intercept_tool_result(self, tool_name: str, result: dict[str, Any]) -> dict[str, Any]:
123
+ """Scan tool result before it re-enters the agent."""
124
+ scan = self.guard.firewall.scan(result, origin_agent=tool_name)
125
+ self.guard.emit_event(
126
+ AuditEvent(
127
+ event_type=AuditEventType.TOOL_RESULT,
128
+ session_id=self.guard.session_id,
129
+ sequence_number=0,
130
+ agent_framework=self.framework_name,
131
+ agent_id=tool_name,
132
+ output_hash=_hash(result),
133
+ redacted_output={"tool_name": tool_name, "result": scan.redacted},
134
+ phi_tags_detected=[t.tag_id for t in scan.tags],
135
+ )
136
+ )
137
+ return scan.redacted # type: ignore[no-any-return]
138
+
139
+ async def intercept_agent_message(
140
+ self, from_agent: str, to_agent: str, message: dict[str, Any]
141
+ ) -> dict[str, Any]:
142
+ """Scan + propagate PHI tags across an agent-to-agent boundary."""
143
+ scan = self.guard.firewall.scan(message, origin_agent=from_agent)
144
+ for tag in scan.tags:
145
+ self.guard.firewall.propagation.propagate(tag.tag_id, from_agent, to_agent)
146
+ self.guard.emit_event(
147
+ AuditEvent(
148
+ event_type=AuditEventType.INTER_AGENT_MESSAGE,
149
+ session_id=self.guard.session_id,
150
+ sequence_number=0,
151
+ agent_framework=self.framework_name,
152
+ agent_id=from_agent,
153
+ output_hash=_hash(message),
154
+ redacted_output={"to": to_agent, "message": scan.redacted},
155
+ phi_tags_detected=[t.tag_id for t in scan.tags],
156
+ )
157
+ )
158
+ return scan.redacted # type: ignore[no-any-return]
@@ -0,0 +1,71 @@
1
+ """Anthropic Claude (Agents) SDK adapter.
2
+
3
+ The Claude API uses ``tool_use`` / ``tool_result`` content blocks. This adapter
4
+ wraps :func:`Anthropic.messages.create` to scan blocks in both directions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from clinicsentry.adapters.base import GenericAdapter
12
+
13
+ __all__ = ["ClaudeSDKAdapter"]
14
+
15
+
16
+ class ClaudeSDKAdapter(GenericAdapter):
17
+ """Adapter for ``anthropic.Anthropic`` clients."""
18
+
19
+ framework_name = "claude_sdk"
20
+
21
+ def wrap(self, client: Any) -> Any:
22
+ """Patch the ``messages.create`` method on a Claude client.
23
+
24
+ Args:
25
+ client: an ``anthropic.Anthropic`` (or ``AsyncAnthropic``) instance.
26
+
27
+ Returns:
28
+ The same client with ``messages.create`` patched.
29
+
30
+ Raises:
31
+ ImportError: if the anthropic SDK is missing.
32
+ """
33
+ try: # pragma: no cover
34
+ import anthropic # noqa: F401
35
+ except ImportError as exc: # pragma: no cover
36
+ raise ImportError(
37
+ "anthropic is not installed. `pip install 'clinicsentry[claude]'`."
38
+ ) from exc
39
+
40
+ adapter = self
41
+ messages = client.messages
42
+ original_create = messages.create
43
+
44
+ def patched_create(**kwargs: Any) -> Any:
45
+ """Scan input messages / tool blocks; then scan the response."""
46
+ inbound = kwargs.get("messages") or []
47
+ scan_in = adapter.guard.firewall.scan(inbound, origin_agent=adapter.framework_name)
48
+ kwargs["messages"] = scan_in.redacted
49
+ response = original_create(**kwargs)
50
+ content = getattr(response, "content", None)
51
+ if content is not None:
52
+ scan_out = adapter.guard.firewall.scan(content, origin_agent=adapter.framework_name)
53
+ response.content = scan_out.redacted
54
+ return response
55
+
56
+ messages.create = patched_create
57
+ return client
58
+
59
+ def scan_tool_use(self, block: dict[str, Any]) -> dict[str, Any]:
60
+ """Synchronously scan a Claude ``tool_use`` content block."""
61
+ scan = self.guard.firewall.scan(
62
+ block.get("input", {}), origin_agent=block.get("name", "tool")
63
+ )
64
+ return {**block, "input": scan.redacted}
65
+
66
+ def scan_tool_result(self, block: dict[str, Any]) -> dict[str, Any]:
67
+ """Synchronously scan a Claude ``tool_result`` content block."""
68
+ scan = self.guard.firewall.scan(
69
+ block.get("content", ""), origin_agent=block.get("tool_use_id", "tool")
70
+ )
71
+ return {**block, "content": scan.redacted}
@@ -0,0 +1,89 @@
1
+ """CrewAI adapter.
2
+
3
+ CrewAI exposes ``before_kickoff`` / ``after_kickoff`` hooks on :class:`Crew` and
4
+ ``callback`` on individual :class:`Task` objects. We attach interception via
5
+ these public extension points; agent-handoff PHI propagation is wired through
6
+ the agent message hook.
7
+
8
+ The adapter is functional without CrewAI installed: :class:`CrewAIAdapter`
9
+ inherits :class:`GenericAdapter`'s interception methods, so the middleware can
10
+ be invoked manually from custom CrewAI tools.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Any
17
+
18
+ from clinicsentry.adapters.base import GenericAdapter
19
+
20
+ __all__ = ["CrewAIAdapter"]
21
+
22
+
23
+ class CrewAIAdapter(GenericAdapter):
24
+ """Adapter specialization for CrewAI Crew + Task workflows."""
25
+
26
+ framework_name = "crewai"
27
+
28
+ def wrap(self, crew: Any) -> Any:
29
+ """Attach ClinicSentry interception to a Crew.
30
+
31
+ Args:
32
+ crew: a ``crewai.Crew`` instance (duck-typed).
33
+
34
+ Returns:
35
+ The same ``crew`` for fluent chaining.
36
+
37
+ Raises:
38
+ ImportError: if CrewAI is not installed.
39
+ """
40
+ try: # pragma: no cover - smoke check
41
+ import crewai # noqa: F401
42
+ except ImportError as exc: # pragma: no cover
43
+ raise ImportError(
44
+ "CrewAI is not installed. `pip install 'clinicsentry[crewai]'`."
45
+ ) from exc
46
+
47
+ adapter = self
48
+ original_kickoff = getattr(crew, "kickoff", None)
49
+ if original_kickoff is None: # pragma: no cover
50
+ return crew
51
+
52
+ def wrapped_kickoff(inputs: dict[str, Any] | None = None, **kwargs: Any) -> Any:
53
+ """Scan inputs, run the crew, scan outputs."""
54
+ scrubbed = inputs or {}
55
+ scan_in = adapter.guard.firewall.scan(scrubbed, origin_agent=adapter.framework_name)
56
+ result = original_kickoff(inputs=scan_in.redacted, **kwargs)
57
+ scan_out = adapter.guard.firewall.scan(result, origin_agent=adapter.framework_name)
58
+ return scan_out.redacted
59
+
60
+ crew.kickoff = wrapped_kickoff
61
+
62
+ # Wrap each task's callback so per-task outputs are scanned and audited.
63
+ for task in getattr(crew, "tasks", []) or []:
64
+ original_cb = getattr(task, "callback", None)
65
+
66
+ def task_callback(
67
+ output: Any,
68
+ _orig: Any = original_cb,
69
+ _task_name: str = getattr(task, "description", "task"),
70
+ ) -> Any:
71
+ """Scan the task output, then invoke the user's callback."""
72
+ scan = adapter.guard.firewall.scan(output, origin_agent=_task_name)
73
+ if _orig is not None:
74
+ _orig(scan.redacted)
75
+ return scan.redacted
76
+
77
+ task.callback = task_callback
78
+
79
+ return crew
80
+
81
+ async def on_agent_handoff(
82
+ self, from_agent: str, to_agent: str, payload: dict[str, Any]
83
+ ) -> dict[str, Any]:
84
+ """Hook intended for CrewAI sequential / hierarchical handoffs."""
85
+ return await self.intercept_agent_message(from_agent, to_agent, payload)
86
+
87
+ def sync_intercept_tool_call(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
88
+ """Synchronous bridge for tools that cannot await."""
89
+ return asyncio.run(self.intercept_tool_call(tool_name, arguments))
@@ -0,0 +1,100 @@
1
+ """Google ADK (Agent Development Kit) adapter.
2
+
3
+ Google ADK exposes ``before_model_callback`` / ``after_model_callback`` on each
4
+ :class:`Agent` and tool-execution callbacks via the runner. We attach
5
+ interception through these public extension points.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from clinicsentry.adapters.base import GenericAdapter
14
+
15
+ __all__ = ["GoogleADKAdapter"]
16
+
17
+
18
+ class GoogleADKAdapter(GenericAdapter):
19
+ """Adapter specialization for Google ADK agents."""
20
+
21
+ framework_name = "google_adk"
22
+
23
+ def wrap(self, agent: Any) -> Any:
24
+ """Attach callbacks to a ``google.adk.Agent`` instance.
25
+
26
+ Args:
27
+ agent: an ADK Agent instance (duck-typed).
28
+
29
+ Returns:
30
+ The same ``agent`` for fluent chaining.
31
+
32
+ Raises:
33
+ ImportError: if google-adk is not installed.
34
+ """
35
+ try: # pragma: no cover
36
+ import google.adk # noqa: F401
37
+ except ImportError as exc: # pragma: no cover
38
+ raise ImportError(
39
+ "Google ADK is not installed. `pip install 'clinicsentry[adk]'`."
40
+ ) from exc
41
+
42
+ adapter = self
43
+ agent.before_model_callback = self._make_before_model_cb(adapter)
44
+ agent.after_model_callback = self._make_after_model_cb(adapter)
45
+ agent.before_tool_callback = self._make_before_tool_cb(adapter)
46
+ agent.after_tool_callback = self._make_after_tool_cb(adapter)
47
+ return agent
48
+
49
+ @staticmethod
50
+ def _make_before_model_cb(adapter: GoogleADKAdapter) -> Callable[..., Any]:
51
+ """Build an ADK before-model callback closing over ``adapter``."""
52
+
53
+ def cb(*, callback_context: Any, llm_request: Any) -> None:
54
+ """Scan request contents in place."""
55
+ contents = getattr(llm_request, "contents", None)
56
+ if contents is None:
57
+ return
58
+ scan = adapter.guard.firewall.scan(contents, origin_agent=adapter.framework_name)
59
+ llm_request.contents = scan.redacted
60
+
61
+ return cb
62
+
63
+ @staticmethod
64
+ def _make_after_model_cb(adapter: GoogleADKAdapter) -> Callable[..., Any]:
65
+ """Build an ADK after-model callback."""
66
+
67
+ def cb(*, callback_context: Any, llm_response: Any) -> None:
68
+ """Scan the model response in place."""
69
+ content = getattr(llm_response, "content", None)
70
+ if content is None:
71
+ return
72
+ scan = adapter.guard.firewall.scan(content, origin_agent=adapter.framework_name)
73
+ llm_response.content = scan.redacted
74
+
75
+ return cb
76
+
77
+ @staticmethod
78
+ def _make_before_tool_cb(adapter: GoogleADKAdapter) -> Callable[..., Any]:
79
+ """Build an ADK before-tool callback."""
80
+
81
+ def cb(*, tool: Any, args: dict[str, Any], tool_context: Any) -> dict[str, Any]:
82
+ """Scan tool args; minimum-necessary applies at decoration time."""
83
+ adapter.guard.meddevice.assert_running()
84
+ scan = adapter.guard.firewall.scan(args, origin_agent=getattr(tool, "name", "tool"))
85
+ return scan.redacted # type: ignore[no-any-return]
86
+
87
+ return cb
88
+
89
+ @staticmethod
90
+ def _make_after_tool_cb(adapter: GoogleADKAdapter) -> Callable[..., Any]:
91
+ """Build an ADK after-tool callback."""
92
+
93
+ def cb(*, tool: Any, args: dict[str, Any], tool_context: Any, tool_response: Any) -> Any:
94
+ """Scan tool result before it re-enters the agent context."""
95
+ scan = adapter.guard.firewall.scan(
96
+ tool_response, origin_agent=getattr(tool, "name", "tool")
97
+ )
98
+ return scan.redacted
99
+
100
+ return cb
@@ -0,0 +1,66 @@
1
+ """LangGraph adapter (best-effort).
2
+
3
+ LangGraph is an optional dependency. The adapter exposes the same interface as
4
+ :class:`GenericAdapter` and provides a ``wrap`` helper that, when LangGraph is
5
+ present, registers ``before_node`` / ``after_node`` callbacks on a StateGraph.
6
+
7
+ If LangGraph is not installed, ``wrap`` raises :class:`ImportError` while the
8
+ adapter's interception methods remain functional for use as in-process
9
+ middleware (e.g., callable from custom node implementations).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from clinicsentry.adapters.base import GenericAdapter
17
+
18
+ __all__ = [
19
+ "LangGraphAdapter",
20
+ ]
21
+
22
+
23
+ class LangGraphAdapter(GenericAdapter):
24
+ """Adapter specialization for LangGraph StateGraph workflows."""
25
+
26
+ framework_name = "langgraph"
27
+
28
+ def wrap(self, graph: Any) -> Any: # pragma: no cover - thin LangGraph integration
29
+ """Attach ClinicSentry interception to a StateGraph instance.
30
+
31
+ We monkey-patch the graph's compiled ``ainvoke`` / ``invoke`` to scan I/O.
32
+ A future revision should switch to LangGraph's official callback handlers
33
+ once their public API stabilizes (target: ``langgraph >= 0.3``).
34
+ """
35
+ try:
36
+ from langgraph.graph import StateGraph # noqa: F401
37
+ except Exception as exc: # pragma: no cover
38
+ raise ImportError(
39
+ "LangGraph is not installed. `pip install clinicsentry[langgraph]`."
40
+ ) from exc
41
+
42
+ original_invoke = getattr(graph, "invoke", None)
43
+ original_ainvoke = getattr(graph, "ainvoke", None)
44
+ adapter = self
45
+
46
+ if original_invoke is not None:
47
+
48
+ def wrapped_invoke(state: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
49
+ scanned = adapter.guard.firewall.scan(state, origin_agent=adapter.framework_name)
50
+ result = original_invoke(scanned.redacted, *args, **kwargs)
51
+ out_scan = adapter.guard.firewall.scan(result, origin_agent=adapter.framework_name)
52
+ return out_scan.redacted
53
+
54
+ graph.invoke = wrapped_invoke
55
+
56
+ if original_ainvoke is not None:
57
+
58
+ async def wrapped_ainvoke(state: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
59
+ scanned = adapter.guard.firewall.scan(state, origin_agent=adapter.framework_name)
60
+ result = await original_ainvoke(scanned.redacted, *args, **kwargs)
61
+ out_scan = adapter.guard.firewall.scan(result, origin_agent=adapter.framework_name)
62
+ return out_scan.redacted
63
+
64
+ graph.ainvoke = wrapped_ainvoke
65
+
66
+ return graph