Tools, sandbox & policy
Everything an agent does — as opposed to says — goes through a tool. Each one is named, typed, individually gateable, and recorded in the audit log by name.
The built-in tools
| Files | read, write, edit, multi_edit, glob, grep |
| Commands | exec (one shot), session (a persistent process it can type into) |
| Web | web_search, web_fetch |
| Memory | memory_write, memory_read, memory_recent |
| Attachments | attachment_read, send_file |
| People | person_record |
| Other agents | delegate (a subagent), ask_agent (another persona), task_plan |
| Skills | skill_create, skill_improve, skill_toggle |
| Itself | agent_create, agent_configure, nouride |
| Sessions | sessions |
Which of them one agent gets is [tools] enabled in its pack. Anything not listed is not in the
schema at all — the model does not know it exists, which is deliberate: a tool in the schema is a
promise the model will try to keep.
web_search follows the same rule at the daemon level. It is only offered when a backend actually
works, so an agent on a box with no search key does not fail on every call; it simply does not have
the tool.
Where a tool may reach
An agent’s file and exec tools resolve paths against its workspace roots:
[workspace]roots = ["/home/you/projects/thing"]Empty means its own directory only — safe, but it cannot touch a project. Every agent also gets a
read root at agents/skills/, so it can open the skills it was offered.
A path outside every root is refused however it is spelled — .. traversal, symlinks and absolute
paths all resolve first. nouride doctor reports a declared root that does not exist, because that
is the case where everything else looks green while every path is rejected.
.nouride/workspace/shared/ is where a relative path lands, and it is shared by every agent on
purpose. Why →
Credentials an agent can use but never read
The credentials directory is deliberately not a workspace root. read, glob and grep cannot
reach it. Only a command can, through $NOURIDE_CREDENTIALS_DIR, which the harness block tells the
agent about.
That distinction is the whole point. A credential that reaches the context window is in the
provider’s request log, in the session on disk, and in whatever the model says next. A chmod
reaches none of those.
[security]credentials_dir = "" # empty = <data_dir>/credentials, created 0700 at bootcat $NOURIDE_CREDENTIALS_DIR/token.json is a hardline path class and asks whatever the exec policy
says. python3 sync.py, where the script opens the file itself, is not. A command you wrote may use
a credential; the agent pulling one into a conversation has to be asked about.
The daemon refuses to start if the credentials directory lands inside any readable root, so a placement that would quietly let the model read a key fails at boot rather than at no point at all.
The other half of the same idea is exec_secrets — secret names whose values arrive in a child
process as environment variables at the moment the command runs, and never in the prompt.
Approvals & permissions →
The approval gate
A gated tool call parks the turn and asks a human. Nothing runs while it waits, and a request nobody answers expires as a denial rather than an approval.
The classifier behind it groups commands into hardline classes — destructive, system control,
infrastructure, credentials, this daemon’s own state — that can never become a standing grant, and it
looks through privilege escalation so sudo rm -rf / classifies as a delete rather than as a
sudo.
The daemon refuses to start in one configuration
A gateway enabled, an agent holding exec, and both the exec allowlist and the sender allowlist
empty. That combination is a remote shell for anyone who finds the bot, so it is a boot failure
rather than a warning in a log nobody reads.
Limits on one call
| Default | ||
|---|---|---|
tool_timeout_ms | 30 s | one tool call |
tool_max_output_bytes | 10 MB | output held to be returned to the model |
approval_timeout_ms | 5 min | unanswered requests expire as denials |
max_tool_iterations | 25 | tool calls in a single turn |
max_turn_duration_ms | 5 min | wall clock on one whole turn |
command_timeout_ms | 10 min | a scheduled --command payload |
max_turn_duration_ms is separate from the per-request and per-tool timeouts, and it exists because
25 iterations each finishing just inside their own limit is a turn that runs for a quarter of an hour
while its chat’s queue waits behind it.
Kernel ceilings on a single command exist and are off by default:
[security.exec]max_memory_mb = 0 # prlimit --as; Linux onlymax_cpu_seconds = 0They are off because --as caps address space rather than resident size, and V8 reserves far more
address space than it uses — at a 1 GB cap, tsc aborts while using 250 MB. A cap low enough to
protect a small machine kills every build on it; one high enough to let builds run protects nothing.
What does help on a shared box: commands the agent spawns run at nice 10, and the generated systemd
unit carries a soft MemoryHigh at 80% of the machine’s memory, written as an absolute number
computed at install time.
Web access
web_fetch blocks private, LAN and loopback addresses by default, and blocks cloud metadata
endpoints unconditionally.
[security.web]allow_private_urls = falseTurning it on is what lets an agent check a service it just started — http://localhost:3000/health
after running a dev server. It is off by default because on a server it turns web_fetch into a
reader of every internal service on the box, and the agent takes its instructions from chat.
web_search has three backends: Brave (a key, a free tier), SearXNG (self-hosted, no key), and a
keyless scraping fallback. On a runtime failure — 429, 5xx, a timeout — the primary falls back rather
than losing an agent’s search mid-task.
Image search rides on the same tool (kind = "image") and returns direct image URLs that send_file
can take, which is the whole “find a picture and send it” path with no browser anywhere in it. Only
the two configured backends can do it; the keyless fallback scrapes result pages and has no image
mode, so it refuses and names both settings rather than handing back page URLs that are not images.
Rate limiting
[security.rate_limit]messages_per_minute = 10 # per sender, per gatewaycooldown_reply = "Please slow down."Untrusted text is marked as such
Two sources are marked before the model sees them: MCP tool results and their descriptions, and web content. Both are written by somebody who is not you.
Marking is not gating. A server you would not run a command from is a server you should not connect.
Interactive commands
exec runs with stdin closed — the right default for a one-shot command. The session tool opens a
persistent process the agent can type into, so ssh host, kubectl exec -it, vim and a sudo
password prompt are reachable.
That needs a real pty, which comes from script(1) in util-linux. It works in the Docker image and
on native Linux with util-linux installed. A native macOS install does not get it — BSD
script cannot allocate a pty under a daemon, so sessions there fall back to pipes: fine for a
program that reads stdin, useless for anything that draws a screen.
Long-running non-interactive work belongs in a background job instead.
Subagents
delegate starts a subagent: no chat history, no slash commands, and half the iteration budget. It
shares the parent’s tools and permissions.
ask_agent is the other shape — a different persona, with its own skills, tools and permissions,
running behind the conversation. Its progress appears labelled with its name and its answer comes
back to the agent that asked. The agent you are talking to stays the only one that speaks.