diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a46049..2052a7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: - '*-keycardai-mcp-fastmcp' - '*-keycardai-fastmcp' - '*-keycardai-a2a' + - '*-keycardai-langchain' jobs: detect-package: diff --git a/justfile b/justfile index c79cc53..429ba98 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,7 @@ test: build just test-package fastmcp just test-package mcp-fastmcp just test-package a2a + just test-package langchain # Run tests for a specific package test-package PACKAGE: @@ -41,6 +42,7 @@ test-coverage: build cd packages/fastmcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=60 cd packages/mcp-fastmcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=70 cd packages/a2a && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=55 + cd packages/langchain && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=85 check: uv run ruff check diff --git a/packages/langchain/README.md b/packages/langchain/README.md new file mode 100644 index 0000000..5f34916 --- /dev/null +++ b/packages/langchain/README.md @@ -0,0 +1,320 @@ +# keycardai-langchain + +Keycard integration for LangChain agents. Every tool call gets a short-lived +credential brokered by Keycard, scoped to the identity the agent is acting for, +and recorded in the audit log. + +Your tools never hold an API key, the model never sees a credential, and you do +not write an OAuth flow. + +## Install + +```bash +pip install keycardai-langchain +``` + +## Quick start + +```python +from langchain.agents import create_agent +from langchain.tools import tool + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +CALENDAR = "https://www.googleapis.com/calendar/v3" + +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=[CALENDAR], + client_id="your-agent", + client_secret=..., +) + + +@tool +def list_events(days_ahead: int = 0) -> str: + """List the user's calendar events.""" + token = get_access_context().access(CALENDAR).access_token + ... + + +agent = create_agent( + model, + tools=[list_events], + middleware=[keycard], + context_schema=KeycardIdentity, +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), +) +``` + +That is the whole integration: one middleware in the agent's middleware list, +and one call inside each tool to read the credential for this call. + +## How it works + +`KeycardGrantMiddleware` implements LangChain's `wrap_tool_call` hook, so it +runs at the tool-call boundary. Before each tool executes it acquires tokens +for the declared resources under the identity of the run, then exposes the +result to the tool as an `AccessContext`. + +Identity travels on the agent's own `context_schema`, and the +pause-for-authorization flow is a LangGraph interrupt. + +The same middleware instance works under `create_agent`, a raw LangGraph graph, +and `create_deep_agent` (deep agents are built on the same middleware system). + +## Access patterns + +`KeycardIdentity` carries the identity for a run, and its fields select the +access pattern: + +| Field | Pattern | Meaning | +|---|---|---| +| `subject_token` | on-behalf-of | Exchange the caller's own token for resource tokens (RFC 8693). | +| `as_self=True` | as itself | Client-credentials grant under the agent's own application identity. No user anywhere. | +| `user_identifier` | impersonation | Substitute-user exchange, authenticated by the agent's credential. Forbidden by default; requires a zone policy. | + +A run with no identity fails with a `missing_identity` error, or pauses with a +`sign_in_required` interrupt when `sign_in_url` is set. It never falls back to +the agent's own authority: acting as itself is always an explicit choice. + +### On-behalf-of: a user-facing agent + +The agent acts for the person in the chat. Their token is exchanged per tool +call, so every resource access is attributed to agent-for-user in the audit +log, and revoking the user's grant cuts the agent off immediately. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://www.googleapis.com/calendar/v3"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # Optional: pause the run in-chat instead of failing. + sign_in_url="https://your-app.example/signin", + authorization_url="https://your-app.example/authorize", +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), +) +``` + +Runnable version: [`examples/user_facing_agent`](examples/user_facing_agent). + +### As itself: a background agent + +No user in the loop: a scheduled digest, a queue worker, a monitor. The agent +authenticates as its own application and Keycard delivers whatever credential +the zone brokers for the resource, including vaulted secrets, so the worker's +environment holds no API keys and revocation lives in one place. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.github.com"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(as_self=True), +) +``` + +As-itself runs never pause on an interrupt, even when `sign_in_url` or +`authorization_url` is set: there is no user to send to a consent page, so a +denied grant stays on the `AccessContext` as an error for the tool and the +operator's logs. + +Runnable version: [`examples/background_agent`](examples/background_agent). + +### Impersonation: acting as a specific user without their token + +The agent asks for tokens *as* a named user, authenticated only by its own +credential. This is the sharpest tool in the box and is forbidden by default; +it requires an explicit impersonation policy in the zone. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://www.googleapis.com/calendar/v3"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(user_identifier="user@example.com"), +) +``` + +### Authenticating without a static secret + +`client_id` / `client_secret` is shorthand for a `ClientSecret` credential. +Every pattern also accepts an `application_credential`, so a deployed agent +can authenticate with a platform-signed OIDC token instead of holding a +secret: + +```python +from keycardai.oauth.server import FileTokenSource, WorkloadIdentity + +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.github.com"], + application_credential=WorkloadIdentity(FileTokenSource()), +) +``` + +`WorkloadIdentity` fetches the platform token per call and sends it as a +jwt-bearer client assertion; nothing long-lived sits in the environment. + +### Identity without per-run context + +For a deployed agent whose surface does not thread per-run context, set +`fallback_identity`. Pass a **callable** to resolve it per tool call, so a +sign-in that happens mid-conversation takes effect on resume without a restart: + +```python +keycard = KeycardGrantMiddleware( + ..., + fallback_identity=lambda: KeycardIdentity(subject_token=session_token()), +) +``` + +## Errors are data, not exceptions + +A missing grant is normal operation in a brokered setup, so the `AccessContext` +records failures instead of raising. Only `access(resource)` raises, and only +when you ask for a resource that has no token: + +```python +access = get_access_context() +if access.has_errors(): + return f"Cannot reach the API yet: {access.get_errors()}" +token = access.access(CALENDAR).access_token +``` + +Returning a readable sentence beats raising here: in a chat UI a raised +exception reads as an internal error, when the truthful message is "you have +not granted this yet." + +## Pausing for sign-in and consent + +With `sign_in_url` and `authorization_url` set, the middleware pauses the run +with a LangGraph interrupt instead of failing, so the whole flow can live in +your chat surface: + +```python +keycard = KeycardGrantMiddleware( + zone_url=..., + resources=[CALENDAR], + sign_in_url="https://your-app.example/signin", + authorization_url=lambda resources: f"https://your-app.example/authorize?r={resources[0]}", +) +``` + +| Payload `type` | Fires when | Resume behavior | +|---|---|---| +| `sign_in_required` | The run carries no identity, or its subject token has expired | Identity is re-resolved, then the exchange runs | +| `authorization_required` | Identity present and valid, grant missing | The exchange is retried | + +Expiry is detected locally (a decode-only check of the JWT's `exp`; the zone +stays the authority on validity), so an expired session routes to sign-in +rather than to a consent page that cannot fix it. The `sign_in_required` +payload carries a `reason` field (`missing_identity` or +`subject_token_expired`) so a chat surface can word the prompt accordingly. + +Both require a checkpointer. Two details worth knowing: + +- **Resume needs no new token.** Consent changes the grant in the zone, not the + token in your session, so the existing subject token exchanges successfully + afterward. +- **Runtime context is not checkpointed.** A resume must re-supply identity, + which a server does on every run anyway. + +Scope granularity falls out of this for free: if a user has granted read but +not write, the read call succeeds and the write call is the one that pauses. + +## Using tools outside the agent + +`get_access_context()` normally only works inside an agent run, because the +middleware sets the context at the tool-call boundary. For code that calls a +tool without the agent loop, `grant()` enters the same access context +explicitly. The motivating case is a UI panel served by the same governed +tool the agent uses in chat: + +```python +def dashboard_snapshot(session_token: str) -> str: + with keycard.grant(KeycardIdentity(subject_token=session_token)): + return list_requests.invoke({}) +``` + +It also serves resources that have no tool at all. Fetching a vaulted LLM +key under the agent's own identity, for example: + +```python +with keycard.grant(KeycardIdentity(as_self=True), resources=[LLM_KEY]) as access: + key = access.access(LLM_KEY).access_token +``` + +`agrant()` is the async variant. Both accept `tool_name=` to apply that +tool's `tool_resources` override, or `resources=` to grant exactly the +listed resources (one or the other, not both), and fall back to +`fallback_identity` when no identity is passed. There is no run to pause, +so nothing interrupts here: failures stay on the yielded `AccessContext`, +exactly as tools see them. + +## Per-tool resources and scopes + +```python +KeycardGrantMiddleware( + zone_url=..., + resources=[CALENDAR], # default for every tool + tool_resources={"post_message": [SLACK]}, # per-tool override + request_scopes={CALENDAR: ["calendar.events"]}, +) +``` + +`request_scopes` is the **outbound** scope requested from Keycard, for both the +exchange and the as-itself grant. It is distinct from any scope enforced on the +caller's inbound token. + +## Testing + +```python +from keycardai.langchain.testing import mock_access_context + + +def test_list_events(): + with mock_access_context(resource_tokens={CALENDAR: "test-token"}): + assert list_events.invoke({"days_ahead": 0}) +``` + +`mock_access_context(access_token=...)` serves one token for any resource, which +is convenient but cannot catch a mistyped resource URL, since every lookup +succeeds. Pass `resource_tokens={...}` when the test should assert which +resource a tool reads. `resource_errors=` and `error_message=` cover the failure +paths, and `override_access_context` takes a hand-built context for full +control. + +The package's own test strategy, row by row with coverage status, lives in +[TESTING.md](TESTING.md). + +## A note on tool arguments + +Give tools arguments that express **intent**, and keep configuration and clocks +out of the model's hands. A tool that accepts a resource URL will eventually be +called with a resource the model invented; a tool that accepts an absolute +timestamp will eventually be called with the wrong date. Prefer +`days_ahead: int` over an ISO string, and read the resource from configuration. diff --git a/packages/langchain/TESTING.md b/packages/langchain/TESTING.md new file mode 100644 index 0000000..905df90 --- /dev/null +++ b/packages/langchain/TESTING.md @@ -0,0 +1,60 @@ +# Test matrix + +The test strategy for the LangChain integration, tracked in +[ECO-224](https://linear.app/keycardlabs/issue/ECO-224). Each row is either +covered by a named test or explicitly deferred with an issue reference. +The TypeScript package (`@keycardai/langchain`, +[ECO-221](https://linear.app/keycardlabs/issue/ECO-221)) mirrors this matrix +when it exists; parity rows apply to it from day one. + +Run the suite with `just test-package langchain`, or directly: + +```bash +cd packages/langchain && uv run --extra test pytest tests/ -v +``` + +## Unit + +| Row | Status | Where | +|---|---|---| +| Grant runs before the tool; AccessContext reachable from inside it | covered | `test_on_behalf_of_exchanges_the_callers_token`, `test_grant_serves_tools_outside_the_agent` | +| Keycard params never leak into the model-facing tool schema | covered | `test_tool_schema_carries_no_keycard_plumbing` | +| Non-throwing error model: failures recorded, `access()` raises `ResourceAccessError` | covered | `test_missing_identity_is_recorded_not_raised`, `test_resource_error_raises_only_on_access` (seam suite) | +| Partial behavior locked: one denied resource does not poison a granted one | covered | `test_partial_grant_yields_token_and_resource_error_side_by_side` | +| Impersonation mode routes to the substitute-user path | covered | `test_impersonation_uses_the_substitute_user_path` (wire format of the substitute-user token is owned by keycardai-oauth's suite) | +| As-itself mode: client credentials, no exchange, scopes honored, denial never interrupts | covered | `test_as_self_uses_client_credentials_not_exchange`, `test_as_self_request_scopes_reach_the_grant`, `test_as_self_denial_is_an_error_never_an_interrupt` | +| Expired subject token routes to sign-in, not consent; opaque tokens pass through | covered | `test_expired_subject_token_pauses_for_sign_in_not_consent`, `test_expired_subject_token_without_sign_in_url_is_an_error`, `test_unexpired_jwt_subject_token_exchanges_normally` | +| Sync path runs on one persistent loop (client cache effective) | covered | `test_sync_path_runs_on_one_persistent_loop` | +| Multi-zone credential selection: issuer-keyed, fail-closed | deferred | [ECO-286](https://linear.app/keycardlabs/issue/ECO-286) (not implemented; middleware is single-zone today) | +| Testing seams themselves, including the resource-pinned form | covered | `tests/test_testing_seam.py` (all five) | + +## Integration + +| Row | Status | Where | +|---|---|---| +| Keycard-protected MCP server via `langchain-mcp-adapters` interceptors | deferred | [ECO-219](https://linear.app/keycardlabs/issue/ECO-219) (the `[mcp]` extra does not exist yet) | +| Live-zone smoke for the exchange paths (gated, not per-PR) | deferred | [ECO-288](https://linear.app/keycardlabs/issue/ECO-288) | +| Version matrix: langchain/langgraph floors, mcp 1.x vs 2.x resolution | deferred | [ECO-287](https://linear.app/keycardlabs/issue/ECO-287) (CI runs latest resolutions only today) | + +## Interrupt flow + +| Row | Status | Where | +|---|---|---| +| Interrupt payload shape (`authorization_required`, `sign_in_required` + `reason`) | covered | `test_authorization_interrupt_pauses_then_resumes`, `test_sign_in_interrupt_picks_up_identity_without_a_restart`, `test_expired_subject_token_pauses_for_sign_in_not_consent` | +| Resume retries the grant and the tool proceeds | covered | same two resume tests | +| Nothing side-effectful runs before the interrupt resolves | covered | `test_no_tool_executes_before_an_interrupt_resolves` | +| Checkpointer-less fallback auth tool | deferred | remaining scope of [ECO-220](https://linear.app/keycardlabs/issue/ECO-220) (interrupts shipped; the fallback tool did not) | + +## E2E + +| Row | Status | Where | +|---|---|---| +| Templates `eval/` run: ephemeral zone, provision from SPEC.md, consent, authenticated call | deferred | [ECO-223](https://linear.app/keycardlabs/issue/ECO-223) (template branch exists, gated on the package release) | + +## Process guards + +| Row | Status | Where | +|---|---|---| +| Suite wired into CI targets from day one | covered | justfile `test` / `test-coverage` targets (the fastmcp omission was [ECO-172](https://linear.app/keycardlabs/issue/ECO-172)) | +| Coverage gate | covered | `--cov-fail-under=85` in the justfile target | +| Cross-language parity claims tested on both halves | deferred | applies when [ECO-221](https://linear.app/keycardlabs/issue/ECO-221) lands; no cross-language claims in this README until then | diff --git a/packages/langchain/examples/background_agent/README.md b/packages/langchain/examples/background_agent/README.md new file mode 100644 index 0000000..f3463eb --- /dev/null +++ b/packages/langchain/examples/background_agent/README.md @@ -0,0 +1,39 @@ +# Background Agent (as itself) + +A LangChain agent with no user anywhere: a scheduled PR-review digest that +fetches open pull requests from GitHub and summarizes them. It authenticates +as its own Keycard application (`KeycardIdentity(as_self=True)`), and Keycard +delivers whatever credential the zone brokers for the GitHub resource — a +vaulted PAT, a GitHub App token — per tool call. The worker's environment +holds no GitHub credential, and revoking access happens in one place. + +## Keycard setup + +1. Create a **zone** at [keycard.ai](https://keycard.ai) (or use an existing one). +2. Create an **application** for the agent and note its client ID and secret. +3. Create a **resource** for `https://api.github.com` and attach a credential + provider that can serve it (for example a zone vault holding a GitHub + token, or a GitHub App provider). +4. Grant the application access to the resource. + +## Run + +```bash +export KEYCARD_ZONE_URL="https://your-zone.keycard.cloud" +export KEYCARD_CLIENT_ID="your-agent-client-id" +export KEYCARD_CLIENT_SECRET="your-agent-client-secret" +export ANTHROPIC_API_KEY="sk-ant-..." +export DIGEST_REPOS="your-org/repo-a,your-org/repo-b" # optional + +uv run main.py +``` + +## What to look at + +- The middleware has **no** `sign_in_url` / `authorization_url`: there is no + user in this process, so a denied grant is an error on the `AccessContext`, + never a consent pause. +- The tool reads its token with `get_access_context().access(...)`; no + credential appears in the environment, the code, or the model's context. +- Each run of the digest produces audit events in the zone with the + application as the actor. diff --git a/packages/langchain/examples/background_agent/main.py b/packages/langchain/examples/background_agent/main.py new file mode 100644 index 0000000..a76bcd4 --- /dev/null +++ b/packages/langchain/examples/background_agent/main.py @@ -0,0 +1,127 @@ +"""A background agent with no user anywhere: a morning PR-review digest. + +The agent runs as itself (KeycardIdentity(as_self=True)): resource access is +attributed to the application alone, the GitHub credential lives in the zone +(vaulted or brokered), and every fetch is an audit event. Nothing in this +process or its environment holds a GitHub credential. + +Run: uv run main.py +(In real use this is a cron entry; running it by hand is the same thing.) +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone + +import httpx +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +GITHUB = os.environ.get("KEYCARD_GITHUB_RESOURCE", "https://api.github.com") +REPOS = [ + r.strip() + for r in os.environ.get("DIGEST_REPOS", "langchain-ai/langchain").split(",") + if r.strip() +] + +# One client for the process: connection reuse across tool calls, bounded waits. +_http = httpx.Client(timeout=httpx.Timeout(15.0, connect=5.0)) + + +@tool +def list_open_pull_requests() -> str: + """List open pull requests across the configured repositories.""" + access = get_access_context() + if access.has_error(): + return f"Cannot reach GitHub: {access.get_error()['message']}" + if access.has_resource_error(GITHUB): + return f"GitHub access not granted: {access.get_resource_error(GITHUB)}" + + headers = { + "Authorization": f"Bearer {access.access(GITHUB).access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + now = datetime.now(timezone.utc) + report: dict[str, list[dict] | str] = {} + for repo in REPOS: + response = _http.get( + f"{GITHUB}/repos/{repo}/pulls", + params={"state": "open", "per_page": 20}, + headers=headers, + ) + if response.status_code != 200: + report[repo] = f"error {response.status_code}: {response.text[:200]}" + continue + report[repo] = [ + { + "number": pr["number"], + "title": pr["title"], + "author": pr["user"]["login"], + "draft": pr["draft"], + "age_days": (now - datetime.fromisoformat(pr["created_at"])).days, + } + for pr in response.json() + ] + return json.dumps(report, indent=2) + + +def _text_of(message) -> str: + """Final text from a message, whether content is a string or block list.""" + content = message.content + if isinstance(content, str): + return content + return "\n".join( + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ).strip() + + +SYSTEM_PROMPT = ( + "You compile a morning review digest for a maintainer. Fetch the open " + "pull requests, then write a short digest: what needs review first, " + "what is a draft and can wait, and anything that looks stuck. " + "Plain prose, under 200 words." +) + + +def main() -> None: + keycard = KeycardGrantMiddleware( + zone_url=os.environ["KEYCARD_ZONE_URL"], + resources=[GITHUB], + client_id=os.environ["KEYCARD_CLIENT_ID"], + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # No authorization_url / sign_in_url on purpose: there is no user in + # this process, so access failures are errors, never consent pauses. + ) + agent = create_agent( + model=ChatAnthropic( + model=os.environ.get("ANTHROPIC_MODEL", "claude-opus-5"), + max_tokens=4096, + ), + tools=[list_open_pull_requests], + system_prompt=SYSTEM_PROMPT, + middleware=[keycard], + context_schema=KeycardIdentity, + ) + + result = agent.invoke( + {"messages": [HumanMessage("Compile this morning's review digest.")]}, + context=KeycardIdentity(as_self=True), + ) + print(_text_of(result["messages"][-1])) + + +if __name__ == "__main__": + main() diff --git a/packages/langchain/examples/background_agent/pyproject.toml b/packages/langchain/examples/background_agent/pyproject.toml new file mode 100644 index 0000000..b420575 --- /dev/null +++ b/packages/langchain/examples/background_agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "background-agent-example" +version = "0.1.0" +description = "A background LangChain agent acting as itself, with credentials brokered by Keycard" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-langchain", + "langchain-anthropic>=1.0", + "httpx>=0.27.0,<1.0.0", +] + +[tool.uv.sources] +keycardai-langchain = { path = "../../", editable = true } diff --git a/packages/langchain/examples/user_facing_agent/README.md b/packages/langchain/examples/user_facing_agent/README.md new file mode 100644 index 0000000..388014a --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/README.md @@ -0,0 +1,45 @@ +# User-Facing Agent (on behalf of) + +A LangChain calendar assistant that acts **on behalf of** the person invoking +it. The caller's Keycard token is exchanged per tool call for a Google +Calendar token (RFC 8693 token exchange), so access is attributed to +agent-for-user in the audit log and revoking the user's grant cuts the agent +off immediately. + +When the user has not granted calendar access yet, the middleware pauses the +run with a LangGraph `authorization_required` interrupt. This CLI prints the +consent link, waits, and resumes the same run — in a chat UI the same payload +becomes an in-chat sign-in card. + +## Keycard setup + +1. Create a **zone** at [keycard.ai](https://keycard.ai) (or use an existing one). +2. Create an **application** for the agent and note its client ID and secret. +3. Create a **resource** for `https://www.googleapis.com/calendar/v3` with a + Google OAuth credential provider. +4. Sign in through the agent's application to obtain a subject token for the + user (any OAuth client can drive this; the token must be issued by your + zone with the agent's application as the audience owner). + +## Run + +```bash +export KEYCARD_ZONE_URL="https://your-zone.keycard.cloud" +export KEYCARD_CLIENT_ID="your-agent-client-id" +export KEYCARD_CLIENT_SECRET="your-agent-client-secret" +export KEYCARD_SUBJECT_TOKEN="" +export ANTHROPIC_API_KEY="sk-ant-..." + +uv run main.py "what's on my calendar today?" +``` + +## What to look at + +- The identity for the run is `KeycardIdentity(subject_token=...)`, passed as + LangChain runtime context — not middleware state, so one deployed agent + serves many users. +- The interrupt/resume loop at the bottom of `main.py`: consent changes the + grant in the zone, not the token in your session, so the resume retries the + exchange with the same subject token and succeeds. +- The tool never sees a Google credential until the moment of the call, and + the model never sees one at all. diff --git a/packages/langchain/examples/user_facing_agent/main.py b/packages/langchain/examples/user_facing_agent/main.py new file mode 100644 index 0000000..ff5a231 --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/main.py @@ -0,0 +1,122 @@ +"""A user-facing agent acting on behalf of the caller: a calendar assistant. + +The caller's Keycard token is exchanged per tool call for a Google Calendar +token (RFC 8693), so every calendar read is attributed to agent-for-user in +the zone's audit log. When the user has not granted calendar access yet, the +run pauses with a LangGraph interrupt; this CLI prints the consent link, +waits, then resumes the same run. + +Run: uv run main.py "what's on my calendar today?" +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timedelta, timezone + +import httpx +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +CALENDAR = os.environ.get( + "KEYCARD_CALENDAR_RESOURCE", "https://www.googleapis.com/calendar/v3" +) + +_http = httpx.Client(timeout=httpx.Timeout(15.0, connect=5.0)) + + +@tool +def list_events(days_ahead: int = 0) -> str: + """List the user's calendar events for one day, days_ahead days from today.""" + access = get_access_context() + if access.has_error(): + return f"Calendar unavailable: {access.get_error()['message']}" + if access.has_resource_error(CALENDAR): + return f"Calendar access not granted: {access.get_resource_error(CALENDAR)}" + + day = datetime.now(timezone.utc) + timedelta(days=days_ahead) + start = day.replace(hour=0, minute=0, second=0, microsecond=0) + response = _http.get( + f"{CALENDAR}/calendars/primary/events", + params={ + "timeMin": start.isoformat(), + "timeMax": (start + timedelta(days=1)).isoformat(), + "singleEvents": "true", + "orderBy": "startTime", + }, + headers={"Authorization": f"Bearer {access.access(CALENDAR).access_token}"}, + ) + if response.status_code != 200: + return f"Calendar API error {response.status_code}: {response.text[:200]}" + events = response.json().get("items", []) + if not events: + return "No events that day." + return "\n".join( + f"- {e.get('start', {}).get('dateTime', e.get('start', {}).get('date'))}: " + f"{e.get('summary', '(no title)')}" + for e in events + ) + + +def main() -> None: + question = " ".join(sys.argv[1:]) or "What's on my calendar today?" + identity = KeycardIdentity(subject_token=os.environ["KEYCARD_SUBJECT_TOKEN"]) + + keycard = KeycardGrantMiddleware( + zone_url=os.environ["KEYCARD_ZONE_URL"], + resources=[CALENDAR], + client_id=os.environ["KEYCARD_CLIENT_ID"], + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # A missing grant pauses the run instead of failing; the loop below + # prints the link and resumes after consent. + authorization_url=os.environ.get( + "KEYCARD_AUTHORIZATION_URL", os.environ["KEYCARD_ZONE_URL"] + ), + ) + agent = create_agent( + model=ChatAnthropic( + model=os.environ.get("ANTHROPIC_MODEL", "claude-opus-5"), + max_tokens=4096, + ), + tools=[list_events], + middleware=[keycard], + context_schema=KeycardIdentity, + checkpointer=InMemorySaver(), # interrupts require a checkpointer + ) + config = {"configurable": {"thread_id": "cli"}} + + result = agent.invoke( + {"messages": [HumanMessage(question)]}, config, context=identity + ) + while result.get("__interrupt__"): + payload = result["__interrupt__"][0].value + print(f"\n{payload['message']}") + print(f" {payload.get('authorization_url') or payload.get('sign_in_url')}") + input("\nPress Enter after granting access to resume... ") + # Runtime context is not checkpointed: a resume re-supplies identity. + result = agent.invoke(Command(resume="granted"), config, context=identity) + + final = result["messages"][-1] + content = final.content + if isinstance(content, list): + content = "\n".join( + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ).strip() + print(content) + + +if __name__ == "__main__": + main() diff --git a/packages/langchain/examples/user_facing_agent/pyproject.toml b/packages/langchain/examples/user_facing_agent/pyproject.toml new file mode 100644 index 0000000..f6d664d --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "user-facing-agent-example" +version = "0.1.0" +description = "A user-facing LangChain agent acting on behalf of the caller, with consent pauses via LangGraph interrupts" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-langchain", + "langchain-anthropic>=1.0", + "httpx>=0.27.0,<1.0.0", +] + +[tool.uv.sources] +keycardai-langchain = { path = "../../", editable = true } diff --git a/packages/langchain/pyproject.toml b/packages/langchain/pyproject.toml new file mode 100644 index 0000000..ad0509c --- /dev/null +++ b/packages/langchain/pyproject.toml @@ -0,0 +1,124 @@ +[project] +name = "keycardai-langchain" +dynamic = ["version"] +description = "LangChain integration for Keycard: delegated, per-tool-call access with brokered credentials" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Keycard", email = "support@keycard.ai" }] +dependencies = [ + "httpx>=0.27.2", + "keycardai-oauth>=0.9.0", + "langchain>=1.0", + "langgraph>=1.0", +] +keywords = ["langchain", "langgraph", "agents", "oauth", "token-exchange", "authentication", "keycard"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Security", + "Topic :: Internet :: WWW/HTTP :: Session", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "License :: OSI Approved :: MIT License", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.4.1", + "pytest-asyncio>=1.1.0", +] + +[project.urls] +Homepage = "https://github.com/keycardai/python-sdk" +Repository = "https://github.com/keycardai/python-sdk" +Documentation = "https://docs.keycardai.com" +Issues = "https://github.com/keycardai/python-sdk/issues" + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +pattern = "(?P\\d+\\.\\d+\\.\\d+)-keycardai-langchain" +style = "pep440" + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + +[tool.hatch.build.targets.wheel] +packages = ["src/keycardai"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, we'll handle case by case +] +isort = { combine-as-imports = true, known-first-party = ["keycardai"] } + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["T20"] + +[tool.mypy] +strict = true +disallow_incomplete_defs = false +disallow_untyped_defs = false +disallow_untyped_calls = false + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[tool.coverage.run] +source = ["tests", "src/keycardai"] + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "@abc.abstractmethod", + "raise NotImplementedError", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q" +asyncio_mode = "auto" + +[tool.commitizen] +name = "cz_customize" +version = "0.1.0" +tag_format = "${version}-keycardai-langchain" +ignored_tag_formats = ["${version}-*"] +update_changelog_on_bump = true +bump_message = "bump: keycardai-langchain $current_version → $new_version" +major_version_zero = true + +[tool.commitizen.customize] +changelog_pattern = "^(feat|fix|refactor|perf|test|build|ci|revert)\\(keycardai-langchain\\)(!)?:" diff --git a/packages/langchain/src/keycardai/langchain/__init__.py b/packages/langchain/src/keycardai/langchain/__init__.py new file mode 100644 index 0000000..f174a1c --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/__init__.py @@ -0,0 +1,67 @@ +"""Keycard integration for LangChain agents. + +Adds delegated access at the tool-call boundary: every tool call gets a +short-lived credential brokered by Keycard, scoped to the identity the agent is +acting for, and audited as a delegation chain. + +Quick start: + + from langchain.agents import create_agent + from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, + ) + + keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.example.com"], + client_id="your-app", + client_secret=..., + ) + + @tool + def call_api(query: str) -> str: + \"\"\"Call the external API.\"\"\" + token = get_access_context().access("https://api.example.com").access_token + ... + + agent = create_agent( + model, + tools=[call_api], + middleware=[keycard], + context_schema=KeycardIdentity, + ) + + agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), + ) + +Re-export guide: + +- Local definitions: ``KeycardGrantMiddleware``, ``KeycardIdentity``, + ``get_access_context``. +- Borrowed from ``keycardai-oauth``: ``AccessContext`` (the per-request token + container) and ``ResourceAccessError`` (raised only by + ``AccessContext.access``), re-exported so callers need one import. +""" + +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.server.exceptions import ResourceAccessError + +from .middleware import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +__all__ = [ + # === Primary API === + "KeycardGrantMiddleware", + "KeycardIdentity", + "get_access_context", + # === Re-exported from keycardai-oauth === + "AccessContext", + "ResourceAccessError", +] diff --git a/packages/langchain/src/keycardai/langchain/middleware.py b/packages/langchain/src/keycardai/langchain/middleware.py new file mode 100644 index 0000000..fe0d61a --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/middleware.py @@ -0,0 +1,541 @@ +"""Keycard grant middleware for LangChain 1.x agents. + +Grants delegated access at the tool-call boundary: before each tool executes, +the middleware exchanges the caller's identity for short-lived resource tokens +(RFC 8693) via the shared keycardai-oauth orchestration, and exposes the result +to the tool as a non-throwing AccessContext. + +The same middleware instance works under `create_agent` and `create_deep_agent` +(deepagents is built on the create_agent middleware system). +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import threading +import time +from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any +from weakref import WeakKeyDictionary + +from langchain_core.messages import ToolMessage +from langgraph.types import Command, interrupt + +from keycardai.oauth import AsyncClient, ClientConfig, NoneAuth +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.server.credentials import ApplicationCredential, ClientSecret +from keycardai.oauth.server.token_exchange import exchange_tokens_for_resources +from langchain.agents.middleware import AgentMiddleware +from langchain.agents.middleware.types import ToolCallRequest + + +@dataclass +class KeycardIdentity: + """Per-invocation identity, passed as the agent's runtime context. + + Exactly one of the three should be set: + - subject_token: on-behalf-of. The caller's Keycard access token, exchanged + per tool call for resource tokens (RFC 8693). + - user_identifier: impersonation. A substitute-user exchange for this user, + authenticated by the agent's own application credential. Forbidden by + default; requires an explicit policy in the zone. + - as_self=True: the agent acts as itself (client credentials). No user + anywhere: resource access is attributed to the application alone. This is + deliberately explicit; a run with no identity at all stays an error (or a + sign-in interrupt), never silently escalates to the agent's own authority. + """ + + subject_token: str | None = None + user_identifier: str | None = None + as_self: bool = False + + def __bool__(self) -> bool: + return bool(self.subject_token or self.user_identifier or self.as_self) + + +_current_access: ContextVar[AccessContext | None] = ContextVar( + "keycard_access_context", default=None +) + + +def _subject_token_expired(token: str) -> bool: + """Whether a JWT subject token is already expired. + + Decode-only, no signature verification: the zone remains the authority on + validity. This check exists to route an expiry to sign-in instead of a + consent page, and to skip an exchange round trip that is guaranteed to + fail. Opaque or malformed tokens return False and are left for the zone + to judge. + """ + parts = token.split(".") + if len(parts) != 3: + return False + try: + raw = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)) + payload = json.loads(raw) + except (ValueError, UnicodeDecodeError): + return False + if not isinstance(payload, dict): + return False + exp = payload.get("exp") + return isinstance(exp, (int, float)) and exp <= time.time() + + +def get_access_context() -> AccessContext: + """The AccessContext for the tool call currently executing. + + Call from inside a tool. Raises RuntimeError when no KeycardGrantMiddleware + wrapped this call. + """ + access = _current_access.get() + if access is None: + raise RuntimeError( + "No Keycard AccessContext for this tool call. Add KeycardGrantMiddleware " + "to the agent's middleware list and invoke the agent with a " + "KeycardIdentity context." + ) + return access + + +class KeycardGrantMiddleware(AgentMiddleware): + """Exchange the caller's identity for resource tokens on every tool call. + + Args: + zone_url: Keycard zone URL (issuer). Required unless `client` is given. + resources: Resource URLs to grant for every tool call. + application_credential: How the agent authenticates to the zone. + `ClientSecret` for Keycard-issued client credentials, + `WorkloadIdentity` for a platform-signed OIDC token (no static + secret on the box). Mutually exclusive with client_id / + client_secret. + client_id / client_secret: Shorthand for + `application_credential=ClientSecret((client_id, client_secret))`. + tool_resources: Optional per-tool override, tool name -> resource URLs. + Tools absent from the map get `resources`. + request_scopes: Optional outbound scopes for the exchange, same shapes + as the core orchestrator (str | list | dict per resource). + authorization_url: When set, a failed exchange pauses the run with a + LangGraph interrupt instead of recording a silent error. The + interrupt payload carries this URL (str, or callable taking the + failed resource URLs) for the user to establish the grant; on + resume the exchange is retried. Requires a checkpointer. + sign_in_url: When set, a run that carries no identity, or whose + subject token has already expired, pauses with a + `sign_in_required` interrupt linking here, instead of failing. + The payload's `reason` field says which case it was. The whole + flow then lives in the chat: sign in, resume. + fallback_identity: Identity used when the runtime context carries + none. Pass a callable to resolve it per tool call, so a sign-in + that happens mid-run is picked up on resume without a restart. + client: Injectable AsyncClient (tests). When set, zone_url is unused + and the client is reused as-is. + """ + + def __init__( + self, + *, + zone_url: str | None = None, + resources: list[str], + application_credential: ApplicationCredential | None = None, + client_id: str | None = None, + client_secret: str | None = None, + tool_resources: dict[str, list[str]] | None = None, + request_scopes: str | list[str] | dict[str, str | list[str]] | None = None, + authorization_url: str | Callable[[list[str]], str] | None = None, + sign_in_url: str | None = None, + fallback_identity: KeycardIdentity + | Callable[[], KeycardIdentity | None] + | None = None, + client: AsyncClient | None = None, + ) -> None: + super().__init__() + if client is None and not zone_url: + raise ValueError( + "KeycardGrantMiddleware requires zone_url (or an injected client)" + ) + if application_credential is not None and (client_id or client_secret): + raise ValueError( + "Pass application_credential or client_id/client_secret, not both" + ) + self._zone_url = zone_url + self._resources = list(resources) + if application_credential is not None: + self._credential: ApplicationCredential | None = application_credential + elif client_id and client_secret: + self._credential = ClientSecret((client_id, client_secret)) + else: + self._credential = None + self._tool_resources = tool_resources or {} + self._request_scopes = request_scopes + self._authorization_url = authorization_url + self._sign_in_url = sign_in_url + self._fallback_identity = fallback_identity + self._injected_client = client + self._loop_clients: WeakKeyDictionary[ + asyncio.AbstractEventLoop, AsyncClient + ] = WeakKeyDictionary() + self._sync_loop: asyncio.AbstractEventLoop | None = None + self._sync_loop_lock = threading.Lock() + + def _resolve_fallback(self) -> KeycardIdentity | None: + fallback = self._fallback_identity + return fallback() if callable(fallback) else fallback + + def _new_client(self) -> AsyncClient: + auth = ( + self._credential.get_http_client_auth() + if self._credential is not None + else NoneAuth() + ) + config = ClientConfig( + enable_metadata_discovery=True, + auto_register_client=False, + ) + if self._credential is not None: + config = self._credential.set_client_config(config, {}) + return AsyncClient(issuer=self._zone_url, auth=auth, config=config) + + def _client(self) -> AsyncClient: + """The client bound to the running event loop. + + Cached per loop rather than per call: an AsyncClient holds connections + owned by its loop and must not outlive it. Under an async server the + loop persists, so this reuses one client (and one metadata discovery) + for the process. The sync tool path runs on the middleware's own + persistent loop (see _run_sync), so it shares one warm client too. + """ + if self._injected_client is not None: + return self._injected_client + loop = asyncio.get_running_loop() + client = self._loop_clients.get(loop) + if client is None: + client = self._new_client() + self._loop_clients[loop] = client + return client + + def _run_sync(self, coro: Coroutine[Any, Any, AccessContext]) -> AccessContext: + """Run grant work from the sync path on one persistent background loop. + + asyncio.run would build a fresh event loop per tool call, so the + per-loop client cache would miss every time and each call would pay + client construction plus metadata discovery again. One long-lived + loop keeps a single warm client (and its connections) for every sync + tool call in the process. + """ + with self._sync_loop_lock: + loop = self._sync_loop + if loop is None: + loop = asyncio.new_event_loop() + threading.Thread( + target=loop.run_forever, + name="keycard-grant-middleware", + daemon=True, + ).start() + self._sync_loop = loop + return asyncio.run_coroutine_threadsafe(coro, loop).result() + + def _resources_for(self, request: ToolCallRequest) -> list[str]: + name = request.tool_call.get("name", "") + return self._tool_resources.get(name, self._resources) + + def _resolve_identity(self, request: ToolCallRequest) -> KeycardIdentity | None: + """The effective identity for this tool call: context first, then fallback. + + Resolved per call: a sign-in that happens mid-run (via the + sign_in_required interrupt) is picked up on resume. + """ + identity = getattr(request.runtime, "context", None) + if identity is not None and ( + getattr(identity, "subject_token", None) + or getattr(identity, "user_identifier", None) + or getattr(identity, "as_self", False) + ): + return KeycardIdentity( + subject_token=getattr(identity, "subject_token", None), + user_identifier=getattr(identity, "user_identifier", None), + as_self=getattr(identity, "as_self", False), + ) + return self._resolve_fallback() + + def _scope_for(self, resource: str) -> str | None: + scopes = self._request_scopes + if scopes is None: + return None + value = scopes.get(resource) if isinstance(scopes, dict) else scopes + if value is None: + return None + return " ".join(value) if isinstance(value, list) else value + + async def _client_auth_fields( + self, client: AsyncClient, resource: str + ) -> dict[str, str]: + """Client-authentication fields the credential puts in the request body. + + Assertion-based credentials (WorkloadIdentity, WebIdentity) carry no + HTTP-level auth; their proof rides in the request as a jwt-bearer + client assertion. The protocol only exposes request preparation for + token exchange, so this prepares one and lifts the auth fields for + the client-credentials call. ClientSecret authenticates at the HTTP + layer and contributes nothing here. + + The subject token below is a placeholder: client credentials has no + subject, the request model requires a non-empty one, and only the + client-auth fields of the prepared request are read. + """ + if self._credential is None: + return {} + prepared = await self._credential.prepare_token_exchange_request( + client=client, subject_token="client-credentials", resource=resource + ) + fields: dict[str, str] = {} + if getattr(prepared, "client_assertion", None): + fields["client_assertion"] = prepared.client_assertion + fields["client_assertion_type"] = prepared.client_assertion_type + return fields + + async def _grant_as_self( + self, resources: list[str], access: AccessContext + ) -> AccessContext: + """Client-credentials acquisition: the agent's own authority, no subject. + + Not routed through exchange_tokens_for_resources(), which only models + subject-token flows. + """ + client = self._client() + for resource in resources: + try: + kwargs: dict[str, Any] = {"resource": resource} + scope = self._scope_for(resource) + if scope: + kwargs["scope"] = scope + kwargs.update(await self._client_auth_fields(client, resource)) + token = await client.client_credentials_grant(**kwargs) + access.set_token(resource, token) + except Exception as e: + error: dict[str, str] = { + "message": f"Client credentials grant failed for {resource}" + } + if hasattr(e, "error"): + error["code"] = e.error + if getattr(e, "error_description", None): + error["description"] = e.error_description + if "code" not in error: + error["raw_error"] = str(e) + access.set_resource_error(resource, error) + return access + + async def _build_access(self, request: ToolCallRequest) -> AccessContext: + return await self._build_access_for( + self._resolve_identity(request), self._resources_for(request) + ) + + async def _build_access_for( + self, identity: KeycardIdentity | None, resources: list[str] + ) -> AccessContext: + access = AccessContext() + + if identity is None: + access.set_error( + { + "message": ( + "No Keycard identity for this run. Sign in to continue." + if self._sign_in_url + else "No Keycard identity on the runtime context. Invoke the " + "agent with context=KeycardIdentity(subject_token=...), " + "KeycardIdentity(user_identifier=...), or " + "KeycardIdentity(as_self=True)." + ), + "code": "missing_identity", + } + ) + return access + + if identity.subject_token and _subject_token_expired(identity.subject_token): + access.set_error( + { + "message": ( + "The subject token for this run has expired. " + "Sign in again to continue." + ), + "code": "subject_token_expired", + } + ) + return access + + if identity.as_self: + return await self._grant_as_self(resources, access) + + return await exchange_tokens_for_resources( + client=self._client(), + resources=resources, + subject_token=identity.subject_token or "", + access_context=access, + application_credential=self._credential, + user_identifier=identity.user_identifier, + request_scopes=self._request_scopes, + ) + + _MAX_AUTHORIZATION_ATTEMPTS = 3 + + def _interrupt_payload( + self, failed: list[str], access: AccessContext + ) -> dict[str, Any]: + url = self._authorization_url + if callable(url): + url = url(failed) + return { + "type": "authorization_required", + "resources": failed, + "authorization_url": url, + "errors": {r: access.get_resource_error(r) for r in failed}, + "message": ( + "Access to the resources above has not been granted yet. " + "Open the authorization URL to grant it, then resume the run." + ), + } + + def _sign_in_payload(self, access: AccessContext) -> dict[str, Any]: + error = access.get_error() or {} + reason = error.get("code", "missing_identity") + return { + "type": "sign_in_required", + "sign_in_url": self._sign_in_url, + "reason": reason, + "message": ( + "Your session has expired. Sign in again, then resume the run." + if reason == "subject_token_expired" + else "Sign in with Keycard to continue. Open the link, sign in, " + "then resume the run." + ), + } + + def _pending_interrupt( + self, access: AccessContext, request: ToolCallRequest + ) -> dict[str, Any] | None: + """The interrupt this AccessContext calls for, if any. + + As-itself runs never interrupt: there is no user to send to a sign-in + or consent page, so failures stay on the AccessContext as errors for + the tool (and the operator's logs) to surface. + """ + identity = self._resolve_identity(request) + if identity is not None and identity.as_self: + return None + if access.has_error() and self._sign_in_url: + return self._sign_in_payload(access) + failed = access.get_failed_resources() + if failed and self._authorization_url is not None: + return self._interrupt_payload(failed, access) + return None + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + access = await self._build_access(request) + for _ in range(self._MAX_AUTHORIZATION_ATTEMPTS): + payload = self._pending_interrupt(access, request) + if payload is None: + break + interrupt(payload) + access = await self._build_access(request) + token = _current_access.set(access) + try: + return await handler(request) + finally: + _current_access.reset(token) + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + access = self._run_sync(self._build_access(request)) + for _ in range(self._MAX_AUTHORIZATION_ATTEMPTS): + payload = self._pending_interrupt(access, request) + if payload is None: + break + interrupt(payload) + access = self._run_sync(self._build_access(request)) + token = _current_access.set(access) + try: + return handler(request) + finally: + _current_access.reset(token) + + def _grant_target( + self, + identity: KeycardIdentity | None, + tool_name: str | None, + resources: list[str] | None, + ) -> tuple[KeycardIdentity | None, list[str]]: + if tool_name is not None and resources is not None: + raise ValueError("Pass tool_name or resources, not both") + resolved = identity if identity is not None else self._resolve_fallback() + if resources is not None: + return resolved, list(resources) + if tool_name is not None: + return resolved, self._tool_resources.get(tool_name, self._resources) + return resolved, self._resources + + @contextmanager + def grant( + self, + identity: KeycardIdentity | None = None, + *, + tool_name: str | None = None, + resources: list[str] | None = None, + ) -> Iterator[AccessContext]: + """Serve get_access_context() for code that runs outside an agent. + + Lets the same governed tools back non-agent surfaces, e.g. seeding a + dashboard panel on page load with the tool the agent uses in chat: + + with keycard.grant(KeycardIdentity(subject_token=token)): + rows = list_requests.invoke({}) + + Also serves resources that have no tool at all, e.g. fetching a + vaulted LLM key under the agent's own identity: + + with keycard.grant( + KeycardIdentity(as_self=True), resources=[LLM_KEY] + ) as access: + key = access.access(LLM_KEY).access_token + + When `identity` is omitted, `fallback_identity` is used. `tool_name` + applies that tool's `tool_resources` override, `resources` grants + exactly the listed resources (the two are mutually exclusive), and + with neither the default resources are granted. There is no run to + pause, so nothing interrupts here: failures stay on the yielded + AccessContext, exactly as tools see them. + """ + resolved, targets = self._grant_target(identity, tool_name, resources) + access = self._run_sync(self._build_access_for(resolved, targets)) + token = _current_access.set(access) + try: + yield access + finally: + _current_access.reset(token) + + @asynccontextmanager + async def agrant( + self, + identity: KeycardIdentity | None = None, + *, + tool_name: str | None = None, + resources: list[str] | None = None, + ) -> AsyncIterator[AccessContext]: + """Async grant(): the same contract on the running event loop.""" + resolved, targets = self._grant_target(identity, tool_name, resources) + access = await self._build_access_for(resolved, targets) + token = _current_access.set(access) + try: + yield access + finally: + _current_access.reset(token) diff --git a/packages/langchain/src/keycardai/langchain/testing/__init__.py b/packages/langchain/src/keycardai/langchain/testing/__init__.py new file mode 100644 index 0000000..5219688 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/testing/__init__.py @@ -0,0 +1,13 @@ +"""Test seams for agents built with keycardai-langchain. + +Lets tests exercise tools without a zone, a network, or real token exchange. + + from keycardai.langchain.testing import mock_access_context + + with mock_access_context(resource_tokens={"https://api.example.com": "tok"}): + result = my_tool.invoke({"query": "hello"}) +""" + +from .test_utils import mock_access_context, override_access_context + +__all__ = ["mock_access_context", "override_access_context"] diff --git a/packages/langchain/src/keycardai/langchain/testing/test_utils.py b/packages/langchain/src/keycardai/langchain/testing/test_utils.py new file mode 100644 index 0000000..3d0c5f9 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/testing/test_utils.py @@ -0,0 +1,84 @@ +"""Install a preloaded AccessContext for the duration of a test.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.types.models import TokenResponse + +from ..middleware import _current_access + + +@contextmanager +def override_access_context(access_context: AccessContext) -> Iterator[AccessContext]: + """Serve `access_context` to tools for the duration of the block. + + The full-control seam: build the AccessContext yourself (including partial + failures) and hand it over. `mock_access_context` covers the common cases. + """ + token = _current_access.set(access_context) + try: + yield access_context + finally: + _current_access.reset(token) + + +@contextmanager +def mock_access_context( + access_token: str | None = None, + resource_tokens: dict[str, str] | None = None, + resource_errors: dict[str, str] | None = None, + error_message: str | None = None, +) -> Iterator[AccessContext]: + """Serve a synthetic AccessContext to tools, with no exchange performed. + + Args: + access_token: Served for every resource. Convenient, but it cannot + catch a mistyped resource URL in an `access(...)` call, since every + lookup succeeds. Prefer `resource_tokens` when the test should + assert which resource a tool reads. + resource_tokens: Per-resource tokens, keyed by resource URL. + resource_errors: Per-resource failures, keyed by resource URL, as a + grant failure would record them. + error_message: A global failure (no identity, unreachable zone). Takes + precedence: no resource tokens are served. + """ + context = _AnyResourceAccessContext(access_token) + + if error_message is not None: + context.set_error({"message": error_message, "code": "mock_error"}) + else: + for resource, token in (resource_tokens or {}).items(): + context.set_token( + resource, TokenResponse(access_token=token, token_type="Bearer") + ) + for resource, message in (resource_errors or {}).items(): + context.set_resource_error( + resource, {"message": message, "code": "mock_resource_error"} + ) + + with override_access_context(context): + yield context + + +class _AnyResourceAccessContext(AccessContext): + """AccessContext that can serve one token for any resource. + + Only used when `mock_access_context(access_token=...)` is given; with + `resource_tokens` the base class behavior applies unchanged. + """ + + def __init__(self, default_token: str | None = None) -> None: + super().__init__() + self._default_token = default_token + + def access(self, resource: str) -> TokenResponse: + if ( + self._default_token is not None + and not self.has_errors() + and resource not in self.get_successful_resources() + ): + return TokenResponse(access_token=self._default_token, token_type="Bearer") + return super().access(resource) diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py new file mode 100644 index 0000000..c9575c3 --- /dev/null +++ b/packages/langchain/tests/test_middleware.py @@ -0,0 +1,536 @@ +"""Middleware behavior, exercised through a real create_agent loop. + +The exchange client is a stub, so no zone or network is involved; everything +else (the agent graph, the middleware hooks, the tool call) is real. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import time + +import pytest +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) +from keycardai.oauth import TokenResponse +from keycardai.oauth.types.models import TokenExchangeRequest + +RESOURCE = "https://api.example.test" +PROMPT = {"messages": [HumanMessage("Read the delegated token.")]} + + +def jwt_with_exp(exp: float) -> str: + """An unsigned JWT carrying only exp; the middleware never verifies.""" + + def b64(part: dict) -> str: + raw = base64.urlsafe_b64encode(json.dumps(part).encode()) + return raw.rstrip(b"=").decode() + + return f"{b64({'alg': 'none'})}.{b64({'exp': exp})}.sig" + + +class StubExchangeClient: + """Stands in for keycardai.oauth.AsyncClient on the exchange paths.""" + + def __init__(self) -> None: + self.exchange_calls: list[TokenExchangeRequest] = [] + self.impersonate_calls: list[dict[str, str]] = [] + self.self_calls: list[dict[str, str]] = [] + self.granted = True + self.self_granted = True + self.denied_resources: set[str] = set() + + async def exchange_token(self, request: TokenExchangeRequest) -> TokenResponse: + self.exchange_calls.append(request) + if not self.granted or request.resource in self.denied_resources: + raise RuntimeError("no grant for this resource yet") + return TokenResponse( + access_token=f"obo-token-for-{request.resource}", + token_type="Bearer", + expires_in=300, + ) + + async def impersonate( + self, *, user_identifier: str, resource: str, scope: str | None = None + ) -> TokenResponse: + self.impersonate_calls.append({"user": user_identifier, "resource": resource}) + return TokenResponse( + access_token=f"impersonated-{user_identifier}-for-{resource}", + token_type="Bearer", + ) + + async def client_credentials_grant(self, request=None, **kwargs) -> TokenResponse: + self.self_calls.append(kwargs) + if not self.self_granted: + raise RuntimeError("policy denies this application self access") + return TokenResponse( + access_token=f"self-token-for-{kwargs.get('resource')}", + token_type="Bearer", + ) + + +@tool +def read_delegated_token(resource: str) -> str: + """Read the delegated Keycard token for a resource.""" + access = get_access_context() + if access.has_error(): + return f"GLOBAL_ERROR: {access.get_error()}" + if access.has_resource_error(resource): + return f"RESOURCE_ERROR: {access.get_resource_error(resource)}" + return f"TOKEN: {access.access(resource).access_token}" + + +class _ToolBindableFakeModel(GenericFakeChatModel): + """GenericFakeChatModel that tolerates bind_tools; the script drives calls.""" + + def bind_tools(self, tools, **kwargs): # noqa: ANN001, ANN003, ANN201 + return self + + +def scripted_model() -> GenericFakeChatModel: + return _ToolBindableFakeModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "read_delegated_token", + "args": {"resource": RESOURCE}, + "id": "call_1", + "type": "tool_call", + } + ], + ), + AIMessage(content="done"), + ] + ) + ) + + +def build_agent(stub: StubExchangeClient, **middleware_kwargs) -> object: + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=stub, **middleware_kwargs + ) + checkpointer = ( + InMemorySaver() + if middleware_kwargs.get("authorization_url") + or middleware_kwargs.get("sign_in_url") + else None + ) + return create_agent( + model=scripted_model(), + tools=[read_delegated_token], + middleware=[middleware], + context_schema=KeycardIdentity, + checkpointer=checkpointer, + ) + + +def last_tool_message(result: dict) -> ToolMessage: + messages = [m for m in result["messages"] if isinstance(m, ToolMessage)] + assert messages, f"no ToolMessage in {result['messages']}" + return messages[-1] + + +def test_on_behalf_of_exchanges_the_callers_token() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(subject_token="caller-token") + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + assert stub.exchange_calls[0].subject_token == "caller-token" + + +async def test_on_behalf_of_works_on_the_async_path() -> None: + stub = StubExchangeClient() + result = await build_agent(stub).ainvoke( + PROMPT, context=KeycardIdentity(subject_token="caller-token") + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_impersonation_uses_the_substitute_user_path() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(user_identifier="user@example.com") + ) + assert "TOKEN: impersonated-user@example.com" in last_tool_message(result).content + assert not stub.exchange_calls + assert len(stub.impersonate_calls) == 1 + + +def test_missing_identity_is_recorded_not_raised() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke(PROMPT) + content = last_tool_message(result).content + assert "GLOBAL_ERROR" in content + assert "missing_identity" in content + assert not stub.exchange_calls + + +def test_tool_schema_carries_no_keycard_plumbing() -> None: + """The model must not see (or be able to supply) auth arguments.""" + properties = read_delegated_token.args_schema.model_json_schema()["properties"] + assert set(properties) == {"resource"} + + +def test_authorization_interrupt_pauses_then_resumes() -> None: + stub = StubExchangeClient() + stub.granted = False + agent = build_agent(stub, authorization_url="https://consent.example/authorize") + config = {"configurable": {"thread_id": "auth-interrupt"}} + + result = agent.invoke( + PROMPT, config, context=KeycardIdentity(subject_token="caller-token") + ) + interrupts = result.get("__interrupt__", []) + assert len(interrupts) == 1 + payload = interrupts[0].value + assert payload["type"] == "authorization_required" + assert payload["authorization_url"] == "https://consent.example/authorize" + assert payload["resources"] == [RESOURCE] + + stub.granted = True # the user consented out of band + # Runtime context is not checkpointed, so a resume re-supplies identity, + # exactly as a server does on every run. + result = agent.invoke( + Command(resume="authorized"), + config, + context=KeycardIdentity(subject_token="caller-token"), + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_sign_in_interrupt_picks_up_identity_without_a_restart() -> None: + stub = StubExchangeClient() + signed_in: dict[str, KeycardIdentity | None] = {"identity": None} + agent = build_agent( + stub, + sign_in_url="https://consent.example/", + authorization_url="https://consent.example/authorize", + fallback_identity=lambda: signed_in["identity"], + ) + config = {"configurable": {"thread_id": "sign-in-interrupt"}} + + result = agent.invoke(PROMPT, config) + payload = result["__interrupt__"][0].value + assert payload["type"] == "sign_in_required" + assert payload["sign_in_url"] == "https://consent.example/" + assert not stub.exchange_calls + + signed_in["identity"] = KeycardIdentity(subject_token="caller-token") + result = agent.invoke(Command(resume="signed in"), config) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_request_scopes_reach_the_exchange() -> None: + stub = StubExchangeClient() + agent = build_agent(stub, request_scopes={RESOURCE: ["read", "write"]}) + agent.invoke(PROMPT, context=KeycardIdentity(subject_token="caller-token")) + assert stub.exchange_calls[0].scope == "read write" + + +def test_as_self_uses_client_credentials_not_exchange() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke(PROMPT, context=KeycardIdentity(as_self=True)) + assert f"TOKEN: self-token-for-{RESOURCE}" in last_tool_message(result).content + assert not stub.exchange_calls + assert not stub.impersonate_calls + assert stub.self_calls == [{"resource": RESOURCE}] + + +def test_as_self_request_scopes_reach_the_grant() -> None: + stub = StubExchangeClient() + agent = build_agent(stub, request_scopes={RESOURCE: ["repo:read"]}) + agent.invoke(PROMPT, context=KeycardIdentity(as_self=True)) + assert stub.self_calls == [{"resource": RESOURCE, "scope": "repo:read"}] + + +def test_as_self_denial_is_an_error_never_an_interrupt() -> None: + """No user exists to send to a consent page, so as-itself must not pause.""" + stub = StubExchangeClient() + stub.self_granted = False + agent = build_agent( + stub, + authorization_url="https://consent.example/authorize", + sign_in_url="https://consent.example/", + ) + config = {"configurable": {"thread_id": "as-self-denied"}} + + result = agent.invoke(PROMPT, config, context=KeycardIdentity(as_self=True)) + assert not result.get("__interrupt__") + content = last_tool_message(result).content + assert "RESOURCE_ERROR" in content + assert "Client credentials grant failed" in content + + +def test_zone_url_is_required_without_an_injected_client() -> None: + with pytest.raises(ValueError, match="zone_url"): + KeycardGrantMiddleware(resources=[RESOURCE]) + + +def test_expired_subject_token_pauses_for_sign_in_not_consent() -> None: + """Consent cannot fix an expired token, so it must not route to consent.""" + stub = StubExchangeClient() + agent = build_agent( + stub, + sign_in_url="https://consent.example/", + authorization_url="https://consent.example/authorize", + ) + config = {"configurable": {"thread_id": "expired-token"}} + + result = agent.invoke( + PROMPT, + config, + context=KeycardIdentity(subject_token=jwt_with_exp(time.time() - 60)), + ) + payload = result["__interrupt__"][0].value + assert payload["type"] == "sign_in_required" + assert payload["reason"] == "subject_token_expired" + assert not stub.exchange_calls, "an expired token must not be sent for exchange" + + +def test_expired_subject_token_without_sign_in_url_is_an_error() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(subject_token=jwt_with_exp(time.time() - 60)) + ) + content = last_tool_message(result).content + assert "GLOBAL_ERROR" in content + assert "subject_token_expired" in content + assert not stub.exchange_calls + + +def test_unexpired_jwt_subject_token_exchanges_normally() -> None: + stub = StubExchangeClient() + token = jwt_with_exp(time.time() + 3600) + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(subject_token=token) + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + assert stub.exchange_calls[0].subject_token == token + + +def test_sync_path_runs_on_one_persistent_loop() -> None: + """A fresh loop per sync call would defeat the per-loop client cache.""" + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=StubExchangeClient() + ) + + async def running_loop() -> asyncio.AbstractEventLoop: + return asyncio.get_running_loop() + + assert middleware._run_sync(running_loop()) is middleware._run_sync(running_loop()) + + +def test_grant_serves_tools_outside_the_agent() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware(resources=[RESOURCE], client=stub) + + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert not access.has_errors() + result = read_delegated_token.invoke({"resource": RESOURCE}) + assert f"TOKEN: obo-token-for-{RESOURCE}" in result + + with pytest.raises(RuntimeError, match="KeycardGrantMiddleware"): + read_delegated_token.invoke({"resource": RESOURCE}) + + +async def test_agrant_serves_tools_on_the_async_path() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware(resources=[RESOURCE], client=stub) + + async with middleware.agrant(KeycardIdentity(as_self=True)) as access: + assert access.access(RESOURCE).access_token == f"self-token-for-{RESOURCE}" + result = await read_delegated_token.ainvoke({"resource": RESOURCE}) + assert f"TOKEN: self-token-for-{RESOURCE}" in result + + +def test_grant_uses_the_fallback_identity_when_omitted() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + fallback_identity=KeycardIdentity(as_self=True), + ) + with middleware.grant() as access: + assert access.access(RESOURCE).access_token == f"self-token-for-{RESOURCE}" + + +def test_grant_applies_the_tool_resources_override() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=["https://other.example.test"], + tool_resources={"read_delegated_token": [RESOURCE]}, + client=stub, + ) + with middleware.grant( + KeycardIdentity(subject_token="caller-token"), tool_name="read_delegated_token" + ) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + assert [c.resource for c in stub.exchange_calls] == [RESOURCE] + + +def test_grant_records_missing_identity_instead_of_raising() -> None: + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=StubExchangeClient() + ) + with middleware.grant() as access: + assert access.has_error() + assert access.get_error()["code"] == "missing_identity" + + +class StubAssertionCredential: + """ApplicationCredential whose proof rides in the request body, + the shape WorkloadIdentity and WebIdentity use.""" + + def get_http_client_auth(self): # noqa: ANN201 + from keycardai.oauth import NoneAuth + + return NoneAuth() + + def set_client_config(self, config, auth_info): # noqa: ANN001, ANN201 + return config + + async def prepare_token_exchange_request( + self, client, subject_token: str, resource: str, auth_info=None + ): # noqa: ANN001, ANN201 + return TokenExchangeRequest( + subject_token=subject_token, + resource=resource, + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + client_assertion="stub-assertion", + client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ) + + +def test_client_id_and_secret_are_client_secret_shorthand() -> None: + """The two spellings must be one object: the params build the same + ClientSecret a caller would pass as application_credential.""" + from keycardai.oauth.server.credentials import ClientSecret + + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + client_id="agent", + client_secret="s3cret", + ) + assert isinstance(middleware._credential, ClientSecret) + + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + request = stub.exchange_calls[0] + assert request.subject_token == "caller-token" + assert request.client_assertion is None + + +def test_credential_and_client_id_are_mutually_exclusive() -> None: + with pytest.raises(ValueError, match="not both"): + KeycardGrantMiddleware( + zone_url="https://zone.example", + resources=[RESOURCE], + application_credential=StubAssertionCredential(), + client_id="agent", + client_secret="secret", + ) + + +def test_credential_prepares_the_exchange_request() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + application_credential=StubAssertionCredential(), + ) + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + request = stub.exchange_calls[0] + assert request.subject_token == "caller-token" + assert request.client_assertion == "stub-assertion" + + +def test_credential_assertion_reaches_the_as_self_grant() -> None: + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], + client=stub, + application_credential=StubAssertionCredential(), + ) + with middleware.grant(KeycardIdentity(as_self=True)) as access: + assert access.access(RESOURCE).access_token == f"self-token-for-{RESOURCE}" + call = stub.self_calls[0] + assert call["resource"] == RESOURCE + assert call["client_assertion"] == "stub-assertion" + assert call["client_assertion_type"].endswith("jwt-bearer") + + +def test_partial_grant_yields_token_and_resource_error_side_by_side() -> None: + """Partial success is the contract: one denied resource must not poison + the granted one, and the failure stays per-resource, not global.""" + stub = StubExchangeClient() + denied = "https://denied.example.test" + stub.denied_resources.add(denied) + middleware = KeycardGrantMiddleware(resources=[RESOURCE, denied], client=stub) + + with middleware.grant(KeycardIdentity(subject_token="caller-token")) as access: + assert access.access(RESOURCE).access_token == f"obo-token-for-{RESOURCE}" + assert access.has_resource_error(denied) + assert not access.has_error() + + +def test_no_tool_executes_before_an_interrupt_resolves() -> None: + """The pause happens before the handler: an interrupted run must contain + no ToolMessage, so nothing side-effectful ran pre-consent.""" + stub = StubExchangeClient() + stub.granted = False + agent = build_agent(stub, authorization_url="https://consent.example/authorize") + config = {"configurable": {"thread_id": "no-tool-before-interrupt"}} + + result = agent.invoke( + PROMPT, config, context=KeycardIdentity(subject_token="caller-token") + ) + assert result.get("__interrupt__") + tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)] + assert not tool_messages, "tool ran before authorization resolved" + + +def test_grant_accepts_explicit_resources_without_a_tool() -> None: + """A resource with no tool attached, e.g. a vaulted LLM key.""" + stub = StubExchangeClient() + middleware = KeycardGrantMiddleware(resources=[RESOURCE], client=stub) + key_resource = "https://llm-key.example.test" + + with middleware.grant( + KeycardIdentity(as_self=True), resources=[key_resource] + ) as access: + assert access.access(key_resource).access_token == ( + f"self-token-for-{key_resource}" + ) + assert stub.self_calls == [{"resource": key_resource}] + + +def test_grant_rejects_tool_name_and_resources_together() -> None: + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=StubExchangeClient() + ) + with pytest.raises(ValueError, match="not both"): + with middleware.grant( + KeycardIdentity(as_self=True), + tool_name="read_delegated_token", + resources=[RESOURCE], + ): + pass diff --git a/packages/langchain/tests/test_testing_seam.py b/packages/langchain/tests/test_testing_seam.py new file mode 100644 index 0000000..d26911a --- /dev/null +++ b/packages/langchain/tests/test_testing_seam.py @@ -0,0 +1,49 @@ +"""The testing seam: exercise tools with no middleware, zone, or network.""" + +from __future__ import annotations + +import pytest +from langchain.tools import tool + +from keycardai.langchain import ResourceAccessError, get_access_context +from keycardai.langchain.testing import mock_access_context + +RESOURCE = "https://api.example.test" + + +@tool +def call_api() -> str: + """Call the API with the delegated token.""" + access = get_access_context() + if access.has_error(): + return f"unavailable: {access.get_error()['message']}" + return access.access(RESOURCE).access_token + + +def test_resource_tokens_are_served_per_resource() -> None: + with mock_access_context(resource_tokens={RESOURCE: "tok-123"}): + assert call_api.invoke({}) == "tok-123" + + +def test_any_resource_token_is_a_convenience_with_a_tradeoff() -> None: + """The bare form serves any resource, so it cannot catch a wrong URL.""" + with mock_access_context(access_token="tok-any"): + assert call_api.invoke({}) == "tok-any" + + +def test_global_error_is_visible_to_the_tool() -> None: + with mock_access_context(error_message="no identity for this run"): + assert call_api.invoke({}) == "unavailable: no identity for this run" + + +def test_resource_error_raises_only_on_access() -> None: + with mock_access_context(resource_errors={RESOURCE: "not granted"}) as access: + assert access.has_errors() + assert not access.has_error() # per-resource, not global + with pytest.raises(ResourceAccessError): + access.access(RESOURCE) + + +def test_outside_the_seam_the_tool_reports_a_missing_middleware() -> None: + with pytest.raises(RuntimeError, match="KeycardGrantMiddleware"): + call_api.invoke({}) diff --git a/pyproject.toml b/pyproject.toml index f5c94e9..2100b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ members = [ "packages/starlette", "packages/mcp", "packages/a2a", + "packages/langchain", ] # Examples are standalone projects with their own lockfiles: each pins its parent # package via a path source and resolves the rest from the index. diff --git a/uv.lock b/uv.lock index 096c477..81df9e9 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ resolution-markers = [ members = [ "keycardai", "keycardai-a2a", + "keycardai-langchain", "keycardai-mcp", "keycardai-oauth", "keycardai-starlette", @@ -1455,6 +1456,33 @@ requires-dist = [ ] provides-extras = ["dev", "test"] +[[package]] +name = "keycardai-langchain" +source = { editable = "packages/langchain" } +dependencies = [ + { name = "httpx" }, + { name = "keycardai-oauth" }, + { name = "langchain" }, + { name = "langgraph" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "keycardai-oauth", editable = "packages/oauth" }, + { name = "langchain", specifier = ">=1.0" }, + { name = "langgraph", specifier = ">=1.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.1.0" }, +] +provides-extras = ["test"] + [[package]] name = "keycardai-mcp" source = { editable = "packages/mcp" }