Skip to content

Commit 504ce40

Browse files
Sterling Iveyclaude
andcommitted
feat(crewai,llamaindex): two-line adoption path + STH stamping
Ports the v0.2 distribution wedge from langchain-haldir to crewai-haldir and llamaindex-haldir. Same HaldirSession contract across all three Python frameworks — operators running multi- framework deployments transfer the mental model verbatim. crewai-haldir v0.1.0 → 0.2.0 llamaindex-haldir v0.1.0 → 0.2.0 Each package now ships: HaldirSession context manager — __enter__ mints a scoped Haldir session; __exit__ revokes it even on user-code exception (revoke failures are swallowed so they can't mask the user's exception). Reads HALDIR_API_KEY + HALDIR_BASE_URL from env via .for_agent(), matches the ergonomics every LangChain-adjacent integration uses. .stamp_sth(result) helper — fetches the tenant's current Signed Tree Head and attaches it to the framework's return value, with shape-aware dispatch: dict gets _haldir_sth key, an object with __dict__ gets it as an attribute (e.g. CrewAI's CrewOutput, LlamaIndex's AgentChatResponse), bare values get wrapped. STH fetch errors NEVER mutate the caller's output — we return the original unchanged so a flaky tree-head call can't break the run's happy path. Auditors pin the STH once, verify offline any time later that the run's audit entries are still in the log unchanged. Spend / audit / STH helpers on the session — spend_summary(), audit_trail(), current_sth() forward to the underlying SDK client scoped to the active session. Used for mid-run dashboards (e.g. "we've burned 62% of our $5 cap"). Lazy framework import — GovernedTool (crewai) and govern_tool / GovernedTool (llamaindex) use a module-level __getattr__ so the package imports cleanly even before `pip install crewai` or `pip install llama-index-core`. This lets CI test the session layer without the heavy framework dependency chain; full test install only needed for the hard-enforcement tool tests. tests/ (new): crewai-haldir: 11 tests covering mint+revoke, exception-path revoke, env vs explicit key, helpers forward correctly, stamp_sth shape dispatch (dict / object / bare), network-error invariance. llamaindex-haldir: 10 tests with the same matrix + a AgentChatResponse-style object-attachment test specifically because LlamaIndex's .chat() returns that shape. examples/ (new): two_line_crew.py — end-to-end CrewAI ReAct flow with SerperDevTool, 2-line Haldir adoption, STH stamp, spend summary. two_line_agent.py — end-to-end LlamaIndex ReActAgent flow with FunctionTool, same 2-line pattern. READMEs rewritten for both: the two-line quickstart is the first thing a reader sees; `What you get` table replaces the v0.1 scattered-feature-list; legacy create_session() noted as back- compat only. Main Haldir suite: 590 still green (zero regressions). Together with langchain-haldir v0.2 (already shipped), Haldir is now in three of the four major Python agent frameworks with the same two-line adoption contract. Remaining: autogen-haldir (same pattern, next tranche) and the Vercel AI SDK TS package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 60e35e4 commit 504ce40

12 files changed

Lines changed: 936 additions & 100 deletions

File tree

integrations/crewai-haldir/README.md

Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,59 @@
11
# crewai-haldir
22

3-
Governance layer for CrewAI agents — audit trails, spend caps, secrets vault, and instant revocation.
3+
Cryptographic-audit + governance for CrewAI agents. Two lines of code.
44

5-
Wrap any CrewAI tool in Haldir's enforcement proxy so every tool call is scope-checked, cost-tracked, and logged to a tamper-evident audit trail.
5+
- **Audit trail** — every tool call logged to a SHA-256 hash-chained + RFC 6962 Merkle-covered audit log
6+
- **Scope enforcement** — denied tools abort with `HaldirPermissionError`
7+
- **Secrets vault** — scope-checked credential retrieval as `SecretStr`
8+
- **Tree-head stamping** — attach the current Signed Tree Head to the crew's output; pin for offline verification later
9+
- **Instant revocation** — any process can revoke a session mid-run; next tool call aborts
10+
- **Auto-lifecycle** — session minted on `with` entry, revoked on exit even if the crew raises
611

