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.
Files changed (66) hide show
  1. gax/__init__.py +3 -0
  2. gax/adapters/__init__.py +3 -0
  3. gax/adapters/base.py +25 -0
  4. gax/adapters/exec_adapter.py +261 -0
  5. gax/adapters/http_adapter.py +33 -0
  6. gax/adapters/k8s_adapter.py +338 -0
  7. gax/adapters/mcp_bridge.py +108 -0
  8. gax/adapters/mock_adapter.py +49 -0
  9. gax/audit.py +54 -0
  10. gax/caps.py +91 -0
  11. gax/cli.py +722 -0
  12. gax/client.py +58 -0
  13. gax/compliance.py +71 -0
  14. gax/config/oauth_providers.yaml +21 -0
  15. gax/config/policy.rego +20 -0
  16. gax/config/policy.yaml +59 -0
  17. gax/daemon.py +187 -0
  18. gax/envelope.py +64 -0
  19. gax/executor.py +151 -0
  20. gax/macaroons_cap.py +80 -0
  21. gax/manifests/aws.s3.list.yaml +20 -0
  22. gax/manifests/demo.echo.yaml +20 -0
  23. gax/manifests/gh.pr.list.yaml +41 -0
  24. gax/manifests/gh.pr.view.yaml +30 -0
  25. gax/manifests/jira.issue.get.yaml +18 -0
  26. gax/manifests/k8s.deployment.scale.yaml +35 -0
  27. gax/manifests/k8s.pod.delete.yaml +30 -0
  28. gax/manifests/kubectl.get.pods.yaml +20 -0
  29. gax/manifests/mcp.github.list_pulls.yaml +29 -0
  30. gax/manifests/pet_findpetsbystatus.yaml +19 -0
  31. gax/manifests/pet_getpetbyid.yaml +19 -0
  32. gax/mcp_client.py +110 -0
  33. gax/mcp_server.py +299 -0
  34. gax/oauth.py +184 -0
  35. gax/onboarding.py +397 -0
  36. gax/opa_policy.py +46 -0
  37. gax/openapi_gen.py +96 -0
  38. gax/otel_audit.py +50 -0
  39. gax/paths.py +46 -0
  40. gax/plan.py +151 -0
  41. gax/policy.py +33 -0
  42. gax/policy_bundle.py +64 -0
  43. gax/profiles/github/gh.issue.list.yaml +25 -0
  44. gax/profiles/github/gh.issue.view.yaml +23 -0
  45. gax/profiles/github/gh.pr.comment.yaml +30 -0
  46. gax/profiles/github/gh.pr.merge.yaml +29 -0
  47. gax/profiles/github/gh.run.list.yaml +22 -0
  48. gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
  49. gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
  50. gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
  51. gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
  52. gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
  53. gax/profiles/k8s/k8s.service.list.yaml +17 -0
  54. gax/profiles.py +143 -0
  55. gax/projection.py +42 -0
  56. gax/registry.py +130 -0
  57. gax/schemas/envelope.v1.json +46 -0
  58. gax/secrets.py +49 -0
  59. gax/side_effects.py +67 -0
  60. gax/spiffe.py +32 -0
  61. gax/vault.py +69 -0
  62. gax_cli-0.4.0.dist-info/METADATA +164 -0
  63. gax_cli-0.4.0.dist-info/RECORD +66 -0
  64. gax_cli-0.4.0.dist-info/WHEEL +5 -0
  65. gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
  66. 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)