superuser 0.1.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.
superuser/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Superuser: a permission-gated sysadmin MCP agent with Cisco AI Defense guardrails."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
superuser/__main__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Entry point: `superuser` / `python -m superuser`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+
9
+ from .config import Config
10
+ from .server import build_server
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="superuser",
16
+ description="Permission-gated superuser MCP agent with Cisco AI Defense guardrails.",
17
+ )
18
+ parser.add_argument("--config", help="Path to a YAML config file (or set SUPERUSER_CONFIG).")
19
+ parser.add_argument(
20
+ "--transport", default="stdio", choices=["stdio", "sse", "streamable-http"],
21
+ help="MCP transport (default: stdio).",
22
+ )
23
+ parser.add_argument("--log-level", default="INFO")
24
+ parser.add_argument(
25
+ "--check", action="store_true",
26
+ help="Validate config and print security status, then exit (no server).",
27
+ )
28
+ args = parser.parse_args(argv)
29
+
30
+ logging.basicConfig(
31
+ level=getattr(logging, args.log_level.upper(), logging.INFO),
32
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
33
+ stream=sys.stderr,
34
+ )
35
+
36
+ config = Config.load(args.config)
37
+ server = build_server(config)
38
+
39
+ if args.check:
40
+ from .authz import DuoAuthorizer
41
+ from .billing import StripeMeter
42
+ from .security import CiscoAIDefense
43
+
44
+ defense = CiscoAIDefense(config.cisco)
45
+ authorizer = DuoAuthorizer(config.duo)
46
+ meter = StripeMeter(config.stripe)
47
+ print("Superuser agent configuration OK.")
48
+ print(f" Human owner: {config.human_owner}")
49
+ print(f" Approval required at: {config.approval_required_at.name}")
50
+ print(f" Audit log: {config.audit_log_path}")
51
+ print(f" Cisco AI Defense: {defense.status}")
52
+ print(f" Cisco Duo (authz): {authorizer.status}")
53
+ print(f" Stripe billing: {meter.status}")
54
+ print(f" GitHub configured: {bool(config.github.token)}")
55
+ return 0
56
+
57
+ server.run(transport=args.transport)
58
+ return 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ raise SystemExit(main())
superuser/audit.py ADDED
@@ -0,0 +1,70 @@
1
+ """Append-only audit logging.
2
+
3
+ Every tool invocation produces an identity-correlated record:
4
+ ``human owner -> agent -> tool -> action -> outcome``. This is the local
5
+ counterpart to the audit chain enforced by Cisco Duo's MCP gateway, and it is
6
+ what makes the agent's actions reviewable after the fact.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import threading
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ # Argument keys whose values must never be written to the audit log verbatim.
19
+ _SENSITIVE_KEYS = {"token", "api_key", "apikey", "password", "secret", "authorization"}
20
+
21
+
22
+ def _redact(value: Any) -> Any:
23
+ if isinstance(value, dict):
24
+ return {
25
+ k: ("***REDACTED***" if k.lower() in _SENSITIVE_KEYS else _redact(v))
26
+ for k, v in value.items()
27
+ }
28
+ if isinstance(value, list):
29
+ return [_redact(v) for v in value]
30
+ if isinstance(value, str) and len(value) > 2000:
31
+ return value[:2000] + f"...[truncated {len(value) - 2000} chars]"
32
+ return value
33
+
34
+
35
+ class AuditLog:
36
+ """Thread-safe JSONL audit logger."""
37
+
38
+ def __init__(self, path: str | os.PathLike[str], human_owner: str = "unknown") -> None:
39
+ self.path = Path(path)
40
+ self.human_owner = human_owner
41
+ self._lock = threading.Lock()
42
+ if self.path.parent and not self.path.parent.exists():
43
+ self.path.parent.mkdir(parents=True, exist_ok=True)
44
+
45
+ def record(
46
+ self,
47
+ tool: str,
48
+ arguments: dict[str, Any],
49
+ decision: str,
50
+ status: str,
51
+ *,
52
+ risk: str | None = None,
53
+ detail: str | None = None,
54
+ ) -> dict[str, Any]:
55
+ entry = {
56
+ "timestamp": datetime.now(timezone.utc).isoformat(),
57
+ "human_owner": self.human_owner,
58
+ "agent": "superuser",
59
+ "tool": tool,
60
+ "arguments": _redact(arguments),
61
+ "risk": risk,
62
+ "decision": decision,
63
+ "status": status,
64
+ "detail": detail,
65
+ }
66
+ line = json.dumps(entry, ensure_ascii=False)
67
+ with self._lock:
68
+ with self.path.open("a", encoding="utf-8") as fh:
69
+ fh.write(line + "\n")
70
+ return entry
superuser/authz.py ADDED
@@ -0,0 +1,155 @@
1
+ """Cisco Duo Agentic Identity authorization.
2
+
3
+ Implements the client side of Duo's "Duo decides, the gateway enforces" model
4
+ for per-tool-call authorization:
5
+
6
+ - The agent authenticates as a registered non-human identity using the OAuth 2.1
7
+ client-credentials grant, requesting scopes (and, per RFC 8707, a resource
8
+ indicator naming the audience it intends to access).
9
+ - Before each tool runs, the call is authorized either by an explicit Duo
10
+ decision endpoint (``authorization_url``) or, by default, by checking the
11
+ tool's required scope against the scopes Duo granted in the token.
12
+
13
+ Only ``requests`` is needed (no extra SDK). When Duo is not configured the
14
+ authorizer degrades gracefully: calls are allowed but flagged UNGOVERNED. In
15
+ ``enforce`` mode, authorization errors fail closed; in ``monitor`` they are
16
+ logged and allowed.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ from dataclasses import dataclass
24
+
25
+ import requests
26
+
27
+ from .config import DuoConfig
28
+
29
+ logger = logging.getLogger("superuser.authz")
30
+
31
+
32
+ @dataclass
33
+ class AuthzDecision:
34
+ allowed: bool
35
+ governed: bool
36
+ reason: str
37
+
38
+ @classmethod
39
+ def ungoverned(cls) -> "AuthzDecision":
40
+ return cls(allowed=True, governed=False, reason="Duo not configured")
41
+
42
+ @classmethod
43
+ def allow(cls, reason: str = "authorized") -> "AuthzDecision":
44
+ return cls(allowed=True, governed=True, reason=reason)
45
+
46
+ @classmethod
47
+ def deny(cls, reason: str) -> "AuthzDecision":
48
+ return cls(allowed=False, governed=True, reason=reason)
49
+
50
+
51
+ class DuoAuthorizer:
52
+ def __init__(self, config: DuoConfig) -> None:
53
+ self.config = config
54
+ self._token: str | None = None
55
+ self._granted_scopes: set[str] = set()
56
+ self._token_expiry: float = 0.0
57
+
58
+ def _fetch_token(self) -> str | None:
59
+ """Client-credentials grant; caches the token until shortly before expiry."""
60
+ now = time.time()
61
+ if self._token and now < self._token_expiry:
62
+ return self._token
63
+
64
+ data = {"grant_type": "client_credentials"}
65
+ if self.config.scopes:
66
+ data["scope"] = " ".join(self.config.scopes)
67
+ if self.config.resource:
68
+ data["resource"] = self.config.resource # RFC 8707
69
+
70
+ resp = requests.post(
71
+ self.config.token_url,
72
+ data=data,
73
+ auth=(self.config.client_id, self.config.client_secret),
74
+ headers={"Accept": "application/json"},
75
+ timeout=15,
76
+ )
77
+ if resp.status_code >= 400:
78
+ raise RuntimeError(f"Duo token endpoint {resp.status_code}: {resp.text[:300]}")
79
+
80
+ body = resp.json()
81
+ self._token = body.get("access_token")
82
+ granted = body.get("scope", "")
83
+ self._granted_scopes = set(granted.split()) if granted else set(self.config.scopes)
84
+ self._token_expiry = now + int(body.get("expires_in", 300)) - 30
85
+ return self._token
86
+
87
+ def authorize(self, tool_name: str, arguments: dict, risk: str) -> AuthzDecision:
88
+ if not self.config.can_enforce:
89
+ return AuthzDecision.ungoverned()
90
+ try:
91
+ token = self._fetch_token()
92
+ if not token:
93
+ raise RuntimeError("no access token returned by Duo")
94
+
95
+ if self.config.authorization_url:
96
+ return self._authorize_via_endpoint(token, tool_name, arguments, risk)
97
+ return self._authorize_via_scopes(tool_name)
98
+ except Exception as exc:
99
+ logger.error("Duo authorization error for %s: %s", tool_name, exc)
100
+ if self.config.mode == "monitor":
101
+ return AuthzDecision(True, True, f"authorization error (monitor): {exc}")
102
+ return AuthzDecision.deny(f"authorization error: {exc}")
103
+
104
+ def _authorize_via_scopes(self, tool_name: str) -> AuthzDecision:
105
+ required = self.config.tool_scopes.get(tool_name)
106
+ if required is None:
107
+ # No scope mapping for this tool: allow but note it's unmapped.
108
+ return AuthzDecision.allow("no scope requirement mapped")
109
+ if required in self._granted_scopes:
110
+ return AuthzDecision.allow(f"scope '{required}' granted")
111
+ reason = f"missing required scope '{required}'"
112
+ if self.config.mode == "monitor":
113
+ logger.warning("Duo (monitor) would deny %s: %s", tool_name, reason)
114
+ return AuthzDecision(True, True, f"{reason} (monitor)")
115
+ return AuthzDecision.deny(reason)
116
+
117
+ def _authorize_via_endpoint(
118
+ self, token: str, tool_name: str, arguments: dict, risk: str
119
+ ) -> AuthzDecision:
120
+ resp = requests.post(
121
+ self.config.authorization_url,
122
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
123
+ json={
124
+ "agent_id": self.config.agent_id,
125
+ "resource": self.config.resource,
126
+ "tool": tool_name,
127
+ "action": tool_name,
128
+ "risk": risk,
129
+ "arguments": arguments,
130
+ },
131
+ timeout=15,
132
+ )
133
+ if resp.status_code == 403:
134
+ return AuthzDecision.deny("Duo policy denied this tool call")
135
+ if resp.status_code >= 400:
136
+ raise RuntimeError(f"Duo decision endpoint {resp.status_code}: {resp.text[:300]}")
137
+
138
+ body = resp.json() if resp.text else {}
139
+ decision = str(body.get("decision", body.get("action", "allow"))).lower()
140
+ if decision in ("allow", "permit", "allowed"):
141
+ return AuthzDecision.allow(body.get("reason", "authorized by Duo"))
142
+ reason = body.get("reason", "denied by Duo policy")
143
+ if self.config.mode == "monitor":
144
+ return AuthzDecision(True, True, f"{reason} (monitor)")
145
+ return AuthzDecision.deny(reason)
146
+
147
+ @property
148
+ def status(self) -> dict:
149
+ return {
150
+ "enabled": self.config.enabled,
151
+ "mode": self.config.mode,
152
+ "enforcing": self.config.can_enforce,
153
+ "agent_id": self.config.agent_id,
154
+ "authorization": "endpoint" if self.config.authorization_url else "scopes",
155
+ }
superuser/billing.py ADDED
@@ -0,0 +1,91 @@
1
+ """Stripe usage-based (metered) billing.
2
+
3
+ After a tool call is authorized and executed successfully, the agent reports a
4
+ Stripe meter event so it can be sold per-action. This uses Stripe's v2 billing
5
+ meter events API via the official ``stripe`` SDK.
6
+
7
+ Billing is strictly best-effort: it must never block or fail a tool call. If the
8
+ SDK is missing, no key is configured, or Stripe returns an error, the failure is
9
+ logged and the tool result is returned unchanged (annotated ``billed: false``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import uuid
16
+ from dataclasses import dataclass
17
+
18
+ from .config import StripeConfig
19
+
20
+ logger = logging.getLogger("superuser.billing")
21
+
22
+
23
+ @dataclass
24
+ class BillingResult:
25
+ billed: bool
26
+ value: int = 0
27
+ identifier: str | None = None
28
+ detail: str | None = None
29
+
30
+
31
+ class StripeMeter:
32
+ def __init__(self, config: StripeConfig) -> None:
33
+ self.config = config
34
+ self._client = None
35
+ self._available = self._probe_sdk()
36
+ if config.enabled and not self._available:
37
+ logger.warning(
38
+ "Stripe SDK not installed; usage will not be metered. "
39
+ "Install with: pip install 'superuser[billing]'"
40
+ )
41
+
42
+ @staticmethod
43
+ def _probe_sdk() -> bool:
44
+ try:
45
+ import stripe # noqa: F401
46
+ except Exception:
47
+ return False
48
+ return True
49
+
50
+ def _get_client(self):
51
+ if self._client is not None:
52
+ return self._client
53
+ if not (self._available and self.config.can_bill):
54
+ return None
55
+ import stripe
56
+
57
+ self._client = stripe.StripeClient(self.config.api_key)
58
+ return self._client
59
+
60
+ def record(self, tool_name: str) -> BillingResult:
61
+ if not self.config.enabled:
62
+ return BillingResult(billed=False, detail="billing disabled")
63
+ client = self._get_client()
64
+ if client is None:
65
+ return BillingResult(billed=False, detail="billing not configured")
66
+
67
+ value = self.config.value_for(tool_name)
68
+ identifier = f"{tool_name}:{uuid.uuid4()}"
69
+ try:
70
+ client.v2.billing.meter_events.create({
71
+ "event_name": self.config.event_name,
72
+ "identifier": identifier,
73
+ "payload": {
74
+ "stripe_customer_id": self.config.customer_id,
75
+ "value": str(value),
76
+ "tool": tool_name,
77
+ },
78
+ })
79
+ return BillingResult(billed=True, value=value, identifier=identifier)
80
+ except Exception as exc: # billing must never break the tool call
81
+ logger.error("Stripe meter event failed for %s: %s", tool_name, exc)
82
+ return BillingResult(billed=False, value=value, detail=str(exc))
83
+
84
+ @property
85
+ def status(self) -> dict:
86
+ return {
87
+ "sdk_installed": self._available,
88
+ "enabled": self.config.enabled,
89
+ "configured": bool(self.config.can_bill),
90
+ "event_name": self.config.event_name,
91
+ }