dev-bubble 0.7.9__tar.gz → 0.7.11__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.9 → dev_bubble-0.7.11}/.claude/CLAUDE.md +3 -3
  2. {dev_bubble-0.7.9/dev_bubble.egg-info → dev_bubble-0.7.11}/PKG-INFO +1 -1
  3. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/SPEC.md +98 -45
  4. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/__init__.py +1 -1
  5. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/ai.py +1 -0
  6. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/auth_proxy.py +27 -71
  7. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/finalization.py +14 -0
  8. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/github_token.py +46 -27
  9. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/gh.sh +1 -0
  10. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/vscode.sh +15 -0
  11. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/provisioning.py +5 -4
  12. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/security.py +1 -93
  13. {dev_bubble-0.7.9 → dev_bubble-0.7.11/dev_bubble.egg-info}/PKG-INFO +1 -1
  14. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_auth_proxy.py +63 -86
  15. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_github_token.py +5 -16
  16. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_graphql_validator.py +0 -38
  17. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_security.py +22 -112
  18. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/.github/workflows/ci.yml +0 -0
  19. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/.github/workflows/publish.yml +0 -0
  20. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/.gitignore +0 -0
  21. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/CHANGELOG.md +0 -0
  22. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/LICENSE +0 -0
  23. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/README.md +0 -0
  24. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/__main__.py +0 -0
  25. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/automation.py +0 -0
  26. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/clean.py +0 -0
  27. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/cli.py +0 -0
  28. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/clone.py +0 -0
  29. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/cloud.py +0 -0
  30. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/cloud_types.py +0 -0
  31. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/__init__.py +0 -0
  32. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/cloud_cmd.py +0 -0
  33. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/completion.py +0 -0
  34. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/doctor.py +0 -0
  35. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/images.py +0 -0
  36. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/infrastructure.py +0 -0
  37. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/lifecycle.py +0 -0
  38. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/list_cmd.py +0 -0
  39. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/relay_cmd.py +0 -0
  40. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/remote_cmd.py +0 -0
  41. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/security_cmd.py +0 -0
  42. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/settings.py +0 -0
  43. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/commands/status_cmd.py +0 -0
  44. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/config.py +0 -0
  45. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/container_helpers.py +0 -0
  46. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/data/skill.md +0 -0
  47. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/default_repos.json +0 -0
  48. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/git_store.py +0 -0
  49. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/graphql_validator.py +0 -0
  50. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/hooks/__init__.py +0 -0
  51. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/hooks/lean.py +0 -0
  52. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/hooks/python.py +0 -0
  53. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/image_management.py +0 -0
  54. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/__init__.py +0 -0
  55. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/builder.py +0 -0
  56. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/base.sh +0 -0
  57. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/cloud-init.sh +0 -0
  58. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/lean-toolchain.sh +0 -0
  59. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/lean.sh +0 -0
  60. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/python.sh +0 -0
  61. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/claude.sh +0 -0
  62. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/codex.sh +0 -0
  63. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/elan.sh +0 -0
  64. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/emacs.sh +0 -0
  65. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/neovim.sh +0 -0
  66. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/images/scripts/tools/pins.json +0 -0
  67. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/lean.py +0 -0
  68. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/lifecycle.py +0 -0
  69. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/naming.py +0 -0
  70. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/native.py +0 -0
  71. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/network.py +0 -0
  72. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/notices.py +0 -0
  73. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/output.py +0 -0
  74. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/relay.py +0 -0
  75. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/remote.py +0 -0
  76. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/repo_registry.py +0 -0
  77. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/runtime/__init__.py +0 -0
  78. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/runtime/base.py +0 -0
  79. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/runtime/colima.py +0 -0
  80. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/runtime/incus.py +0 -0
  81. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/setup.py +0 -0
  82. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/skill.py +0 -0
  83. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/spinner.py +0 -0
  84. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/target.py +0 -0
  85. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/token_store.py +0 -0
  86. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/tools.py +0 -0
  87. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/tunnel.py +0 -0
  88. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/bubble/vscode.py +0 -0
  89. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/config/com.bubble.git-update.plist +0 -0
  90. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/config/com.bubble.image-refresh.plist +0 -0
  91. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/config/com.bubble.relay-daemon.plist +0 -0
  92. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/conftest.py +0 -0
  93. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/dev_bubble.egg-info/SOURCES.txt +0 -0
  94. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/dev_bubble.egg-info/dependency_links.txt +0 -0
  95. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/dev_bubble.egg-info/entry_points.txt +0 -0
  96. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/dev_bubble.egg-info/requires.txt +0 -0
  97. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/dev_bubble.egg-info/top_level.txt +0 -0
  98. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/pyproject.toml +0 -0
  99. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/setup.cfg +0 -0
  100. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/conftest.py +0 -0
  101. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_ai.py +0 -0
  102. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_branch_no_target.py +0 -0
  103. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_build_lock.py +0 -0
  104. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_claude_projects_symlink.py +0 -0
  105. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_cloud.py +0 -0
  106. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_completion.py +0 -0
  107. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_config.py +0 -0
  108. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_customize.py +0 -0
  109. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_editor.py +0 -0
  110. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_git_store.py +0 -0
  111. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_hooks.py +0 -0
  112. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_integration.py +0 -0
  113. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_lifecycle.py +0 -0
  114. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_list_columns.py +0 -0
  115. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_list_remote.py +0 -0
  116. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_mounts.py +0 -0
  117. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_multi_target.py +0 -0
  118. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_naming.py +0 -0
  119. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_native.py +0 -0
  120. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_network.py +0 -0
  121. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_notices.py +0 -0
  122. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_relay.py +0 -0
  123. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_remote.py +0 -0
  124. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_repo_registry.py +0 -0
  125. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_skill.py +0 -0
  126. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_spinner.py +0 -0
  127. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_status.py +0 -0
  128. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_target.py +0 -0
  129. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_tools.py +0 -0
  130. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_tunnel.py +0 -0
  131. {dev_bubble-0.7.9 → dev_bubble-0.7.11}/tests/test_vscode.py +0 -0
