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/__init__.py +58 -0
- firstops/_identity.py +59 -0
- firstops/_runtime.py +150 -0
- firstops/channels.py +38 -0
- firstops/client.py +427 -0
- firstops/coverage.py +65 -0
- firstops/dpop.py +78 -0
- firstops/enforcement.py +73 -0
- firstops/events.py +195 -0
- firstops/integrations/__init__.py +12 -0
- firstops/integrations/_common.py +132 -0
- firstops/integrations/claude.py +84 -0
- firstops/integrations/langgraph.py +87 -0
- firstops/integrations/openai_agents.py +87 -0
- firstops/llm.py +51 -0
- firstops/proxy.py +408 -0
- firstops/tools.py +318 -0
- firstops-0.2.0.dist-info/METADATA +160 -0
- firstops-0.2.0.dist-info/RECORD +21 -0
- firstops-0.2.0.dist-info/WHEEL +4 -0
- firstops-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|