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/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""FirstOps SDK — secure MCP proxy sidecar with DPoP authentication and management API."""
|
|
2
|
+
|
|
3
|
+
from firstops._runtime import (
|
|
4
|
+
Runtime,
|
|
5
|
+
init,
|
|
6
|
+
llm_base_url,
|
|
7
|
+
mcp_url,
|
|
8
|
+
runtime,
|
|
9
|
+
shutdown,
|
|
10
|
+
)
|
|
11
|
+
from firstops.client import (
|
|
12
|
+
Agent,
|
|
13
|
+
Connection,
|
|
14
|
+
FirstOps,
|
|
15
|
+
FirstOpsError,
|
|
16
|
+
ParamDefinition,
|
|
17
|
+
ServerTemplate,
|
|
18
|
+
)
|
|
19
|
+
from firstops.coverage import capability, coverage_report, ungoverned_tools
|
|
20
|
+
from firstops.enforcement import EnforcementClient
|
|
21
|
+
from firstops.events import ActionEvent, Decision
|
|
22
|
+
from firstops.llm import anthropic_client, configure_llm_env, openai_client
|
|
23
|
+
from firstops.proxy import current_agent_id, is_running
|
|
24
|
+
from firstops.tools import FirstOpsPolicyError, tool
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# management client
|
|
28
|
+
"FirstOps",
|
|
29
|
+
"FirstOpsError",
|
|
30
|
+
"Agent",
|
|
31
|
+
"Connection",
|
|
32
|
+
"ServerTemplate",
|
|
33
|
+
"ParamDefinition",
|
|
34
|
+
# runtime
|
|
35
|
+
"init",
|
|
36
|
+
"shutdown",
|
|
37
|
+
"runtime",
|
|
38
|
+
"Runtime",
|
|
39
|
+
"is_running",
|
|
40
|
+
"current_agent_id",
|
|
41
|
+
# base API — tool governance
|
|
42
|
+
"tool",
|
|
43
|
+
"FirstOpsPolicyError",
|
|
44
|
+
# base API — LLM chain-link + MCP
|
|
45
|
+
"llm_base_url",
|
|
46
|
+
"mcp_url",
|
|
47
|
+
"openai_client",
|
|
48
|
+
"anthropic_client",
|
|
49
|
+
"configure_llm_env",
|
|
50
|
+
# enforcement surface
|
|
51
|
+
"EnforcementClient",
|
|
52
|
+
"ActionEvent",
|
|
53
|
+
"Decision",
|
|
54
|
+
# coverage honesty
|
|
55
|
+
"capability",
|
|
56
|
+
"coverage_report",
|
|
57
|
+
"ungoverned_tools",
|
|
58
|
+
]
|
firstops/_identity.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared agent identity — the loaded key, DPoP signer, and gateway URL.
|
|
2
|
+
|
|
3
|
+
Established once per process and shared by the MCP sidecar proxy and the
|
|
4
|
+
enforcement (EvaluateHook) client, so a single agent identity backs every
|
|
5
|
+
signed request the SDK makes. Key material never leaves this object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
14
|
+
|
|
15
|
+
from firstops.dpop import create_proof, jwk_thumbprint, load_private_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Identity:
|
|
20
|
+
"""An agent's signing identity. Construct via :func:`build_identity`."""
|
|
21
|
+
|
|
22
|
+
agent_id: str
|
|
23
|
+
gateway_url: str # normalized, no trailing slash
|
|
24
|
+
_key: ec.EllipticCurvePrivateKey
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def bearer_token(self) -> str:
|
|
28
|
+
return f"fo_agent_{self.agent_id}"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def jkt(self) -> str:
|
|
32
|
+
"""RFC 7638 JWK thumbprint of this identity's key."""
|
|
33
|
+
return jwk_thumbprint(self._key)
|
|
34
|
+
|
|
35
|
+
def proof(self, method: str, url: str) -> str:
|
|
36
|
+
"""Create a DPoP proof (RFC 9449) for an HTTP method + target URL.
|
|
37
|
+
|
|
38
|
+
The htu claim is the URL with query and fragment stripped, matching the
|
|
39
|
+
gateway's **byte-exact** htu binding. We strip via string slicing (not a
|
|
40
|
+
urlparse round-trip) so we do NOT alter scheme/host casing — any
|
|
41
|
+
normalization the SDK applies that sentinel does not would 401 every
|
|
42
|
+
proof, which `evaluate()` silently converts to a fail-open allow.
|
|
43
|
+
"""
|
|
44
|
+
htu = url.split("#", 1)[0].split("?", 1)[0]
|
|
45
|
+
return create_proof(self._key, method, htu)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_identity(agent_id: str, private_key_pem: str, gateway_url: str) -> Identity:
|
|
49
|
+
"""Load the key and validate the gateway URL, returning an Identity.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: if ``gateway_url`` is malformed or the key is not P-256.
|
|
53
|
+
"""
|
|
54
|
+
gateway = gateway_url.rstrip("/")
|
|
55
|
+
parsed = urlparse(gateway)
|
|
56
|
+
if not parsed.scheme or not parsed.netloc:
|
|
57
|
+
raise ValueError(f"invalid gateway_url: {gateway_url}")
|
|
58
|
+
key = load_private_key(private_key_pem)
|
|
59
|
+
return Identity(agent_id=agent_id, gateway_url=gateway, _key=key)
|
firstops/_runtime.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Process-wide FirstOps runtime — the single entrypoint an agent calls.
|
|
2
|
+
|
|
3
|
+
``firstops.init()`` establishes the agent identity, builds the enforcement
|
|
4
|
+
client (the EvaluateHook spine), and starts the dual-mode sidecar (MCP terminal
|
|
5
|
+
+ LLM chain-link). The returned :class:`Runtime` handle is what tool decorators
|
|
6
|
+
and harness adapters use to forward action events; it is also stored
|
|
7
|
+
process-globally so ``firstops.shutdown()`` works without a handle.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from firstops import proxy
|
|
16
|
+
from firstops._identity import Identity, build_identity
|
|
17
|
+
from firstops.enforcement import EnforcementClient
|
|
18
|
+
|
|
19
|
+
_DEFAULT_PORT = 9322
|
|
20
|
+
_DEFAULT_GATEWAY = "https://api.firstops.dev"
|
|
21
|
+
|
|
22
|
+
# Default LLM upstreams. Override per-provider via ``init(llm_upstreams=...)``
|
|
23
|
+
# to point the chain-link at a customer's existing gateway instead.
|
|
24
|
+
_DEFAULT_LLM_UPSTREAMS = {
|
|
25
|
+
"openai": "https://api.openai.com",
|
|
26
|
+
"anthropic": "https://api.anthropic.com",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_lock = threading.Lock()
|
|
30
|
+
_runtime: Runtime | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Runtime:
|
|
35
|
+
"""Handle to the live FirstOps runtime for this process."""
|
|
36
|
+
|
|
37
|
+
identity: Identity
|
|
38
|
+
enforcement: EnforcementClient
|
|
39
|
+
port: int
|
|
40
|
+
llm_upstreams: dict[str, str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def init(
|
|
44
|
+
agent_id: str,
|
|
45
|
+
private_key_pem: str,
|
|
46
|
+
*,
|
|
47
|
+
port: int = _DEFAULT_PORT,
|
|
48
|
+
gateway_url: str = _DEFAULT_GATEWAY,
|
|
49
|
+
llm_upstreams: dict[str, str] | None = None,
|
|
50
|
+
) -> Runtime:
|
|
51
|
+
"""Establish identity, the enforcement client, and the dual-mode sidecar.
|
|
52
|
+
|
|
53
|
+
Idempotent for the same identity: the sidecar refuses to switch agents
|
|
54
|
+
mid-flight (raises ``RuntimeError``); re-initializing with the same
|
|
55
|
+
parameters returns the existing runtime.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
llm_upstreams: per-provider upstream base URLs for the LLM chain-link.
|
|
59
|
+
Merged over the defaults (openai/anthropic public endpoints). Point
|
|
60
|
+
a provider at your own gateway to chain in front of it.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The process :class:`Runtime` handle.
|
|
64
|
+
"""
|
|
65
|
+
global _runtime
|
|
66
|
+
|
|
67
|
+
gateway = gateway_url.rstrip("/")
|
|
68
|
+
upstreams = dict(_DEFAULT_LLM_UPSTREAMS)
|
|
69
|
+
if llm_upstreams:
|
|
70
|
+
upstreams.update(llm_upstreams)
|
|
71
|
+
|
|
72
|
+
with _lock:
|
|
73
|
+
if _runtime is not None:
|
|
74
|
+
# Idempotent re-confirm — proxy.init owns the mismatch guard.
|
|
75
|
+
proxy.init(
|
|
76
|
+
agent_id=agent_id,
|
|
77
|
+
private_key_pem=private_key_pem,
|
|
78
|
+
port=port,
|
|
79
|
+
gateway_url=gateway,
|
|
80
|
+
enforcement=_runtime.enforcement,
|
|
81
|
+
llm_upstreams=_runtime.llm_upstreams,
|
|
82
|
+
)
|
|
83
|
+
return _runtime
|
|
84
|
+
|
|
85
|
+
identity = build_identity(agent_id, private_key_pem, gateway)
|
|
86
|
+
enforcement = EnforcementClient(identity)
|
|
87
|
+
proxy.init(
|
|
88
|
+
agent_id=agent_id,
|
|
89
|
+
private_key_pem=private_key_pem,
|
|
90
|
+
port=port,
|
|
91
|
+
gateway_url=gateway,
|
|
92
|
+
enforcement=enforcement,
|
|
93
|
+
llm_upstreams=upstreams,
|
|
94
|
+
)
|
|
95
|
+
_runtime = Runtime(
|
|
96
|
+
identity=identity,
|
|
97
|
+
enforcement=enforcement,
|
|
98
|
+
port=port,
|
|
99
|
+
llm_upstreams=upstreams,
|
|
100
|
+
)
|
|
101
|
+
return _runtime
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def shutdown() -> None:
|
|
105
|
+
"""Stop the sidecar and tear down the runtime. Idempotent.
|
|
106
|
+
|
|
107
|
+
Lifecycle: call at process/agent teardown, **not** concurrently with
|
|
108
|
+
in-flight governed calls. ``EnforcementClient.evaluate`` is fully
|
|
109
|
+
fail-open, so a racing ``evaluate()`` during shutdown degrades to an
|
|
110
|
+
allow (never a crash or hang) — but the supported pattern is init-once,
|
|
111
|
+
run, shutdown-once.
|
|
112
|
+
"""
|
|
113
|
+
global _runtime
|
|
114
|
+
proxy.shutdown()
|
|
115
|
+
with _lock:
|
|
116
|
+
if _runtime is not None:
|
|
117
|
+
_runtime.enforcement.close()
|
|
118
|
+
_runtime = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def runtime() -> Runtime | None:
|
|
122
|
+
"""Return the live runtime, or None if init() has not been called."""
|
|
123
|
+
with _lock:
|
|
124
|
+
return _runtime
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def llm_base_url(provider: str = "openai") -> str:
|
|
128
|
+
"""Return the local sidecar base URL to point an LLM client at.
|
|
129
|
+
|
|
130
|
+
Point your client's ``base_url`` (or ``OPENAI_BASE_URL`` /
|
|
131
|
+
``ANTHROPIC_BASE_URL``) here; the sidecar governs the request and forwards
|
|
132
|
+
to the configured upstream.
|
|
133
|
+
"""
|
|
134
|
+
rt = runtime()
|
|
135
|
+
if rt is None:
|
|
136
|
+
raise RuntimeError("firstops.init() must be called before llm_base_url()")
|
|
137
|
+
base = f"http://127.0.0.1:{rt.port}/llm/{provider}"
|
|
138
|
+
return base + "/v1" if provider == "openai" else base
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def mcp_url(connection_id: str) -> str:
|
|
142
|
+
"""Return the local sidecar URL to point an MCP client at for a connection.
|
|
143
|
+
|
|
144
|
+
The sidecar DPoP-signs each request and forwards it to the FirstOps gateway,
|
|
145
|
+
which brokers the upstream credentials — the agent never holds them.
|
|
146
|
+
"""
|
|
147
|
+
rt = runtime()
|
|
148
|
+
if rt is None:
|
|
149
|
+
raise RuntimeError("firstops.init() must be called before mcp_url()")
|
|
150
|
+
return f"http://127.0.0.1:{rt.port}/mcp/proxy/{connection_id}"
|
firstops/channels.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Channel classification for tool calls.
|
|
2
|
+
|
|
3
|
+
Mirrors the daemon's tool_name → channel mapping so sentinel sees SDK tool
|
|
4
|
+
calls the same way it sees coding-agent hook events. MCP tools (named
|
|
5
|
+
``mcp__<server>__<tool>``) classify as the MCP channel; everything else a
|
|
6
|
+
decorated tool does is ``system_tools``. LLM traffic is stamped ``llm``
|
|
7
|
+
directly by the sidecar's LLM route, not by this classifier.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from firstops.events import CHANNEL_MCP, CHANNEL_SYSTEM_TOOLS, MCPInfo
|
|
13
|
+
|
|
14
|
+
_MCP_PREFIX = "mcp__"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def classify(tool_name: str) -> str:
|
|
18
|
+
"""Return the channel a tool call belongs to.
|
|
19
|
+
|
|
20
|
+
A name is MCP only if it's a *well-formed* ``mcp__<server>__<tool>`` (so
|
|
21
|
+
classify and :func:`mcp_info` always agree — we never emit a ``channel=mcp``
|
|
22
|
+
event with no mcp metadata). A malformed ``mcp__`` name (e.g. ``mcp__foo``)
|
|
23
|
+
is treated as ``system_tools``.
|
|
24
|
+
"""
|
|
25
|
+
if mcp_info(tool_name) is not None:
|
|
26
|
+
return CHANNEL_MCP
|
|
27
|
+
return CHANNEL_SYSTEM_TOOLS
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mcp_info(tool_name: str) -> MCPInfo | None:
|
|
31
|
+
"""Parse ``mcp__<server>__<tool>`` into MCPInfo, or None if not a well-formed MCP name."""
|
|
32
|
+
if not tool_name.startswith(_MCP_PREFIX):
|
|
33
|
+
return None
|
|
34
|
+
parts = tool_name.split("__")
|
|
35
|
+
# Require non-empty server and tool segments.
|
|
36
|
+
if len(parts) >= 3 and parts[1] and "__".join(parts[2:]):
|
|
37
|
+
return MCPInfo(server=parts[1], tool="__".join(parts[2:]))
|
|
38
|
+
return None
|