@@ -139,15 +139,15 @@ The auth proxy (`auth_proxy.py`) provides repo-scoped GitHub authentication with
139
139
 
140
140
  **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`.
141
141
 
142
- **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.
142
+ **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.
143
143
 
144
- **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).
144
+ **REST security:** REST paths validated against `/repos/{owner}/{repo}/...` (repo-scoped). All HTTP methods are allowed when REST access is enabled, since path validation already constrains access to the scoped repo. 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).
145
145
 
146
146
  **Local bubbles:** Exposed via Incus proxy devices — TCP for git, Unix socket for gh (`listen=unix:/bubble/gh-proxy.sock`).
147
147
 
148
148
  **Remote/cloud bubbles:** SSH reverse tunnel forwards the local proxy port. Incus proxy devices on the remote expose both TCP and Unix socket endpoints.
149
149
 
150
- **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.
150
+ **Token management:** Per-container tokens in `~/.bubble/auth-tokens.json` map to `{container, owner, repo, rest_api, graphql_read, graphql_write}`. Tokens are cleaned up on `bubble pop`. The daemon is managed via launchd/systemd.
151
151
 
152
152
  ### Security Model
153
153
  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.9
3
+ Version: 0.7.11
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
@@ -339,9 +339,16 @@ of the versioned image for next time.
339
339
  > compromised container could write poisoned `.olean` files that subsequent
340
340
  > containers would pick up via `lake exe cache get`. The cache is *not* shared
341
341
  > with `lake exe cache` run on the host — only bubble containers are affected.
342
- > Set `shared-cache` to `off` (via `bubble security set shared-cache off`) to
343
- > mount the cache read-only and prevent future poisoning. If you suspect the
344
- > cache has already been compromised, delete `~/.bubble/mathlib-cache/`.
342
+ > Three modes are available via `bubble security set shared-cache`:
343
+ > - `on` (default): shared read-write fastest, but vulnerable to cache
344
+ > poisoning across containers
345
+ > - `overlay`: shared cache mounted read-only with a per-container writable
346
+ > overlayfs layer — fast reads from the shared cache with isolated writes
347
+ > that don't affect other containers (recommended for security-conscious use)
348
+ > - `off`: shared cache mounted read-only — prevents future poisoning
349
+ >
350
+ > If you suspect the cache has already been compromised, delete
351
+ > `~/.bubble/mathlib-cache/`.
345
352
 
346
353
  **Post-clone behavior:**
347
354
  - Pre-populate Lake dependencies from `lake-manifest.json` (see 2.4)
@@ -670,6 +677,7 @@ Each tool has:
670
677
  | `elan` | 10 | `elan` | — |
671
678
  | `claude` | 50 | `claude` | `api.anthropic.com` |
672
679
  | `codex` | 50 | `codex` | `api.openai.com` |
680
+ | `gh` | 50 | `gh` | — |
673
681
  | `vscode` | 90 | `code` | VS Code marketplace domains |
674
682
  | `emacs` | 90 | `emacs` | — |
675
683
  | `neovim` | 90 | `nvim` | — |
@@ -701,6 +709,10 @@ versions from a `pins.json` file.
701
709
 
702
710
  **`bubble tools set TOOL yes|no|auto`** — configure a tool.
703
711
 
712
+ **VS Code extension auto-install:** When both `claude` and `vscode` tools are
713
+ enabled, the Claude Code VS Code extension (`anthropic.claude-code`) is
714
+ pre-installed in the base image.
715
+
704
716
  ### 6.4 Drift detection
705
717
 
706
718
  A content-aware hash of the enabled tool set (tool names + script contents +
@@ -922,6 +934,12 @@ Credential files (`.credentials.json`) are mounted by default. Disable with
922
934
  **Symlink safety:** Reject symlinks that escape `~/.claude/` to prevent
923
935
  exposing arbitrary host files.
924
936
 
937
+ **Settings pre-population:** During container finalization, `~/.claude.json` is
938
+ written in all containers (not just those with AI task injection). Allowlisted
939
+ settings (theme, onboarding state) are copied from the host, project trust is
940
+ pre-configured for the container's project directory, and
941
+ `hasCompletedOnboarding` is set to skip the first-run wizard.
942
+
925
943
  ### 10.2 Codex config mounting
926
944
 
927
945
  Similar to Claude. Config: `config.toml` (read-only). Credentials: `auth.json`
@@ -941,27 +959,33 @@ Similar to Claude. Config: `config.toml` (read-only). Credentials: `auth.json`
941
959
  ### 10.4 GitHub auth proxy
942
960
 
943
961
  An HTTP reverse proxy on the host provides GitHub authentication without
944
- exposing the host's token. Git and REST API requests are repo-scoped;
945
- GraphQL requests are operation-validated (queries vs mutations) but not
946
- repo-scoped see access levels below.
962
+ exposing the host's token. The access level is controlled by the unified
963
+ `github` security setting, which picks one level from a graduated escalation
964
+ ladder (see Security section).
947
965
 
948
966
  **Port:** 7654 (default, configurable).
949
967
 
950
- **Flow:**
951
- 1. Container git is configured with `url.insteadOf` to route HTTPS through the proxy
952
- 2. Container sends request with `X-Bubble-Token` header
953
- 3. Proxy validates token against `~/.bubble/auth-tokens.json` (mode 0600)
954
- 4. For git/REST: proxy checks path matches the allowed `owner/repo`. For GraphQL: proxy validates operation type (queries allowed at level 3, mutations require level 4) but does not scope to a specific repo
955
- 5. Proxy adds `Authorization: token <real-token>` header
956
- 6. Proxy forwards to `https://github.com`
957
- 7. Response returned to container
968
+ **Git flow:** Container git → `url.insteadOf` rewrites to
969
+ `http://127.0.0.1:7654/git/...` proxy validates `X-Bubble-Token` header
970
+ checks path matches allowed `owner/repo` → adds `Authorization` header
971
+ forwards to `https://github.com` returns response.
972
+
973
+ **gh CLI flow:** `gh` configured with `http_unix_socket: /bubble/gh-proxy.sock`
974
+ (via `GH_CONFIG_DIR=/etc/bubble/gh`) sends requests through Unix socket →
975
+ proxy validates token enforces access level (REST repo-scoping, GraphQL
976
+ allowlist validation) → adds real token → forwards to `https://api.github.com`.
958
977
 
