lcode-cli 0.4.1__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.
lcode/__init__.py CHANGED
@@ -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"
lcode/agent.py CHANGED
@@ -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)
lcode/cli.py CHANGED
@@ -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
lcode/config.py CHANGED
@@ -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:
lcode/repl.py CHANGED
@@ -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"):
lcode/sandbox.py ADDED
@@ -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
lcode/tools.py CHANGED
@@ -9,11 +9,10 @@ import json
9
9
  import os
10
10
  import queue
11
11
  import re
12
- import shlex
12
+ import secrets
13
13
  import shutil
14
14
  import signal
15
15
  import subprocess
16
- import tempfile
17
16
  import threading
18
17
  import time
19
18
  from pathlib import Path
@@ -25,10 +24,15 @@ from rich.text import Text
25
24
 
26
25
  from lcode import web
27
26
  from lcode.permissions import bash_key, is_read_only
27
+ from lcode.sandbox import SandboxError
28
28
 
29
29
  if TYPE_CHECKING:
30
30
  from lcode.agent import Agent
31
31
 
32
+ NO_NETWORK = re.compile(
33
+ r"Network is unreachable|Temporary failure in name resolution|Could not resolve host|getaddrinfo|ENOTFOUND|"
34
+ r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
35
+ )
32
36
  MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
33
37
  IGNORE_DIRS = {
34
38
  ".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
@@ -277,6 +281,14 @@ class Toolbox:
277
281
  p = Path(os.path.expanduser(path or "."))
278
282
  return (p if p.is_absolute() else self.agent.cwd / p).resolve()
279
283
 
284
+ def path(self, path: str | None) -> Path:
285
+ """Resolve a path from the model; with the sandbox on, it must be inside the project."""
286
+ p = self.resolve(path)
287
+ root = self.agent.sandbox_root()
288
+ if root is not None and p != root and root not in p.parents:
289
+ raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
290
+ return p
291
+
280
292
  def rel(self, p: Path) -> str:
281
293
  try:
282
294
  return str(p.relative_to(self.agent.cwd)) or "."
@@ -344,7 +356,7 @@ class Toolbox:
344
356
 
345
357
  # -- read-only
346
358
  def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
347
- p = self.resolve(path)
359
+ p = self.path(path)
348
360
  if not p.exists():
349
361
  raise ToolError(f"{path} does not exist")
350
362
  if p.is_dir():
@@ -364,13 +376,13 @@ class Toolbox:
364
376
  return truncate(out, 120_000)
365
377
 
366
378
  def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
367
- p = self.resolve(path)
379
+ p = self.path(path)
368
380
  if not p.is_dir():
369
381
  raise ToolError(f"{path} is not a directory")
370
382
  return tree(p, depth=max(1, min(int(depth or 2), 6)))
371
383
 
372
384
  def t_glob(self, pattern: str, path: str = ".") -> str:
373
- base = self.resolve(path)
385
+ base = self.path(path)
374
386
  pattern = pattern.removeprefix("./")
375
387
  patterns = [pattern] + ([pattern[3:]] if pattern.startswith("**/") else [])
376
388
  hits = [
@@ -385,7 +397,7 @@ class Toolbox:
385
397
  def t_grep(
386
398
  self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
387
399
  ) -> str:
388
- p = self.resolve(path)
400
+ p = self.path(path)
389
401
  if shutil.which("rg"):
390
402
  cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
391
403
  cmd += ["--max-columns", "400"]
@@ -450,7 +462,7 @@ class Toolbox:
450
462
  self.read_mtimes[str(p)] = p.stat().st_mtime
451
463
 
452
464
  def t_write_file(self, path: str, content: str) -> str:
453
- p = self.resolve(path)
465
+ p = self.path(path)
454
466
  exists = p.exists()
455
467
  if exists:
456
468
  if p.is_dir():
@@ -472,7 +484,7 @@ class Toolbox:
472
484
  return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)."
473
485
 
474
486
  def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
475
- p = self.resolve(path)
487
+ p = self.path(path)
476
488
  if not p.exists():
477
489
  raise ToolError(f"{path} does not exist (use write_file to create it)")
478
490
  self._check_fresh(p)
@@ -515,20 +527,30 @@ class Toolbox:
515
527
 
516
528
  # -- shell
517
529
  def t_bash(self, command: str, timeout: int = 180) -> str:
518
- if not is_read_only(command):
530
+ sandbox = self.agent.sandbox
531
+ if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit"):
519
532
  body = Syntax(command, "bash", theme="monokai", word_wrap=True)
520
- ok, feedback = self.agent.perms.request(
521
- bash_key(command), "bash", f"Run command (in {self.agent.cwd})", body
522
- )
533
+ where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
534
+ ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body)
523
535
  if not ok:
