limbo-code 0.1.9__tar.gz → 0.1.10__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 (171) hide show
  1. {limbo_code-0.1.9 → limbo_code-0.1.10}/.github/workflows/test.yml +4 -0
  2. {limbo_code-0.1.9 → limbo_code-0.1.10}/AGENTS.md +2 -2
  3. {limbo_code-0.1.9 → limbo_code-0.1.10}/PKG-INFO +17 -16
  4. {limbo_code-0.1.9 → limbo_code-0.1.10}/README.md +16 -15
  5. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/agent.py +38 -3
  6. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/history.py +22 -5
  7. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/anthropic_client.py +23 -3
  8. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/openai_client.py +82 -4
  9. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/responses_client.py +39 -19
  10. limbo_code-0.1.10/src/limbo/llm/sse.py +49 -0
  11. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/models.py +9 -1
  12. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/prompt.py +1 -1
  13. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/base.py +24 -4
  14. limbo_code-0.1.10/src/limbo/tools/bash.py +393 -0
  15. limbo_code-0.1.10/src/limbo/tools/edit.py +253 -0
  16. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/find.py +28 -0
  17. limbo_code-0.1.10/src/limbo/tools/grep.py +147 -0
  18. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/ls.py +24 -3
  19. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/read.py +69 -7
  20. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/registry.py +16 -3
  21. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/trace.py +10 -0
  22. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/main.py +5 -1
  23. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_agent.py +94 -0
  24. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_anthropic_client.py +38 -0
  25. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_history.py +27 -0
  26. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_llm_client.py +99 -1
  27. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_responses_client.py +44 -0
  28. limbo_code-0.1.10/tests/test_sse.py +92 -0
  29. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_trace.py +13 -0
  30. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_base.py +5 -2
  31. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_bash.py +70 -2
  32. limbo_code-0.1.10/tests/tools/test_edit.py +289 -0
  33. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_find.py +37 -0
  34. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_grep.py +45 -37
  35. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_ls.py +19 -1
  36. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_read.py +66 -0
  37. limbo_code-0.1.10/uv.lock +936 -0
  38. limbo_code-0.1.9/src/limbo/llm/sse.py +0 -31
  39. limbo_code-0.1.9/src/limbo/tools/bash.py +0 -204
  40. limbo_code-0.1.9/src/limbo/tools/edit.py +0 -74
  41. limbo_code-0.1.9/src/limbo/tools/grep.py +0 -190
  42. limbo_code-0.1.9/tests/tools/test_edit.py +0 -130
  43. limbo_code-0.1.9/uv.lock +0 -936
  44. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  45. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  46. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  47. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  48. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  49. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/SKILL.md +0 -0
  50. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/agents/openai.yaml +0 -0
  51. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/mocking.md +0 -0
  52. {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/tests.md +0 -0
  53. {limbo_code-0.1.9 → limbo_code-0.1.10}/.github/workflows/publish.yml +0 -0
  54. {limbo_code-0.1.9 → limbo_code-0.1.10}/.gitignore +0 -0
  55. {limbo_code-0.1.9 → limbo_code-0.1.10}/CONTEXT.md +0 -0
  56. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/confirm_view.html +0 -0
  57. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/limbo-ui-minimal.md +0 -0
  58. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/limbo-ui-redesign.md +0 -0
  59. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype-minimal-confirm.png +0 -0
  60. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype-minimal.html +0 -0
  61. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype-minimal.png +0 -0
  62. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype.html +0 -0
  63. {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype.png +0 -0
  64. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/limbo-current-ui.png +0 -0
  65. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/limbo-new-ui.png +0 -0
  66. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
  67. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
  68. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
  69. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
  70. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
  71. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
  72. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
  73. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
  74. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
  75. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
  76. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
  77. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
  78. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/session-management.md +0 -0
  79. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/skills.md +0 -0
  80. {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/ui-redesign-proposal.md +0 -0
  81. {limbo_code-0.1.9 → limbo_code-0.1.10}/pyproject.toml +0 -0
  82. {limbo_code-0.1.9 → limbo_code-0.1.10}/scripts/check_contrast.py +0 -0
  83. {limbo_code-0.1.9 → limbo_code-0.1.10}/scripts/gen_banner.py +0 -0
  84. {limbo_code-0.1.9 → limbo_code-0.1.10}/scripts/ui_walkthrough.py +0 -0
  85. {limbo_code-0.1.9 → limbo_code-0.1.10}/skills-lock.json +0 -0
  86. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/__init__.py +0 -0
  87. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/__main__.py +0 -0
  88. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/app.py +0 -0
  89. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/attachments.py +0 -0
  90. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/compaction.py +0 -0
  91. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/config.py +0 -0
  92. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/__init__.py +0 -0
  93. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/catalog.py +0 -0
  94. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/client.py +0 -0
  95. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/factory.py +0 -0
  96. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/retry.py +0 -0
  97. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/scaffold.py +0 -0
  98. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/usage.py +0 -0
  99. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/model_switch.py +0 -0
  100. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/sessions.py +0 -0
  101. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/skills.py +0 -0
  102. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/steer.py +0 -0
  103. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/__init__.py +0 -0
  104. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/ignore.py +0 -0
  105. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/mutation_queue.py +0 -0
  106. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/write.py +0 -0
  107. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/__init__.py +0 -0
  108. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/app.py +0 -0
  109. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/app.tcss +0 -0
  110. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/banner.py +0 -0
  111. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/clipboard.py +0 -0
  112. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/commands.py +0 -0
  113. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/contrast.py +0 -0
  114. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/__init__.py +0 -0
  115. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/game2048.py +0 -0
  116. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/model_picker.py +0 -0
  117. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/session_picker.py +0 -0
  118. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/syntax.py +0 -0
  119. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/theme.py +0 -0
  120. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/__init__.py +0 -0
  121. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/chat.py +0 -0
  122. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/command_menu.py +0 -0
  123. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/input.py +0 -0
  124. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/status_bar.py +0 -0
  125. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/tool_card.py +0 -0
  126. {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/user_paths.py +0 -0
  127. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/conftest.py +0 -0
  128. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_attachments.py +0 -0
  129. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_catalog.py +0 -0
  130. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_cli.py +0 -0
  131. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_clipboard.py +0 -0
  132. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_compaction.py +0 -0
  133. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_config.py +0 -0
  134. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_integration.py +0 -0
  135. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_llm_scaffold.py +0 -0
  136. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_model_switch.py +0 -0
  137. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_models.py +0 -0
  138. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_prompt.py +0 -0
  139. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_retry.py +0 -0
  140. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_sessions.py +0 -0
  141. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_skills.py +0 -0
  142. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_steer.py +0 -0
  143. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_usage.py +0 -0
  144. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_user_paths.py +0 -0
  145. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_ignore.py +0 -0
  146. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_mutation_queue.py +0 -0
  147. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_registry.py +0 -0
  148. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_write.py +0 -0
  149. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
  150. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
  151. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
  152. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
  153. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_app_smoke.py +0 -0
  154. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_command_menu.py +0 -0
  155. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_commands.py +0 -0
  156. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_compact_ui.py +0 -0
  157. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_contrast.py +0 -0
  158. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_game2048.py +0 -0
  159. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_input_attachments.py +0 -0
  160. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_input_history.py +0 -0
  161. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_input_paste.py +0 -0
  162. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_main_screen.py +0 -0
  163. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_model_picker.py +0 -0
  164. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_scroll_follow.py +0 -0
  165. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_sessions_ui.py +0 -0
  166. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_skills_ui.py +0 -0
  167. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_snapshots.py +0 -0
  168. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_startup_art.py +0 -0
  169. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_steer_ui.py +0 -0
  170. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_theme.py +0 -0
  171. {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_widgets.py +0 -0
@@ -25,6 +25,10 @@ jobs:
25
25
  with:
26
26
  python-version: ${{ matrix.python-version }}
27
27
 
28
+ # grep hard-depends on ripgrep (no Python fallback since LIM-24).
29
+ - name: Install ripgrep
30
+ run: sudo apt-get install -y ripgrep
31
+
28
32
  # Install from uv.lock so CI renders snapshots with the exact same
29
33
  # textual/rich/syrupy versions the baselines were generated with.
30
34
  - name: Install dependencies
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Limbo** is a minimal terminal AI coding agent (Python 3.11+, Textual). Users converse with an LLM in a TUI to explore, read, edit, and write code via 7 tools.
4
4
 
5
- **Tools execute immediately — no confirmation flow.** Guardrails: a workdir fence on file tools (+ session-scoped grants from user-mentioned paths), a sensitive-file blocklist on `read`, and a heuristic dangerous-command filter on `bash` (rejects matches outright; bypassable via subshells/variables — documented in the tool description).
5
+ **Tools execute immediately — no confirmation flow.** Convenience guardrails (not a security boundary): a workdir scope on file tools (+ session-scoped grants from user-mentioned paths), a sensitive-file skip list shared by `read`/`grep`/`find`, and a heuristic dangerous-command filter on `bash` (best-effort, rejects matches outright; `bash` is otherwise not covered by any guardrail).
6
6
 
7
7
  ## Quick Start
8
8
 
@@ -33,7 +33,7 @@ src/limbo/
33
33
  │ # openai_client.py, anthropic_client.py, responses_client.py, retry.py, sse.py,
34
34
  │ # usage.py (token accounting: usage normalization + prompt-size estimation),
35
35
  │ # scaffold.py (plumbing shared by dialect clients: credentials, retry, images)
36
- ├── tools/ # base.py (BaseTool + fence + truncation), registry.py (dispatch, grants),
36
+ ├── tools/ # base.py (BaseTool + path guardrail + truncation), registry.py (dispatch, grants),
37
37
  │ # mutation_queue.py (per-file locks), ignore.py (.gitignore),
38
38
  │ # read/bash/edit/write/grep/find/ls.py
39
39
  └── ui/ # app.py + app.tcss (ALL styles here; theme vars only, no bare hex),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: limbo-code
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: A minimal terminal AI coding agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -226,23 +226,24 @@ flight.
226
226
  ## Safety
227
227
 
228
228
  Limbo executes every tool call immediately, without asking for confirmation.
229
- File tools (`read`, `edit`, `write`, `grep`, `find`, `ls`) are bounded to the
230
- current working directory and reject paths that escape it, including via
231
- symlinks. The boundary check resolves the path before each operation, so a
232
- symlink swapped between the check and the operation (a time-of-check-to-time-of-use
233
- race) could escape the workdir. **This is a known limitation for the MVP.**
229
+
230
+ File tools (`read`, `edit`, `write`, `grep`, `find`, `ls`) are scoped to the
231
+ current working directory plus any session grants (paths the user mentions in
232
+ a message), and `read`/`grep`/`find` skip a small blocklist of sensitive files
233
+ (`.env`, SSH keys) to avoid leaking secrets into the model context by accident.
234
+ **These are convenience guardrails against accidents, not a security boundary.**
235
+ The path check resolves the path before each operation, so a symlink swapped
236
+ between the check and the operation (a time-of-check-to-time-of-use race) could
237
+ escape it.
234
238
 
235
239
  Bash is an exception: it is started in the working directory but is **not**
236
- sandboxed. Commands can `cd ..`, use absolute paths, and read or write outside
237
- the workdir. In addition, commands that match dangerous patterns such as `rm`
238
- or `git reset --hard` are **rejected outright**.
239
- The pattern list is configurable but cannot be disabled from the UI. Bash
240
- commands are filtered with a simple heuristic, but that filter can be bypassed
241
- by subshells (`bash -c 'rm -rf /'`), command substitution (`$(rm -rf /)`),
242
- variable indirection, options before the command name
243
- (`git -C /foo reset --hard`), variable assignments before the command name
244
- (`VAR=1 rm -rf /`), and similar shell constructs. Only run Limbo with
245
- trusted commands and in repositories you can afford to modify or lose.
240
+ covered by any of these guardrails. Commands can `cd ..`, use absolute paths,
241
+ and read or write outside the workdir. Commands that match dangerous patterns
242
+ such as `rm` or `git reset --hard` are **rejected outright** by a best-effort
243
+ heuristic filter — ordinary shell constructs can bypass it, so it only
244
+ protects against accidents. The pattern list is configurable but cannot be
245
+ disabled from the UI. Only run Limbo with trusted commands and in
246
+ repositories you can afford to modify or lose.
246
247
 
247
248
  If you need to work with untrusted projects, disable the bash tool entirely:
248
249
 
@@ -204,23 +204,24 @@ flight.
204
204
  ## Safety
205
205
 
206
206
  Limbo executes every tool call immediately, without asking for confirmation.
207
- File tools (`read`, `edit`, `write`, `grep`, `find`, `ls`) are bounded to the
208
- current working directory and reject paths that escape it, including via
209
- symlinks. The boundary check resolves the path before each operation, so a
210
- symlink swapped between the check and the operation (a time-of-check-to-time-of-use
211
- race) could escape the workdir. **This is a known limitation for the MVP.**
207
+
208
+ File tools (`read`, `edit`, `write`, `grep`, `find`, `ls`) are scoped to the
209
+ current working directory plus any session grants (paths the user mentions in
210
+ a message), and `read`/`grep`/`find` skip a small blocklist of sensitive files
211
+ (`.env`, SSH keys) to avoid leaking secrets into the model context by accident.
212
+ **These are convenience guardrails against accidents, not a security boundary.**
213
+ The path check resolves the path before each operation, so a symlink swapped
214
+ between the check and the operation (a time-of-check-to-time-of-use race) could
215
+ escape it.
212
216
 
213
217
  Bash is an exception: it is started in the working directory but is **not**
214
- sandboxed. Commands can `cd ..`, use absolute paths, and read or write outside
215
- the workdir. In addition, commands that match dangerous patterns such as `rm`
216
- or `git reset --hard` are **rejected outright**.
217
- The pattern list is configurable but cannot be disabled from the UI. Bash
218
- commands are filtered with a simple heuristic, but that filter can be bypassed
219
- by subshells (`bash -c 'rm -rf /'`), command substitution (`$(rm -rf /)`),
220
- variable indirection, options before the command name
221
- (`git -C /foo reset --hard`), variable assignments before the command name
222
- (`VAR=1 rm -rf /`), and similar shell constructs. Only run Limbo with
223
- trusted commands and in repositories you can afford to modify or lose.
218
+ covered by any of these guardrails. Commands can `cd ..`, use absolute paths,
219
+ and read or write outside the workdir. Commands that match dangerous patterns
220
+ such as `rm` or `git reset --hard` are **rejected outright** by a best-effort
221
+ heuristic filter — ordinary shell constructs can bypass it, so it only
222
+ protects against accidents. The pattern list is configurable but cannot be
223
+ disabled from the UI. Only run Limbo with trusted commands and in
224
+ repositories you can afford to modify or lose.
224
225
 
225
226
  If you need to work with untrusted projects, disable the bash tool entirely:
226
227
 
@@ -246,6 +246,16 @@ class Agent:
246
246
  resolved=_resolved_llm_trace_fields(config),
247
247
  )
248
248
 
249
+ def close(self) -> None:
250
+ """Release owned resources (the trace log file handle).
251
+
252
+ Call when this agent is replaced (``/new``, resume) or torn down so
253
+ the append-only trace file descriptor is not leaked across sessions.
254
+ The LLM client is owned by the UI layer and closed separately.
255
+ Idempotent: safe to call more than once.
256
+ """
257
+ self.trace.close()
258
+
249
259
  def update_llm(self, llm_client: LLMClient) -> None:
250
260
  """Swap the LLM client after a /model switch.
251
261
 
@@ -661,9 +671,34 @@ class Agent:
661
671
  # Record results in assistant source order after the batch completes.
662
672
  for tc in tool_calls:
663
673
  result = results[tc["id"]]
664
- self._history.record_result(
665
- tc["id"], result.output or result.error or ""
666
- )
674
+ content, attachments = self._gate_tool_attachments(result)
675
+ self._history.record_result(tc["id"], content, attachments)
676
+
677
+ def _gate_tool_attachments(
678
+ self, result: ToolResult
679
+ ) -> tuple[str, list[Attachment] | None]:
680
+ """Apply the vision gate to tool-result attachments.
681
+
682
+ Mirrors the user-attachment policy (limbo.attachments): image
683
+ payloads reach the model only when it supports vision; otherwise
684
+ they degrade to a path-reference note in the text content so the
685
+ model knows the file exists and where it is. Nothing is silently
686
+ dropped.
687
+ """
688
+ content = result.output or result.error or ""
689
+ images = [
690
+ a for a in (result.attachments or []) if a.kind == "image"
691
+ ]
692
+ if not images:
693
+ return content, None
694
+ if self._vision:
695
+ return content, result.attachments
696
+ notes = "\n".join(
697
+ f"[图片 {a.name} 位于 {a.path};当前模型不支持图像输入,无法直接查看]"
698
+ for a in images
699
+ )
700
+ content = f"{content}\n{notes}" if content else notes
701
+ return content, None
667
702
 
668
703
  async def _execute_tool_safe(
669
704
  self,
@@ -6,7 +6,7 @@ Owns the invariant that keeps the OpenAI API happy: every assistant
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from limbo.models import Message
9
+ from limbo.models import Attachment, Message
10
10
 
11
11
  INTERRUPTED_CONTENT = "[session restored: tool call interrupted]"
12
12
 
@@ -17,15 +17,32 @@ class ToolHistory:
17
17
  def __init__(self, messages: list[Message]):
18
18
  self.messages = messages
19
19
 
20
- def record_result(self, tool_call_id: str, content: str) -> None:
21
- """Replace the existing result for a call, or append a new one."""
20
+ def record_result(
21
+ self,
22
+ tool_call_id: str,
23
+ content: str,
24
+ attachments: list[Attachment] | None = None,
25
+ ) -> None:
26
+ """Replace the existing result for a call, or append a new one.
27
+
28
+ ``attachments`` carries image payloads from tools (e.g. read on an
29
+ image file); they are stored on the tool message and replayed as
30
+ multimodal blocks by the clients, same as user-message attachments.
31
+ """
22
32
  for idx, msg in enumerate(self.messages):
23
33
  if msg.role == "tool" and msg.tool_call_id == tool_call_id:
24
- self.messages[idx] = msg.model_copy(update={"content": content})
34
+ self.messages[idx] = msg.model_copy(
35
+ update={"content": content, "attachments": attachments}
36
+ )
25
37
  break
26
38
  else:
27
39
  self.messages.append(
28
- Message(role="tool", content=content, tool_call_id=tool_call_id)
40
+ Message(
41
+ role="tool",
42
+ content=content,
43
+ tool_call_id=tool_call_id,
44
+ attachments=attachments,
45
+ )
29
46
  )
30
47
 
31
48
 
@@ -274,7 +274,7 @@ def _messages_to_anthropic(
274
274
  block = {
275
275
  "type": "tool_result",
276
276
  "tool_use_id": message.tool_call_id or "",
277
- "content": message.content or "",
277
+ "content": _tool_result_content(message),
278
278
  }
279
279
  previous = converted[-1] if converted else None
280
280
  if previous is not None and previous.get("role") == "user" and isinstance(
@@ -287,8 +287,8 @@ def _messages_to_anthropic(
287
287
  return "\n\n".join(system_parts), converted
288
288
 
289
289
 
290
- def _user_content(message: Message) -> str | list[dict[str, Any]]:
291
- """User content: plain string, or image blocks + text when attached.
290
+ def _image_blocks(message: Message) -> list[dict[str, Any]]:
291
+ """Anthropic image blocks for a message's attachments.
292
292
 
293
293
  Missing image files (expired session attachments) are skipped so a
294
294
  restored session can still be replayed; the marker text in ``content``
@@ -312,6 +312,26 @@ def _user_content(message: Message) -> str | list[dict[str, Any]]:
312
312
  },
313
313
  }
314
314
  )
315
+ return blocks
316
+
317
+
318
+ def _user_content(message: Message) -> str | list[dict[str, Any]]:
319
+ """User content: plain string, or image blocks + text when attached."""
320
+ blocks = _image_blocks(message)
321
+ if not blocks:
322
+ return message.content or ""
323
+ blocks.append({"type": "text", "text": message.content or ""})
324
+ return blocks
325
+
326
+
327
+ def _tool_result_content(message: Message) -> str | list[dict[str, Any]]:
328
+ """tool_result content: plain string, or image blocks + text.
329
+
330
+ A tool result carries attachments when a tool returned an image
331
+ payload (e.g. read on an image file); Anthropic accepts image blocks
332
+ inside tool_result content.
333
+ """
334
+ blocks = _image_blocks(message)
315
335
  if not blocks:
316
336
  return message.content or ""
317
337
  blocks.append({"type": "text", "text": message.content or ""})
@@ -132,10 +132,11 @@ class OpenAICompatibleClient:
132
132
  ) -> AsyncIterator[LLMEvent]:
133
133
  """One streaming attempt: build the request and yield its events."""
134
134
  include_reasoning = self.spec.requires_reasoning_content
135
- request_messages = [
136
- _message_to_openai(m, include_reasoning=include_reasoning)
137
- for m in messages
138
- ]
135
+ request_messages = _messages_to_openai(
136
+ messages,
137
+ include_reasoning=include_reasoning,
138
+ vision=self.spec.vision,
139
+ )
139
140
  kwargs: dict[str, Any] = {
140
141
  "model": self.config.llm.model,
141
142
  "messages": request_messages,
@@ -258,6 +259,83 @@ def _is_stream_options_error(error: BadRequestError) -> bool:
258
259
  return "stream_options" in message or "include_usage" in message
259
260
 
260
261
 
262
+ def _messages_to_openai(
263
+ messages: list[Message], *, include_reasoning: bool = False, vision: bool = True
264
+ ) -> list[dict[str, Any]]:
265
+ """Convert internal messages to chat-completions params, pi-style.
266
+
267
+ The chat completions API rejects ``image_url`` parts in tool messages,
268
+ so tool results stay text-only; images a tool returned (e.g. read on
269
+ an image file) are collected across each consecutive run of tool
270
+ messages and appended as ONE synthetic user message afterwards
271
+ (``"Attached image(s) from tool result:"`` + image_url blocks), the
272
+ same shape pi uses. Images are dropped entirely when the current
273
+ model lacks vision (possible after a mid-session /model switch).
274
+ """
275
+ params: list[dict[str, Any]] = []
276
+ pending_images: list[dict[str, Any]] = []
277
+
278
+ def flush_images() -> None:
279
+ if pending_images:
280
+ params.append(
281
+ {
282
+ "role": "user",
283
+ "content": [
284
+ {
285
+ "type": "text",
286
+ "text": "Attached image(s) from tool result:",
287
+ },
288
+ *pending_images,
289
+ ],
290
+ }
291
+ )
292
+ pending_images.clear()
293
+
294
+ for message in messages:
295
+ if message.role == "tool":
296
+ params.append(
297
+ _tool_message_to_openai(message, pending_images, vision=vision)
298
+ )
299
+ continue
300
+ flush_images()
301
+ params.append(
302
+ _message_to_openai(message, include_reasoning=include_reasoning)
303
+ )
304
+ flush_images()
305
+ return params
306
+
307
+
308
+ def _tool_message_to_openai(
309
+ message: Message, images_out: list[dict[str, Any]], *, vision: bool
310
+ ) -> dict[str, Any]:
311
+ """Text-only tool message; its image blocks are collected into ``images_out``."""
312
+ has_images = False
313
+ for attachment in message.attachments or []:
314
+ if attachment.kind != "image":
315
+ continue
316
+ encoded = encode_image_data(attachment) if vision else None
317
+ if encoded is None:
318
+ continue
319
+ has_images = True
320
+ data, mime = encoded
321
+ images_out.append(
322
+ {
323
+ "type": "image_url",
324
+ "image_url": {"url": f"data:{mime};base64,{data}"},
325
+ }
326
+ )
327
+ content = message.content or ""
328
+ if not content:
329
+ # Placeholders keep the API contract (non-empty tool content) even
330
+ # for image-only or empty results.
331
+ content = "(see attached image)" if has_images else "(no tool output)"
332
+ return {
333
+ "role": "tool",
334
+ "content": content,
335
+ "tool_call_id": message.tool_call_id or "",
336
+ }
337
+
338
+
261
339
  def _message_to_openai(
262
340
  message: Message, *, include_reasoning: bool = False
263
341
  ) -> dict[str, Any]:
@@ -316,32 +316,52 @@ def _messages_to_responses(
316
316
  {
317
317
  "type": "function_call_output",
318
318
  "call_id": message.tool_call_id or "",
319
- "output": message.content or "",
319
+ "output": _tool_output(message),
320
320
  }
321
321
  )
322
322
 
323
323
  return "\n\n".join(system_parts), items
324
324
 
325
325
 
326
- def _user_content(message: Message) -> list[dict[str, Any]]:
326
+ def _image_blocks(message: Message) -> list[dict[str, Any]]:
327
+ """input_image blocks for a message's attachments.
328
+
329
+ Missing files (expired session attachments) are skipped.
330
+ """
327
331
  blocks: list[dict[str, Any]] = []
328
- if message.attachments:
329
- # Multimodal user message: input_image blocks + text. Missing files
330
- # (expired session attachments) are skipped.
331
- for attachment in message.attachments:
332
- if attachment.kind != "image":
333
- continue
334
- encoded = encode_image_data(attachment)
335
- if encoded is None:
336
- continue
337
- data, mime = encoded
338
- blocks.append(
339
- {
340
- "type": "input_image",
341
- "detail": "auto",
342
- "image_url": f"data:{mime};base64,{data}",
343
- }
344
- )
332
+ for attachment in message.attachments or []:
333
+ if attachment.kind != "image":
334
+ continue
335
+ encoded = encode_image_data(attachment)
336
+ if encoded is None:
337
+ continue
338
+ data, mime = encoded
339
+ blocks.append(
340
+ {
341
+ "type": "input_image",
342
+ "detail": "auto",
343
+ "image_url": f"data:{mime};base64,{data}",
344
+ }
345
+ )
346
+ return blocks
347
+
348
+
349
+ def _user_content(message: Message) -> list[dict[str, Any]]:
350
+ blocks = _image_blocks(message)
351
+ blocks.append({"type": "input_text", "text": message.content or ""})
352
+ return blocks
353
+
354
+
355
+ def _tool_output(message: Message) -> str | list[dict[str, Any]]:
356
+ """function_call_output payload: plain string, or content parts.
357
+
358
+ A tool result carries attachments when a tool returned an image
359
+ payload (e.g. read on an image file); the Responses API accepts
360
+ input_image parts inside function call output.
361
+ """
362
+ blocks = _image_blocks(message)
363
+ if not blocks:
364
+ return message.content or ""
345
365
  blocks.append({"type": "input_text", "text": message.content or ""})
346
366
  return blocks
347
367
 
@@ -0,0 +1,49 @@
1
+ """Shared SSE parsing for the plain-httpx LLM clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import AsyncIterator
7
+ from typing import Any, cast
8
+
9
+ import httpx
10
+
11
+
12
+ async def iter_sse(resp: httpx.Response) -> AsyncIterator[dict[str, Any]]:
13
+ """Yield parsed ``data:`` payloads from an SSE stream.
14
+
15
+ ``event:``/``id:``/``retry:`` lines and comments (``: ping``) are
16
+ ignored; the payload's own ``type`` field drives dispatch. Both the
17
+ Anthropic Messages and OpenAI Responses streams follow this convention.
18
+
19
+ A final flush after the stream ends catches a trailing event that the
20
+ server closed without a terminating blank line — that last event often
21
+ carries ``usage`` / ``finish_reason``, so dropping it would desync the
22
+ compaction trigger and token counters.
23
+ """
24
+ data_lines: list[str] = []
25
+
26
+ def _flush() -> dict[str, Any] | None:
27
+ if not data_lines:
28
+ return None
29
+ payload = "\n".join(data_lines)
30
+ data_lines.clear()
31
+ try:
32
+ return cast(dict[str, Any], json.loads(payload))
33
+ except json.JSONDecodeError:
34
+ return None
35
+
36
+ async for line in resp.aiter_lines():
37
+ if not line:
38
+ event = _flush()
39
+ if event is not None:
40
+ yield event
41
+ continue
42
+ if line.startswith("data:"):
43
+ data_lines.append(line[len("data:"):].strip())
44
+ # The server may close the connection without a trailing blank line; a
45
+ # buffered event would otherwise be lost. Anthropic/OpenAI both put
46
+ # usage + finish_reason in this final event.
47
+ event = _flush()
48
+ if event is not None:
49
+ yield event
@@ -65,11 +65,19 @@ class ToolCall(BaseModel):
65
65
 
66
66
 
67
67
  class ToolResult(BaseModel):
68
- """Result of executing a tool."""
68
+ """Result of executing a tool.
69
+
70
+ ``attachments`` carries image payloads a tool wants the model to see
71
+ (e.g. read returning an image file). Only the path is persisted —
72
+ never bytes — same policy as user-message attachments, so restored
73
+ sessions may reference files that no longer exist; clients skip
74
+ missing images when replaying.
75
+ """
69
76
 
70
77
  success: bool
71
78
  output: str | None = None
72
79
  error: str | None = None
80
+ attachments: list[Attachment] | None = None
73
81
 
74
82
 
75
83
  @dataclass(frozen=True)
@@ -25,7 +25,7 @@ _BASE_INTRO = (
25
25
 
26
26
  _GUIDELINES_TAIL = (
27
27
  "- Use read to examine files before editing\n"
28
- "- Use edit for precise changes (old_text must match exactly)\n"
28
+ "- Use edit for precise changes (edits[].old_text must match exactly)\n"
29
29
  "- Use write only for new files or complete rewrites\n"
30
30
  "- Be concise in your responses\n"
31
31
  "- Show file paths clearly when working with files"
@@ -1,13 +1,20 @@
1
1
  """Base class and shared helpers for tools.
2
2
 
3
- The base module owns the rituals every tool used to repeat: workdir-safe
3
+ The base module owns the rituals every tool used to repeat: workdir-scoped
4
4
  path resolution (raising ``ToolError`` instead of returning union types)
5
5
  and the output truncation policy.
6
+
7
+ The workdir scope and the sensitive-file list are *convenience guardrails*,
8
+ not a security boundary: they keep the model from wandering outside the
9
+ project or pulling secrets into the context by accident, and the user can
10
+ widen the scope by mentioning paths in a message (session-scoped grants).
11
+ ``bash`` is intentionally not covered by either guardrail.
6
12
  """
7
13
 
8
14
  from __future__ import annotations
9
15
 
10
16
  from abc import ABC, abstractmethod
17
+ from collections.abc import Collection
11
18
  from dataclasses import dataclass
12
19
  from pathlib import Path
13
20
  from typing import Any
@@ -59,11 +66,11 @@ class BaseTool(ABC):
59
66
  # -- path resolution -------------------------------------------------------
60
67
 
61
68
  def resolve(self, raw_path: str, *, strict: bool = True) -> Path:
62
- """Resolve ``raw_path``, enforcing the workdir/allowed-roots boundary.
69
+ """Resolve ``raw_path`` against the workdir/allowed-roots guardrail.
63
70
 
64
71
  ``~`` is expanded and absolute paths are honored as-is; relative
65
72
  paths resolve under the workdir. Raises ``ToolError`` for paths
66
- outside the boundary, unresolvable paths, and (when ``strict``)
73
+ outside the guardrail, unresolvable paths, and (when ``strict``)
67
74
  broken symlinks.
68
75
  """
69
76
  try:
@@ -80,7 +87,9 @@ class BaseTool(ABC):
80
87
  if not self.is_within_scope(target):
81
88
  raise ToolError(
82
89
  f"Path is outside working directory ({self.workdir}). "
83
- "Use bash to access paths outside the working directory."
90
+ "This scope is a convenience guardrail, not a security "
91
+ "boundary. If access is genuinely needed, ask the user to "
92
+ "mention the path in a message to grant it."
84
93
  )
85
94
 
86
95
  if strict and candidate.is_symlink() and not target.exists():
@@ -116,6 +125,17 @@ class BaseTool(ABC):
116
125
  """Resolve a path that may not exist yet (e.g. for writing)."""
117
126
  return self.resolve(raw_path, strict=False)
118
127
 
128
+ def is_sensitive_path(path: Path, sensitive_files: Collection[str]) -> bool:
129
+ """True if ``path``'s name or any of its parts is on the sensitive list.
130
+
131
+ Convenience guardrail against accidentally pulling secrets (``.env``,
132
+ SSH keys) into the model context — not a security boundary (``bash``
133
+ is not covered by it). Shared by ``read``/``grep``/``find`` so the
134
+ guardrail behaves identically across file tools.
135
+ """
136
+ return any(part in sensitive_files for part in path.parts)
137
+
138
+
119
139
  def is_within_workdir(path: Path, workdir: Path) -> bool:
120
140
  """Return True if resolved path is inside or equal to workdir."""
121
141
  try: