xybern 2.0.0__tar.gz
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-2.0.0/PKG-INFO +89 -0
- xybern-2.0.0/README.md +72 -0
- xybern-2.0.0/pyproject.toml +33 -0
- xybern-2.0.0/setup.cfg +4 -0
- xybern-2.0.0/xybern/__init__.py +29 -0
- xybern-2.0.0/xybern/auto.py +167 -0
- xybern-2.0.0/xybern/cli/__init__.py +0 -0
- xybern-2.0.0/xybern/cli/__main__.py +158 -0
- xybern-2.0.0/xybern/client.py +83 -0
- xybern-2.0.0/xybern/config.py +91 -0
- xybern-2.0.0/xybern/discovery/__init__.py +109 -0
- xybern-2.0.0/xybern/discovery/detectors.py +358 -0
- xybern-2.0.0/xybern/exceptions.py +21 -0
- xybern-2.0.0/xybern/telemetry.py +52 -0
- xybern-2.0.0/xybern.egg-info/PKG-INFO +89 -0
- xybern-2.0.0/xybern.egg-info/SOURCES.txt +18 -0
- xybern-2.0.0/xybern.egg-info/dependency_links.txt +1 -0
- xybern-2.0.0/xybern.egg-info/entry_points.txt +2 -0
- xybern-2.0.0/xybern.egg-info/requires.txt +7 -0
- xybern-2.0.0/xybern.egg-info/top_level.txt +1 -0
xybern-2.0.0/PKG-INFO
ADDED
|
@@ -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
|
xybern-2.0.0/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Xybern
|
|
2
|
+
|
|
3
|
+
**Identity & authorisation infrastructure for AI agents.** Install once — Xybern
|
|
4
|
+
discovers every AI agent in your system, gives each a cryptographic identity, and
|
|
5
|
+
(when you turn enforcement on) authorises every action *before* it executes.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install xybern
|
|
9
|
+
xybern login # browser device-code flow (auto-links your workspace)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from xybern import auto
|
|
14
|
+
auto.connect() # discovers frameworks + agents + tools, registers them, instruments them
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
✓ Detected frameworks: CrewAI, LangGraph, 2 MCP servers
|
|
19
|
+
✓ Found 12 agents · 48 tools
|
|
20
|
+
✓ Registered to workspace "Acme Corp" (each issued a cryptographic identity)
|
|
21
|
+
✓ Mode: OBSERVE — actions logged, nothing blocked yet
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
- **Auto-discovery** — detects and instruments LangChain, CrewAI, OpenAI Agents SDK,
|
|
27
|
+
MCP servers, LangGraph, AutoGen, Semantic Kernel, LlamaIndex (and FastAPI/Celery).
|
|
28
|
+
No manual wiring; agents appear in your Xybern dashboard as your app creates them.
|
|
29
|
+
- **Cryptographic identity** — every discovered agent is registered and issued an
|
|
30
|
+
identity, so its actions are attributable and signable.
|
|
31
|
+
- **Authorisation before execution** — each tool/agent action passes through Xybern's
|
|
32
|
+
policy engine; `allow` / `block` / `escalate`.
|
|
33
|
+
- **Observe-first & fail-open** — default mode only *logs* (never blocks). When you
|
|
34
|
+
switch to enforce, the SDK fails open if Xybern is unreachable, so it can't take
|
|
35
|
+
your agents down.
|
|
36
|
+
- **Privacy** — sends content **hashes** by default, not raw payloads.
|
|
37
|
+
|
|
38
|
+
## Modes
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
auto.connect() # OBSERVE (default): log + inventory, never blocks
|
|
42
|
+
auto.connect(mode="enforce") # authorise actions (allow/block/escalate)
|
|
43
|
+
```
|
|
44
|
+
or persist it: `xybern enforce on` / `xybern enforce off`.
|
|
45
|
+
|
|
46
|
+
## CLI
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
xybern login [--api-key xb_...] # device-code flow, or paste a key / set XYBERN_API_KEY
|
|
50
|
+
xybern agents # dry-run: what would be discovered
|
|
51
|
+
xybern status
|
|
52
|
+
xybern enforce on|off
|
|
53
|
+
xybern logout
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Auth options
|
|
57
|
+
|
|
58
|
+
1. **Device code** — `xybern login` opens a browser; approve + pick a workspace; a
|
|
59
|
+
scoped key is minted and stored in `~/.xybern/credentials.json`.
|
|
60
|
+
2. **API key** — `xybern login --api-key xb_...`, or `export XYBERN_API_KEY=xb_...`,
|
|
61
|
+
or `auto.connect(api_key="xb_...")`.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
| Option | Default | Meaning |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `mode` | `observe` | `observe` (log only) or `enforce` (act on decisions) |
|
|
68
|
+
| `fail_open` | `True` | allow actions through if Xybern is unreachable (enforce mode) |
|
|
69
|
+
| `redact` | `True` | send content hashes instead of raw content |
|
|
70
|
+
| `frameworks` | all | restrict to specific frameworks |
|
|
71
|
+
|
|
72
|
+
Docs: https://docs.xybern.com/authorization/sdk
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xybern"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Identity & authorisation infrastructure for AI agents — install once, discover every agent, authorise every action."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Xybern", email = "info@xybern.com" }]
|
|
13
|
+
keywords = ["ai", "agents", "authorization", "identity", "governance", "langchain", "crewai", "mcp", "provenance"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"requests>=2.25.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
# Per-agent cryptographic identity signing (Ed25519). Optional — falls back to
|
|
20
|
+
# unsigned registration if absent.
|
|
21
|
+
crypto = ["cryptography>=41.0.0"]
|
|
22
|
+
dev = ["pytest"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://www.xybern.com"
|
|
26
|
+
Documentation = "https://docs.xybern.com/authorization/sdk"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
xybern = "xybern.cli.__main__:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
include = ["xybern*"]
|
xybern-2.0.0/setup.cfg
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__"]
|
|
@@ -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)}
|
|
File without changes
|
|
@@ -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())
|
|
@@ -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})
|