524
536
  return feedback
537
+ if not is_read_only(command):
525
538
  self.agent.checkpoint()
526
539
  self.console.print(Text(f" $ {command}", style="bold cyan"))
527
- fd, cwd_file = tempfile.mkstemp(prefix="lcode_cwd_")
528
- os.close(fd)
529
- script = f"{command}\n__lcode_ec=$?\npwd -P > {shlex.quote(cwd_file)}\nexit $__lcode_ec\n"
540
+ marker = f"__lcode_cwd_{secrets.token_hex(8)}__"
541
+ script = f"{command}\n__lcode_ec=$?\nprintf '\\n{marker}%s\\n' \"$(pwd -P)\"\nexit $__lcode_ec\n"
542
+ token = ""
543
+ if sandbox:
544
+ try:
545
+ with self.console.status("Starting the sandbox…"):
546
+ sandbox.ensure(self.agent.cwd)
547
+ except SandboxError as e:
548
+ return f"Error: the sandbox can't start, so the command didn't run: {e}"
549
+ argv, token = sandbox.exec_argv(self.agent.cwd, script)
550
+ else:
551
+ argv = ["bash", "-c", script]
530
552
  proc = subprocess.Popen(
531
- ["bash", "-c", script],
553
+ argv,
532
554
  cwd=self.agent.cwd,
533
555
  stdout=subprocess.PIPE,
534
556
  stderr=subprocess.STDOUT,
@@ -546,9 +568,14 @@ class Toolbox:
546
568
  lines.put(line)
547
569
  lines.put(None)
548
570
 
571
+ def stop() -> None:
572
+ if sandbox and token:
573
+ sandbox.kill(token) # the command runs in the container, not under our process
574
+ os.killpg(proc.pid, signal.SIGKILL)
575
+
549
576
  threading.Thread(target=pump, daemon=True).start()
550
577
  out: list[str] = []
551
- shown, status = 0, ""
578
+ shown, status, recorded = 0, "", ""
552
579
  deadline = time.time() + int(timeout or 180)
553
580
  try:
554
581
  while True:
@@ -556,30 +583,37 @@ class Toolbox:
556
583
  line = lines.get(timeout=0.2)
557
584
  except queue.Empty:
558
585
  if time.time() > deadline:
559
- os.killpg(proc.pid, signal.SIGKILL)
586
+ stop()
560
587
  status = f"\n[Command timed out after {timeout}s and was killed]"
561
588
  break
562
589
  continue
563
590
  if line is None:
564
591
  break
592
+ if line.startswith(marker):
593
+ recorded = line[len(marker) :].strip()
594
+ continue
565
595
  out.append(line)
566
596
  if shown <= 40:
567
597
  msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
568
598
  self.console.print(Text(msg, style="dim"))
569
599
  shown += 1
570
600
  except KeyboardInterrupt:
571
- os.killpg(proc.pid, signal.SIGKILL)
601
+ stop()
572
602
  raise
573
603
  code = proc.wait()
574
- try:
575
- recorded = Path(cwd_file).read_text().strip()
576
- if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
604
+ if out and out[-1] == "\n":
605
+ out.pop() # the blank line printed before the marker
606
+ if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
607
+ if sandbox and not sandbox.contains(Path(recorded).resolve()):
608
+ status += "\n[That directory is outside the project; the working directory stays the same]"
609
+ else:
577
610
  self.agent.cwd = Path(recorded).resolve()
578
611
  status += f"\n[Working directory is now {self.agent.cwd}]"
579
- except OSError:
580
- pass
581
- finally:
582
- Path(cwd_file).unlink(missing_ok=True)
612
+ if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(out[-40:])):
613
+ status += (
614
+ "\n[The sandbox has no network access. If this needs the network, ask the user to allow it "
615
+ "with /sandbox network on]"
616
+ )
583
617
  self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
