Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions MCP_RUNTIME_INTEGRATIONS_PRD.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions docs/integrations/runtime-integrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Runtime Integration Architecture

ProofLayer runtime integrations adapt framework-specific activity into a shared
security contract. The contract lets future integrations reuse the same
detection engine, policy decisions, audit event hashing, and compliance evidence
without duplicating LangGraph-specific code.

## Shared Primitives

Phase 1 adds `prooflayer.integrations.common` with these public primitives:

| Primitive | Purpose |
|---|---|
| `RuntimeSecurityConfig` | Shared detection and audit configuration for runtime adapters |
| `SecurityEnvelope` | Normalized representation of runtime activity before detection |
| `Decision` | Normalized allow, warn, or block decision returned by adapters |
| `IntegrationAdapter` | Protocol for framework-specific wrappers |
| `SecuredRuntimeProxy` | Base proxy that delegates unknown attributes to wrapped runtimes |
| `ToolCallEvent` / `ToolOutputEvent` | Normalized tool activity records |
| `AuditEventRecorder` | In-memory audit recorder with sha256 chain-of-custody hashes |

## Integration Flow

Every runtime integration should follow this flow:

1. Convert runtime input, tool call, state update, or output into a
`SecurityEnvelope`.
2. Run deterministic ProofLayer rules synchronously.
3. Apply integration or customer policy.
4. Convert the result into a `Decision`.
5. Raise the integration-specific blocked exception when configured to block.
6. Record an audit event with rule IDs, timestamps, and hash-chain fields.
7. Delegate to the underlying runtime when the decision allows execution.

## Backward Compatibility

The LangGraph integration keeps its public API:

```python
from prooflayer.integrations.langgraph import SecurityConfig, SecurityMiddleware
```

Internally, `SecurityConfig` now subclasses `RuntimeSecurityConfig`, and
`SecurityMiddleware` records audit events through the shared recorder. Existing
LangGraph customers can keep using the v0.2 API while later integrations reuse
the shared primitives.

## Audit Hash Fields

Integration audit events include:

```json
{
"event_type": "detection",
"session_id": "thread-1",
"rule_ids": ["direct-ignore-previous"],
"previous_hash": null,
"event_hash": "64-character-sha256",
"hash": "sha256:64-character-sha256"
}
```

`previous_hash` links each event to the prior event in the same recorder. This
supports chain-of-custody evidence without requiring a hosted service.

## Adapter Rules

New integrations must:

- import optional framework dependencies lazily
- preserve the wrapped framework's native invocation semantics
- emit the shared audit schema
- expose a small public wrapper API
- include benign, warn, block, and audit tests
- avoid benchmark claims unless measured in this repository

## Phase 2 Handoff

Phase 2 should add `prooflayer.integrations.langchain_mcp` and
`prooflayer.integrations.llamaindex` using these shared primitives. It should
not add package extras until exact supported dependency versions are confirmed.
4 changes: 2 additions & 2 deletions prooflayer/evals/adversarial_suite.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Built-in LangGraph-specific adversarial probe suite."""

from dataclasses import dataclass
from typing import Iterable, List, Sequence
from typing import Iterable, List, Optional, Sequence

from ..integrations.langgraph.exceptions import BlockedError, SecurityException
from .langgraph_target import LangGraphEvalTarget
Expand All @@ -22,7 +22,7 @@ class AdversarialProbe:
class AdversarialSuite:
"""Run bundled adversarial probes directly against a LangGraph target."""

def __init__(self, probes: Sequence[AdversarialProbe] | None = None) -> None:
def __init__(self, probes: Optional[Sequence[AdversarialProbe]] = None) -> None:
"""Initialize the suite with default or custom probes."""
self.probes = list(probes) if probes is not None else default_probes()

Expand Down
22 changes: 22 additions & 0 deletions prooflayer/integrations/common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Shared primitives for ProofLayer runtime integrations."""

from .adapter import IntegrationAdapter
from .config import DetectionAction, RuntimeSecurityConfig
from .decisions import Decision
from .envelope import SecurityEnvelope
from .exceptions import IntegrationSecurityError, RuntimeBlockedError
from .runtime_proxy import SecuredRuntimeProxy
from .tool_events import ToolCallEvent, ToolOutputEvent

