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.
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.github/workflows/test.yml +4 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/AGENTS.md +2 -2
- {limbo_code-0.1.9 → limbo_code-0.1.10}/PKG-INFO +17 -16
- {limbo_code-0.1.9 → limbo_code-0.1.10}/README.md +16 -15
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/agent.py +38 -3
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/history.py +22 -5
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/anthropic_client.py +23 -3
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/openai_client.py +82 -4
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/responses_client.py +39 -19
- limbo_code-0.1.10/src/limbo/llm/sse.py +49 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/models.py +9 -1
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/prompt.py +1 -1
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/base.py +24 -4
- limbo_code-0.1.10/src/limbo/tools/bash.py +393 -0
- limbo_code-0.1.10/src/limbo/tools/edit.py +253 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/find.py +28 -0
- limbo_code-0.1.10/src/limbo/tools/grep.py +147 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/ls.py +24 -3
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/read.py +69 -7
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/registry.py +16 -3
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/trace.py +10 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/main.py +5 -1
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_agent.py +94 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_anthropic_client.py +38 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_history.py +27 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_llm_client.py +99 -1
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_responses_client.py +44 -0
- limbo_code-0.1.10/tests/test_sse.py +92 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_trace.py +13 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_base.py +5 -2
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_bash.py +70 -2
- limbo_code-0.1.10/tests/tools/test_edit.py +289 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_find.py +37 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_grep.py +45 -37
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_ls.py +19 -1
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_read.py +66 -0
- limbo_code-0.1.10/uv.lock +936 -0
- limbo_code-0.1.9/src/limbo/llm/sse.py +0 -31
- limbo_code-0.1.9/src/limbo/tools/bash.py +0 -204
- limbo_code-0.1.9/src/limbo/tools/edit.py +0 -74
- limbo_code-0.1.9/src/limbo/tools/grep.py +0 -190
- limbo_code-0.1.9/tests/tools/test_edit.py +0 -130
- limbo_code-0.1.9/uv.lock +0 -936
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/grill-with-docs/SKILL.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/SKILL.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/agents/openai.yaml +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/mocking.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.agents/skills/tdd/tests.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.github/workflows/publish.yml +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/.gitignore +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/CONTEXT.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/confirm_view.html +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/limbo-ui-minimal.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/limbo-ui-redesign.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype-minimal-confirm.png +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype-minimal.html +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype-minimal.png +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype.html +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/design/prototype.png +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/limbo-current-ui.png +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/limbo-new-ui.png +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/session-management.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/skills.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/docs/ui-redesign-proposal.md +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/pyproject.toml +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/scripts/check_contrast.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/scripts/gen_banner.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/scripts/ui_walkthrough.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/skills-lock.json +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/__init__.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/__main__.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/app.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/attachments.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/compaction.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/config.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/__init__.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/catalog.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/client.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/factory.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/retry.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/scaffold.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/llm/usage.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/model_switch.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/sessions.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/skills.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/steer.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/__init__.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/ignore.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/mutation_queue.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/tools/write.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/__init__.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/app.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/app.tcss +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/banner.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/clipboard.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/commands.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/contrast.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/__init__.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/game2048.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/model_picker.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/screens/session_picker.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/syntax.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/theme.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/__init__.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/chat.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/command_menu.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/input.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/status_bar.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/ui/widgets/tool_card.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/src/limbo/user_paths.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/conftest.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_attachments.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_catalog.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_cli.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_clipboard.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_compaction.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_config.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_integration.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_llm_scaffold.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_model_switch.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_models.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_prompt.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_retry.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_sessions.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_skills.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_steer.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_usage.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/test_user_paths.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_ignore.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_mutation_queue.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_registry.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/tools/test_write.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
- {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
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_app_smoke.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_command_menu.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_commands.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_compact_ui.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_contrast.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_game2048.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_input_attachments.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_input_history.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_input_paste.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_main_screen.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_model_picker.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_scroll_follow.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_sessions_ui.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_skills_ui.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_snapshots.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_startup_art.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_steer_ui.py +0 -0
- {limbo_code-0.1.9 → limbo_code-0.1.10}/tests/ui/test_theme.py +0 -0
- {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.**
|
|
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 +
|
|
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.
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
the workdir.
|
|
238
|
-
or `git reset --hard` are **rejected outright
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
the workdir.
|
|
216
|
-
or `git reset --hard` are **rejected outright
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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.
|
|
665
|
-
|
|
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(
|
|
21
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
291
|
-
"""
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
{
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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:
|