Workspace Sandbox Configuration
The LLM4S workspace subsystem provides powerful capabilities (read/write files, execute commands, search) that require explicit sandboxing and security configuration.
Overview
- WorkspaceSandboxConfig: Explicit configuration describing allowed paths, resource limits, shell access, and timeouts
- Validation at startup: the runner reads
WORKSPACE_SANDBOX_PROFILEwhen it starts. Unset or empty, it usespermissive; an unknown profile name makesRunnerMainfail and the runner stop. Only a known profile that fails validation (which the built-in profiles cannot) is logged and replaced bypermissive - Enforcement: Runner enforces limits and shell allowance; file path boundaries are enforced via
resolvePath;executeCommandchecks each command’s executable, options, path arguments and environment (see Command policy), whether it arrives over the WebSocket protocol (ContainerisedWorkspace) or through a direct call
Configuration
Environment Variables (Runner)
When running the workspace runner (e.g. in Docker):
| Variable | Description | Default |
|---|---|---|
WORKSPACE_PATH |
Workspace root directory | /workspace |
WORKSPACE_SANDBOX_PROFILE |
Sandbox profile: permissive or locked; any other value stops the runner |
permissive |
WORKSPACE_EXTRA_COMMANDS |
Programs added to the profile’s allowedCommands, separated by commas or whitespace (for example sbt); a name that is not a bare program name, or is a shell or launcher, stops the runner |
none |
These two variables are the only things that decide what the runner enforces.
Adding programs to the allowlist
A program the agent needs that is not on the profile’s list, such as a build tool, is added with
WORKSPACE_EXTRA_COMMANDS. ContainerisedWorkspace (and CodeWorker) take it as extraAllowedCommands and start
the container with it; CodeGenExample adds sbt this way so its agent can run sbt compile and sbt run:
1
2
new ContainerisedWorkspace(workspaceDir, imageName, hostPort, extraAllowedCommands = Set("sbt"))
// docker run ... -e WORKSPACE_EXTRA_COMMANDS=sbt ...
An added program is held to every check below (no shell, forbidden characters, path arguments inside the workspace,
the environment allowlist), but it has no per-program option rules, so it can do anything its own arguments allow:
sbt run runs the project’s code. Add only what the agent needs. Shells (sh, bash, cmd, pwsh, …) and
launchers that run a program named in their arguments (env, xargs, sudo, nohup, timeout, …) are refused
(WorkspaceSandboxConfig.NeverAllowedCommands), as either would bring back the shell
#1756 removed.
Profiles
- permissive: Current behavior—shell allowed with the read-write command allowlist (
ReadWriteCommands), standard limits (1MB file size, 500 dir entries, 30s command timeout) - locked: Shell disabled; strict limits (10s timeout). File writes and modifications remain allowed; this profile does not enforce a read-only filesystem.
HOCON (Client)
WorkspaceConfigSupport.loadSandboxConfig() reads a profile from the client’s configuration:
1
2
3
llm4s.workspace.sandbox {
profile = "locked" # or "permissive"
}
This does not control enforcement: the client does not pass it to the container, and nothing but tests calls
loadSandboxConfig. To lock a runner down, start its container with WORKSPACE_SANDBOX_PROFILE=locked (see the
example below). An unknown profile name makes loadSandboxConfig return a Left.
WorkspaceSandboxConfig Structure
| Field | Type | Description |
|---|---|---|
limits |
WorkspaceLimits | maxFileSize, maxDirectoryEntries, maxSearchResults, maxOutputSize |
excludePatterns |
List[String] | Glob patterns excluded from explore/search (e.g. node_modules, .git) |
shellAllowed |
Boolean | Whether executeCommand is allowed |
defaultCommandTimeout |
FiniteDuration | Default timeout for shell commands (more than zero, at most 1 hour) |
readOnlyPaths |
List[String] | Paths under workspace that are read-only (Phase 2) |
allowedPaths |
List[String] | If non-empty, only these paths accessible (Phase 2) |
networkAllowed |
Boolean | Documentation only; Phase 2: enforce network restrictions |
allowedCommands |
Set[String] | Executable names executeCommand may run; field default ReadOnlyCommands; the permissive profile (the default profile) uses ReadWriteCommands, which adds write-capable ones (cp, mv, rm, mkdir, …). Arguments are checked too: see Command policy |
Command policy
The policy applies to every command the runner executes, by either path: a direct executeCommand on
WorkspaceAgentInterfaceImpl, and the WebSocket protocol’s ExecuteCommandCommand, which is how
ContainerisedWorkspace.executeCommand and executeCommandWithStreaming reach the runner in its container. Both go
through one function, so they share the checks, the codes and the way the program is started
(#1756). Before that fix, the WebSocket path ran the raw command string
through sh -c (cmd.exe /c on Windows) with the client’s environment copied in, and none of the checks below
applied to it.
No shell is involved on either path. The command is split into words (single and double quotes group a word, a
backslash escapes the next character), the first word names the program, and the program is started with the other
words as its arguments. Pipes, redirection, ;, &&, $(...), backquotes and variable expansion are not
interpreted; a word holding one of their characters is refused. Write each command as one program and its arguments,
and run a second command as a second request.
executeCommand runs a command only when every check passes, in this order, and otherwise fails with the code shown:
| Check | Code |
|---|---|
Shell turned off (shellAllowed = false) |
SHELL_DISABLED |
| Working directory, as written, outside the workspace, or not a directory | PATH_ESCAPE_ATTEMPT, INVALID_DIRECTORY |
| Command with no words | EMPTY_COMMAND |
| Executable given as a path | EXECUTABLE_PATH_NOT_ALLOWED |
Executable not in allowedCommands |
EXECUTABLE_NOT_ALLOWED |
A shell metacharacter (&, \|, <, >, ^, ;, `, $, %) in any word |
FORBIDDEN_CHARACTERS |
| Working directory really outside the workspace (a symbolic link out of it) | PATH_ESCAPE_ATTEMPT |
environment sets a variable other than LANG, LANGUAGE, LC_*, TZ, TERM, COLUMNS, LINES, NO_COLOR |
ENVIRONMENT_NOT_ALLOWED |
| An option the program refuses (below) | ARGUMENT_NOT_ALLOWED |
| An argument longer than 4096 characters, or paths that need more than 20000 lookups to check | ARGUMENT_NOT_ALLOWED |
An argument holding a NUL character, on Windows one holding ", or one whose check fails with an error |
ARGUMENT_NOT_ALLOWED |
On Windows, a cmd.exe built-in’s argument holding a character cmd.exe splits or parses (, = ( ) @ !, a control character, a non-ASCII space) |
ARGUMENT_NOT_ALLOWED |
git with a .git file or link between the working directory and the workspace root |
PATH_ESCAPE_ATTEMPT |
| An argument that names a location outside the workspace | PATH_ESCAPE_ATTEMPT |
On Windows, a form listed under On Windows (a device name, a trailing . or space, @, ~, glob syntax) |
ARGUMENT_NOT_ALLOWED |
cp only: a name it would write leads outside, or a recursive copy’s destination holds a link that does |
PATH_ESCAPE_ATTEMPT |
Over the WebSocket protocol a refused command gets a WorkspaceAgentErrorResponse carrying the code above, then a
CommandCompletedMessage with exit code 1, and no CommandStartedMessage or output; ContainerisedWorkspace
throws it as a WorkspaceAgentException whose message begins with the code. A command that runs streams its output
as before, and is stopped at its timeout, or at the sandbox’s defaultCommandTimeout when the request sets none.
A command that passes every check runs with its standard input read from the null device (/dev/null, or NUL
on Windows), as nothing can write to it: a program that reads standard input when given no file (cat, cat -,
sort, wc, grep x, findstr x) sees an empty input and finishes at once instead of waiting until the command
timeout (#1728).
An allowlist names programs; these rules stop a listed program from writing, deleting, running another program or following links out of the workspace through its own options:
| Program | Refused |
|---|---|
find |
-delete, -exec, -execdir, -ok, -okdir, -fprint, -fprint0, -fprintf, -fls, -files0-from, -follow, -L (also in -HL) |
git |
any subcommand but status, log, show, diff, ls-files, ls-tree, grep, blame, rev-parse, branch; any global option but --version, --no-pager, --no-optional-locks, --literal-pathspecs, --no-replace-objects (so -c, -C, --exec-path, --git-dir, --work-tree, -p); --output, --ext-diff, --textconv, --show-signature on log/show/diff; -O, --open-files-in-pager, --textconv on grep; --textconv on blame; an argument starting with : (pathspec magic, index paths); branch with anything but listing options, or with a name unless --list/-l makes it a pattern (the values of --merged, --no-merged, --contains, --no-contains, --points-at, --sort and --format are values, not names) |
sort |
-o, --output, --compress-program, --files0-from; on Windows also /O, /T, -O, -T, -t, --temporary-directory. The value of -t / --field-separator is not path-checked only when it really is that value: see Option values |
findstr (Windows) |
a switch with F among its letters (/F:list), a /D: value holding , or ; |
uniq |
a second operand (the output file); every argument after the first operand counts as one, as BSD uniq does not reorder its arguments, so write options before the file (uniq -c a.txt, not uniq a.txt -c) |
wc |
--files0-from |
ls |
-L, --dereference |
grep |
-R, --dereference-recursive, -S (BSD) |
cp |
-L, --dereference, -H, -s, --symbolic-link; with -R, -r, -a, -P or -d (or their long forms) anywhere in the arguments, two sources with the same name, or several sources and one that names a directory’s contents (src/., src/) |
chmod |
-L, -H, --dereference |
hostname |
an operand, -F, --file, -b, --boot |
mv, rm, mkdir and touch have no option that follows a link out of the workspace, so they get the path rule
only.
Every argument is scanned for these options, including those after --, because an option that takes a value can
consume the -- itself. A short option is refused anywhere in a cluster (sort -ro out), and a long one under any
abbreviation of at least one letter (sort --outp=out), as GNU programs and git accept an unambiguous prefix.
Every argument of every program except echo, pwd, whoami and hostname is then held to the workspace. Each
candidate path is resolved from the real working directory the way the kernel resolves it, one component at a time,
following each symbolic link where it is met (so link/.. is the parent of the link’s target), and must stay inside
the real workspace root. It must also stay inside under the reading Windows uses, which removes . and .. as text
before following any link (so link/.. is the directory holding the link): a path is refused on every platform
unless both readings are inside, so with l -> a/b, l/../../x (a/x on POSIX, x beside the workspace on
Windows) is refused. Only a .. after a symbolic link makes the two differ. That applies to programs added to a custom allowlist too. The candidates are:
- a positional argument, or an option’s value given as the next argument: the whole argument;
- a long option
--name=value: the whole argument and the value, sogit log --since=2024/01/01,--grep=feat/x,git ls-files --exclude=*/target/*,grep --include=sub/*.scalaandls --hide=x/yrun, while--exclude-from=../xor a value through a link out of the workspace is refused; - a short option: the whole argument and every tail after its dash, so an attached value at any position (
-f/x,-rf/x) is checked, and so is the name a program opens when it reads the argument as a file (BSD programs stop reading options at their first operand, socat a.txt -fopens-f); sort -tand--field-separatortake a separator, not a path:sort -t/ -k2,sort -t / -k2andsort --field-separator=/run (POSIX only; see Option values).- on Windows, the
/Xswitches ofdir,findstr,copy,moveandsortare switches, not paths, but a value after:(findstr /G:file) is checked; - on Windows, a string the platform cannot parse as a path is judged by the part before the first character a path
cannot hold (
HEAD:src/xbyHEAD,..\*by..\); one that starts with\or/and has no such part (\\?\C:\x,\??\C:\x) is refused, and so is a drive-relative path on a drive other than the workspace’s (D:x, which the program would resolve from that drive’s own working directory). Windows removes..as text before it opens a name or matches a wildcard, so such a string is also refused when it has a..component after that character (x*\..\..\outside\f,x?\..\..,ab:c\..\.., which open..\outside\falthough their prefix is inside), or when, with each such character replaced by_, it leads outside;dir *.txt,type a?.txtandfindstr /C:x a.txtrun; - on Windows, an argument holding
"is refused (ARGUMENT_NOT_ALLOWED) before any path or option check: the C runtime’s argument parser and cmd.exe delete"as a quote, so"..\outside\fopens..\outside\fand"C:\outside\fan absolute path, and a Windows file name cannot hold one. Quote an argument in the command string instead (findstr "/C:two words" a.txt): that quoting is removed before the checks; - on Windows, the built-ins the runner starts through
cmd.exe /c(dir,type,copy,move,echo, …) refuse (ARGUMENT_NOT_ALLOWED) an argument holding,,=,(,),@,!, a control character (VT, FF, a line feed) or a space other than U+0020 (NBSP, U+00FF):ProcessBuilderquotes an argument only for a space, a tab,",<or>, and cmd.exe splits a built-in’s arguments on,,;,=, VT, FF and 0xFF as well, sotype a.txt,..\outside\ftypeda.txtand then..\outside\falthough the argument checked was one name inside.echomay still print,,=and parentheses. Programs that are not built-ins (findstr) get their arguments from the C runtime, which splits on space and tab only, sofindstr x a,b.txtruns. A wildcard in the last component (dir .*) can match the..entry, butdironly lists it andtypeandfindstrcannot read a directory, so it is not refused.
A working directory, or a file operation’s path, that is not a valid path (a NUL character, or on Windows a : or
wildcard in it) is refused with PATH_ESCAPE_ATTEMPT rather than failing with an exception.
A relative value with no .. component can only leave the workspace through a link, so the over-blocking is limited
to text that is absolute or climbs out with ..: a grep pattern /api or ../x, or an option value such as
git log --grep=/x or --grep /x, is refused although it is not a path (write [/]api, [/]x).
cp writes through a symbolic link it finds at the name it writes, so its destinations are checked as well. Any
operand may be the target, so for every pair of operands the name the source takes under the target (and, for
src/ or src/., the target itself; with --parents, the target joined with the source) must resolve inside the
workspace. A recursive copy also writes below those names, so each that is an existing directory is searched, without
following links, for a link that leads outside.
What these checks do not cover:
gitreads the repository’s own.git/configand runs its hooks, so where the agent can write files it can setcore.fsmonitor,diff.externalor a filter driver, or add a hook such as.git/hooks/post-index-change, thatgit statusorgit diffthen runs, or point git at files outside throughcore.worktree,.git/commondiror.git/objects/info/alternates(#1721).diff -rfollows symbolic links it meets inside the tree it walks; no portable option stops it.- A relative link moved or copied to another depth by the read-write list (
mv a/b/rel rel) can come to point outside. Paths through it are refused, and so is a recursivecpinto its directory, but the link is not removed. - The checks run before the program starts, so a link made at a checked name by a concurrent command is not seen.
Windows
copygets the path rule but notcp’s destination checks.
Option values
An option that takes a value takes the next argument whatever it is, so the argument after a -t is a separator
only if that -t is an option and not another option’s value: in sort -T -t /etc/passwd and
sort --random-source -t /etc/passwd, -T and --random-source take -t, and /etc/passwd is a file sort reads
(#1763). On POSIX the runner therefore parses sort’s arguments as
getopt does, each option’s value consumed exactly once, for the options of both GNU and BSD sort:
| Takes a value | Short | Long |
|---|---|---|
| always | -k, -o, -S, -t, -T |
--batch-size, --buffer-size, --compress-program, --field-separator, --files0-from, --key, --output, --parallel, --random-source, --sort, --temporary-directory |
only after = |
--check |
|
| GNU only: attached, or a next argument of digits | -y |
A value is attached (-Tdir, -rTdir, --temporary-directory=dir) or the next argument (-T dir, -rT dir,
--temporary-directory dir, and any unambiguous abbreviation such as --temp dir); a -- an option takes as its
value does not end the options. Every operand and every other option’s value is checked whole. The separator is
left out only when:
- every option is one GNU or BSD sort has, unambiguously abbreviated, with its value where it needs one;
- no argument starts with
+(BSD sort rewrites the obsolete+POS1 -POS2into-kbefore it reads options, even inside another option’s value, sosort -T +0 -1t /etc/passwdreads/etc/passwd); - the
-tcomes before the first operand (withPOSIXLY_CORRECTin the runner’s environment, GNU sort reads every argument after its first operand as a file; writesort -t / a.txt, notsort a.txt -t /).
Otherwise the separator is checked like any other value, so -t / is refused there. cp is parsed the same way
(GNU’s -S / --suffix and -t / --target-directory take a value; macOS cp takes none and stops at its first
operand), so a -- that -S takes does not hide a later -R (cp -S -- -R src dst), and --path, GNU’s old
name for --parents, gets the --parents destination check. uniq (its -f, -s, -w values) and git branch
(the values of --merged, --contains, --sort, --format, …) already consume each value once. Windows keeps
its own sort rules (see On Windows); there the argument after --field-separator is checked.
On Windows
On Windows the policy refuses (ARGUMENT_NOT_ALLOWED) every form below rather than reasoning about what Win32, cmd.exe
or a program’s runtime makes of it. Over-blocking is accepted there: only the forms the rest of this section allows
are supported. Each rule runs after the path rule, so an argument that leads outside is still PATH_ESCAPE_ATTEMPT.
- Device names, for every program but
echo,pwd,whoamiandhostname: a path component that isCON,PRN,AUX,NUL,COM0-COM9,LPT0-LPT9,COM¹²³,LPT¹²³,CONIN$orCONOUT$, in any case, with any extension and ignoring trailing dots and spaces (nul,sub\con,NUL.txt,aux .txt,aux:s). A device is opened whatever directory precedes it, so it bypasses the path rule;copy a.txt nulis refused too. - Trailing dots and spaces, for the same programs: a component, other than
.and.., that ends in.or a space (outside.,a.txt). Win32 strips them, sooutside.opensoutside. This also refuses git’s open rangeHEAD..(writeHEAD..HEAD) and afindstrpattern ending in.. - Programs that are not cmd.exe built-ins (
findstr,sort,git,grep, …), which may run under a runtime that re-parses the command line itself (MSYS2, Cygwin, Git for Windows):- an argument starting with
@(a response file whose lines become arguments:grep x @args.txt,git log @{1}) or~(a home directory); - an argument holding
{,},[,],',(or)(glob and quoting syntax):grep [ab] a.txtis refused on Windows, so the[/]apiform suggested below for a pattern starting with/is not available there; - a string the program might open as a path that starts with
/(/sub/a.txt,-f/x,--file=/x), which such a runtime reads from its own root rather than the workspace’s drive;findstrandsortkeep their/Xswitches; - a wildcard (
*,?) anywhere but the last component (*/a.txt,--exclude=*/target/*), in an absolute string or one with a..component, or in a last component with no literal character other than.(*,.*,??,*.*), which can match...grep x *.txt,grep x sub/*.scalaandfindstr /S /I x *.txtrun. The built-insdirandtypekeep the wildcard rule above (dir *runs).
- an argument starting with
findstr: a switch withFamong its letters (/F:list,-F:list,/SIF:list;/OFF[LINE]is allowed), which reads the names of the files to search from a file the path rule cannot see into; and a/D:value holding,or;(a directory list)./D:dirwith a single directory is held to the workspace like any path.sort:/O[UTPUT],/T[EMPORARY](any switch whose letter isOorT), a short-option cluster holdingo,O,torT, and--temporary-directory, which write the output or temporary files (a GNUsortearlier on thePATHtakes-tas its field separator; it is refused rather than guessed).- Not refused, by reasoning:
- 8.3 short names (
PROGRA~1). A short name aliases an entry of the directory it is in, so it cannot climb out of that directory, and the path rule’s final step resolves the existing part of a path withtoRealPath, which expands short names before the comparison with the real root. A short name that does not exist names nothing, and refusing~followed by a digit would refuseHEAD~1. - Alternate data streams (
file:stream). The part before the:is judged, so..\outside\f:sis refused anda.txt:sruns.
- 8.3 short names (
git’s repository
git looks for its repository in the working directory and then in each directory above it, so a workspace that is a
subdirectory of a larger repository ran git on that repository: git show HEAD:secret, git diff, git log -p
and git status read files outside the workspace. The runner therefore starts git with GIT_CEILING_DIRECTORIES
set to the workspace root’s parent and without any GIT_* variable the runner’s own environment may carry - those
that point git at another repository or object store (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR,
GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_NAMESPACE,
GIT_DISCOVERY_ACROSS_FILESYSTEM), add configuration (GIT_CONFIG_*, GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT
with GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n) or name a program (GIT_EXEC_PATH, GIT_EXTERNAL_DIFF): a workspace
without a repository of its own gets not a git repository. GIT_CONFIG_NOSYSTEM is not set, so the system and
global git configuration of whoever runs the runner still apply. GIT_CEILING_DIRECTORIES is a list split on the
path-list separator (: on POSIX, ; on Windows) with no escaping, so where the workspace root’s parent path - as
configured or with links resolved - holds that character, git is refused (PATH_ESCAPE_ATTEMPT) rather than run
with a ceiling it would misread. On every platform, git is also refused (PATH_ESCAPE_ATTEMPT) when the nearest
.git between the working directory and the workspace root is not a directory, or is one whose real path lies
outside the workspace (a gitdir: file, a link or a Windows junction points git at a repository elsewhere), and an
argument starting with : is refused (ARGUMENT_NOT_ALLOWED): pathspec magic (:/, :(top), :!x) and index
paths (:a.txt) are resolved from the repository’s top level, not the working directory. A repository inside the
workspace whose .git directory points git at files elsewhere through what git reads from it (core.worktree in
.git/config, .git/commondir, .git/objects/info/alternates), or a bare repository written into the workspace,
is the same class of gap as #1721: it needs the agent to write files.
Security Gaps Addressed
| Gap | Phase 1 | Phase 2 |
|---|---|---|
| Explicit config | ✓ WorkspaceSandboxConfig | |
| Validation at startup | ✓ | |
| Shell allow/block | ✓ shellAllowed | |
| Resource limits | ✓ limits configurable | |
| Read-only areas | Config present | Enforcement |
| Allowed/blocked paths | Config present | Enforcement |
| Network restrictions | Documentation only | Enforcement |
Example: Locked-Down Sandbox
Run the minimal demo (local filesystem, no Docker):
1
sbt "workspaceSamples/runMain org.llm4s.samples.workspace.LockedDownSandboxDemo"
Run the containerized runner with locked sandbox:
- After
sbt workspaceRunner/docker:publishLocal, get the image tag:1
docker images llm4s/workspace-runner --format "{{.Tag}}"
Use that tag (for example
0.3.2or a dynver snapshot such as0.3.2+abc123-SNAPSHOT) in place ofTAGbelow. - Run the container (replace
TAGand the host path to your workspace):1
docker run --rm -e WORKSPACE_SANDBOX_PROFILE=locked -v /path/to/workspace:/workspace -p 8080:8080 llm4s/workspace-runner:TAG
On Windows with Docker Desktop, use a path Docker can mount (e.g.
C:\Users\you\workspaceor//c/Users/you/workspacedepending on your setup).
Phased Implementation Plan
Phase 1: Config + docs + sample ✓
- Affected: workspaceShared, workspaceRunner, workspaceClient, workspaceSamples, docs
- Complexity: Low
- Risks: Minimal; backward compatible (default = permissive)
Phase 2: Enforcement
- Affected: WorkspaceAgentInterfaceImpl (readOnlyPaths, allowedPaths), tools
- Complexity: Medium
- Risks: Path validation edge cases; breaking changes if strict
Phase 3: Advanced policies (optional)
- Affected: New policies module, profiles (dev/staging/prod)
- Complexity: High
- Risks: Over-engineering; maintenance burden