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 +5 -0
- superuser/__main__.py +62 -0
- superuser/audit.py +70 -0
- superuser/authz.py +155 -0
- superuser/billing.py +91 -0
- superuser/config.py +314 -0
- superuser/guard.py +152 -0
- superuser/security.py +170 -0
- superuser/server.py +138 -0
- superuser/tools/__init__.py +1 -0
- superuser/tools/github.py +90 -0
- superuser/tools/shell.py +61 -0
- superuser/tools/system.py +69 -0
- superuser-0.1.0.dist-info/METADATA +215 -0
- superuser-0.1.0.dist-info/RECORD +18 -0
- superuser-0.1.0.dist-info/WHEEL +5 -0
- superuser-0.1.0.dist-info/entry_points.txt +2 -0
- superuser-0.1.0.dist-info/top_level.txt +1 -0
superuser/__init__.py
ADDED
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
|
+
}
|