__all__ = [
"Decision",
"DetectionAction",
"IntegrationAdapter",
"IntegrationSecurityError",
"RuntimeBlockedError",
"RuntimeSecurityConfig",
"SecuredRuntimeProxy",
"SecurityEnvelope",
"ToolCallEvent",
"ToolOutputEvent",
]
34 changes: 34 additions & 0 deletions prooflayer/integrations/common/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Runtime adapter protocol for ProofLayer integrations."""

from typing import Any, Optional, Protocol

from .decisions import Decision


class IntegrationAdapter(Protocol):
"""Protocol implemented by framework-specific ProofLayer adapters."""

integration_name: str

def wrap(self, target: Any) -> Any:
"""Return a protected runtime object for the target."""

def scan_input(
self,
payload: Any,
config: Optional[dict[str, Any]] = None,
) -> Decision:
"""Inspect runtime input before execution."""

def scan_output(
self,
payload: Any,
config: Optional[dict[str, Any]] = None,
) -> Decision:
"""Inspect runtime output after execution."""

def get_audit_log(
self,
session_id: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Return audit events, optionally filtered by session ID."""
44 changes: 44 additions & 0 deletions prooflayer/integrations/common/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Shared audit helpers for runtime integrations."""

from copy import deepcopy
from typing import Any, Optional

from ...audit.integrity import chain_hash


class AuditEventRecorder:
"""Store audit events with a sha256 chain-of-custody hash."""

def __init__(self) -> None:
"""Create an empty audit event recorder."""
self._events: list[dict[str, Any]] = []
self._previous_hash: Optional[str] = None

@property
def previous_hash(self) -> Optional[str]:
"""Return the previous event hash in the audit chain."""
return self._previous_hash

def append(self, event: dict[str, Any]) -> dict[str, Any]:
"""Append, hash, and return a copy of an audit event."""
stored = deepcopy(event)
stored.setdefault("previous_hash", self._previous_hash)
payload = deepcopy(stored)
payload.pop("event_hash", None)
payload.pop("hash", None)
event_hash = chain_hash(payload, self._previous_hash)
stored["event_hash"] = event_hash
stored["hash"] = f"sha256:{event_hash}"
self._previous_hash = event_hash
self._events.append(stored)
return deepcopy(stored)

def list(self, session_id: Optional[str] = None) -> list[dict[str, Any]]:
"""Return stored audit events, optionally filtered by session ID."""
if session_id is None:
return deepcopy(self._events)
return [
deepcopy(event)
for event in self._events
if event.get("session_id") == session_id
]
106 changes: 106 additions & 0 deletions prooflayer/integrations/common/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Shared configuration for ProofLayer runtime integrations."""

from dataclasses import dataclass, field
from typing import Literal, Optional


DetectionAction = Literal["allow", "warn", "block"]
StreamingBlockMode = Literal["raise", "replace"]

_VALID_ACTIONS = {"allow", "warn", "block"}
_VALID_FRAMEWORKS = {"nist_ai_rmf", "eu_ai_act", "soc2", "hipaa"}
_VALID_STREAMING_BLOCK_MODES = {"raise", "replace"}


@dataclass
class RuntimeSecurityConfig:
"""Runtime security configuration shared by ProofLayer integrations."""

prompt_injection: DetectionAction = "warn"
jailbreak: DetectionAction = "warn"
tool_abuse: DetectionAction = "warn"
tool_poisoning: DetectionAction = "warn"
command_injection: DetectionAction = "block"
exfil: DetectionAction = "block"
scope_drift: DetectionAction = "warn"
state_manipulation: DetectionAction = "warn"
multi_turn: DetectionAction = "warn"
memory_poisoning: DetectionAction = "warn"
unsafe_handoff: DetectionAction = "warn"
allowed_tools: Optional[list[str]] = None
blocked_tools: Optional[list[str]] = None
allowed_domains: Optional[list[str]] = None
blocked_domains: Optional[list[str]] = None
max_tool_calls_per_turn: Optional[int] = None
compliance_frameworks: list[str] = field(default_factory=list)
emit_to: list[str] = field(default_factory=lambda: ["stdout"])
session_id_key: str = "session_id"
streaming_block_mode: StreamingBlockMode = "raise"
blocked_token: str = "[BLOCKED]"

