gax-cli 0.4.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.
- gax/__init__.py +3 -0
- gax/adapters/__init__.py +3 -0
- gax/adapters/base.py +25 -0
- gax/adapters/exec_adapter.py +261 -0
- gax/adapters/http_adapter.py +33 -0
- gax/adapters/k8s_adapter.py +338 -0
- gax/adapters/mcp_bridge.py +108 -0
- gax/adapters/mock_adapter.py +49 -0
- gax/audit.py +54 -0
- gax/caps.py +91 -0
- gax/cli.py +722 -0
- gax/client.py +58 -0
- gax/compliance.py +71 -0
- gax/config/oauth_providers.yaml +21 -0
- gax/config/policy.rego +20 -0
- gax/config/policy.yaml +59 -0
- gax/daemon.py +187 -0
- gax/envelope.py +64 -0
- gax/executor.py +151 -0
- gax/macaroons_cap.py +80 -0
- gax/manifests/aws.s3.list.yaml +20 -0
- gax/manifests/demo.echo.yaml +20 -0
- gax/manifests/gh.pr.list.yaml +41 -0
- gax/manifests/gh.pr.view.yaml +30 -0
- gax/manifests/jira.issue.get.yaml +18 -0
- gax/manifests/k8s.deployment.scale.yaml +35 -0
- gax/manifests/k8s.pod.delete.yaml +30 -0
- gax/manifests/kubectl.get.pods.yaml +20 -0
- gax/manifests/mcp.github.list_pulls.yaml +29 -0
- gax/manifests/pet_findpetsbystatus.yaml +19 -0
- gax/manifests/pet_getpetbyid.yaml +19 -0
- gax/mcp_client.py +110 -0
- gax/mcp_server.py +299 -0
- gax/oauth.py +184 -0
- gax/onboarding.py +397 -0
- gax/opa_policy.py +46 -0
- gax/openapi_gen.py +96 -0
- gax/otel_audit.py +50 -0
- gax/paths.py +46 -0
- gax/plan.py +151 -0
- gax/policy.py +33 -0
- gax/policy_bundle.py +64 -0
- gax/profiles/github/gh.issue.list.yaml +25 -0
- gax/profiles/github/gh.issue.view.yaml +23 -0
- gax/profiles/github/gh.pr.comment.yaml +30 -0
- gax/profiles/github/gh.pr.merge.yaml +29 -0
- gax/profiles/github/gh.run.list.yaml +22 -0
- gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
- gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
- gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
- gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
- gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
- gax/profiles/k8s/k8s.service.list.yaml +17 -0
- gax/profiles.py +143 -0
- gax/projection.py +42 -0
- gax/registry.py +130 -0
- gax/schemas/envelope.v1.json +46 -0
- gax/secrets.py +49 -0
- gax/side_effects.py +67 -0
- gax/spiffe.py +32 -0
- gax/vault.py +69 -0
- gax_cli-0.4.0.dist-info/METADATA +164 -0
- gax_cli-0.4.0.dist-info/RECORD +66 -0
- gax_cli-0.4.0.dist-info/WHEEL +5 -0
- gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
- gax_cli-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""MCP → GAX adapter: one registered command → one MCP tool call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from gax.mcp_client import McpStdioClient, github_mcp_env
|
|
8
|
+
from gax.registry import CommandManifest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _mcp_cfg(manifest: CommandManifest) -> dict[str, Any]:
|
|
12
|
+
return dict((manifest.raw or {}).get("mcp") or {})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _server_cmd(cfg: dict[str, Any]) -> list[str]:
|
|
16
|
+
cmd = cfg.get("server_command", "npx")
|
|
17
|
+
args = list(cfg.get("server_args") or ["-y", "@modelcontextprotocol/server-github"])
|
|
18
|
+
return [str(cmd), *[str(a) for a in args]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _map_args(manifest: CommandManifest, args: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
out = dict(args)
|
|
23
|
+
repo = args.get("repo")
|
|
24
|
+
if isinstance(repo, str) and "/" in repo:
|
|
25
|
+
owner, name = repo.split("/", 1)
|
|
26
|
+
out["owner"] = owner
|
|
27
|
+
out["repo"] = name
|
|
28
|
+
mapping = (manifest.raw or {}).get("mcp_arg_map") or {}
|
|
29
|
+
if mapping:
|
|
30
|
+
mapped: dict[str, Any] = {}
|
|
31
|
+
for src, dst in mapping.items():
|
|
32
|
+
if src in out:
|
|
33
|
+
mapped[str(dst)] = out[src]
|
|
34
|
+
for k, v in out.items():
|
|
35
|
+
mapped.setdefault(k, v)
|
|
36
|
+
out = mapped
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_list_pulls(raw: Any) -> dict[str, Any]:
|
|
41
|
+
"""Map GitHub MCP list_pull_requests output to gh.pr.list envelope shape."""
|
|
42
|
+
rows: list[Any]
|
|
43
|
+
if isinstance(raw, list):
|
|
44
|
+
rows = raw
|
|
45
|
+
elif isinstance(raw, dict):
|
|
46
|
+
if "items" in raw and isinstance(raw["items"], list):
|
|
47
|
+
rows = raw["items"]
|
|
48
|
+
elif "pull_requests" in raw:
|
|
49
|
+
rows = raw["pull_requests"]
|
|
50
|
+
else:
|
|
51
|
+
rows = [raw]
|
|
52
|
+
else:
|
|
53
|
+
rows = []
|
|
54
|
+
|
|
55
|
+
items = []
|
|
56
|
+
for row in rows:
|
|
57
|
+
if not isinstance(row, dict):
|
|
58
|
+
continue
|
|
59
|
+
user = row.get("user") or row.get("author") or {}
|
|
60
|
+
login = user.get("login") if isinstance(user, dict) else str(user or "unknown")
|
|
61
|
+
items.append(
|
|
62
|
+
{
|
|
63
|
+
"number": row.get("number") or row.get("pull_number"),
|
|
64
|
+
"title": row.get("title", ""),
|
|
65
|
+
"state": str(row.get("state", "OPEN")).upper(),
|
|
66
|
+
"url": row.get("html_url") or row.get("url", ""),
|
|
67
|
+
"author": login,
|
|
68
|
+
"draft": bool(row.get("draft") or row.get("isDraft")),
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return {"items": items}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize(manifest: CommandManifest, raw: Any) -> dict[str, Any]:
|
|
75
|
+
if manifest.command in ("mcp.github.list_pulls", "mcp.github.list_pulls.mock"):
|
|
76
|
+
if isinstance(raw, dict) and isinstance(raw.get("result"), list):
|
|
77
|
+
raw = raw["result"]
|
|
78
|
+
return _normalize_list_pulls(raw)
|
|
79
|
+
if isinstance(raw, dict):
|
|
80
|
+
return raw
|
|
81
|
+
return {"result": raw}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run(
|
|
85
|
+
manifest: CommandManifest,
|
|
86
|
+
args: dict[str, Any],
|
|
87
|
+
*,
|
|
88
|
+
tenant_id: str | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
cfg = _mcp_cfg(manifest)
|
|
91
|
+
tool_name = str(cfg.get("tool_name", ""))
|
|
92
|
+
if not tool_name:
|
|
93
|
+
raise RuntimeError(f"mcp.tool_name missing in manifest {manifest.command}")
|
|
94
|
+
|
|
95
|
+
env = dict(cfg.get("env") or {})
|
|
96
|
+
cmd_line = " ".join(_server_cmd(cfg))
|
|
97
|
+
needs_github = cfg.get("require_github_token")
|
|
98
|
+
if needs_github is None:
|
|
99
|
+
needs_github = "server-github" in cmd_line or "@modelcontextprotocol/server-github" in cmd_line
|
|
100
|
+
if needs_github:
|
|
101
|
+
env.update(github_mcp_env())
|
|
102
|
+
|
|
103
|
+
client = McpStdioClient(_server_cmd(cfg), env=env, timeout=float(cfg.get("timeout", 120)))
|
|
104
|
+
try:
|
|
105
|
+
raw = client.call_tool(tool_name, _map_args(manifest, args))
|
|
106
|
+
return _normalize(manifest, raw)
|
|
107
|
+
finally:
|
|
108
|
+
client.close()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from gax.registry import CommandManifest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(manifest: CommandManifest, args: dict[str, Any]) -> dict[str, Any]:
|
|
9
|
+
if manifest.command == "demo.echo":
|
|
10
|
+
return {"echo": args.get("message", "hello")}
|
|
11
|
+
if manifest.command == "gh.pr.list":
|
|
12
|
+
repo = args.get("repo", "octocat/Hello-World")
|
|
13
|
+
return {
|
|
14
|
+
"items": [
|
|
15
|
+
{
|
|
16
|
+
"number": 1,
|
|
17
|
+
"title": f"[mock] Sample PR for {repo}",
|
|
18
|
+
"state": "OPEN",
|
|
19
|
+
"url": f"https://github.com/{repo}/pull/1",
|
|
20
|
+
"author": "mock-user",
|
|
21
|
+
"draft": False,
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"_mock": True,
|
|
25
|
+
}
|
|
26
|
+
if manifest.command == "gh.pr.view":
|
|
27
|
+
return {
|
|
28
|
+
"number": args.get("number", 1),
|
|
29
|
+
"title": "[mock] PR title",
|
|
30
|
+
"body": "Mock body",
|
|
31
|
+
"state": "OPEN",
|
|
32
|
+
"url": f"https://github.com/{args.get('repo', 'o/r')}/pull/{args.get('number', 1)}",
|
|
33
|
+
"author": "mock-user",
|
|
34
|
+
"_mock": True,
|
|
35
|
+
}
|
|
36
|
+
if manifest.command == "kubectl.get.pods":
|
|
37
|
+
ns = args.get("namespace", "default")
|
|
38
|
+
return {
|
|
39
|
+
"items": [{"name": "mock-pod-1", "namespace": ns, "status": "Running"}],
|
|
40
|
+
"_mock": True,
|
|
41
|
+
}
|
|
42
|
+
if manifest.command == "aws.s3.list":
|
|
43
|
+
return {"buckets": [{"name": "mock-bucket-a"}, {"name": "mock-bucket-b"}], "_mock": True}
|
|
44
|
+
if manifest.command == "jira.issue.get":
|
|
45
|
+
key = args.get("issue_key", "DEMO-1")
|
|
46
|
+
return {"key": key, "summary": "[mock] Issue", "status": "Open", "_mock": True}
|
|
47
|
+
if manifest.command.startswith("api."):
|
|
48
|
+
return {"_mock": True, "command": manifest.command, "args": args}
|
|
49
|
+
return {"result": "ok", "command": manifest.command, "args": args}
|
gax/audit.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from gax.paths import AUDIT_PATH, ensure_gax_home
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def log_event(
|
|
11
|
+
*,
|
|
12
|
+
audit_id: str,
|
|
13
|
+
tenant_id: str,
|
|
14
|
+
subject: str,
|
|
15
|
+
command: str,
|
|
16
|
+
args: dict[str, Any],
|
|
17
|
+
ok: bool,
|
|
18
|
+
error_kind: str | None = None,
|
|
19
|
+
duration_ms: float | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
ensure_gax_home()
|
|
22
|
+
# Never log raw secrets; args should already be sanitized by caller
|
|
23
|
+
safe_args = {k: v for k, v in args.items() if k.lower() not in ("token", "secret", "password")}
|
|
24
|
+
from gax.spiffe import attest_metadata
|
|
25
|
+
|
|
26
|
+
record = {
|
|
27
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
28
|
+
"audit_id": audit_id,
|
|
29
|
+
"tenant_id": tenant_id,
|
|
30
|
+
"subject": subject,
|
|
31
|
+
"command": command,
|
|
32
|
+
"args": safe_args,
|
|
33
|
+
"ok": ok,
|
|
34
|
+
"error_kind": error_kind,
|
|
35
|
+
"duration_ms": duration_ms,
|
|
36
|
+
**attest_metadata(),
|
|
37
|
+
}
|
|
38
|
+
with AUDIT_PATH.open("a") as f:
|
|
39
|
+
f.write(json.dumps(record) + "\n")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from gax.otel_audit import export_audit_event
|
|
43
|
+
|
|
44
|
+
export_audit_event(
|
|
45
|
+
audit_id=audit_id,
|
|
46
|
+
tenant_id=tenant_id,
|
|
47
|
+
subject=subject,
|
|
48
|
+
command=command,
|
|
49
|
+
ok=ok,
|
|
50
|
+
duration_ms=duration_ms,
|
|
51
|
+
error_kind=error_kind,
|
|
52
|
+
)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
gax/caps.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
|
|
10
|
+
from gax.paths import CONFIG_PATH, ensure_gax_home
|
|
11
|
+
from gax.side_effects import DEFAULT_CEILING
|
|
12
|
+
from gax.side_effects import normalize as normalize_side_effect
|
|
13
|
+
|
|
14
|
+
DEFAULT_SECRET = "gax-dev-secret-change-in-production"
|
|
15
|
+
DEFAULT_TENANT = "default"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_config() -> dict[str, Any]:
|
|
19
|
+
ensure_gax_home()
|
|
20
|
+
if CONFIG_PATH.exists():
|
|
21
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
22
|
+
cfg = {
|
|
23
|
+
"jwt_secret": DEFAULT_SECRET,
|
|
24
|
+
"tenant_id": DEFAULT_TENANT,
|
|
25
|
+
"subject": "dev@local",
|
|
26
|
+
}
|
|
27
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
|
28
|
+
return cfg
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def jwt_secret() -> str:
|
|
32
|
+
return _load_config().get("jwt_secret", DEFAULT_SECRET)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mint_capability(
|
|
36
|
+
*,
|
|
37
|
+
tenant_id: str | None = None,
|
|
38
|
+
subject: str | None = None,
|
|
39
|
+
commands: list[str] | None = None,
|
|
40
|
+
scopes: list[str] | None = None,
|
|
41
|
+
ttl_seconds: int = 3600,
|
|
42
|
+
budget: dict[str, int] | None = None,
|
|
43
|
+
max_side_effect: str | None = None,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Mint a capability token.
|
|
47
|
+
|
|
48
|
+
`max_side_effect` is the danger ceiling (`read` | `write` | `destructive`).
|
|
49
|
+
It defaults to `read`, so a token minted without thinking about danger cannot
|
|
50
|
+
invoke a write or destructive command even if that command is in `commands`.
|
|
51
|
+
"""
|
|
52
|
+
cfg = _load_config()
|
|
53
|
+
now = datetime.now(timezone.utc)
|
|
54
|
+
payload: dict[str, Any] = {
|
|
55
|
+
"sub": subject or cfg.get("subject", "dev@local"),
|
|
56
|
+
"tenant_id": tenant_id or cfg.get("tenant_id", DEFAULT_TENANT),
|
|
57
|
+
"iat": now,
|
|
58
|
+
"exp": now + timedelta(seconds=ttl_seconds),
|
|
59
|
+
"scopes": scopes or ["*"],
|
|
60
|
+
"commands": commands or ["*"],
|
|
61
|
+
"budget": budget or {"max_calls": 1000, "max_rows": 10_000},
|
|
62
|
+
"max_side_effect": normalize_side_effect(max_side_effect or DEFAULT_CEILING),
|
|
63
|
+
}
|
|
64
|
+
return jwt.encode(payload, jwt_secret(), algorithm="HS256")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decode_capability(token: str | None) -> dict[str, Any]:
|
|
68
|
+
if not token:
|
|
69
|
+
token = os.environ.get("GAX_CAP", "").strip()
|
|
70
|
+
if not token:
|
|
71
|
+
raise ValueError("missing capability token (set GAX_CAP or pass GAX-Capability header)")
|
|
72
|
+
# JWT has three dot-separated segments; macaroon is a single base64 blob
|
|
73
|
+
if token.count(".") == 2:
|
|
74
|
+
return jwt.decode(token, jwt_secret(), algorithms=["HS256"])
|
|
75
|
+
from gax.macaroons_cap import verify_macaroon
|
|
76
|
+
|
|
77
|
+
return verify_macaroon(token)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cap_allows_command(claims: dict[str, Any], command: str) -> bool:
|
|
81
|
+
allowed = claims.get("commands") or []
|
|
82
|
+
if "*" in allowed:
|
|
83
|
+
return True
|
|
84
|
+
return command in allowed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cap_allows_scopes(claims: dict[str, Any], required: list[str]) -> bool:
|
|
88
|
+
held = set(claims.get("scopes") or [])
|
|
89
|
+
if "*" in held:
|
|
90
|
+
return True
|
|
91
|
+
return set(required).issubset(held)
|