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/onboarding.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`gax init` and `gax doctor` — the on-ramp.
|
|
3
|
+
|
|
4
|
+
We measured the pre-existing first-run path in a clean virtualenv: a new engineer
|
|
5
|
+
hit **two dead ends** before their first successful command. `gax demo.echo` returned
|
|
6
|
+
a bare `[Errno 61] Connection refused` with no hint the sidecar wasn't running, and
|
|
7
|
+
`gax --local demo.echo` then failed with `missing capability token`. Both are
|
|
8
|
+
correct behavior and both are useless as a first experience.
|
|
9
|
+
|
|
10
|
+
`init` collapses that into one command. `doctor` diagnoses the same failures after
|
|
11
|
+
the fact. Neither invents state silently: `init` reports exactly what it did, and is
|
|
12
|
+
idempotent so re-running is safe.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from gax.paths import (
|
|
28
|
+
AUDIT_PATH,
|
|
29
|
+
CONFIG_PATH,
|
|
30
|
+
DEFAULT_HOST,
|
|
31
|
+
DEFAULT_PORT,
|
|
32
|
+
GAX_HOME,
|
|
33
|
+
PID_PATH,
|
|
34
|
+
ensure_gax_home,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
CAP_PATH = GAX_HOME / "dev_cap.jwt"
|
|
38
|
+
|
|
39
|
+
# Enough to be useful immediately, all read-only. Destructive commands are
|
|
40
|
+
# deliberately excluded: a dev capability minted by `init` must not be able to
|
|
41
|
+
# delete anything, and raising its ceiling should be a conscious act.
|
|
42
|
+
DEV_COMMANDS = [
|
|
43
|
+
"demo.echo",
|
|
44
|
+
"gh.pr.list",
|
|
45
|
+
"gh.pr.view",
|
|
46
|
+
"k8s.pod.list",
|
|
47
|
+
"kubectl.get.pods",
|
|
48
|
+
"aws.s3.list",
|
|
49
|
+
"jira.issue.get",
|
|
50
|
+
]
|
|
51
|
+
DEV_SCOPES = [
|
|
52
|
+
"demo:echo",
|
|
53
|
+
"github:pull_request:read",
|
|
54
|
+
"k8s:pods:read",
|
|
55
|
+
"aws:s3:read",
|
|
56
|
+
"jira:issue:read",
|
|
57
|
+
]
|
|
58
|
+
DEV_TTL_SECONDS = 30 * 24 * 3600 # 30 days — long enough to not derail a trial
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Check:
|
|
63
|
+
name: str
|
|
64
|
+
ok: bool
|
|
65
|
+
detail: str
|
|
66
|
+
fix: str = ""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def symbol(self) -> str:
|
|
70
|
+
return "✓" if self.ok else "✗"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def daemon_health(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> dict[str, Any] | None:
|
|
74
|
+
try:
|
|
75
|
+
import httpx
|
|
76
|
+
|
|
77
|
+
r = httpx.get(f"http://{host}:{port}/health", timeout=1.5)
|
|
78
|
+
return r.json() if r.status_code == 200 else None
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def daemon_running(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
|
|
84
|
+
return daemon_health(host, port) is not None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def config_fingerprint() -> str:
|
|
88
|
+
"""Fingerprint of the local signing secret — never the secret itself."""
|
|
89
|
+
import hashlib
|
|
90
|
+
|
|
91
|
+
from gax.caps import jwt_secret
|
|
92
|
+
|
|
93
|
+
return hashlib.sha256(jwt_secret().encode()).hexdigest()[:12]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def daemon_config_matches(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool | None:
|
|
97
|
+
"""
|
|
98
|
+
Whether a running daemon shares our signing secret.
|
|
99
|
+
|
|
100
|
+
A daemon started against a different `~/.gax` accepts connections but rejects
|
|
101
|
+
every capability with "Signature verification failed" — a confusing failure
|
|
102
|
+
that looks like a broken token rather than a stale process. `None` means the
|
|
103
|
+
daemon is unreachable or too old to report a fingerprint.
|
|
104
|
+
"""
|
|
105
|
+
health = daemon_health(host, port)
|
|
106
|
+
if not health:
|
|
107
|
+
return None
|
|
108
|
+
remote = health.get("config_fingerprint")
|
|
109
|
+
if not remote:
|
|
110
|
+
return None
|
|
111
|
+
return remote == config_fingerprint()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _start_daemon(host: str, port: int) -> bool:
|
|
115
|
+
"""Spawn gaxd detached. Returns True once /health answers."""
|
|
116
|
+
exe = shutil.which("gaxd")
|
|
117
|
+
cmd = [exe, "start"] if exe else [sys.executable, "-m", "gax.daemon", "start"]
|
|
118
|
+
try:
|
|
119
|
+
subprocess.Popen(
|
|
120
|
+
[*cmd, "--host", host, "--port", str(port)],
|
|
121
|
+
stdout=subprocess.DEVNULL,
|
|
122
|
+
stderr=subprocess.DEVNULL,
|
|
123
|
+
start_new_session=True,
|
|
124
|
+
)
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
for _ in range(30): # up to ~6s
|
|
128
|
+
if daemon_running(host, port):
|
|
129
|
+
return True
|
|
130
|
+
time.sleep(0.2)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cap_claims(token: str) -> dict[str, Any] | None:
|
|
135
|
+
try:
|
|
136
|
+
from gax.caps import decode_capability
|
|
137
|
+
|
|
138
|
+
return decode_capability(token)
|
|
139
|
+
except Exception:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_saved_cap() -> str | None:
|
|
144
|
+
if not CAP_PATH.exists():
|
|
145
|
+
return None
|
|
146
|
+
token = CAP_PATH.read_text().strip()
|
|
147
|
+
return token or None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_init(
|
|
151
|
+
*,
|
|
152
|
+
host: str = DEFAULT_HOST,
|
|
153
|
+
port: int = DEFAULT_PORT,
|
|
154
|
+
start_daemon: bool = True,
|
|
155
|
+
force: bool = False,
|
|
156
|
+
profiles: list[str] | None = None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""
|
|
159
|
+
Idempotent setup. Returns a report rather than printing, so the CLI owns
|
|
160
|
+
presentation and tests can assert on structure.
|
|
161
|
+
"""
|
|
162
|
+
steps: list[str] = []
|
|
163
|
+
ensure_gax_home()
|
|
164
|
+
steps.append(f"created {GAX_HOME}")
|
|
165
|
+
|
|
166
|
+
# Profiles first: the capability minted below should cover what got installed.
|
|
167
|
+
installed_profiles: list[dict[str, Any]] = []
|
|
168
|
+
for name in profiles or []:
|
|
169
|
+
from gax.profiles import install_profile
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
result = install_profile(name, force=force)
|
|
173
|
+
except KeyError:
|
|
174
|
+
steps.append(f"unknown profile '{name}' — skipped")
|
|
175
|
+
continue
|
|
176
|
+
installed_profiles.append(result)
|
|
177
|
+
n = len(result["installed"])
|
|
178
|
+
detail = f"installed profile '{name}' ({n} commands: {result['summary']})"
|
|
179
|
+
if result["skipped"]:
|
|
180
|
+
detail += f", {len(result['skipped'])} already present"
|
|
181
|
+
steps.append(detail)
|
|
182
|
+
|
|
183
|
+
if not CONFIG_PATH.exists():
|
|
184
|
+
CONFIG_PATH.write_text(
|
|
185
|
+
json.dumps(
|
|
186
|
+
{
|
|
187
|
+
"jwt_secret": os.urandom(24).hex(),
|
|
188
|
+
"tenant_id": "default",
|
|
189
|
+
"subject": "dev@local",
|
|
190
|
+
},
|
|
191
|
+
indent=2,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
steps.append("wrote config.json (random dev signing secret)")
|
|
195
|
+
else:
|
|
196
|
+
steps.append("config.json already present — kept")
|
|
197
|
+
|
|
198
|
+
# Mint only when missing or expired, so re-running init doesn't invalidate a
|
|
199
|
+
# capability the user already exported into their shell.
|
|
200
|
+
from gax.caps import mint_capability
|
|
201
|
+
|
|
202
|
+
# Cover the profile's read-only commands too, otherwise `init --profile k8s`
|
|
203
|
+
# registers commands the dev capability cannot call. Write/destructive commands
|
|
204
|
+
# are deliberately NOT added: the ceiling stays `read`, so reaching them
|
|
205
|
+
# requires minting a capability on purpose.
|
|
206
|
+
commands = list(DEV_COMMANDS)
|
|
207
|
+
scopes = list(DEV_SCOPES)
|
|
208
|
+
for result in installed_profiles:
|
|
209
|
+
from gax.profiles import get_profile
|
|
210
|
+
|
|
211
|
+
profile = get_profile(result["profile"])
|
|
212
|
+
if not profile:
|
|
213
|
+
continue
|
|
214
|
+
for cmd in profile.by_level("read"):
|
|
215
|
+
if cmd.command not in commands:
|
|
216
|
+
commands.append(cmd.command)
|
|
217
|
+
for scope in cmd.required_scopes:
|
|
218
|
+
if scope not in scopes:
|
|
219
|
+
scopes.append(scope)
|
|
220
|
+
|
|
221
|
+
existing = read_saved_cap()
|
|
222
|
+
reuse = bool(existing) and not force and _cap_claims(existing or "") is not None
|
|
223
|
+
# A profile installed after the capability was minted needs a re-mint, or its
|
|
224
|
+
# read commands would be registered but uncallable.
|
|
225
|
+
if reuse and installed_profiles:
|
|
226
|
+
claims = _cap_claims(existing or "") or {}
|
|
227
|
+
held = set(claims.get("commands") or [])
|
|
228
|
+
if not set(commands).issubset(held):
|
|
229
|
+
reuse = False
|
|
230
|
+
|
|
231
|
+
if reuse:
|
|
232
|
+
token = existing or ""
|
|
233
|
+
steps.append("dev capability still valid — kept")
|
|
234
|
+
else:
|
|
235
|
+
token = mint_capability(
|
|
236
|
+
commands=commands,
|
|
237
|
+
scopes=scopes,
|
|
238
|
+
ttl_seconds=DEV_TTL_SECONDS,
|
|
239
|
+
max_side_effect="read",
|
|
240
|
+
)
|
|
241
|
+
CAP_PATH.write_text(token)
|
|
242
|
+
CAP_PATH.chmod(0o600)
|
|
243
|
+
steps.append(
|
|
244
|
+
f"minted 30-day read-only dev capability ({len(commands)} commands) → {CAP_PATH}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
daemon_ok = daemon_running(host, port)
|
|
248
|
+
stale_daemon = False
|
|
249
|
+
if daemon_ok:
|
|
250
|
+
if daemon_config_matches(host, port) is False:
|
|
251
|
+
# Running, but signed with a different secret — every invoke would
|
|
252
|
+
# fail with "Signature verification failed". Report it loudly here
|
|
253
|
+
# rather than let the user debug a token that is actually fine.
|
|
254
|
+
stale_daemon = True
|
|
255
|
+
steps.append(
|
|
256
|
+
f"gaxd on {host}:{port} uses a DIFFERENT config — "
|
|
257
|
+
"restart it: gaxd stop && gaxd start --background"
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
steps.append(f"gaxd already running on {host}:{port}")
|
|
261
|
+
elif start_daemon:
|
|
262
|
+
daemon_ok = _start_daemon(host, port)
|
|
263
|
+
steps.append(
|
|
264
|
+
f"started gaxd on {host}:{port}" if daemon_ok else "could not start gaxd"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
"gax_home": str(GAX_HOME),
|
|
269
|
+
"capability_path": str(CAP_PATH),
|
|
270
|
+
"capability": token,
|
|
271
|
+
"daemon_running": daemon_ok,
|
|
272
|
+
"daemon_config_mismatch": stale_daemon,
|
|
273
|
+
"host": host,
|
|
274
|
+
"port": port,
|
|
275
|
+
"steps": steps,
|
|
276
|
+
"commands": DEV_COMMANDS,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run_doctor(
|
|
281
|
+
*,
|
|
282
|
+
host: str = DEFAULT_HOST,
|
|
283
|
+
port: int = DEFAULT_PORT,
|
|
284
|
+
) -> list[Check]:
|
|
285
|
+
"""Diagnose the failures a first-run user actually hits."""
|
|
286
|
+
checks: list[Check] = []
|
|
287
|
+
|
|
288
|
+
checks.append(
|
|
289
|
+
Check(
|
|
290
|
+
"gax home",
|
|
291
|
+
GAX_HOME.exists(),
|
|
292
|
+
str(GAX_HOME) if GAX_HOME.exists() else "missing",
|
|
293
|
+
fix="run: gax init",
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
checks.append(
|
|
298
|
+
Check(
|
|
299
|
+
"config",
|
|
300
|
+
CONFIG_PATH.exists(),
|
|
301
|
+
"config.json present" if CONFIG_PATH.exists() else "missing",
|
|
302
|
+
fix="run: gax init",
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Registry
|
|
307
|
+
try:
|
|
308
|
+
from gax.registry import Registry
|
|
309
|
+
|
|
310
|
+
n = len(Registry().list_commands())
|
|
311
|
+
checks.append(Check("registry", n > 0, f"{n} commands registered",
|
|
312
|
+
fix="check manifests/ directory"))
|
|
313
|
+
except Exception as e:
|
|
314
|
+
checks.append(Check("registry", False, f"failed to load: {e}"))
|
|
315
|
+
|
|
316
|
+
# Capability: env wins over saved file, since that is what invokes actually use.
|
|
317
|
+
env_cap = os.environ.get("GAX_CAP", "").strip()
|
|
318
|
+
saved = read_saved_cap()
|
|
319
|
+
token = env_cap or saved or ""
|
|
320
|
+
source = "GAX_CAP env" if env_cap else ("~/.gax/dev_cap.jwt" if saved else "none")
|
|
321
|
+
if not token:
|
|
322
|
+
checks.append(
|
|
323
|
+
Check("capability", False, "no capability found",
|
|
324
|
+
fix="run: gax init, then eval \"$(gax init --print-export)\"")
|
|
325
|
+
)
|
|
326
|
+
else:
|
|
327
|
+
claims = _cap_claims(token)
|
|
328
|
+
if claims is None:
|
|
329
|
+
checks.append(
|
|
330
|
+
Check("capability", False, f"invalid or expired ({source})",
|
|
331
|
+
fix="run: gax init --force")
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
exp = claims.get("exp")
|
|
335
|
+
left = int(exp - time.time()) if isinstance(exp, (int, float)) else None
|
|
336
|
+
detail = f"valid from {source}"
|
|
337
|
+
if left is not None:
|
|
338
|
+
detail += f", expires in {left // 86400}d {left % 86400 // 3600}h"
|
|
339
|
+
detail += f", ceiling={claims.get('max_side_effect', 'read')}"
|
|
340
|
+
checks.append(Check("capability", True, detail))
|
|
341
|
+
if not env_cap and saved:
|
|
342
|
+
# Not a failure: the CLI falls back to the saved capability, so
|
|
343
|
+
# commands work without exporting. Only other tools (raw HTTP,
|
|
344
|
+
# the MCP server under some launchers) need the env var, so this
|
|
345
|
+
# is informational — flagging it red would be a false alarm.
|
|
346
|
+
checks.append(
|
|
347
|
+
Check(
|
|
348
|
+
"GAX_CAP env",
|
|
349
|
+
True,
|
|
350
|
+
'not set; CLI uses ~/.gax/dev_cap.jwt. For other tools: '
|
|
351
|
+
'eval "$(gax init --print-export)"',
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
running = daemon_running(host, port)
|
|
356
|
+
checks.append(
|
|
357
|
+
Check(
|
|
358
|
+
"gaxd sidecar",
|
|
359
|
+
running,
|
|
360
|
+
f"responding on {host}:{port}" if running else f"not reachable on {host}:{port}",
|
|
361
|
+
fix="run: gaxd start --background (or use: gax --local <cmd>)",
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
if running and daemon_config_matches(host, port) is False:
|
|
365
|
+
checks.append(
|
|
366
|
+
Check(
|
|
367
|
+
"gaxd config",
|
|
368
|
+
False,
|
|
369
|
+
"daemon is using a different signing secret — every invoke will "
|
|
370
|
+
"fail with 'Signature verification failed'",
|
|
371
|
+
fix="run: gaxd stop && gaxd start --background",
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Backends — informational; absence falls back to mocks rather than failing.
|
|
376
|
+
for binary, why in (("gh", "gh.pr.* commands"), ("kubectl", "k8s.* commands")):
|
|
377
|
+
found = shutil.which(binary)
|
|
378
|
+
checks.append(
|
|
379
|
+
Check(
|
|
380
|
+
f"{binary} binary",
|
|
381
|
+
bool(found),
|
|
382
|
+
found or f"not found — {why} will return mock data",
|
|
383
|
+
fix=f"install {binary} for live {why}",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
checks.append(
|
|
388
|
+
Check(
|
|
389
|
+
"audit log",
|
|
390
|
+
True,
|
|
391
|
+
f"{AUDIT_PATH} ({AUDIT_PATH.stat().st_size} bytes)"
|
|
392
|
+
if AUDIT_PATH.exists()
|
|
393
|
+
else "no invocations recorded yet",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return checks
|
gax/opa_policy.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""OPA/Rego policy evaluation (optional `opa` binary) with YAML fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from gax.policy_bundle import PolicyDenied, check_policy_bundle
|
|
12
|
+
from gax.paths import CONFIG_DIR
|
|
13
|
+
from gax.registry import CommandManifest
|
|
14
|
+
|
|
15
|
+
REGO_PATH = CONFIG_DIR / "policy.rego"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_opa_or_yaml(
|
|
19
|
+
claims: dict[str, Any],
|
|
20
|
+
manifest: CommandManifest,
|
|
21
|
+
args: dict[str, Any],
|
|
22
|
+
) -> None:
|
|
23
|
+
if shutil.which("opa") and REGO_PATH.exists():
|
|
24
|
+
inp = {
|
|
25
|
+
"claims": claims,
|
|
26
|
+
"command": manifest.command,
|
|
27
|
+
"side_effects": manifest.side_effects,
|
|
28
|
+
"args": args,
|
|
29
|
+
}
|
|
30
|
+
proc = subprocess.run(
|
|
31
|
+
["opa", "eval", "-d", str(REGO_PATH), "-I", "data", "data.gax.allow"],
|
|
32
|
+
input=json.dumps({"input": inp}),
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
timeout=10,
|
|
36
|
+
)
|
|
37
|
+
if proc.returncode == 0:
|
|
38
|
+
try:
|
|
39
|
+
val = json.loads(proc.stdout)
|
|
40
|
+
allowed = val.get("result", [{}])[0].get("expressions", [{}])[0].get("value")
|
|
41
|
+
if allowed is False:
|
|
42
|
+
raise PolicyDenied("OPA policy denied invoke")
|
|
43
|
+
return
|
|
44
|
+
except (json.JSONDecodeError, IndexError, KeyError):
|
|
45
|
+
pass
|
|
46
|
+
check_policy_bundle(claims, manifest, args)
|
gax/openapi_gen.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Generate GAX manifests from OpenAPI 3.x (subset: GET operations)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _slug(s: str) -> str:
|
|
14
|
+
s = re.sub(r"[^a-zA-Z0-9]+", ".", s.strip("/"))
|
|
15
|
+
return s.strip(".").lower()[:80] or "op"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate_manifests(
|
|
19
|
+
spec: dict[str, Any],
|
|
20
|
+
*,
|
|
21
|
+
prefix: str = "api",
|
|
22
|
+
adapter: str = "mock",
|
|
23
|
+
) -> list[dict[str, Any]]:
|
|
24
|
+
paths = spec.get("paths") or {}
|
|
25
|
+
out: list[dict[str, Any]] = []
|
|
26
|
+
for path, methods in paths.items():
|
|
27
|
+
if not isinstance(methods, dict):
|
|
28
|
+
continue
|
|
29
|
+
for method, op in methods.items():
|
|
30
|
+
if method.lower() != "get" or not isinstance(op, dict):
|
|
31
|
+
continue
|
|
32
|
+
op_id = op.get("operationId") or f"{method}_{_slug(path)}"
|
|
33
|
+
cmd = f"{prefix}.{_slug(op_id)}"
|
|
34
|
+
params = op.get("parameters") or []
|
|
35
|
+
props: dict[str, Any] = {}
|
|
36
|
+
required: list[str] = []
|
|
37
|
+
path_params: list[str] = []
|
|
38
|
+
query_params: list[str] = []
|
|
39
|
+
for p in params:
|
|
40
|
+
if not isinstance(p, dict):
|
|
41
|
+
continue
|
|
42
|
+
name = p.get("name")
|
|
43
|
+
if not name:
|
|
44
|
+
continue
|
|
45
|
+
loc = p.get("in", "query")
|
|
46
|
+
props[name] = {"type": "string", "description": p.get("description", "")}
|
|
47
|
+
if p.get("required"):
|
|
48
|
+
required.append(name)
|
|
49
|
+
if loc == "path":
|
|
50
|
+
path_params.append(name)
|
|
51
|
+
elif loc == "query":
|
|
52
|
+
query_params.append(name)
|
|
53
|
+
manifest: dict[str, Any] = {
|
|
54
|
+
"command": cmd,
|
|
55
|
+
"version": "1.0.0",
|
|
56
|
+
"description": op.get("summary") or op.get("description") or cmd,
|
|
57
|
+
"category": prefix,
|
|
58
|
+
"adapter": adapter,
|
|
59
|
+
"required_scopes": [f"{prefix}:read"],
|
|
60
|
+
"side_effects": "read",
|
|
61
|
+
"idempotent": True,
|
|
62
|
+
"input_schema": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": props,
|
|
65
|
+
"required": required,
|
|
66
|
+
},
|
|
67
|
+
"output_schema": {"type": "object"},
|
|
68
|
+
}
|
|
69
|
+
if adapter == "http":
|
|
70
|
+
servers = spec.get("servers") or [{"url": "https://api.example.com"}]
|
|
71
|
+
base = servers[0].get("url", "").rstrip("/")
|
|
72
|
+
manifest["http"] = {
|
|
73
|
+
"method": "GET",
|
|
74
|
+
"url": base + path,
|
|
75
|
+
"path_params": path_params,
|
|
76
|
+
"query_params": query_params,
|
|
77
|
+
}
|
|
78
|
+
out.append(manifest)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_manifests(manifests: list[dict[str, Any]], out_dir: Path) -> list[Path]:
|
|
83
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
written: list[Path] = []
|
|
85
|
+
for m in manifests:
|
|
86
|
+
path = out_dir / f"{m['command'].replace('.', '_')}.yaml"
|
|
87
|
+
path.write_text(yaml.safe_dump(m, sort_keys=False))
|
|
88
|
+
written.append(path)
|
|
89
|
+
return written
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_spec(path: Path) -> dict[str, Any]:
|
|
93
|
+
text = path.read_text()
|
|
94
|
+
if path.suffix in (".yaml", ".yml"):
|
|
95
|
+
return yaml.safe_load(text) or {}
|
|
96
|
+
return json.loads(text)
|
gax/otel_audit.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from gax.paths import GAX_HOME, ensure_gax_home
|
|
9
|
+
|
|
10
|
+
OTEL_LOG_PATH = GAX_HOME / "audit.otel.jsonl"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_audit_event(
|
|
14
|
+
*,
|
|
15
|
+
audit_id: str,
|
|
16
|
+
tenant_id: str,
|
|
17
|
+
subject: str,
|
|
18
|
+
command: str,
|
|
19
|
+
ok: bool,
|
|
20
|
+
duration_ms: float | None = None,
|
|
21
|
+
error_kind: str | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Append OpenTelemetry-log-compatible JSON (stdout or file)."""
|
|
24
|
+
if os.environ.get("GAX_OTEL_STDOUT") == "1":
|
|
25
|
+
sink = None
|
|
26
|
+
else:
|
|
27
|
+
ensure_gax_home()
|
|
28
|
+
sink = OTEL_LOG_PATH
|
|
29
|
+
|
|
30
|
+
body = {
|
|
31
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
32
|
+
"severity_text": "INFO" if ok else "ERROR",
|
|
33
|
+
"body": f"gax.invoke {command}",
|
|
34
|
+
"attributes": {
|
|
35
|
+
"gax.audit_id": audit_id,
|
|
36
|
+
"gax.tenant_id": tenant_id,
|
|
37
|
+
"gax.subject": subject,
|
|
38
|
+
"gax.command": command,
|
|
39
|
+
"gax.ok": ok,
|
|
40
|
+
"gax.duration_ms": duration_ms,
|
|
41
|
+
"gax.error_kind": error_kind or "",
|
|
42
|
+
},
|
|
43
|
+
"trace_id": audit_id.replace("aud_", "")[:32].ljust(32, "0"),
|
|
44
|
+
}
|
|
45
|
+
line = json.dumps(body) + "\n"
|
|
46
|
+
if sink is None:
|
|
47
|
+
print(line, end="")
|
|
48
|
+
else:
|
|
49
|
+
with sink.open("a") as f:
|
|
50
|
+
f.write(line)
|
gax/paths.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_PKG_DIR = Path(__file__).resolve().parent # .../gax/gax
|
|
6
|
+
_REPO_ROOT = _PKG_DIR.parent # .../gax (repo checkout only)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _data_dir(name: str) -> Path:
|
|
10
|
+
"""
|
|
11
|
+
Locate a bundled data directory.
|
|
12
|
+
|
|
13
|
+
Installed wheels carry these *inside* the package (`gax/gax/manifests`), while
|
|
14
|
+
a repo checkout keeps the editable sources one level up (`gax/manifests`, next
|
|
15
|
+
to `gax/gax`).
|
|
16
|
+
|
|
17
|
+
The repo copy wins when both exist. `sync_package_data.py` leaves a build
|
|
18
|
+
artifact at `gax/gax/<name>/`, and preferring it would silently shadow the
|
|
19
|
+
sources — editing `config/policy.yaml` would appear to do nothing until the
|
|
20
|
+
next sync. In an installed wheel there is no repo copy, so the packaged one
|
|
21
|
+
is used.
|
|
22
|
+
"""
|
|
23
|
+
repo_copy = _REPO_ROOT / name
|
|
24
|
+
if repo_copy.exists() and repo_copy != _PKG_DIR / name:
|
|
25
|
+
return repo_copy
|
|
26
|
+
return _PKG_DIR / name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MANIFESTS_DIR = _data_dir("manifests")
|
|
30
|
+
SCHEMAS_DIR = _data_dir("schemas")
|
|
31
|
+
CONFIG_DIR = _data_dir("config")
|
|
32
|
+
PROFILES_DIR = _data_dir("profiles")
|
|
33
|
+
GAX_HOME = Path.home() / ".gax"
|
|
34
|
+
# User-installed commands (profiles, hand-written manifests). Loaded after the
|
|
35
|
+
# packaged set and wins on conflict, so upgrades never clobber local commands.
|
|
36
|
+
USER_MANIFESTS_DIR = GAX_HOME / "manifests"
|
|
37
|
+
CONFIG_PATH = GAX_HOME / "config.json"
|
|
38
|
+
AUDIT_PATH = GAX_HOME / "audit.jsonl"
|
|
39
|
+
PID_PATH = GAX_HOME / "gaxd.pid"
|
|
40
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
41
|
+
DEFAULT_PORT = 9477
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ensure_gax_home() -> Path:
|
|
45
|
+
GAX_HOME.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return GAX_HOME
|