xybern 2.0.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.
- xybern/__init__.py +29 -0
- xybern/auto.py +167 -0
- xybern/cli/__init__.py +0 -0
- xybern/cli/__main__.py +158 -0
- xybern/client.py +83 -0
- xybern/config.py +91 -0
- xybern/discovery/__init__.py +109 -0
- xybern/discovery/detectors.py +358 -0
- xybern/exceptions.py +21 -0
- xybern/telemetry.py +52 -0
- xybern-2.0.0.dist-info/METADATA +89 -0
- xybern-2.0.0.dist-info/RECORD +15 -0
- xybern-2.0.0.dist-info/WHEEL +5 -0
- xybern-2.0.0.dist-info/entry_points.txt +2 -0
- xybern-2.0.0.dist-info/top_level.txt +1 -0
xybern/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Xybern — identity & authorisation infrastructure for AI agents.
|
|
3
|
+
|
|
4
|
+
Install once; Xybern discovers every AI agent in your system, gives each a
|
|
5
|
+
cryptographic identity, and (when you turn enforcement on) authorises every
|
|
6
|
+
action before it executes.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
from xybern import auto
|
|
11
|
+
auto.connect() # uses the key from `xybern login`, env, or config
|
|
12
|
+
|
|
13
|
+
# discovers frameworks + agents + tools, registers them to your workspace
|
|
14
|
+
# (each gets a DID), and instruments them in OBSERVE mode by default.
|
|
15
|
+
|
|
16
|
+
CLI::
|
|
17
|
+
|
|
18
|
+
xybern login # device-code browser flow (auto-picks your workspace key)
|
|
19
|
+
xybern agents # dry-run: show what would be discovered
|
|
20
|
+
xybern status
|
|
21
|
+
xybern enforce on # switch from observe to active authorisation
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from . import auto
|
|
25
|
+
from .config import Config
|
|
26
|
+
|
|
27
|
+
__version__ = "2.0.0"
|
|
28
|
+
|
|
29
|
+
__all__ = ["auto", "Config", "__version__"]
|
xybern/auto.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The one-line entrypoint: discover, register (with identity), instrument.
|
|
2
|
+
|
|
3
|
+
from xybern import auto
|
|
4
|
+
auto.connect()
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from . import discovery
|
|
15
|
+
from .client import XybernClient
|
|
16
|
+
from .config import Config
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("xybern")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ConnectResult:
|
|
23
|
+
authenticated: bool
|
|
24
|
+
mode: str
|
|
25
|
+
frameworks: List[str] = field(default_factory=list)
|
|
26
|
+
agents: List[str] = field(default_factory=list)
|
|
27
|
+
instrumented: List[str] = field(default_factory=list)
|
|
28
|
+
workspace: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
def summary(self) -> str:
|
|
31
|
+
lines = [
|
|
32
|
+
f"✓ Detected frameworks: {', '.join(self.frameworks) or 'none'}",
|
|
33
|
+
f"✓ Found {len(self.agents)} agent(s)/tool(s)",
|
|
34
|
+
]
|
|
35
|
+
if self.authenticated:
|
|
36
|
+
lines.append(f"✓ Registered to workspace (each issued an identity)")
|
|
37
|
+
lines.append(f"✓ Mode: {self.mode.upper()}" +
|
|
38
|
+
(" — actions logged, nothing blocked yet" if self.mode == "observe" else " — authorising actions"))
|
|
39
|
+
else:
|
|
40
|
+
lines.append("⚠ Not connected — run `xybern login` (or set XYBERN_API_KEY) to register + govern")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Registrar:
|
|
45
|
+
"""Idempotently maps discovered agents → registered agent_ids (with identity)."""
|
|
46
|
+
def __init__(self, client: XybernClient, cfg: Config):
|
|
47
|
+
self.client = client
|
|
48
|
+
self.cfg = cfg
|
|
49
|
+
self._by_key: Dict[str, str] = {} # name|framework -> agent_id
|
|
50
|
+
self._loaded = False
|
|
51
|
+
|
|
52
|
+
def _key(self, name: str, framework: str) -> str:
|
|
53
|
+
return f"{framework}|{name}".lower()
|
|
54
|
+
|
|
55
|
+
def _load_existing(self):
|
|
56
|
+
if self._loaded or not self.cfg.authenticated:
|
|
57
|
+
return
|
|
58
|
+
self._loaded = True
|
|
59
|
+
try:
|
|
60
|
+
data = self.client.list_agents()
|
|
61
|
+
for a in (data.get("agents") or []):
|
|
62
|
+
self._by_key[self._key(a.get("name", ""), a.get("framework", ""))] = a.get("agent_id")
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def ensure(self, info: "discovery.AgentInfo") -> Optional[str]:
|
|
67
|
+
if not self.cfg.authenticated:
|
|
68
|
+
return None
|
|
69
|
+
self._load_existing()
|
|
70
|
+
k = self._key(info.name, info.framework)
|
|
71
|
+
if k in self._by_key:
|
|
72
|
+
return self._by_key[k]
|
|
73
|
+
try:
|
|
74
|
+
resp = self.client.register_agent(
|
|
75
|
+
name=info.name, framework=info.framework,
|
|
76
|
+
capabilities=info.capabilities or [],
|
|
77
|
+
description=f"Auto-discovered {info.kind} via Xybern SDK",
|
|
78
|
+
)
|
|
79
|
+
agent_id = (resp.get("agent") or {}).get("agent_id")
|
|
80
|
+
if agent_id:
|
|
81
|
+
self._by_key[k] = agent_id
|
|
82
|
+
return agent_id
|
|
83
|
+
except Exception as e:
|
|
84
|
+
log.debug("xybern: register failed for %s: %s", info.name, e)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_STATE: Dict = {"connected": False}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def connect(api_key: Optional[str] = None, mode: Optional[str] = None,
|
|
92
|
+
frameworks: Optional[List[str]] = None, instrument: bool = True,
|
|
93
|
+
**overrides) -> ConnectResult:
|
|
94
|
+
"""Discover agents, register them (with cryptographic identity), and instrument
|
|
95
|
+
the detected frameworks. OBSERVE mode by default — nothing is ever blocked until
|
|
96
|
+
you switch to enforce (`xybern enforce on` or mode="enforce")."""
|
|
97
|
+
cfg = Config.resolve(api_key=api_key, mode=mode, frameworks=frameworks, **overrides)
|
|
98
|
+
client = XybernClient(cfg)
|
|
99
|
+
|
|
100
|
+
from .telemetry import Telemetry
|
|
101
|
+
tel = Telemetry(client, cfg)
|
|
102
|
+
tel.start()
|
|
103
|
+
registrar = _Registrar(client, cfg)
|
|
104
|
+
|
|
105
|
+
# 1) inventory whatever already exists, register each
|
|
106
|
+
found = discovery.discover_all(cfg.frameworks)
|
|
107
|
+
for a in found:
|
|
108
|
+
registrar.ensure(a)
|
|
109
|
+
|
|
110
|
+
# 2) the action hook used by all framework interceptors
|
|
111
|
+
def hook(framework, action_type, agent_name, content, metadata) -> bool:
|
|
112
|
+
return _on_action(cfg, client, tel, registrar, framework, action_type,
|
|
113
|
+
agent_name, content, metadata)
|
|
114
|
+
|
|
115
|
+
def on_discover(info):
|
|
116
|
+
registrar.ensure(info)
|
|
117
|
+
|
|
118
|
+
instrumented = []
|
|
119
|
+
if instrument:
|
|
120
|
+
instrumented = discovery.instrument_all(hook, on_discover, cfg.frameworks)
|
|
121
|
+
|
|
122
|
+
_STATE.update({"connected": True, "cfg": cfg, "client": client,
|
|
123
|
+
"telemetry": tel, "registrar": registrar})
|
|
124
|
+
|
|
125
|
+
return ConnectResult(
|
|
126
|
+
authenticated=cfg.authenticated, mode=cfg.mode,
|
|
127
|
+
frameworks=discovery.detected_frameworks(cfg.frameworks),
|
|
128
|
+
agents=[a.name for a in found], instrumented=instrumented,
|
|
129
|
+
workspace=cfg.workspace,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _on_action(cfg, client, tel, registrar, framework, action_type, agent_name,
|
|
134
|
+
content, metadata) -> bool:
|
|
135
|
+
"""Return True to allow, False to block. OBSERVE always allows (logs only)."""
|
|
136
|
+
agent_id = None
|
|
137
|
+
if agent_name:
|
|
138
|
+
from .discovery import AgentInfo
|
|
139
|
+
agent_id = registrar.ensure(AgentInfo(name=agent_name, framework=framework))
|
|
140
|
+
|
|
141
|
+
if cfg.mode != "enforce":
|
|
142
|
+
# observe: enqueue, never block
|
|
143
|
+
if cfg.authenticated:
|
|
144
|
+
ev = {"action_type": action_type, "agent_id": agent_id,
|
|
145
|
+
"metadata": {"framework": framework, **(metadata or {})}}
|
|
146
|
+
if content is not None:
|
|
147
|
+
ev["metadata"]["content_sha256"] = hashlib.sha256(
|
|
148
|
+
content.encode("utf-8", "replace")).hexdigest()
|
|
149
|
+
tel.record(ev)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
# enforce: synchronous decision, fail-open per config
|
|
153
|
+
if not cfg.authenticated:
|
|
154
|
+
return True
|
|
155
|
+
try:
|
|
156
|
+
result = client.intercept(action_type=action_type, action_content=content,
|
|
157
|
+
agent_id=agent_id,
|
|
158
|
+
metadata={"framework": framework, **(metadata or {})})
|
|
159
|
+
decision = result.get("decision", "allow")
|
|
160
|
+
return decision == "allow"
|
|
161
|
+
except Exception:
|
|
162
|
+
return cfg.fail_open
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def status() -> Dict:
|
|
166
|
+
return {"connected": _STATE.get("connected", False),
|
|
167
|
+
"mode": getattr(_STATE.get("cfg"), "mode", None)}
|
xybern/cli/__init__.py
ADDED
|
File without changes
|
xybern/cli/__main__.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""`xybern` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from ..config import (Config, DEFAULT_BASE_URL, save_credentials, clear_credentials,
|
|
12
|
+
set_mode)
|
|
13
|
+
from ..client import XybernClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client(api_key=None) -> XybernClient:
|
|
17
|
+
base = os.environ.get("XYBERN_BASE_URL", DEFAULT_BASE_URL)
|
|
18
|
+
return XybernClient(Config(api_key=api_key, base_url=base))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── commands ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def cmd_login(args) -> int:
|
|
24
|
+
base = os.environ.get("XYBERN_BASE_URL", DEFAULT_BASE_URL)
|
|
25
|
+
|
|
26
|
+
# Path 1 — explicit/paste key (or env). Validate then save.
|
|
27
|
+
api_key = args.api_key or os.environ.get("XYBERN_API_KEY")
|
|
28
|
+
if api_key:
|
|
29
|
+
cli = _client(api_key)
|
|
30
|
+
try:
|
|
31
|
+
cli.whoami()
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"✗ Could not validate that API key: {e}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
save_credentials(api_key, base_url=base)
|
|
36
|
+
print("✓ Logged in with API key. Credentials saved to ~/.xybern/credentials.json")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
# Path 2 — device-code browser flow (auto-picks your workspace key)
|
|
40
|
+
cli = _client()
|
|
41
|
+
try:
|
|
42
|
+
start = cli.device_auth_start(hostname=socket.gethostname())
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"✗ Device login unavailable ({e}).\n"
|
|
45
|
+
f" Use: xybern login --api-key xb_... (create a key in your Xybern dashboard)",
|
|
46
|
+
file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
url = start.get("verification_url") or start.get("verification_uri")
|
|
50
|
+
user_code = start.get("user_code")
|
|
51
|
+
device_code = start.get("device_code")
|
|
52
|
+
interval = int(start.get("interval", 5))
|
|
53
|
+
expires = int(start.get("expires_in", 600))
|
|
54
|
+
|
|
55
|
+
print("\nTo authorise this machine, open:\n")
|
|
56
|
+
print(f" {url}")
|
|
57
|
+
print(f"\nand enter the code: {user_code}\n")
|
|
58
|
+
try:
|
|
59
|
+
import webbrowser
|
|
60
|
+
webbrowser.open(url)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
print("Waiting for approval…", end="", flush=True)
|
|
65
|
+
deadline = time.time() + expires
|
|
66
|
+
while time.time() < deadline:
|
|
67
|
+
time.sleep(interval)
|
|
68
|
+
print(".", end="", flush=True)
|
|
69
|
+
try:
|
|
70
|
+
poll = cli.device_auth_poll(device_code)
|
|
71
|
+
except Exception:
|
|
72
|
+
continue
|
|
73
|
+
if poll.get("api_key"):
|
|
74
|
+
save_credentials(poll["api_key"], base_url=base, workspace=poll.get("workspace"))
|
|
75
|
+
print(f"\n✓ Logged in to workspace {poll.get('workspace') or ''}. "
|
|
76
|
+
f"Credentials saved.")
|
|
77
|
+
return 0
|
|
78
|
+
if poll.get("status") in ("denied", "expired"):
|
|
79
|
+
print(f"\n✗ Login {poll['status']}.", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
print("\n✗ Timed out waiting for approval.", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_logout(args) -> int:
|
|
86
|
+
clear_credentials()
|
|
87
|
+
print("✓ Logged out.")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_agents(args) -> int:
|
|
92
|
+
from .. import discovery
|
|
93
|
+
fw = discovery.detected_frameworks()
|
|
94
|
+
found = discovery.discover_all()
|
|
95
|
+
print(f"✓ Detected frameworks: {', '.join(fw) or 'none'}")
|
|
96
|
+
print(f"✓ Found {len(found)} agent(s)/tool(s) already instantiated"
|
|
97
|
+
+ ("" if found else " (more appear as your app creates them)"))
|
|
98
|
+
by_fw = {}
|
|
99
|
+
for a in found:
|
|
100
|
+
by_fw.setdefault(a.framework, []).append(a)
|
|
101
|
+
for f, items in by_fw.items():
|
|
102
|
+
print(f" • {f}: " + ", ".join(sorted({i.name for i in items}))[:200])
|
|
103
|
+
cfg = Config.resolve()
|
|
104
|
+
if not cfg.authenticated:
|
|
105
|
+
print("\n⚠ Not connected. Run `xybern login`, then `auto.connect()` in your app "
|
|
106
|
+
"to register these agents (each gets an identity).")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_status(args) -> int:
|
|
111
|
+
cfg = Config.resolve()
|
|
112
|
+
print(f"Base URL: {cfg.base_url}")
|
|
113
|
+
print(f"Mode: {cfg.mode}")
|
|
114
|
+
print(f"Auth: {'✓ logged in' if cfg.authenticated else '✗ not logged in'}")
|
|
115
|
+
if cfg.authenticated:
|
|
116
|
+
try:
|
|
117
|
+
n = len((_client(cfg.api_key).list_agents().get('agents') or []))
|
|
118
|
+
print(f"Workspace: {cfg.workspace or '(linked)'} · {n} registered agent(s)")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
print(f"Workspace: (could not reach control plane: {e})")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_enforce(args) -> int:
|
|
125
|
+
if args.state == "on":
|
|
126
|
+
set_mode("enforce")
|
|
127
|
+
print("✓ Enforcement ON — actions will be authorised (allow/block/escalate) on next connect.")
|
|
128
|
+
else:
|
|
129
|
+
set_mode("observe")
|
|
130
|
+
print("✓ Enforcement OFF — observe only (actions logged, nothing blocked).")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv=None) -> int:
|
|
135
|
+
p = argparse.ArgumentParser(prog="xybern", description="Xybern — identity & authorisation for AI agents")
|
|
136
|
+
sub = p.add_subparsers(dest="cmd")
|
|
137
|
+
|
|
138
|
+
lp = sub.add_parser("login", help="Authenticate this machine (device code, or --api-key)")
|
|
139
|
+
lp.add_argument("--api-key", help="Use an API key directly instead of the browser flow")
|
|
140
|
+
lp.set_defaults(func=cmd_login)
|
|
141
|
+
|
|
142
|
+
sub.add_parser("logout", help="Remove stored credentials").set_defaults(func=cmd_logout)
|
|
143
|
+
sub.add_parser("agents", help="Discover agents/tools in this project (dry run)").set_defaults(func=cmd_agents)
|
|
144
|
+
sub.add_parser("status", help="Show login + mode").set_defaults(func=cmd_status)
|
|
145
|
+
|
|
146
|
+
ep = sub.add_parser("enforce", help="Turn enforcement on/off")
|
|
147
|
+
ep.add_argument("state", choices=["on", "off"])
|
|
148
|
+
ep.set_defaults(func=cmd_enforce)
|
|
149
|
+
|
|
150
|
+
args = p.parse_args(argv)
|
|
151
|
+
if not getattr(args, "func", None):
|
|
152
|
+
p.print_help()
|
|
153
|
+
return 0
|
|
154
|
+
return args.func(args)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|
xybern/client.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Thin REST client for the Xybern control plane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class XybernClient:
|
|
14
|
+
def __init__(self, config: Config):
|
|
15
|
+
self.cfg = config
|
|
16
|
+
self._session = requests.Session()
|
|
17
|
+
if config.api_key:
|
|
18
|
+
self._session.headers.update({"X-API-Key": config.api_key})
|
|
19
|
+
self._session.headers.update({"User-Agent": "xybern-sdk/2.0"})
|
|
20
|
+
|
|
21
|
+
# ── low level ────────────────────────────────────────────────────────────
|
|
22
|
+
def _url(self, path: str) -> str:
|
|
23
|
+
return f"{self.cfg.base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
24
|
+
|
|
25
|
+
def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
26
|
+
r = self._session.post(self._url(path), json=body, timeout=self.cfg.timeout)
|
|
27
|
+
r.raise_for_status()
|
|
28
|
+
return r.json()
|
|
29
|
+
|
|
30
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
31
|
+
r = self._session.get(self._url(path), params=params or {}, timeout=self.cfg.timeout)
|
|
32
|
+
r.raise_for_status()
|
|
33
|
+
return r.json()
|
|
34
|
+
|
|
35
|
+
# ── agents / registration ────────────────────────────────────────────────
|
|
36
|
+
def register_agent(self, name: str, framework: str = "custom",
|
|
37
|
+
description: Optional[str] = None,
|
|
38
|
+
capabilities: Optional[List[str]] = None,
|
|
39
|
+
permissions: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
40
|
+
"""Register (or idempotently re-register) an agent; returns agent + credential."""
|
|
41
|
+
return self._post("/enforce/agents", {
|
|
42
|
+
"name": name,
|
|
43
|
+
"framework": framework,
|
|
44
|
+
"description": description,
|
|
45
|
+
"capabilities": capabilities or [],
|
|
46
|
+
"permissions": permissions or {},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
def list_agents(self) -> Dict[str, Any]:
|
|
50
|
+
return self._get("/enforce/agents")
|
|
51
|
+
|
|
52
|
+
# ── enforcement ────────────────────────────────────────────────────────────
|
|
53
|
+
def intercept(self, action_type: str, action_content: Optional[str] = None,
|
|
54
|
+
agent_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
"""Authorise one action. Respects redact (sends a content hash by default)."""
|
|
57
|
+
body: Dict[str, Any] = {"action_type": action_type, "agent_id": agent_id,
|
|
58
|
+
"metadata": metadata or {}}
|
|
59
|
+
if action_content is not None:
|
|
60
|
+
if self.cfg.redact:
|
|
61
|
+
body["metadata"]["content_sha256"] = hashlib.sha256(
|
|
62
|
+
action_content.encode("utf-8", "replace")).hexdigest()
|
|
63
|
+
body["action_content"] = None
|
|
64
|
+
else:
|
|
65
|
+
body["action_content"] = action_content
|
|
66
|
+
return self._post("/enforce/intercept", body)
|
|
67
|
+
|
|
68
|
+
def batch_intercept(self, actions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
69
|
+
return self._post("/enforce/batch", {"actions": actions})
|
|
70
|
+
|
|
71
|
+
def list_policies(self) -> Dict[str, Any]:
|
|
72
|
+
return self._get("/enforce/policies")
|
|
73
|
+
|
|
74
|
+
def whoami(self) -> Dict[str, Any]:
|
|
75
|
+
"""Lightweight auth/workspace check (lists agents; 200 => key valid)."""
|
|
76
|
+
return self._get("/enforce/agents")
|
|
77
|
+
|
|
78
|
+
# ── device-code login ──────────────────────────────────────────────────────
|
|
79
|
+
def device_auth_start(self, hostname: str) -> Dict[str, Any]:
|
|
80
|
+
return self._post("/auth/device/start", {"hostname": hostname, "client": "xybern-sdk"})
|
|
81
|
+
|
|
82
|
+
def device_auth_poll(self, device_code: str) -> Dict[str, Any]:
|
|
83
|
+
return self._post("/auth/device/poll", {"device_code": device_code})
|
xybern/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Configuration + credential resolution for the Xybern SDK.
|
|
2
|
+
|
|
3
|
+
Resolution order for the API key (first hit wins):
|
|
4
|
+
1. explicit api_key=... passed to auto.connect()/Config
|
|
5
|
+
2. env XYBERN_API_KEY
|
|
6
|
+
3. ~/.xybern/credentials.json (written by `xybern login`)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Dict, Any
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://www.xybern.com/api/v1"
|
|
18
|
+
CONFIG_DIR = Path(os.environ.get("XYBERN_HOME", str(Path.home() / ".xybern")))
|
|
19
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_credentials_file() -> Dict[str, Any]:
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(CREDENTIALS_FILE.read_text())
|
|
25
|
+
except Exception:
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_credentials(api_key: str, base_url: str = DEFAULT_BASE_URL,
|
|
30
|
+
workspace: Optional[str] = None) -> None:
|
|
31
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
CREDENTIALS_FILE.write_text(json.dumps({
|
|
33
|
+
"api_key": api_key, "base_url": base_url, "workspace": workspace,
|
|
34
|
+
}, indent=2))
|
|
35
|
+
try:
|
|
36
|
+
os.chmod(CREDENTIALS_FILE, 0o600) # don't leave the key world-readable
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clear_credentials() -> None:
|
|
42
|
+
try:
|
|
43
|
+
CREDENTIALS_FILE.unlink()
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_mode(mode: str) -> None:
|
|
49
|
+
"""Persist the enforcement mode (observe|enforce) for future connects."""
|
|
50
|
+
creds = _read_credentials_file()
|
|
51
|
+
creds["mode"] = mode
|
|
52
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
CREDENTIALS_FILE.write_text(json.dumps(creds, indent=2))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Config:
|
|
58
|
+
"""Resolved SDK configuration."""
|
|
59
|
+
api_key: Optional[str] = None
|
|
60
|
+
base_url: str = DEFAULT_BASE_URL
|
|
61
|
+
# "observe" = log/registry only, never blocks. "enforce" = act on decisions.
|
|
62
|
+
mode: str = "observe"
|
|
63
|
+
# If Xybern is unreachable, allow the action through (True) or block (False).
|
|
64
|
+
fail_open: bool = True
|
|
65
|
+
# Send hashes of action content instead of raw content.
|
|
66
|
+
redact: bool = True
|
|
67
|
+
# Frameworks to instrument; None = all detected. [] = none (discovery only).
|
|
68
|
+
frameworks: Optional[list] = None
|
|
69
|
+
timeout: float = 10.0
|
|
70
|
+
workspace: Optional[str] = None
|
|
71
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def resolve(cls, api_key: Optional[str] = None, **overrides) -> "Config":
|
|
75
|
+
creds = _read_credentials_file()
|
|
76
|
+
key = api_key or os.environ.get("XYBERN_API_KEY") or creds.get("api_key")
|
|
77
|
+
base = (overrides.pop("base_url", None)
|
|
78
|
+
or os.environ.get("XYBERN_BASE_URL")
|
|
79
|
+
or creds.get("base_url") or DEFAULT_BASE_URL)
|
|
80
|
+
mode = (overrides.pop("mode", None) or os.environ.get("XYBERN_MODE")
|
|
81
|
+
or creds.get("mode") or "observe")
|
|
82
|
+
cfg = cls(api_key=key, base_url=base, mode=mode,
|
|
83
|
+
workspace=creds.get("workspace"))
|
|
84
|
+
for k, v in overrides.items():
|
|
85
|
+
if hasattr(cfg, k) and v is not None:
|
|
86
|
+
setattr(cfg, k, v)
|
|
87
|
+
return cfg
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def authenticated(self) -> bool:
|
|
91
|
+
return bool(self.api_key and self.api_key.startswith("xb_"))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Framework auto-discovery + instrumentation.
|
|
2
|
+
|
|
3
|
+
We never "scan the machine". We inspect what's *already importable / imported* in
|
|
4
|
+
the running process and (when instrumenting) hook the creation + execution points
|
|
5
|
+
of supported frameworks. Everything is best-effort and defensive: a detector that
|
|
6
|
+
can't find an expected internal simply reports presence and no-ops — it must never
|
|
7
|
+
crash the host application.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Callable, List, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AgentInfo:
|
|
21
|
+
name: str
|
|
22
|
+
framework: str
|
|
23
|
+
kind: str = "agent" # agent | tool | workflow | mcp_server
|
|
24
|
+
capabilities: List[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
def fingerprint(self) -> str:
|
|
27
|
+
"""Stable id for idempotent registration (survives restarts)."""
|
|
28
|
+
raw = f"{self.framework}|{self.kind}|{self.name}"
|
|
29
|
+
return "xagt_" + hashlib.sha256(raw.encode()).hexdigest()[:20]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Callback the host process calls before an action executes.
|
|
33
|
+
# Signature: (framework, action_type, agent_name, content, metadata) -> bool(allow)
|
|
34
|
+
ActionHook = Callable[[str, str, Optional[str], Optional[str], dict], bool]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Detector:
|
|
38
|
+
framework: str = "base"
|
|
39
|
+
module_hint: str = "" # top-level module that signals presence
|
|
40
|
+
|
|
41
|
+
def available(self) -> bool:
|
|
42
|
+
try:
|
|
43
|
+
if self.module_hint in sys.modules:
|
|
44
|
+
return True
|
|
45
|
+
return importlib.util.find_spec(self.module_hint) is not None
|
|
46
|
+
except Exception:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def imported(self) -> bool:
|
|
50
|
+
return self.module_hint in sys.modules
|
|
51
|
+
|
|
52
|
+
def scan(self) -> List[AgentInfo]:
|
|
53
|
+
"""Best-effort inventory of already-instantiated agents/tools."""
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
def instrument(self, hook: ActionHook, on_discover: Callable[[AgentInfo], None]) -> bool:
|
|
57
|
+
"""Hook creation/execution points. Return True if any hook was installed."""
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── registry ────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _all_detectors() -> List[Detector]:
|
|
64
|
+
from . import detectors # late import to avoid cycles
|
|
65
|
+
return detectors.build()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_detectors(frameworks: Optional[List[str]] = None,
|
|
69
|
+
available_only: bool = True) -> List[Detector]:
|
|
70
|
+
dets = _all_detectors()
|
|
71
|
+
if frameworks is not None:
|
|
72
|
+
wanted = {f.lower() for f in frameworks}
|
|
73
|
+
dets = [d for d in dets if d.framework.lower() in wanted]
|
|
74
|
+
if available_only:
|
|
75
|
+
dets = [d for d in dets if _safe(d.available, default=False)]
|
|
76
|
+
return dets
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discover_all(frameworks: Optional[List[str]] = None) -> List[AgentInfo]:
|
|
80
|
+
found: List[AgentInfo] = []
|
|
81
|
+
for d in get_detectors(frameworks):
|
|
82
|
+
found.extend(_safe(d.scan, default=[]) or [])
|
|
83
|
+
# de-dupe by fingerprint
|
|
84
|
+
seen, out = set(), []
|
|
85
|
+
for a in found:
|
|
86
|
+
fp = a.fingerprint()
|
|
87
|
+
if fp not in seen:
|
|
88
|
+
seen.add(fp); out.append(a)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detected_frameworks(frameworks: Optional[List[str]] = None) -> List[str]:
|
|
93
|
+
return [d.framework for d in get_detectors(frameworks)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def instrument_all(hook: ActionHook, on_discover: Callable[[AgentInfo], None],
|
|
97
|
+
frameworks: Optional[List[str]] = None) -> List[str]:
|
|
98
|
+
instrumented = []
|
|
99
|
+
for d in get_detectors(frameworks):
|
|
100
|
+
if _safe(lambda: d.instrument(hook, on_discover), default=False):
|
|
101
|
+
instrumented.append(d.framework)
|
|
102
|
+
return instrumented
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _safe(fn, default=None):
|
|
106
|
+
try:
|
|
107
|
+
return fn()
|
|
108
|
+
except Exception:
|
|
109
|
+
return default
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Per-framework detectors. Every hook is defensive — guarded by hasattr and
|
|
2
|
+
wrapped so a version mismatch degrades to "presence detected" rather than crashing
|
|
3
|
+
the host application."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import gc
|
|
8
|
+
from typing import Callable, List
|
|
9
|
+
|
|
10
|
+
from . import AgentInfo, Detector, ActionHook
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── shared helpers ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
def _wrap(cls, method_name: str, make_wrapper) -> bool:
|
|
16
|
+
"""Replace cls.method_name with make_wrapper(orig); idempotent."""
|
|
17
|
+
orig = getattr(cls, method_name, None)
|
|
18
|
+
if orig is None or getattr(orig, "_xybern_wrapped", False):
|
|
19
|
+
return False
|
|
20
|
+
wrapper = make_wrapper(orig)
|
|
21
|
+
try:
|
|
22
|
+
wrapper._xybern_wrapped = True
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
setattr(cls, method_name, wrapper)
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _enforce(hook: ActionHook, framework: str, action_type: str,
|
|
30
|
+
agent: str = None, content: str = None, meta: dict = None):
|
|
31
|
+
"""Call the host hook; raise PolicyBlocked if it denies. Never lets unrelated errors propagate."""
|
|
32
|
+
try:
|
|
33
|
+
allow = hook(framework, action_type or "action", agent, content, meta or {})
|
|
34
|
+
except Exception:
|
|
35
|
+
return # telemetry/transport hiccup must not break the agent (fail-open handled upstream)
|
|
36
|
+
if allow is False:
|
|
37
|
+
from ..exceptions import PolicyBlocked
|
|
38
|
+
raise PolicyBlocked(action_type or "action")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _scan(cls, framework: str, kind: str, name_attrs: List[str]) -> List[AgentInfo]:
|
|
42
|
+
out = []
|
|
43
|
+
try:
|
|
44
|
+
for o in gc.get_objects():
|
|
45
|
+
if isinstance(o, cls):
|
|
46
|
+
nm = None
|
|
47
|
+
for at in name_attrs:
|
|
48
|
+
nm = getattr(o, at, None)
|
|
49
|
+
if nm:
|
|
50
|
+
break
|
|
51
|
+
out.append(AgentInfo(name=str(nm or type(o).__name__), framework=framework, kind=kind))
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── LangChain ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
class LangChainDetector(Detector):
|
|
60
|
+
framework = "langchain"
|
|
61
|
+
module_hint = "langchain_core"
|
|
62
|
+
|
|
63
|
+
def _tool_cls(self):
|
|
64
|
+
import langchain_core.tools as t
|
|
65
|
+
return t.BaseTool
|
|
66
|
+
|
|
67
|
+
def scan(self):
|
|
68
|
+
if not self.imported():
|
|
69
|
+
return []
|
|
70
|
+
try:
|
|
71
|
+
return _scan(self._tool_cls(), self.framework, "tool", ["name"])
|
|
72
|
+
except Exception:
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def instrument(self, hook, on_discover):
|
|
76
|
+
import langchain_core.tools as t
|
|
77
|
+
BaseTool = t.BaseTool
|
|
78
|
+
ok = False
|
|
79
|
+
for m in ("run", "arun", "_run", "invoke"):
|
|
80
|
+
def mk(orig):
|
|
81
|
+
def wrapper(self, *a, **k):
|
|
82
|
+
name = getattr(self, "name", type(self).__name__)
|
|
83
|
+
try:
|
|
84
|
+
on_discover(AgentInfo(name=name, framework="langchain", kind="tool"))
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
_enforce(hook, "langchain", str(name), content=_firststr(a, k))
|
|
88
|
+
return orig(self, *a, **k)
|
|
89
|
+
return wrapper
|
|
90
|
+
ok = _wrap(BaseTool, m, mk) or ok
|
|
91
|
+
return ok
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── CrewAI ──────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
class CrewAIDetector(Detector):
|
|
97
|
+
framework = "crewai"
|
|
98
|
+
module_hint = "crewai"
|
|
99
|
+
|
|
100
|
+
def scan(self):
|
|
101
|
+
if not self.imported():
|
|
102
|
+
return []
|
|
103
|
+
try:
|
|
104
|
+
import crewai
|
|
105
|
+
return _scan(crewai.Agent, self.framework, "agent", ["role", "name"])
|
|
106
|
+
except Exception:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
def instrument(self, hook, on_discover):
|
|
110
|
+
import crewai
|
|
111
|
+
ok = False
|
|
112
|
+
|
|
113
|
+
def mk_init(orig):
|
|
114
|
+
def wrapper(self, *a, **k):
|
|
115
|
+
orig(self, *a, **k)
|
|
116
|
+
try:
|
|
117
|
+
on_discover(AgentInfo(name=str(getattr(self, "role", None) or "crew-agent"),
|
|
118
|
+
framework="crewai", kind="agent",
|
|
119
|
+
capabilities=[getattr(t, "name", "") for t in (getattr(self, "tools", []) or [])]))
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
return wrapper
|
|
123
|
+
ok = _wrap(crewai.Agent, "__init__", mk_init) or ok
|
|
124
|
+
|
|
125
|
+
for meth in ("execute_task",):
|
|
126
|
+
def mk(orig):
|
|
127
|
+
def wrapper(self, *a, **k):
|
|
128
|
+
_enforce(hook, "crewai", "execute_task",
|
|
129
|
+
agent=str(getattr(self, "role", None) or "crew-agent"))
|
|
130
|
+
return orig(self, *a, **k)
|
|
131
|
+
return wrapper
|
|
132
|
+
ok = _wrap(crewai.Agent, meth, mk) or ok
|
|
133
|
+
return ok
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── OpenAI Agents SDK (`agents` package) ──────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
class OpenAIAgentsDetector(Detector):
|
|
139
|
+
framework = "openai-agents"
|
|
140
|
+
module_hint = "agents"
|
|
141
|
+
|
|
142
|
+
def scan(self):
|
|
143
|
+
if not self.imported():
|
|
144
|
+
return []
|
|
145
|
+
try:
|
|
146
|
+
import agents
|
|
147
|
+
return _scan(agents.Agent, self.framework, "agent", ["name"])
|
|
148
|
+
except Exception:
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
def instrument(self, hook, on_discover):
|
|
152
|
+
import agents
|
|
153
|
+
ok = False
|
|
154
|
+
|
|
155
|
+
def mk_init(orig):
|
|
156
|
+
def wrapper(self, *a, **k):
|
|
157
|
+
orig(self, *a, **k)
|
|
158
|
+
try:
|
|
159
|
+
on_discover(AgentInfo(name=str(getattr(self, "name", "agent")),
|
|
160
|
+
framework="openai-agents", kind="agent"))
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
return wrapper
|
|
164
|
+
ok = _wrap(getattr(agents, "Agent", object), "__init__", mk_init) or ok
|
|
165
|
+
|
|
166
|
+
# Function tools expose on_invoke_tool / a callable; wrap if present.
|
|
167
|
+
FunctionTool = getattr(agents, "FunctionTool", None)
|
|
168
|
+
if FunctionTool is not None:
|
|
169
|
+
for meth in ("on_invoke_tool", "invoke", "run"):
|
|
170
|
+
def mk(orig):
|
|
171
|
+
def wrapper(self, *a, **k):
|
|
172
|
+
_enforce(hook, "openai-agents", str(getattr(self, "name", "tool")))
|
|
173
|
+
return orig(self, *a, **k)
|
|
174
|
+
return wrapper
|
|
175
|
+
ok = _wrap(FunctionTool, meth, mk) or ok
|
|
176
|
+
return ok
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── MCP servers ───────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
class MCPDetector(Detector):
|
|
182
|
+
framework = "mcp"
|
|
183
|
+
module_hint = "mcp"
|
|
184
|
+
|
|
185
|
+
def instrument(self, hook, on_discover):
|
|
186
|
+
ok = False
|
|
187
|
+
# FastMCP tool calls
|
|
188
|
+
try:
|
|
189
|
+
from mcp.server.fastmcp import FastMCP
|
|
190
|
+
for meth in ("call_tool",):
|
|
191
|
+
def mk(orig):
|
|
192
|
+
def wrapper(self, name, *a, **k):
|
|
193
|
+
try:
|
|
194
|
+
on_discover(AgentInfo(name=str(name), framework="mcp", kind="mcp_server"))
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
_enforce(hook, "mcp", str(name))
|
|
198
|
+
return orig(self, name, *a, **k)
|
|
199
|
+
return wrapper
|
|
200
|
+
ok = _wrap(FastMCP, meth, mk) or ok
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
# Low-level server
|
|
204
|
+
try:
|
|
205
|
+
from mcp.server import Server
|
|
206
|
+
for meth in ("call_tool",):
|
|
207
|
+
def mk(orig):
|
|
208
|
+
def wrapper(self, *a, **k):
|
|
209
|
+
_enforce(hook, "mcp", "call_tool")
|
|
210
|
+
return orig(self, *a, **k)
|
|
211
|
+
return wrapper
|
|
212
|
+
ok = _wrap(Server, meth, mk) or ok
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return ok
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── LangGraph ─────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
class LangGraphDetector(Detector):
|
|
221
|
+
framework = "langgraph"
|
|
222
|
+
module_hint = "langgraph"
|
|
223
|
+
|
|
224
|
+
def instrument(self, hook, on_discover):
|
|
225
|
+
ok = False
|
|
226
|
+
try:
|
|
227
|
+
from langgraph.graph import StateGraph
|
|
228
|
+
|
|
229
|
+
def mk(orig):
|
|
230
|
+
def wrapper(self, node, *a, **k):
|
|
231
|
+
try:
|
|
232
|
+
on_discover(AgentInfo(name=str(node), framework="langgraph", kind="workflow"))
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
return orig(self, node, *a, **k)
|
|
236
|
+
return wrapper
|
|
237
|
+
ok = _wrap(StateGraph, "add_node", mk) or ok
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
return ok
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ── AutoGen ───────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
class AutoGenDetector(Detector):
|
|
246
|
+
framework = "autogen"
|
|
247
|
+
module_hint = "autogen"
|
|
248
|
+
|
|
249
|
+
def instrument(self, hook, on_discover):
|
|
250
|
+
ok = False
|
|
251
|
+
try:
|
|
252
|
+
import autogen
|
|
253
|
+
CA = getattr(autogen, "ConversableAgent", None)
|
|
254
|
+
if CA is not None:
|
|
255
|
+
def mk_init(orig):
|
|
256
|
+
def wrapper(self, *a, **k):
|
|
257
|
+
orig(self, *a, **k)
|
|
258
|
+
try:
|
|
259
|
+
on_discover(AgentInfo(name=str(getattr(self, "name", "autogen-agent")),
|
|
260
|
+
framework="autogen", kind="agent"))
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
return wrapper
|
|
264
|
+
ok = _wrap(CA, "__init__", mk_init) or ok
|
|
265
|
+
|
|
266
|
+
def mk(orig):
|
|
267
|
+
def wrapper(self, *a, **k):
|
|
268
|
+
_enforce(hook, "autogen", "execute_function",
|
|
269
|
+
agent=str(getattr(self, "name", "autogen-agent")))
|
|
270
|
+
return orig(self, *a, **k)
|
|
271
|
+
return wrapper
|
|
272
|
+
for meth in ("execute_function",):
|
|
273
|
+
ok = _wrap(CA, meth, mk) or ok
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
return ok
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── Semantic Kernel ─────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
class SemanticKernelDetector(Detector):
|
|
282
|
+
framework = "semantic-kernel"
|
|
283
|
+
module_hint = "semantic_kernel"
|
|
284
|
+
|
|
285
|
+
def instrument(self, hook, on_discover):
|
|
286
|
+
ok = False
|
|
287
|
+
try:
|
|
288
|
+
from semantic_kernel.functions.kernel_function import KernelFunction
|
|
289
|
+
for meth in ("invoke", "__call__"):
|
|
290
|
+
def mk(orig):
|
|
291
|
+
def wrapper(self, *a, **k):
|
|
292
|
+
name = getattr(self, "name", None) or getattr(self, "fully_qualified_name", "kernel-function")
|
|
293
|
+
try:
|
|
294
|
+
on_discover(AgentInfo(name=str(name), framework="semantic-kernel", kind="tool"))
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
_enforce(hook, "semantic-kernel", str(name))
|
|
298
|
+
return orig(self, *a, **k)
|
|
299
|
+
return wrapper
|
|
300
|
+
ok = _wrap(KernelFunction, meth, mk) or ok
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
return ok
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── LlamaIndex ────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
class LlamaIndexDetector(Detector):
|
|
309
|
+
framework = "llamaindex"
|
|
310
|
+
module_hint = "llama_index"
|
|
311
|
+
|
|
312
|
+
def instrument(self, hook, on_discover):
|
|
313
|
+
ok = False
|
|
314
|
+
try:
|
|
315
|
+
from llama_index.core.tools import FunctionTool
|
|
316
|
+
for meth in ("call", "__call__", "acall"):
|
|
317
|
+
def mk(orig):
|
|
318
|
+
def wrapper(self, *a, **k):
|
|
319
|
+
meta = getattr(self, "metadata", None)
|
|
320
|
+
name = getattr(meta, "name", None) if meta else "llamaindex-tool"
|
|
321
|
+
try:
|
|
322
|
+
on_discover(AgentInfo(name=str(name), framework="llamaindex", kind="tool"))
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
_enforce(hook, "llamaindex", str(name))
|
|
326
|
+
return orig(self, *a, **k)
|
|
327
|
+
return wrapper
|
|
328
|
+
ok = _wrap(FunctionTool, meth, mk) or ok
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
return ok
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── Generic: FastAPI + Celery presence (deep hooks land in a later pass) ──────
|
|
335
|
+
|
|
336
|
+
class FastAPIDetector(Detector):
|
|
337
|
+
framework = "fastapi"
|
|
338
|
+
module_hint = "fastapi"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class CeleryDetector(Detector):
|
|
342
|
+
framework = "celery"
|
|
343
|
+
module_hint = "celery"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def build() -> List[Detector]:
|
|
347
|
+
return [
|
|
348
|
+
LangChainDetector(), CrewAIDetector(), OpenAIAgentsDetector(), MCPDetector(),
|
|
349
|
+
LangGraphDetector(), AutoGenDetector(), SemanticKernelDetector(), LlamaIndexDetector(),
|
|
350
|
+
FastAPIDetector(), CeleryDetector(),
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _firststr(args, kwargs, limit: int = 2000):
|
|
355
|
+
for v in list(args) + list(kwargs.values()):
|
|
356
|
+
if isinstance(v, str):
|
|
357
|
+
return v[:limit]
|
|
358
|
+
return None
|
xybern/exceptions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Xybern SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class XybernError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PolicyBlocked(XybernError):
|
|
9
|
+
"""Raised when an action is blocked by an enforced policy."""
|
|
10
|
+
def __init__(self, action_type: str, reason: str = ""):
|
|
11
|
+
self.action_type = action_type
|
|
12
|
+
self.reason = reason
|
|
13
|
+
super().__init__(f"Xybern blocked action '{action_type}'" + (f": {reason}" if reason else ""))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PolicyEscalated(XybernError):
|
|
17
|
+
"""Raised when an action requires human approval before it can proceed."""
|
|
18
|
+
def __init__(self, action_type: str, escalation_id: str = ""):
|
|
19
|
+
self.action_type = action_type
|
|
20
|
+
self.escalation_id = escalation_id
|
|
21
|
+
super().__init__(f"Xybern escalated action '{action_type}' for human review")
|
xybern/telemetry.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Async, fail-open telemetry for OBSERVE mode.
|
|
2
|
+
|
|
3
|
+
In observe mode every intercepted action is enqueued and flushed in batches to the
|
|
4
|
+
control plane (decisions are recorded server-side; the SDK never blocks). The queue
|
|
5
|
+
is bounded and lossy under pressure — telemetry must never slow down or crash the
|
|
6
|
+
host agent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import queue
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Telemetry:
|
|
17
|
+
def __init__(self, client, config, batch_size: int = 50, flush_interval: float = 2.0):
|
|
18
|
+
self.client = client
|
|
19
|
+
self.cfg = config
|
|
20
|
+
self.batch_size = batch_size
|
|
21
|
+
self.flush_interval = flush_interval
|
|
22
|
+
self._q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=10000)
|
|
23
|
+
self._started = False
|
|
24
|
+
|
|
25
|
+
def start(self):
|
|
26
|
+
if self._started or not self.cfg.authenticated:
|
|
27
|
+
return
|
|
28
|
+
self._started = True
|
|
29
|
+
threading.Thread(target=self._loop, daemon=True, name="xybern-telemetry").start()
|
|
30
|
+
|
|
31
|
+
def record(self, event: Dict[str, Any]):
|
|
32
|
+
try:
|
|
33
|
+
self._q.put_nowait(event)
|
|
34
|
+
except queue.Full:
|
|
35
|
+
pass # drop under pressure — never block the agent
|
|
36
|
+
|
|
37
|
+
def _loop(self):
|
|
38
|
+
batch = []
|
|
39
|
+
while True:
|
|
40
|
+
try:
|
|
41
|
+
batch.append(self._q.get(timeout=self.flush_interval))
|
|
42
|
+
except queue.Empty:
|
|
43
|
+
pass
|
|
44
|
+
if batch and (len(batch) >= self.batch_size or self._q.empty()):
|
|
45
|
+
self._flush(batch)
|
|
46
|
+
batch = []
|
|
47
|
+
|
|
48
|
+
def _flush(self, batch):
|
|
49
|
+
try:
|
|
50
|
+
self.client.batch_intercept(batch)
|
|
51
|
+
except Exception:
|
|
52
|
+
pass # fail-open: a telemetry outage must not affect the host
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xybern
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Identity & authorisation infrastructure for AI agents — install once, discover every agent, authorise every action.
|
|
5
|
+
Author-email: Xybern <info@xybern.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.xybern.com
|
|
8
|
+
Project-URL: Documentation, https://docs.xybern.com/authorization/sdk
|
|
9
|
+
Keywords: ai,agents,authorization,identity,governance,langchain,crewai,mcp,provenance
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.25.0
|
|
13
|
+
Provides-Extra: crypto
|
|
14
|
+
Requires-Dist: cryptography>=41.0.0; extra == "crypto"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# Xybern
|
|
19
|
+
|
|
20
|
+
**Identity & authorisation infrastructure for AI agents.** Install once — Xybern
|
|
21
|
+
discovers every AI agent in your system, gives each a cryptographic identity, and
|
|
22
|
+
(when you turn enforcement on) authorises every action *before* it executes.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install xybern
|
|
26
|
+
xybern login # browser device-code flow (auto-links your workspace)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from xybern import auto
|
|
31
|
+
auto.connect() # discovers frameworks + agents + tools, registers them, instruments them
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
✓ Detected frameworks: CrewAI, LangGraph, 2 MCP servers
|
|
36
|
+
✓ Found 12 agents · 48 tools
|
|
37
|
+
✓ Registered to workspace "Acme Corp" (each issued a cryptographic identity)
|
|
38
|
+
✓ Mode: OBSERVE — actions logged, nothing blocked yet
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## What it does
|
|
42
|
+
|
|
43
|
+
- **Auto-discovery** — detects and instruments LangChain, CrewAI, OpenAI Agents SDK,
|
|
44
|
+
MCP servers, LangGraph, AutoGen, Semantic Kernel, LlamaIndex (and FastAPI/Celery).
|
|
45
|
+
No manual wiring; agents appear in your Xybern dashboard as your app creates them.
|
|
46
|
+
- **Cryptographic identity** — every discovered agent is registered and issued an
|
|
47
|
+
identity, so its actions are attributable and signable.
|
|
48
|
+
- **Authorisation before execution** — each tool/agent action passes through Xybern's
|
|
49
|
+
policy engine; `allow` / `block` / `escalate`.
|
|
50
|
+
- **Observe-first & fail-open** — default mode only *logs* (never blocks). When you
|
|
51
|
+
switch to enforce, the SDK fails open if Xybern is unreachable, so it can't take
|
|
52
|
+
your agents down.
|
|
53
|
+
- **Privacy** — sends content **hashes** by default, not raw payloads.
|
|
54
|
+
|
|
55
|
+
## Modes
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
auto.connect() # OBSERVE (default): log + inventory, never blocks
|
|
59
|
+
auto.connect(mode="enforce") # authorise actions (allow/block/escalate)
|
|
60
|
+
```
|
|
61
|
+
or persist it: `xybern enforce on` / `xybern enforce off`.
|
|
62
|
+
|
|
63
|
+
## CLI
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
xybern login [--api-key xb_...] # device-code flow, or paste a key / set XYBERN_API_KEY
|
|
67
|
+
xybern agents # dry-run: what would be discovered
|
|
68
|
+
xybern status
|
|
69
|
+
xybern enforce on|off
|
|
70
|
+
xybern logout
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Auth options
|
|
74
|
+
|
|
75
|
+
1. **Device code** — `xybern login` opens a browser; approve + pick a workspace; a
|
|
76
|
+
scoped key is minted and stored in `~/.xybern/credentials.json`.
|
|
77
|
+
2. **API key** — `xybern login --api-key xb_...`, or `export XYBERN_API_KEY=xb_...`,
|
|
78
|
+
or `auto.connect(api_key="xb_...")`.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Option | Default | Meaning |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| `mode` | `observe` | `observe` (log only) or `enforce` (act on decisions) |
|
|
85
|
+
| `fail_open` | `True` | allow actions through if Xybern is unreachable (enforce mode) |
|
|
86
|
+
| `redact` | `True` | send content hashes instead of raw content |
|
|
87
|
+
| `frameworks` | all | restrict to specific frameworks |
|
|
88
|
+
|
|
89
|
+
Docs: https://docs.xybern.com/authorization/sdk
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
xybern/__init__.py,sha256=nxrIqGsOgmvHqtczfNK11U3uLihGCfgkPEAb5aabRG4,900
|
|
2
|
+
xybern/auto.py,sha256=23m3SKxcnRD5jFwpcIDpMoUXbaFiH4SaDGz1Zp56ZQs,6150
|
|
3
|
+
xybern/client.py,sha256=wUFwAXPd5gIni4PcKfe9GX3o1vUUu54kaBQ1YCjGXSY,4101
|
|
4
|
+
xybern/config.py,sha256=C7NYFQueJPHnM8NFN2cv8zkyF7lH0rgyjl0yRsPlc_E,3161
|
|
5
|
+
xybern/exceptions.py,sha256=a7z4TbXcrRnFXDLHj9drirbppS9_3gqcz6t1cL045r0,755
|
|
6
|
+
xybern/telemetry.py,sha256=y7i-W2-C6b4elVvptJfm9jRzli-A3Bwh4BzNO9OPbOs,1703
|
|
7
|
+
xybern/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
xybern/cli/__main__.py,sha256=E0baJXPUsl46TLI6SePv6czIe_z56HwmDrSbVNstqo4,5766
|
|
9
|
+
xybern/discovery/__init__.py,sha256=5PIPdy42m6n2pctoW5VD8t4mXRayPSN4VO0u2zhsaRQ,3775
|
|
10
|
+
xybern/discovery/detectors.py,sha256=dd0iuNi6TlPaZuR9gGakimAE_UxUBGk2Wg2GzYiw6OU,13641
|
|
11
|
+
xybern-2.0.0.dist-info/METADATA,sha256=DDdYvt95jaESmYCmIhBdsxDRsTmvrc8zuDnSaa5ofJs,3386
|
|
12
|
+
xybern-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
xybern-2.0.0.dist-info/entry_points.txt,sha256=80JV-7qZiC55cjgBSO9tzpsVgZlOwyzvUHEkyE3YNTs,52
|
|
14
|
+
xybern-2.0.0.dist-info/top_level.txt,sha256=VjRinkGTjBhdhPoKoZAk521u6oOVLYFvkoFlWT_umPY,7
|
|
15
|
+
xybern-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xybern
|