Skip to content

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

Filesread, write, edit, multi_edit, glob, grep
Commandsexec (one shot), session (a persistent process it can type into)
Webweb_search, web_fetch
Memorymemory_write, memory_read, memory_recent
Attachmentsattachment_read, send_file
Peopleperson_record
Other agentsdelegate (a subagent), ask_agent (another persona), task_plan
Skillsskill_create, skill_improve, skill_toggle
Itselfagent_create, agent_configure, nouride
Sessionssessions

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:

.nouride/agents/nouva/config.toml
[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 boot

cat $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 full permission model →

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_ms30 sone tool call
tool_max_output_bytes10 MBoutput held to be returned to the model
approval_timeout_ms5 minunanswered requests expire as denials
max_tool_iterations25tool calls in a single turn
max_turn_duration_ms5 minwall clock on one whole turn
command_timeout_ms10 mina 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 only
max_cpu_seconds = 0

They 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 = false

Turning 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 gateway
cooldown_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.