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 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