Astrolabe / ajxpublic /korvus
Repository

Workspace Sandbox Client and Built-in Tools

reference scope internal/workspace Verification failed
  • 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

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.

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.