lcode-cli 0.4.1__tar.gz → 0.5.0__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 (90) hide show
  1. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/CHANGELOG.md +14 -1
  2. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/PKG-INFO +3 -2
  3. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/README.md +2 -1
  4. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/SECURITY.md +7 -2
  5. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/configuration.md +4 -0
  6. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/how-it-works.md +1 -0
  7. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/quickstart.md +1 -1
  8. lcode_cli-0.5.0/docs/sandbox.md +79 -0
  9. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/usage.md +8 -2
  10. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/mkdocs.yml +1 -0
  11. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/__init__.py +1 -1
  12. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/agent.py +18 -0
  13. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/cli.py +24 -0
  14. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/config.py +10 -0
  15. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/repl.py +33 -0
  16. lcode_cli-0.5.0/src/lcode/sandbox.py +204 -0
  17. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/tools.py +60 -26
  18. lcode_cli-0.5.0/tests/test_sandbox.py +166 -0
  19. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.editorconfig +0 -0
  20. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/CODEOWNERS +0 -0
  21. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  22. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  23. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  24. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  25. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  26. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/dependabot.yml +0 -0
  27. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/workflows/ci.yml +0 -0
  28. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/workflows/docs.yml +0 -0
  29. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.github/workflows/release.yml +0 -0
  30. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.gitignore +0 -0
  31. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/.pre-commit-config.yaml +0 -0
  32. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/AGENTS.md +0 -0
  33. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  34. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/CONTRIBUTING.md +0 -0
  35. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/LICENSE +0 -0
  36. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/demo.svg +0 -0
  37. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/extra.css +0 -0
  38. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/logo.svg +0 -0
  39. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/assets/social-preview.png +0 -0
  40. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/bench.md +0 -0
  41. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/changelog.md +0 -0
  42. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/contributing.md +0 -0
  43. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/index.md +0 -0
  44. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/installation.md +0 -0
  45. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/mcp.md +0 -0
  46. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/models.md +0 -0
  47. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/docs/troubleshooting.md +0 -0
  48. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/install.sh +0 -0
  49. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/overrides/main.html +0 -0
  50. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/pyproject.toml +0 -0
  51. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/scripts/record_demo.py +0 -0
  52. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/scripts/social-preview.html +0 -0
  53. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/__main__.py +0 -0
  54. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/bench.py +0 -0
  55. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/catalog.py +0 -0
  56. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/checkpoints.py +0 -0
  57. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/hardware.py +0 -0
  58. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/limits.py +0 -0
  59. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/__init__.py +0 -0
  60. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/auth.py +0 -0
  61. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/catalog.py +0 -0
  62. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/catalog.toml +0 -0
  63. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/client.py +0 -0
  64. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/commands.py +0 -0
  65. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/config.py +0 -0
  66. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/http.py +0 -0
  67. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/manager.py +0 -0
  68. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/protocol.py +0 -0
  69. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/mcp/stdio.py +0 -0
  70. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/models.toml +0 -0
  71. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/ollama.py +0 -0
  72. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/permissions.py +0 -0
  73. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/render.py +0 -0
  74. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/sessions.py +0 -0
  75. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/src/lcode/web.py +0 -0
  76. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/conftest.py +0 -0
  77. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/fake_mcp_server.py +0 -0
  78. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_agent.py +0 -0
  79. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_bench.py +0 -0
  80. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_catalog.py +0 -0
  81. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_checkpoints.py +0 -0
  82. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_cli.py +0 -0
  83. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_config.py +0 -0
  84. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_context_picker.py +0 -0
  85. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_mcp.py +0 -0
  86. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_permissions.py +0 -0
  87. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_sessions.py +0 -0
  88. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_tools.py +0 -0
  89. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/tests/test_web.py +0 -0
  90. {lcode_cli-0.4.1 → lcode_cli-0.5.0}/uv.lock +0 -0
@@ -6,6 +6,18 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-01
10
+
11
+ ### Added
12
+
13
+ - An optional sandbox for shell commands: with `lcode --sandbox` (or `sandbox = "docker"` /
14
+ `"podman"`), the model's commands run in a container that only sees the project folder, as your
15
+ user, with no network access unless you allow it (`/sandbox network on`, `sandbox_network`), no
16
+ Linux capabilities and a process limit. The file tools are limited to the project too. lcode builds
17
+ a default image with Python, Node.js, git and build tools on first use (`sandbox_image` for your
18
+ own). In `auto-edit` mode, sandboxed commands run without asking. If the sandbox can't start, lcode
19
+ stops instead of running commands without it.
20
+
9
21
  ## [0.4.1] - 2026-10-01
