mayi 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mayi/__init__.py +18 -0
- mayi/__main__.py +3 -0
- mayi/adapters.py +84 -0
- mayi/agent_client.py +66 -0
- mayi/approvals.py +79 -0
- mayi/audit.py +93 -0
- mayi/boundary.py +477 -0
- mayi/catalog.py +265 -0
- mayi/cli.py +380 -0
- mayi/client.py +66 -0
- mayi/diff.py +94 -0
- mayi/explain.py +91 -0
- mayi/gateway.py +596 -0
- mayi/generate.py +80 -0
- mayi/integrations/__init__.py +0 -0
- mayi/integrations/anthropic.py +121 -0
- mayi/integrations/langgraph.py +126 -0
- mayi/integrations/mcp.py +52 -0
- mayi/limits.py +43 -0
- mayi/mcp_proxy.py +242 -0
- mayi/notify.py +54 -0
- mayi/prove.py +175 -0
- mayi/provenance.py +88 -0
- mayi/report.py +116 -0
- mayi/resolve.py +29 -0
- mayi/results.py +28 -0
- mayi/schema.py +43 -0
- mayi/seal.py +96 -0
- mayi/signing.py +157 -0
- mayi/store.py +131 -0
- mayi/validators.py +68 -0
- mayi-0.0.0.dist-info/METADATA +282 -0
- mayi-0.0.0.dist-info/RECORD +38 -0
- mayi-0.0.0.dist-info/WHEEL +5 -0
- mayi-0.0.0.dist-info/entry_points.txt +2 -0
- mayi-0.0.0.dist-info/licenses/LICENSE +202 -0
- mayi-0.0.0.dist-info/licenses/NOTICE +4 -0
- mayi-0.0.0.dist-info/top_level.txt +1 -0
mayi/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .approvals import ApprovalSet, ApprovalToken
|
|
2
|
+
from .audit import SqliteAuditLog
|
|
3
|
+
from .boundary import ToolBoundary
|
|
4
|
+
from .catalog import Catalog, CatalogError, Pen
|
|
5
|
+
from .diff import CatalogDiff, diff_catalogs
|
|
6
|
+
from .report import GovernanceReport, generate_report
|
|
7
|
+
from .resolve import SourcePathError, dotted_lookup
|
|
8
|
+
from .results import Escalated, Executed, Rejected
|
|
9
|
+
from .schema import hide_application_pen, tools_for_model
|
|
10
|
+
from .seal import MayiEscalationRequired, MayiRejected, check_no_direct_exposure, gate
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ApprovalSet", "ApprovalToken", "ToolBoundary", "Catalog", "CatalogError", "Pen",
|
|
14
|
+
"Escalated", "Executed", "Rejected", "SqliteAuditLog", "hide_application_pen", "tools_for_model",
|
|
15
|
+
"dotted_lookup", "SourcePathError", "GovernanceReport", "generate_report",
|
|
16
|
+
"CatalogDiff", "diff_catalogs",
|
|
17
|
+
"gate", "MayiEscalationRequired", "MayiRejected", "check_no_direct_exposure",
|
|
18
|
+
]
|
mayi/__main__.py
ADDED
mayi/adapters.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapters that put an agent framework's tools behind a mayi gateway.
|
|
3
|
+
|
|
4
|
+
Each takes the tool definitions in the Anthropic shape (name, description,
|
|
5
|
+
input_schema -- what `mayi.agent_client.tool_schemas` returns) and returns
|
|
6
|
+
framework tools whose every invocation goes through MayiClient.call. The
|
|
7
|
+
tool body never runs in your process: the gateway decides, and runs the real
|
|
8
|
+
tool only when the rules allow. A held or rejected call comes back to the
|
|
9
|
+
model as a plain message saying so.
|
|
10
|
+
|
|
11
|
+
from mayi.adapters import openai_agents_tools, crewai_tools
|
|
12
|
+
tools = openai_agents_tools(MayiClient(), "ticket-1042", schemas)
|
|
13
|
+
|
|
14
|
+
The framework packages are imported only when you call the adapter.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .client import MayiClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def outcome_text(res: dict) -> str:
|
|
26
|
+
"""What the model is told. Keeps the reason, drops nothing a model needs to explain itself."""
|
|
27
|
+
o = res.get("outcome")
|
|
28
|
+
if o == "executed":
|
|
29
|
+
return json.dumps({"status": "done", "result": res.get("result")}, default=str)
|
|
30
|
+
if o == "escalated":
|
|
31
|
+
return json.dumps({"status": "waiting for a person to approve", "argument": res.get("arg"),
|
|
32
|
+
"reason": res.get("reason")}, default=str)
|
|
33
|
+
return json.dumps({"status": "not allowed", "reason": res.get("reason")}, default=str)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _invoke(client: MayiClient, session_id: str, tool: str, args: dict) -> str:
|
|
37
|
+
return outcome_text(client.call(session_id, tool, args))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def openai_agents_tools(client: MayiClient, session_id: str, schemas: list[dict]) -> list[Any]:
|
|
41
|
+
from agents import FunctionTool # type: ignore[import-not-found]
|
|
42
|
+
|
|
43
|
+
tools = []
|
|
44
|
+
for sch in schemas:
|
|
45
|
+
name = sch["name"]
|
|
46
|
+
|
|
47
|
+
async def on_invoke(ctx: Any, raw: str, _name: str = name) -> str:
|
|
48
|
+
try:
|
|
49
|
+
args = json.loads(raw or "{}")
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
return json.dumps({"status": "not allowed", "reason": "arguments were not valid JSON"})
|
|
52
|
+
if not isinstance(args, dict):
|
|
53
|
+
return json.dumps({"status": "not allowed", "reason": "arguments must be an object"})
|
|
54
|
+
return _invoke(client, session_id, _name, args)
|
|
55
|
+
|
|
56
|
+
schema = dict(sch["input_schema"])
|
|
57
|
+
schema.setdefault("additionalProperties", False)
|
|
58
|
+
tools.append(FunctionTool(name=name, description=sch.get("description", name),
|
|
59
|
+
params_json_schema=schema, on_invoke_tool=on_invoke,
|
|
60
|
+
strict_json_schema=False))
|
|
61
|
+
return tools
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def crewai_tools(client: MayiClient, session_id: str, schemas: list[dict]) -> list[Any]:
|
|
65
|
+
from crewai.tools import BaseTool # type: ignore[import-not-found]
|
|
66
|
+
from pydantic import Field, create_model
|
|
67
|
+
|
|
68
|
+
tools = []
|
|
69
|
+
for sch in schemas:
|
|
70
|
+
name = sch["name"]
|
|
71
|
+
props = sch["input_schema"].get("properties", {})
|
|
72
|
+
Args = create_model(f"{name}_args", **{p: (Any, Field(default=None, description=d.get("description", p)))
|
|
73
|
+
for p, d in props.items()})
|
|
74
|
+
|
|
75
|
+
def _run(self: Any, _name: str = name, **kwargs: Any) -> str:
|
|
76
|
+
return _invoke(client, session_id, _name, {k: v for k, v in kwargs.items() if v is not None})
|
|
77
|
+
|
|
78
|
+
cls = type(f"Mayi_{name}", (BaseTool,), {
|
|
79
|
+
"__module__": __name__,
|
|
80
|
+
"__annotations__": {"name": str, "description": str, "args_schema": type},
|
|
81
|
+
"name": name, "description": sch.get("description", name),
|
|
82
|
+
"args_schema": Args, "_run": _run})
|
|
83
|
+
tools.append(cls())
|
|
84
|
+
return tools
|
mayi/agent_client.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A model-agnostic agent loop that talks to a mayi gateway with the AGENT key.
|
|
3
|
+
|
|
4
|
+
The agent sees only the arguments it is allowed to propose (model and human
|
|
5
|
+
pens). Application-pen and derived arguments are never offered to it, and
|
|
6
|
+
anything it sends for them is discarded by the gateway anyway.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _post(base: str, key: str, path: str, body: dict) -> dict:
|
|
17
|
+
r = urllib.request.Request(base + path, json.dumps(body).encode(), method="POST",
|
|
18
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
|
19
|
+
with urllib.request.urlopen(r, timeout=30) as resp: # nosec B310 (operator-configured URL)
|
|
20
|
+
return json.loads(resp.read())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tool_schemas(catalog_view: dict, descriptions: dict[str, str] | None = None) -> list[dict]:
|
|
24
|
+
"""Anthropic tool schemas from the gateway's /v1/catalog view (agent-proposable args only)."""
|
|
25
|
+
out = []
|
|
26
|
+
for tool, args in catalog_view["tools"].items():
|
|
27
|
+
props = {a: {"description": f"{a} ({pen}-controlled)"} for a, pen in args.items()
|
|
28
|
+
if pen in ("model", "human")}
|
|
29
|
+
out.append({"name": tool, "description": (descriptions or {}).get(tool, tool),
|
|
30
|
+
"input_schema": {"type": "object", "properties": props, "required": list(props)}})
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_agent(client: Any, model: str, base: str, agent_key: str, session_id: str,
|
|
35
|
+
tools: list[dict], task: str, max_turns: int = 6, log=print,
|
|
36
|
+
wait_for_approval: float = 0, poll: float = 2.0, session_token: str | None = None) -> list[dict]:
|
|
37
|
+
"""Run a tool-use loop; every tool call goes through the gateway. Returns the gateway outcomes."""
|
|
38
|
+
messages = [{"role": "user", "content": task}]
|
|
39
|
+
outcomes: list[dict] = []
|
|
40
|
+
for _ in range(max_turns):
|
|
41
|
+
resp = client.messages.create(model=model, max_tokens=1024, tools=tools, messages=messages)
|
|
42
|
+
uses = [b for b in resp.content if getattr(b, "type", "") == "tool_use"]
|
|
43
|
+
if not uses:
|
|
44
|
+
break
|
|
45
|
+
messages.append({"role": "assistant", "content": resp.content})
|
|
46
|
+
results = []
|
|
47
|
+
for u in uses:
|
|
48
|
+
body = {"session_id": session_id, "tool": u.name, "args": u.input}
|
|
49
|
+
if session_token:
|
|
50
|
+
body["session_token"] = session_token
|
|
51
|
+
r = _post(base, agent_key, "/v1/call", body)
|
|
52
|
+
if r.get("outcome") == "escalated" and wait_for_approval:
|
|
53
|
+
# Hold the call open for a human decision. simulate never consumes
|
|
54
|
+
# an approval; only the final /v1/call executes (once).
|
|
55
|
+
log(f" waiting up to {wait_for_approval:.0f}s for a human to approve {u.name}...")
|
|
56
|
+
deadline = time.time() + wait_for_approval
|
|
57
|
+
while time.time() < deadline:
|
|
58
|
+
time.sleep(poll)
|
|
59
|
+
if _post(base, agent_key, "/v1/simulate", body).get("outcome") == "simulated":
|
|
60
|
+
r = _post(base, agent_key, "/v1/call", body)
|
|
61
|
+
break
|
|
62
|
+
outcomes.append(r)
|
|
63
|
+
log(f" model proposed {u.name}({json.dumps(u.input)}) -> {r['outcome']} {r.get('reason', '')}")
|
|
64
|
+
results.append({"type": "tool_result", "tool_use_id": u.id, "content": json.dumps(r)})
|
|
65
|
+
messages.append({"role": "user", "content": results})
|
|
66
|
+
return outcomes
|
mayi/approvals.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
An approval token covers one tool, one argument, one value. It is never
|
|
3
|
+
a standing "whatever it needs" grant — a bounded standing approval is
|
|
4
|
+
expressed in the catalog's `limit` instead (see limits.py), reviewed and
|
|
5
|
+
committed once, not minted per call.
|
|
6
|
+
|
|
7
|
+
Tokens are single-use and may carry an expiry. A token that already fired,
|
|
8
|
+
or that has expired, is exactly as absent as one that was never issued.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ApprovalToken:
|
|
20
|
+
tool: str
|
|
21
|
+
arg: str
|
|
22
|
+
value: Any
|
|
23
|
+
approved_by: str
|
|
24
|
+
expires_at: Optional[float] = None # epoch seconds; None = no expiry
|
|
25
|
+
used: bool = False
|
|
26
|
+
# Optional resource scope, e.g. {"ticket_id": "T-9"}: the approval holds
|
|
27
|
+
# only for a call whose application-pen (trusted) argument values equal
|
|
28
|
+
# these. Keys are checked against values the application resolved, never
|
|
29
|
+
# against anything the model proposed. A scoped token with no matching
|
|
30
|
+
# trusted context is exactly as absent as no token (fails closed).
|
|
31
|
+
scope: Optional[dict] = None
|
|
32
|
+
approvers: tuple = () # for two-person approvals: every distinct person who signed off
|
|
33
|
+
store_id: Optional[int] = None # row id when a StateStore keeps this token
|
|
34
|
+
|
|
35
|
+
def matches(self, tool: str, arg: str, value: Any, context: Optional[dict] = None) -> bool:
|
|
36
|
+
if not (self.tool == tool and self.arg == arg and self.value == value):
|
|
37
|
+
return False
|
|
38
|
+
if self.scope:
|
|
39
|
+
context = context or {}
|
|
40
|
+
return all(k in context and context[k] == v for k, v in self.scope.items())
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def is_live(self, now: Optional[float] = None) -> bool:
|
|
44
|
+
now = time.time() if now is None else now
|
|
45
|
+
return not self.used and (self.expires_at is None or now <= self.expires_at)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ApprovalSet:
|
|
49
|
+
"""A small collection of tokens gathered for one call attempt."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, tokens: Optional[list[ApprovalToken]] = None, on_use: Optional[Any] = None):
|
|
52
|
+
self._tokens = tokens or []
|
|
53
|
+
self.on_use = on_use # called with the token the moment it is consumed, before the tool runs
|
|
54
|
+
|
|
55
|
+
def add(self, token: "ApprovalToken") -> None:
|
|
56
|
+
self._tokens.append(token)
|
|
57
|
+
|
|
58
|
+
def take(self, tool: str, arg: str, value: Any, now: Optional[float] = None,
|
|
59
|
+
context: Optional[dict] = None) -> Optional[ApprovalToken]:
|
|
60
|
+
"""
|
|
61
|
+
Find a live token matching exactly this tool/arg/value and consume
|
|
62
|
+
it. Returns None if no such token exists, is expired, or was
|
|
63
|
+
already used by an earlier call in this same run.
|
|
64
|
+
"""
|
|
65
|
+
for token in self._tokens:
|
|
66
|
+
if token.matches(tool, arg, value, context) and token.is_live(now):
|
|
67
|
+
token.used = True
|
|
68
|
+
if self.on_use is not None:
|
|
69
|
+
self.on_use(token)
|
|
70
|
+
return token
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def peek(self, tool: str, arg: str, value: Any, now: Optional[float] = None,
|
|
74
|
+
context: Optional[dict] = None) -> Optional[ApprovalToken]:
|
|
75
|
+
"""Like take(), but does not consume the token (used by dry runs)."""
|
|
76
|
+
for token in self._tokens:
|
|
77
|
+
if token.matches(tool, arg, value, context) and token.is_live(now):
|
|
78
|
+
return token
|
|
79
|
+
return None
|
mayi/audit.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A durable, queryable audit log backed by SQLite. Pass an instance of
|
|
3
|
+
SqliteAuditLog as ToolBoundary's audit_log callable, or write your own --
|
|
4
|
+
the boundary only needs something callable with one dict argument.
|
|
5
|
+
|
|
6
|
+
Known fields get their own column so they're queryable with plain SQL.
|
|
7
|
+
Anything else (e.g. a field an integration adds) is kept, not dropped, in `extra`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sqlite3
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
_KNOWN = (
|
|
18
|
+
"tool", "outcome", "actor", "catalog_hash", "catalog_version", "catalog_reviewed_by",
|
|
19
|
+
"model_proposed", "pens", "notes", "reason", "escalated_arg", "escalated_value",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SqliteAuditLog:
|
|
24
|
+
def __init__(self, path: str = "mayi_audit.db"):
|
|
25
|
+
self.path = path
|
|
26
|
+
self._conn = sqlite3.connect(path)
|
|
27
|
+
self._conn.execute("""
|
|
28
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
ts REAL NOT NULL,
|
|
31
|
+
tool TEXT,
|
|
32
|
+
outcome TEXT NOT NULL,
|
|
33
|
+
actor TEXT,
|
|
34
|
+
catalog_hash TEXT,
|
|
35
|
+
catalog_version TEXT,
|
|
36
|
+
catalog_reviewed_by TEXT,
|
|
37
|
+
model_proposed TEXT NOT NULL,
|
|
38
|
+
pens TEXT NOT NULL,
|
|
39
|
+
notes TEXT NOT NULL,
|
|
40
|
+
reason TEXT,
|
|
41
|
+
escalated_arg TEXT,
|
|
42
|
+
escalated_value TEXT,
|
|
43
|
+
extra TEXT NOT NULL
|
|
44
|
+
)
|
|
45
|
+
""")
|
|
46
|
+
self._conn.commit()
|
|
47
|
+
|
|
48
|
+
def __call__(self, record: dict) -> None:
|
|
49
|
+
extra = {k: v for k, v in record.items() if k not in _KNOWN}
|
|
50
|
+
self._conn.execute(
|
|
51
|
+
"INSERT INTO audit_log (ts, tool, outcome, actor, catalog_hash, catalog_version, "
|
|
52
|
+
"catalog_reviewed_by, model_proposed, pens, notes, reason, escalated_arg, "
|
|
53
|
+
"escalated_value, extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
54
|
+
(
|
|
55
|
+
time.time(), record.get("tool"), record["outcome"], record.get("actor"),
|
|
56
|
+
record.get("catalog_hash"), record.get("catalog_version"), record.get("catalog_reviewed_by"),
|
|
57
|
+
json.dumps(record.get("model_proposed", {})), json.dumps(record.get("pens", {})),
|
|
58
|
+
json.dumps(record.get("notes", {})), record.get("reason"),
|
|
59
|
+
record.get("escalated_arg"), json.dumps(record.get("escalated_value"))
|
|
60
|
+
if "escalated_value" in record else None,
|
|
61
|
+
json.dumps(extra),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
self._conn.commit()
|
|
65
|
+
|
|
66
|
+
_COLUMNS = ("id", "ts", "tool", "outcome", "actor", "catalog_hash", "catalog_version",
|
|
67
|
+
"catalog_reviewed_by", "model_proposed", "pens", "notes", "reason",
|
|
68
|
+
"escalated_arg", "escalated_value", "extra")
|
|
69
|
+
_SELECT_ALL = (
|
|
70
|
+
"SELECT id, ts, tool, outcome, actor, catalog_hash, catalog_version, catalog_reviewed_by, "
|
|
71
|
+
"model_proposed, pens, notes, reason, escalated_arg, escalated_value, extra "
|
|
72
|
+
"FROM audit_log ORDER BY id"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def all(self) -> list[dict]:
|
|
76
|
+
rows = self._conn.execute(self._SELECT_ALL).fetchall()
|
|
77
|
+
out = []
|
|
78
|
+
for row in rows:
|
|
79
|
+
d = dict(zip(self._COLUMNS, row))
|
|
80
|
+
d["model_proposed"] = json.loads(d["model_proposed"])
|
|
81
|
+
d["pens"] = json.loads(d["pens"])
|
|
82
|
+
d["notes"] = json.loads(d["notes"])
|
|
83
|
+
d["escalated_value"] = json.loads(d["escalated_value"]) if d["escalated_value"] is not None else None
|
|
84
|
+
extra = json.loads(d.pop("extra"))
|
|
85
|
+
d.update(extra) # extra fields land back at the top level, same as what was logged
|
|
86
|
+
out.append(d)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
def rejected_and_escalated(self) -> list[dict]:
|
|
90
|
+
return [r for r in self.all() if r["outcome"] != "Executed"]
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
self._conn.close()
|