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
gax/client.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from gax.paths import DEFAULT_HOST, DEFAULT_PORT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def base_url(host: str | None = None, port: int | None = None) -> str:
|
|
13
|
+
h = host or os.environ.get("GAX_HOST", DEFAULT_HOST)
|
|
14
|
+
p = port or int(os.environ.get("GAX_PORT", DEFAULT_PORT))
|
|
15
|
+
return f"http://{h}:{p}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def capability_header(capability: str | None = None) -> dict[str, str]:
|
|
19
|
+
cap = (capability or os.environ.get("GAX_CAP", "")).strip()
|
|
20
|
+
if not cap:
|
|
21
|
+
return {}
|
|
22
|
+
return {"GAX-Capability": cap}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def remote_invoke(
|
|
26
|
+
command: str,
|
|
27
|
+
args: dict[str, Any],
|
|
28
|
+
*,
|
|
29
|
+
surface: str = "model",
|
|
30
|
+
host: str | None = None,
|
|
31
|
+
port: int | None = None,
|
|
32
|
+
capability: str | None = None,
|
|
33
|
+
) -> tuple[dict[str, Any], int]:
|
|
34
|
+
url = base_url(host, port) + "/invoke"
|
|
35
|
+
body = {"command": command, "args": args, "surface": surface}
|
|
36
|
+
r = httpx.post(url, json=body, headers=capability_header(capability), timeout=120.0)
|
|
37
|
+
data = r.json()
|
|
38
|
+
env = data.get("envelope", data)
|
|
39
|
+
exit_code = int(data.get("exit_code", 1 if not env.get("ok") else 0))
|
|
40
|
+
return env, exit_code
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def remote_doc(command: str, host: str | None = None, port: int | None = None) -> dict[str, Any]:
|
|
44
|
+
r = httpx.get(f"{base_url(host, port)}/commands/{command}/doc", timeout=10.0)
|
|
45
|
+
r.raise_for_status()
|
|
46
|
+
return r.json()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def remote_search(query: str, host: str | None = None, port: int | None = None) -> dict[str, Any]:
|
|
50
|
+
r = httpx.get(f"{base_url(host, port)}/search", params={"q": query}, timeout=10.0)
|
|
51
|
+
r.raise_for_status()
|
|
52
|
+
return r.json()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remote_schema(command: str, host: str | None = None, port: int | None = None) -> dict[str, Any]:
|
|
56
|
+
r = httpx.get(f"{base_url(host, port)}/schema/{command}", timeout=10.0)
|
|
57
|
+
r.raise_for_status()
|
|
58
|
+
return r.json()
|
gax/compliance.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""SOC2-friendly audit exports from ~/.gax/audit.jsonl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from gax.paths import AUDIT_PATH, GAX_HOME, ensure_gax_home
|
|
12
|
+
|
|
13
|
+
SOC2_FIELDS = [
|
|
14
|
+
"timestamp",
|
|
15
|
+
"audit_id",
|
|
16
|
+
"tenant_id",
|
|
17
|
+
"subject",
|
|
18
|
+
"command",
|
|
19
|
+
"ok",
|
|
20
|
+
"error_kind",
|
|
21
|
+
"duration_ms",
|
|
22
|
+
"args_json",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_audit_lines(path: Path | None = None) -> list[dict[str, Any]]:
|
|
27
|
+
p = path or AUDIT_PATH
|
|
28
|
+
if not p.exists():
|
|
29
|
+
return []
|
|
30
|
+
rows = []
|
|
31
|
+
for line in p.read_text().splitlines():
|
|
32
|
+
if line.strip():
|
|
33
|
+
rows.append(json.loads(line))
|
|
34
|
+
return rows
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def export_soc2_csv(out_path: Path, *, since: str | None = None) -> int:
|
|
38
|
+
rows = load_audit_lines()
|
|
39
|
+
if since:
|
|
40
|
+
rows = [r for r in rows if r.get("ts", "") >= since]
|
|
41
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with out_path.open("w", newline="") as f:
|
|
43
|
+
w = csv.DictWriter(f, fieldnames=SOC2_FIELDS)
|
|
44
|
+
w.writeheader()
|
|
45
|
+
for r in rows:
|
|
46
|
+
w.writerow(
|
|
47
|
+
{
|
|
48
|
+
"timestamp": r.get("ts"),
|
|
49
|
+
"audit_id": r.get("audit_id"),
|
|
50
|
+
"tenant_id": r.get("tenant_id"),
|
|
51
|
+
"subject": r.get("subject"),
|
|
52
|
+
"command": r.get("command"),
|
|
53
|
+
"ok": r.get("ok"),
|
|
54
|
+
"error_kind": r.get("error_kind") or "",
|
|
55
|
+
"duration_ms": r.get("duration_ms") or "",
|
|
56
|
+
"args_json": json.dumps(r.get("args") or {}),
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
return len(rows)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def export_soc2_json(out_path: Path) -> dict[str, Any]:
|
|
63
|
+
rows = load_audit_lines()
|
|
64
|
+
bundle = {
|
|
65
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
66
|
+
"standard": "SOC2-aligned access log (MVP)",
|
|
67
|
+
"record_count": len(rows),
|
|
68
|
+
"records": rows,
|
|
69
|
+
}
|
|
70
|
+
out_path.write_text(json.dumps(bundle, indent=2))
|
|
71
|
+
return bundle
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# OAuth 2.0 device authorization (RFC 8628) providers for `gax auth login`
|
|
2
|
+
#
|
|
3
|
+
# GitHub: create an OAuth App at https://github.com/settings/developers
|
|
4
|
+
# - Enable Device Flow
|
|
5
|
+
# - Set GAX_GITHUB_CLIENT_ID to the Client ID
|
|
6
|
+
|
|
7
|
+
providers:
|
|
8
|
+
github:
|
|
9
|
+
device_authorization_url: https://github.com/login/device/code
|
|
10
|
+
token_url: https://github.com/login/oauth/access_token
|
|
11
|
+
client_id_env: GAX_GITHUB_CLIENT_ID
|
|
12
|
+
scopes:
|
|
13
|
+
- repo
|
|
14
|
+
- read:org
|
|
15
|
+
|
|
16
|
+
# Generic template (OIDC/OAuth provider with RFC 8628 endpoints)
|
|
17
|
+
# custom:
|
|
18
|
+
# device_authorization_url: https://auth.example.com/oauth/device
|
|
19
|
+
# token_url: https://auth.example.com/oauth/token
|
|
20
|
+
# client_id_env: GAX_CUSTOM_CLIENT_ID
|
|
21
|
+
# scopes: [openid, profile]
|
gax/config/policy.rego
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package gax
|
|
2
|
+
|
|
3
|
+
default allow = false
|
|
4
|
+
|
|
5
|
+
allow {
|
|
6
|
+
input.side_effects == "read"
|
|
7
|
+
not denied_command
|
|
8
|
+
not denied_repo
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
denied_command {
|
|
12
|
+
input.command == "destructive.example"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
denied_repo {
|
|
16
|
+
some repo
|
|
17
|
+
repo := input.args.repo
|
|
18
|
+
input.claims.tenant_id == "restricted"
|
|
19
|
+
repo != "allowed/repo"
|
|
20
|
+
}
|
gax/config/policy.yaml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# GAX policy bundle (MVP — YAML rules; OPA/Rego compatible path later)
|
|
2
|
+
version: 1
|
|
3
|
+
|
|
4
|
+
defaults:
|
|
5
|
+
# Org-wide kill switch for destructive commands. A tenant may override it.
|
|
6
|
+
# This is the blunt control; the per-capability `max_side_effect` ceiling
|
|
7
|
+
# (gax/side_effects.py) is the primary, fine-grained one.
|
|
8
|
+
deny_destructive: true
|
|
9
|
+
max_rows_per_invoke: 500
|
|
10
|
+
|
|
11
|
+
tenants:
|
|
12
|
+
default:
|
|
13
|
+
# Local/dev tenant. Opt in to destructive commands here; each invoke still
|
|
14
|
+
# needs a capability minted with `--max-side-effect destructive`.
|
|
15
|
+
deny_destructive: false
|
|
16
|
+
# "*" means "any registered command", NOT "any action" — the registry is
|
|
17
|
+
# still a closed set, and the per-capability allowlist + scopes + ceiling are
|
|
18
|
+
# the real controls. A wildcard here keeps installed profiles usable without
|
|
19
|
+
# editing policy; production tenants should enumerate instead (see below).
|
|
20
|
+
allowed_commands:
|
|
21
|
+
- "*"
|
|
22
|
+
denied_commands: []
|
|
23
|
+
repo_allowlist: [] # empty = allow all repos
|
|
24
|
+
|
|
25
|
+
# Template for a production tenant: enumerate exactly what may run, and keep
|
|
26
|
+
# the destructive kill switch on unless a specific need is documented.
|
|
27
|
+
production:
|
|
28
|
+
deny_destructive: true
|
|
29
|
+
allowed_commands:
|
|
30
|
+
- gh.pr.list
|
|
31
|
+
- gh.pr.view
|
|
32
|
+
- k8s.pod.list
|
|
33
|
+
- k8s.pod.logs
|
|
34
|
+
denied_commands: []
|
|
35
|
+
repo_allowlist: []
|
|
36
|
+
|
|
37
|
+
# Example: a tenant that must never run destructive commands, regardless of
|
|
38
|
+
# what capability a caller presents.
|
|
39
|
+
readonly-tenant:
|
|
40
|
+
deny_destructive: true
|
|
41
|
+
allowed_commands:
|
|
42
|
+
- "*"
|
|
43
|
+
denied_commands: []
|
|
44
|
+
repo_allowlist: []
|
|
45
|
+
capguard:
|
|
46
|
+
allowed_commands:
|
|
47
|
+
- demo.echo
|
|
48
|
+
- gitlab.mr.search
|
|
49
|
+
- gitlab.mr.get
|
|
50
|
+
- gitlab.mr.diffs
|
|
51
|
+
- gitlab.mr.pipelines
|
|
52
|
+
- gitlab.note.draft
|
|
53
|
+
denied_commands: []
|
|
54
|
+
repo_allowlist: [] # empty = allow all repos
|
|
55
|
+
|
|
56
|
+
rules:
|
|
57
|
+
- id: block_private_repos
|
|
58
|
+
description: Deny gh commands on repos marked private (future)
|
|
59
|
+
enabled: false
|
gax/daemon.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, urlparse
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from gax.executor import invoke
|
|
16
|
+
from gax.paths import DEFAULT_HOST, DEFAULT_PORT, PID_PATH, ensure_gax_home
|
|
17
|
+
from gax.registry import Registry
|
|
18
|
+
|
|
19
|
+
_REGISTRY = Registry()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GaxdHandler(BaseHTTPRequestHandler):
|
|
23
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
24
|
+
return # quiet by default
|
|
25
|
+
|
|
26
|
+
def _json_response(self, code: int, body: dict[str, Any]) -> None:
|
|
27
|
+
payload = json.dumps(body, indent=2).encode()
|
|
28
|
+
self.send_response(code)
|
|
29
|
+
self.send_header("Content-Type", "application/json")
|
|
30
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
31
|
+
self.end_headers()
|
|
32
|
+
self.wfile.write(payload)
|
|
33
|
+
|
|
34
|
+
def _read_json(self) -> dict[str, Any]:
|
|
35
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
36
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
37
|
+
return json.loads(raw.decode() or "{}")
|
|
38
|
+
|
|
39
|
+
def do_GET(self) -> None:
|
|
40
|
+
parsed = urlparse(self.path)
|
|
41
|
+
path = parsed.path
|
|
42
|
+
qs = parse_qs(parsed.query)
|
|
43
|
+
|
|
44
|
+
if path == "/health":
|
|
45
|
+
# Expose a fingerprint of the signing secret (never the secret) so a
|
|
46
|
+
# client can tell it is talking to a daemon that shares its config.
|
|
47
|
+
# A daemon started against a different ~/.gax will reject every
|
|
48
|
+
# capability with "Signature verification failed", which is otherwise
|
|
49
|
+
# very hard to diagnose.
|
|
50
|
+
from gax.caps import jwt_secret
|
|
51
|
+
|
|
52
|
+
fp = hashlib.sha256(jwt_secret().encode()).hexdigest()[:12]
|
|
53
|
+
self._json_response(
|
|
54
|
+
200,
|
|
55
|
+
{
|
|
56
|
+
"ok": True,
|
|
57
|
+
"service": "gaxd",
|
|
58
|
+
"commands": len(_REGISTRY.list_commands()),
|
|
59
|
+
"config_fingerprint": fp,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if path == "/commands":
|
|
65
|
+
items = [
|
|
66
|
+
{"command": m.command, "version": m.version, "description": m.description}
|
|
67
|
+
for m in _REGISTRY.list_commands()
|
|
68
|
+
]
|
|
69
|
+
self._json_response(200, {"commands": items})
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if path.startswith("/commands/") and path.endswith("/doc"):
|
|
73
|
+
cmd = path.split("/")[2]
|
|
74
|
+
doc = _REGISTRY.doc_stub(cmd)
|
|
75
|
+
if not doc:
|
|
76
|
+
self._json_response(404, {"error": "not_found"})
|
|
77
|
+
return
|
|
78
|
+
self._json_response(200, doc)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if path == "/search":
|
|
82
|
+
q = (qs.get("q") or [""])[0]
|
|
83
|
+
hits = _REGISTRY.search(q)
|
|
84
|
+
self._json_response(
|
|
85
|
+
200,
|
|
86
|
+
{
|
|
87
|
+
"query": q,
|
|
88
|
+
"results": [
|
|
89
|
+
{"command": m.command, "description": m.description, "category": m.category}
|
|
90
|
+
for m in hits
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if path.startswith("/schema/"):
|
|
97
|
+
cmd = path.split("/schema/", 1)[1]
|
|
98
|
+
m = _REGISTRY.get(cmd)
|
|
99
|
+
if not m:
|
|
100
|
+
self._json_response(404, {"error": "not_found"})
|
|
101
|
+
return
|
|
102
|
+
self._json_response(
|
|
103
|
+
200,
|
|
104
|
+
{"command": cmd, "input_schema": m.input_schema, "output_schema": m.output_schema},
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
self._json_response(404, {"error": "not_found"})
|
|
109
|
+
|
|
110
|
+
def do_POST(self) -> None:
|
|
111
|
+
if self.path != "/invoke":
|
|
112
|
+
self._json_response(404, {"error": "not_found"})
|
|
113
|
+
return
|
|
114
|
+
body = self._read_json()
|
|
115
|
+
cap = self.headers.get("GAX-Capability") or body.get("capability")
|
|
116
|
+
env, exit_code = invoke(
|
|
117
|
+
_REGISTRY,
|
|
118
|
+
command=str(body.get("command", "")),
|
|
119
|
+
args=dict(body.get("args") or {}),
|
|
120
|
+
surface=str(body.get("surface") or "model"),
|
|
121
|
+
capability=cap,
|
|
122
|
+
)
|
|
123
|
+
http_code = 200 if env.get("ok") else 400
|
|
124
|
+
self._json_response(http_code, {"envelope": env, "exit_code": exit_code})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_server(host: str, port: int) -> None:
|
|
128
|
+
server = HTTPServer((host, port), GaxdHandler)
|
|
129
|
+
click.echo(f"gaxd listening on http://{host}:{port}")
|
|
130
|
+
server.serve_forever()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@click.group()
|
|
134
|
+
def main() -> None:
|
|
135
|
+
"""GAX daemon (gaxd)."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@main.command("start")
|
|
139
|
+
@click.option("--host", default=DEFAULT_HOST)
|
|
140
|
+
@click.option("--port", default=DEFAULT_PORT, type=int)
|
|
141
|
+
@click.option("--background", is_flag=True, help="Daemonize (write pid file)")
|
|
142
|
+
def start(host: str, port: int, background: bool) -> None:
|
|
143
|
+
ensure_gax_home()
|
|
144
|
+
if background:
|
|
145
|
+
pid = os.fork()
|
|
146
|
+
if pid > 0:
|
|
147
|
+
PID_PATH.write_text(str(pid))
|
|
148
|
+
click.echo(f"gaxd started pid={pid} http://{host}:{port}")
|
|
149
|
+
return
|
|
150
|
+
os.setsid()
|
|
151
|
+
sys.stdout = open(os.devnull, "w")
|
|
152
|
+
sys.stderr = open(os.devnull, "w")
|
|
153
|
+
else:
|
|
154
|
+
PID_PATH.write_text(str(os.getpid()))
|
|
155
|
+
run_server(host, port)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@main.command("stop")
|
|
159
|
+
def stop() -> None:
|
|
160
|
+
if not PID_PATH.exists():
|
|
161
|
+
click.echo("gaxd not running (no pid file)")
|
|
162
|
+
return
|
|
163
|
+
pid = int(PID_PATH.read_text().strip())
|
|
164
|
+
try:
|
|
165
|
+
os.kill(pid, signal.SIGTERM)
|
|
166
|
+
PID_PATH.unlink(missing_ok=True)
|
|
167
|
+
click.echo(f"stopped gaxd pid={pid}")
|
|
168
|
+
except ProcessLookupError:
|
|
169
|
+
PID_PATH.unlink(missing_ok=True)
|
|
170
|
+
click.echo("stale pid file removed")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command("status")
|
|
174
|
+
@click.option("--host", default=DEFAULT_HOST)
|
|
175
|
+
@click.option("--port", default=DEFAULT_PORT, type=int)
|
|
176
|
+
def status(host: str, port: int) -> None:
|
|
177
|
+
import httpx
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
r = httpx.get(f"http://{host}:{port}/health", timeout=2.0)
|
|
181
|
+
click.echo(r.text)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
click.echo(f"gaxd unreachable: {e}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
main()
|
gax/envelope.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def new_audit_id() -> str:
|
|
9
|
+
return f"aud_{uuid.uuid4().hex[:16]}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_envelope(
|
|
13
|
+
*,
|
|
14
|
+
ok: bool,
|
|
15
|
+
cmd: str,
|
|
16
|
+
surface: str,
|
|
17
|
+
data: Any,
|
|
18
|
+
audit_id: str | None = None,
|
|
19
|
+
schema: str | None = None,
|
|
20
|
+
meta: dict[str, Any] | None = None,
|
|
21
|
+
error: dict[str, Any] | None = None,
|
|
22
|
+
next_actions: list[dict[str, Any]] | None = None,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
env: dict[str, Any] = {
|
|
25
|
+
"v": 1,
|
|
26
|
+
"ok": ok,
|
|
27
|
+
"cmd": cmd,
|
|
28
|
+
"audit_id": audit_id or new_audit_id(),
|
|
29
|
+
"surface": surface,
|
|
30
|
+
"data": data if data is not None else {},
|
|
31
|
+
"meta": meta or {},
|
|
32
|
+
"error": error,
|
|
33
|
+
}
|
|
34
|
+
if schema:
|
|
35
|
+
env["schema"] = schema
|
|
36
|
+
if next_actions:
|
|
37
|
+
env["next"] = next_actions
|
|
38
|
+
return env
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fail_envelope(
|
|
42
|
+
*,
|
|
43
|
+
cmd: str,
|
|
44
|
+
surface: str,
|
|
45
|
+
kind: str,
|
|
46
|
+
message: str,
|
|
47
|
+
retryable: bool = False,
|
|
48
|
+
audit_id: str | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
return make_envelope(
|
|
51
|
+
ok=False,
|
|
52
|
+
cmd=cmd,
|
|
53
|
+
surface=surface,
|
|
54
|
+
data={},
|
|
55
|
+
audit_id=audit_id,
|
|
56
|
+
error={"kind": kind, "message": message, "retryable": retryable},
|
|
57
|
+
meta={"duration_ms": 0},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def timed_meta(start: float, **extra: Any) -> dict[str, Any]:
|
|
62
|
+
meta = {"duration_ms": round((time.perf_counter() - start) * 1000, 2)}
|
|
63
|
+
meta.update(extra)
|
|
64
|
+
return meta
|
gax/executor.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from gax.adapters.base import run_adapter
|
|
7
|
+
from gax.audit import log_event
|
|
8
|
+
from gax.caps import decode_capability
|
|
9
|
+
from gax.envelope import fail_envelope, make_envelope, timed_meta
|
|
10
|
+
from gax.policy import PolicyDenied, check_invoke
|
|
11
|
+
from gax.projection import project_data
|
|
12
|
+
from gax.registry import Registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def invoke(
|
|
16
|
+
registry: Registry,
|
|
17
|
+
*,
|
|
18
|
+
command: str,
|
|
19
|
+
args: dict[str, Any],
|
|
20
|
+
surface: str = "model",
|
|
21
|
+
capability: str | None = None,
|
|
22
|
+
) -> tuple[dict[str, Any], int]:
|
|
23
|
+
start = time.perf_counter()
|
|
24
|
+
manifest = registry.get(command)
|
|
25
|
+
cmd_label = f"{command}@?" if not manifest else manifest.cmd_id
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
claims = decode_capability(capability)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
env = fail_envelope(
|
|
31
|
+
cmd=cmd_label,
|
|
32
|
+
surface=surface,
|
|
33
|
+
kind="capability_invalid",
|
|
34
|
+
message=str(e),
|
|
35
|
+
)
|
|
36
|
+
log_event(
|
|
37
|
+
audit_id=env["audit_id"],
|
|
38
|
+
tenant_id="unknown",
|
|
39
|
+
subject="unknown",
|
|
40
|
+
command=command,
|
|
41
|
+
args=args,
|
|
42
|
+
ok=False,
|
|
43
|
+
error_kind="capability_invalid",
|
|
44
|
+
)
|
|
45
|
+
return env, 3
|
|
46
|
+
|
|
47
|
+
tenant_id = str(claims.get("tenant_id", "unknown"))
|
|
48
|
+
subject = str(claims.get("sub", "unknown"))
|
|
49
|
+
|
|
50
|
+
if not manifest:
|
|
51
|
+
env = fail_envelope(
|
|
52
|
+
cmd=cmd_label,
|
|
53
|
+
surface=surface,
|
|
54
|
+
kind="not_found",
|
|
55
|
+
message=f"unknown command: {command}",
|
|
56
|
+
)
|
|
57
|
+
log_event(
|
|
58
|
+
audit_id=env["audit_id"],
|
|
59
|
+
tenant_id=tenant_id,
|
|
60
|
+
subject=subject,
|
|
61
|
+
command=command,
|
|
62
|
+
args=args,
|
|
63
|
+
ok=False,
|
|
64
|
+
error_kind="not_found",
|
|
65
|
+
)
|
|
66
|
+
return env, 4
|
|
67
|
+
|
|
68
|
+
cmd_label = manifest.cmd_id
|
|
69
|
+
try:
|
|
70
|
+
check_invoke(claims, manifest, args)
|
|
71
|
+
except PolicyDenied as e:
|
|
72
|
+
env = fail_envelope(
|
|
73
|
+
cmd=cmd_label,
|
|
74
|
+
surface=surface,
|
|
75
|
+
kind="policy_denied",
|
|
76
|
+
message=e.message,
|
|
77
|
+
retryable=False,
|
|
78
|
+
)
|
|
79
|
+
log_event(
|
|
80
|
+
audit_id=env["audit_id"],
|
|
81
|
+
tenant_id=tenant_id,
|
|
82
|
+
subject=subject,
|
|
83
|
+
command=command,
|
|
84
|
+
args=args,
|
|
85
|
+
ok=False,
|
|
86
|
+
error_kind="policy_denied",
|
|
87
|
+
)
|
|
88
|
+
return env, 2
|
|
89
|
+
|
|
90
|
+
audit_id = make_envelope(ok=True, cmd=cmd_label, surface=surface, data={})["audit_id"]
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
raw = run_adapter(manifest, args, tenant_id=tenant_id)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
env = fail_envelope(
|
|
96
|
+
cmd=cmd_label,
|
|
97
|
+
surface=surface,
|
|
98
|
+
kind="adapter_error",
|
|
99
|
+
message=str(e),
|
|
100
|
+
retryable=True,
|
|
101
|
+
audit_id=audit_id,
|
|
102
|
+
)
|
|
103
|
+
log_event(
|
|
104
|
+
audit_id=audit_id,
|
|
105
|
+
tenant_id=tenant_id,
|
|
106
|
+
subject=subject,
|
|
107
|
+
command=command,
|
|
108
|
+
args=args,
|
|
109
|
+
ok=False,
|
|
110
|
+
error_kind="adapter_error",
|
|
111
|
+
duration_ms=timed_meta(start)["duration_ms"],
|
|
112
|
+
)
|
|
113
|
+
return env, 5
|
|
114
|
+
|
|
115
|
+
projected, proj_meta = project_data(raw, surface)
|
|
116
|
+
meta = timed_meta(start, **proj_meta)
|
|
117
|
+
|
|
118
|
+
next_actions = None
|
|
119
|
+
if manifest.command == "gh.pr.list" and isinstance(projected, dict):
|
|
120
|
+
items = projected.get("items") or []
|
|
121
|
+
if items:
|
|
122
|
+
first = items[0]
|
|
123
|
+
next_actions = [
|
|
124
|
+
{
|
|
125
|
+
"cmd": "gh.pr.view",
|
|
126
|
+
"args": {"repo": args.get("repo"), "number": first.get("number")},
|
|
127
|
+
"reason": "inspect first PR in list",
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
env = make_envelope(
|
|
132
|
+
ok=True,
|
|
133
|
+
cmd=cmd_label,
|
|
134
|
+
surface=surface,
|
|
135
|
+
data=projected,
|
|
136
|
+
audit_id=audit_id,
|
|
137
|
+
schema=manifest.schema_uri,
|
|
138
|
+
meta=meta,
|
|
139
|
+
next_actions=next_actions,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
log_event(
|
|
143
|
+
audit_id=audit_id,
|
|
144
|
+
tenant_id=tenant_id,
|
|
145
|
+
subject=subject,
|
|
146
|
+
command=command,
|
|
147
|
+
args=args,
|
|
148
|
+
ok=True,
|
|
149
|
+
duration_ms=meta.get("duration_ms"),
|
|
150
|
+
)
|
|
151
|
+
return env, 0
|