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 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
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from .cli import main
3
+ sys.exit(main())
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()