Read and Search a Codebase
A read-only agent that greps and reads files in a mounted repo and reports what it found.
This recipe builds a code-investigation agent. Given a repo path and a
question, it greps and reads files inside the repo, then returns a structured
answer plus the list of files it consulted. The agent gets read, grep,
find, ls — and nothing that writes.
Level 2 of 7. This is your first taste of tools and sandboxes. It assumes Level 1 — structured extraction, where the agent had no tools. Here you add file tools and mount a repo read-only.
Scope the tools down
The agent investigates; it never mutates. So you list exactly four tool ids:
tools: ["ls", "grep", "find", "read"]No write, no edit, no bash. Two reasons:
bashis unnecessary.grep,find,ls, andreadcover discovery and reading. They run inside the sandbox and obey its mounts — you don’t need a shell to chain them.- A smaller toolset is a smaller blast radius. The model can only call what
you list. Omitting
write/editmeans a read-only task stays read-only by construction, not by hoping the prompt holds.
Define the agent
import { Type } from "@sinclair/typebox";
import { defineAgent } from "@drover/core";
export const codeSearch = defineAgent({
id: "code-search",
description: "Answer questions about a codebase by reading and grepping it.",
systemPrompt: [
"You investigate a code repository to answer a question.",
"Use grep/find/ls to locate relevant files, read to inspect them.",
"Cite every file you opened in files_consulted. Do not guess paths you",
"did not read. Return JSON matching the outputSchema, no prose.",
].join(" "),
inputSchema: Type.Object({
repo_path: Type.String(),
question: Type.String(),
}),
outputSchema: Type.Object({
answer: Type.String(),
files_consulted: Type.Array(Type.String()),
snippets: Type.Optional(
Type.Array(
Type.Object({
file: Type.String(),
lines: Type.String(),
text: Type.String(),
}),
),
),
}),
model: "cheap",
tools: ["ls", "grep", "find", "read"],
quota: { maxTurns: 8 },
});maxTurns: 8 gives the agent room for a few grep/read cycles before it must
answer. Investigation is iterative — a flat-out cap of 2 would starve it.
Build the read-only sandbox
By default runAgent mounts the run’s cwd read-write. For investigation
you want the opposite: mount the repo readonly so even a stray write (which
isn’t in the toolset anyway) could not reach disk.
import { createJustBashSandbox } from "@drover/sandbox-just-bash";
function readonlyRepo(repoPath: string) {
return createJustBashSandbox({
mounts: [{ source: repoPath, target: repoPath, mode: "readonly" }],
});
}Mounting source and target at the same path means absolute paths the model
emits resolve to the same place you’d see on the host. The agent cannot
escape the mount root — no .., absolute-path, or symlink traversal reaches
outside repoPath. Anything you didn’t mount simply does not exist to the
agent. Contrast the default:
| Setup | Mount | What the agent can touch |
|---|---|---|
Default (sandbox omitted) | run cwd, readwrite | reads and writes under cwd |
| This recipe | repoPath, readonly | reads under repoPath; writes fail |
The file tools honour the mount. read, grep, find, ls all operate
inside the sandbox namespace and gate user paths through the adapter — there is
no path outside the mount for them to reach. See
Sandboxes for the full boundary model.
Run it and watch the investigation
Pass the sandbox and a cwd so relative paths the model uses resolve against
the repo root:
import { runAgent } from "@drover/facade";
const repoPath = process.argv[2] ?? process.cwd();
const handle = runAgent(
codeSearch,
{ repo_path: repoPath, question: "Where is the model alias map defined?" },
{ sandbox: readonlyRepo(repoPath), cwd: repoPath },
);
for await (const e of handle.events) {
if (e.kind === "tool_call_start") console.log("→ calling", e.toolName);
if (e.kind === "tool_call_end") console.log("← done ", e.toolName);
if (e.kind === "assistant_text") console.log("[reason]", e.text);
}
const result = await handle.result;
console.log("status:", result.status);
if (result.status === "success" && result.output) {
console.log("answer:", result.output.answer);
console.log("files:", result.output.files_consulted);
}The event stream is your window into the agent’s reasoning loop:
tool_call_start/tool_call_endbracket each tool invocation — watching them shows you the grep-then-read path the agent took.assistant_textcarries the model’s between-call reasoning.
A plausible run prints:
→ calling grep
← done grep
[reason] Found DEFAULT_ALIASES in packages/model/src. Reading it.
→ calling read
← done read
status: success
answer: Defined in @drover/model as DEFAULT_ALIASES (packages/model/src).
files: [ "packages/model/src/aliases.ts" ]
What you assembled
- Built-in tools
ls,grep,find,readfrom@drover/tools— discovery + reading, no mutation. createJustBashSandboxwith a singlereadonlymount — a real filesystem boundary; the agent can’t escaperepoPath.cwdinRunOptionsso relative paths resolve against the repo root.quota: { maxTurns: 8 }to bound the investigation loop.- An event loop filtering
tool_call_start/tool_call_end/assistant_textto trace each step.