959
978
  **Token format:** `~/.bubble/auth-tokens.json`
960
979
  ```json
961
980
  {
962
- "hex_token": {"container": "name", "owner": "owner", "repo": "repo"}
981
+ "hex_token": {
982
+ "container": "name",
983
+ "owner": "owner",
984
+ "repo": "repo",
985
+ "graphql_read": "whitelisted",
986
+ "graphql_write": "whitelisted"
987
+ }
963
988
  }
964
- ```
965
989
 
966
990
  **Allowed paths (git smart HTTP only):**
967
991
  - `GET /git/{owner}/{repo}[.git]/info/refs?service=git-upload-pack`
@@ -969,33 +993,53 @@ repo-scoped — see access levels below.
969
993
  - `POST /git/{owner}/{repo}[.git]/git-upload-pack`
970
994
  - `POST /git/{owner}/{repo}[.git]/git-receive-pack`
971
995
 
972
- **Access levels (per-container):**
973
- | Level | Description | Scope |
974
- |-------|-------------|-------|
975
- | 1 | Git smart HTTP only (push/pull) | Repo-scoped |
976
- | 2 | Git + REST API read-only | Repo-scoped (REST paths validated against `/repos/{owner}/{repo}/...`) |
977
- | 3 (default) | Git + gh read-only (REST read + GraphQL queries) | Git and REST are repo-scoped; **GraphQL is account-wide** — queries can read any data the host token can access |
978
- | 4 | Git + gh read-write (REST + GraphQL + mutations) | Git and REST are repo-scoped; **GraphQL queries and mutations are account-wide** |
979
-
980
- > **Note:** GitHub's GraphQL API does not support path-based scoping.
981
- > At the default level 3, a container can query any repository, org membership,
982
- > or user data readable by the host token. To restrict containers to git-only
983
- > access, use `bubble security set github-api off`.
996
+ **Access levels** (set via `bubble security set github <level>`):
997
+
998
+ | `github` level | Behavior |
999
+ |----------------|----------|
1000
+ | `off` | No GitHub access at all |
1001
+ | `basic` | Git push/pull only (proxy rewrites, repo-scoped) |
1002
+ | `rest` | + repo-scoped REST API |
1003
+ | `allowlist-read-graphql` | + allowlisted read-only GraphQL queries |
1004
+ | `allowlist-write-graphql` (default) | + allowlisted GraphQL mutations |
1005
+ | `write-graphql` | + arbitrary GraphQL, no allowlist filtering |
1006
+ | `direct` | Inject the raw token into the container, no proxy |
1007
+
1008
+ **`direct` mode:** The host's `GH_TOKEN` and `GITHUB_TOKEN` environment
1009
+ variables are injected into the container via `/etc/profile.d`. No proxy is
1010
+ used — git and `gh` connect directly to GitHub. Network allowlisting permits
1011
+ direct GitHub access (iptables allows `github.com`).
1012
+
1013
+ **GraphQL validation** (`allowlist-read-graphql` and `allowlist-write-graphql`
1014
+ levels): GraphQL access is controlled by two independent axes — `graphql_read`
1015
+ and `graphql_write` — each supporting `whitelisted`, `unrestricted`, or `none`
1016
+ modes. In whitelisted mode, a lightweight tokenizer/parser validates structure
1017
+ (single operation, single top-level field, no aliases/directives, no fragments
1018
+ in mutations) and semantics. Read validation repo-scopes `repository` queries
1019
+ via variables and verifies `node` queries via pre-flight ownership checks.
1020
+ Write validation checks mutations against an allowlist with repo-scoping via
1021
+ repositoryId comparison or pre-flight node ownership verification.
1022
+
1023
+ **REST security:** REST paths validated against `/repos/{owner}/{repo}/...`
1024
+ (repo-scoped). API redirects (e.g., CI log downloads) followed with hardened
1025
+ rules: GET/HEAD only, HTTPS only, allowlisted hosts
1026
+ (`*.blob.core.windows.net`, `*.githubusercontent.com`), max 2 hops, auth
1027
+ headers stripped. GitHub 4xx errors are passed through to clients.
984
1028
 
