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.
- clinicsentry/__init__.py +32 -0
- clinicsentry/adapters/__init__.py +27 -0
- clinicsentry/adapters/a2a.py +60 -0
- clinicsentry/adapters/base.py +158 -0
- clinicsentry/adapters/claude_sdk.py +71 -0
- clinicsentry/adapters/crewai.py +89 -0
- clinicsentry/adapters/google_adk.py +100 -0
- clinicsentry/adapters/langgraph.py +66 -0
- clinicsentry/adapters/mcp_proxy.py +92 -0
- clinicsentry/adapters/openai_agents.py +92 -0
- clinicsentry/audit/__init__.py +24 -0
- clinicsentry/audit/backend.py +220 -0
- clinicsentry/audit/chain.py +137 -0
- clinicsentry/audit/migrations/__init__.py +16 -0
- clinicsentry/audit/migrations/alembic.ini +38 -0
- clinicsentry/audit/migrations/env.py +54 -0
- clinicsentry/audit/migrations/runner.py +43 -0
- clinicsentry/audit/migrations/script.py.mako +24 -0
- clinicsentry/audit/migrations/versions/0001_initial_audit_events.py +66 -0
- clinicsentry/audit/otel.py +74 -0
- clinicsentry/audit/pdf_report.py +127 -0
- clinicsentry/audit/postgres.py +188 -0
- clinicsentry/audit/report.py +191 -0
- clinicsentry/audit/s3.py +169 -0
- clinicsentry/cli.py +303 -0
- clinicsentry/compliance/__init__.py +34 -0
- clinicsentry/compliance/engine.py +442 -0
- clinicsentry/compliance/rules/eu_ai_act.yaml +21 -0
- clinicsentry/compliance/rules/fda_tplc.yaml +21 -0
- clinicsentry/compliance/rules/hipaa.yaml +33 -0
- clinicsentry/compliance/rules/iec62304.yaml +21 -0
- clinicsentry/dashboard/__init__.py +21 -0
- clinicsentry/dashboard/app.py +242 -0
- clinicsentry/errors.py +176 -0
- clinicsentry/escalation/__init__.py +36 -0
- clinicsentry/escalation/channels.py +228 -0
- clinicsentry/escalation/confidence.py +178 -0
- clinicsentry/escalation/extra_signals.py +216 -0
- clinicsentry/escalation/router.py +222 -0
- clinicsentry/escalation/temperature_scaling.py +209 -0
- clinicsentry/guard.py +279 -0
- clinicsentry/meddevice/__init__.py +51 -0
- clinicsentry/meddevice/cia.py +125 -0
- clinicsentry/meddevice/clinician_auth.py +115 -0
- clinicsentry/meddevice/cloud_kms.py +216 -0
- clinicsentry/meddevice/http_kms.py +144 -0
- clinicsentry/meddevice/iec62304_v2.py +64 -0
- clinicsentry/meddevice/keys.py +152 -0
- clinicsentry/meddevice/mode.py +208 -0
- clinicsentry/observability/__init__.py +16 -0
- clinicsentry/observability/logging.py +68 -0
- clinicsentry/observability/metrics.py +82 -0
- clinicsentry/observability/tracing.py +30 -0
- clinicsentry/performance.py +130 -0
- clinicsentry/phi/__init__.py +34 -0
- clinicsentry/phi/adversarial.py +220 -0
- clinicsentry/phi/detectors.py +198 -0
- clinicsentry/phi/firewall.py +296 -0
- clinicsentry/phi/medical_ner.py +110 -0
- clinicsentry/phi/minimum_necessary.py +100 -0
- clinicsentry/phi/multilingual.py +100 -0
- clinicsentry/phi/ocr.py +58 -0
- clinicsentry/phi/parsers.py +149 -0
- clinicsentry/phi/pipeline.py +93 -0
- clinicsentry/phi/propagation.py +51 -0
- clinicsentry/phi/redaction.py +105 -0
- clinicsentry/policy.py +366 -0
- clinicsentry/py.typed +0 -0
- clinicsentry/types.py +203 -0
- clinicsentry-0.3.0.dist-info/METADATA +303 -0
- clinicsentry-0.3.0.dist-info/RECORD +75 -0
- clinicsentry-0.3.0.dist-info/WHEEL +4 -0
- clinicsentry-0.3.0.dist-info/entry_points.txt +2 -0
- clinicsentry-0.3.0.dist-info/licenses/LICENSE +201 -0
- clinicsentry-0.3.0.dist-info/licenses/NOTICE +7 -0
clinicsentry/__init__.py
ADDED
|
@@ -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
|