712
## Install
813

914
```bash
1015
pip install crewai-haldir
1116
```
1217

13-
You'll need a Haldir API key. Create one free at [haldir.xyz](https://haldir.xyz).
18+
Free Haldir API key at [haldir.xyz](https://haldir.xyz).
1419

15-
## 30-second quickstart
20+
## Two-line quickstart
1621

1722
```python
1823
from crewai import Agent, Task, Crew
1924
from crewai_tools import SerperDevTool
20-
from crewai_haldir import create_session, GovernedTool
21-
22-
# Create a scoped Haldir session with a $10 spend cap
23-
client, session_id = create_session(
24-
api_key="hld_xxx",
25-
agent_id="research-crew",
26-
scopes=["read", "search", "spend"],
27-
spend_limit=10.0,
28-
)
29-
30-
# Wrap your tools so Haldir enforces permissions
31-
search = GovernedTool.wrap(
32-
SerperDevTool(),
33-
client=client,
34-
session_id=session_id,
35-
required_scope="search",
36-
cost_usd=0.01,
37-
)
25+
from crewai_haldir import HaldirSession, GovernedTool # ← line 1
26+
27+
with HaldirSession.for_agent("research-crew", # ← line 2
28+
scopes=["read", "search"],
29+
spend_limit=10.0) as haldir:
30+
search = GovernedTool.wrap(
31+
SerperDevTool(),
32+
client=haldir.client,
33+
session_id=haldir.session_id,
34+
required_scope="search",
35+
cost_usd=0.01,
36+
)
37+
38+
researcher = Agent(role="Researcher", goal="...", tools=[search])
39+
task = Task(description="...", expected_output="...", agent=researcher)
40+
crew = Crew(agents=[researcher], tasks=[task])
41+
42+
result = haldir.stamp_sth(crew.kickoff())
43+
# result["_haldir_sth"] or result._haldir_sth — pin it for
44+
# offline verification any time later.
45+
```
3846

39-
# Build your crew as usual — governance happens transparently
40-
researcher = Agent(
41-
role="Senior Research Analyst",
42-
goal="Find the most recent news on a topic",
43-
backstory="A diligent researcher with attention to sources.",
44-
tools=[search],
45-
)
47+
`HaldirSession.for_agent(...)` reads `HALDIR_API_KEY` + `HALDIR_BASE_URL` from the env. Session auto-revoked on scope exit, including on exception.
4648

47-
task = Task(
48-
description="Find the latest news about AI agent security incidents.",
49-
expected_output="A bulleted list with sources.",
50-
agent=researcher,
51-
)
52-
53-
crew = Crew(agents=[researcher], tasks=[task])
54-
crew.kickoff()
55-
```
49+
## What you get
5650

57-
Every tool call is now:
58-
- **Permission-checked** before execution (revoked or out-of-scope sessions raise `HaldirPermissionError`)
59-
- **Cost-tracked** against the session's `spend_limit`
60-
- **Logged** to Haldir's hash-chained audit trail with tool name, timestamp, and cost
51+
| Step | Haldir action |
52+
|---|---|
53+
| Session enter | Mints a scoped session with the spend cap |
54+
| GovernedTool `_run` | Checks scope; logs call with input/output/cost |
55+
| `haldir.stamp_sth(result)` | Fetches current STH and attaches to the result |
56+
| Session exit (any path) | Revokes the session |
6157

6258
## Secrets without leaking them to the model
6359

integrations/crewai-haldir/crewai_haldir/__init__.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,27 @@
3030
crew.kickoff()
3131
"""
3232

33-
from .session import create_session
34-
from .governed_tool import GovernedTool
33+
"""Exports. `GovernedTool` requires the `crewai` package to be
34+
installed; if the user hasn't installed it yet, `HaldirSession` +
35+
`HaldirSecrets` still work standalone. Lazy import keeps the package
36+
importable even before `pip install crewai` so the session-level
37+
surface is testable in CI without the heavy dep chain."""
38+
39+
from .session import HaldirSession, create_session
3540
from .secrets import HaldirSecrets
3641

3742
__all__ = [
43+
"HaldirSession",
3844
"create_session",
3945
"GovernedTool",
4046
"HaldirSecrets",
4147
]
4248

43-
__version__ = "0.1.0"
49+
__version__ = "0.2.0"
50+
51+
52+
def __getattr__(name: str):
53+
if name == "GovernedTool":
54+
from .governed_tool import GovernedTool
55+
return GovernedTool
56+
raise AttributeError(f"module 'crewai_haldir' has no attribute {name!r}")

integrations/crewai-haldir/crewai_haldir/session.py

Lines changed: 181 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,196 @@
1-
"""Helper to create a Haldir session for a CrewAI run."""
1+
"""
2+
HaldirSession for CrewAI — context manager that auto-mints + auto-revokes.
3+
4+
The two-line promise::
5+
6+
from crewai_haldir import HaldirSession
7+
8+
with HaldirSession.for_agent("research-crew",
9+
scopes=["read", "search"],
10+
spend_limit=10.0) as haldir:
11+
# Use haldir.client + haldir.session_id to wrap your
12+
# CrewAI tools with GovernedTool.wrap(...). Session is
13+
# auto-revoked on scope exit, even on exception.
14+
...
15+
# After crew.kickoff(), stamp the run's output with the
16+
# current STH so an auditor can verify later:
17+
# result = haldir.stamp_sth(crew.kickoff())
18+
19+
Why it exists:
20+
21+
CrewAI doesn't have a single BaseCallbackHandler primitive the way
22+
LangChain does, so the integration lives at the TOOL level
23+
(GovernedTool.wrap) and the SESSION level (this module). The
24+
session handles lifecycle + ergonomics; GovernedTool handles the
25+
per-call scope check + audit write.
26+
"""
227

328
from __future__ import annotations
429

5-
from typing import Optional
30+
import os
31+
from types import TracebackType
32+
from typing import Any
633

734
from sdk.client import HaldirClient
835

936

37+
class HaldirSession:
38+
"""Haldir session lifecycle scoped to a CrewAI run.
39+
40+
Usage::
41+
42+
with HaldirSession.for_agent("my-crew") as haldir:
43+
search_tool = GovernedTool.wrap(
44+
SerperDevTool(),
45+
client=haldir.client,
46+
session_id=haldir.session_id,
47+
required_scope="search",
48+
cost_usd=0.01,
49+
)
50+
crew = Crew(agents=[...], tasks=[...])
51+
result = haldir.stamp_sth(crew.kickoff())
52+
53+
``result`` now carries an `_haldir_sth` attribute (if it's a dict or
54+
has ``__dict__``) with the tenant's current Signed Tree Head.
55+
"""
56+
57+
def __init__(
58+
self,
59+
*,
60+
api_key: str | None = None,
61+
base_url: str | None = None,
62+
agent_id: str,
63+
scopes: list[str] | None = None,
64+
ttl: int = 3600,
65+
spend_limit: float | None = None,
66+
) -> None:
67+
api_key = api_key or os.environ.get("HALDIR_API_KEY", "").strip()
68+
base_url = base_url or os.environ.get(
69+
"HALDIR_BASE_URL", "https://haldir.xyz",
70+
)
71+
if not api_key:
72+
raise RuntimeError(
73+
"HaldirSession requires an API key. Pass `api_key=...` "
74+
"or set HALDIR_API_KEY in the environment."
75+
)
76+
self._agent_id = agent_id
77+
self._scopes = scopes
78+
self._ttl = ttl
79+
self._spend_limit = spend_limit
80+
self.client = HaldirClient(api_key=api_key, base_url=base_url)
81+
self.session_id: str | None = None
82+
83+
@classmethod
84+
def for_agent(
85+
cls,
86+
agent_id: str,
87+
*,
88+
scopes: list[str] | None = None,
89+
spend_limit: float | None = None,
90+
**kwargs: Any,
91+
) -> "HaldirSession":
92+
"""Shortest entry point. Reads HALDIR_API_KEY + HALDIR_BASE_URL
93+
from env."""
94+
return cls(
95+
agent_id=agent_id,
96+
scopes=scopes,
97+
spend_limit=spend_limit,
98+
**kwargs,
99+
)
100+
101+
# ── Context-manager protocol ────────────────────────────────────
102+
103+
def __enter__(self) -> "HaldirSession":
104+
session = self.client.create_session(
105+
self._agent_id,
106+
scopes=self._scopes,
107+
ttl=self._ttl,
108+
spend_limit=self._spend_limit,
109+
)
110+
self.session_id = session["session_id"]
111+
return self
112+
113+
def __exit__(
114+
self,
115+
exc_type: type[BaseException] | None,
116+
exc: BaseException | None,
117+
tb: TracebackType | None,
118+
) -> None:
119+
"""Always revoke on scope exit, even on exception. A revoke
120+
failure MUST NOT mask the user's exception."""
121+
if self.session_id is None:
122+
return
123+
try:
124+
self.client.revoke_session(self.session_id)
125+
except Exception:
126+
pass
127+
finally:
128+
self.session_id = None
129+
130+
# ── Ergonomic helpers ────────────────────────────────────────────
131+
132+
def spend_summary(self) -> dict[str, Any]:
133+
"""Current spend on this session."""
134+
if not self.session_id:
135+
return {"total_usd": 0.0, "action_count": 0}
136+
return self.client.get_spend(session_id=self.session_id)
137+
138+
def audit_trail(self, limit: int = 100) -> dict[str, Any]:
139+
"""Every audit entry written under this session."""
140+
if not self.session_id:
141+
return {"entries": [], "count": 0}
142+
return self.client.get_audit_trail(
143+
session_id=self.session_id, limit=limit,
144+
)
145+
146+
def current_sth(self) -> dict[str, Any]:
147+
"""Fetch the tenant's current Signed Tree Head. Pin it for
148+
later offline verification."""
149+
return self.client.get_tree_head()
150+
151+
def stamp_sth(self, result: Any) -> Any:
152+
"""Attach the current STH to a CrewAI run result so an auditor
153+
can pin it.
154+
155+
Behaviour depends on the shape `crew.kickoff()` returned:
156+
157+
- dict: writes result["_haldir_sth"] = sth
158+
- object with __dict__: sets result._haldir_sth = sth
159+
- anything else: wraps into a new dict
160+
{"output": result, "_haldir_sth": sth}
161+
162+
Returns the (possibly-wrapped) result unchanged in identity
163+
whenever possible. Network errors fetching the STH never
164+
break the caller's output path — we return the original
165+
result unmodified if the fetch fails."""
166+
try:
167+
sth = self.current_sth()
168+
except Exception:
169+
return result
170+
if isinstance(result, dict):
171+
result["_haldir_sth"] = sth
172+
return result
173+
if hasattr(result, "__dict__"):
174+
try:
175+
setattr(result, "_haldir_sth", sth)
176+
return result
177+
except Exception:
178+
pass
179+
return {"output": result, "_haldir_sth": sth}
180+
181+
182+
# ── Legacy helper ─────────────────────────────────────────────────
183+
10184
def create_session(
11185
api_key: str,
12186
agent_id: str,
13-
scopes: Optional[list[str]] = None,
14-
ttl: Optional[int] = None,
15-
spend_limit: Optional[float] = None,
187+
scopes: list[str] | None = None,
188+
ttl: int | None = None,
189+
spend_limit: float | None = None,
16190
base_url: str = "https://haldir.xyz",
17191
) -> tuple[HaldirClient, str]:
18-
"""Create a Haldir client and a scoped session in one step.
19-
20-
Returns:
21-
(client, session_id) — pass both into GovernedTool.wrap and
22-
HaldirSecrets so they can enforce and log against the same session.
23-
"""
192+
"""Legacy helper — returns (client, session_id). New code should
193+
prefer HaldirSession. Kept for back-compat with v0.1 callers."""
24194
client = HaldirClient(api_key=api_key, base_url=base_url)
25195
session = client.create_session(
26196
agent_id=agent_id,

0 commit comments

Comments
 (0)