10
22
 
11
23
  ### Added
@@ -174,7 +186,8 @@ First public release.
174
186
  - `AGENTS.md` project instructions and `/init` to generate them.
175
187
  - One-line installer for Linux and macOS.
176
188
 
177
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.4.1...HEAD
189
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.5.0...HEAD
190
+ [0.5.0]: https://github.com/nasser1941/lcode/compare/v0.4.1...v0.5.0
178
191
  [0.4.1]: https://github.com/nasser1941/lcode/compare/v0.4.0...v0.4.1
179
192
  [0.4.0]: https://github.com/nasser1941/lcode/compare/v0.3.1...v0.4.0
180
193
  [0.3.1]: https://github.com/nasser1941/lcode/compare/v0.3.0...v0.3.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -78,6 +78,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
78
78
  real coding tasks on your machine.
79
79
  - **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
80
80
  your approval. `auto-edit` and `yolo` modes when you want speed.
81
+ - **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
82
+ Podman) that only sees your project, without network access unless you allow it.
81
83
  - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
82
84
  request's edits, new files and shell-command changes, without touching your git history.
83
85
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
@@ -184,7 +186,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
184
186
 
185
187
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
186
188
 
187
- - An [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
188
189
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
189
190
 
190
191
  ## Contributing
@@ -44,6 +44,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
44
44
  real coding tasks on your machine.
45
45
  - **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
46
46
  your approval. `auto-edit` and `yolo` modes when you want speed.
47
+ - **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
48
+ Podman) that only sees your project, without network access unless you allow it.
47
49
  - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
48
50
  request's edits, new files and shell-command changes, without touching your git history.
49
51
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
@@ -150,7 +152,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
150
152
 
151
153
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
152
154
 
