mnki 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.
mnki-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: mnki
3
+ Version: 0.1.0
4
+ Summary: mnki — the Agent Trust SDK for Python: give an AI agent an identity, verify its authority with evidence, delegate, attest, sign requests (Agent-Proof).
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://mnki.com
7
+ Project-URL: Repository, https://github.com/MNKIAgentOS/agent-trust
8
+ Keywords: ai-agents,agent-security,authorization,delegation,mcp,a2a,authzen,spiffe
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Security
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Provides-Extra: signing
15
+ Requires-Dist: cryptography>=42; extra == "signing"
16
+ Provides-Extra: httpx
17
+ Requires-Dist: httpx>=0.27; extra == "httpx"
18
+ Provides-Extra: openai-agents
19
+ Requires-Dist: openai-agents>=0.1; extra == "openai-agents"
20
+ Provides-Extra: langchain
21
+ Requires-Dist: langchain-core>=0.3; extra == "langchain"
22
+ Provides-Extra: crewai
23
+ Requires-Dist: crewai>=0.80; extra == "crewai"
24
+ Provides-Extra: pydantic-ai
25
+ Requires-Dist: pydantic-ai>=0.1; extra == "pydantic-ai"
26
+
27
+ # mnki (Python)
28
+
29
+ ```bash
30
+ pip install mnki # verify, delegate, attest — no dependencies
31
+ pip install "mnki[signing]" # Agent-Proof signing with a local key
32
+ ```
33
+
34
+ ```python
35
+ from mnki import AgentTrustClient, AgentIdentity
36
+
37
+ c = AgentTrustClient("https://mnki.com", api_key="at_verify_…")
38
+ d = c.verify({"agent": "invoice-agent", "action": "refund.create", "amount": 420, "currency": "EUR"})
39
+ print(d["decision"], [e["title"] for e in d["evidence"]])
40
+ ```
41
+
42
+ Local mode (no account) runs the same pipeline as the control plane, in-process, validated against the conformance vectors:
43
+
44
+ ```python
45
+ from mnki import Guard, Denied, demo_world
46
+ g = Guard(world=demo_world(), agent="invoice-agent", action_prefix="", mode="observe") # observe → warn → require_approval → enforce
47
+
48
+ @g.wrap("refund.create")
49
+ def refund(customer_id: str, amount: float, currency: str = "EUR"): ...
50
+ ```
51
+
52
+ Framework adapters (the framework is imported lazily, never required): `mnki.adapters.openai_agents`, `.langchain`,
53
+ `.crewai`, `.pydantic_ai` — each exposes `guard_tools(guard, tools)`; refusals come back to the model as
54
+ `{"error": "denied", "reasons": [...], "evidence": [...]}` or raise with `on_refused="throw"`. Extras:
55
+ `pip install "mnki[openai-agents]"`, `[langchain]`, `[crewai]`, `[pydantic-ai]`, `[httpx]`.
56
+
57
+ `agent_trust` remains importable as a deprecated alias for one minor version. Apache-2.0.
mnki-0.1.0/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # mnki (Python)
2
+
3
+ ```bash
4
+ pip install mnki # verify, delegate, attest — no dependencies
5
+ pip install "mnki[signing]" # Agent-Proof signing with a local key
6
+ ```
7
+
8
+ ```python
9
+ from mnki import AgentTrustClient, AgentIdentity
10
+
11
+ c = AgentTrustClient("https://mnki.com", api_key="at_verify_…")
12
+ d = c.verify({"agent": "invoice-agent", "action": "refund.create", "amount": 420, "currency": "EUR"})
13
+ print(d["decision"], [e["title"] for e in d["evidence"]])
14
+ ```
15
+
16
+ Local mode (no account) runs the same pipeline as the control plane, in-process, validated against the conformance vectors:
17
+
18
+ ```python
19
+ from mnki import Guard, Denied, demo_world
20
+ g = Guard(world=demo_world(), agent="invoice-agent", action_prefix="", mode="observe") # observe → warn → require_approval → enforce
21
+
22
+ @g.wrap("refund.create")
23
+ def refund(customer_id: str, amount: float, currency: str = "EUR"): ...
24
+ ```
25
+
26
+ Framework adapters (the framework is imported lazily, never required): `mnki.adapters.openai_agents`, `.langchain`,
27
+ `.crewai`, `.pydantic_ai` — each exposes `guard_tools(guard, tools)`; refusals come back to the model as
28
+ `{"error": "denied", "reasons": [...], "evidence": [...]}` or raise with `on_refused="throw"`. Extras:
29
+ `pip install "mnki[openai-agents]"`, `[langchain]`, `[crewai]`, `[pydantic-ai]`, `[httpx]`.
30
+
31
+ `agent_trust` remains importable as a deprecated alias for one minor version. Apache-2.0.
@@ -0,0 +1,6 @@
1
+ """Deprecated import path. `agent_trust` is now `mnki` (pip install mnki); this shim will be removed in 0.2."""
2
+ import warnings as _w
3
+ _w.warn("`agent_trust` has been renamed to `mnki` — `from mnki import AgentTrustClient`", DeprecationWarning, stacklevel=2)
4
+ from mnki import AgentTrustClient, AgentTrustError, AgentIdentity, b64url, body_hash # noqa: E402,F401
5
+
6
+ __all__ = ["AgentTrustClient", "AgentTrustError", "AgentIdentity", "b64url", "body_hash"]
@@ -0,0 +1,22 @@
1
+ """
2
+ mnki — give every AI agent an identity, authority and proof of action.
3
+
4
+ from mnki import AgentTrustClient, AgentIdentity
5
+ c = AgentTrustClient("https://mnki.com", api_key="at_verify_…")
6
+ d = c.verify({"agent": "invoice-agent", "action": "refund.create", "amount": 420, "currency": "EUR"})
7
+ d["decision"] # "ALLOW" | "DENY" | "REQUIRE_APPROVAL"; d["evidence"] is the ✓/⚠/✕ list
8
+
9
+ Local mode (no account): `Guard(world=demo_world(), agent="invoice-agent", action_prefix="")` — the same pipeline as the
10
+ control plane, in-process. Framework adapters: `mnki.adapters.openai_agents`, `.langchain`, `.crewai`, `.pydantic_ai`.
11
+ Zero dependencies for verify / attest / delegate; `pip install mnki[signing]` for Agent-Proof signing.
12
+ """
13
+ from .client import AgentTrustClient, Client
14
+ from .errors import AgentTrustError, MnkiError
15
+ from .identity import AgentIdentity, b64url, b64url_decode, body_hash
16
+ from . import offline
17
+ from .guard import Guard, Denied, ApprovalRequired, wrap
18
+ from .local import demo_world, local_verify, load_world
19
+ from . import pipeline, policy, adapters
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = ["AgentTrustClient", "Client", "AgentTrustError", "MnkiError", "AgentIdentity", "b64url", "b64url_decode", "body_hash", "offline", "Guard", "Denied", "ApprovalRequired", "wrap", "demo_world", "local_verify", "load_world", "pipeline", "policy", "adapters"]
@@ -0,0 +1,16 @@
1
+ """
2
+ Framework adapters — thin, framework-free wrappers over `Guard.check`. Each module imports its framework lazily
3
+ (only when a real framework object is handed in), so `mnki` stays dependency-free.
4
+
5
+ from mnki.adapters.openai_agents import guard_tools # openai-agents (FunctionTool.on_invoke_tool)
6
+ from mnki.adapters.langchain import guard_tools # langchain-core BaseTool (invoke / ainvoke)
7
+ from mnki.adapters.crewai import guard_tools # crewai BaseTool (_run)
8
+ from mnki.adapters.pydantic_ai import guard_tool # pydantic-ai tool functions (decorator)
9
+
10
+ Refusals are returned to the model as a JSON result by default (`on_refused="result"`), or raised
11
+ (`on_refused="throw"`).
12
+ """
13
+ from . import shared
14
+ from .shared import refusal, refusal_json, wanted
15
+
16
+ __all__ = ["shared", "refusal", "refusal_json", "wanted"]
@@ -0,0 +1,59 @@
1
+ """
2
+ CrewAI — `crewai.tools.BaseTool`. Real tools become a `GuardedTool` subclass whose `_run` verifies before
3
+ delegating; other objects with `run`/`_run` get a duck-typed proxy. No framework import unless a real tool is given.
4
+
5
+ from mnki.adapters.crewai import guard_tools
6
+ Agent(role="Refunds", tools=guard_tools(guard, [refund_tool]))
7
+ """
8
+ from __future__ import annotations
9
+ import json
10
+ from typing import Any, Optional
11
+
12
+ from .shared import check_or_refuse, wanted
13
+
14
+
15
+ def _base_tool():
16
+ try:
17
+ from crewai.tools import BaseTool # type: ignore
18
+ return BaseTool
19
+ except ImportError: return None
20
+
21
+
22
+ def _guarded_class(BaseTool: Any) -> Any:
23
+ class GuardedTool(BaseTool): # type: ignore[misc,valid-type]
24
+ inner: Any = None
25
+ guard: Any = None
26
+ verified_name: str = ""
27
+ on_refused: str = "result"
28
+
29
+ def _run(self, *a: Any, **kw: Any) -> Any:
30
+ r = check_or_refuse(self.guard, self.verified_name, kw or ({"input": a[0]} if len(a) == 1 else {"args": list(a)}), self.on_refused)
31
+ if r is not None: return json.dumps(r)
32
+ return self.inner._run(*a, **kw)
33
+
34
+ GuardedTool.__name__ = "GuardedTool"
35
+ return GuardedTool
36
+
37
+
38
+ class _Proxy:
39
+ def __init__(self, guard: Any, inner: Any, verified: str, on_refused: str): self._g, self._i, self._n, self._o = guard, inner, verified, on_refused
40
+ def __getattr__(self, k: str) -> Any: return getattr(self._i, k)
41
+ def _run(self, *a: Any, **kw: Any) -> Any:
42
+ r = check_or_refuse(self._g, self._n, kw or ({"input": a[0]} if len(a) == 1 else {"args": list(a)}), self._o); return json.dumps(r) if r is not None else self._i._run(*a, **kw)
43
+ def run(self, *a: Any, **kw: Any) -> Any:
44
+ r = check_or_refuse(self._g, self._n, kw or ({"input": a[0]} if len(a) == 1 else {"args": list(a)}), self._o); return json.dumps(r) if r is not None else self._i.run(*a, **kw)
45
+
46
+
47
+ def guard_tool(guard: Any, tool: Any, on_refused: str = "result", only: Optional[list] = None, skip: Optional[list] = None, tool_name=None) -> Any:
48
+ name = getattr(tool, "name", None) or getattr(tool, "__name__", "tool")
49
+ if not wanted(name, only, skip): return tool
50
+ verified = tool_name(name) if tool_name else name
51
+ BaseTool = _base_tool()
52
+ if BaseTool is not None and isinstance(tool, BaseTool):
53
+ fields = {"name": tool.name, "description": tool.description, "inner": tool, "guard": guard, "verified_name": verified, "on_refused": on_refused}
54
+ if getattr(tool, "args_schema", None) is not None: fields["args_schema"] = tool.args_schema
55
+ return _guarded_class(BaseTool)(**fields)
56
+ return _Proxy(guard, tool, verified, on_refused)
57
+
58
+
59
+ def guard_tools(guard: Any, tools: list, **kw: Any) -> list: return [guard_tool(guard, t, **kw) for t in tools]
@@ -0,0 +1,74 @@
1
+ """
2
+ LangChain / LangGraph (Python) — `langchain-core` tools. Real `BaseTool` instances become a `GuardedTool`
3
+ subclass (so `ToolNode`, `bind_tools` and isinstance checks keep working) that verifies in `_run`/`_arun` before
4
+ delegating to the original. Anything else with `invoke`/`run` gets a duck-typed proxy — no framework import.
5
+
6
+ from mnki.adapters.langchain import guard_tools
7
+ graph = create_react_agent(model, guard_tools(guard, [refund, lookup]))
8
+ """
9
+ from __future__ import annotations
10
+ import json
11
+ from typing import Any, Optional
12
+
13
+ from .shared import check_or_refuse, wanted
14
+
15
+
16
+ def _base_tool():
17
+ try:
18
+ from langchain_core.tools import BaseTool # type: ignore
19
+ return BaseTool
20
+ except ImportError: return None
21
+
22
+
23
+ def _guarded_class(BaseTool: Any) -> Any:
24
+ class GuardedTool(BaseTool): # type: ignore[misc,valid-type]
25
+ inner: Any = None
26
+ guard: Any = None
27
+ verified_name: str = ""
28
+ on_refused: str = "result"
29
+
30
+ # LangChain passes `config` / `run_manager` when the signature declares them; the tool arguments are the rest.
31
+ def _run(self, *a: Any, config: Any = None, run_manager: Any = None, **kw: Any) -> Any:
32
+ args = kw or ({"input": a[0]} if len(a) == 1 else {"args": list(a)})
33
+ r = check_or_refuse(self.guard, self.verified_name, args, self.on_refused)
34
+ if r is not None: return json.dumps(r)
35
+ return self.inner.invoke(args if kw else (a[0] if len(a) == 1 else list(a)), config)
36
+
37
+ async def _arun(self, *a: Any, config: Any = None, run_manager: Any = None, **kw: Any) -> Any:
38
+ args = kw or ({"input": a[0]} if len(a) == 1 else {"args": list(a)})
39
+ r = check_or_refuse(self.guard, self.verified_name, args, self.on_refused)
40
+ if r is not None: return json.dumps(r)
41
+ return await self.inner.ainvoke(args if kw else (a[0] if len(a) == 1 else list(a)), config)
42
+
43
+ GuardedTool.__name__ = "GuardedTool"
44
+ return GuardedTool
45
+
46
+
47
+ class _Proxy:
48
+ """Duck-typed guard for tool-like objects with invoke / ainvoke / run (used when langchain-core is absent)."""
49
+ def __init__(self, guard: Any, inner: Any, verified: str, on_refused: str): self._g, self._i, self._n, self._o = guard, inner, verified, on_refused
50
+ def __getattr__(self, k: str) -> Any: return getattr(self._i, k)
51
+ def _args(self, inp: Any) -> Any: return inp.get("args", inp) if isinstance(inp, dict) and "name" in inp and "args" in inp else inp
52
+ def invoke(self, inp: Any, config: Any = None, **kw: Any) -> Any:
53
+ r = check_or_refuse(self._g, self._n, self._args(inp), self._o); return json.dumps(r) if r is not None else self._i.invoke(inp, config, **kw)
54
+ async def ainvoke(self, inp: Any, config: Any = None, **kw: Any) -> Any:
55
+ r = check_or_refuse(self._g, self._n, self._args(inp), self._o); return json.dumps(r) if r is not None else await self._i.ainvoke(inp, config, **kw)
56
+ def run(self, inp: Any, **kw: Any) -> Any:
57
+ r = check_or_refuse(self._g, self._n, self._args(inp), self._o); return json.dumps(r) if r is not None else self._i.run(inp, **kw)
58
+
59
+
60
+ def guard_tool(guard: Any, tool: Any, on_refused: str = "result", only: Optional[list] = None, skip: Optional[list] = None, tool_name=None) -> Any:
61
+ name = getattr(tool, "name", None) or getattr(tool, "__name__", "tool")
62
+ if not wanted(name, only, skip): return tool
63
+ verified = tool_name(name) if tool_name else name
64
+ BaseTool = _base_tool()
65
+ if BaseTool is not None and isinstance(tool, BaseTool):
66
+ fields = {"name": tool.name, "description": tool.description, "inner": tool, "guard": guard, "verified_name": verified, "on_refused": on_refused}
67
+ for k in ("args_schema", "return_direct", "response_format", "metadata", "tags"):
68
+ v = getattr(tool, k, None)
69
+ if v is not None: fields[k] = v
70
+ return _guarded_class(BaseTool)(**fields)
71
+ return _Proxy(guard, tool, verified, on_refused)
72
+
73
+
74
+ def guard_tools(guard: Any, tools: list, **kw: Any) -> list: return [guard_tool(guard, t, **kw) for t in tools]
@@ -0,0 +1,40 @@
1
+ """
2
+ OpenAI Agents SDK (Python) — `openai-agents`. `FunctionTool.on_invoke_tool(ctx, input_json)` is verified before
3
+ the original runs; every other attribute (name, params_json_schema, strict…) is untouched, so `Agent(tools=…)`
4
+ accepts the guarded copies.
5
+
6
+ from agents import Agent, function_tool
7
+ from mnki.adapters.openai_agents import guard_tools
8
+ agent = Agent(name="invoice-agent", tools=guard_tools(guard, [refund, lookup]))
9
+ """
10
+ from __future__ import annotations
11
+ import copy, json
12
+ from typing import Any, Optional
13
+
14
+ from .shared import check_or_refuse, parse_json_args, wanted
15
+
16
+
17
+ def guard_tool(guard: Any, tool: Any, on_refused: str = "result", only: Optional[list] = None, skip: Optional[list] = None, tool_name=None) -> Any:
18
+ name = getattr(tool, "name", None) or getattr(tool, "__name__", "tool")
19
+ if not wanted(name, only, skip): return tool
20
+ verified = tool_name(name) if tool_name else name
21
+ original = tool.on_invoke_tool
22
+
23
+ async def on_invoke_tool(ctx: Any, input_json: str) -> Any:
24
+ r = check_or_refuse(guard, verified, parse_json_args(input_json), on_refused)
25
+ if r is not None: return json.dumps(r)
26
+ return await original(ctx, input_json)
27
+
28
+ g = copy.copy(tool)
29
+ try: object.__setattr__(g, "on_invoke_tool", on_invoke_tool)
30
+ except (AttributeError, TypeError): g = _replace(tool, on_invoke_tool)
31
+ return g
32
+
33
+
34
+ def _replace(tool: Any, fn: Any) -> Any:
35
+ import dataclasses
36
+ if dataclasses.is_dataclass(tool): return dataclasses.replace(tool, on_invoke_tool=fn)
37
+ raise TypeError("tool does not expose a writable on_invoke_tool")
38
+
39
+
40
+ def guard_tools(guard: Any, tools: list, **kw: Any) -> list: return [guard_tool(guard, t, **kw) for t in tools]
@@ -0,0 +1,55 @@
1
+ """
2
+ Pydantic AI — tools are plain functions registered with `@agent.tool` / `@agent.tool_plain`, or `Tool(...)`
3
+ objects. `guard_tool` wraps the function (sync or async, with or without a `RunContext` first argument) so the
4
+ guard runs before the body; a refusal returns the structured JSON result to the model.
5
+
6
+ from mnki.adapters.pydantic_ai import guard_tool
7
+ @agent.tool_plain
8
+ @guard_tool(guard)
9
+ def refund(customer_id: str, amount: float, currency: str = "EUR") -> str: ...
10
+ """
11
+ from __future__ import annotations
12
+ import functools, inspect, json
13
+ from typing import Any, Callable, Optional
14
+
15
+ from .shared import check_or_refuse, wanted
16
+
17
+
18
+ def _is_run_context(v: Any) -> bool: return type(v).__name__ == "RunContext"
19
+
20
+
21
+ def guard_tool(guard: Any, name: Optional[str] = None, on_refused: str = "result", only: Optional[list] = None, skip: Optional[list] = None) -> Callable:
22
+ def deco(fn: Callable) -> Callable:
23
+ tool = name or fn.__name__
24
+ if not wanted(tool, only, skip): return fn
25
+ sig = inspect.signature(fn)
26
+
27
+ def args_of(a: tuple, kw: dict) -> dict:
28
+ params = list(sig.parameters); vals = list(a)
29
+ if vals and _is_run_context(vals[0]): vals = vals[1:]; params = params[1:]
30
+ out = dict(zip(params, vals)); out.update(kw); return out
31
+
32
+ if inspect.iscoroutinefunction(fn):
33
+ @functools.wraps(fn)
34
+ async def aw(*a: Any, **kw: Any) -> Any:
35
+ r = check_or_refuse(guard, tool, args_of(a, kw), on_refused); return json.dumps(r) if r is not None else await fn(*a, **kw)
36
+ return aw
37
+
38
+ @functools.wraps(fn)
39
+ def w(*a: Any, **kw: Any) -> Any:
40
+ r = check_or_refuse(guard, tool, args_of(a, kw), on_refused); return json.dumps(r) if r is not None else fn(*a, **kw)
41
+ return w
42
+ return deco
43
+
44
+
45
+ def guard_tools(guard: Any, fns: list, **kw: Any) -> list:
46
+ """Wrap plain functions, or `Tool` objects (their `.function` is replaced)."""
47
+ out = []
48
+ for f in fns:
49
+ if callable(f) and not hasattr(f, "function"): out.append(guard_tool(guard, **kw)(f)); continue
50
+ fn = getattr(f, "function", None)
51
+ if callable(fn):
52
+ try: f.function = guard_tool(guard, name=getattr(f, "name", None), **kw)(fn)
53
+ except (AttributeError, TypeError): pass
54
+ out.append(f)
55
+ return out
@@ -0,0 +1,42 @@
1
+ """Shared by every adapter: refusal handling and tool filtering."""
2
+ from __future__ import annotations
3
+ import inspect, json
4
+ from typing import Any, Callable, Optional
5
+
6
+ from ..errors import AgentTrustError
7
+ from ..guard import ApprovalRequired, Denied
8
+
9
+
10
+ def wanted(name: str, only: Optional[list] = None, skip: Optional[list] = None) -> bool:
11
+ return name not in (skip or []) and (not only or name in only)
12
+
13
+
14
+ def refusal(e: BaseException) -> dict:
15
+ """Turn a guard error into the structured result the model sees; anything else is re-raised."""
16
+ if isinstance(e, Denied):
17
+ return {"error": "denied", "message": str(e), "reasons": e.reasons, "evidence": [{k: x[k] for k in ("step", "status", "title", "detail") if k in x} for x in e.evidence if x.get("status") != "pass"], "decision_id": e.decision.get("decision_id")}
18
+ if isinstance(e, ApprovalRequired):
19
+ return {"error": e.code, "message": str(e), "reasons": e.reasons, "decision_id": e.decision_id, "approval_id": e.approval_id}
20
+ if isinstance(e, AgentTrustError):
21
+ return {"error": "verification_failed", "message": f"{e.code}: {e}", "reasons": [e.code]}
22
+ raise e
23
+
24
+
25
+ def refusal_json(e: BaseException) -> str: return json.dumps(refusal(e))
26
+
27
+
28
+ def check_or_refuse(guard: Any, tool: str, args: Any, on_refused: str) -> Optional[dict]:
29
+ """None when the call may proceed; the refusal dict otherwise (or raises when on_refused == "throw")."""
30
+ try: guard.check(tool, args if isinstance(args, dict) else {"input": args}); return None
31
+ except AgentTrustError as e:
32
+ if on_refused == "throw": raise
33
+ return refusal(e)
34
+
35
+
36
+ def parse_json_args(inp: Any) -> Any:
37
+ if not isinstance(inp, str): return inp if inp is not None else {}
38
+ try: return json.loads(inp)
39
+ except ValueError: return {"input": inp}
40
+
41
+
42
+ def is_coroutine_fn(fn: Callable) -> bool: return inspect.iscoroutinefunction(fn)
@@ -0,0 +1,73 @@
1
+ """
2
+ mnki — the Agent Trust SDK for Python.
3
+
4
+ from mnki import AgentTrustClient, AgentIdentity
5
+ c = AgentTrustClient("https://staging.mnki.com", api_key="at_verify_…")
6
+ d = c.verify({"agent": "procurement-7821", "action": "purchase.create", "amount": 2450, "currency": "EUR"})
7
+ d["decision"] # "ALLOW" | "DENY" | "REQUIRE_APPROVAL"; d["evidence"] is the ✓/⚠/✕ list
8
+
9
+ Signing (Agent-Proof, DPoP-style) needs `pip install cryptography`:
10
+ ident = AgentIdentity.create(agent_id) # ES256 key pair; register ident.public_jwk via c.rotate(...)
11
+ c.verify(req, identity=ident) # adds the Agent-Proof header
12
+ """
13
+ from __future__ import annotations
14
+ import base64, hashlib, json, os, time, urllib.request, urllib.error
15
+ from typing import Any, Callable, Optional
16
+ from .identity import AgentIdentity
17
+ from .errors import AgentTrustError
18
+
19
+ def b64url(b: bytes) -> str:
20
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
21
+
22
+ def body_hash(body: bytes | str) -> str:
23
+ return b64url(hashlib.sha256(body.encode() if isinstance(body, str) else body).digest())
24
+
25
+ class AgentTrustClient:
26
+ def __init__(self, base_url: str, api_key: str, opener: Optional[Callable[[urllib.request.Request], Any]] = None, timeout: float = 15.0):
27
+ self.base = base_url.rstrip("/"); self.key = api_key; self._open = opener or (lambda req: urllib.request.urlopen(req, timeout=timeout))
28
+
29
+ def _call(self, method: str, path: str, body: Any = None, headers: Optional[dict] = None, raw: Optional[str] = None) -> Any:
30
+ data = raw.encode() if raw is not None else (None if body is None else json.dumps(body).encode())
31
+ req = urllib.request.Request(f"{self.base}/api{path}", data=data, method=method)
32
+ req.add_header("authorization", f"Bearer {self.key}")
33
+ if data is not None: req.add_header("content-type", "application/json")
34
+ for k, v in (headers or {}).items(): req.add_header(k, v)
35
+ try:
36
+ with self._open(req) as r: return json.loads(r.read() or b"{}")
37
+ except urllib.error.HTTPError as e:
38
+ try: j = json.loads(e.read() or b"{}")
39
+ except Exception: j = {}
40
+ raise AgentTrustError(e.code, j.get("error", f"http_{e.code}"), j.get("detail", j)) from None
41
+
42
+ # --- decisions ---
43
+ def verify(self, request: dict, identity: Optional[AgentIdentity] = None, credential: Optional[str] = None) -> dict:
44
+ body = json.dumps(request); headers = {}
45
+ if identity: headers["agent-proof"] = identity.sign_proof("POST", f"{self.base}/api/v1/verify", body)
46
+ if credential: headers["agent-credential"] = credential
47
+ return self._call("POST", "/v1/verify", headers=headers, raw=body)
48
+ def approval_status(self, approval_id: str) -> dict: return self._call("GET", f"/v1/approvals/{approval_id}/status")
49
+ def decision(self, decision_id: str) -> dict: return self._call("GET", f"/v1/decisions/{decision_id}")
50
+ def authzen(self, subject_id: str, action: str, resource: Optional[dict] = None, context: Optional[dict] = None, properties: Optional[dict] = None) -> dict:
51
+ return self._call("POST", "/access/v1/evaluation", {"subject": {"type": "agent", "id": subject_id}, "action": {"name": action, "properties": properties or {}}, "resource": resource or {}, "context": context or {}})
52
+ # --- agents ---
53
+ def register(self, **agent: Any) -> dict: return self._call("POST", "/v1/agents", agent)
54
+ def agent(self, agent_id: str) -> dict: return self._call("GET", f"/v1/agents/{agent_id}")
55
+ def rotate(self, agent_id: str, public_jwk: dict, kid: str, kind: str = "jwt_svid", issuer: Optional[str] = None) -> dict:
56
+ return self._call("POST", f"/v1/agents/{agent_id}/rotate", {"kind": kind, "kid": kid, "publicKeyJwk": public_jwk, "issuer": issuer})
57
+ def enrol(self, **agent: Any) -> tuple[dict, AgentIdentity]:
58
+ r = self.register(**agent); ident = AgentIdentity.create(r["id"]); self.rotate(r["id"], ident.public_jwk, ident.kid, issuer=agent.get("issuer")); return r, ident
59
+ def attest(self, agent_id: str, kind: str, claims: dict, proof: Optional[dict] = None, expires_at: Optional[str] = None) -> dict:
60
+ return self._call("POST", f"/v1/agents/{agent_id}/attestations", {"kind": kind, "claims": claims, "proof": proof, "expires_at": expires_at})
61
+ def agent_card(self, agent_id: str) -> dict: return self._call("GET", f"/v1/agents/{agent_id}/agent-card")
62
+ # --- authority ---
63
+ def delegate(self, issuer: dict, subject_agent_id: str, capabilities: list, parent_id: Optional[str] = None, task: Optional[str] = None, not_after: Optional[str] = None) -> dict:
64
+ return self._call("POST", "/v1/delegations", {"issuer": issuer, "subjectAgentId": subject_agent_id, "capabilities": capabilities, "parentId": parent_id, "task": task, "notAfter": not_after})
65
+ def credential_chain(self, delegation_id: str) -> dict: return self._call("GET", f"/v1/delegations/{delegation_id}/credential")
66
+ def issue_attestation(self, decision_id: str, ttl_seconds: Optional[int] = None, audience: Optional[str] = None) -> dict:
67
+ return self._call("POST", "/v1/attestations", {"decision_id": decision_id, "ttl_seconds": ttl_seconds, "audience": audience})
68
+ def revoke_attestation(self, attestation_id: str, reason: str) -> dict: return self._call("DELETE", f"/v1/attestations/{attestation_id}", {"reason": reason})
69
+ def status(self, subject: str) -> dict: return self._call("GET", f"/v1/status/{subject}")
70
+ def jwks(self, org_id: str) -> dict:
71
+ with self._open(urllib.request.Request(f"{self.base}/api/v1/orgs/{org_id}/jwks")) as r: return json.loads(r.read())
72
+
73
+ Client = AgentTrustClient
@@ -0,0 +1,12 @@
1
+ """Error taxonomy shared by the Python SDK, the guard and the adapters."""
2
+ from __future__ import annotations
3
+ from typing import Any
4
+
5
+
6
+ class AgentTrustError(Exception):
7
+ """An error returned by the control plane (HTTP status + machine code + detail)."""
8
+ def __init__(self, status: int, code: str, detail: Any = None):
9
+ super().__init__(f"{code} ({status})"); self.status, self.code, self.detail = status, code, detail
10
+
11
+
12
+ MnkiError = AgentTrustError
@@ -0,0 +1,133 @@
1
+ """
2
+ guard() / wrap() for Python: verify a tool call before it runs, against the hosted control plane.
3
+ Modes: observe (log only), warn (run, surface evidence), require_approval (escalate only), enforce.
4
+
5
+ from mnki import AgentTrustClient, Guard, Denied, ApprovalRequired
6
+ g = Guard(AgentTrustClient("https://mnki.com", api_key="at_verify_…"), agent="invoice-agent")
7
+
8
+ @g.wrap("refund.create")
9
+ def refund(customer_id: str, amount: float, currency: str = "EUR"): ...
10
+
11
+ Local mode (no account): `Guard(world=demo_world(), agent="invoice-agent", action_prefix="")` runs the same
12
+ pipeline in-process (`mnki.pipeline`, validated against the conformance vectors).
13
+ """
14
+ from __future__ import annotations
15
+ import asyncio, functools, hashlib, inspect, json, random, time
16
+ from typing import Any, Callable, Optional
17
+
18
+ from .errors import AgentTrustError
19
+ from .identity import AgentIdentity, b64url
20
+ from .local import local_verify
21
+
22
+ Mode = str # "observe" | "warn" | "require_approval" | "enforce"
23
+
24
+
25
+ class Denied(AgentTrustError):
26
+ """The action was verified and refused. `evidence` is the ✓/⚠/✕ list (fail and warn rows first)."""
27
+ def __init__(self, decision: dict):
28
+ reasons = [r for r in decision.get("reasons", []) if not r.endswith("_verified") and r != "authority_valid"]
29
+ super().__init__(403, "denied", decision); self.decision = decision; self.reasons = decision.get("reasons", [])
30
+ self.args = (f"denied: {', '.join(reasons) or 'policy'}",)
31
+ @property
32
+ def evidence(self) -> list:
33
+ order = {"fail": 0, "warn": 1, "pass": 2, "skipped": 3}
34
+ return sorted(self.decision.get("evidence", []), key=lambda e: order.get(e.get("status"), 9))
35
+
36
+
37
+ class ApprovalRequired(AgentTrustError):
38
+ """A human must decide (or already decided against, or the wait ended)."""
39
+ def __init__(self, approval_id: Optional[str], decision_id: str, status: str, reasons: Optional[list] = None):
40
+ code = {"pending": "approval_required", "rejected": "approval_rejected", "expired": "approval_expired"}.get(status, "approval_timeout")
41
+ super().__init__(202, code, {"approval_id": approval_id, "decision_id": decision_id}); self.approval_id, self.decision_id, self.status, self.reasons = approval_id, decision_id, status, reasons or []
42
+
43
+
44
+ def _default_map(_tool: str, args: dict) -> dict:
45
+ out: dict = {}
46
+ amount = args.get("amount", args.get("value"))
47
+ if isinstance(amount, str) and amount.replace(".", "", 1).isdigit(): amount = float(amount)
48
+ if isinstance(amount, (int, float)) and not isinstance(amount, bool): out["amount"] = amount
49
+ cur = args.get("currency")
50
+ if isinstance(cur, str) and len(cur) == 3 and cur.isupper(): out["currency"] = cur
51
+ for k in ("resource", "customer_id", "customerId", "id"):
52
+ if isinstance(args.get(k), str): out["resource"] = args[k]; break
53
+ if isinstance(args.get("region"), str): out["context"] = {"region": args["region"]}
54
+ return out
55
+
56
+
57
+ def _tags(d: dict) -> list:
58
+ t: list = []; r = d.get("reasons", [])
59
+ if d.get("decision") == "DENY": t.append("would_deny")
60
+ if d.get("decision") == "REQUIRE_APPROVAL": t.append("approval_required")
61
+ if "agent_unknown" in r or any(x.startswith("credential_") for x in r): t.append("missing_identity")
62
+ if "no_delegation" in r or any(x.startswith("delegation_") for x in r): t.append("missing_delegation")
63
+ if any(x in r for x in ("capability_missing", "constraint_violated", "budget_exceeded")): t.append("excess_capability")
64
+ if any(x.startswith("policy:") and not x.endswith(":allow") for x in r): t.append("policy_mismatch")
65
+ return t
66
+
67
+
68
+ class Guard:
69
+ def __init__(self, client: Any = None, agent: str = "", identity: Optional[AgentIdentity] = None, mode: Mode = "enforce", action_prefix: str = "tool:", map_args: Optional[Callable[[str, dict], dict]] = None, on_approval: str = "wait", approval_timeout: float = 300.0, initial_delay: float = 1.0, max_delay: float = 15.0, on_decision: Optional[Callable[[dict], None]] = None, sleep: Callable[[float], None] = time.sleep, world: Optional[dict] = None, now: Optional[Callable[[], Any]] = None):
70
+ if mode not in ("observe", "warn", "require_approval", "enforce"): raise ValueError("mode must be observe | warn | require_approval | enforce")
71
+ if client is None and world is None: raise ValueError("guard needs either `client` (hosted) or `world` (local)")
72
+ if not agent: raise ValueError("agent is required")
73
+ self.client, self.agent, self.identity, self.mode, self.prefix, self.world, self._now = client, agent, identity, mode, action_prefix, world, now
74
+ self.map_args, self.on_approval, self.timeout, self.initial_delay, self.max_delay, self.on_decision, self._sleep = map_args or _default_map, on_approval, approval_timeout, initial_delay, max_delay, on_decision, sleep
75
+
76
+ def _input(self, tool: str, args: dict) -> dict:
77
+ mapped = self.map_args(tool, args or {})
78
+ ctx = dict(mapped.pop("context", {}) or {}); ctx.update({"tool": tool, "arguments_hash": b64url(hashlib.sha256(json.dumps(args or {}, sort_keys=True, separators=(",", ":")).encode()).digest())})
79
+ return {"agent": self.agent, "action": f"{self.prefix}{tool}", **mapped, "context": ctx}
80
+
81
+ def wait_for_approval(self, approval_id: str) -> str:
82
+ deadline = time.time() + self.timeout; delay = self.initial_delay
83
+ while True:
84
+ s = self.client.approval_status(approval_id); st = s.get("status")
85
+ if st in ("approved", "rejected", "expired"): return st
86
+ if time.time() >= deadline: return "timeout"
87
+ self._sleep(min(max(0.0, deadline - time.time()), delay + random.random() * 0.25)); delay = min(self.max_delay, delay * 2)
88
+
89
+ def check(self, tool: str, args: Optional[dict] = None) -> dict:
90
+ """Verify one tool call. Returns the decision when the call may proceed; raises Denied / ApprovalRequired otherwise (mode permitting)."""
91
+ if self.client is not None: d = self.client.verify(self._input(tool, args or {}), identity=self.identity)
92
+ else:
93
+ r = local_verify(self.world, self._input(tool, args or {}), self._now() if self._now else None)
94
+ if not r["ok"]: raise AgentTrustError(400, r["code"])
95
+ d = dict(r["result"]); d.update({"request_id": "local", "decision_id": f"local_{int(time.time() * 1000):x}", "approval_id": "local_pending" if d["decision"] == "REQUIRE_APPROVAL" else None, "latency_ms": 0})
96
+ enforced = self.mode == "enforce" or (self.mode == "require_approval" and d.get("decision") == "REQUIRE_APPROVAL")
97
+ record = {"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "mode": self.mode, "tool": tool, "decision": d.get("decision"), "enforced": enforced, "reasons": d.get("reasons", []), "evidence": d.get("evidence", []), "decision_id": d.get("decision_id"), "approval_id": d.get("approval_id"), "tags": _tags(d)}
98
+ if self.on_decision: self.on_decision(record)
99
+ result = {"allowed": d.get("decision") == "ALLOW", "decision": d.get("decision"), "reasons": d.get("reasons", []), "evidence": d.get("evidence", []), "decision_id": d.get("decision_id"), "approval_id": d.get("approval_id"), "enforced": enforced}
100
+ if d.get("decision") == "ALLOW" or not enforced: result["allowed"] = True; return result
101
+ if d.get("decision") == "DENY": raise Denied(d)
102
+ if self.client is None or self.on_approval == "throw" or not d.get("approval_id"): raise ApprovalRequired(d.get("approval_id"), d.get("decision_id", ""), "pending", d.get("reasons"))
103
+ status = self.wait_for_approval(d["approval_id"])
104
+ if status != "approved": raise ApprovalRequired(d["approval_id"], d.get("decision_id", ""), status, d.get("reasons"))
105
+ try: result["attestation"] = self.client.issue_attestation(d["decision_id"]).get("token")
106
+ except AgentTrustError: pass
107
+ result["allowed"] = True; return result
108
+
109
+ def wrap(self, tool: Optional[str] = None):
110
+ """Decorator: the function runs only after `check` resolves. Keyword arguments (or a single dict) are the tool arguments."""
111
+ def deco(fn: Callable):
112
+ name = tool or fn.__name__
113
+ if inspect.iscoroutinefunction(fn):
114
+ @functools.wraps(fn)
115
+ async def aw(*a, **kw):
116
+ await asyncio.get_running_loop().run_in_executor(None, self.check, name, _args(a, kw)); return await fn(*a, **kw)
117
+ return aw
118
+ @functools.wraps(fn)
119
+ def w(*a, **kw):
120
+ self.check(name, _args(a, kw)); return fn(*a, **kw)
121
+ return w
122
+ return deco
123
+
124
+
125
+ def _args(a: tuple, kw: dict) -> dict:
126
+ if kw: return dict(kw)
127
+ if len(a) == 1 and isinstance(a[0], dict): return a[0]
128
+ return {"args": list(a)} if a else {}
129
+
130
+
131
+ def wrap(client: Any, agent: str, tool: Optional[str] = None, **guard_kw):
132
+ """`@mnki.wrap(client, "invoice-agent", "refund.create")` — a one-line guard for a single tool."""
133
+ return Guard(client, agent, **guard_kw).wrap(tool)