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/projection.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
MODEL_MAX_ROWS = 10
|
|
7
|
+
MODEL_MAX_STRING = 500
|
|
8
|
+
REDACT_KEYS = {"token", "secret", "password", "authorization"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _redact(obj: Any) -> Any:
|
|
12
|
+
if isinstance(obj, dict):
|
|
13
|
+
out = {}
|
|
14
|
+
for k, v in obj.items():
|
|
15
|
+
if k.lower() in REDACT_KEYS:
|
|
16
|
+
out[k] = "[redacted]"
|
|
17
|
+
else:
|
|
18
|
+
out[k] = _redact(v)
|
|
19
|
+
return out
|
|
20
|
+
if isinstance(obj, list):
|
|
21
|
+
return [_redact(x) for x in obj]
|
|
22
|
+
if isinstance(obj, str) and len(obj) > MODEL_MAX_STRING:
|
|
23
|
+
return obj[:MODEL_MAX_STRING] + "…"
|
|
24
|
+
return obj
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def project_data(data: Any, surface: str) -> tuple[Any, dict[str, Any]]:
|
|
28
|
+
meta: dict[str, Any] = {}
|
|
29
|
+
if surface == "full":
|
|
30
|
+
return data, meta
|
|
31
|
+
|
|
32
|
+
if surface not in ("model", "human"):
|
|
33
|
+
surface = "model"
|
|
34
|
+
|
|
35
|
+
projected = _redact(copy.deepcopy(data))
|
|
36
|
+
if isinstance(projected, dict) and "items" in projected and isinstance(projected["items"], list):
|
|
37
|
+
items = projected["items"]
|
|
38
|
+
if len(items) > MODEL_MAX_ROWS:
|
|
39
|
+
projected["items"] = items[:MODEL_MAX_ROWS]
|
|
40
|
+
meta["truncated"] = True
|
|
41
|
+
meta["row_count"] = len(items)
|
|
42
|
+
return projected, meta
|
gax/registry.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from gax.paths import MANIFESTS_DIR, USER_MANIFESTS_DIR
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CommandManifest:
|
|
14
|
+
command: str
|
|
15
|
+
version: str
|
|
16
|
+
description: str
|
|
17
|
+
category: str
|
|
18
|
+
adapter: str
|
|
19
|
+
backend: list[str] = field(default_factory=list)
|
|
20
|
+
required_scopes: list[str] = field(default_factory=list)
|
|
21
|
+
side_effects: str = "read"
|
|
22
|
+
idempotent: bool = True
|
|
23
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
output_schema: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def cmd_id(self) -> str:
|
|
29
|
+
return f"{self.command}@{self.version}"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def schema_uri(self) -> str:
|
|
33
|
+
return f"https://schemas.gax.dev/{self.command.replace('.', '/')}/v{self.version.split('.')[0]}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Registry:
|
|
37
|
+
"""
|
|
38
|
+
Command registry.
|
|
39
|
+
|
|
40
|
+
Loads from two places, in order: the manifests bundled with the package, then
|
|
41
|
+
`~/.gax/manifests/`. The user directory wins on conflict, so installing a
|
|
42
|
+
profile or hand-editing a command survives a package upgrade instead of being
|
|
43
|
+
overwritten by it.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, manifests_dir: Path | None = None) -> None:
|
|
47
|
+
self._dir = manifests_dir or MANIFESTS_DIR
|
|
48
|
+
# An explicit dir (tests, alternate deployments) means exactly that dir.
|
|
49
|
+
self._extra_dirs: list[Path] = [] if manifests_dir else [USER_MANIFESTS_DIR]
|
|
50
|
+
self._commands: dict[str, CommandManifest] = {}
|
|
51
|
+
self.reload()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def source_dirs(self) -> list[Path]:
|
|
55
|
+
return [self._dir, *self._extra_dirs]
|
|
56
|
+
|
|
57
|
+
def reload(self) -> None:
|
|
58
|
+
self._commands.clear()
|
|
59
|
+
for directory in self.source_dirs:
|
|
60
|
+
if directory.exists():
|
|
61
|
+
self._load_dir(directory)
|
|
62
|
+
|
|
63
|
+
def _load_dir(self, directory: Path) -> None:
|
|
64
|
+
for path in sorted(directory.glob("*.yaml")):
|
|
65
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
66
|
+
cmd = data.get("command")
|
|
67
|
+
if not cmd:
|
|
68
|
+
continue
|
|
69
|
+
self._commands[cmd] = CommandManifest(
|
|
70
|
+
command=cmd,
|
|
71
|
+
version=str(data.get("version", "1.0.0")),
|
|
72
|
+
description=str(data.get("description", "")),
|
|
73
|
+
category=str(data.get("category", "general")),
|
|
74
|
+
adapter=str(data.get("adapter", "mock")),
|
|
75
|
+
backend=list(data.get("backend") or []),
|
|
76
|
+
required_scopes=list(data.get("required_scopes") or []),
|
|
77
|
+
side_effects=str(data.get("side_effects", "read")),
|
|
78
|
+
idempotent=bool(data.get("idempotent", True)),
|
|
79
|
+
input_schema=dict(data.get("input_schema") or {}),
|
|
80
|
+
output_schema=dict(data.get("output_schema") or {}),
|
|
81
|
+
raw=data,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def get(self, command: str) -> CommandManifest | None:
|
|
85
|
+
return self._commands.get(command)
|
|
86
|
+
|
|
87
|
+
def list_commands(self) -> list[CommandManifest]:
|
|
88
|
+
return list(self._commands.values())
|
|
89
|
+
|
|
90
|
+
def search(self, query: str, limit: int = 5) -> list[CommandManifest]:
|
|
91
|
+
q = query.lower().strip()
|
|
92
|
+
if not q:
|
|
93
|
+
return self.list_commands()[:limit]
|
|
94
|
+
scored: list[tuple[int, CommandManifest]] = []
|
|
95
|
+
for m in self._commands.values():
|
|
96
|
+
hay = f"{m.command} {m.description} {m.category}".lower()
|
|
97
|
+
score = 0
|
|
98
|
+
if q in m.command.lower():
|
|
99
|
+
score += 10
|
|
100
|
+
if q in m.description.lower():
|
|
101
|
+
score += 5
|
|
102
|
+
for word in q.split():
|
|
103
|
+
if word in hay:
|
|
104
|
+
score += 2
|
|
105
|
+
if score > 0:
|
|
106
|
+
scored.append((score, m))
|
|
107
|
+
scored.sort(key=lambda x: (-x[0], x[1].command))
|
|
108
|
+
return [m for _, m in scored[:limit]]
|
|
109
|
+
|
|
110
|
+
def doc_stub(self, command: str) -> dict[str, Any] | None:
|
|
111
|
+
m = self.get(command)
|
|
112
|
+
if not m:
|
|
113
|
+
return None
|
|
114
|
+
props = (m.input_schema or {}).get("properties") or {}
|
|
115
|
+
return {
|
|
116
|
+
"command": m.command,
|
|
117
|
+
"version": m.version,
|
|
118
|
+
"description": m.description,
|
|
119
|
+
"usage": f"gax {m.command} --repo owner/name",
|
|
120
|
+
"required_scopes": m.required_scopes,
|
|
121
|
+
"arguments": {
|
|
122
|
+
k: {
|
|
123
|
+
"type": v.get("type", "string"),
|
|
124
|
+
"description": v.get("description", ""),
|
|
125
|
+
"required": k in ((m.input_schema or {}).get("required") or []),
|
|
126
|
+
}
|
|
127
|
+
for k, v in props.items()
|
|
128
|
+
},
|
|
129
|
+
"side_effects": m.side_effects,
|
|
130
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.gax.dev/envelope/v1",
|
|
4
|
+
"title": "GAX Envelope v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["v", "ok", "cmd", "audit_id", "surface", "data", "meta"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"v": { "const": 1 },
|
|
9
|
+
"ok": { "type": "boolean" },
|
|
10
|
+
"cmd": { "type": "string", "description": "command@version" },
|
|
11
|
+
"audit_id": { "type": "string" },
|
|
12
|
+
"schema": { "type": "string", "format": "uri" },
|
|
13
|
+
"surface": { "enum": ["model", "human", "full"] },
|
|
14
|
+
"data": {},
|
|
15
|
+
"meta": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"truncated": { "type": "boolean" },
|
|
19
|
+
"cursor": { "type": "string" },
|
|
20
|
+
"row_count": { "type": "integer" },
|
|
21
|
+
"duration_ms": { "type": "number" }
|
|
22
|
+
},
|
|
23
|
+
"additionalProperties": true
|
|
24
|
+
},
|
|
25
|
+
"error": {
|
|
26
|
+
"type": ["object", "null"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"kind": { "type": "string" },
|
|
29
|
+
"message": { "type": "string" },
|
|
30
|
+
"retryable": { "type": "boolean" }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"next": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"required": ["cmd", "args"],
|
|
38
|
+
"properties": {
|
|
39
|
+
"cmd": { "type": "string" },
|
|
40
|
+
"args": { "type": "object" },
|
|
41
|
+
"reason": { "type": "string" }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
gax/secrets.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from gax.paths import ensure_gax_home
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _use_keyring() -> bool:
|
|
11
|
+
try:
|
|
12
|
+
import keyring # noqa: F401
|
|
13
|
+
|
|
14
|
+
return True
|
|
15
|
+
except ImportError:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _service_name(tenant: str, provider: str) -> str:
|
|
20
|
+
return f"gax:{tenant}:{provider}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def save_secret(tenant: str, provider: str, data: dict[str, Any], *, file_path: Path) -> None:
|
|
24
|
+
"""Prefer OS keychain when keyring is installed; else encrypted-at-rest file (mode 600)."""
|
|
25
|
+
payload = json.dumps(data)
|
|
26
|
+
if _use_keyring():
|
|
27
|
+
import keyring
|
|
28
|
+
|
|
29
|
+
keyring.set_password(_service_name(tenant, provider), "oauth_tokens", payload)
|
|
30
|
+
# Remove plaintext file if migrating
|
|
31
|
+
if file_path.exists():
|
|
32
|
+
file_path.unlink()
|
|
33
|
+
return
|
|
34
|
+
ensure_gax_home()
|
|
35
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
file_path.write_text(payload)
|
|
37
|
+
file_path.chmod(0o600)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_secret(tenant: str, provider: str, *, file_path: Path) -> dict[str, Any] | None:
|
|
41
|
+
if _use_keyring():
|
|
42
|
+
import keyring
|
|
43
|
+
|
|
44
|
+
raw = keyring.get_password(_service_name(tenant, provider), "oauth_tokens")
|
|
45
|
+
if raw:
|
|
46
|
+
return json.loads(raw)
|
|
47
|
+
if file_path.exists():
|
|
48
|
+
return json.loads(file_path.read_text())
|
|
49
|
+
return None
|
gax/side_effects.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Side-effect ladder — the danger level of a command, and the ceiling a capability
|
|
3
|
+
is allowed to reach.
|
|
4
|
+
|
|
5
|
+
Every manifest declares `side_effects: read | write | destructive`. Every capability
|
|
6
|
+
carries a `max_side_effect` ceiling (default `read`). A capability may only invoke a
|
|
7
|
+
command whose level is at or below its ceiling.
|
|
8
|
+
|
|
9
|
+
**Why this exists as a second check.** The capability already carries an explicit
|
|
10
|
+
command allowlist, so in principle naming the command is enough. But an allowlist is
|
|
11
|
+
edited by humans under time pressure, and the failure mode is silent: add
|
|
12
|
+
`k8s.pod.delete` to a token that was meant to be read-only and nothing complains. The
|
|
13
|
+
ceiling makes that mistake inert — two independent things must be wrong before
|
|
14
|
+
something gets deleted, and the second one has to be stated in the explicit language
|
|
15
|
+
of danger rather than as an opaque command name.
|
|
16
|
+
|
|
17
|
+
Defaults fail closed:
|
|
18
|
+
- a manifest with no/unknown `side_effects` is treated as `destructive`
|
|
19
|
+
- a capability with no ceiling is treated as `read`
|
|
20
|
+
|
|
21
|
+
so a command added without thinking about danger is refused rather than allowed.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
# Ordered least → most dangerous. Index is the comparison key.
|
|
29
|
+
LADDER: tuple[str, ...] = ("read", "write", "destructive")
|
|
30
|
+
|
|
31
|
+
DEFAULT_CEILING = "read"
|
|
32
|
+
# Unknown manifest levels fail closed at the top of the ladder.
|
|
33
|
+
UNKNOWN_LEVEL = "destructive"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def level_rank(level: str | None) -> int:
|
|
37
|
+
"""Rank of a side-effect level. Unknown/missing → most dangerous."""
|
|
38
|
+
if not level:
|
|
39
|
+
return LADDER.index(UNKNOWN_LEVEL)
|
|
40
|
+
try:
|
|
41
|
+
return LADDER.index(str(level).strip().lower())
|
|
42
|
+
except ValueError:
|
|
43
|
+
return LADDER.index(UNKNOWN_LEVEL)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize(level: str | None) -> str:
|
|
47
|
+
"""Canonical level name, failing closed on anything unrecognized."""
|
|
48
|
+
return LADDER[level_rank(level)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def capability_ceiling(claims: dict[str, Any]) -> str:
|
|
52
|
+
"""
|
|
53
|
+
The ceiling this capability may reach.
|
|
54
|
+
|
|
55
|
+
Absent → `read`. A capability minted before ceilings existed therefore cannot
|
|
56
|
+
invoke a destructive command, which is the safe direction for that migration.
|
|
57
|
+
"""
|
|
58
|
+
return normalize(claims.get("max_side_effect") or DEFAULT_CEILING)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def exceeds_ceiling(claims: dict[str, Any], command_level: str | None) -> bool:
|
|
62
|
+
"""True when `command_level` is more dangerous than the capability allows."""
|
|
63
|
+
return level_rank(command_level) > level_rank(capability_ceiling(claims))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_destructive(level: str | None) -> bool:
|
|
67
|
+
return normalize(level) == "destructive"
|
gax/spiffe.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SPIFFE / workload identity hooks for gaxd (MVP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def workload_identity() -> dict[str, Any]:
|
|
11
|
+
"""Read SPIFFE material from env (set by tbot, SPIRE agent, or K8s CSI)."""
|
|
12
|
+
cert = os.environ.get("GAX_SPIFFE_CERT_PATH", "")
|
|
13
|
+
key = os.environ.get("GAX_SPIFFE_KEY_PATH", "")
|
|
14
|
+
svid = os.environ.get("GAX_SPIFFE_SVID", "")
|
|
15
|
+
spiffe_id = os.environ.get("GAX_SPIFFE_ID", "")
|
|
16
|
+
out: dict[str, Any] = {
|
|
17
|
+
"enabled": bool(cert or svid or spiffe_id),
|
|
18
|
+
"spiffe_id": spiffe_id or None,
|
|
19
|
+
"cert_present": bool(cert and Path(cert).exists()),
|
|
20
|
+
"key_present": bool(key and Path(key).exists()),
|
|
21
|
+
}
|
|
22
|
+
if svid:
|
|
23
|
+
out["svid_jwt_prefix"] = svid[:20] + "…"
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def attest_metadata() -> dict[str, Any]:
|
|
28
|
+
"""Metadata attached to audit events when workload identity is configured."""
|
|
29
|
+
wi = workload_identity()
|
|
30
|
+
if not wi["enabled"]:
|
|
31
|
+
return {}
|
|
32
|
+
return {"workload_identity": wi}
|
gax/vault.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Multi-tenant secret vault (file MVP; HashiCorp Vault hook via env)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from gax.paths import GAX_HOME, ensure_gax_home
|
|
13
|
+
from gax.secrets import load_secret, save_secret
|
|
14
|
+
|
|
15
|
+
VAULT_DIR = GAX_HOME / "vault"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tenant_path(tenant: str) -> Path:
|
|
19
|
+
ensure_gax_home()
|
|
20
|
+
d = VAULT_DIR / tenant
|
|
21
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return d / "secrets.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def vault_put(tenant: str, key: str, value: str) -> None:
|
|
26
|
+
hv = os.environ.get("GAX_HASHICORP_VAULT_ADDR")
|
|
27
|
+
if hv:
|
|
28
|
+
_vault_hc_put(hv, tenant, key, value)
|
|
29
|
+
return
|
|
30
|
+
path = _tenant_path(tenant)
|
|
31
|
+
data = load_secret(tenant, "vault", file_path=path) or {}
|
|
32
|
+
data[key] = value
|
|
33
|
+
save_secret(tenant, "vault", data, file_path=path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def vault_get(tenant: str, key: str) -> str | None:
|
|
37
|
+
hv = os.environ.get("GAX_HASHICORP_VAULT_ADDR")
|
|
38
|
+
if hv:
|
|
39
|
+
return _vault_hc_get(hv, tenant, key)
|
|
40
|
+
path = _tenant_path(tenant)
|
|
41
|
+
data = load_secret(tenant, "vault", file_path=path) or {}
|
|
42
|
+
return data.get(key)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _vault_hc_headers() -> dict[str, str]:
|
|
46
|
+
tok = os.environ.get("GAX_HASHICORP_VAULT_TOKEN", "")
|
|
47
|
+
h = {"Content-Type": "application/json"}
|
|
48
|
+
if tok:
|
|
49
|
+
h["X-Vault-Token"] = tok
|
|
50
|
+
return h
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _vault_hc_put(addr: str, tenant: str, key: str, value: str) -> None:
|
|
54
|
+
path = f"{addr.rstrip('/')}/v1/secret/data/gax/{tenant}/{key}"
|
|
55
|
+
httpx.post(
|
|
56
|
+
path,
|
|
57
|
+
headers=_vault_hc_headers(),
|
|
58
|
+
json={"data": {"value": value}},
|
|
59
|
+
timeout=15.0,
|
|
60
|
+
).raise_for_status()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _vault_hc_get(addr: str, tenant: str, key: str) -> str | None:
|
|
64
|
+
path = f"{addr.rstrip('/')}/v1/secret/data/gax/{tenant}/{key}"
|
|
65
|
+
r = httpx.get(path, headers=_vault_hc_headers(), timeout=15.0)
|
|
66
|
+
if r.status_code == 404:
|
|
67
|
+
return None
|
|
68
|
+
r.raise_for_status()
|
|
69
|
+
return (r.json().get("data") or {}).get("data", {}).get("value")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gax-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Governed execution layer for agent shell commands — capability-gated, audited, MCP-compatible
|
|
5
|
+
Author: Sparsh Nagpal
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/0sparsh2/GAX
|
|
8
|
+
Project-URL: Repository, https://github.com/0sparsh2/GAX
|
|
9
|
+
Project-URL: Documentation, https://github.com/0sparsh2/GAX/blob/main/docs/QUICKSTART.md
|
|
10
|
+
Project-URL: Issues, https://github.com/0sparsh2/GAX/issues
|
|
11
|
+
Keywords: ai-agents,mcp,model-context-protocol,agent-governance,capability-security,audit,llm-tools
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: click>=8.1
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
28
|
+
Requires-Dist: jsonschema>=4.20
|
|
29
|
+
Requires-Dist: PyJWT>=2.8
|
|
30
|
+
Requires-Dist: httpx>=0.27
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
33
|
+
Provides-Extra: keyring
|
|
34
|
+
Requires-Dist: keyring>=25.0; extra == "keyring"
|
|
35
|
+
|
|
36
|
+
# GAX — Governed Agent eXecution
|
|
37
|
+
|
|
38
|
+
**Your MCP gateway can't see `bash`. GAX governs both.**
|
|
39
|
+
|
|
40
|
+
GAX is a governed execution layer for agent shell commands. Agents get a
|
|
41
|
+
command-line-shaped surface; OAuth, policy, audit, and tenancy live in a sidecar the
|
|
42
|
+
model never sees.
|
|
43
|
+
|
|
44
|
+
The guarantee: **an agent can only run commands you registered, every invoke is checked
|
|
45
|
+
against a capability token before any backend runs, and every invoke produces an
|
|
46
|
+
`audit_id`.**
|
|
47
|
+
|
|
48
|
+
It **complements MCP** rather than competing with it — MCP servers become adapters behind
|
|
49
|
+
stable command names, so one policy file and one audit trail cover both your MCP calls
|
|
50
|
+
and your shell calls.
|
|
51
|
+
|
|
52
|
+
Full docs, research, and evaluation: **https://github.com/0sparsh2/GAX**
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install gax-cli
|
|
60
|
+
gax init --profile k8s --profile github
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That creates `~/.gax`, mints a 30-day **read-only** capability, starts the sidecar, and
|
|
64
|
+
registers 22 real commands. Measured on a clean machine: about a second.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
gax k8s.pod.logs --pod web-1 # works immediately, no exports needed
|
|
68
|
+
gax doctor # diagnose config, capability, sidecar, backends
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## See it stop something dangerous
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
export GAX_K8S_MOCK=1 # no cluster needed
|
|
75
|
+
gax k8s.namespace.delete --namespace prod
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"ok": false,
|
|
81
|
+
"error": {
|
|
82
|
+
"kind": "policy_denied",
|
|
83
|
+
"message": "side_effects 'destructive' exceeds capability ceiling 'read'"
|
|
84
|
+
},
|
|
85
|
+
"audit_id": "aud_cef808f0fadd41e7"
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Refused before `kubectl` was ever spawned, and the denial is audited with its arguments.
|
|
90
|
+
|
|
91
|
+
Even if the command sits on the token's allowlist it is *still* refused, because the
|
|
92
|
+
danger ceiling is a separate control — **two independent things must be wrong before
|
|
93
|
+
something gets deleted.**
|
|
94
|
+
|
|
95
|
+
To run it, raise the ceiling deliberately, with `--dry-run` available first:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
export GAX_CAP="$(gax auth cap-mint --command k8s.namespace.delete \
|
|
99
|
+
--scope k8s:namespaces:write --max-side-effect destructive --raw)"
|
|
100
|
+
|
|
101
|
+
gax k8s.namespace.delete --namespace staging --dry-run
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Use it from any MCP client
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
claude mcp add gax -- gax-mcp
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Publishes exactly three tools — `gax_search`, `gax_doc`, `gax_invoke` — keeping the
|
|
111
|
+
registry behind them, so schema cost stays constant no matter how many commands you
|
|
112
|
+
register.
|
|
113
|
+
|
|
114
|
+
## Add your own command
|
|
115
|
+
|
|
116
|
+
One YAML file in `~/.gax/manifests/`:
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
command: db.migration.run
|
|
120
|
+
version: "1.0.0"
|
|
121
|
+
description: Apply pending migrations
|
|
122
|
+
adapter: exec
|
|
123
|
+
required_scopes: [db:schema:write]
|
|
124
|
+
side_effects: destructive # sets the ceiling required to invoke it
|
|
125
|
+
input_schema:
|
|
126
|
+
type: object
|
|
127
|
+
properties:
|
|
128
|
+
env: { type: string, description: Target environment }
|
|
129
|
+
dry_run: { type: boolean, description: Validate without applying }
|
|
130
|
+
required: [env]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
CLI flags are generated from the schema. Omit `side_effects` and GAX fails closed —
|
|
134
|
+
undeclared commands are treated as `destructive`.
|
|
135
|
+
|
|
136
|
+
Generate manifests instead of writing them: `gax openapi generate spec.json`.
|
|
137
|
+
|
|
138
|
+
## Commands
|
|
139
|
+
|
|
140
|
+
| | |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `gax init` / `gax doctor` | Setup and diagnostics |
|
|
143
|
+
| `gax profile list` / `add` | Bundled command sets (`k8s`, `github`) |
|
|
144
|
+
| `gax search` / `doc` / `schema` | Lazy discovery |
|
|
145
|
+
| `gax <command>` | Invoke a registered command |
|
|
146
|
+
| `gax auth cap-mint` | Mint a capability (`--max-side-effect read\|write\|destructive`) |
|
|
147
|
+
| `gax auth login` | OAuth 2.0 device flow |
|
|
148
|
+
| `gax plan run <file>` | Multi-step / parallel workflows |
|
|
149
|
+
| `gax vault put` / `get` | Tenant secrets (file or HashiCorp) |
|
|
150
|
+
| `gax compliance export` | SOC2-aligned audit export |
|
|
151
|
+
| `gaxd start` / `stop` / `status` | Sidecar lifecycle |
|
|
152
|
+
| `gax-mcp` | Run GAX as an MCP server (stdio) |
|
|
153
|
+
|
|
154
|
+
Audit log: `~/.gax/audit.jsonl`, one JSON object per invocation, including denials.
|
|
155
|
+
|
|
156
|
+
## Honest scope
|
|
157
|
+
|
|
158
|
+
- Raw CLI costs ~3.5× fewer tokens than GAX on like-for-like tasks. Governance is not
|
|
159
|
+
free, and we publish that number rather than a flattering one.
|
|
160
|
+
- Vault, SPIFFE, and OPA integrations are hooks and stubs, not production features.
|
|
161
|
+
- The evaluation is a self-assessment by the author; methodology and known defects are
|
|
162
|
+
documented in the repository.
|
|
163
|
+
|
|
164
|
+
MIT licensed. Issues and contributions: https://github.com/0sparsh2/GAX
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
gax/__init__.py,sha256=MtEPF32M-McRPfaigQYR6LxocQx0UzKyWW5x7mbAp5A,63
|
|
2
|
+
gax/audit.py,sha256=xsYEQ9OP2seWC4MJOrC9HCCinbJf6UCBg3hhXvCqVnc,1390
|
|
3
|
+
gax/caps.py,sha256=R6UanAet-EEgAkuCUxm954e-3bsmUGe4sI6YEUODiVo,2889
|
|
4
|
+
gax/cli.py,sha256=7wi9AVg5hcr5i4rYkPLFCNoWbyOBfCVD3tyJAFsrNiE,24173
|
|
5
|
+
gax/client.py,sha256=wBlg_5q28K2XS5ixW6TPF6y7rQDbSK_Ws6EKgTZZePU,1838
|
|
6
|
+
gax/compliance.py,sha256=xZ1tD__hxC0GMFiGtpGU6ZBbXrhStCV9oTojFJJqvvA,2025
|
|
7
|
+
gax/daemon.py,sha256=ZblMjUu1nicmc6BTFP6arLrF1z2mfbdGrvYLSow75-8,5996
|
|
8
|
+
gax/envelope.py,sha256=AF7qcfpI1y1XCLwFIeICgBE0vRbiMb1W0BaHkCjrpr4,1443
|
|
9
|
+
gax/executor.py,sha256=WU2Wyjl0MZsY4vWpFl4EqY9DfjmC8UdU1IzHZS7952k,4091
|
|
10
|
+
gax/macaroons_cap.py,sha256=VJPKletpgwOPdkxB7Ltes_xBfsoisCSTCslv1-hKZRM,2517
|
|
11
|
+
gax/mcp_client.py,sha256=UQwM_upcT54uUgiMv-GGUxMQGGnamkBFZlDO6UnAXNk,3587
|
|
12
|
+
gax/mcp_server.py,sha256=wiC_ErL0FWC3PwfHNXgZstNfORDCiGyfDCaXuZG4e38,10249
|
|
13
|
+
gax/oauth.py,sha256=U4ZoTjI1RefFWcRp35DiWlCYYgqp4Z2K0hKsBO8U-4w,6069
|
|
14
|
+
gax/onboarding.py,sha256=sQUWnsR6M92GHoCyFK7PAki7X1Jp57UnY2IaOvGTzps,13008
|
|
15
|
+
gax/opa_policy.py,sha256=2JfkvZjTtC16Du34icypxzpLf1yR34NvmZ9BgydiciU,1432
|
|
16
|
+
gax/openapi_gen.py,sha256=siVSMBq6Ua45yPHWD0aJc1D6fBkVcq-mxNcPNi1AIQ8,3297
|
|
17
|
+
gax/otel_audit.py,sha256=D0ReRaA-mYLPj7LfA3MHQ5-lnKyHH7YeHWyT-fRvaQQ,1327
|
|
18
|
+
gax/paths.py,sha256=nzU2t51yGfldhw_w_n9L_b-dAYGogCmiY1JvopxoDug,1574
|
|
19
|
+
gax/plan.py,sha256=H0djp11oao9k2hJkz46CiOlm6SFD_xNzb4Tjqtpa44A,4793
|
|
20
|
+
gax/policy.py,sha256=ZVCo20a--Fy9fgW9IK9moRrCSrQ2TSc4PerfvQnBWGE,1353
|
|
21
|
+
gax/policy_bundle.py,sha256=jUYHu_TJddIZPcxk_rQHceWqg_BrqwQjxFFcwK3fkY4,2200
|
|
22
|
+
gax/profiles.py,sha256=ScNM2AjEv5_LCJ4krIBNGsydSZhx-xgCwA4RuGDSUfI,4475
|
|
23
|
+
gax/projection.py,sha256=pv1DmGK1bNjXXnHR7p2FzYSKE_BVaozizdYFMknjruw,1247
|
|
24
|
+
gax/registry.py,sha256=Ba27QZAbIzmReC3oKRkwdjo6KhQUtn4vDq5bfDqlzvM,4683
|
|
25
|
+
gax/secrets.py,sha256=KDGNl3dyGTGYenb6tKaCsSO_m2lK7pId3Syi0xujZxk,1356
|
|
26
|
+
gax/side_effects.py,sha256=gtYKUJ7ZtCWcbhigjQXjcm2bE3z3-FqyqN5izh41NEE,2566
|
|
27
|
+
gax/spiffe.py,sha256=F5EBL3I1Sxhp3uJHWWEHrKSgslsgerGJpKAVtuCTkt0,1039
|
|
28
|
+
gax/vault.py,sha256=c9gFLLGCbaWOycIksvQyj_QPsy9mAVmJQYBZn4Sz2AU,1972
|
|
29
|
+
gax/adapters/__init__.py,sha256=2tFcSP8OoYq9z8b9suULv-r8QV3nFz737pX86qABScc,69
|
|
30
|
+
gax/adapters/base.py,sha256=WvenAGsiGe3SpyjKxCAc9rBXr8bCb7PZTYbi3Ze5aTQ,887
|
|
31
|
+
gax/adapters/exec_adapter.py,sha256=RdeyxAlyoD6BOMGXE65MnIhS8wcLrlInGVxEjO0fi3o,8008
|
|
32
|
+
gax/adapters/http_adapter.py,sha256=M7E79JIm4bfMwERWDH7ggziTrBKuDVb2THoVbUWAbHQ,1078
|
|
33
|
+
gax/adapters/k8s_adapter.py,sha256=p3pvwxFoBrPsf6ECr-CEqgCiNNi8Xso4oxZcTmHsYZo,10873
|
|
34
|
+
gax/adapters/mcp_bridge.py,sha256=srprT_oBQVT5vxNEO2Ebe9FW1TSNYgU5MnuVZ57SjBI,3610
|
|
35
|
+
gax/adapters/mock_adapter.py,sha256=mCButxH3n1Ctal8tPQDPqAuZ_PEZfuew-JfvyoF9mQU,1885
|
|
36
|
+
gax/config/oauth_providers.yaml,sha256=yHT9uGVGSFStT2-1pQf-2FXfRpYfmjwXhVknXvxx7ac,730
|
|
37
|
+
gax/config/policy.rego,sha256=mJEUxxhQegn2U87vvIaRp0YTc8P6ydOwaojBbFtDJ-g,298
|
|
38
|
+
gax/config/policy.yaml,sha256=-1eaJdVEDo4LrowFFBcij-P1QvGQ2UK4-skFwQYXksI,1948
|
|
39
|
+
gax/manifests/aws.s3.list.yaml,sha256=yNrKSXGMe7RPwqgfFq9y3JRHMr1R-JrKhs-5-ivDvXo,342
|
|
40
|
+
gax/manifests/demo.echo.yaml,sha256=yUUG9QjYLa775FfRPe2WL3_iNHEN1XI7o3bWh4qngqQ,357
|
|
41
|
+
gax/manifests/gh.pr.list.yaml,sha256=ydaisewA8GqZ_rlR6I5UtdDFT1AUEKDTc9JO3-pXKvc,826
|
|
42
|
+
gax/manifests/gh.pr.view.yaml,sha256=kJVGwUUguesQWBtm1tWGQnpz6c4-BuJzWONixCfuZJU,565
|
|
43
|
+
gax/manifests/jira.issue.get.yaml,sha256=k7Bz28o6gKyFaIsfwUt-Wx0PJaFu9puPc3GyAPUM1oI,353
|
|
44
|
+
gax/manifests/k8s.deployment.scale.yaml,sha256=ic-HlPhFQlX2ArGos1SYIfxe8YGJ36lTaCChAwJHEP0,906
|
|
45
|
+
gax/manifests/k8s.pod.delete.yaml,sha256=gSGcVn_E7J1Za3Uem7WghNUV0OpZLdVqzZQFcKrw4N8,768
|
|
46
|
+
gax/manifests/kubectl.get.pods.yaml,sha256=GLSHU6rrumgCNN0UJaRJim-mj30K_WxLABlBPFe5Kmo,391
|
|
47
|
+
gax/manifests/mcp.github.list_pulls.yaml,sha256=nWDUGOnh-QW5UhMnNIOE_wg41Z68o4DQ2SJHa_YF3ag,605
|
|
48
|
+
gax/manifests/pet_findpetsbystatus.yaml,sha256=8mvQ9F8RVIuqp5AAES0drPbP1oPMN-t1HgEjkOpEj50,319
|
|
49
|
+
gax/manifests/pet_getpetbyid.yaml,sha256=lJ8nWdnXLWeOyLlRhavtCo4TElw0WdwiNRTZto2t8SQ,305
|
|
50
|
+
gax/profiles/github/gh.issue.list.yaml,sha256=Qh80Z3MHmKMW7UiwMRwddTSri3Aij6uYuTQZvS3Ks2U,487
|
|
51
|
+
gax/profiles/github/gh.issue.view.yaml,sha256=LlFdKLjkutG6t-j-JGTnK-VOo9O6g2OcUBxOw43YBnw,412
|
|
52
|
+
gax/profiles/github/gh.pr.comment.yaml,sha256=ax-RTErkvEpxI3Vgwgx3-DvSw6FysQwhlx35ZoPHdWI,580
|
|
53
|
+
gax/profiles/github/gh.pr.merge.yaml,sha256=wkuFsbIOCTxkWACLRToMEw_q2vr-V6Z88rcDKvCbhoE,625
|
|
54
|
+
gax/profiles/github/gh.run.list.yaml,sha256=csBxO1qh_m8SFSZCfW95PjxX59KBxfulS7AXdT3gPx0,421
|
|
55
|
+
gax/profiles/k8s/k8s.deployment.list.yaml,sha256=sXsE0KfPoikaIDIkH1_bjsBOM_hAu2zZYNd5eAa3QoQ,350
|
|
56
|
+
gax/profiles/k8s/k8s.deployment.restart.yaml,sha256=d5_viY8w02yu--hdjfWONQC1dLKXbSR5pc4yjLPx8qk,553
|
|
57
|
+
gax/profiles/k8s/k8s.namespace.delete.yaml,sha256=3FZkRnC8Q2i_9fvXuYDB3Zlf2BLUumm1pr4AarY6YJ8,499
|
|
58
|
+
gax/profiles/k8s/k8s.pod.describe.yaml,sha256=QtEMd6u_UAWKCxyPDM9jrOxlqur3D5qK0TdVasBl49w,430
|
|
59
|
+
gax/profiles/k8s/k8s.pod.logs.yaml,sha256=NySwRyDbnBbqwGXMDvaE6AQwnk2mQDeJZN9BAOZlovI,595
|
|
60
|
+
gax/profiles/k8s/k8s.service.list.yaml,sha256=yQv8Xe3Xd-k7P2ZKOpHSoUx88V-m_2p-LvLyvr4us2o,341
|
|
61
|
+
gax/schemas/envelope.v1.json,sha256=8xJzS2DOa9OLboSTTF9g2z3qsCw6CbPC-8z7zWjZjwk,1321
|
|
62
|
+
gax_cli-0.4.0.dist-info/METADATA,sha256=ag56qONifVADjiKjh2jZn1NfrGjgugnLBAF5rvTHCjc,5713
|
|
63
|
+
gax_cli-0.4.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
64
|
+
gax_cli-0.4.0.dist-info/entry_points.txt,sha256=Jnu0YxVDRRiSZ-x_aZr3XD6xWp7KTkzrICfxgF9yZM8,90
|
|
65
|
+
gax_cli-0.4.0.dist-info/top_level.txt,sha256=1LX0rekJftkdwIHUy--CeuWoPGXAtOkBkJsa4AfuUAQ,4
|
|
66
|
+
gax_cli-0.4.0.dist-info/RECORD,,
|