985
1029
  **Security:**
986
1030
  - Path canonicalization: reject encoded separators, dot-segments, duplicate slashes
987
- - No redirect following (returns redirects as-is to prevent token leakage)
988
1031
  - Pinned to `github.com:443` with TLS verification
989
1032
  - Ignores `HTTPS_PROXY`/`ALL_PROXY` environment variables
990
1033
  - Rate limited: 60/minute, 600/hour per container
991
1034
  - Maximum request body: 256 MB
992
1035
 
993
- **Local containers:** Exposed via Incus proxy device connecting host TCP port to
994
- container `127.0.0.1:7654`.
1036
+ **Local containers:** Exposed via Incus proxy devices TCP for git, Unix
1037
+ socket for gh (`listen=unix:/bubble/gh-proxy.sock`).
995
1038
 
996
- **Remote containers:** SSH reverse tunnel (`ssh -R 7654:127.0.0.1:7654 remote`)
997
- forwards the local proxy port to the remote host. Tunnel PID files in
998
- `~/.bubble/tunnels/`. One tunnel per remote host (shared across containers).
1039
+ **Remote containers:** SSH reverse tunnel forwards the local proxy port to the
1040
+ remote host. Incus proxy devices on the remote expose both TCP and Unix socket
1041
+ endpoints. Tunnel PID files in `~/.bubble/tunnels/`. One tunnel per remote
1042
+ host (shared across containers).
999
1043
 
