dev-bubble 0.7.3__tar.gz → 0.7.6__tar.gz
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.
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.claude/CLAUDE.md +5 -2
- {dev_bubble-0.7.3/dev_bubble.egg-info → dev_bubble-0.7.6}/PKG-INFO +1 -1
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/__init__.py +1 -1
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/auth_proxy.py +208 -14
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/github_token.py +60 -17
- dev_bubble-0.7.6/bubble/graphql_validator.py +917 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/gh.sh +10 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/vscode.sh +44 -10
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/security.py +1 -1
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/tools.py +6 -1
- {dev_bubble-0.7.3 → dev_bubble-0.7.6/dev_bubble.egg-info}/PKG-INFO +1 -1
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/SOURCES.txt +2 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_auth_proxy.py +9 -1
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_github_token.py +9 -2
- dev_bubble-0.7.6/tests/test_graphql_validator.py +845 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_security.py +44 -6
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.gitignore +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/CHANGELOG.md +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/LICENSE +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/README.md +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/SPEC.md +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/__main__.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/automation.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/claude.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/clean.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/cli.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/clone.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/cloud.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/completion.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/doctor.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/images.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/infrastructure.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/lifecycle.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/list_cmd.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/security_cmd.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/settings.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/status_cmd.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/config.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/container_helpers.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/data/skill.md +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/default_repos.json +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/finalization.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/git_store.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/hooks/__init__.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/hooks/lean.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/hooks/python.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/image_management.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/builder.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/base.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/python.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/pins.json +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/lean.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/lifecycle.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/naming.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/native.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/network.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/notices.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/output.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/provisioning.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/relay.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/remote.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/base.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/colima.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/incus.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/setup.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/skill.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/spinner.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/target.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/token_store.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/tunnel.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/vscode.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/conftest.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/pyproject.toml +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/setup.cfg +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/conftest.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_branch_no_target.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_build_lock.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_claude.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_claude_projects_symlink.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_cloud.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_completion.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_config.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_customize.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_editor.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_git_store.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_hooks.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_integration.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_lifecycle.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_list_columns.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_mounts.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_multi_target.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_naming.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_native.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_network.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_notices.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_relay.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_remote.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_skill.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_spinner.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_status.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_target.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_tools.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_tunnel.py +0 -0
- {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_vscode.py +0 -0
|
@@ -23,6 +23,7 @@ bubble/
|
|
|
23
23
|
├── automation.py # Periodic jobs: launchd (macOS), systemd (Linux)
|
|
24
24
|
├── relay.py # Bubble-in-bubble relay daemon (Unix socket, validation, rate limiting)
|
|
25
25
|
├── auth_proxy.py # HTTP reverse proxy for repo-scoped GitHub auth (token stays on host)
|
|
26
|
+
├── graphql_validator.py # GraphQL tokenizer, parser, and allowlist validation for auth proxy
|
|
26
27
|
├── tunnel.py # SSH reverse tunnel management for remote auth proxy access
|
|
27
28
|
├── remote.py # Remote SSH host support: run bubbles on remote machines
|
|
28
29
|
├── cloud.py # Hetzner Cloud auto-provisioning (provision, destroy, start, stop)
|
|
@@ -133,13 +134,15 @@ The auth proxy (`auth_proxy.py`) provides repo-scoped GitHub authentication with
|
|
|
133
134
|
|
|
134
135
|
**gh CLI flow:** `gh` configured with `http_unix_socket: /bubble/gh-proxy.sock` (via `GH_CONFIG_DIR=/etc/bubble/gh`) → sends requests through Unix socket → proxy validates token from `Authorization` header → enforces access level (REST repo-scoping, GraphQL mutation filtering) → adds real token → forwards to `https://api.github.com`.
|
|
135
136
|
|
|
136
|
-
**
|
|
137
|
+
**GraphQL validation** (`graphql_validator.py`): GraphQL access is controlled by two independent axes — `graphql_read` and `graphql_write` — each supporting `whitelisted`, `unrestricted`, or `none` modes. The default is `whitelisted` for both. In whitelisted mode, a lightweight tokenizer/parser validates structure (single operation, single top-level field, no aliases/directives, no fragments in mutations) and semantics. Read validation repo-scopes `repository` queries via variables, verifies `node` queries via pre-flight ownership checks, and checks second-level fields against an allowlist. Write validation checks mutations against an allowlist (createPullRequest, addComment, mergePullRequest, etc.) with repo-scoping via repositoryId comparison or pre-flight node ownership verification. Old tokens without `graphql_*` fields derive policies from the legacy `level` field for backward compatibility.
|
|
138
|
+
|
|
139
|
+
**REST security:** REST paths validated against `/repos/{owner}/{repo}/...` (repo-scoped). REST level defaults to `LEVEL_GH_READWRITE` since path validation already constrains access. API redirects (e.g. CI log downloads) followed with hardened rules: GET/HEAD only, HTTPS only, allowlisted hosts, max 2 hops, auth headers stripped. GitHub 4xx errors are passed through to clients (not collapsed to 502).
|
|
137
140
|
|
|
138
141
|
**Local bubbles:** Exposed via Incus proxy devices — TCP for git, Unix socket for gh (`listen=unix:/bubble/gh-proxy.sock`).
|
|
139
142
|
|
|
140
143
|
**Remote/cloud bubbles:** SSH reverse tunnel forwards the local proxy port. Incus proxy devices on the remote expose both TCP and Unix socket endpoints.
|
|
141
144
|
|
|
142
|
-
**Token management:** Per-container tokens in `~/.bubble/auth-tokens.json` map to `{container, owner, repo, level}`. Tokens are cleaned up on `bubble pop`. The daemon is managed via launchd/systemd.
|
|
145
|
+
**Token management:** Per-container tokens in `~/.bubble/auth-tokens.json` map to `{container, owner, repo, level, graphql_read, graphql_write}`. Tokens are cleaned up on `bubble pop`. The daemon is managed via launchd/systemd.
|
|
143
146
|
|
|
144
147
|
### Security Model
|
|
145
148
|
The `user` account has no sudo and a locked password. Network allowlisting is applied on container creation. SSH keys are injected via `incus file push` (not shell interpolation). All user-supplied values in shell commands are quoted with `shlex.quote()`. Each container mounts only its specific bare repo, not the entire git store.
|
|
@@ -141,16 +141,29 @@ logger = logging.getLogger("bubble.auth_proxy")
|
|
|
141
141
|
|
|
142
142
|
|
|
143
143
|
def generate_auth_token(
|
|
144
|
-
container_name: str,
|
|
144
|
+
container_name: str,
|
|
145
|
+
owner: str,
|
|
146
|
+
repo: str,
|
|
147
|
+
level: int = DEFAULT_LEVEL,
|
|
148
|
+
graphql_read: str = "whitelisted",
|
|
149
|
+
graphql_write: str = "whitelisted",
|
|
145
150
|
) -> str:
|
|
146
151
|
"""Generate an auth proxy token for a container.
|
|
147
152
|
|
|
148
|
-
The token maps to (container_name, owner, repo, level
|
|
149
|
-
uses this to validate requests and enforce
|
|
153
|
+
The token maps to (container_name, owner, repo, level, graphql_read,
|
|
154
|
+
graphql_write) — the proxy uses this to validate requests and enforce
|
|
155
|
+
the access policy.
|
|
150
156
|
Uses file locking to prevent read-modify-write races.
|
|
151
157
|
"""
|
|
152
158
|
return TokenStore(AUTH_PROXY_TOKENS).generate(
|
|
153
|
-
{
|
|
159
|
+
{
|
|
160
|
+
"container": container_name,
|
|
161
|
+
"owner": owner,
|
|
162
|
+
"repo": repo,
|
|
163
|
+
"level": level,
|
|
164
|
+
"graphql_read": graphql_read,
|
|
165
|
+
"graphql_write": graphql_write,
|
|
166
|
+
}
|
|
154
167
|
)
|
|
155
168
|
|
|
156
169
|
|
|
@@ -420,6 +433,31 @@ def _parse_graphql_op_type(query: str) -> str | None:
|
|
|
420
433
|
return "query"
|
|
421
434
|
|
|
422
435
|
|
|
436
|
+
def _resolve_graphql_policies(info: dict) -> tuple[str, str]:
|
|
437
|
+
"""Extract GraphQL policies from token info, with backward compat.
|
|
438
|
+
|
|
439
|
+
Returns (graphql_read, graphql_write).
|
|
440
|
+
For tokens created before the graphql_read/graphql_write fields
|
|
441
|
+
existed, derives policies from the legacy level field.
|
|
442
|
+
"""
|
|
443
|
+
from .graphql_validator import VALID_GRAPHQL_POLICIES
|
|
444
|
+
|
|
445
|
+
graphql_read = info.get("graphql_read")
|
|
446
|
+
graphql_write = info.get("graphql_write")
|
|
447
|
+
|
|
448
|
+
if graphql_read in VALID_GRAPHQL_POLICIES and graphql_write in VALID_GRAPHQL_POLICIES:
|
|
449
|
+
return graphql_read, graphql_write
|
|
450
|
+
|
|
451
|
+
# Backward compat: derive from level
|
|
452
|
+
level = info.get("level", LEVEL_GIT_ONLY)
|
|
453
|
+
if level < LEVEL_GH_READ:
|
|
454
|
+
return "none", "none"
|
|
455
|
+
elif level < LEVEL_GH_READWRITE:
|
|
456
|
+
return "unrestricted", "none"
|
|
457
|
+
else:
|
|
458
|
+
return "unrestricted", "unrestricted"
|
|
459
|
+
|
|
460
|
+
|
|
423
461
|
def classify_graphql(body: bytes) -> tuple[str | None, str | None]:
|
|
424
462
|
"""Classify a GraphQL request body.
|
|
425
463
|
|
|
@@ -605,6 +643,10 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
|
|
|
605
643
|
rate_limiter: ProxyRateLimiter
|
|
606
644
|
token_refresher: GitHubTokenRefresher
|
|
607
645
|
|
|
646
|
+
# Thread-safe caches for pre-flight queries (shared across handler instances)
|
|
647
|
+
_repo_node_id_cache: dict[tuple[str, str], str] = {}
|
|
648
|
+
_repo_node_id_lock = threading.Lock()
|
|
649
|
+
|
|
608
650
|
def log_message(self, format, *args):
|
|
609
651
|
"""Route HTTP server logs to our logger."""
|
|
610
652
|
logger.info(format, *args)
|
|
@@ -663,6 +705,7 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
|
|
|
663
705
|
owner = info["owner"]
|
|
664
706
|
repo = info["repo"]
|
|
665
707
|
level = info.get("level", LEVEL_GIT_ONLY)
|
|
708
|
+
graphql_read, graphql_write = _resolve_graphql_policies(info)
|
|
666
709
|
|
|
667
710
|
# Rate limit
|
|
668
711
|
if not self.rate_limiter.check(container):
|
|
@@ -689,7 +732,7 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
|
|
|
689
732
|
|
|
690
733
|
# Route: GraphQL (/graphql)
|
|
691
734
|
if path == "/graphql" and method == "POST":
|
|
692
|
-
self._handle_graphql_request(body, container, owner, repo,
|
|
735
|
+
self._handle_graphql_request(body, container, owner, repo, graphql_read, graphql_write)
|
|
693
736
|
return
|
|
694
737
|
|
|
695
738
|
# Route: REST API (/repos/{owner}/{repo}/...)
|
|
@@ -756,33 +799,184 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
|
|
|
756
799
|
follow_redirects=(method in ("GET", "HEAD")),
|
|
757
800
|
)
|
|
758
801
|
|
|
759
|
-
def _handle_graphql_request(self, body, container, owner, repo,
|
|
760
|
-
"""Handle GraphQL requests
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
802
|
+
def _handle_graphql_request(self, body, container, owner, repo, graphql_read, graphql_write):
|
|
803
|
+
"""Handle GraphQL requests with policy-based access control.
|
|
804
|
+
|
|
805
|
+
graphql_read/graphql_write: "whitelisted", "unrestricted", or "none".
|
|
806
|
+
"""
|
|
807
|
+
if graphql_read == "none" and graphql_write == "none":
|
|
808
|
+
self._send_error(403, "GraphQL access not enabled")
|
|
809
|
+
logger.info("BLOCKED POST /graphql container=%s reason=graphql_disabled", container)
|
|
764
810
|
return
|
|
765
811
|
|
|
766
812
|
if not body:
|
|
767
813
|
self._send_error(400, "Missing request body for GraphQL")
|
|
768
814
|
return
|
|
769
815
|
|
|
770
|
-
|
|
816
|
+
# For unrestricted mode, use the simpler classify_graphql path
|
|
817
|
+
if graphql_read == "unrestricted" and graphql_write == "unrestricted":
|
|
818
|
+
op_type, error = classify_graphql(body)
|
|
819
|
+
if error:
|
|
820
|
+
self._send_error(400, f"GraphQL validation failed: {error}")
|
|
821
|
+
logger.info("BLOCKED POST /graphql container=%s reason=%s", container, error)
|
|
822
|
+
return
|
|
823
|
+
# Both unrestricted: allow any query or mutation
|
|
824
|
+
upstream_url = f"{GITHUB_API_URL}/graphql"
|
|
825
|
+
self._forward_to_github(
|
|
826
|
+
"POST", upstream_url, body, container, "/graphql", host=GITHUB_API_HOST
|
|
827
|
+
)
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
if graphql_read == "unrestricted" and graphql_write == "none":
|
|
831
|
+
# Legacy level 3 behavior: unrestricted reads, no writes
|
|
832
|
+
op_type, error = classify_graphql(body)
|
|
833
|
+
if error:
|
|
834
|
+
self._send_error(400, f"GraphQL validation failed: {error}")
|
|
835
|
+
logger.info("BLOCKED POST /graphql container=%s reason=%s", container, error)
|
|
836
|
+
return
|
|
837
|
+
if op_type == "mutation":
|
|
838
|
+
self._send_error(403, "Mutations not allowed at this access level")
|
|
839
|
+
logger.info(
|
|
840
|
+
"BLOCKED POST /graphql container=%s reason=mutation_rejected", container
|
|
841
|
+
)
|
|
842
|
+
return
|
|
843
|
+
upstream_url = f"{GITHUB_API_URL}/graphql"
|
|
844
|
+
self._forward_to_github(
|
|
845
|
+
"POST", upstream_url, body, container, "/graphql", host=GITHUB_API_HOST
|
|
846
|
+
)
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
# Whitelisted mode: full structural + semantic validation
|
|
850
|
+
from .graphql_validator import (
|
|
851
|
+
_count_operations,
|
|
852
|
+
_tokenize,
|
|
853
|
+
parse_graphql,
|
|
854
|
+
validate_read,
|
|
855
|
+
validate_structure,
|
|
856
|
+
validate_write,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
parsed, error = parse_graphql(body)
|
|
771
860
|
if error:
|
|
772
861
|
self._send_error(400, f"GraphQL validation failed: {error}")
|
|
773
862
|
logger.info("BLOCKED POST /graphql container=%s reason=%s", container, error)
|
|
774
863
|
return
|
|
775
864
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
865
|
+
# Count operations for structural validation
|
|
866
|
+
tokens = _tokenize(parsed.query_str)
|
|
867
|
+
op_count = _count_operations(tokens)
|
|
868
|
+
|
|
869
|
+
# Structural validation
|
|
870
|
+
error = validate_structure(parsed, op_count=op_count)
|
|
871
|
+
if error:
|
|
872
|
+
self._send_error(403, f"GraphQL structural validation failed: {error}")
|
|
873
|
+
logger.info("BLOCKED POST /graphql container=%s reason=structural_%s", container, error)
|
|
779
874
|
return
|
|
780
875
|
|
|
876
|
+
if parsed.op_type == "mutation":
|
|
877
|
+
if graphql_write == "none":
|
|
878
|
+
self._send_error(403, "Mutations not allowed")
|
|
879
|
+
logger.info(
|
|
880
|
+
"BLOCKED POST /graphql container=%s reason=mutation_rejected", container
|
|
881
|
+
)
|
|
882
|
+
return
|
|
883
|
+
|
|
884
|
+
if graphql_write == "whitelisted":
|
|
885
|
+
error = validate_write(
|
|
886
|
+
parsed,
|
|
887
|
+
owner,
|
|
888
|
+
repo,
|
|
889
|
+
preflight_fn=self._preflight_check,
|
|
890
|
+
repo_node_id_fn=self._get_repo_node_id,
|
|
891
|
+
)
|
|
892
|
+
if error:
|
|
893
|
+
self._send_error(403, f"GraphQL mutation validation failed: {error}")
|
|
894
|
+
logger.info(
|
|
895
|
+
"BLOCKED POST /graphql container=%s mutation=%s reason=%s",
|
|
896
|
+
container,
|
|
897
|
+
parsed.top_level_fields[0].name if parsed.top_level_fields else "?",
|
|
898
|
+
error,
|
|
899
|
+
)
|
|
900
|
+
return
|
|
901
|
+
# "unrestricted" write: structural validation already passed
|
|
902
|
+
else:
|
|
903
|
+
# Query
|
|
904
|
+
if graphql_read == "none":
|
|
905
|
+
self._send_error(403, "Queries not allowed")
|
|
906
|
+
logger.info("BLOCKED POST /graphql container=%s reason=query_rejected", container)
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
if graphql_read == "whitelisted":
|
|
910
|
+
error = validate_read(
|
|
911
|
+
parsed,
|
|
912
|
+
owner,
|
|
913
|
+
repo,
|
|
914
|
+
preflight_fn=self._preflight_check,
|
|
915
|
+
)
|
|
916
|
+
if error:
|
|
917
|
+
self._send_error(403, f"GraphQL read validation failed: {error}")
|
|
918
|
+
logger.info("BLOCKED POST /graphql container=%s reason=%s", container, error)
|
|
919
|
+
return
|
|
920
|
+
# "unrestricted" read: structural validation already passed
|
|
921
|
+
|
|
781
922
|
upstream_url = f"{GITHUB_API_URL}/graphql"
|
|
782
923
|
self._forward_to_github(
|
|
783
924
|
"POST", upstream_url, body, container, "/graphql", host=GITHUB_API_HOST
|
|
784
925
|
)
|
|
785
926
|
|
|
927
|
+
def _github_graphql_query(self, query: str, variables: dict) -> dict:
|
|
928
|
+
"""Make a GraphQL query to GitHub API. Returns parsed JSON response.
|
|
929
|
+
|
|
930
|
+
Used for pre-flight ownership checks and repo node ID resolution.
|
|
931
|
+
"""
|
|
932
|
+
github_token = self.token_refresher.token
|
|
933
|
+
body = json.dumps({"query": query, "variables": variables}).encode()
|
|
934
|
+
|
|
935
|
+
req = Request(f"{GITHUB_API_URL}/graphql", data=body, method="POST")
|
|
936
|
+
req.add_header("Authorization", f"token {github_token}")
|
|
937
|
+
req.add_header("Content-Type", "application/json")
|
|
938
|
+
req.add_header("Host", GITHUB_API_HOST)
|
|
939
|
+
|
|
940
|
+
ctx = ssl.create_default_context()
|
|
941
|
+
opener = build_opener(ProxyHandler({}), HTTPSHandler(context=ctx))
|
|
942
|
+
resp = opener.open(req, timeout=30)
|
|
943
|
+
return json.loads(resp.read())
|
|
944
|
+
|
|
945
|
+
def _get_repo_node_id(self, owner: str, repo: str) -> str | None:
|
|
946
|
+
"""Get the GitHub node ID for a repository. Cached."""
|
|
947
|
+
key = (owner.lower(), repo.lower())
|
|
948
|
+
with self._repo_node_id_lock:
|
|
949
|
+
cached = self._repo_node_id_cache.get(key)
|
|
950
|
+
if cached is not None:
|
|
951
|
+
return cached
|
|
952
|
+
|
|
953
|
+
from .graphql_validator import REPO_ID_QUERY
|
|
954
|
+
|
|
955
|
+
try:
|
|
956
|
+
data = self._github_graphql_query(REPO_ID_QUERY, {"owner": owner, "name": repo})
|
|
957
|
+
node_id = data.get("data", {}).get("repository", {}).get("id")
|
|
958
|
+
if node_id:
|
|
959
|
+
with self._repo_node_id_lock:
|
|
960
|
+
self._repo_node_id_cache[key] = node_id
|
|
961
|
+
return node_id
|
|
962
|
+
except Exception:
|
|
963
|
+
logger.info("PREFLIGHT repo_node_id failed for %s/%s", owner, repo)
|
|
964
|
+
return None
|
|
965
|
+
|
|
966
|
+
def _preflight_check(self, node_id: str) -> str | None:
|
|
967
|
+
"""Check which repo a node belongs to.
|
|
968
|
+
|
|
969
|
+
Returns "owner/repo" string or None on failure.
|
|
970
|
+
"""
|
|
971
|
+
from .graphql_validator import PREFLIGHT_QUERY, extract_repo_from_preflight
|
|
972
|
+
|
|
973
|
+
try:
|
|
974
|
+
data = self._github_graphql_query(PREFLIGHT_QUERY, {"id": node_id})
|
|
975
|
+
return extract_repo_from_preflight(data)
|
|
976
|
+
except Exception:
|
|
977
|
+
logger.info("PREFLIGHT check failed for node %s", node_id)
|
|
978
|
+
return None
|
|
979
|
+
|
|
786
980
|
def _forward_to_github(
|
|
787
981
|
self,
|
|
788
982
|
method,
|
|
@@ -107,20 +107,52 @@ def _ensure_auth_proxy_running() -> int | None:
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
def _resolve_access_level(config: dict, gh_enabled: bool) -> int:
|
|
110
|
-
"""Determine the auth proxy access level for a container.
|
|
110
|
+
"""Determine the auth proxy REST access level for a container.
|
|
111
111
|
|
|
112
|
-
Returns the access level (1
|
|
112
|
+
Returns the REST access level (1 or 4) based on config and tool
|
|
113
|
+
availability. GraphQL policies are resolved separately by
|
|
114
|
+
_resolve_graphql_config().
|
|
113
115
|
"""
|
|
114
|
-
from .auth_proxy import
|
|
115
|
-
from .security import
|
|
116
|
+
from .auth_proxy import LEVEL_GH_READWRITE, LEVEL_GIT_ONLY
|
|
117
|
+
from .security import is_enabled
|
|
116
118
|
|
|
117
119
|
if not gh_enabled or not is_enabled(config, "github_api"):
|
|
118
120
|
return LEVEL_GIT_ONLY
|
|
119
121
|
|
|
122
|
+
# REST is already repo-scoped by path validation, so read-write is
|
|
123
|
+
# safe by default. This enables REST POST operations like
|
|
124
|
+
# gh run rerun (/repos/{owner}/{repo}/actions/runs/{id}/rerun).
|
|
125
|
+
return LEVEL_GH_READWRITE
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_graphql_config(config: dict, gh_enabled: bool) -> tuple[str, str]:
|
|
129
|
+
"""Determine GraphQL policies for a container.
|
|
130
|
+
|
|
131
|
+
Returns (graphql_read, graphql_write).
|
|
132
|
+
"""
|
|
133
|
+
from .security import get_setting, is_enabled
|
|
134
|
+
|
|
135
|
+
if not gh_enabled or not is_enabled(config, "github_api"):
|
|
136
|
+
return "none", "none"
|
|
137
|
+
|
|
120
138
|
if get_setting(config, "github_api") == "read-write":
|
|
121
|
-
return
|
|
139
|
+
return "unrestricted", "unrestricted"
|
|
140
|
+
|
|
141
|
+
# Default: whitelisted for both — repo-scoped reads, allowlisted mutations
|
|
142
|
+
return "whitelisted", "whitelisted"
|
|
122
143
|
|
|
123
|
-
|
|
144
|
+
|
|
145
|
+
def _describe_graphql_mode(graphql_read: str, graphql_write: str) -> str:
|
|
146
|
+
"""Human-readable description of GraphQL access mode."""
|
|
147
|
+
if graphql_read == "whitelisted" and graphql_write == "whitelisted":
|
|
148
|
+
return "repo-scoped (allowlisted GraphQL)"
|
|
149
|
+
if graphql_read == "unrestricted" and graphql_write == "unrestricted":
|
|
150
|
+
return "unrestricted GraphQL read-write"
|
|
151
|
+
if graphql_read == "unrestricted" and graphql_write == "none":
|
|
152
|
+
return "unrestricted GraphQL read-only"
|
|
153
|
+
if graphql_read == "none" and graphql_write == "none":
|
|
154
|
+
return "git only"
|
|
155
|
+
return f"GraphQL read={graphql_read}, write={graphql_write}"
|
|
124
156
|
|
|
125
157
|
|
|
126
158
|
def _wait_for_proxy_device(runtime: ContainerRuntime, container: str, port: int):
|
|
@@ -168,6 +200,7 @@ def setup_auth_proxy(
|
|
|
168
200
|
from .auth_proxy import generate_auth_token
|
|
169
201
|
|
|
170
202
|
level = _resolve_access_level(config or {}, gh_enabled)
|
|
203
|
+
graphql_read, graphql_write = _resolve_graphql_config(config or {}, gh_enabled)
|
|
171
204
|
|
|
172
205
|
port = _ensure_auth_proxy_running()
|
|
173
206
|
if not port:
|
|
@@ -177,7 +210,14 @@ def setup_auth_proxy(
|
|
|
177
210
|
return False
|
|
178
211
|
|
|
179
212
|
# Generate per-container token with appropriate access level
|
|
180
|
-
token = generate_auth_token(
|
|
213
|
+
token = generate_auth_token(
|
|
214
|
+
container,
|
|
215
|
+
owner,
|
|
216
|
+
repo,
|
|
217
|
+
level=level,
|
|
218
|
+
graphql_read=graphql_read,
|
|
219
|
+
graphql_write=graphql_write,
|
|
220
|
+
)
|
|
181
221
|
|
|
182
222
|
# Add Incus proxy device: expose host TCP port into container
|
|
183
223
|
# On macOS (Colima), need to use the host IP from the VM's perspective
|
|
@@ -234,11 +274,8 @@ def setup_auth_proxy(
|
|
|
234
274
|
_setup_gh_proxy(runtime, container, token, connect_addr, machine_readable)
|
|
235
275
|
|
|
236
276
|
if not machine_readable:
|
|
237
|
-
|
|
238
|
-
detail(
|
|
239
|
-
f"GitHub auth proxy configured"
|
|
240
|
-
f" (scoped to {owner}/{repo}, level {level}: {level_desc.get(level, '?')})."
|
|
241
|
-
)
|
|
277
|
+
mode_desc = _describe_graphql_mode(graphql_read, graphql_write)
|
|
278
|
+
detail(f"GitHub auth proxy configured (scoped to {owner}/{repo}, {mode_desc}).")
|
|
242
279
|
return True
|
|
243
280
|
|
|
244
281
|
|
|
@@ -316,6 +353,7 @@ def setup_auth_proxy_remote(
|
|
|
316
353
|
from .tunnel import start_tunnel
|
|
317
354
|
|
|
318
355
|
level = _resolve_access_level(config or {}, gh_enabled)
|
|
356
|
+
graphql_read, graphql_write = _resolve_graphql_config(config or {}, gh_enabled)
|
|
319
357
|
|
|
320
358
|
port = _ensure_auth_proxy_running()
|
|
321
359
|
if not port:
|
|
@@ -331,7 +369,14 @@ def setup_auth_proxy_remote(
|
|
|
331
369
|
return False
|
|
332
370
|
|
|
333
371
|
# Generate per-container token with appropriate access level
|
|
334
|
-
token = generate_auth_token(
|
|
372
|
+
token = generate_auth_token(
|
|
373
|
+
container,
|
|
374
|
+
owner,
|
|
375
|
+
repo,
|
|
376
|
+
level=level,
|
|
377
|
+
graphql_read=graphql_read,
|
|
378
|
+
graphql_write=graphql_write,
|
|
379
|
+
)
|
|
335
380
|
|
|
336
381
|
# Add Incus proxy device on the remote: tunneled port → container
|
|
337
382
|
from .tunnel import TUNNEL_REMOTE_PORT
|
|
@@ -398,11 +443,9 @@ def setup_auth_proxy_remote(
|
|
|
398
443
|
_setup_gh_proxy_remote(remote_host, container, token, connect_addr, machine_readable)
|
|
399
444
|
|
|
400
445
|
if not machine_readable:
|
|
401
|
-
|
|
446
|
+
mode_desc = _describe_graphql_mode(graphql_read, graphql_write)
|
|
402
447
|
detail(
|
|
403
|
-
f"GitHub auth proxy configured"
|
|
404
|
-
f" (scoped to {owner}/{repo}, level {level}: {level_desc.get(level, '?')}"
|
|
405
|
-
f", via SSH tunnel)."
|
|
448
|
+
f"GitHub auth proxy configured (scoped to {owner}/{repo}, {mode_desc}, via SSH tunnel)."
|
|
406
449
|
)
|
|
407
450
|
return True
|
|
408
451
|
|