Tools, Tiers, and Permissions
The agent runtime exposes a catalog of tools — Go functions that an LLM can call to read files, run shell commands, search the web, spawn subagents, and more. Not every tool is visible to the LLM at once. Instead, the catalog is split into two tiers: a compact core set that is always present, and a larger deferred set that is hidden until the agent activates what it needs. On top of that, every run has a permission model that governs filesystem access and whether humans must approve tool calls before they execute.
This page explains both systems: what they are, why they are designed this way, and how to configure them.
Security default — read this first.
When permissions is omitted from a run request, the runner applies DefaultPermissionConfig(): sandbox "unrestricted" and approval "none". This means the agent has unrestricted access to the filesystem and never asks for human approval before executing any tool call — including writes, deletes, and arbitrary shell commands.
Sandboxing and approval are opt-in. A run with no permissions block is fully unsandboxed and full-auto. If you are running the harness in any environment with sensitive data, set permissions explicitly.
Core vs deferred tools
The tool catalog is divided into two tiers, controlled by a Tier field on each registered tool.
| Tier | Constant | Behavior |
|---|---|---|
| Core | TierCore = "core" | Always included in the tool list sent to the LLM |
| Deferred | TierDeferred = "deferred" | Hidden from the LLM until activated via find_tool |
Why two tiers? Every tool definition consumes tokens in the LLM's context window — its name, description, and parameter schema all take space. The full catalog is large. By keeping infrequently-used tools deferred, the runtime delivers a lean, focused tool list to the model by default, reserving context for the actual task.
Core tools
Core tools are always present. They cover the operations an agent needs on virtually every run: file I/O, shell execution, memory, context management, and a few meta-capabilities.
Selected core tools
| Tool | What it does |
|---|---|
read | Read a file in the workspace (up to 1 MB, default 16 KB) |
write | Write or append to a file |
edit | Replace exact text in a file (old_text → new_text) |
apply_patch | Apply a unified diff, a batch of edits, or a single find/replace |
bash | Run a shell command (default timeout 30 s, max 3600 s) |
job_output | Fetch stdout/stderr from a background bash job |
job_kill | Kill a background job |
AskUserQuestion | Pause the run and ask a human a question |
working_memory | Per-run key-value store (set, get, delete, list) |
context_status | Report estimated context token usage |
compact_history | Compact conversation history to reduce context pressure |
todos | Manage a per-run todo list |
skill | Run a named skill by name and optional args |
find_tool | Activate deferred tools by keyword search or direct select |
cron_create, cron_list, cron_get, cron_update | Create, list, read, and update scoped recurring jobs |
cron_history, cron_delete, cron_pause, cron_resume | Inspect executions and manage a job lifecycle |
Source: internal/harness/tools_default.go:191–228.
Deferred tools and find_tool
Deferred tools are registered into the catalog but not sent to the LLM in the initial tool list. The agent activates them by calling find_tool, a core meta-tool that accepts either a keyword query or a direct select:<name> specifier.
When find_tool activates a tool, the tool is added to the LLM's available set for the remainder of that run. The ActivationTracker (internal/harness/activation.go) maintains this per-run state and cleans it up when the run ends.
Deferred tool groups include:
- Git / code intelligence —
git_log_search,git_file_history,git_blame_context,git_diff_range - Web —
web_search,web_fetch,agentic_fetch - Agent orchestration —
run_agent,spawn_agent,start_subagent,wait_subagent - MCP integration —
connect_mcp,list_mcp_resources,read_mcp_resource, plus dynamically registeredmcp_<server>_<tool>tools - Workflows and skills —
create_workflow,run_workflow,create_skill,verify_skill - Profile management —
list_profiles,get_profile,create_profile,update_profile
Source: internal/harness/tools_default.go:224–470.
When a cron client is configured, all eight cron_* tools listed above are
core tools. They are available in the initial model turn and must be called
directly; find_tool only selects deferred tools and is not a cron discovery
step.
LSP tools (lsp_diagnostics, lsp_references) are defined but are not included in the default registry. They require a running language server and must be wired manually.
Permission model
Every run operates under a PermissionConfig with two independent axes: sandbox scope and approval policy.
// internal/harness/types.go:693-696
type PermissionConfig struct {
Sandbox SandboxScope `json:"sandbox"`
Approval ApprovalPolicy `json:"approval"`
}
Sandbox scope
The sandbox scope controls what the agent's bash tool can access.
"unrestricted" — No filesystem restrictions. This is the default when permissions is omitted.
The agent can read and write any path on the host filesystem and run arbitrary shell commands.
Source: internal/harness/types.go:670–677.
Approval policy
The approval policy controls when the agent must pause and wait for a human operator to approve a tool call before it executes.
"none" — Never ask for approval. The agent runs fully autonomously. This is the default.
Source: internal/harness/types.go:684–690.
How approval requests flow through the API
When a tool call requires approval, the run transitions to waiting_for_approval status and the SSE stream emits a tool.approval_required event:
{
"type": "tool.approval_required",
"payload": {
"call_id": "call_abc",
"tool": "bash",
"arguments": "{\"command\":\"rm -rf build/\"}",
"deadline_at": "2024-01-15T12:00:30Z"
}
}
The operator approves or denies via the HTTP API:
# Approve
curl -X POST http://localhost:8080/v1/runs/{id}/approve
# Deny
curl -X POST http://localhost:8080/v1/runs/{id}/deny
After approval, the stream emits tool.approval_granted and execution continues. After denial, the stream emits tool.approval_denied and the tool returns a permission_denied result to the LLM — the run does not fail, it continues with that outcome.
Source: internal/harness/approval_broker.go:72, internal/server/http_runs.go.
Restricting a run with allowed_tools
In addition to the permission model, you can limit which tools an agent can even attempt to call by passing an allowed_tools allowlist in the RunRequest.
{
"prompt": "Summarize the README file",
"allowed_tools": ["read", "bash", "working_memory"],
"permissions": {
"sandbox": "workspace",
"approval": "none"
}
}
When allowed_tools is non-empty, the LLM only sees the listed tools. If it is empty or omitted, all registered tools are available.
AlwaysAvailableTools
Three tools always bypass the allowed_tools filter regardless of what is listed:
// internal/harness/skill_constraint.go:13
var AlwaysAvailableTools = map[string]bool{
"AskUserQuestion": true,
"find_tool": true,
"skill": true,
}
These are infrastructure tools the agent needs to function: AskUserQuestion for human-in-the-loop interactions, find_tool for activating deferred tools, and skill for running named skills. You cannot exclude them via allowed_tools.
Skill constraints
When the agent invokes a skill that carries its own tool-filter constraint, that constraint temporarily overrides the base allowed_tools for the duration of the skill execution. The skill.constraint.activated and skill.constraint.deactivated events mark the boundaries of this override.
Source: internal/harness/types.go:383–389, internal/harness/skill_constraint.go.
Putting it together — a hardened RunRequest
Here is a RunRequest body that activates all three safety levers: workspace-confined sandbox, approval required for destructive calls, and a narrow tool allowlist.
{
"prompt": "Refactor internal/server/http.go to extract the route table",
"model": "gpt-4.1",
"allowed_tools": [
"read",
"edit",
"apply_patch",
"bash",
"git_status",
"working_memory"
],
"permissions": {
"sandbox": "workspace",
"approval": "destructive"
}
}
Send it to a running harnessd instance:
curl -X POST http://localhost:8080/v1/runs \
-H "Content-Type: application/json" \
-d '{
"prompt": "Refactor internal/server/http.go to extract the route table",
"model": "gpt-4.1",
"allowed_tools": ["read", "edit", "apply_patch", "bash", "working_memory"],
"permissions": {
"sandbox": "workspace",
"approval": "destructive"
}
}'
For a key-free smoke test, start harnessd with the fake provider first:
HARNESS_PROVIDER=fake HARNESS_AUTH_DISABLED=true go run ./cmd/harnessd
Source: internal/harness/types.go:319–425, cmd/harnessd/main.go.
permissions is a nested object inside the run body — not top-level sandbox/approval fields. Passing {"sandbox": "workspace"} at the top level has no effect; it must be {"permissions": {"sandbox": "workspace", "approval": "destructive"}}.
Quick reference
Sandbox scopes
| Value | Filesystem access | Network (bash) |
|---|---|---|
"unrestricted" | Anywhere (default) | Unrestricted |
"local" | Anywhere | Blocked (curl, wget, nc, etc.) |
"workspace" | Workspace directory only (heuristic) | Unrestricted |
Approval policies
| Value | Reads | Writes / bash | All calls |
|---|---|---|---|
"none" | Auto | Auto (default) | — |
"destructive" | Auto | Requires approval | — |
"all" | Requires approval | Requires approval | Requires approval |