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.
Files changed (131) hide show
  1. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.claude/CLAUDE.md +5 -2
  2. {dev_bubble-0.7.3/dev_bubble.egg-info → dev_bubble-0.7.6}/PKG-INFO +1 -1
  3. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/__init__.py +1 -1
  4. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/auth_proxy.py +208 -14
  5. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/github_token.py +60 -17
  6. dev_bubble-0.7.6/bubble/graphql_validator.py +917 -0
  7. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/gh.sh +10 -0
  8. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/vscode.sh +44 -10
  9. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/security.py +1 -1
  10. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/tools.py +6 -1
  11. {dev_bubble-0.7.3 → dev_bubble-0.7.6/dev_bubble.egg-info}/PKG-INFO +1 -1
  12. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/SOURCES.txt +2 -0
  13. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_auth_proxy.py +9 -1
  14. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_github_token.py +9 -2
  15. dev_bubble-0.7.6/tests/test_graphql_validator.py +845 -0
  16. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_security.py +44 -6
  17. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.github/workflows/ci.yml +0 -0
  18. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.github/workflows/publish.yml +0 -0
  19. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/.gitignore +0 -0
  20. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/CHANGELOG.md +0 -0
  21. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/LICENSE +0 -0
  22. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/README.md +0 -0
  23. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/SPEC.md +0 -0
  24. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/__main__.py +0 -0
  25. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/automation.py +0 -0
  26. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/claude.py +0 -0
  27. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/clean.py +0 -0
  28. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/cli.py +0 -0
  29. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/clone.py +0 -0
  30. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/cloud.py +0 -0
  31. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/cloud_types.py +0 -0
  32. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/__init__.py +0 -0
  33. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/cloud_cmd.py +0 -0
  34. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/completion.py +0 -0
  35. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/doctor.py +0 -0
  36. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/images.py +0 -0
  37. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/infrastructure.py +0 -0
  38. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/lifecycle.py +0 -0
  39. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/list_cmd.py +0 -0
  40. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/relay_cmd.py +0 -0
  41. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/remote_cmd.py +0 -0
  42. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/security_cmd.py +0 -0
  43. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/settings.py +0 -0
  44. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/commands/status_cmd.py +0 -0
  45. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/config.py +0 -0
  46. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/container_helpers.py +0 -0
  47. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/data/skill.md +0 -0
  48. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/default_repos.json +0 -0
  49. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/finalization.py +0 -0
  50. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/git_store.py +0 -0
  51. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/hooks/__init__.py +0 -0
  52. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/hooks/lean.py +0 -0
  53. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/hooks/python.py +0 -0
  54. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/image_management.py +0 -0
  55. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/__init__.py +0 -0
  56. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/builder.py +0 -0
  57. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/base.sh +0 -0
  58. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/cloud-init.sh +0 -0
  59. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/lean-toolchain.sh +0 -0
  60. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/lean.sh +0 -0
  61. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/python.sh +0 -0
  62. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/claude.sh +0 -0
  63. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/codex.sh +0 -0
  64. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/elan.sh +0 -0
  65. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/emacs.sh +0 -0
  66. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/neovim.sh +0 -0
  67. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/images/scripts/tools/pins.json +0 -0
  68. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/lean.py +0 -0
  69. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/lifecycle.py +0 -0
  70. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/naming.py +0 -0
  71. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/native.py +0 -0
  72. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/network.py +0 -0
  73. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/notices.py +0 -0
  74. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/output.py +0 -0
  75. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/provisioning.py +0 -0
  76. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/relay.py +0 -0
  77. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/remote.py +0 -0
  78. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/repo_registry.py +0 -0
  79. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/__init__.py +0 -0
  80. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/base.py +0 -0
  81. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/colima.py +0 -0
  82. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/runtime/incus.py +0 -0
  83. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/setup.py +0 -0
  84. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/skill.py +0 -0
  85. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/spinner.py +0 -0
  86. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/target.py +0 -0
  87. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/token_store.py +0 -0
  88. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/tunnel.py +0 -0
  89. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/bubble/vscode.py +0 -0
  90. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/config/com.bubble.git-update.plist +0 -0
  91. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/config/com.bubble.image-refresh.plist +0 -0
  92. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/config/com.bubble.relay-daemon.plist +0 -0
  93. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/conftest.py +0 -0
  94. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/dependency_links.txt +0 -0
  95. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/entry_points.txt +0 -0
  96. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/requires.txt +0 -0
  97. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/dev_bubble.egg-info/top_level.txt +0 -0
  98. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/pyproject.toml +0 -0
  99. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/setup.cfg +0 -0
  100. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/conftest.py +0 -0
  101. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_branch_no_target.py +0 -0
  102. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_build_lock.py +0 -0
  103. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_claude.py +0 -0
  104. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_claude_projects_symlink.py +0 -0
  105. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_cloud.py +0 -0
  106. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_completion.py +0 -0
  107. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_config.py +0 -0
  108. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_customize.py +0 -0
  109. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_editor.py +0 -0
  110. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_git_store.py +0 -0
  111. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_hooks.py +0 -0
  112. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_integration.py +0 -0
  113. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_lifecycle.py +0 -0
  114. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_list_columns.py +0 -0
  115. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_list_remote.py +0 -0
  116. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_mounts.py +0 -0
  117. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_multi_target.py +0 -0
  118. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_naming.py +0 -0
  119. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_native.py +0 -0
  120. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_network.py +0 -0
  121. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_notices.py +0 -0
  122. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_relay.py +0 -0
  123. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_remote.py +0 -0
  124. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_repo_registry.py +0 -0
  125. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_skill.py +0 -0
  126. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_spinner.py +0 -0
  127. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_status.py +0 -0
  128. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_target.py +0 -0
  129. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_tools.py +0 -0
  130. {dev_bubble-0.7.3 → dev_bubble-0.7.6}/tests/test_tunnel.py +0 -0
  131. {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
- **API security:** REST paths validated against `/repos/{owner}/{repo}/...` (repo-scoped). GraphQL scans ALL operations in a document and classifies by the most dangerous one — `query` allowed at level 3, `mutation` requires level 4. This prevents `operationName`-based bypasses where a query is listed first but a mutation is selected for execution. Batched requests, subscriptions, and malformed bodies rejected. Note: GraphQL is NOT repo-scoped queries can access any data the host token can read (GitHub's GraphQL API doesn't support path-based scoping). 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
+ **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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.3
3
+ Version: 0.7.6
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.7.3"
3
+ __version__ = "0.7.6"
@@ -141,16 +141,29 @@ logger = logging.getLogger("bubble.auth_proxy")
141
141
 
142
142
 
143
143
  def generate_auth_token(
144
- container_name: str, owner: str, repo: str, level: int = DEFAULT_LEVEL
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) — the proxy
149
- uses this to validate requests and enforce the access level.
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
- {"container": container_name, "owner": owner, "repo": repo, "level": level}
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, level)
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, level):
760
- """Handle GraphQL requests (level 3+)."""
761
- if level < LEVEL_GH_READ:
762
- self._send_error(403, "GraphQL access not enabled at this access level")
763
- logger.info("BLOCKED POST /graphql container=%s reason=level_%d", container, level)
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
- op_type, error = classify_graphql(body)
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
- if op_type == "mutation" and level < LEVEL_GH_READWRITE:
777
- self._send_error(403, "Mutations not allowed at this access level")
778
- logger.info("BLOCKED POST /graphql container=%s reason=mutation_rejected", container)
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-4) based on config and tool availability.
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 DEFAULT_LEVEL, LEVEL_GH_READWRITE, LEVEL_GIT_ONLY
115
- from .security import get_setting, is_enabled
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 LEVEL_GH_READWRITE
139
+ return "unrestricted", "unrestricted"
140
+
141
+ # Default: whitelisted for both — repo-scoped reads, allowlisted mutations
142
+ return "whitelisted", "whitelisted"
122
143
 
123
- return DEFAULT_LEVEL # LEVEL_GH_READ (3)
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(container, owner, repo, level=level)
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
- level_desc = {1: "git only", 2: "REST read-only", 3: "gh read-only", 4: "gh read-write"}
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(container, owner, repo, level=level)
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
- level_desc = {1: "git only", 2: "REST read-only", 3: "gh read-only", 4: "gh read-write"}
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