eniyan 0.1.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.
- eniyan-0.1.0/.gitignore +4 -0
- eniyan-0.1.0/PKG-INFO +85 -0
- eniyan-0.1.0/README.md +64 -0
- eniyan-0.1.0/RELEASING.md +21 -0
- eniyan-0.1.0/pyproject.toml +34 -0
- eniyan-0.1.0/src/eniyan/__init__.py +26 -0
- eniyan-0.1.0/src/eniyan/client.py +268 -0
- eniyan-0.1.0/src/eniyan/errors.py +39 -0
- eniyan-0.1.0/src/eniyan/mcp/__init__.py +1 -0
- eniyan-0.1.0/src/eniyan/mcp/server.py +136 -0
- eniyan-0.1.0/src/eniyan/mcp/tools.py +145 -0
- eniyan-0.1.0/src/eniyan/runs.py +185 -0
- eniyan-0.1.0/tests/__init__.py +0 -0
- eniyan-0.1.0/tests/integration_local.py +111 -0
- eniyan-0.1.0/tests/test_governed_run.py +169 -0
- eniyan-0.1.0/tests/test_mcp_tools.py +95 -0
eniyan-0.1.0/.gitignore
ADDED
eniyan-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: eniyan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Eniyan SDK — govern your AI agents wherever they run: identity, scoped authority, JIT windows, and self-reported run telemetry.
|
|
5
|
+
Project-URL: Homepage, https://eniyantrust.com
|
|
6
|
+
Project-URL: Documentation, https://eniyantrust.com/docs
|
|
7
|
+
Author-email: Eniyan <stevland@eniyantrust.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: agent-identity,ai-agents,governance,mcp,rbac
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: httpx>=0.24
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Provides-Extra: mcp
|
|
19
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Eniyan SDK (Python)
|
|
23
|
+
|
|
24
|
+
Govern your AI agents wherever they run. This SDK wraps the Eniyan API —
|
|
25
|
+
agent identity verification, live scope decisions, JIT credential windows,
|
|
26
|
+
short-lived OAuth tokens, and self-reported run telemetry — so any agent
|
|
27
|
+
loop becomes Eniyan-governed in a few lines. Eniyan never hosts or observes
|
|
28
|
+
your loop; your agent reports metadata-only telemetry (names, scopes, token
|
|
29
|
+
counts, outcomes — never prompts, arguments, results, or model output).
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install eniyan
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
from eniyan import EniyanClient, governed_run, EniyanScopeRefused
|
|
40
|
+
|
|
41
|
+
client = EniyanClient(
|
|
42
|
+
base_url="https://api.eniyantrust.com",
|
|
43
|
+
api_key=os.environ["ENIYAN_API_KEY"],
|
|
44
|
+
credential_token=os.environ["ENIYAN_CREDENTIAL_TOKEN"],
|
|
45
|
+
agent_id=os.environ["ENIYAN_AGENT_ID"],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
with governed_run(client, harness="my-loop", jit=True, scopes=["crm:read"]) as run:
|
|
49
|
+
fetch = run.tool("fetch_accounts", scope="crm:read")(fetch_accounts)
|
|
50
|
+
accounts = fetch() # refused under block mode BEFORE it runs
|
|
51
|
+
run.model_call("claude-sonnet-5", input_tokens=1200, output_tokens=300)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
On exit — success or crash — the run is finished, buffered steps are
|
|
55
|
+
flushed, and the JIT window is completed and attested.
|
|
56
|
+
|
|
57
|
+
## What each piece maps to
|
|
58
|
+
|
|
59
|
+
| SDK call | API |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `client.verify()` / `client.check_scope()` | `POST /v1/credentials/verify` |
|
|
62
|
+
| `client.mint_token()` / `client.introspect()` | `POST /v1/oauth/token` / `/introspect` |
|
|
63
|
+
| `client.validate_grant()` | `POST /v1/agents/credentials/validate` |
|
|
64
|
+
| `client.create_task()` / `complete_task()` / `attest_task()` | JIT task lifecycle |
|
|
65
|
+
| `client.start_run()` / `append_steps()` / `finish_run()` | `POST /v1/runs` |
|
|
66
|
+
|
|
67
|
+
Advisory-mode scope violations do not raise — the call proceeds and the
|
|
68
|
+
violation is flagged server-side (`GovernanceDecision.advisory_flagged`).
|
|
69
|
+
Block mode raises `EniyanScopeRefused` before the tool executes.
|
|
70
|
+
|
|
71
|
+
## MCP server
|
|
72
|
+
|
|
73
|
+
`pip install "eniyan[mcp]"` and run `eniyan-mcp` to expose the same
|
|
74
|
+
governance operations as MCP tools for Claude Code or any MCP-capable
|
|
75
|
+
harness. See the gated docs for configuration.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install -e ".[dev]"
|
|
81
|
+
pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`tests/integration_local.py` runs the full flow against a local Eniyan
|
|
85
|
+
stack (`docker compose up` from the repo root).
|
eniyan-0.1.0/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Eniyan SDK (Python)
|
|
2
|
+
|
|
3
|
+
Govern your AI agents wherever they run. This SDK wraps the Eniyan API —
|
|
4
|
+
agent identity verification, live scope decisions, JIT credential windows,
|
|
5
|
+
short-lived OAuth tokens, and self-reported run telemetry — so any agent
|
|
6
|
+
loop becomes Eniyan-governed in a few lines. Eniyan never hosts or observes
|
|
7
|
+
your loop; your agent reports metadata-only telemetry (names, scopes, token
|
|
8
|
+
counts, outcomes — never prompts, arguments, results, or model output).
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install eniyan
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import os
|
|
18
|
+
from eniyan import EniyanClient, governed_run, EniyanScopeRefused
|
|
19
|
+
|
|
20
|
+
client = EniyanClient(
|
|
21
|
+
base_url="https://api.eniyantrust.com",
|
|
22
|
+
api_key=os.environ["ENIYAN_API_KEY"],
|
|
23
|
+
credential_token=os.environ["ENIYAN_CREDENTIAL_TOKEN"],
|
|
24
|
+
agent_id=os.environ["ENIYAN_AGENT_ID"],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
with governed_run(client, harness="my-loop", jit=True, scopes=["crm:read"]) as run:
|
|
28
|
+
fetch = run.tool("fetch_accounts", scope="crm:read")(fetch_accounts)
|
|
29
|
+
accounts = fetch() # refused under block mode BEFORE it runs
|
|
30
|
+
run.model_call("claude-sonnet-5", input_tokens=1200, output_tokens=300)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
On exit — success or crash — the run is finished, buffered steps are
|
|
34
|
+
flushed, and the JIT window is completed and attested.
|
|
35
|
+
|
|
36
|
+
## What each piece maps to
|
|
37
|
+
|
|
38
|
+
| SDK call | API |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `client.verify()` / `client.check_scope()` | `POST /v1/credentials/verify` |
|
|
41
|
+
| `client.mint_token()` / `client.introspect()` | `POST /v1/oauth/token` / `/introspect` |
|
|
42
|
+
| `client.validate_grant()` | `POST /v1/agents/credentials/validate` |
|
|
43
|
+
| `client.create_task()` / `complete_task()` / `attest_task()` | JIT task lifecycle |
|
|
44
|
+
| `client.start_run()` / `append_steps()` / `finish_run()` | `POST /v1/runs` |
|
|
45
|
+
|
|
46
|
+
Advisory-mode scope violations do not raise — the call proceeds and the
|
|
47
|
+
violation is flagged server-side (`GovernanceDecision.advisory_flagged`).
|
|
48
|
+
Block mode raises `EniyanScopeRefused` before the tool executes.
|
|
49
|
+
|
|
50
|
+
## MCP server
|
|
51
|
+
|
|
52
|
+
`pip install "eniyan[mcp]"` and run `eniyan-mcp` to expose the same
|
|
53
|
+
governance operations as MCP tools for Claude Code or any MCP-capable
|
|
54
|
+
harness. See the gated docs for configuration.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install -e ".[dev]"
|
|
60
|
+
pytest
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`tests/integration_local.py` runs the full flow against a local Eniyan
|
|
64
|
+
stack (`docker compose up` from the repo root).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Releasing `eniyan` to PyPI
|
|
2
|
+
|
|
3
|
+
Manual for now. One-time setup: create the PyPI project `eniyan` and an API
|
|
4
|
+
token scoped to it; keep the token out of the repo.
|
|
5
|
+
|
|
6
|
+
1. Bump `version` in `pyproject.toml` AND `__version__` in
|
|
7
|
+
`src/eniyan/__init__.py` (keep them identical).
|
|
8
|
+
2. From `sdk/python/`:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python -m pip install --upgrade build twine
|
|
12
|
+
python -m build
|
|
13
|
+
python -m twine upload dist/*
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
3. Tag: `git tag sdk-v<version> && git push origin sdk-v<version>`.
|
|
17
|
+
4. Smoke test in a clean venv: `pip install eniyan==<version>` and run the
|
|
18
|
+
quickstart against staging.
|
|
19
|
+
|
|
20
|
+
Until the PyPI project exists, early-access installs work from git:
|
|
21
|
+
`pip install "git+https://github.com/Eniyan-Inc/eniyan.git#subdirectory=sdk/python"`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eniyan"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Eniyan SDK — govern your AI agents wherever they run: identity, scoped authority, JIT windows, and self-reported run telemetry."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Eniyan", email = "stevland@eniyantrust.com" }]
|
|
13
|
+
keywords = ["ai-agents", "agent-identity", "governance", "rbac", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["httpx>=0.24"]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
mcp = ["mcp>=1.0"]
|
|
24
|
+
dev = ["pytest>=8"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://eniyantrust.com"
|
|
28
|
+
Documentation = "https://eniyantrust.com/docs"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
eniyan-mcp = "eniyan.mcp.server:main"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/eniyan"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Eniyan SDK — govern your AI agents wherever they run."""
|
|
2
|
+
|
|
3
|
+
from .client import EniyanClient, GovernanceDecision
|
|
4
|
+
from .errors import (
|
|
5
|
+
EniyanAPIError,
|
|
6
|
+
EniyanAuthError,
|
|
7
|
+
EniyanError,
|
|
8
|
+
EniyanRunError,
|
|
9
|
+
EniyanScopeRefused,
|
|
10
|
+
)
|
|
11
|
+
from .runs import RunHandle, governed_run
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"EniyanClient",
|
|
17
|
+
"GovernanceDecision",
|
|
18
|
+
"EniyanError",
|
|
19
|
+
"EniyanAPIError",
|
|
20
|
+
"EniyanAuthError",
|
|
21
|
+
"EniyanRunError",
|
|
22
|
+
"EniyanScopeRefused",
|
|
23
|
+
"RunHandle",
|
|
24
|
+
"governed_run",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EniyanClient — a thin, typed wrapper over the Eniyan HTTP API.
|
|
3
|
+
|
|
4
|
+
Nothing here is invented: every method maps 1:1 onto an existing endpoint.
|
|
5
|
+
The client authenticates with an org API key; agent-scoped calls also carry
|
|
6
|
+
the agent's credential JWT (issued at enrollment).
|
|
7
|
+
|
|
8
|
+
Telemetry sent through this client is METADATA-ONLY by contract: step names,
|
|
9
|
+
scopes, token counts, outcomes. Never send tool arguments, tool results,
|
|
10
|
+
prompts, or model output — the server truncates labels defensively, but the
|
|
11
|
+
contract is yours to honor.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .errors import EniyanAPIError, EniyanAuthError, EniyanRunError, EniyanScopeRefused
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class GovernanceDecision:
|
|
26
|
+
"""Outcome of a scope check against the agent's live permitted set."""
|
|
27
|
+
|
|
28
|
+
allowed: bool
|
|
29
|
+
scope: str
|
|
30
|
+
advisory_flagged: bool
|
|
31
|
+
reason: str | None
|
|
32
|
+
raw: dict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EniyanClient:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
base_url: str,
|
|
39
|
+
api_key: str,
|
|
40
|
+
credential_token: str | None = None,
|
|
41
|
+
agent_id: str | None = None,
|
|
42
|
+
timeout: float = 15.0,
|
|
43
|
+
transport: httpx.BaseTransport | None = None,
|
|
44
|
+
):
|
|
45
|
+
self.base_url = base_url.rstrip("/")
|
|
46
|
+
self.api_key = api_key
|
|
47
|
+
self.credential_token = credential_token
|
|
48
|
+
self.agent_id = agent_id
|
|
49
|
+
self._http = httpx.Client(
|
|
50
|
+
base_url=self.base_url,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
53
|
+
transport=transport,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
self._http.close()
|
|
58
|
+
|
|
59
|
+
# ── Internals ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
62
|
+
resp = self._http.request(method, path, **kwargs)
|
|
63
|
+
if resp.status_code >= 400:
|
|
64
|
+
code, message = None, resp.text[:500]
|
|
65
|
+
try:
|
|
66
|
+
detail = resp.json().get("detail")
|
|
67
|
+
if isinstance(detail, dict):
|
|
68
|
+
code = detail.get("code")
|
|
69
|
+
message = detail.get("message") or message
|
|
70
|
+
elif isinstance(detail, str):
|
|
71
|
+
message = detail
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
err_cls = EniyanAuthError if resp.status_code in (401, 403) else EniyanAPIError
|
|
75
|
+
raise err_cls(resp.status_code, code, message)
|
|
76
|
+
return resp.json() if resp.content else {}
|
|
77
|
+
|
|
78
|
+
def _require_credential(self) -> str:
|
|
79
|
+
if not self.credential_token:
|
|
80
|
+
raise EniyanAPIError(0, "NO_CREDENTIAL", "This call needs credential_token.")
|
|
81
|
+
return self.credential_token
|
|
82
|
+
|
|
83
|
+
def _require_agent(self, agent_id: str | None = None) -> str:
|
|
84
|
+
resolved = agent_id or self.agent_id
|
|
85
|
+
if not resolved:
|
|
86
|
+
raise EniyanAPIError(0, "NO_AGENT", "Pass agent_id or set it on the client.")
|
|
87
|
+
return resolved
|
|
88
|
+
|
|
89
|
+
# ── Identity & authority ──────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def verify(self, requested_scope: str | None = None) -> dict:
|
|
92
|
+
"""POST /v1/credentials/verify — online verification of this agent's credential."""
|
|
93
|
+
body: dict[str, Any] = {"credential_token": self._require_credential()}
|
|
94
|
+
if requested_scope is not None:
|
|
95
|
+
body["requested_scope"] = requested_scope
|
|
96
|
+
return self._request("POST", "/v1/credentials/verify", json=body)
|
|
97
|
+
|
|
98
|
+
def check_scope(self, scope: str) -> GovernanceDecision:
|
|
99
|
+
"""
|
|
100
|
+
Scope decision against the agent's live permitted set (roles, live
|
|
101
|
+
delegations, approved exceptions — whatever the org policy composes).
|
|
102
|
+
|
|
103
|
+
Block mode refusal raises EniyanScopeRefused; advisory mode returns
|
|
104
|
+
allowed=True with advisory_flagged=True (the violation is recorded
|
|
105
|
+
server-side either way).
|
|
106
|
+
"""
|
|
107
|
+
result = self.verify(requested_scope=scope)
|
|
108
|
+
authorized = result.get("scope_authorized")
|
|
109
|
+
valid = bool(result.get("valid"))
|
|
110
|
+
if not valid and result.get("reason") == "scope_not_authorized":
|
|
111
|
+
raise EniyanScopeRefused(scope, result.get("reason"))
|
|
112
|
+
if not valid:
|
|
113
|
+
raise EniyanAuthError(200, result.get("reason"), "credential not valid")
|
|
114
|
+
return GovernanceDecision(
|
|
115
|
+
allowed=True,
|
|
116
|
+
scope=scope,
|
|
117
|
+
advisory_flagged=(authorized is False),
|
|
118
|
+
reason=result.get("reason"),
|
|
119
|
+
raw=result,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def mint_token(self, scope: str | None = None) -> dict:
|
|
123
|
+
"""POST /v1/oauth/token (client_credentials) — short-lived access token."""
|
|
124
|
+
data = {
|
|
125
|
+
"grant_type": "client_credentials",
|
|
126
|
+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
127
|
+
"client_assertion": self._require_credential(),
|
|
128
|
+
}
|
|
129
|
+
if scope:
|
|
130
|
+
data["scope"] = scope
|
|
131
|
+
return self._request("POST", "/v1/oauth/token", data=data)
|
|
132
|
+
|
|
133
|
+
def introspect(self, token: str) -> dict:
|
|
134
|
+
"""POST /v1/oauth/introspect (RFC 7662) — live re-evaluation."""
|
|
135
|
+
return self._request("POST", "/v1/oauth/introspect", data={"token": token})
|
|
136
|
+
|
|
137
|
+
def validate_grant(
|
|
138
|
+
self,
|
|
139
|
+
scope: str,
|
|
140
|
+
resource_type: str | None = None,
|
|
141
|
+
resource_id: str | None = None,
|
|
142
|
+
ttl_seconds: int | None = None,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""POST /v1/agents/credentials/validate — per-action access grant."""
|
|
145
|
+
body: dict[str, Any] = {
|
|
146
|
+
"credential_token": self._require_credential(),
|
|
147
|
+
"scope": scope,
|
|
148
|
+
}
|
|
149
|
+
if resource_type:
|
|
150
|
+
body["resource_type"] = resource_type
|
|
151
|
+
if resource_id:
|
|
152
|
+
body["resource_id"] = resource_id
|
|
153
|
+
if ttl_seconds:
|
|
154
|
+
body["ttl_seconds"] = ttl_seconds
|
|
155
|
+
return self._request("POST", "/v1/agents/credentials/validate", json=body)
|
|
156
|
+
|
|
157
|
+
# ── JIT task windows ──────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def create_task(
|
|
160
|
+
self,
|
|
161
|
+
task_label: str,
|
|
162
|
+
agent_id: str | None = None,
|
|
163
|
+
ttl_minutes: int = 15,
|
|
164
|
+
required_scopes: list[str] | None = None,
|
|
165
|
+
) -> dict:
|
|
166
|
+
agent = self._require_agent(agent_id)
|
|
167
|
+
body: dict[str, Any] = {
|
|
168
|
+
"task_label": task_label,
|
|
169
|
+
"ttl_minutes": ttl_minutes,
|
|
170
|
+
"auto_activate": True,
|
|
171
|
+
}
|
|
172
|
+
if required_scopes is not None:
|
|
173
|
+
body["required_scopes"] = required_scopes
|
|
174
|
+
return self._request("POST", f"/v1/verifications/agents/{agent}/tasks", json=body)
|
|
175
|
+
|
|
176
|
+
def complete_task(
|
|
177
|
+
self, task_id: str, agent_id: str | None = None, reason: str = "completed"
|
|
178
|
+
) -> dict:
|
|
179
|
+
agent = self._require_agent(agent_id)
|
|
180
|
+
return self._request(
|
|
181
|
+
"POST",
|
|
182
|
+
f"/v1/verifications/agents/{agent}/tasks/{task_id}/complete",
|
|
183
|
+
json={"reason": reason},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def attest_task(
|
|
187
|
+
self,
|
|
188
|
+
task_id: str,
|
|
189
|
+
outcome: str,
|
|
190
|
+
notes: str | None = None,
|
|
191
|
+
agent_id: str | None = None,
|
|
192
|
+
) -> dict:
|
|
193
|
+
agent = self._require_agent(agent_id)
|
|
194
|
+
body: dict[str, Any] = {"outcome": outcome}
|
|
195
|
+
if notes:
|
|
196
|
+
body["notes"] = notes
|
|
197
|
+
return self._request(
|
|
198
|
+
"POST", f"/v1/verifications/agents/{agent}/tasks/{task_id}/attest", json=body
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# ── Runs (self-reported telemetry) ────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def start_run(
|
|
204
|
+
self,
|
|
205
|
+
agent_id: str | None = None,
|
|
206
|
+
harness: str = "custom",
|
|
207
|
+
model: str | None = None,
|
|
208
|
+
agent_task_id: str | None = None,
|
|
209
|
+
) -> dict:
|
|
210
|
+
body: dict[str, Any] = {
|
|
211
|
+
"agent_id": self._require_agent(agent_id),
|
|
212
|
+
"harness": harness,
|
|
213
|
+
}
|
|
214
|
+
if model:
|
|
215
|
+
body["model"] = model
|
|
216
|
+
if agent_task_id:
|
|
217
|
+
body["agent_task_id"] = agent_task_id
|
|
218
|
+
return self._request("POST", "/v1/runs", json=body)
|
|
219
|
+
|
|
220
|
+
def append_steps(self, run_id: str, steps: list[dict]) -> dict:
|
|
221
|
+
if not steps:
|
|
222
|
+
return {"run_id": run_id, "steps": []}
|
|
223
|
+
try:
|
|
224
|
+
return self._request("POST", f"/v1/runs/{run_id}/steps", json={"steps": steps})
|
|
225
|
+
except EniyanAPIError as err:
|
|
226
|
+
if err.status_code in (404, 409):
|
|
227
|
+
raise EniyanRunError(str(err)) from err
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
def finish_run(
|
|
231
|
+
self,
|
|
232
|
+
run_id: str,
|
|
233
|
+
status: str = "completed",
|
|
234
|
+
outcome: str | None = None,
|
|
235
|
+
cost_cents: int | None = None,
|
|
236
|
+
) -> dict:
|
|
237
|
+
body: dict[str, Any] = {"status": status}
|
|
238
|
+
if outcome is not None:
|
|
239
|
+
body["outcome"] = outcome
|
|
240
|
+
if cost_cents is not None:
|
|
241
|
+
body["cost_cents"] = cost_cents
|
|
242
|
+
try:
|
|
243
|
+
return self._request("POST", f"/v1/runs/{run_id}/finish", json=body)
|
|
244
|
+
except EniyanAPIError as err:
|
|
245
|
+
if err.status_code in (404, 409):
|
|
246
|
+
raise EniyanRunError(str(err)) from err
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
# ── RBAC exception workflow ───────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def request_scope_exception(
|
|
252
|
+
self,
|
|
253
|
+
scope: str,
|
|
254
|
+
justification: str,
|
|
255
|
+
agent_id: str | None = None,
|
|
256
|
+
expires_at: str | None = None,
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""
|
|
259
|
+
POST /v1/agent-roles/agents/{agent_id}/exceptions — ask a human
|
|
260
|
+
approver for out-of-role authority. NOTE: this endpoint requires a
|
|
261
|
+
dashboard JWT (a member requests on the agent's behalf); with an
|
|
262
|
+
API key it returns 401 — surface the request to your operator.
|
|
263
|
+
"""
|
|
264
|
+
agent = self._require_agent(agent_id)
|
|
265
|
+
body: dict[str, Any] = {"scope": scope, "justification": justification}
|
|
266
|
+
if expires_at:
|
|
267
|
+
body["expires_at"] = expires_at
|
|
268
|
+
return self._request("POST", f"/v1/agent-roles/agents/{agent}/exceptions", json=body)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Typed exceptions for the Eniyan SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EniyanError(Exception):
|
|
7
|
+
"""Base class for all SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EniyanAPIError(EniyanError):
|
|
11
|
+
"""The API returned an error response."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status_code: int, code: str | None, message: str):
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.code = code
|
|
16
|
+
self.message = message
|
|
17
|
+
super().__init__(f"[{status_code}] {code or 'error'}: {message}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EniyanAuthError(EniyanAPIError):
|
|
21
|
+
"""API key or credential rejected (401/403)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EniyanScopeRefused(EniyanError):
|
|
25
|
+
"""
|
|
26
|
+
The requested scope was refused under the org's block enforcement mode.
|
|
27
|
+
|
|
28
|
+
`advisory` refusals do NOT raise — the call proceeds and the violation
|
|
29
|
+
is flagged server-side; check GovernanceDecision.advisory_flagged.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, scope: str, reason: str | None = None):
|
|
33
|
+
self.scope = scope
|
|
34
|
+
self.reason = reason or "scope_not_authorized"
|
|
35
|
+
super().__init__(f"scope refused: {scope} ({self.reason})")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EniyanRunError(EniyanError):
|
|
39
|
+
"""Run lifecycle failure (finish on a finished run, unknown run, ...)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Eniyan MCP server — governance as MCP tools for any MCP-capable harness."""
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
eniyan-mcp — stdio MCP server exposing Eniyan governance to any harness.
|
|
3
|
+
|
|
4
|
+
Configuration (env):
|
|
5
|
+
ENIYAN_API_URL default https://api.eniyantrust.com
|
|
6
|
+
ENIYAN_API_KEY org API key (required)
|
|
7
|
+
ENIYAN_CREDENTIAL_TOKEN the agent's credential JWT (required for
|
|
8
|
+
scope checks, grants, and OAuth mints)
|
|
9
|
+
ENIYAN_AGENT_ID the agent's public id (required for JIT + runs)
|
|
10
|
+
|
|
11
|
+
Claude Code example (.mcp.json):
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"eniyan": {
|
|
15
|
+
"command": "eniyan-mcp",
|
|
16
|
+
"env": {
|
|
17
|
+
"ENIYAN_API_URL": "https://api.eniyantrust.com",
|
|
18
|
+
"ENIYAN_API_KEY": "...",
|
|
19
|
+
"ENIYAN_CREDENTIAL_TOKEN": "...",
|
|
20
|
+
"ENIYAN_AGENT_ID": "..."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
from ..client import EniyanClient
|
|
32
|
+
from . import tools
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _client_from_env() -> EniyanClient:
|
|
36
|
+
api_key = os.environ.get("ENIYAN_API_KEY")
|
|
37
|
+
if not api_key:
|
|
38
|
+
raise SystemExit("eniyan-mcp: set ENIYAN_API_KEY")
|
|
39
|
+
return EniyanClient(
|
|
40
|
+
base_url=os.environ.get("ENIYAN_API_URL", "https://api.eniyantrust.com"),
|
|
41
|
+
api_key=api_key,
|
|
42
|
+
credential_token=os.environ.get("ENIYAN_CREDENTIAL_TOKEN"),
|
|
43
|
+
agent_id=os.environ.get("ENIYAN_AGENT_ID"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_server(client: EniyanClient):
|
|
48
|
+
"""Bind the governance operations to a FastMCP server instance."""
|
|
49
|
+
try:
|
|
50
|
+
# mcp 2.x renamed FastMCP → MCPServer; support both.
|
|
51
|
+
try:
|
|
52
|
+
from mcp.server.mcpserver import MCPServer as _Server
|
|
53
|
+
except ImportError:
|
|
54
|
+
from mcp.server.fastmcp import FastMCP as _Server
|
|
55
|
+
except ImportError as exc:
|
|
56
|
+
raise SystemExit('eniyan-mcp needs the mcp extra: pip install "eniyan[mcp]"') from exc
|
|
57
|
+
|
|
58
|
+
server = _Server(
|
|
59
|
+
"eniyan",
|
|
60
|
+
instructions=(
|
|
61
|
+
"Eniyan governs this agent's identity and authority. Check scopes "
|
|
62
|
+
"before privileged actions, record metadata-only run telemetry "
|
|
63
|
+
"(names, scopes, token counts — never payloads or prompts), and "
|
|
64
|
+
"route refusals through the human exception workflow."
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@server.tool()
|
|
69
|
+
def eniyan_check_scope(scope: str) -> dict:
|
|
70
|
+
"""Check whether this agent is currently permitted to use a scope (live decision)."""
|
|
71
|
+
return tools.check_scope(client, scope)
|
|
72
|
+
|
|
73
|
+
@server.tool()
|
|
74
|
+
def eniyan_open_jit_window(
|
|
75
|
+
task_label: str, ttl_minutes: int = 15, required_scopes: list[str] | None = None
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Open a just-in-time credential window for a task."""
|
|
78
|
+
return tools.open_jit_window(client, task_label, ttl_minutes, required_scopes)
|
|
79
|
+
|
|
80
|
+
@server.tool()
|
|
81
|
+
def eniyan_complete_task(task_id: str, reason: str = "completed") -> dict:
|
|
82
|
+
"""Close a JIT window; the credential re-suspends."""
|
|
83
|
+
return tools.complete_task(client, task_id, reason)
|
|
84
|
+
|
|
85
|
+
@server.tool()
|
|
86
|
+
def eniyan_attest(task_id: str, outcome: str, notes: str | None = None) -> dict:
|
|
87
|
+
"""File the post-task attestation (as_intended | partial | failed)."""
|
|
88
|
+
return tools.attest(client, task_id, outcome, notes)
|
|
89
|
+
|
|
90
|
+
@server.tool()
|
|
91
|
+
def eniyan_start_run(
|
|
92
|
+
harness: str = "eniyan-mcp", model: str | None = None, agent_task_id: str | None = None
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Start a self-reported run; record steps against the returned run_id."""
|
|
95
|
+
return tools.start_run(client, harness, model, agent_task_id)
|
|
96
|
+
|
|
97
|
+
@server.tool()
|
|
98
|
+
def eniyan_record_step(
|
|
99
|
+
run_id: str,
|
|
100
|
+
kind: str,
|
|
101
|
+
name: str,
|
|
102
|
+
scope: str | None = None,
|
|
103
|
+
input_tokens: int | None = None,
|
|
104
|
+
output_tokens: int | None = None,
|
|
105
|
+
outcome: str = "ok",
|
|
106
|
+
detail: str | None = None,
|
|
107
|
+
) -> dict:
|
|
108
|
+
"""Record one metadata-only run step. Labels only — never payloads or prompts."""
|
|
109
|
+
return tools.record_step(
|
|
110
|
+
client, run_id, kind, name, scope, input_tokens, output_tokens, outcome, detail
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@server.tool()
|
|
114
|
+
def eniyan_finish_run(
|
|
115
|
+
run_id: str,
|
|
116
|
+
status: str = "completed",
|
|
117
|
+
outcome: str | None = None,
|
|
118
|
+
cost_cents: int | None = None,
|
|
119
|
+
) -> dict:
|
|
120
|
+
"""Finish the run (completed | failed)."""
|
|
121
|
+
return tools.finish_run(client, run_id, status, outcome, cost_cents)
|
|
122
|
+
|
|
123
|
+
@server.tool()
|
|
124
|
+
def eniyan_request_scope_exception(scope: str, justification: str) -> dict:
|
|
125
|
+
"""Ask a human approver for authority outside this agent's roles."""
|
|
126
|
+
return tools.request_scope_exception(client, scope, justification)
|
|
127
|
+
|
|
128
|
+
return server
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main() -> None:
|
|
132
|
+
build_server(_client_from_env()).run()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|