firstops 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
firstops/events.py ADDED
@@ -0,0 +1,195 @@
1
+ """Action-event model and wire (de)serialization for hook evaluation.
2
+
3
+ Mirrors the backend ``hookwire`` JSON contract exactly (see
4
+ ``backend/shared/lib/hookwire/types.go``):
5
+
6
+ request : event_type, agent, tool_name, tool_input(obj), tool_output(obj),
7
+ session_id, cwd, channel, mcp{server,tool,url}, cli_version
8
+ response: decision, reason, modified_payload(base64 bytes), policy_id
9
+
10
+ Principal and tenant are resolved server-side from the DPoP JWK thumbprint and
11
+ are never sent in the body.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ # --- channel constants (match enforcement/types.go Channel) ---
22
+ CHANNEL_MCP = "mcp"
23
+ CHANNEL_SYSTEM_TOOLS = "system_tools"
24
+ CHANNEL_LLM = "llm"
25
+
26
+ # --- event types (match enforcement EventType) ---
27
+ EVENT_PRE_TOOL_USE = "pre_tool_use"
28
+ EVENT_POST_TOOL_USE = "post_tool_use"
29
+
30
+ # --- decisions (match hookwire response decision) ---
31
+ DECISION_ALLOW = "allow"
32
+ DECISION_DENY = "deny"
33
+ DECISION_ASK = "ask"
34
+ DECISION_MODIFY = "modify"
35
+ _KNOWN_DECISIONS = frozenset(
36
+ {DECISION_ALLOW, DECISION_DENY, DECISION_ASK, DECISION_MODIFY}
37
+ )
38
+
39
+ # Producer label for the `agent` field. The backend normalizer
40
+ # (FromHookRequest) maps this to audit Source `SourceSDK` ("sdk") per
41
+ # design-doc §7, so SDK actions are attributed correctly in audit. Keep this
42
+ # value in sync with the normalizer's `req.Agent == "firstops-sdk"` case.
43
+ SDK_AGENT_LABEL = "firstops-sdk"
44
+
45
+
46
+ @dataclass
47
+ class MCPInfo:
48
+ """MCP-specific metadata; populated only when ``channel == "mcp"``."""
49
+
50
+ server: str = ""
51
+ tool: str = ""
52
+ url: str = ""
53
+
54
+ def to_wire(self) -> dict[str, str]:
55
+ out: dict[str, str] = {}
56
+ if self.server:
57
+ out["server"] = self.server
58
+ if self.tool:
59
+ out["tool"] = self.tool
60
+ if self.url:
61
+ out["url"] = self.url
62
+ return out
63
+
64
+
65
+ @dataclass
66
+ class ActionEvent:
67
+ """A single governable action, serializable to the hookwire request shape."""
68
+
69
+ event_type: str
70
+ tool_name: str
71
+ channel: str = ""
72
+ tool_input: dict[str, Any] | None = None
73
+ tool_output: dict[str, Any] | None = None
74
+ agent: str = SDK_AGENT_LABEL
75
+ session_id: str = ""
76
+ cwd: str = ""
77
+ mcp: MCPInfo | None = None
78
+ cli_version: str = ""
79
+ # The producer can apply a request-path modification before the action runs
80
+ # (decorator rebind / Claude updatedInput / LangGraph arg rewrite / LLM body
81
+ # rewrite). When True, sentinel ships `modify` for outbound scrub instead of
82
+ # escalating to deny. Adapters that CAN'T mutate (OpenAI guardrails) leave
83
+ # this False.
84
+ producer_can_apply_modify: bool = False
85
+ # Free-form producer metadata (e.g. {"harness": "langgraph"}). Merged into
86
+ # the event metadata server-side and surfaced in audit.
87
+ metadata: dict[str, str] | None = None
88
+
89
+ def to_wire(self) -> dict[str, Any]:
90
+ body: dict[str, Any] = {
91
+ "event_type": self.event_type,
92
+ "agent": self.agent,
93
+ "tool_name": self.tool_name,
94
+ }
95
+ # tool_input/output are JSON objects (not stringified) so nested
96
+ # structure is preserved end-to-end.
97
+ if self.tool_input is not None:
98
+ body["tool_input"] = self.tool_input
99
+ if self.tool_output is not None:
100
+ body["tool_output"] = self.tool_output
101
+ if self.session_id:
102
+ body["session_id"] = self.session_id
103
+ if self.cwd:
104
+ body["cwd"] = self.cwd
105
+ if self.channel:
106
+ body["channel"] = self.channel
107
+ if self.mcp is not None:
108
+ mcp_wire = self.mcp.to_wire()
109
+ if mcp_wire:
110
+ body["mcp"] = mcp_wire
111
+ if self.cli_version:
112
+ body["cli_version"] = self.cli_version
113
+ if self.producer_can_apply_modify:
114
+ body["producer_can_apply_modify"] = True
115
+ if self.metadata:
116
+ body["metadata"] = self.metadata
117
+ return body
118
+
119
+
120
+ @dataclass
121
+ class Decision:
122
+ """The enforcement verdict for an action."""
123
+
124
+ action: str = DECISION_ALLOW
125
+ reason: str = ""
126
+ modified_payload: bytes | None = None
127
+ policy_id: str = ""
128
+ failed_open: bool = False # True when we allowed due to an infra failure
129
+
130
+ @property
131
+ def blocked(self) -> bool:
132
+ return self.action == DECISION_DENY
133
+
134
+ @property
135
+ def modified(self) -> bool:
136
+ return self.action == DECISION_MODIFY and self.modified_payload is not None
137
+
138
+ @classmethod
139
+ def from_wire(cls, data: dict[str, Any]) -> Decision:
140
+ action = data.get("decision") or DECISION_ALLOW
141
+ reason = data.get("reason", "") or ""
142
+ policy_id = data.get("policy_id", "") or ""
143
+
144
+ # An unrecognized verdict must not silently behave like a clean allow:
145
+ # fail open (never block on a verdict we can't act on) but set the
146
+ # failed_open flag so it is visible downstream, not indistinguishable
147
+ # from a real allow.
148
+ if action not in _KNOWN_DECISIONS:
149
+ return cls(
150
+ action=DECISION_ALLOW,
151
+ reason=f"unknown decision {action!r}: {reason}".rstrip(": "),
152
+ policy_id=policy_id,
153
+ failed_open=True,
154
+ )
155
+
156
+ raw = data.get("modified_payload")
157
+ payload: bytes | None = None
158
+ if raw:
159
+ try:
160
+ # Go marshals []byte as a base64 string. validate=True so a
161
+ # corrupted payload raises instead of silently decoding to
162
+ # wrong bytes that would then be applied to a live call.
163
+ payload = (
164
+ base64.b64decode(raw, validate=True)
165
+ if isinstance(raw, str)
166
+ else bytes(raw)
167
+ )
168
+ except (binascii.Error, ValueError):
169
+ return cls(
170
+ action=DECISION_ALLOW,
171
+ reason=f"invalid modified_payload: {reason}".rstrip(": "),
172
+ policy_id=policy_id,
173
+ failed_open=True,
174
+ )
175
+
176
+ # A modify verdict with no usable payload is malformed: fail open
177
+ # visibly rather than report a "modify" the caller can't apply.
178
+ if action == DECISION_MODIFY and payload is None:
179
+ return cls(
180
+ action=DECISION_ALLOW,
181
+ reason=f"modify without payload: {reason}".rstrip(": "),
182
+ policy_id=policy_id,
183
+ failed_open=True,
184
+ )
185
+
186
+ return cls(
187
+ action=action,
188
+ reason=reason,
189
+ modified_payload=payload,
190
+ policy_id=policy_id,
191
+ )
192
+
193
+ @classmethod
194
+ def fail_open(cls, reason: str) -> Decision:
195
+ return cls(action=DECISION_ALLOW, reason=reason, failed_open=True)
@@ -0,0 +1,12 @@
1
+ """Harness adapters — one-touch governance for supported agent frameworks.
2
+
3
+ Each adapter wires the FirstOps enforcement spine into a framework's official,
4
+ intervention-capable extension point:
5
+
6
+ - ``firstops.integrations.claude`` — Claude Agent SDK ``PreToolUse`` hook
7
+ - ``firstops.integrations.langgraph`` — LangGraph agent middleware (wrap_tool_call)
8
+ - ``firstops.integrations.openai_agents`` — OpenAI Agents SDK tool guardrails
9
+
10
+ Adapters import their framework lazily, so importing this package never
11
+ requires the frameworks to be installed.
12
+ """
@@ -0,0 +1,132 @@
1
+ """Shared governance core for harness adapters.
2
+
3
+ The adapters all reduce to: build a pre_tool_use event, ask sentinel, translate
4
+ the Decision into the framework's native verb (deny / mutate / allow). That
5
+ reduction lives here so every adapter shares one tested implementation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from firstops.channels import classify, mcp_info
14
+ from firstops.events import CHANNEL_MCP, EVENT_PRE_TOOL_USE, ActionEvent, Decision
15
+
16
+ # A neutral decision vocabulary the adapters translate into framework types.
17
+ ACTION_ALLOW = "allow"
18
+ ACTION_DENY = "deny"
19
+ ACTION_MODIFY = "modify"
20
+
21
+ # Harness identifiers stamped into event metadata (audit/filtering).
22
+ HARNESS_LANGGRAPH = "langgraph"
23
+ HARNESS_CLAUDE = "claude-agent-sdk"
24
+ HARNESS_OPENAI_AGENTS = "openai-agents"
25
+
26
+
27
+ def _json_safe(value: Any) -> Any:
28
+ """Return a JSON-serializable view of ``value`` (str fallback)."""
29
+ try:
30
+ json.dumps(value)
31
+ return value
32
+ except (TypeError, ValueError):
33
+ return str(value)
34
+
35
+
36
+ def coerce_input(tool_input: Any) -> dict[str, Any]:
37
+ """Normalize a framework's tool input into a JSON-able dict for the event.
38
+
39
+ Never raises and always returns a JSON-serializable dict (bytes decode with
40
+ ``errors="replace"``; non-serializable objects are stringified) so a tool
41
+ passing binary/exotic args can't crash the agent loop.
42
+ """
43
+ if isinstance(tool_input, dict):
44
+ return {k: _json_safe(v) for k, v in tool_input.items()}
45
+ if isinstance(tool_input, bytes):
46
+ tool_input = tool_input.decode("utf-8", errors="replace")
47
+ if isinstance(tool_input, str):
48
+ try:
49
+ parsed = json.loads(tool_input)
50
+ if isinstance(parsed, dict):
51
+ return {k: _json_safe(v) for k, v in parsed.items()}
52
+ except (ValueError, TypeError):
53
+ pass
54
+ return {"input": tool_input}
55
+ if tool_input is None:
56
+ return {}
57
+ return {"input": _json_safe(tool_input)}
58
+
59
+
60
+ def govern_tool(
61
+ rt,
62
+ tool_name: str,
63
+ tool_input: Any,
64
+ can_apply_modify: bool = False,
65
+ harness: str = "",
66
+ ) -> Decision:
67
+ """Evaluate a tool call via the enforcement spine. Allows if no runtime.
68
+
69
+ ``can_apply_modify``: True when the adapter can rewrite the tool args before
70
+ execution (Claude updatedInput, LangGraph arg rewrite) — lets sentinel ship
71
+ a request-path scrub as ``modify`` instead of escalating to deny. Adapters
72
+ that can't mutate (OpenAI guardrails) leave it False.
73
+
74
+ ``harness``: the producing framework, stamped into event metadata when known.
75
+
76
+ Hardened to never raise into the agent loop: a None/odd tool_name and
77
+ non-serializable inputs are coerced rather than propagated.
78
+ """
79
+ if rt is None:
80
+ return Decision(action="allow")
81
+ name = tool_name or ""
82
+ channel = classify(name)
83
+ event = ActionEvent(
84
+ event_type=EVENT_PRE_TOOL_USE,
85
+ tool_name=name,
86
+ channel=channel,
87
+ # coerce_input already returns a fresh dict — no aliasing of the
88
+ # caller's live args (which frameworks may mutate after the call).
89
+ tool_input=coerce_input(tool_input),
90
+ mcp=mcp_info(name) if channel == CHANNEL_MCP else None,
91
+ producer_can_apply_modify=can_apply_modify,
92
+ metadata={"harness": harness} if harness else None,
93
+ )
94
+ return rt.enforcement.evaluate(event)
95
+
96
+
97
+ def modified_input(decision: Decision) -> dict[str, Any] | None:
98
+ """Return the scrubbed input dict from a modify decision, or None."""
99
+ if decision.modified and decision.modified_payload:
100
+ try:
101
+ value = json.loads(decision.modified_payload)
102
+ if isinstance(value, dict):
103
+ return value
104
+ except (ValueError, TypeError):
105
+ pass
106
+ return None
107
+
108
+
109
+ def decide(
110
+ rt,
111
+ tool_name: str,
112
+ tool_input: Any,
113
+ can_apply_modify: bool = False,
114
+ harness: str = "",
115
+ ) -> tuple[str, Any]:
116
+ """Reduce a tool call to ``(action, payload)``:
117
+
118
+ - ``("deny", reason)`` — block the call
119
+ - ``("modify", new_input_dict)`` — proceed with scrubbed input
120
+ - ``("allow", None)`` — proceed unchanged
121
+
122
+ ``can_apply_modify`` and ``harness`` are forwarded to the event (see govern_tool).
123
+ """
124
+ decision = govern_tool(
125
+ rt, tool_name, tool_input, can_apply_modify=can_apply_modify, harness=harness
126
+ )
127
+ if decision.blocked:
128
+ return ACTION_DENY, decision.reason or "blocked by FirstOps policy"
129
+ new_input = modified_input(decision)
130
+ if new_input is not None:
131
+ return ACTION_MODIFY, new_input
132
+ return ACTION_ALLOW, None
@@ -0,0 +1,84 @@
1
+ """Claude Agent SDK adapter — the daemon model, in-process.
2
+
3
+ A single ``PreToolUse`` hook governs every tool the agent calls: built-ins
4
+ (``Bash``, ``Write``, ``WebFetch``), MCP tools (``mcp__*``), and in-process
5
+ SDK-MCP tools — with both block (deny) and argument rewrite (updatedInput).
6
+
7
+ Usage::
8
+
9
+ from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
10
+ from firstops.integrations.claude import firstops_hooks
11
+
12
+ options = ClaudeAgentOptions(hooks=firstops_hooks(fo))
13
+ async with ClaudeSDKClient(options=options) as client:
14
+ ...
15
+
16
+ Targets the Claude Agent SDK PreToolUse hook contract (hookSpecificOutput /
17
+ permissionDecision). The governance logic (`_govern_pre_tool_use`) is
18
+ framework-free and unit-tested; `firstops_hooks` is the thin lazily-imported
19
+ shell.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ from firstops import _runtime
27
+ from firstops.integrations._common import (
28
+ ACTION_DENY,
29
+ ACTION_MODIFY,
30
+ HARNESS_CLAUDE,
31
+ decide,
32
+ )
33
+
34
+
35
+ def _govern_pre_tool_use(rt, input_data: dict[str, Any]) -> dict[str, Any]:
36
+ """Map a PreToolUse hook input to a hook response. Pure / testable.
37
+
38
+ Returns ``{}`` (allow) when there's nothing to do, a deny envelope, or an
39
+ allow-with-updatedInput envelope for a scrub.
40
+ """
41
+ tool_name = input_data.get("tool_name", "") or ""
42
+ tool_input = input_data.get("tool_input")
43
+ # Claude PreToolUse updatedInput can rewrite args → request-path scrub applies.
44
+ action, payload = decide(
45
+ rt, tool_name, tool_input, can_apply_modify=True, harness=HARNESS_CLAUDE
46
+ )
47
+ if action == ACTION_DENY:
48
+ return {
49
+ "hookSpecificOutput": {
50
+ "hookEventName": "PreToolUse",
51
+ "permissionDecision": "deny",
52
+ "permissionDecisionReason": payload,
53
+ }
54
+ }
55
+ # Only emit an updatedInput envelope when there's a non-empty replacement —
56
+ # Claude's updatedInput is a FULL replacement of tool_input, so an empty
57
+ # dict would wipe every argument. Empty/odd payload → allow unchanged.
58
+ if action == ACTION_MODIFY and isinstance(payload, dict) and payload:
59
+ return {
60
+ "hookSpecificOutput": {
61
+ "hookEventName": "PreToolUse",
62
+ "permissionDecision": "allow",
63
+ "updatedInput": payload,
64
+ }
65
+ }
66
+ return {}
67
+
68
+
69
+ def firstops_hooks(fo=None) -> dict[str, Any]:
70
+ """Return a Claude Agent SDK ``hooks`` config that governs all tool calls."""
71
+ rt = fo if fo is not None else _runtime.runtime()
72
+ try:
73
+ from claude_agent_sdk import HookMatcher
74
+ except ImportError as e: # pragma: no cover - env-dependent
75
+ raise RuntimeError(
76
+ "claude-agent-sdk is not installed: pip install claude-agent-sdk"
77
+ ) from e
78
+
79
+ async def _pre_tool_use(input_data, tool_use_id, context):
80
+ return _govern_pre_tool_use(rt, input_data)
81
+
82
+ # matcher=None is the match-ALL contract; "*" would match only a tool
83
+ # literally named "*" (i.e. nothing) and silently disable governance.
84
+ return {"PreToolUse": [HookMatcher(matcher=None, hooks=[_pre_tool_use])]}
@@ -0,0 +1,87 @@
1
+ """LangGraph adapter — agent middleware that governs every tool call.
2
+
3
+ Built on LangChain v1 agent middleware (`wrap_tool_call`), which can block
4
+ (return a ToolMessage instead of running the tool) and mutate (rewrite the
5
+ tool args). One middleware governs all tools the agent calls, including
6
+ framework built-ins (`ShellTool`, `SQLDatabaseToolkit`, …) the developer never
7
+ authored.
8
+
9
+ Usage::
10
+
11
+ from langchain.agents import create_agent
12
+ from firstops.integrations.langgraph import FirstOpsMiddleware
13
+
14
+ agent = create_agent(model, tools=[...], middleware=[FirstOpsMiddleware(fo)])
15
+
16
+ Coverage honesty: middleware attaches per compiled graph and does NOT
17
+ auto-propagate into subgraphs. A subgraph built without FirstOps middleware is
18
+ ungoverned — wire one per graph. (Detect-and-warn for subgraphs is tracked for
19
+ a later milestone.)
20
+
21
+ The decision logic is `firstops.integrations._common.decide` (framework-free,
22
+ tested); `FirstOpsMiddleware` is the thin lazily-imported shell targeting the
23
+ LangChain v1 middleware API.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from firstops import _runtime
29
+ from firstops.integrations._common import (
30
+ ACTION_DENY,
31
+ ACTION_MODIFY,
32
+ HARNESS_LANGGRAPH,
33
+ decide,
34
+ )
35
+
36
+
37
+ def FirstOpsMiddleware(fo=None):
38
+ """Return a LangChain agent middleware instance that governs tool calls."""
39
+ rt = fo if fo is not None else _runtime.runtime()
40
+ try:
41
+ from langchain.agents.middleware import AgentMiddleware
42
+ from langchain_core.messages import ToolMessage
43
+ except ImportError as e: # pragma: no cover - env-dependent
44
+ raise RuntimeError(
45
+ "langchain/langgraph not installed: pip install langchain langgraph"
46
+ ) from e
47
+
48
+ class _FirstOpsMiddleware(AgentMiddleware):
49
+ def wrap_tool_call(self, request, handler):
50
+ call = getattr(request, "tool_call", None) or {}
51
+ name = call.get("name", "") or ""
52
+ args = call.get("args", {}) or {}
53
+ action, payload = decide(
54
+ rt, name, args, can_apply_modify=True, harness=HARNESS_LANGGRAPH
55
+ )
56
+ if action == ACTION_DENY:
57
+ return ToolMessage(
58
+ content=f"blocked by FirstOps policy: {payload}",
59
+ tool_call_id=call.get("id", ""),
60
+ status="error",
61
+ )
62
+ if action == ACTION_MODIFY:
63
+ request = request.override(
64
+ tool_call={**request.tool_call, "args": payload}
65
+ )
66
+ return handler(request)
67
+
68
+ async def awrap_tool_call(self, request, handler):
69
+ call = getattr(request, "tool_call", None) or {}
70
+ name = call.get("name", "") or ""
71
+ args = call.get("args", {}) or {}
72
+ action, payload = decide(
73
+ rt, name, args, can_apply_modify=True, harness=HARNESS_LANGGRAPH
74
+ )
75
+ if action == ACTION_DENY:
76
+ return ToolMessage(
77
+ content=f"blocked by FirstOps policy: {payload}",
78
+ tool_call_id=call.get("id", ""),
79
+ status="error",
80
+ )
81
+ if action == ACTION_MODIFY:
82
+ request = request.override(
83
+ tool_call={**request.tool_call, "args": payload}
84
+ )
85
+ return await handler(request)
86
+
87
+ return _FirstOpsMiddleware()
@@ -0,0 +1,87 @@
1
+ """OpenAI Agents SDK adapter — tool input guardrails.
2
+
3
+ Uses the SDK's tool input guardrail to block a tool call before it runs.
4
+
5
+ **Attachment is per-tool, not per-agent.** OpenAI Agents has no agent-level
6
+ tool-input guardrail — `tool_input_guardrails` is a field on `@function_tool` /
7
+ `FunctionTool`. So wire the FirstOps guardrail onto each function tool::
8
+
9
+ from agents import function_tool
10
+ from firstops.integrations.openai_agents import firstops_tool_input_guardrail
11
+
12
+ guard = firstops_tool_input_guardrail(fo)
13
+
14
+ @function_tool(tool_input_guardrails=[guard])
15
+ def send_email(to: str, body: str): ...
16
+
17
+ Capability note (honest): OpenAI Agents tool guardrails are **read-only** — they
18
+ can block but cannot mutate tool arguments. So argument *scrub* is not available
19
+ through this adapter; a ``modify`` decision degrades to allow here. To scrub tool
20
+ args on OpenAI Agents, wrap the underlying function with ``@firstops.tool``
21
+ instead (the base-API decorator).
22
+
23
+ The decision logic (`_decide_tool`) is framework-free and tested; the guardrail
24
+ wiring is the thin lazily-imported shell.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Any
30
+
31
+ from firstops import _runtime
32
+ from firstops.integrations._common import (
33
+ ACTION_DENY,
34
+ ACTION_MODIFY,
35
+ HARNESS_OPENAI_AGENTS,
36
+ decide,
37
+ )
38
+
39
+
40
+ def _decide_tool(rt, tool_name: str, tool_input: Any) -> tuple[bool, str]:
41
+ """Return ``(blocked, reason)`` for a tool call.
42
+
43
+ Guardrails can't mutate args (read-only). We leave ``can_apply_modify``
44
+ False, so sentinel escalates a request-path scrub to deny — but defensively,
45
+ if a ``modify`` ever reaches here we **block** (fail closed) rather than let
46
+ unscrubbed arguments through. To scrub on OpenAI Agents, use ``@firstops.tool``.
47
+ """
48
+ action, payload = decide(rt, tool_name, tool_input, harness=HARNESS_OPENAI_AGENTS)
49
+ if action == ACTION_DENY:
50
+ return True, str(payload)
51
+ if action == ACTION_MODIFY:
52
+ return True, (
53
+ "scrub required but tool-arg rewrite is unsupported on OpenAI Agents "
54
+ "(use @firstops.tool to scrub)"
55
+ )
56
+ return False, ""
57
+
58
+
59
+ def firstops_tool_input_guardrail(fo=None):
60
+ """Return a single reusable tool-input guardrail to attach per function tool.
61
+
62
+ Pass it in each tool's ``tool_input_guardrails=[...]`` list.
63
+ """
64
+ rt = fo if fo is not None else _runtime.runtime()
65
+ try:
66
+ from agents import tool_input_guardrail
67
+ from agents.tool_guardrails import ToolGuardrailFunctionOutput
68
+ except ImportError as e: # pragma: no cover - env-dependent
69
+ raise RuntimeError(
70
+ "openai-agents not installed: pip install openai-agents"
71
+ ) from e
72
+
73
+ @tool_input_guardrail
74
+ async def _fo_tool_input_guardrail(data):
75
+ # ToolInputGuardrailData carries .context (ToolContext) and .agent.
76
+ # tool_arguments is a raw JSON string; coerce_input handles that.
77
+ ctx = getattr(data, "context", None)
78
+ tool_name = getattr(ctx, "tool_name", "") or ""
79
+ tool_args = getattr(ctx, "tool_arguments", None)
80
+ blocked, reason = _decide_tool(rt, tool_name, tool_args)
81
+ if blocked:
82
+ return ToolGuardrailFunctionOutput.reject_content(
83
+ message=f"blocked by FirstOps policy: {reason}"
84
+ )
85
+ return ToolGuardrailFunctionOutput.allow()
86
+
87
+ return _fo_tool_input_guardrail
firstops/llm.py ADDED
@@ -0,0 +1,51 @@
1
+ """Convenience helpers for pointing LLM clients at the sidecar chain-link.
2
+
3
+ These are optional sugar over :func:`firstops.llm_base_url`. The SDK does not
4
+ depend on ``openai`` / ``anthropic`` — the client factories import them lazily
5
+ and raise a clear error if the package is absent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ from firstops._runtime import llm_base_url
14
+
15
+
16
+ def openai_client(**kwargs: Any):
17
+ """Return an ``openai.OpenAI`` client pointed at the sidecar.
18
+
19
+ Pass your real ``api_key`` as usual (or set ``OPENAI_API_KEY``) — it is
20
+ forwarded verbatim to the upstream; FirstOps never stores it.
21
+ """
22
+ try:
23
+ import openai
24
+ except ImportError as e: # pragma: no cover - env-dependent
25
+ raise RuntimeError("openai is not installed: pip install openai") from e
26
+ kwargs.setdefault("base_url", llm_base_url("openai"))
27
+ return openai.OpenAI(**kwargs)
28
+
29
+
30
+ def anthropic_client(**kwargs: Any):
31
+ """Return an ``anthropic.Anthropic`` client pointed at the sidecar."""
32
+ try:
33
+ import anthropic
34
+ except ImportError as e: # pragma: no cover - env-dependent
35
+ raise RuntimeError("anthropic is not installed: pip install anthropic") from e
36
+ kwargs.setdefault("base_url", llm_base_url("anthropic"))
37
+ return anthropic.Anthropic(**kwargs)
38
+
39
+
40
+ def configure_llm_env() -> dict[str, str]:
41
+ """Set ``OPENAI_BASE_URL`` / ``ANTHROPIC_BASE_URL`` to the sidecar.
42
+
43
+ For frameworks that construct their own LLM client internally and only
44
+ honor the env vars. Returns the variables it set.
45
+ """
46
+ env = {
47
+ "OPENAI_BASE_URL": llm_base_url("openai"),
48
+ "ANTHROPIC_BASE_URL": llm_base_url("anthropic"),
49
+ }
50
+ os.environ.update(env)
51
+ return env