Workspace¶
Workspace adds file and Shell tools under one allow / ask / deny policy
for paths and commands. Define the root and permission boundary before exposing
these capabilities to an Agent.
from lovia import Agent
from lovia.workspace import CommandRule, Workspace
agent = Agent(
name="coder",
instructions="Make small, targeted code changes.",
model="<model>",
workspace=Workspace.local(
".",
mode="coding",
readable=("~/reference-docs",), # extra read scope outside the root
denied_paths=(".env*",),
command_rules=(
CommandRule("pytest", "allow"),
CommandRule("rm -rf", "deny"),
),
),
)
The workspace contributes its tool bundle at run time, injects a generated
## Workspace section into the system prompt (derived from the policy, so
the prompt never promises more than the session enforces), and exposes its
live session to custom tools as ctx.workspace. mode takes a
WorkspaceMode ("readonly" / "coding" / "trusted"); denials raise
PermissionDeniedError and closed-session use raises
WorkspaceClosedError (both WorkspaceError, itself a ToolError — the
model sees them and adapts).
Modes¶
mode picks a preset policy; every mode allows reads inside the root:
| Mode | Writes inside | Reads outside | Writes outside | Shell |
|---|---|---|---|---|
readonly |
deny | deny | deny | none |
coding (default) |
allow | ask | deny | ask |
trusted |
allow | allow | ask | allow |
Refine any preset with readable= / writable= (grants), denied_paths=
(hard blocks), full path_rules= / command_rules=, or replace the whole
thing with policy=WorkspacePolicy(...) (mutually exclusive with the
shorthand knobs).
The ACL¶
Three values, two enforcement points:
denyis enforced in the session — the single choke point every file operation and command passes through, whether called by the built-in tools, your custom tools, or your own code. Denials raisePermissionDeniedError(aToolError— the model sees it and adapts).askis resolved at the tool layer — the built-in tools carryneeds_approvalpredicates that consult the policy, soaskdecisions surface through the standard approval channel, same as any gated tool.
Path rules. PathRule(pattern, action, ops={"read","write"}); patterns
are globs with three addressing forms — absolute/~ (matches the resolved
path and its subtree), containing / (workspace-relative), or bare
(.env*: gitignore-style, matches a basename or any ancestor segment,
inside or outside the root). Precedence: denied_paths first, then the
first matching path rule, then the mode defaults.
Command rules. CommandRule(pattern, action) matches on
word-boundary prefix: "git push" matches git push origin, never
git pushx. Compound commands are split on &&, ||, ;, |, &; each
segment is judged and the most restrictive decision wins.
Symlinks have no special case: every path is resolved first (symlinks
followed, ~ expanded, relative anchored at the root) and judged by where
it lands — so a .venv/bin/python pointing at the system interpreter
just works when the policy allows that target, and a symlink escaping the
root is treated as the outside path it is.
The tools¶
The bundle adapts to the policy (no write tools on a read-only workspace;
no shell when disabled):
| Tool | Notes | Parallel? |
|---|---|---|
read_file |
1-based start/end line paging; refuses binary / non-UTF-8 files |
yes |
list_files |
glob filter, hidden-file toggle | yes |
grep_files |
regex, per-file and match caps | yes |
write_file |
create_only=True refuses overwrite |
barrier |
edit_file |
exact-substring replace; fails on 0 or >1 matches unless replace_all; CRLF-tolerant |
barrier |
shell |
cwd and per-call timeout (default 300s); background=true starts a background process instead; optional description — a user-facing one-liner the UI shows, ignored by policy |
barrier |
read_process_output |
incremental output + status of a background process | yes |
kill_process |
kill a background process's whole group | barrier |
Mutators default to parallel=False
(execution barriers) so file
and process side effects never race within a turn; read-only tools stay
parallel.
Outputs are bounded at the tool layer by WorkspaceLimits (pass
limits=WorkspaceLimits(...)): max_file_read_chars=50_000 per read (page
with start/end), max_shell_output_chars=30_000 (head + tail kept),
plus byte caps for reads and grep, and result caps for list/grep. All
truncation is announced in the output.
Shell execution details worth knowing: commands run via the system shell
with a minimal environment by default (PATH, HOME, locale — secrets
are not passed through; inherit_env=True opts into the full environment,
env= adds specific variables), in a fresh process group; a timeout kills
the whole group and reports timed_out=True.
Background processes¶
shell(command, background=true) starts the command as a session-owned
background process and returns a process id immediately — the way to run
dev servers, watchers, and long builds or test runs, then verify them with
follow-up commands (http_request against the server, read_process_output
for the test tail). The start is judged by the same policy and approval
gate as a foreground command; backgrounding never softens the verdict.
Semantics:
- stdout and stderr are merged and spooled to a bounded per-process
buffer;
read_process_output(process_id)returns what arrived since the last read, plus status (running/exitedwith code /killed). Between reads only the newestmax_shell_output_charsare kept — older unread output is dropped and the drop is announced. - Reads never turn into errors: polling an exited process reports its exit code and drains the remainder. An unknown id raises with the live ids listed in the message (there is no separate list tool).
kill_process(process_id)kills the whole process group (children included) and returns the final tail. No timeout applies to background processes; they die with the session (close()reaps every group).- Every turn, a transient status reminder (same view-injection
mechanism as the todo re-show — never persisted, never accumulating)
keeps running processes in the model's view and announces an exit until
a
read_process_output/kill_processdelivers it. So a crashed dev server is noticed on the next turn without polling. - Processes are ephemeral: a checkpoint resume does not restore them.
After a restart,
read_process_outputsays so and the fix is to re-run the start command from the transcript. spawnrefuses to run under a customShellExecutorrather than silently bypassing its sandbox (background support for executors is not wired yet).
The same surface is available on the session for library use and custom
tools: spawn / read_process_output / kill_process, plus
background_processes() — a passive status list (nothing consumed) that
feeds the reminder and suits UI listings.
Session lifetime is the serving layer's dial. By default the runner
opens a session per run and closes it at the run's end — fine for one-shot
Runner.run scripts, where the run is the conversation. A serving layer
that holds conversations open should scope the session wider by binding a
caller-owned one via LocalWorkspace.bind(session) (or the
.session() context manager): lovia web binds one session per chat,
so a dev server started in one turn is still up when the next message
arrives, and dies when the chat is deleted or the server shuts down
(Ctrl+C; a hard kill -9 skips teardown and orphans processes).
A virtualenv at the workspace root (.venv preferred, venv accepted) is
auto-activated for every command: its bin dir is prepended to PATH
and VIRTUAL_ENV is set, so python/pip resolve to the workspace's own
environment rather than the one lovia runs in. Detection is per command —
a venv the agent just created takes effect immediately — and only bites
when a real interpreter is inside (a directory merely named venv
doesn't). An explicit env={"PATH": ...} still wins. The workspace's
system-prompt fragment tells the model to create .venv before installing
Python packages rather than installing globally.
For writable workspaces, the automatic instructions place temporary work in
tmp/, follow the existing repository layout, and never treat tmp/ as a
deliverable.
The command guard¶
Static command rules can't see paths, so the session also lexically extracts path claims from each command — redirect targets count as writes, path-looking arguments as reads — and merges their path-ACL verdicts with the static rule verdict, most-restrictive-wins. A command that names a denied path (redirects included) is denied even when its binary is allowed.
The guard is advisory and one-sided: it cannot see python -c payloads
or $(...) substitutions, and a missed path falls back to the static
rules — it can add restrictions, never loosen them. The local shell still
runs as the host user. For hard isolation, the ShellExecutor seam
exists precisely to plug in an OS sandbox:
class ShellExecutor(Protocol):
async def run(self, command, *, cwd, env, timeout, policy, root) -> CommandResult: ...
An executor runs after the policy and approval gates (it decides how,
never whether) and can derive Seatbelt/bubblewrap/Landlock scopes from
the policy it receives. Workspace.local(..., executor=my_sandbox).
As a library, and from custom tools¶
The workspace is usable without an agent — the same session the tools use:
async with Workspace.local("./project", mode="trusted").session() as ws:
session = await ws.open()
content = await session.read_text("hello.txt")
matches = await session.grep("TODO", glob="*.py")
result = await session.run("pytest -q")
Custom tools reach the active run's session as ctx.workspace and get the
same gate: read_text / write_text / edit_text / list_files /
grep / run / spawn / read_process_output / kill_process, plus
decide_path(path, write=...) and
decide_command(command) for tools that want to check before acting.
Deny raises; ask returns as a decision your tool's own needs_approval
predicate can honor. (Workspace.local(...) returns a LocalWorkspace
whose open() yields a LocalWorkspaceSession; the calls return typed
results — FileContent, FileChange, EditResult, DirEntry,
GrepMatch, CommandResult, ProcessStart, ProcessOutput — and
PathRule.ops takes FileOp values, "read"/"write".)
By default each run opens a fresh session and closes it at run end; the
.session() context manager above holds one open across runs
(close_after_run=False) when startup cost matters.
Sharp edges¶
- The command guard is not a sandbox. It is an honest, lexical gate;
interpreters and substitutions walk past it. Anything security-critical
needs the
ShellExecutorseam or an isolated host — the docs and the generated system prompt say the same thing on purpose. denied_pathsbeats everything, including your ownreadable=grants — check precedence before debugging "why can't it read that".- A cancelled
shellcall may leave work half-done. The process group is killed on timeout, but a run-level cancel between approval and completion behaves like any sync-tool cancellation: effects may land anyway; resume re-executes dangling calls. - Background processes are session-scoped. They die on
close()and are not restored by a checkpoint resume — treat a dev server started withbackground=trueas something to re-start, not something that survives. How long the session itself lives is the serving layer's choice: per run by default, per chat inlovia web(see above). - Skills file IO bypasses this ACL — skill directories are read with the plugin's own IO, not the workspace's.
See also¶
- Tool approval — where
askdecisions land - Tools — barriers, truncation, error semantics
- Examples:
19_workspace.py(library),20_workspace_agent.py(coding agent)