584
618
  return truncate("".join(out)) + status + f"\n[exit code: {code}]"
585
619
 
@@ -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
@@ -1,20 +1,21 @@
1
- lcode/__init__.py,sha256=1t5InCerqpe0xlveEpLyHmEy2nEXzOrj4qbGJyWN6WQ,117
1
+ lcode/__init__.py,sha256=-nk7j2Efg9_MFgFlQo5YWln10lcT6SR31CYBoENbTLo,117
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=dxW07COUQo8UZnIwNfegCOSX_ZStorYsL93jhZFkvN8,27753
3
+ lcode/agent.py,sha256=evwGEJg8zWvA2SnP4yI_vkWba3ILICK9OzVb0cbk6bw,28478
4
4
  lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
5
5
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
6
6
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
7
- lcode/cli.py,sha256=ICyUQ1DQmUhy0cNBrRFYvFqyqvLxdcLWn-21-Q6qSWk,25845
8
- lcode/config.py,sha256=i7EYhhTPaIFxSWyqHiffIEoL4PgHjkBBpF20gc--MtQ,6306
7
+ lcode/cli.py,sha256=iGP5qywc7tUFuLNpzcrlYTicFX6dAaQpeXCfElqxPdQ,27041
8
+ lcode/config.py,sha256=1_dhAwLRZD2rEhD47rBl3W4kvW0lQ6-45TQdq1KHz58,6956
9
9
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
10
10
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
11
11
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
12
12
  lcode/ollama.py,sha256=-EB_8DKh87larG7iV0neyndPRom1Hxh4y8AAfI1Ebq8,6690
13
13
  lcode/permissions.py,sha256=OcWUDLrSoohUWHDUQ3UuBE3zI2JnBnOPizLE3bIgqyU,3982
14
14
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
15
- lcode/repl.py,sha256=vvKndnzchza2FNkluZTOCJ4qZOF5Dhm-Hhj6N_PfFrI,28814
15
+ lcode/repl.py,sha256=2p6UG1xCmn85YEYcWUtgfoLJc3rHLaGLb_VV-RvzYvw,30332
16
+ lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
16
17
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
17
- lcode/tools.py,sha256=fK7OOe6IagBrj9aCF3z4faJTZO3jAamzEPF3AaaH5m0,27234
18
+ lcode/tools.py,sha256=Y23bVt7Bow72v1QJUpvSahoEep77XW8e44Mg-s9igds,29146
18
19
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
19
20
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
20
21
  lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
@@ -27,8 +28,8 @@ lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
27
28
  lcode/mcp/manager.py,sha256=UPpY97jwW_KIi2G4hR860Iq4zgfqNFHjsyeu48aEa44,14784
28
29
  lcode/mcp/protocol.py,sha256=8uhyzzDQmaXr6pdt9bWD5ccvWZ93rvxd4v3Z5J84vKw,4938
29
30
  lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
30
- lcode_cli-0.4.1.dist-info/METADATA,sha256=jGUe-zgy_zUWIaqhz9-h6J03eZmXhy9RWwt2BZrgyi0,9869
31
- lcode_cli-0.4.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
32
- lcode_cli-0.4.1.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
33
- lcode_cli-0.4.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
34
- lcode_cli-0.4.1.dist-info/RECORD,,
31
+ lcode_cli-0.5.0.dist-info/METADATA,sha256=AKzBZkJaIZLAFgClC2t3OhO36jiVaAAXB7d1EXFEV8Q,9965
32
+ lcode_cli-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
33
+ lcode_cli-0.5.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
34
+ lcode_cli-0.5.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
35
+ lcode_cli-0.5.0.dist-info/RECORD,,