Workspace Sandbox Client and Built-in Tools
- internal/workspace/client.go
- internal/workspace/handlers.go
- internal/workspace/args.go
- internal/workspace/sandbox.go
- internal/workspace/symlinks.go
- internal/workspace/run.go
- displayPath
- friendlyFSError
- requiredString
- intArg
- splitLines
Relevant source files · 9
- internal/workspace/client.go 1–20, 30–32, 44–52, 54–72 +5 more
- internal/workspace/handlers.go 14–30, 32–117, 119–181, 183–265 +3 more
- internal/workspace/sandbox.go 11–19, 21–36, 43–47, 70–115 +3 more
- internal/session/persist.go 342–352
- internal/workspace/args.go 12–14, 25–31, 51–66, 68–79 +2 more
- internal/workspace/symlinks.go 22–73, 91–162
- internal/session/scratch.go 104–137
- internal/mcp/registry.go 117–139
- internal/workspace/run.go 28–47
The internal/workspace package gives the agent a filesystem: five tools — list, read, grep, write, edit — that operate on exactly one directory, the session's scratch directory, and nothing outside it. The package exists because the agent needs to read the skills and docs it was seeded with, write its notes, and search source while investigating an incident, but the constraints that govern Korvus forbid the obvious implementations. A separate mcp-workspace binary spoken to over stdio would need os/exec, and a long-lived HTTP workspace service would add a process and a token scheme to a system that has no auth at all. So the tools live in-process, behind the same mcp.MCPClient interface every remote MCP server implements, and the agent reaches them only through the registry's Call — the audited surface client.go.
Two design decisions shape everything else. First, path scoping is structural rather than checked by convention: the sandbox root is a field of the client that no tool argument can influence, and every handler resolves its argument against it client.go. Second, the handlers are plain functions of (root, args) rather than methods closing over state, so that lifting them into a standalone binary later is a transport swap rather than a rewrite client.go.
How it works
The client
Client holds the resolved root and a table of tools, and nothing else — no connection, no mutable state — so a single client can serve every turn of a run concurrently client.go. New resolves the root through filepath.EvalSymlinks exactly once, at construction, so that every later prefix comparison is between two fully resolved paths; a root that is itself a symlink therefore cannot smuggle a path out of the sandbox client.go. The root must already exist: a missing scratch directory is a session-setup defect, and failing at construction is louder than every tool call failing later client.go.
ListTools returns the advertised tools sorted by name client.go. CallTool looks the name up in the table and, on a hit, hands the call to the handler together with the root client.go. An unknown tool name is the one case that returns a Go error rather than an error-flagged result; the registry turns that into an error-flagged result one layer up client.go.
Every model-facing failure goes through errorResult, which returns a CallResult with IsError set and the message formatted from the call site client.go. The package treats the wording of these messages as part of the contract: the comment on the helper says vague messages produce doom loops, so a refusal names the rule that was broken and the argument that would satisfy it instead.
The tools
The tool table lives in builtins, a slice of tool values pairing an mcp.Tool definition with its handler handlers.go. Each definition carries an inline JSON Schema, wrapped by schema, a helper that panics on a malformed literal so that a programming error surfaces in the first test that lists tools rather than as a confusing wire error client.go.
| Tool | Purpose | Arguments |
|---|---|---|
workspace_list |
List a directory in the workspace | path (defaults to the root), recursive |
workspace_read |
Read a text file with line numbers | path (required), offset (1-based, default 1), limit |
workspace_grep |
Search file contents with a Go regular expression | pattern (required), path, glob |
workspace_write |
Create or overwrite a file | path (required), content (required) |
workspace_edit |
Replace an exact substring in a file | path, old_string, new_string (required), replace_all |
Ceilings are declared once, next to the tools that honour them: reads are capped at 32 KiB, writes at 1 MiB, listings at 1000 entries, greps at 200 matches, and greps skip files larger than 4 MiB handlers.go. The read ceiling deliberately matches the agent's own tool-result ceiling, so a result truncated at the workspace layer is not truncated a second time with a differently-worded notice appended handlers.go. The notices themselves differ on purpose: the agent's generic advice about narrowing an observability query cannot be followed for a file, so this layer emits its own notice naming the offset and limit arguments that actually exist on workspace_read handlers.go.
Resolving a path
Every handler begins the same way: take the model's relative path and turn it into an absolute path proven to be inside the sandbox. resolve performs that in four steps, and the order is the whole security property sandbox.go.
Absolute paths and volume-qualified paths are rejected syntactically, then any .. element is rejected after cleaning. The path is joined onto the resolved root, symlinks are resolved, and only then is the result compared against the root. Checking the prefix before resolving symlinks is the classic escape: root/link where link -> /etc passes a textual prefix test and then reads /etc/passwd sandbox.go.
resolveExisting is what makes that ordering work for paths that do not exist yet — every write to a new file, for instance. It walks up to the deepest existing ancestor and resolves that, and it handles the case filepath.EvalSymlinks cannot: a dangling symlink, whose target does not exist yet, is reported as ENOENT, the same as a plain absent path. Stepping up past such a link would check the link's parent and never the link, while os.WriteFile would follow it and create its target wherever it points. So each ENOENT is confirmed with Lstat, and a symlink found there is followed by hand, its target joined onto the resolved parent, with resolution restarted from there sandbox.go. The walk is bounded by maxSymlinkHops, so a link cycle ends in a "too many levels of symbolic links" error instead of looping sandbox.go.
Containment is then enforced again by the kernel. openRoot opens the sandbox as an os.Root and returns the path as a name relative to it; every handler does its I/O through that handle sandbox.go. Because os.Root refuses any traversal, symlink or .. that leaves the root at open time, a gap in the path checks cannot by itself produce an out-of-sandbox read.
Read-only trees
Four top-level names are read-only at runtime: skills, docs, templates and context sandbox.go. The first three are synced from the workspace git repository at a pinned commit, so a session that rewrote them would break reproducibility; context holds the inherited artifact enumeration and is the incident-isolation boundary. checkWritable asks about both the path the model spelled and its symlink-resolved form, because the two catch opposite mechanisms: the resolved form catches a symlink inside the sandbox pointing at a denied directory, and the spelled form catches a denied directory that is itself a symlink sandbox.go. The spelled form is checked first so that the refusal names the directory the model actually typed sandbox.go.
ReadOnlyPrefixes hands out a copy of that list so that the persist phase can skip the trees a session could not have produced, without the two packages holding separate copies of the same names sandbox.go. The harvest in the session package does exactly that, adding the checkout directory to the skip set persist.go.
The handlers
listHandler opens the target through the root handle and walks it either one level or recursively, rendering each entry through describeEntry, which flags a symlink rather than silently omitting it handlers.go. readHandler numbers lines as line|text and pages with offset and limit; a file with no lines returns "is empty" rather than an error, and a read past the end of the file reports the line count so the model can correct the offset handlers.go. grepHandler compiles the pattern as a Go regular expression, walks the tree without following symlinked directories, and skips files over the size ceiling; an unreadable subtree is skipped rather than abandoning the search handlers.go.
writeHandler refuses content over the per-file ceiling with a message that tells the model to write the file in pieces or to write a summary instead of raw data, then resolves, checks writability, creates parent directories, and writes the file handlers.go. editHandler requires an exact match: no match is an error, and a match occurring more than once is an error unless replace_all is set, in which case the message tells the model to add surrounding context or to set the flag handlers.go. It preserves the existing file mode rather than imposing a hardcoded one, since the file was already there handlers.go.
Arguments and path hiding
Arguments arrive as decoded JSON, so an integer arrives as float64 and a "1" may arrive as a string. The coercion helpers in args.go are lenient about that while still refusing a genuinely wrong type, so a formatting slip does not cost a whole turn args.go. requiredString returns false when the key is absent, when the value is not a string, or when the string is empty args.go. intArg accepts a number in any of the three integer shapes JSON decoding can produce, or a string holding one args.go. splitLines drops a single trailing empty element so a file ending in a newline does not report a phantom final line args.go.
Two helpers exist purely so that the model never learns where the sandbox lives. displayPath renders an absolute sandbox path in the root-relative form the model used, because a leaked host path would tell the model where the sandbox is and invite it to use that path directly args.go. friendlyFSError does the same for errors: the os error text embeds the full path, so a plain %v would leak it, and the helper rewrites the message into "does not exist", "is not readable", or the operation and cause with the root-relative path substituted args.go.
Symlinks in the synced tree
The runtime checks are keyed on path names, which leaves one shape they cannot see: a checkout that stores the pinned tree elsewhere and symlinks a denied name onto it. That is closed at sync time instead. validateSymlinks refuses a commit whose tree carries a symlink the sandbox cannot treat as harmless — one that is, or sits inside, a deny-listed top-level directory, one with an absolute target, or one that resolves outside the checkout or to nothing symlinks.go. The commit is refused as a whole rather than with the links dropped, because a pinned commit must be exactly what the session's workspace reference names symlinks.go. The link walk follows targets the way open(2) would, keeping a stack of the directories it is physically in, so a .. that follows a symlink resolves against the link's target rather than the tree root symlinks.go. The rest of the sync story — checkout, materialization into a session's scratch directory, and the periodic refresh loop — is covered in Workspace Sync, Checkout and Materialization.
Relationships
flowchart TD
agent["internal/agent/toolexec.go"] --> registry
registry["internal/mcp/registry.go"] --> client
client["internal/workspace/client.go<br/>Client"] --> builtins
builtins["internal/workspace/handlers.go<br/>builtins"] --> handlers
handlers["listHandler, readHandler, grepHandler,<br/>writeHandler, editHandler"] --> resolve
handlers --> checkWritable
handlers --> openRoot
resolve["internal/workspace/sandbox.go<br/>resolve, resolveExisting"] --> openRoot
checkWritable --> denyWritePrefixes
args["internal/workspace/args.go<br/>requiredString, intArg, splitLines,<br/>displayPath, friendlyFSError"] --> handlers
sync["internal/workspace/sync.go"] --> symlinks["internal/workspace/symlinks.go<br/>validateSymlinks"]
session["internal/session/scratch.go"] --> client
session --> persist["internal/session/persist.go<br/>skipPrefixes"]
persist --> readOnly["ReadOnlyPrefixes"]The session layer constructs one client per run and registers it with a per-session MCP registry under the server name workspace; the same registry also carries the session's pinned docs_search client scratch.go. The agent then dispatches tool calls through the registry, which routes by tool name to the owning server registry.go. See Agent Run Loop, Tool Execution and Provider Calls for the dispatch path and MCP Client, Registry and Server Connection for the registry itself.
Reference
| Symbol | Kind | What it does | Location |
|---|---|---|---|
Client |
struct | In-process mcp.MCPClient serving file tools over one session's scratch directory; holds the resolved root and the tool table client.go |
client.go |
New |
function | Builds a client over root, resolving the root's symlinks once at construction client.go |
client.go |
Client.Root |
method | Returns the resolved sandbox root, for session wiring that reports the directory a run was confined to client.go | client.go |
Client.ListTools |
method | Advertises the workspace tools sorted by name client.go | client.go |
Client.CallTool |
method | Dispatches one tool call to its handler, or returns an error for an unknown tool name client.go | client.go |
errorResult |
function | Builds the error-flagged CallResult used for every model-facing failure client.go |
client.go |
schema |
function | Wraps an inline JSON Schema literal and panics if the literal is not valid JSON client.go | client.go |
builtins |
variable | The five workspace tool definitions with their schemas and handlers handlers.go | handlers.go |
listHandler |
function | Lists a directory, one level or recursively, rendering entries with describeEntry handlers.go |
handlers.go |
readHandler |
function | Reads a text file with line numbers, honouring offset and limit handlers.go |
handlers.go |
grepHandler |
function | Searches file contents with a Go regular expression, honouring glob and a match ceiling handlers.go |
handlers.go |
writeHandler |
function | Creates or overwrites a file after the size and writability checks handlers.go | handlers.go |
editHandler |
function | Replaces an exact substring, requiring a unique match unless replace_all is set handlers.go |
handlers.go |
resolve |
function | Turns a model-supplied relative path into an absolute path proven to be inside the sandbox sandbox.go | sandbox.go |
resolveExisting |
function | Resolves symlinks on a path, following dangling links by hand when the target does not exist yet sandbox.go | sandbox.go |
openRoot |
function | Opens the sandbox as an os.Root and returns the path as a name relative to it sandbox.go |
sandbox.go |
checkWritable |
function | Rejects a write whose spelled or resolved path lands in a read-only directory sandbox.go | sandbox.go |
denyWritePrefixes |
variable | The four read-only first path elements: skills, docs, templates, context sandbox.go |
sandbox.go |
ReadOnlyPrefixes |
function | Returns a copy of the read-only prefix list for the session persist phase sandbox.go | sandbox.go |
validateSymlinks |
function | Refuses a commit whose tree carries a symlink the sandbox cannot treat as harmless symlinks.go | symlinks.go |
resolveLink |
function | Follows a symlink target through the tree the way open(2) would symlinks.go |
symlinks.go |
Syncer.Run |
method | Refreshes the checkout on every tick of the interval, swallowing and logging failures so the loop survives a transient outage run.go | run.go |
requiredString |
function | Reads a required non-empty string argument args.go | args.go |
intArg |
function | Reads an integer argument in any numeric shape JSON decoding produces args.go | args.go |
splitLines |
function | Splits on newlines and drops a single trailing empty element args.go | args.go |
displayPath |
function | Renders an absolute sandbox path in the root-relative form the model used args.go | args.go |
friendlyFSError |
function | Rewrites a filesystem error so it carries the root-relative path instead of the host path args.go | args.go |
Generated by astrolabe from commit 0c2dca73 on branch main.