1000
1044
  **Daemon management:** launchd (`com.bubble.auth-proxy`) or systemd
1001
1045
  (`bubble-auth-proxy.service`).
@@ -1067,6 +1111,12 @@ location = "fsn1"
1067
1111
  server_name = "bubble-cloud"
1068
1112
  default = false
1069
1113
 
1114
+ [ai]
1115
+ preferred = "claude"
1116
+ autonomy = "plan"
1117
+ second_opinion = "auto"
1118
+ second_opinion_provider = "codex"
1119
+
1070
1120
  [claude]
1071
1121
  credentials = true
1072
1122
 
@@ -1098,19 +1148,18 @@ get/set interface:
1098
1148
 
1099
1149
  ### Security settings
1100
1150
 
1101
- Every isolation-weakening feature is individually configurable with three
1102
- values: `auto`, `on`, `off`.
1151
+ Most settings are individually configurable with three values: `auto`, `on`,
1152
+ `off`. The `github` setting uses graduated named levels (see auth proxy
1153
+ section) and `shared-cache` accepts an additional `overlay` value.
1103
1154
 
1104
1155
  | Setting | Auto default | Description |
1105
1156
  |---------|-------------|-------------|
1106
- | `shared-cache` | on | Writable shared mounts (mathlib cache) — see [Lean 4 hook](#22-lean-4-hook) security note |
1157
+ | `shared-cache` | on | Writable shared mounts (mathlib cache) — `on`, `off`, or `overlay`. See [Lean 4 hook](#22-lean-4-hook) security note |
1107
1158
  | `user-mounts` | on | `--mount` flag support |
1108
1159
  | `git-manifest-trust` | on | Auto-clone Lake manifest dependencies |
1109
1160
  | `claude-credentials` | on | Mount Claude credentials into containers |
1110
1161
  | `codex-credentials` | on | Mount Codex credentials into containers |
1111
- | `github-auth` | on | Repo-scoped GitHub auth via proxy (git push/pull) |
1112
- | `github-api` | on | GitHub API access via auth proxy: REST is repo-scoped; **GraphQL queries are read-only but account-wide** (can read any repo the host token can access). Set to `off` for git-only, or `read-write` for mutations |
1113
- | `github-token-inject` | off | Direct GitHub token injection (bypasses proxy) |
1162
+ | `github` | allowlist-write-graphql | Unified GitHub access level graduated from `off` through `basic`, `rest`, `allowlist-read-graphql`, `allowlist-write-graphql`, `write-graphql`, to `direct` (see [auth proxy](#104-github-auth-proxy)) |
1114
1163
  | `relay` | on | Bubble-in-bubble relay |
1115
1164
  | `host-key-trust` | on | Disable SSH StrictHostKeyChecking |
1116
1165
 
@@ -1119,15 +1168,15 @@ user to `bubble security`. Suppressed by `BUBBLE_QUIET_SECURITY=1`.
1119
1168
 
1120
1169
  **Commands:**
1121
1170
  - `bubble security` — show full security posture
1122
- - `bubble security set NAME on|off|auto` set individual setting
1171
+ - `bubble security set NAME VALUE` — set individual setting (most accept `on|off|auto`; `github` accepts named levels; `shared-cache` also accepts `overlay`)
1123
1172
  - `bubble security permissive` — set all to `on`
1124
1173
  - `bubble security lockdown` — set all to `off`
1125
1174
  - `bubble security default` — reset all to `auto`
1126
1175
 
1127
1176
  **GitHub network access:** Direct GitHub network access (via iptables) is only
1128
- allowed when `github-token-inject` is enabled (level 5). At all other auth
1129
- levels (0–4), iptables blocks direct GitHub traffic and forces it through the
1130
- auth proxy on loopback, which enforces repo-scoping and rate limits.
1177
+ allowed when `github` is set to `direct`. At all other levels, iptables blocks
1178
+ direct GitHub traffic and forces it through the auth proxy on loopback, which
1179
+ enforces rate limits and (for levels below `write-graphql`) repo-scoping.
1131
1180
 
1132
1181
  ---
1133
1182
 
@@ -1159,6 +1208,10 @@ container lifecycle, but a complete implementation should include them:
1159
1208
  - `bubble remote clear-default` — clear default remote host
1160
1209
  - `bubble remote status` — show remote configuration and list remote bubbles
1161
1210
  - `bubble gh status` — show GitHub authentication status
1211
+ - `bubble completion <shell>` — output shell completion script (`bash`, `zsh`, `fish`); `--install` writes to a persistent location
1212
+ - `bubble ai set autonomy read|plan|implement|pr|merge` — set AI autonomy level
1213
+ - `bubble ai set second-opinion auto|on|off` — toggle second-opinion provider
1214
+ - `bubble ai status` — show AI provider settings and credential status
1162
1215
 
1163
1216
  ---
1164
1217
 
@@ -1189,7 +1242,7 @@ container lifecycle, but a complete implementation should include them:
1189
1242
  | `~/.bubble/cloud_key` | SSH private key for cloud (ed25519, mode 0600) |
1190
1243
  | `~/.bubble/cloud_key.pub` | SSH public key for cloud |
1191
1244
  | `~/.bubble/known_hosts` | SSH known_hosts for cloud |
1192
- | `~/.bubble/claude-projects/` | Claude session state for containers |
1245
+ | `~/.bubble/ai-projects/` | AI provider session state for containers |
1193
1246
  | `~/.ssh/config.d/bubble` | Auto-managed SSH config entries |
1194
1247
 
1195
1248
  ---
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.7.9"
3
+ __version__ = "0.7.11"
@@ -301,6 +301,7 @@ _CLAUDE_JSON_SAFE_KEYS = frozenset(
301
301
  "numStartups",
302
302
  "preferredNotifChannel",
303
303
  "autoUpdaterStatus",
304
+ "effortCalloutV2Dismissed",
304
305
  }
305
306
  )
306
307
 
@@ -9,17 +9,15 @@ The host GitHub token never enters the container. Each container
9
9
  gets a per-container bearer token that only works against this
10
10
  proxy and is scoped to a single repository.
11
11
 
12
- Access levels (per-container):
13
- 1 = git smart HTTP only (push/pull)
14
- 2 = git + REST API read-only (GET /repos/{owner}/{repo}/...)
15
- 3 = git + gh read-only (REST read + GraphQL queries, no mutations)
16
- 4 = git + gh read-write (REST read-write + GraphQL queries + mutations)
12
+ Access policies (per-container):
13
+ rest_api: whether repo-scoped REST API access is allowed
14
+ graphql_read: "whitelisted", "unrestricted", or "none"
15
+ graphql_write: "whitelisted", "unrestricted", or "none"
17
16
 
18
17
  Security model:
19
18
  - Git: strict 4-pattern allowlist (git smart HTTP protocol only)
20
19
  - REST API: path-validated against /repos/{owner}/{repo}/...
21
- - GraphQL: parsed operation type, mutations rejected at level 3
22
- (NOT repo-scoped — queries can access any data the host token can read)
20
+ - GraphQL: controlled by graphql_read/graphql_write policies
23
21
  - Path canonicalization rejects encoded separators, dot-segments,
24
22
  duplicate slashes
25
23
  - Redirect following for API responses (CI logs) with hardened rules:
@@ -76,18 +74,6 @@ RATE_LIMIT_PER_HOUR = 600
76
74
  # Maximum tracked containers
77
75
  MAX_TRACKED_CONTAINERS = 100
78
76
 
79
- # ---------------------------------------------------------------------------
80
- # Access levels
81
- # ---------------------------------------------------------------------------
82
-
83
- LEVEL_GIT_ONLY = 1
84
- LEVEL_REST_READ = 2
85
- LEVEL_GH_READ = 3
86
- LEVEL_GH_READWRITE = 4
87
- LEVEL_TOKEN_INJECT = 5
88
-
89
- DEFAULT_LEVEL = LEVEL_GH_READ
90
-
91
77
  # ---------------------------------------------------------------------------
92
78
  # Path patterns
93
79
  # ---------------------------------------------------------------------------
@@ -144,13 +130,13 @@ def generate_auth_token(
144
130
  container_name: str,
145
131
  owner: str,
146
132
  repo: str,
147
- level: int = DEFAULT_LEVEL,
133
+ rest_api: bool = True,
148
134
  graphql_read: str = "whitelisted",
149
135
  graphql_write: str = "whitelisted",
150
136
  ) -> str:
151
137
  """Generate an auth proxy token for a container.
152
138
 
153
- The token maps to (container_name, owner, repo, level, graphql_read,
139
+ The token maps to (container_name, owner, repo, rest_api, graphql_read,
154
140
  graphql_write) — the proxy uses this to validate requests and enforce
155
141
  the access policy.
156
142
  Uses file locking to prevent read-modify-write races.
@@ -160,7 +146,7 @@ def generate_auth_token(
160
146
  "container": container_name,
161
147
  "owner": owner,
162
148
  "repo": repo,
163
- "level": level,
149
+ "rest_api": rest_api,
164
150
  "graphql_read": graphql_read,
165
151
  "graphql_write": graphql_write,
166
152
  }
@@ -278,14 +264,15 @@ def _build_api_url(path: str, query: str) -> str:
278
264
 
279
265
 
280
266
  # ---------------------------------------------------------------------------
281
- # API path validation (levels 2+)
267
+ # API path validation
282
268
  # ---------------------------------------------------------------------------
283
269
 
284
270
 
285
- def validate_api_path(
286
- path: str, query: str, method: str, owner: str, repo: str, level: int
287
- ) -> str | None:
288
- """Validate a REST API request path against the access level.
271
+ def validate_api_path(path: str, query: str, method: str, owner: str, repo: str) -> str | None:
272
+ """Validate a REST API request path.
273
+
274
+ REST is always repo-scoped by path validation, so all HTTP methods
275
+ are allowed when REST access is enabled.
289
276
 
290
277
  Returns an error message string, or None if the path is valid.
291
278
  """
@@ -313,11 +300,6 @@ def validate_api_path(
313
300
  if path_owner.lower() != owner.lower() or path_repo.lower() != repo.lower():
314
301
  return f"Repository mismatch: {path_owner}/{path_repo} != {owner}/{repo}"
315
302
 
316
- # Method checks: levels 1-3 are read-only REST, level 4 allows writes
317
- if level < LEVEL_GH_READWRITE:
318
- if method not in ("GET", "HEAD"):
319
- return f"Method {method} not allowed at access level {level}"
320
-
321
303
  return None
322
304
 
323
305
 
@@ -433,31 +415,6 @@ def _parse_graphql_op_type(query: str) -> str | None:
433
415
  return "query"
434
416
 
435
417
 
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
-
461
418
  def classify_graphql(body: bytes) -> tuple[str | None, str | None]:
462
419
  """Classify a GraphQL request body.
463
420
 
@@ -704,8 +661,9 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
704
661
  container = info["container"]
705
662
  owner = info["owner"]
706
663
  repo = info["repo"]
707
- level = info.get("level", LEVEL_GIT_ONLY)
708
- graphql_read, graphql_write = _resolve_graphql_policies(info)
664
+ rest_api = info.get("rest_api", False)
665
+ graphql_read = info.get("graphql_read", "none")
666
+ graphql_write = info.get("graphql_write", "none")
709
667
 
710
668
  # Rate limit
711
669
  if not self.rate_limiter.check(container):
@@ -727,7 +685,7 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
727
685
 
728
686
  # Route: git smart HTTP (/git/...)
729
687
  if path.startswith("/git/"):
730
- self._handle_git_request(method, path, query, body, container, owner, repo, level)
688
+ self._handle_git_request(method, path, query, body, container, owner, repo)
731
689
  return
732
690
 
733
691
  # Route: GraphQL (/graphql)
@@ -737,7 +695,7 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
737
695
 
738
696
  # Route: REST API (/repos/{owner}/{repo}/...)
739
697
  if path.startswith("/repos/"):
740
- self._handle_api_request(method, path, query, body, container, owner, repo, level)
698
+ self._handle_api_request(method, path, query, body, container, owner, repo, rest_api)
741
699
  return
742
700
 
743
701
  self._send_error(403, "Path not recognized")
@@ -759,8 +717,8 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
759
717
  return self._read_chunked()
760
718
  return b""
761
719
 
762
- def _handle_git_request(self, method, path, query, body, container, owner, repo, level):
763
- """Handle git smart HTTP requests (level 1+)."""
720
+ def _handle_git_request(self, method, path, query, body, container, owner, repo):
721
+ """Handle git smart HTTP requests (always allowed for valid tokens)."""
764
722
  error = validate_path(path, query, owner, repo)
765
723
  if error:
766
724
  self._send_error(403, error)
@@ -772,16 +730,14 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
772
730
  method, upstream_url, body, container, path, host=GITHUB_HOST, follow_redirects=False
773
731
  )
774
732
 
775
- def _handle_api_request(self, method, path, query, body, container, owner, repo, level):
776
- """Handle REST API requests (level 2+)."""
777
- if level < LEVEL_REST_READ:
778
- self._send_error(403, "REST API access not enabled at this access level")
779
- logger.info(
780
- "BLOCKED %s %s container=%s reason=level_%d", method, path, container, level
781
- )
733
+ def _handle_api_request(self, method, path, query, body, container, owner, repo, rest_api):
734
+ """Handle REST API requests (requires rest_api=True)."""
735
+ if not rest_api:
736
+ self._send_error(403, "REST API access not enabled")
737
+ logger.info("BLOCKED %s %s container=%s reason=rest_disabled", method, path, container)
782
738
  return
783
739
 
784
- error = validate_api_path(path, query, method, owner, repo, level)
740
+ error = validate_api_path(path, query, method, owner, repo)
785
741
  if error:
786
742
  self._send_error(403, error)
787
743
  logger.info("BLOCKED %s %s container=%s reason=%s", method, path, container, error)
@@ -42,6 +42,20 @@ def finalize_bubble(
42
42
  if hook:
43
43
  hook.post_clone(runtime, name, project_dir)
44
44
 
45
+ # Add a "github" remote with SSH-format URL for gh CLI host discovery.
46
+ # The global url.insteadOf rewrites HTTPS github.com URLs to the proxy,
47
+ # so `git remote -v` shows proxy URLs that gh can't match to github.com.
48
+ # SSH-format URLs (git@github.com:...) bypass the HTTPS insteadOf rule,
49
+ # letting gh discover the host without needing to actually use the remote.
50
+ if t.owner and t.repo:
51
+ q_repo = shlex.quote(f"git@github.com:{t.owner}/{t.repo}.git")
52
+ q_dir = shlex.quote(project_dir)
53
+ add_cmd = f"cd {q_dir} && git remote add github {q_repo} 2>/dev/null || true"
54
+ try:
55
+ runtime.exec(name, ["su", "-", "user", "-c", add_cmd])
56
+ except Exception:
57
+ pass
58
+
45
59
  # Pre-populate Claude Code settings to skip the first-run wizard
46
60
  from .ai import setup_claude_settings
47
61