def __post_init__(self) -> None:
"""Validate actions, evidence frameworks, audit sinks, and limits."""
invalid_actions = {
category: action
for category, action in self.category_actions().items()
if action not in _VALID_ACTIONS
}
if invalid_actions:
details = ", ".join(
f"{category}={action!r}" for category, action in invalid_actions.items()
)
raise ValueError(f"Invalid runtime security action(s): {details}")

invalid_frameworks = [
framework
for framework in self.compliance_frameworks
if framework not in _VALID_FRAMEWORKS
]
if invalid_frameworks:
raise ValueError(
"Unsupported compliance framework(s): "
+ ", ".join(sorted(invalid_frameworks))
)

if not self.emit_to:
raise ValueError("emit_to must include at least one audit sink")

for sink in self.emit_to:
if sink in {"stdout", "siem"}:
continue
if sink.startswith("logfile:") and sink.removeprefix("logfile:").strip():
continue
raise ValueError(f"Unsupported audit sink: {sink!r}")

if not self.session_id_key:
raise ValueError("session_id_key must not be empty")

if self.streaming_block_mode not in _VALID_STREAMING_BLOCK_MODES:
raise ValueError(
f"Unsupported streaming_block_mode: {self.streaming_block_mode!r}"
)

if not self.blocked_token:
raise ValueError("blocked_token must not be empty")

if (
self.max_tool_calls_per_turn is not None
and self.max_tool_calls_per_turn < 1
):
raise ValueError("max_tool_calls_per_turn must be positive")

def category_actions(self) -> dict[str, DetectionAction]:
"""Return detection categories mapped to configured actions."""
return {
"prompt_injection": self.prompt_injection,
"jailbreak": self.jailbreak,
"tool_abuse": self.tool_abuse,
"tool_poisoning": self.tool_poisoning,
"command_injection": self.command_injection,
"exfil": self.exfil,
"scope_drift": self.scope_drift,
"state_manipulation": self.state_manipulation,
"multi_turn": self.multi_turn,
"memory_poisoning": self.memory_poisoning,
"unsafe_handoff": self.unsafe_handoff,
}
29 changes: 29 additions & 0 deletions prooflayer/integrations/common/decisions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Decision models shared by ProofLayer runtime integrations."""

from dataclasses import asdict, dataclass, field
from typing import Any, Optional

from ...response.actions import ThreatAction


@dataclass(frozen=True)
class Decision:
"""A normalized security decision returned by integration adapters."""

action: ThreatAction
category: Optional[str] = None
risk_score: int = 0
rule_ids: list[str] = field(default_factory=list)
reason: Optional[str] = None
metadata: dict[str, Any] = field(default_factory=dict)

@classmethod
def allow(cls) -> "Decision":
"""Return a reusable ALLOW decision."""
return cls(action=ThreatAction.ALLOW)

def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable dictionary."""
payload = asdict(self)
payload["action"] = self.action.value
return payload
45 changes: 45 additions & 0 deletions prooflayer/integrations/common/envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Security envelope model for runtime integration events."""

from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Optional


@dataclass(frozen=True)
class SecurityEnvelope:
"""Normalize runtime activity before ProofLayer detection."""

integration: str
event_type: str
payload: Any
session_id: Optional[str] = None
actor: Optional[str] = None
tool_name: Optional[str] = None
output: Any = None
metadata: dict[str, Any] = field(default_factory=dict)
timestamp: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)

def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable dictionary."""
return asdict(self)


def extract_config_session_id(
session_id_key: str,
config: Optional[dict[str, Any]] = None,
payload: Any = None,
) -> Optional[str]:
"""Extract a session ID from runtime config or payload conventions."""
if config:
configurable = config.get("configurable", {})
if session_id_key in configurable:
return str(configurable[session_id_key])
if "thread_id" in configurable:
return str(configurable["thread_id"])

if isinstance(payload, dict) and session_id_key in payload:
return str(payload[session_id_key])

return None
9 changes: 9 additions & 0 deletions prooflayer/integrations/common/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Shared exceptions for ProofLayer runtime integrations."""


class IntegrationSecurityError(Exception):
"""Base exception for integration security failures."""


class RuntimeBlockedError(IntegrationSecurityError):
"""Raised when an integration blocks runtime execution."""
Loading
Loading