dev-bubble 0.7.5__tar.gz → 0.7.8__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.5 → dev_bubble-0.7.8}/.claude/CLAUDE.md +13 -8
  2. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/CHANGELOG.md +1 -1
  3. {dev_bubble-0.7.5/dev_bubble.egg-info → dev_bubble-0.7.8}/PKG-INFO +1 -1
  4. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/__init__.py +1 -1
  5. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/claude.py +98 -7
  6. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/clean.py +3 -1
  7. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/cli.py +37 -9
  8. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/security_cmd.py +6 -4
  9. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/settings.py +68 -20
  10. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/config.py +2 -0
  11. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/container_helpers.py +8 -6
  12. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/github_token.py +41 -23
  13. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/builder.py +22 -14
  14. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/base.sh +5 -0
  15. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/lean-toolchain.sh +1 -1
  16. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/lean.sh +3 -1
  17. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/python.sh +2 -2
  18. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/gh.sh +10 -0
  19. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/vscode.sh +44 -10
  20. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/runtime/base.py +22 -0
  21. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/runtime/incus.py +47 -0
  22. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/security.py +187 -34
  23. {dev_bubble-0.7.5 → dev_bubble-0.7.8/dev_bubble.egg-info}/PKG-INFO +1 -1
  24. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_claude.py +188 -8
  25. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_config.py +61 -2
  26. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_github_token.py +11 -14
  27. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_security.py +260 -151
  28. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_tools.py +54 -6
  29. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/.github/workflows/ci.yml +0 -0
  30. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/.github/workflows/publish.yml +0 -0
  31. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/.gitignore +0 -0
  32. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/LICENSE +0 -0
  33. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/README.md +0 -0
  34. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/SPEC.md +0 -0
  35. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/__main__.py +0 -0
  36. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/auth_proxy.py +0 -0
  37. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/automation.py +0 -0
  38. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/clone.py +0 -0
  39. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/cloud.py +0 -0
  40. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/cloud_types.py +0 -0
  41. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/__init__.py +0 -0
  42. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/cloud_cmd.py +0 -0
  43. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/completion.py +0 -0
  44. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/doctor.py +0 -0
  45. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/images.py +0 -0
  46. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/infrastructure.py +0 -0
  47. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/lifecycle.py +0 -0
  48. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/list_cmd.py +0 -0
  49. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/relay_cmd.py +0 -0
  50. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/remote_cmd.py +0 -0
  51. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/commands/status_cmd.py +0 -0
  52. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/data/skill.md +0 -0
  53. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/default_repos.json +0 -0
  54. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/finalization.py +0 -0
  55. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/git_store.py +0 -0
  56. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/graphql_validator.py +0 -0
  57. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/hooks/__init__.py +0 -0
  58. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/hooks/lean.py +0 -0
  59. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/hooks/python.py +0 -0
  60. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/image_management.py +0 -0
  61. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/__init__.py +0 -0
  62. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/cloud-init.sh +0 -0
  63. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/claude.sh +0 -0
  64. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/codex.sh +0 -0
  65. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/elan.sh +0 -0
  66. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/emacs.sh +0 -0
  67. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/neovim.sh +0 -0
  68. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/images/scripts/tools/pins.json +0 -0
  69. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/lean.py +0 -0
  70. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/lifecycle.py +0 -0
  71. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/naming.py +0 -0
  72. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/native.py +0 -0
  73. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/network.py +0 -0
  74. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/notices.py +0 -0
  75. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/output.py +0 -0
  76. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/provisioning.py +0 -0
  77. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/relay.py +0 -0
  78. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/remote.py +0 -0
  79. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/repo_registry.py +0 -0
  80. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/runtime/__init__.py +0 -0
  81. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/runtime/colima.py +0 -0
  82. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/setup.py +0 -0
  83. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/skill.py +0 -0
  84. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/spinner.py +0 -0
  85. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/target.py +0 -0
  86. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/token_store.py +0 -0
  87. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/tools.py +0 -0
  88. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/tunnel.py +0 -0
  89. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/bubble/vscode.py +0 -0
  90. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/config/com.bubble.git-update.plist +0 -0
  91. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/config/com.bubble.image-refresh.plist +0 -0
  92. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/config/com.bubble.relay-daemon.plist +0 -0
  93. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/conftest.py +0 -0
  94. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/dev_bubble.egg-info/SOURCES.txt +0 -0
  95. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/dev_bubble.egg-info/dependency_links.txt +0 -0
  96. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/dev_bubble.egg-info/entry_points.txt +0 -0
  97. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/dev_bubble.egg-info/requires.txt +0 -0
  98. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/dev_bubble.egg-info/top_level.txt +0 -0
  99. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/pyproject.toml +0 -0
  100. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/setup.cfg +0 -0
  101. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/conftest.py +0 -0
  102. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_auth_proxy.py +0 -0
  103. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_branch_no_target.py +0 -0
  104. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_build_lock.py +0 -0
  105. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_claude_projects_symlink.py +0 -0
  106. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_cloud.py +0 -0
  107. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_completion.py +0 -0
  108. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_customize.py +0 -0
  109. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_editor.py +0 -0
  110. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_git_store.py +0 -0
  111. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_graphql_validator.py +0 -0
  112. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_hooks.py +0 -0
  113. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_integration.py +0 -0
  114. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_lifecycle.py +0 -0
  115. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_list_columns.py +0 -0
  116. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_list_remote.py +0 -0
  117. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_mounts.py +0 -0
  118. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_multi_target.py +0 -0
  119. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_naming.py +0 -0
  120. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_native.py +0 -0
  121. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_network.py +0 -0
  122. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_notices.py +0 -0
  123. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_relay.py +0 -0
  124. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_remote.py +0 -0
  125. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_repo_registry.py +0 -0
  126. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_skill.py +0 -0
  127. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_spinner.py +0 -0
  128. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_status.py +0 -0
  129. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_target.py +0 -0
  130. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_tunnel.py +0 -0
  131. {dev_bubble-0.7.5 → dev_bubble-0.7.8}/tests/test_vscode.py +0 -0