153
- - An [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
154
155
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
155
156
 
156
157
  ## Contributing
@@ -25,8 +25,13 @@ user, in your working directory**. It is not a sandbox. Keep in mind:
25
25
 
26
26
  - In the default `ask` mode, every file edit and every shell command that isn't on the read-only
27
27
  allowlist (`ls`, `cat`, `grep`, `git status`, …) is shown to you and needs your approval.
28
- `auto-edit` skips approval for edits; `yolo` skips all approval — use it only in disposable
29
- environments (a container, a VM, a throwaway clone).
28
+ `auto-edit` skips approval for edits; `yolo` skips all approval — use it only with the sandbox or
29
+ in disposable environments (a VM, a throwaway clone).
30
+ - The optional sandbox (`sandbox = "docker"` or `"podman"`, or `lcode --sandbox`) runs shell commands
31
+ in a container that sees only the project folder, as your user, without network access unless
32
+ allowed. The file tools are then limited to the project too. It doesn't cover web or MCP tools,
33
+ the project folder itself stays writable, and a container shares the host kernel; see
34
+ https://nasser1941.github.io/lcode/sandbox/ for its limits.
30
35
  - Content the model reads (files, command output, search results and web pages) can contain prompt
31
36
  injections that try to make it run harmful commands. lcode marks web content as untrusted, but
32
37
  review commands before approving them.
@@ -25,6 +25,9 @@ lcode config path # print the file location
25
25
  | `web` | `on` | Web search and page fetching: `on`, `ask` (before each search/website) or `off` |
26
26
  | `search_backend` | `auto` | `auto`, `ollama`, `brave`, `tavily` or `searxng`; `auto` uses the first one configured |
27
27
  | `searxng_url` | | Your SearXNG instance, e.g. `http://localhost:8888` |
28
+ | `sandbox` | `off` | Run shell commands in a container: `docker` or `podman` ([Sandbox](sandbox.md)) |
29
+ | `sandbox_image` | lcode's image | Container image for the sandbox; any image with bash and setsid |
30
+ | `sandbox_network` | `false` | Let commands in the sandbox use the network |
28
31
  | `mcp_tools` | `auto` | How [MCP](mcp.md#context) tool definitions reach the model: `auto`, `direct` or `search` (on demand) |
29
32
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
30
33
 
@@ -50,6 +53,7 @@ Environment variables override the file, which is useful for one-off runs and CI
50
53
  | `LCODE_KEEP_ALIVE` | `keep_alive` |
51
54
  | `OLLAMA_HOST` | `ollama_host` (same variable the Ollama CLI uses) |
52
55
  | `LCODE_WEB` | `web` |
56
+ | `LCODE_SANDBOX` | `sandbox` |
53
57
  | `OLLAMA_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY` | API key for that [search provider](usage.md#web-search) (never stored in the config file) |
54
58
  | `LCODE_HOME` | where sessions and prompt history are stored |
55
59
 
@@ -77,6 +77,7 @@ the Ollama server you configure, it only contacts the web when the model searche
77
77
  | `permissions.py` | Approval prompts and the read-only allowlist |
78
78
  | `mcp/` | MCP client: stdio and HTTP transports, OAuth sign-in, server settings, the catalog, `lcode mcp` |
79
79
  | `bench.py` | `lcode bench`: the benchmark tasks, their checks and the reports |
80
+ | `sandbox.py` | The optional container for shell commands |
80
81
  | `checkpoints.py` | Snapshots before the model changes files, for `/undo` and `/rewind` |
81
82
  | `catalog.py`, `models.toml` | Model catalog and memory estimates |
82
83
  | `hardware.py` | NVIDIA / Apple Silicon detection |
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.4.1 — local coding agent │
15
+ │ lcode v0.5.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -0,0 +1,79 @@
1
+ # Sandbox
2
+
3
+ By default the model's shell commands run directly on your machine, as you. The permission prompts
4
+ let you check each command, but they're a guardrail, not isolation, and `yolo` mode removes even
5
+ that. The sandbox runs the commands in a container that can only see your project:
6
+
7
+ ```bash
8
+ lcode --sandbox # this session
9
+ lcode config set sandbox docker # every session (or podman)
10
+ ```
11
+
12
+ The session's banner then shows `sandbox docker · lcode's image · no network`.
13
+
14
+ ## What the container can and can't do
15
+
16
+ | | |
17
+ |---|---|
18
+ | **Sees** | Your project: the git repository you started lcode in (or the folder itself, outside a repository), mounted at the same path, read-write |
19
+ | **Doesn't see** | Anything else on your machine: your home folder, SSH keys, cloud credentials, other projects, the Docker socket |
20
+ | **Network** | None, unless you allow it (`/sandbox network on`, or `sandbox_network = true`) |
21
+ | **Runs as** | Your user ID, so files it creates belong to you; no Linux capabilities, no privilege escalation, at most 1,024 processes |
22
+ | **Lives** | For the session: started when lcode starts, removed when it exits. Containers left behind by a crash are removed the next time |
23
+
24
+ lcode's file tools (reading, editing, searching) run on your machine, but with the sandbox on they're
25
+ limited to the same project folder, so the model's whole world is the project.
26
+
27
+ ## Approvals
28
+
29
+ With the sandbox on, `auto-edit` mode runs shell commands without asking too, since they can only
30
+ change the project, and [`/undo`](usage.md#undo-and-checkpoints) can take those changes back. `ask`
31
+ mode still asks before every command that isn't read-only. `yolo` asks for nothing, with or without
32
+ the sandbox.
33
+
34
+ ## The image
35
+
36
+ lcode builds its default image the first time you use the sandbox (a minute or two): Python 3.12,
37
+ Node.js, git, build tools, ripgrep, curl and jq. Use your own image to match your project's toolchain:
38
+
39
+ ```bash
40
+ lcode config set sandbox_image node:22
41
+ ```
42
+
43
+ Any image with `bash` and `setsid` works. Installing packages (`pip install`, `npm install`) needs
44
+ the network: allow it with `/sandbox network on` for the session, or `sandbox_network = true`. The
45
+ model is told when a command failed because there's no network.
46
+
47
+ ## Settings
48
+
49
+ | Setting | Default | |
50
+ |---|---|---|
51
+ | `sandbox` | `off` | `docker` or `podman` to run commands in a container |
52
+ | `sandbox_image` | lcode's image | Any image with bash and setsid |
53
+ | `sandbox_network` | `false` | Let commands use the network |
54
+
55
+ In a session, `/sandbox` shows the sandbox and `/sandbox network on|off` changes network access (the
56
+ container restarts; files in the project stay). `lcode doctor` checks that Docker or Podman works.
57
+
58
+ ## Requirements
59
+
60
+ - **Linux:** [Docker Engine](https://docs.docker.com/engine/install/) (your user in the `docker`
61
+ group) or [Podman](https://podman.io/docs/installation) (rootless; lcode passes `--userns keep-id`.
62
+ Podman is less tested than Docker, so reports are welcome).
63
+ - **macOS:** Docker Desktop, [OrbStack](https://orbstack.dev) or [colima](https://github.com/abiosoft/colima).
64
+ Your project must be in a folder they share with containers (your home folder is, by default).
65
+
66
+ If the sandbox is on but can't start, lcode stops instead of running commands without it, and says
67
+ how to fix it.
68
+
69
+ ## What the sandbox doesn't cover
70
+
71
+ It isolates **shell commands**. Know its limits:
72
+
73
+ - The project folder is writable: a command can still delete or damage your project's files. Use git,
74
+ and `/undo` for changes since the request started.
75
+ - With the network on, commands can reach the internet, including to send out anything in the project.
76
+ - [Web search and page fetching](usage.md#web-search) and [MCP tools](mcp.md) run on your machine,
77
+ not in the container. They still need their own approval (MCP) or can be turned off (`--no-web`).
78
+ - A container is not a virtual machine: it shares your machine's kernel, so a kernel vulnerability
79
+ could let a process escape. For untrusted code you can't afford to run, use a separate VM.
@@ -25,6 +25,7 @@ lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases
25
25
  | `--no-think` | Turn off the model's reasoning: faster, less accurate |
26
26
  | `--no-web` | No web search or page fetching in this session |
27
27
  | `--no-mcp` | Don't start [MCP servers](mcp.md) in this session |
28
+ | `--sandbox` | Run shell commands in a container that only sees the project ([Sandbox](sandbox.md)) |
28
29
  | `--show-thinking` | Print the model's reasoning as it streams |
29
30
  | `-V, --version` | Print the version |
30
31
 
@@ -55,6 +56,7 @@ reasoning is on.
55
56
  | `/undo` | Undo the file changes of the last request ([details](#undo-and-checkpoints)) |
56
57
  | `/rewind [N]` | Go back to before request N: its files, and optionally the conversation |
57
58
  | `/checkpoints` | List the requests that changed files, and which files |
59
+ | `/sandbox [network on\|off]` | The [sandbox](sandbox.md) for shell commands |
58
60
  | `/mcp [tools\|login\|restart NAME]` | [MCP servers](mcp.md), their status and tools |
59
61
  | `/compact [focus]` | Summarize the conversation to free context |
60
62
  | `/context [size]` | Show how full the context window is and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
@@ -101,8 +103,12 @@ or ++n++. Text after ++n++ goes to the model as instructions: `n run the tests w
101
103
 
102
104
  !!! warning "yolo mode"
103
105
 
104
- In `yolo` mode the model can run any command as your user. Use it in a container, a VM or a
105
- throwaway clone, not on a machine with data you care about.
106
+ In `yolo` mode the model can run any command as your user. Use it with the
107
+ [sandbox](sandbox.md), in a VM or in a throwaway clone, not on a machine with data you care
108
+ about.
109
+
110
+ With the [sandbox](sandbox.md) on, `auto-edit` mode runs shell commands without asking too: they can
111
+ only reach the project, and `/undo` can take their changes back.
106
112
 
107
113
  ## Undo and checkpoints
108
114
 
@@ -95,6 +95,7 @@ nav:
95
95
  - Models & context windows: models.md
96
96
  - Benchmarking models: bench.md
97
97
  - MCP servers: mcp.md
98
+ - Sandbox: sandbox.md
98
99
  - Configuration: configuration.md
99
100
  - Reference:
100
101
  - How it works: how-it-works.md
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.5.0"
@@ -26,6 +26,7 @@ from lcode.mcp import McpManager
26
26
  from lcode.ollama import Ollama, OllamaError
27
27
  from lcode.permissions import Permissions
28
28
  from lcode.render import MarkdownStreamer
29
+ from lcode.sandbox import Sandbox, SandboxError, project_root
29
30
  from lcode.tools import (
30
31
  SCHEMAS,
31
32
  WEB_FETCH_SCHEMA,
@@ -140,6 +141,9 @@ class Settings:
140
141
  search_backend: str = "auto"
141
142
  searxng_url: str | None = None
142
143
  checkpoints: bool = True # snapshot files before the model changes them, for /undo
144
+ sandbox: str = "off" # off, docker or podman: where the model's shell commands run
145
+ sandbox_image: str | None = None
146
+ sandbox_network: bool = False
143
147
 
144
148
 
145
149
  class Agent:
@@ -153,6 +157,11 @@ class Agent:
153
157
  self.session_id = self.new_session_id()
154
158
  self.checkpoints = Checkpoints(self.console, settings.checkpoints)
155
159
  self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
160
+ self.sandbox = (
161
+ Sandbox(settings.sandbox, settings.sandbox_image, settings.sandbox_network, self.console)
162
+ if settings.sandbox != "off"
163
+ else None
164
+ )
156
165
  self._mcp_prompt = "" # the MCP part at the end of the system prompt
157
166
  self.session_name = ""
158
167
  self.session_title = ""
@@ -462,6 +471,15 @@ class Agent:
462
471
  finally:
463
472
  self.checkpoints.end_turn()
464
473
 
474
+ def sandbox_root(self) -> Path | None:
475
+ """The folder the model is limited to while the sandbox is on (None when it's off)."""
476
+ if not self.sandbox:
477
+ return None
478
+ try:
479
+ return project_root(self.cwd)
480
+ except SandboxError:
481
+ return self.cwd
482
+
465
483
  def checkpoint(self) -> None:
466
484
  """Called before the model changes files: snapshot them once per request, for /undo."""
467
485
  self.checkpoints.before_change(self.cwd)
@@ -321,6 +321,15 @@ def cmd_doctor(args) -> None:
321
321
  except ValueError as e:
322
322
  line("MCP", str(e), False)
323
323
  ok = False
324
+ if cfg["sandbox"] == "off":
325
+ line("Sandbox", "off (lcode config set sandbox docker)", True)
326
+ else:
327
+ from lcode.sandbox import engine_problem
328
+
329
+ problem = engine_problem(cfg["sandbox"])
330
+ network = "network on" if cfg["sandbox_network"] else "no network"
331
+ line("Sandbox", problem or f"{cfg['sandbox']} · {network}", problem is None)
332
+ ok &= problem is None
324
333
  node = shutil.which("npx")
325
334
  line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
326
335
  sys.exit(0 if ok else 1)
@@ -485,8 +494,20 @@ def cmd_chat(args) -> None:
485
494
  search_backend=cfg["search_backend"],
486
495
  searxng_url=cfg["searxng_url"],
487
496
  checkpoints=cfg["checkpoints"],
497
+ sandbox=(cfg["sandbox"] if cfg["sandbox"] != "off" else "docker") if args.sandbox else cfg["sandbox"],
498
+ sandbox_image=cfg["sandbox_image"],
499
+ sandbox_network=cfg["sandbox_network"],
488
500
  )
489
501
  agent = Agent(ollama, settings, cwd, console=console)
502
+ if agent.sandbox:
503
+ # Never run commands unsandboxed when the user asked for a sandbox: stop here instead.
504
+ from lcode.sandbox import SandboxError
505
+
506
+ try:
507
+ with console.status("Starting the sandbox…"):
508
+ agent.sandbox.ensure(cwd)
509
+ except SandboxError as e:
510
+ fail(f"the sandbox can't start: {e}\nTurn it off with: lcode config set sandbox off")
490
511
  if not args.no_mcp:
491
512
  from lcode.mcp.commands import start_session
492
513
 
@@ -496,6 +517,8 @@ def cmd_chat(args) -> None:
496
517
  finally:
497
518
  if agent.mcp:
498
519
  agent.mcp.close()
520
+ if agent.sandbox:
521
+ agent.sandbox.stop()
499
522
 
500
523
 
501
524
  def build_parser() -> argparse.ArgumentParser:
@@ -522,6 +545,7 @@ def build_parser() -> argparse.ArgumentParser:
522
545
  parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
523
546
  parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
524
547
  parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
548
+ parser.add_argument("--sandbox", action="store_true", help="run shell commands in a container (Docker or Podman)")
525
549
  parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
526
550
  parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
527
551
  return parser
@@ -25,6 +25,7 @@ PERMISSION_MODES = ("ask", "auto-edit", "yolo")
25
25
  WEB_MODES = ("on", "ask", "off")
26
26
  SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
27
27
  MCP_TOOL_MODES = ("auto", "direct", "search")
28
+ SANDBOX_ENGINES = ("off", "docker", "podman")
28
29
 
29
30
  # key -> (default, type, help)
30
31
  SETTINGS: dict[str, tuple[object, type, str]] = {
@@ -39,6 +40,9 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
39
40
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
40
41
  "searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
41
42
  "checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
43
+ "sandbox": ("off", str, "run the model's shell commands in a container: off | docker | podman"),
44
+ "sandbox_image": (None, str, "container image for the sandbox (default: lcode's, built on first use)"),
45
+ "sandbox_network": (False, bool, "let commands in the sandbox use the network"),
42
46
  "mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
43
47
  }
44
48
  ENV_OVERRIDES = {
@@ -48,6 +52,7 @@ ENV_OVERRIDES = {
48
52
  "LCODE_KEEP_ALIVE": "keep_alive",
49
53
  "OLLAMA_HOST": "ollama_host",
50
54
  "LCODE_WEB": "web",
55
+ "LCODE_SANDBOX": "sandbox",
51
56
  }
52
57
 
53
58
 
@@ -99,6 +104,11 @@ def coerce(key: str, value: object) -> object:
99
104
  if value not in WEB_MODES:
100
105
  raise ConfigError(f"web must be one of {', '.join(WEB_MODES)}")
101
106
  return value
107
+ if key == "sandbox":
108
+ value = {"none": "off", "false": "off", "no": "off"}.get(str(value).lower(), str(value).lower())
109
+ if value not in SANDBOX_ENGINES:
110
+ raise ConfigError(f"sandbox must be one of {', '.join(SANDBOX_ENGINES)}")
111
+ return value
102
112
  if key == "mcp_tools" and value not in MCP_TOOL_MODES:
103
113
  raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
104
114
  if key == "search_backend" and value not in SEARCH_BACKENDS:
@@ -39,6 +39,7 @@ COMMANDS = {
39
39
  "/undo": "Undo the file changes of the last request (lcode saves a checkpoint before changing files)",
40
40
  "/rewind": "Go back to before an earlier request: its files, and optionally the conversation",
41
41
  "/checkpoints": "List the requests that changed files, and which files",
42
+ "/sandbox": "Shell-command sandbox: status, or /sandbox network on|off",
42
43
  "/mcp": "MCP servers and their tools: /mcp, /mcp tools NAME, /mcp login NAME, /mcp restart NAME",
43
44
  "/compact": "Summarize the conversation to free context (optional: what to focus on)",
44
45
  "/context": "Show context usage and change the window size: pick from a list, or /context 128k",
@@ -143,6 +144,11 @@ def banner(agent: Agent) -> None:
143
144
  f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
144
145
  f"[dim]web[/] {web_status(agent)}\n"
145
146
  + (f"[dim]mcp[/] {escape(', '.join(agent.mcp.servers))} [dim](/mcp)[/]\n" if agent.mcp else "")
147
+ + (
148
+ f"[dim]sandbox[/] {escape(agent.sandbox.describe())} [dim](/sandbox)[/]\n"
149
+ if agent.sandbox
150
+ else ""
151
+ )
146
152
  + "\n"
147
153
  "[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
148
154
  ),
@@ -240,6 +246,31 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
240
246
  apply_context(agent, size, hardware)
241
247
 
242
248
 
249
+ def sandbox_command(agent: Agent, arg: str) -> None:
250
+ c, sandbox = agent.console, agent.sandbox
251
+ if sandbox is None:
252
+ c.print(
253
+ "The sandbox is off: shell commands run directly on this machine. Start lcode with --sandbox, or "
254
+ "turn it on for every session with: lcode config set sandbox docker"
255
+ )
256
+ return
257
+ words = arg.split()
258
+ if len(words) == 2 and words[0] == "network" and words[1] in ("on", "off"):
259
+ sandbox.network = words[1] == "on"
260
+ sandbox.stop() # the next command starts a container with the new setting
261
+ c.print(f"Network access in the sandbox is {words[1]} from the next command.")
262
+ return
263
+ if arg:
264
+ c.print("[yellow]Use /sandbox or /sandbox network on|off.[/]")
265
+ return
266
+ where = f" · container {sandbox.container}, mounting {sandbox.root}" if sandbox.container else ""
267
+ c.print(f"Sandbox: {escape(sandbox.describe())}{escape(where)}")
268
+ c.print(
269
+ "[dim]Shell commands run in the container and can only see this project; file tools are limited to it "
270
+ "too. In auto-edit mode, commands run without asking.[/]"
271
+ )
272
+
273
+
243
274
  def mcp_command(agent: Agent, arg: str) -> None:
244
275
  from lcode.mcp.commands import status_table
245
276
  from lcode.mcp.protocol import McpError
@@ -515,6 +546,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
515
546
  print_checkpoints(agent)
516
547
  elif cmd == "/mcp":
517
548
  mcp_command(agent, arg)
549
+ elif cmd == "/sandbox":
550
+ sandbox_command(agent, arg)
518
551
  elif cmd == "/compact":
519
552
  agent.compact(arg)
520
553
  elif cmd in ("/context", "/ctx"):
@@ -0,0 +1,204 @@
1
+ """An optional sandbox: the model's shell commands run in a container that only sees the project.
2
+
3
+ One container per session. The project (the git repository around the working directory, or the
4
+ working directory itself) is mounted at the same path, so paths in command output stay valid and
5
+ lcode's file tools, which run on the host, see the same files. Commands run as the host user, with
6
+ no network unless it's allowed, no Linux capabilities, a process limit, and nothing else from the
7
+ host: not the home folder, not the Docker socket.
8
+
9
+ The default image is built locally on first use from the Dockerfile below. Any image with bash and
10
+ setsid (util-linux or busybox) can be used instead (`sandbox_image`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import os
17
+ import platform
18
+ import secrets
19
+ import shutil
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from rich.console import Console
24
+
25
+ DOCKERFILE = """\
26
+ FROM python:3.12-slim-bookworm
27
+ RUN apt-get update \\
28
+ && apt-get install -y --no-install-recommends \\
29
+ git curl ca-certificates build-essential ripgrep jq unzip less procps nodejs npm \\
30
+ && rm -rf /var/lib/apt/lists/*
31
+ """
32
+ DEFAULT_IMAGE = f"lcode-sandbox:{hashlib.sha256(DOCKERFILE.encode()).hexdigest()[:12]}"
33
+ LABEL = "dev.lcode.sandbox"
34
+ HOME = "/tmp/lcode-home"
35
+ ENGINES = ("docker", "podman")
36
+
37
+
38
+ class SandboxError(Exception):
39
+ """The sandbox can't be used; the message says how to fix it."""
40
+
41
+
42
+ def engine_problem(engine: str) -> str | None:
43
+ """Why `engine` can't run containers here, or None if it can."""
44
+ if not shutil.which(engine):
45
+ if engine == "podman":
46
+ return "podman isn't installed (https://podman.io/docs/installation)"
47
+ if platform.system() == "Darwin":
48
+ return "docker isn't installed: install Docker Desktop, OrbStack or colima"
49
+ return "docker isn't installed (https://docs.docker.com/engine/install/)"
50
+ try:
51
+ r = subprocess.run(
52
+ [engine, "version", "--format", "{{.Server.Version}}"], capture_output=True, text=True, timeout=20
53
+ )
54
+ except (OSError, subprocess.SubprocessError) as e:
55
+ return f"{engine} doesn't respond: {e}"
56
+ if r.returncode != 0:
57
+ detail = (r.stderr.strip().splitlines() or [""])[-1]
58
+ if "permission denied" in detail.lower():
59
+ return (
60
+ f"{engine} needs permission: add yourself to the docker group "
61
+ "(sudo usermod -aG docker $USER, then log in again)"
62
+ )
63
+ if platform.system() == "Darwin":
64
+ return f"{engine} isn't running: start Docker Desktop, OrbStack or colima ({detail})"
65
+ return f"{engine} isn't running: start it (sudo systemctl start {engine}) ({detail})"
66
+ return None
67
+
68
+
69
+ def remove_orphans(engine: str) -> None:
70
+ """Remove sandboxes left behind by lcode sessions that ended without cleaning up."""
71
+ try:
72
+ r = subprocess.run(
73
+ [engine, "ps", "-a", "--filter", f"label={LABEL}", "--format", '{{.ID}} {{.Label "dev.lcode.pid"}}'],
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=20,
77
+ )
78
+ except (OSError, subprocess.SubprocessError):
79
+ return
80
+ stale = []
81
+ for line in r.stdout.split("\n"):
82
+ parts = line.split()
83
+ if len(parts) == 2 and parts[1].isdigit() and not _alive(int(parts[1])):
84
+ stale.append(parts[0])
85
+ if stale:
86
+ subprocess.run([engine, "rm", "-f", *stale], capture_output=True, timeout=60)
87
+
88
+
89
+ def _alive(pid: int) -> bool:
90
+ try:
91
+ os.kill(pid, 0)
92
+ except ProcessLookupError:
93
+ return False
94
+ except PermissionError:
95
+ return True
96
+ return True
97
+
98
+
99
+ class Sandbox:
100
+ def __init__(self, engine: str, image: str | None = None, network: bool = False, console: Console | None = None):
101
+ if engine not in ENGINES:
102
+ raise SandboxError(f"unknown sandbox {engine!r}; use docker or podman")
103
+ self.engine = engine
104
+ self.image = image or DEFAULT_IMAGE
105
+ self.network = network
106
+ self.console = console or Console()
107
+ self.container: str | None = None
108
+ self.root: Path | None = None
109
+
110
+ # -- lifecycle
111
+ def check(self) -> None:
112
+ problem = engine_problem(self.engine)
113
+ if problem:
114
+ raise SandboxError(problem)
115
+
116
+ def contains(self, path: Path) -> bool:
117
+ return self.root is not None and (path == self.root or self.root in path.parents)
118
+
119
+ def ensure(self, cwd: Path) -> None:
120
+ """Have a container running whose mounted folder includes `cwd`."""
121
+ if self.container and self.contains(cwd):
122
+ return
123
+ self.stop()
124
+ self.check()
125
+ remove_orphans(self.engine)
126
+ if self.image == DEFAULT_IMAGE:
127
+ self._build_default_image()
128
+ root = project_root(cwd)
129
+ name = f"lcode-sandbox-{os.getpid()}-{secrets.token_hex(3)}"
130
+ command = [
131
+ self.engine, "run", "--detach", "--init", "--name", name,
132
+ "--label", LABEL, "--label", f"dev.lcode.pid={os.getpid()}",
133
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "1024",
134
+ "--volume", f"{root}:{root}", "--workdir", str(cwd),
135
+ "--env", f"HOME={HOME}", "--tmpfs", f"{HOME}:exec,mode=1777",
136
+ ] # fmt: skip
137
+ if not self.network:
138
+ command += ["--network", "none"]
139
+ if self.engine == "podman":
140
+ command += ["--userns", "keep-id"] # rootless podman: files belong to the host user
141
+ else:
142
+ command += ["--user", f"{os.getuid()}:{os.getgid()}"]
143
+ command += [self.image, "sleep", "infinity"]
144
+ r = subprocess.run(command, capture_output=True, text=True, timeout=300)
145
+ if r.returncode != 0:
146
+ raise SandboxError(f"couldn't start the sandbox: {(r.stderr.strip().splitlines() or ['?'])[-1]}")
147
+ self.container, self.root = name, root
148
+
149
+ def _build_default_image(self) -> None:
150
+ exists = subprocess.run([self.engine, "image", "inspect", self.image], capture_output=True, timeout=30)
151
+ if exists.returncode == 0:
152
+ return
153
+ with self.console.status("Building the sandbox image (once; a minute or two)…"):
154
+ r = subprocess.run(
155
+ [self.engine, "build", "--tag", self.image, "-"],
156
+ input=DOCKERFILE,
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=1800,
160
+ )
161
+ if r.returncode != 0:
162
+ tail = "\n".join(r.stderr.strip().splitlines()[-5:])
163
+ raise SandboxError(f"couldn't build the sandbox image:\n{tail}")
164
+
165
+ def exec_argv(self, cwd: Path, script: str) -> tuple[list[str], str]:
166
+ """The command that runs `script` with bash inside the container, and a token for kill()."""
167
+ assert self.container is not None
168
+ token = secrets.token_hex(6)
169
+ # Its own session, so a timeout or Ctrl+C can stop everything the command started.
170
+ script = f"echo $$ > /tmp/.lcode-{token}.pgid\n{script}"
171
+ argv = [self.engine, "exec", "--interactive", "--workdir", str(cwd), self.container]
172
+ return [*argv, "setsid", "--wait", "bash", "-c", script], token
173
+
174
+ def kill(self, token: str) -> None:
175
+ """Stop a command and everything it started (after a timeout or Ctrl+C); the container stays."""
176
+ if self.container:
177
+ pgid = f"$(cat /tmp/.lcode-{token}.pgid 2>/dev/null)"
178
+ subprocess.run(
179
+ # bash, not sh: dash's kill doesn't accept "--" before a negative (process group) id
180
+ [self.engine, "exec", self.container, "bash", "-c", f'[ -n "{pgid}" ] && kill -KILL -- -{pgid}'],
181
+ capture_output=True,
182
+ timeout=30,
183
+ )
184
+
185
+ def stop(self) -> None:
186
+ if self.container:
187
+ subprocess.run([self.engine, "rm", "--force", self.container], capture_output=True, timeout=60)
188
+ self.container, self.root = None, None
189
+
190
+ def describe(self) -> str:
191
+ image = "lcode's image" if self.image == DEFAULT_IMAGE else self.image
192
+ network = "network on" if self.network else "no network"
193
+ return f"{self.engine} · {image} · {network}"
194
+
195
+
196
+ def project_root(cwd: Path) -> Path:
197
+ """The folder the sandbox mounts: the git repository around `cwd`, or `cwd` itself."""
198
+ from lcode.checkpoints import work_tree_for
199
+
200
+ root = work_tree_for(cwd)
201
+ home = Path.home().resolve()
202
+ if root == home or root in home.parents or root == Path("/"):
203
+ raise SandboxError(f"won't mount {root} into the sandbox; start lcode in a project folder")
204
+ return root