diff --git a/README.md b/README.md index 20c752d..2f2fee6 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,12 @@ Turn any LangChain agent into a trust-verified agent in 2 lines: ```python from langchain_capiscio import CapiscioGuard -# Zero-config — reads CAPISCIO_API_KEY from env, connects to registry -secured = CapiscioGuard() | my_chain +# Reads CAPISCIO_API_KEY from env, connects to registry +secured = CapiscioGuard.connect() | my_chain result = secured.invoke({"input": "Summarize this ticket"}) ``` -`CapiscioGuard` reads your environment, connects to the CapiscIO registry on first use, and verifies caller trust badges before every invocation. +`CapiscioGuard.connect()` reads your environment, connects to the CapiscIO registry, and returns a guard that verifies caller trust badges before every invocation — consistent with `CapiscIO.connect()` and `CapiscioMCPServer.connect()` across the ecosystem. ## Why LangChain Guard? @@ -51,9 +51,9 @@ LangChain Guard solves this with: Control enforcement behavior per guard instance: ```python -guard = CapiscioGuard(mode="block") # Fail closed (production default) -guard = CapiscioGuard(mode="monitor") # Warn but continue -guard = CapiscioGuard(mode="log") # Log only +guard = CapiscioGuard.connect(mode="block") # Fail closed (production default) +guard = CapiscioGuard.connect(mode="monitor") # Warn but continue +guard = CapiscioGuard.connect(mode="log") # Log only ``` ## LCEL Pipe Composition @@ -68,7 +68,7 @@ from langchain_capiscio import CapiscioGuard from langgraph.prebuilt import create_react_agent agent = create_react_agent(llm, tools) -secured = CapiscioGuard(mode="log") | agent +secured = CapiscioGuard.connect(mode="log") | agent result = secured.invoke({"input": "What's 42 * 17?"}) ``` @@ -94,7 +94,7 @@ Events emitted: `task_started`, `task_completed`, `task_failed`, `tool_call`, `t from langchain_capiscio import CapiscioGuard, capiscio_guard # Option 1: Runnable as graph node -graph.add_node("verify", CapiscioGuard()) +graph.add_node("verify", CapiscioGuard.connect()) # Option 2: Decorator @capiscio_guard(mode="block") @@ -106,7 +106,7 @@ def call_agent(state: dict) -> dict: ### Zero-config (recommended) -Set environment variables and create a guard with no arguments: +Set environment variables and connect with no arguments: ```bash export CAPISCIO_API_KEY="cap_..." @@ -116,41 +116,39 @@ export CAPISCIO_DEV_MODE="true" # optional ``` ```python -guard = CapiscioGuard() # reads env vars, connects on first invoke() +guard = CapiscioGuard.connect() # reads env vars, connects eagerly ``` ### Explicit configuration ```python -guard = CapiscioGuard( - mode="block", +guard = CapiscioGuard.connect( api_key="cap_...", + mode="block", name="my-agent", server_url="https://dev.registry.capisc.io", ) ``` -### `connect_kwargs` +### Extra connect kwargs -Pass extra keyword arguments through to `CapiscIO.connect()`: +Pass additional keyword arguments through to `CapiscIO.connect()`: ```python -guard = CapiscioGuard( +guard = CapiscioGuard.connect( mode="log", - connect_kwargs={ - "dev_mode": True, - "keys_dir": "capiscio_keys/", - "agent_card": my_card_dict, - }, + dev_mode=True, + keys_dir="capiscio_keys/", + agent_card=my_card_dict, ) ``` ## Using Environment Variables -`CapiscioGuard.from_env()` mirrors the `CapiscIO.from_env()` / `MCPServerIdentity.from_env()` pattern used across CapiscIO packages: +`CapiscioGuard.connect()` reads environment variables automatically. `from_env()` is kept as a convenience alias: ```python -guard = CapiscioGuard.from_env(mode="log") +guard = CapiscioGuard.connect(mode="log") ``` | Variable | Required | Description | Default | @@ -200,8 +198,8 @@ services: ``` ```python -# No code changes needed — CapiscioGuard reads env vars automatically -secured = CapiscioGuard(mode="block") | my_agent +# No code changes needed — CapiscioGuard.connect() reads env vars automatically +secured = CapiscioGuard.connect(mode="block") | my_agent ``` > **Warning:** Never bake private keys into container images. Inject them at runtime via environment variables or mounted secrets. @@ -241,10 +239,11 @@ set_capiscio_context(CapiscioRequestContext( ### Guard -- `CapiscioGuard(mode, api_key, name, server_url, connect_kwargs, identity, config)` — LCEL-composable trust enforcement Runnable +- `CapiscioGuard.connect(api_key, *, mode, name, server_url, dev_mode, **kwargs)` — Connect to registry and return a ready-to-use guard (recommended) +- `CapiscioGuard.from_env(mode, **kwargs)` — Alias for `connect()` (reads env vars) +- `CapiscioGuard(*, identity, config, mode, api_key, name, server_url, connect_kwargs)` — Low-level constructor (keyword-only, lazy init on first invoke) - `CapiscioGuard.invoke(input, config)` — Verify badge and pass through to downstream - `CapiscioGuard.ainvoke(input, config)` — Async version -- `CapiscioGuard.from_env(mode, **kwargs)` — Create guard from environment variables ### Callbacks diff --git a/langchain_capiscio/__init__.py b/langchain_capiscio/__init__.py index 467efa2..2bd065d 100644 --- a/langchain_capiscio/__init__.py +++ b/langchain_capiscio/__init__.py @@ -3,7 +3,7 @@ from langchain_capiscio import CapiscioGuard from langchain_openai import ChatOpenAI -secured = CapiscioGuard() | ChatOpenAI() +secured = CapiscioGuard.connect() | ChatOpenAI() result = secured.invoke("Summarise quarterly earnings") """ diff --git a/langchain_capiscio/guard.py b/langchain_capiscio/guard.py index c425fa2..663d5ec 100644 --- a/langchain_capiscio/guard.py +++ b/langchain_capiscio/guard.py @@ -101,19 +101,100 @@ def __init__( self._wire_badge_renewal(identity) self._initialized = True + @classmethod + def connect( + cls, + api_key: str | None = None, + *, + mode: str = "block", + name: str | None = None, + server_url: str | None = None, + dev_mode: bool | None = None, + **kwargs: Any, + ) -> CapiscioGuard: + """Connect to CapiscIO and return a ready-to-use guard. + + This is the recommended entry point — consistent with + ``CapiscIO.connect()`` and ``CapiscioMCPServer.connect()`` + across the CapiscIO ecosystem. + + Eagerly connects to the registry so errors surface immediately + rather than on first ``invoke()``. + + Args: + api_key: Registry API key. Falls back to ``CAPISCIO_API_KEY`` + env var when ``None``. + mode: Enforcement mode — ``"block"``, ``"monitor"``, or ``"log"``. + name: Agent name for registration. Falls back to + ``CAPISCIO_AGENT_NAME`` env var. + server_url: Registry URL override. Falls back to + ``CAPISCIO_SERVER_URL`` env var. + dev_mode: Enable dev mode (relaxed validation). When ``None``, + falls back to ``CAPISCIO_DEV_MODE`` env var. Explicit + ``True``/``False`` overrides the env var. + **kwargs: Extra keyword arguments forwarded to + ``CapiscIO.connect()`` (e.g. ``keys_dir``, ``agent_card``). + + Returns: + A fully-initialized ``CapiscioGuard`` ready for use in LCEL + pipes or as a standalone Runnable. + + Raises: + CapiscioConfigError: If no API key is available. + + Example:: + + guard = CapiscioGuard.connect(mode="block") + secured = guard | my_chain + """ + import os + + effective_api_key = api_key or os.environ.get("CAPISCIO_API_KEY") + if not effective_api_key: + raise CapiscioConfigError( + "No API key provided. Set CAPISCIO_API_KEY env var" + " or pass api_key= to CapiscioGuard.connect()." + ) + + from capiscio_sdk import CapiscIO + + connect_kwargs: dict[str, Any] = {**kwargs} + + effective_name = name or os.environ.get("CAPISCIO_AGENT_NAME") + if effective_name: + connect_kwargs["name"] = effective_name + + effective_url = server_url or os.environ.get("CAPISCIO_SERVER_URL") + if effective_url: + connect_kwargs["server_url"] = effective_url + + if dev_mode is None: + dev_mode = os.environ.get("CAPISCIO_DEV_MODE", "").lower() in ( + "true", + "1", + "yes", + ) + if dev_mode: + connect_kwargs["dev_mode"] = True + + identity = CapiscIO.connect(effective_api_key, **connect_kwargs) + config = cls._make_config(mode) + + return cls(identity=identity, config=config, mode=mode) + @classmethod def from_env(cls, *, mode: str = "block", **kwargs: Any) -> CapiscioGuard: - """Create a CapiscioGuard using environment variables for configuration. + """Create a guard from environment variables. - Reads CAPISCIO_API_KEY, CAPISCIO_SERVER_URL, CAPISCIO_AGENT_NAME, - and CAPISCIO_DEV_MODE from the environment. Mirrors the ``from_env()`` - pattern used by ``CapiscIO.from_env()`` and - ``MCPServerIdentity.from_env()``. + Convenience alias for ``CapiscioGuard.connect()`` — reads API key, + agent name, server URL, and dev mode from environment variables. + Explicit ``**kwargs`` override env vars when provided. - Any explicit keyword arguments are forwarded to the constructor and - take precedence over environment variables. + .. deprecated:: + Prefer ``CapiscioGuard.connect()`` for consistency with the + rest of the CapiscIO ecosystem. """ - return cls(mode=mode, **kwargs) + return cls.connect(mode=mode, **kwargs) @staticmethod def _make_config(mode: str) -> Any: diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index b7d9b81..c7e2259 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -123,6 +123,183 @@ def test_no_api_key_raises_on_lazy_init(self): os.environ["CAPISCIO_API_KEY"] = old +# ---- connect() tests ---- + + +class TestCapiscioGuardConnect: + def test_connect_returns_initialized_guard(self): + """connect() should eagerly initialize and return a ready guard.""" + import os + import sys + from unittest.mock import MagicMock, patch + + fake_guard_obj = FakeGuard() + fake_keeper = FakeKeeper() + fake_identity = FakeIdentity(_guard=fake_guard_obj, _keeper=fake_keeper) + + mock_sdk = MagicMock() + mock_sdk.CapiscIO.connect.return_value = fake_identity + mock_sdk.SecurityConfig.production.return_value = FakeConfig("block") + + env = {"CAPISCIO_API_KEY": "cap_test_key"} + with patch.dict(os.environ, env, clear=False): + # Clear other CAPISCIO_ vars that could leak from the runner + os.environ.pop("CAPISCIO_AGENT_NAME", None) + os.environ.pop("CAPISCIO_SERVER_URL", None) + os.environ.pop("CAPISCIO_DEV_MODE", None) + with patch.dict(sys.modules, {"capiscio_sdk": mock_sdk}): + guard = CapiscioGuard.connect() + + assert guard._initialized is True + assert guard.mode == "block" + mock_sdk.CapiscIO.connect.assert_called_once_with("cap_test_key") + + def test_connect_explicit_params(self): + """Explicit params should be forwarded to CapiscIO.connect().""" + import os + import sys + from unittest.mock import MagicMock, patch + + fake_guard_obj = FakeGuard() + fake_keeper = FakeKeeper() + fake_identity = FakeIdentity(_guard=fake_guard_obj, _keeper=fake_keeper) + + mock_sdk = MagicMock() + mock_sdk.CapiscIO.connect.return_value = fake_identity + mock_sdk.SecurityConfig.development.return_value = FakeConfig("warn") + + env = { + "CAPISCIO_AGENT_NAME": "", + "CAPISCIO_SERVER_URL": "", + "CAPISCIO_DEV_MODE": "", + } + with patch.dict(os.environ, env, clear=False): + os.environ.pop("CAPISCIO_AGENT_NAME", None) + os.environ.pop("CAPISCIO_SERVER_URL", None) + os.environ.pop("CAPISCIO_DEV_MODE", None) + with patch.dict(sys.modules, {"capiscio_sdk": mock_sdk}): + guard = CapiscioGuard.connect( + api_key="cap_explicit", + mode="log", + name="my-agent", + server_url="https://dev.registry.capisc.io", + ) + + assert guard._initialized is True + assert guard.mode == "log" + mock_sdk.CapiscIO.connect.assert_called_once_with( + "cap_explicit", + name="my-agent", + server_url="https://dev.registry.capisc.io", + ) + + def test_connect_dev_mode(self): + """dev_mode=True should be forwarded to CapiscIO.connect().""" + import sys + from unittest.mock import MagicMock, patch + + fake_identity = FakeIdentity(_guard=FakeGuard(), _keeper=FakeKeeper()) + mock_sdk = MagicMock() + mock_sdk.CapiscIO.connect.return_value = fake_identity + mock_sdk.SecurityConfig.production.return_value = FakeConfig("block") + + with patch.dict(sys.modules, {"capiscio_sdk": mock_sdk}): + guard = CapiscioGuard.connect(api_key="cap_test", dev_mode=True) + + mock_sdk.CapiscIO.connect.assert_called_once_with( + "cap_test", + dev_mode=True, + ) + assert guard._initialized is True + + def test_connect_no_api_key_raises(self): + """connect() with no api_key and no env var should raise immediately.""" + import os + + old = os.environ.pop("CAPISCIO_API_KEY", None) + try: + with pytest.raises(CapiscioConfigError, match="No API key"): + CapiscioGuard.connect() + finally: + if old is not None: + os.environ["CAPISCIO_API_KEY"] = old + + def test_connect_env_dev_mode(self): + """CAPISCIO_DEV_MODE env var should be respected.""" + import os + import sys + from unittest.mock import MagicMock, patch + + fake_identity = FakeIdentity(_guard=FakeGuard(), _keeper=FakeKeeper()) + mock_sdk = MagicMock() + mock_sdk.CapiscIO.connect.return_value = fake_identity + mock_sdk.SecurityConfig.production.return_value = FakeConfig("block") + + env = { + "CAPISCIO_API_KEY": "cap_test", + "CAPISCIO_DEV_MODE": "true", + } + with patch.dict(os.environ, env, clear=False): + os.environ.pop("CAPISCIO_AGENT_NAME", None) + os.environ.pop("CAPISCIO_SERVER_URL", None) + with patch.dict(sys.modules, {"capiscio_sdk": mock_sdk}): + CapiscioGuard.connect() + + mock_sdk.CapiscIO.connect.assert_called_once_with( + "cap_test", + dev_mode=True, + ) + + def test_connect_dev_mode_false_overrides_env(self): + """Explicit dev_mode=False should override CAPISCIO_DEV_MODE env var.""" + import os + import sys + from unittest.mock import MagicMock, patch + + fake_identity = FakeIdentity(_guard=FakeGuard(), _keeper=FakeKeeper()) + mock_sdk = MagicMock() + mock_sdk.CapiscIO.connect.return_value = fake_identity + mock_sdk.SecurityConfig.production.return_value = FakeConfig("block") + + env = { + "CAPISCIO_API_KEY": "cap_test", + "CAPISCIO_DEV_MODE": "true", + } + with patch.dict(os.environ, env, clear=False): + os.environ.pop("CAPISCIO_AGENT_NAME", None) + os.environ.pop("CAPISCIO_SERVER_URL", None) + with patch.dict(sys.modules, {"capiscio_sdk": mock_sdk}): + CapiscioGuard.connect(dev_mode=False) + + # dev_mode=False should NOT include dev_mode in connect kwargs + mock_sdk.CapiscIO.connect.assert_called_once_with("cap_test") + + def test_from_env_delegates_to_connect(self): + """from_env() should delegate to connect().""" + import os + import sys + from unittest.mock import MagicMock, patch + + fake_identity = FakeIdentity(_guard=FakeGuard(), _keeper=FakeKeeper()) + mock_sdk = MagicMock() + mock_sdk.CapiscIO.connect.return_value = fake_identity + mock_sdk.SecurityConfig.development.return_value = FakeConfig("warn") + + old_key = os.environ.get("CAPISCIO_API_KEY") + os.environ["CAPISCIO_API_KEY"] = "cap_test" + try: + with patch.dict(sys.modules, {"capiscio_sdk": mock_sdk}): + guard = CapiscioGuard.from_env(mode="log") + finally: + if old_key is not None: + os.environ["CAPISCIO_API_KEY"] = old_key + else: + os.environ.pop("CAPISCIO_API_KEY", None) + + assert guard._initialized is True + assert guard.mode == "log" + + # ---- Invoke tests (with fake identity, no real SDK) ----