@@ -121,14 +121,19 @@ Bubbles can run on a remote machine instead of locally. The `--ssh HOST` flag (o
121
121
  Users can place a `customize.sh` script at `~/.bubble/customize.sh` to run custom setup in all container images. The script runs as root as the final step when building any image (base, lean, lean-v4.X.Y). This lets users add tools, dotfiles, shell config, etc. without forking image scripts. The script's content hash is tracked in `~/.bubble/customize-hash`; on `bubble open`, if the hash differs from the stored value, a background rebuild of the base image is triggered (same pattern as VS Code commit hash drift). Code is in `builder.py` (`customize_hash()`, `_run_customize_script()`).
122
122
 
123
123
  ### GitHub Auth Proxy
124
- The auth proxy (`auth_proxy.py`) provides repo-scoped GitHub authentication without injecting the host's token into containers. It's an HTTP reverse proxy that runs on the host with graduated access levels:
125
-
126
- | Level | Description | Routes |
127
- |-------|-------------|--------|
128
- | 1 | Git only | `/git/{owner}/{repo}/...` (smart HTTP) |
129
- | 2 | Git + REST read | + `GET /repos/{owner}/{repo}/...` |
130
- | 3 | Git + gh read-only (default) | + `POST /graphql` (queries only, mutations blocked) |
131
- | 4 | Git + gh read-write | + mutations + REST POST/PATCH/DELETE |
124
+ The auth proxy (`auth_proxy.py`) provides repo-scoped GitHub authentication without injecting the host's token into containers. It's an HTTP reverse proxy that runs on the host. The access level is controlled by the unified `github` security setting (`security.py`), which picks one level from a graduated escalation ladder:
125
+
126
+ | `github` level | Behavior |
127
+ |----------------|----------|
128
+ | `off` | no GitHub access at all |
129
+ | `basic` | git push/pull only (proxy rewrites, repo-scoped) |
130
+ | `rest` | + repo-scoped REST API |
131
+ | `allowlist-read-graphql` | + allowlisted GraphQL queries |
132
+ | `allowlist-write-graphql` | + allowlisted GraphQL mutations (default) |
133
+ | `write-graphql` | + arbitrary GraphQL, no allowlist filtering |
134
+ | `direct` | inject the raw token, no proxy |
135
+
136
+ `auto` defaults to `allowlist-write-graphql`. The old `github-auth`, `github-api`, and `github-token-inject` settings are deprecated but migrated automatically.
132
137
 
133
138
  **Git flow:** Container git → `url.insteadOf` rewrites to `http://127.0.0.1:7654/git/...` → proxy validates `X-Bubble-Token` header → checks path matches allowed `owner/repo` → adds `Authorization: token <real-token>` → forwards to `https://github.com` → returns response.
134
139
 
@@ -218,7 +218,7 @@
218
218
  - Templated Claude prompts for issues and PRs (#33)
219
219
  - PR bubbles now auto-inject a Claude prompt that checks CI status and summarizes PR comments
220
220
  - User-customizable prompt templates via `~/.bubble/templates/issue.txt` and `~/.bubble/templates/pr.txt`
221
- - Issue template placeholders: `{owner}`, `{repo}`, `{issue_num}`, `{title}`, `{body}`, `{comments}`, `{comments_section}`, `{branch}`
221
+ - Issue template placeholders: `{owner}`, `{repo}`, `{issue_num}`, `{title}`, `{body}`, `{comments}`, `{comments_section}`, `{branch}`, `{instructions}`
222
222
  - PR template placeholders: `{owner}`, `{repo}`, `{pr_num}`, `{title}`, `{body}`, `{branch}`
223
223
  - Falls back to built-in defaults when no custom template exists
224
224
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.5
3
+ Version: 0.7.8
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.5"
3
+ __version__ = "0.7.8"
@@ -18,18 +18,56 @@ CLAUDE_TASK_COMMAND = (
18
18
 
19
19
  TEMPLATES_DIR = DATA_DIR / "templates"
20
20
 
21
+ # Ordered autonomy levels. Each level implies all lower levels.
22
+ AUTONOMY_LEVELS = ("read", "plan", "implement", "pr", "merge")
23
+
24
+ # Valid values for the second_opinion setting.
25
+ SECOND_OPINION_VALUES = ("auto", "on", "off")
26
+
27
+ # Issue prompt instructions keyed by autonomy level.
28
+ _ISSUE_INSTRUCTIONS = {
29
+ "read": (
30
+ "Please read and understand the issue. "
31
+ "Summarize the problem and any relevant context, but take no further action."
32
+ ),
33
+ "plan": (
34
+ "Please read and understand the issue, then propose a plan to fix it. "
35
+ "Describe what files need to change and how, but do not implement anything yet."
36
+ ),
37
+ "implement": (
38
+ "Please claim this issue (assign it to yourself if possible), "
39
+ "then implement a fix or feature as described. "
40
+ "Work on a branch named `{branch}`. Do not commit or open a PR."
41
+ ),
42
+ "pr": (
43
+ "Please claim this issue (assign it to yourself if possible), "
44
+ "then implement a fix or feature as described. "
45
+ "Work on a branch named `{branch}`, and open a PR when done."
46
+ ),
47
+ "merge": (
48
+ "Please claim this issue (assign it to yourself if possible), "
49
+ "then implement a fix or feature as described. "
50
+ "Work on a branch named `{branch}`, open a PR, "
51
+ "rebase onto the default branch, watch CI, and merge when it passes."
52
+ ),
53
+ }
54
+
55
+ _SECOND_OPINION_SUFFIX = (
56
+ "\n\nBefore proceeding, get a second opinion from another AI "
57
+ "(e.g. Codex) to review your approach."
58
+ )
59
+
21
60
  # Default issue prompt template. Placeholders:
22
61
  # {owner}, {repo}, {issue_num}, {title}, {body}, {comments},
23
- # {comments_section} (pre-formatted, empty when no comments), {branch}
62
+ # {comments_section} (pre-formatted, empty when no comments), {branch},
63
+ # {instructions}
24
64
  _DEFAULT_ISSUE_TEMPLATE = (
25
65
  'Please read and understand GitHub issue #{issue_num}: "{title}".\n'
26
66
  "\n"
27
67
  "Issue description:\n"
28
68
  "{body}\n"
29
69
  "{comments_section}"
30
- "\nPlease claim this issue (assign it to yourself if possible), "
31
- "then implement a fix or feature as described. "
32
- "Work on a branch named `{branch}`, and open a PR when done."
70
+ "\n{instructions}"
33
71
  )
34
72
 
35
73
  # Default PR prompt template. Placeholders:
@@ -105,12 +143,44 @@ def _fetch_github_item(owner: str, repo: str, endpoint: str, jq: str) -> str | N
105
143
  return None
106
144
 
107
145
 
108
- def generate_issue_prompt(owner: str, repo: str, issue_num: str, branch: str) -> str | None:
146
+ def _resolve_second_opinion(second_opinion: str, config: dict | None = None) -> bool:
147
+ """Resolve the second_opinion setting to a boolean.
148
+
149
+ 'on' → True, 'off' → False, 'auto' → True if codex will be
150
+ available in the container (checked via tool resolution, not host PATH).
151
+ """
152
+ if second_opinion == "on":
153
+ return True
154
+ if second_opinion == "off":
155
+ return False
156
+ # auto: check if codex is resolved as an enabled tool
157
+ if config is not None:
158
+ from .tools import resolve_tools
159
+
160
+ return "codex" in resolve_tools(config)
161
+ # Fallback when no config available: check host PATH
162
+ import shutil
163
+
164
+ return shutil.which("codex") is not None
165
+
166
+
167
+ def generate_issue_prompt(
168
+ owner: str,
169
+ repo: str,
170
+ issue_num: str,
171
+ branch: str,
172
+ autonomy: str = "plan",
173
+ second_opinion: str = "auto",
174
+ config: dict | None = None,
175
+ ) -> str | None:
109
176
  """Fetch GitHub issue details and generate a Claude prompt.
110
177
 
111
178
  Returns the prompt string, or None if the issue can't be fetched.
112
179
  Uses a custom template from ~/.bubble/templates/issue.txt if present,
113
180
  otherwise falls back to the built-in default.
181
+
182
+ The autonomy level controls what action instructions are included:
183
+ read, plan, implement, pr, merge.
114
184
  """
115
185
  raw = _fetch_github_item(owner, repo, f"issues/{issue_num}", ".title,.body")
116
186
  if raw is None:
@@ -129,8 +199,21 @@ def generate_issue_prompt(owner: str, repo: str, issue_num: str, branch: str) ->
129
199
  if comments_text:
130
200
  comments_section = f"\nComments:\n{comments_text}\n"
131
201
 
132
- template = _load_template("issue") or _DEFAULT_ISSUE_TEMPLATE
133
- return _render_template(
202
+ # Build instructions from autonomy level
203
+ if autonomy not in AUTONOMY_LEVELS:
204
+ autonomy = "plan"
205
+ instructions = _ISSUE_INSTRUCTIONS[autonomy]
206
+ # The instructions may contain {branch} placeholder
207
+ instructions = instructions.format(branch=branch)
208
+
209
+ # Append second opinion request if enabled
210
+ if _resolve_second_opinion(second_opinion, config=config):
211
+ instructions += _SECOND_OPINION_SUFFIX
212
+
213
+ custom_template = _load_template("issue")
214
+ template = custom_template or _DEFAULT_ISSUE_TEMPLATE
215
+
216
+ prompt = _render_template(
134
217
  template,
135
218
  owner=owner,
136
219
  repo=repo,
@@ -140,8 +223,16 @@ def generate_issue_prompt(owner: str, repo: str, issue_num: str, branch: str) ->
140
223
  comments=comments_text,
141
224
  comments_section=comments_section,
142
225
  branch=branch,
226
+ instructions=instructions,
143
227
  )
144
228
 
229
+ # If a custom template didn't use {instructions}, append them so
230
+ # autonomy/second-opinion settings are never silently lost.
231
+ if custom_template and "{instructions}" not in custom_template:
232
+ prompt = prompt.rstrip() + "\n\n" + instructions
233
+
234
+ return prompt
235
+
145
236
 
146
237
  def generate_pr_prompt(owner: str, repo: str, pr_num: str, branch: str) -> str | None:
147
238
  """Fetch GitHub PR details and generate a Claude prompt.
@@ -70,7 +70,9 @@ REASONS=""
70
70
  EXPECTED=$(echo {q_repo} | tr '[:upper:]' '[:lower:]')
71
71
 
72
72
  # Check 1: no unexpected non-hidden items in home
73
- ITEMS=$(ls /home/user/ 2>/dev/null || true)
73
+ # Filter out known infrastructure files (build.log from background builds,
74
+ # bin/ from tool installation)
75
+ ITEMS=$(ls /home/user/ 2>/dev/null | grep -v -x -e 'build.log' -e 'bin' || true)
74
76
  if [ -n "$EXPECTED" ]; then
75
77
  if [ "$(echo "$ITEMS" | tr '[:upper:]' '[:lower:]')" != "$EXPECTED" ]; then
76
78
  CLEAN=false
@@ -51,6 +51,7 @@ from .native import open_native
51
51
  from .provisioning import mount_overlaps, provision_container
52
52
  from .repo_registry import RepoRegistry
53
53
  from .security import (
54
+ get_github_level,
54
55
  is_enabled,
55
56
  is_locked_off,
56
57
  print_warnings,
@@ -264,7 +265,20 @@ def _resolve_claude_prompt_locally(target: str, new_branch: str | None = None) -
264
265
  from .output import detail
265
266
 
266
267
  detail(f"Fetching issue #{t.ref} for Claude prompt...")
267
- prompt = generate_issue_prompt(t.owner, t.repo, t.ref, branch) or ""
268
+ _cfg = load_config()
269
+ _claude_cfg = _cfg.get("claude", {})
270
+ prompt = (
271
+ generate_issue_prompt(
272
+ t.owner,
273
+ t.repo,
274
+ t.ref,
275
+ branch,
276
+ autonomy=_claude_cfg.get("autonomy", "plan"),
277
+ second_opinion=_claude_cfg.get("second_opinion", "auto"),
278
+ config=_cfg,
279
+ )
280
+ or ""
281
+ )
268
282
  elif t.kind == "pr":
269
283
  from .claude import generate_pr_prompt
270
284
 
@@ -329,8 +343,9 @@ def _open_remote(
329
343
  # Inject local SSH keys into the container so the chained ProxyCommand works
330
344
  inject_local_ssh_keys(remote_host, name)
331
345
 
332
- # Set up GitHub auth: token injection (level 5) or tunneled proxy (levels 1-4)
333
- if is_enabled(config, "github_token_inject"):
346
+ # Set up GitHub auth based on the unified github security level
347
+ gh_level = get_github_level(config)
348
+ if gh_level == "direct":
334
349
  from .github_token import setup_gh_token
335
350
 
336
351
  setup_gh_token(
@@ -339,7 +354,7 @@ def _open_remote(
339
354
  remote_host=remote_host,
340
355
  token_inject=True,
341
356
  )
342
- elif is_enabled(config, "github_auth"):
357
+ elif gh_level != "off":
343
358
  from .github_token import setup_gh_token
344
359
  from .tools import resolve_tools
345
360
 
@@ -993,9 +1008,10 @@ def _open_single(
993
1008
  )
994
1009
 
995
1010
  # Set up GitHub auth BEFORE clone — network allowlisting strips
996
- # github.com from allowed domains when using the auth proxy (levels
997
- # 1-4), so git must be configured to route through the proxy first.
998
- if is_enabled(config, "github_token_inject"):
1011
+ # github.com from allowed domains when using the auth proxy, so
1012
+ # git must be configured to route through the proxy first.
1013
+ gh_level = get_github_level(config)
1014
+ if gh_level == "direct":
999
1015
  from .github_token import setup_gh_token
1000
1016
 
1001
1017
  setup_gh_token(
@@ -1004,7 +1020,7 @@ def _open_single(
1004
1020
  machine_readable=machine_readable,
1005
1021
  token_inject=True,
1006
1022
  )
1007
- elif is_enabled(config, "github_auth"):
1023
+ elif gh_level != "off":
1008
1024
  from .github_token import setup_gh_token
1009
1025
  from .tools import resolve_tools
1010
1026
 
@@ -1033,7 +1049,19 @@ def _open_single(
1033
1049
  from .output import detail
1034
1050
 
1035
1051
  detail(f"Fetching issue #{t.ref} for Claude prompt...")
1036
- claude_prompt = generate_issue_prompt(t.owner, t.repo, t.ref, checkout_branch) or ""
1052
+ claude_cfg = config.get("claude", {})
1053
+ claude_prompt = (
1054
+ generate_issue_prompt(
1055
+ t.owner,
1056
+ t.repo,
1057
+ t.ref,
1058
+ checkout_branch,
1059
+ autonomy=claude_cfg.get("autonomy", "plan"),
1060
+ second_opinion=claude_cfg.get("second_opinion", "auto"),
1061
+ config=config,
1062
+ )
1063
+ or ""
1064
+ )
1037
1065
  elif not claude_prompt and t.kind == "pr" and not machine_readable:
1038
1066
  from .claude import generate_pr_prompt
1039
1067
  from .output import detail
@@ -38,8 +38,9 @@ def register_security_commands(main):
38
38
  if changed:
39
39
  save_config(config)
40
40
  for name in changed:
41
- click.echo(f" security.{display_setting_name(name)} = on")
42
- click.echo(f"Set {len(changed)} setting(s) to on. All conveniences enabled.")
41
+ val = config["security"][name]
42
+ click.echo(f" security.{display_setting_name(name)} = {val}")
43
+ click.echo(f"Set {len(changed)} setting(s) to most permissive.")
43
44
  else:
44
45
  click.echo("All settings are already on.")
45
46
 
@@ -75,11 +76,12 @@ def register_security_commands(main):
75
76
  def security_set(key, value):
76
77
  """Set a security setting: bubble security set <name> <value>.
77
78
 
78
- Setting names use hyphens (e.g. github-auth, claude-credentials).
79
+ Setting names use hyphens (e.g. github, claude-credentials).
79
80
  Underscores are also accepted as permanent aliases.
80
81
 
81
82
  Most settings accept: auto, on, off.
82
- github-api also accepts: read-write (enables mutations).
83
+ github accepts: off, basic, rest, allowlist-read-graphql,
84
+ allowlist-write-graphql, write-graphql, direct.
83
85
  """
84
86
  # Accept both "security.X" and bare "X", normalize hyphens to underscores
85
87
  name = normalize_setting_name(key.removeprefix("security."))
@@ -157,12 +157,64 @@ def register_settings_commands(main):
157
157
  else:
158
158
  click.echo("Claude credentials disabled.")
159
159
 
160
+ @claude_group.command("set")
161
+ @click.argument("key", type=click.Choice(["autonomy", "second-opinion"]))
162
+ @click.argument("value")
163
+ def claude_set_cmd(key, value):
164
+ """Set a Claude Code setting.
165
+
166
+ \b
167
+ Settings:
168
+ autonomy read, plan, implement, pr, merge (default: plan)
169
+ second-opinion auto, on, off (default: auto)
170
+
171
+ \b
172
+ Examples:
173
+ bubble claude set autonomy pr
174
+ bubble claude set second-opinion on
175
+ """
176
+ from ..claude import AUTONOMY_LEVELS, SECOND_OPINION_VALUES
177
+
178
+ config_key = key.replace("-", "_")
179
+
180
+ if config_key == "autonomy":
181
+ if value not in AUTONOMY_LEVELS:
182
+ click.echo(
183
+ f"Invalid autonomy level: {value}. Choose from: {', '.join(AUTONOMY_LEVELS)}",
184
+ err=True,
185
+ )
186
+ sys.exit(1)
187
+ elif config_key == "second_opinion":
188
+ if value not in SECOND_OPINION_VALUES:
189
+ click.echo(
190
+ f"Invalid second-opinion value: {value}. "
191
+ f"Choose from: {', '.join(SECOND_OPINION_VALUES)}",
192
+ err=True,
193
+ )
194
+ sys.exit(1)
195
+
196
+ config = load_config()
197
+ config.setdefault("claude", {})[config_key] = value
198
+ save_config(config)
199
+ click.echo(f"Set claude.{key} = {value}")
200
+
160
201
  @claude_group.command("status")
161
202
  def claude_status_cmd():
162
203
  """Show current Claude Code settings."""
204
+ from ..claude import _resolve_second_opinion
205
+
163
206
  config = load_config()
164
- creds = config.get("claude", {}).get("credentials", True)
165
- click.echo(f" credentials: {'on' if creds else 'off'}")
207
+ claude_cfg = config.get("claude", {})
208
+ creds = claude_cfg.get("credentials", True)
209
+ autonomy = claude_cfg.get("autonomy", "plan")
210
+ second_opinion = claude_cfg.get("second_opinion", "auto")
211
+ resolved_so = _resolve_second_opinion(second_opinion, config=config)
212
+
213
+ click.echo(f" credentials: {'on' if creds else 'off'}")
214
+ click.echo(f" autonomy: {autonomy}")
215
+ so_resolved = "on" if resolved_so else "off"
216
+ so_extra = f" (resolved: {so_resolved})" if second_opinion == "auto" else ""
217
+ click.echo(f" second-opinion: {second_opinion}{so_extra}")
166
218
 
167
219
  # --- codex ---
168
220
 
@@ -321,34 +373,30 @@ def register_settings_commands(main):
321
373
  """Show GitHub integration status."""
322
374
  from ..automation import is_auth_proxy_installed
323
375
  from ..github_token import has_gh_auth
324
- from ..security import is_enabled as sec_is_enabled
376
+ from ..security import get_github_level
325
377
 
326
378
  config = load_config()
327
- github_auth = get_setting(config, "github_auth")
328
- enabled = sec_is_enabled(config, "github_auth")
379
+ gh_level = get_github_level(config)
380
+ raw_value = get_setting(config, "github")
329
381
  host_auth = has_gh_auth()
330
382
  proxy_installed = is_auth_proxy_installed()
331
383
 
332
- token_inject = get_setting(config, "github_token_inject")
333
- inject_enabled = sec_is_enabled(config, "github_token_inject")
334
-
335
- click.echo(f"GitHub auth: {github_auth} (effectively {'on' if enabled else 'off'})")
336
- click.echo(
337
- f"Token injection: {token_inject} (effectively {'on' if inject_enabled else 'off'})"
338
- )
384
+ if raw_value == "auto":
385
+ click.echo(f"GitHub level: auto (effectively {gh_level})")
386
+ else:
387
+ click.echo(f"GitHub level: {gh_level}")
339
388
  click.echo(f"Host gh auth: {'authenticated' if host_auth else 'not authenticated'}")
340
389
  click.echo(f"Auth proxy: {'installed' if proxy_installed else 'not installed'}")
341
- if inject_enabled:
390
+ if gh_level == "direct":
342
391
  click.echo(
343
- "\nWarning: token injection is enabled. Containers get your full GitHub token."
344
- "\nDisable: bubble security set github-token-inject off"
392
+ "\nWarning: direct token injection is enabled."
393
+ " Containers get your full GitHub token."
394
+ "\nChange: bubble security set github allowlist-write-graphql"
345
395
  )
396
+ elif gh_level == "off":
397
+ click.echo("\nGitHub access is disabled. Enable: bubble security set github auto")
346
398
  elif not host_auth:
347
399
  click.echo("\nRun 'gh auth login' to authenticate on the host first.")
348
- elif not enabled:
349
- click.echo(
350
- "\nRun 'bubble security set github-auth on' to enable GitHub auth in bubbles."
351
- )
352
400
 
353
401
  @gh_group.group("proxy")
354
402
  def gh_proxy_group():
@@ -479,7 +527,7 @@ def register_settings_commands(main):
479
527
  """Set a security setting: bubble config set security.<name> <value>.
480
528
 
481
529
  Alias for `bubble security set <name> <value>`.
482
- Setting names use hyphens (e.g. github-auth, claude-credentials).
530
+ Setting names use hyphens (e.g. github, claude-credentials).
483
531
  Underscores are also accepted as permanent aliases.
484
532
  """
485
533
  # Accept both "security.X" and bare "X", normalize hyphens to underscores
@@ -62,6 +62,8 @@ DEFAULT_CONFIG = {
62
62
  },
63
63
  "claude": {
64
64
  "credentials": True,
65
+ "autonomy": "plan",
66
+ "second_opinion": "auto",
65
67
  },
66
68
  "codex": {
67
69
  "credentials": True,
@@ -11,7 +11,7 @@ import click
11
11
  from .lifecycle import load_registry
12
12
  from .output import detail, step
13
13
  from .runtime.base import ContainerRuntime
14
- from .security import filter_github_domains, is_enabled
14
+ from .security import filter_github_domains
15
15
  from .vscode import add_ssh_config
16
16
 
17
17
 
@@ -123,11 +123,13 @@ def apply_network(
123
123
  for d in tool_runtime_domains(resolve_tools(config)):
124
124
  if d not in domains:
125
125
  domains.append(d)
126
- # Direct GitHub network access is only allowed when github_token_inject
127
- # is enabled (level 5: direct token injection). For proxy-mediated access
128
- # (levels 0-4) or no auth, iptables blocks direct GitHub traffic — all
129
- # GitHub communication is forced through the auth proxy on loopback.
130
- if not is_enabled(config, "github_token_inject"):
126
+ # Direct GitHub network access is only allowed when the github level
127
+ # is "direct" (raw token injection). For proxy-mediated access or no
128
+ # auth, iptables blocks direct GitHub traffic — all GitHub communication
129
+ # is forced through the auth proxy on loopback.
130
+ from .security import get_github_level
131
+
132
+ if get_github_level(config) != "direct":
131
133
  domains = filter_github_domains(domains)
132
134
  if domains:
133
135
  try:
@@ -1,6 +1,6 @@
1
1
  """GitHub authentication for containers via auth proxy or direct injection.
2
2
 
3
- Levels 1-4 use an HTTP reverse proxy on the host:
3
+ Most github levels use an HTTP reverse proxy on the host:
4
4
  1. Receives plain HTTP git requests from the container
5
5
  2. Validates the request targets only the allowed repository
6
6
  3. Adds the real Authorization header
@@ -9,8 +9,8 @@ Levels 1-4 use an HTTP reverse proxy on the host:
9
9
  The host GitHub token never enters the container. Each container
10
10
  gets a per-container bearer token scoped to one repository.
11
11
 
12
- Level 5 (token injection) bypasses the proxy entirely: the host's
13
- actual GitHub token is injected into the container as GH_TOKEN and
12
+ The "direct" level bypasses the proxy entirely: the host's actual
13
+ GitHub token is injected into the container as GH_TOKEN and
14
14
  GITHUB_TOKEN environment variables, giving unrestricted access.
15
15
 
16
16
  For local containers, the proxy is exposed via Incus proxy devices.
@@ -18,15 +18,18 @@ For remote/cloud containers, an SSH reverse tunnel forwards the
18
18
  local proxy port to the remote host, then an Incus proxy device
19
19
  on the remote exposes it into the container.
20
20
 
21
- Access levels:
22
- Level 1: git only (push/pull)
23
- Level 3: git + gh read-only (REST read + GraphQL queries)
24
- Level 4: git + gh read-write (REST read-write + GraphQL mutations)
25
- Level 5: direct token injection (bypasses proxy)
26
-
27
- When the gh tool is installed and github_api is enabled, the proxy
28
- is also exposed as a Unix socket at /bubble/gh-proxy.sock and gh
29
- is configured to route through it via http_unix_socket.
21
+ GitHub levels (each a strict superset of the one above):
22
+ off: no GitHub access
23
+ basic: git push/pull only
24
+ rest: + repo-scoped REST API
25
+ allowlist-read-graphql: + allowlisted GraphQL queries
26
+ allowlist-write-graphql: + allowlisted GraphQL mutations (default)
27
+ write-graphql: + arbitrary GraphQL
28
+ direct: raw token injection, no proxy
29
+
30
+ When gh is installed and the github level includes REST or higher,
31
+ the proxy is also exposed as a Unix socket at /bubble/gh-proxy.sock
32
+ and gh is configured to route through it via http_unix_socket.
30
33
  """
31
34
 
32
35
  import platform
@@ -109,16 +112,23 @@ def _ensure_auth_proxy_running() -> int | None:
109
112
  def _resolve_access_level(config: dict, gh_enabled: bool) -> int:
110
113
  """Determine the auth proxy REST access level for a container.
111
114
 
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().
115
+ Returns the REST access level (1 or 4) based on the unified github
116
+ security level and tool availability. GraphQL policies are resolved
117
+ separately by _resolve_graphql_config().
115
118
  """
116
119
  from .auth_proxy import LEVEL_GH_READWRITE, LEVEL_GIT_ONLY
117
- from .security import is_enabled
120
+ from .security import get_github_level
121
+
122
+ level = get_github_level(config)
123
+
124
+ # basic = git only; off/direct shouldn't reach here but return git-only
125
+ if level in ("off", "basic", "direct"):
126
+ return LEVEL_GIT_ONLY
118
127
 
119
- if not gh_enabled or not is_enabled(config, "github_api"):
128
+ if not gh_enabled:
120
129
  return LEVEL_GIT_ONLY
121
130
 
131
+ # rest, allowlist-read-graphql, allowlist-write-graphql, write-graphql
122
132
  # REST is already repo-scoped by path validation, so read-write is
123
133
  # safe by default. This enables REST POST operations like
124
134
  # gh run rerun (/repos/{owner}/{repo}/actions/runs/{id}/rerun).
@@ -128,18 +138,26 @@ def _resolve_access_level(config: dict, gh_enabled: bool) -> int:
128
138
  def _resolve_graphql_config(config: dict, gh_enabled: bool) -> tuple[str, str]:
129
139
  """Determine GraphQL policies for a container.
130
140
 
131
- Returns (graphql_read, graphql_write).
141
+ Returns (graphql_read, graphql_write) based on the unified github
142
+ security level.
132
143
  """
133
- from .security import get_setting, is_enabled
144
+ from .security import get_github_level
134
145
 
135
- if not gh_enabled or not is_enabled(config, "github_api"):
146
+ level = get_github_level(config)
147
+
148
+ if not gh_enabled or level in ("off", "basic", "rest", "direct"):
136
149
  return "none", "none"
137
150
 
138
- if get_setting(config, "github_api") == "read-write":
151
+ if level == "allowlist-read-graphql":
152
+ return "whitelisted", "none"
153
+
154
+ if level == "allowlist-write-graphql":
155
+ return "whitelisted", "whitelisted"
156
+
157
+ if level == "write-graphql":
139
158
  return "unrestricted", "unrestricted"
140
159
 
141
- # Default: whitelisted for both — repo-scoped reads, allowlisted mutations
142
- return "whitelisted", "whitelisted"
160
+ return "none", "none"
143
161
 
144
162
 
145
163
  def _describe_graphql_mode(graphql_read: str, graphql_write: str) -> str: