Browse docs/

Local Tools

Contenox never touches the filesystem or spawns processes itself. Local tools give a model access to the filesystem and shell of the machine it runs on, and they differ in what they need to do it. local_fs holds two halves under one name: its content tools (read, write, edit, sed) go through the connected client’s fs/* capability, and its browsing tools (list_dir, grep, find_files, stat_file, count_stats) walk the tree in the process the runtime runs in. local_shell is forwarded to the client’s terminal/* capability.

A standing host has no client, so the client-backed half of local_fs and all of local_shell are absent there; what a host has is the in-process toolsets, and every other capability is an MCP server or an OpenAPI service you attached.

An unattended mission unit — what contenox run "<task>" dispatches, and what contenox mission fire and /mission fire — has no client either, so it runs on the in-process toolsets (local_fs, native-git, native-go, native-jq, the mission tools) and whatever MCP servers you connected. Its local_fs is backed by the workspace itself rather than by a client, and its local_shell runs on the machine the runtime runs on.

local_fs — Filesystem access

Forwarded to the ACP client’s fs/* capability. Provides read, write, and edit operations scoped to a configured directory. All paths are validated against the allowed directory; attempts to escape with ../ are rejected.

The filesystem root comes from the ACP session’s workspace context.

tools_policies.local_fs controls read/output limits and denied path substrings, and can override the root for a specific task with _allowed_dir.

Directory listing, searching, and globbing are not part of this toolset — ACP defines no fs/list or fs/grep method to forward them through. Use local_shell (ls, find, grep/rg) for those instead.

What the envelope axes reach

The files.read and files.write axes bind to these tools by name, which is what makes a one-word grant enforceable:

AxisTools it emits rules for
files.readread_file, read_file_range, and the directory probe accessview/agentview run
files.writewrite_file, edit_file, sed
shelllocal_shell

Between them the two file axes cover every tool local_fs exposes, so files.read = "deny" is a complete answer for reads rather than a partial one — there is no sixth local_fs tool to slip past it. The always_deny rules use tool = "*" instead, so the credential quarantine holds regardless of what the toolset grows.

The consequence worth internalising: a tool the axes do not name is not covered by them. A tool you connected is an MCP tool reached by its own name, so it matches no axis rule and falls to default_action until you name it under [envelopes.<name>.tools].

Tools

ToolParametersDescription
read_filepathRead the full content of a file. Also satisfies the read-before-mutate prerequisite for write_file / edit_file / sed against the same path.
write_filepath, contentWrite content to a file (creates parent dirs, overwrites). For existing files, requires a prior full read_file against the same current file version in this session.
edit_filepath, old_string, new_string, replace_all (optional)Replace an exact, byte-for-byte occurrence of old_string with new_string in an existing file — the targeted alternative to write_file’s full overwrite. See edit_file below.
sedpath, pattern, replacementReplace a literal string in a file (not regex). For existing files, requires a prior read_file or read_file_range of the same path in this session.
read_file_rangepath, start_line, end_lineRead a specific line range. Satisfies targeted edit_file / sed mutations, but not full-file write_file overwrites.

edit_file: exact-string replacement

edit_file replaces one exact occurrence of old_string with new_string in an existing file, without resending the whole file — the preferred tool for a targeted change, cheaper and safer than write_file’s full overwrite.

  • Byte-exact and unique. old_string must match the file’s current on-disk text exactly, whitespace included. By default it must occur exactly once; if it matches zero times the file is left unchanged and the model is told to re-read and retry with the exact current text, and if it matches more than once the call is refused with a count so the model can add surrounding context to make it unique — a fuzzy or ambiguous match is never applied.
  • replace_all. Set replace_all: true to replace every occurrence instead of requiring exactly one (e.g. renaming an identifier throughout the file).
  • Read-before-write. Same contract as sed: a prior read_file or read_file_range of the current file version in this session is required before edit_file may run; the file’s hash is re-verified immediately before writing, and a change since the read (by anyone) refuses the edit rather than clobbering it.
  • Returns compact JSON (path, written, replacements, old_bytes, new_bytes, old_sha256, new_sha256) — not the full file bodies.

Read-before-write contract

write_file against an existing file is blocked unless the same session has previously called full read_file on that exact current file version. A line-range read is not enough for full-file overwrite because unseen content could be destroyed.

edit_file and sed are targeted mutators, so either read_file or read_file_range against the same path can satisfy their prerequisite. New files (paths that do not yet exist) are unaffected.

The model receives a soft denial it can act on: it sees a tool result instructing it to read the file first, then retry the mutation.

This is a deterministic guard — no LLM judgement involved — designed to prevent confabulated edits to files the model has never seen. The contract is scoped per session: a read in one contenox session does not satisfy a write in another. The state lives in a private local_fs_reads table the tool maintains itself; the chain engine has no visibility into it.

If the model uses local_shell (cat, head, grep, sed) instead of local_fs.read_file, the guard does not count it as a satisfying read — by design. The shell tools are not bounded the same way and broadening the guard to recognise their output reliably is impractical. Prefer local_fs.* tools for file inspection (the default chains include a TOOL PREFERENCE system-prompt addendum that nudges the model toward this).

Approval diff

When a write_file, edit_file, or sed call is gated to approve by the active HITL policy, the approval prompt carries a unified diff of the exact change, not just the raw tool arguments: HITL independently re-reads the file’s current on-disk contents (bypassing this session’s read-dedup cache, so the diff is never built from a stale copy) and computes the prospective new contents by replaying the same mutation the tool would make, then renders a unified diff (±3 lines of context, capped at 500 file lines / 120 diff lines) for the human to review before approving. A new file (no prior content) shows as an addition. If the current contents cannot be established safely, the ask is still shown, without a diff.

tools_policies.local_fs keys

Set per-task read/output limits and denied path substrings by adding a tools_policies.local_fs block to execute_config:

KeyTypeDefaultDescription
_allowed_dirpathregistration rootOverride the allowed filesystem root for this task. Relative paths resolve against the active workspace/cwd where available.
_max_read_bytesint1048576 (1 MiB)Max file size for a whole-file read_file. 0 or negative = unlimited. Larger files return an error so the model can narrow with read_file_range.
_max_output_bytesint32768 (32 KiB)Max byte size of any tool result returned to the model. 0 or negative = unlimited. Prefer setting _model_context_tokens (below) over overriding this directly.
_model_context_tokensintunsetWhen set (and _max_output_bytes is not), derives the output cap as a fraction of the model’s context window instead of using the fixed default.
_denied_path_substringscomma-sepemptyPath substrings that always reject (e.g. node_modules,.git/,dist/). Matched against the path relative to the allowed root.
_verbose_tool_descriptionsbool-stringfalseRestore the long-form tool descriptions (truncation semantics, did-you-mean suggestions, the read-before-write contract) for large-context models. Off by default to save tokens on every turn.
"tools_policies": {
  "local_fs": {
    "_allowed_dir": ".",
    "_max_read_bytes": "1048576",
    "_max_output_bytes": "32768",
    "_denied_path_substrings": "node_modules,.git/,dist/,/.next/,/out/,package-lock.json"
  }
}

Values are strings even when conceptually numeric — tools_policies is the chain’s policy carrier and uses string values uniformly across tools. The default chains ship with conservative limit, root, and deny-substring defaults.

Chain example

"execute_config": {
  "model": "qwen3:8b",
  "provider": "ollama",
  "tools": ["local_fs"]
}

local_shell — Shell command execution

Caution: local_shell gives the model direct access to run arbitrary commands on the machine the ACP client is running on. Never enable it in public-facing deployments or when processing untrusted user input.

local_shell is forwarded to the ACP client’s terminal/* capability, governed by HITL policy — there is no CLI flag that turns it on or off. Directory listing, searching and file metadata do not need it: local_fs carries list_dir, grep, find_files, stat_file and count_stats, which run in-process and are therefore also reachable from a shape with no client to forward a terminal to. Prefer them over ls/find/grep in a shell.

Command policy is a file, not a CLI flag. For a declared agent it lives in agents.toml, globally or under [agents.<name>]:

[tools_policies.local_shell]
_allowed_commands = "git,go,make,ls,cat"
_denied_commands  = "sudo,su,dd,mkfs"

This is the toolset’s own gate — it decides what the tool will run at all. Which of the surviving commands ask is the envelope’s shell axis, a separate and later question:

[envelopes.mine.shell]
grant = "approve"
blacklist = ["mkfs", "fdisk", "shred"]
prefix_allowlist = ["go test", "ls", "cat"]
ask_always = ["rm", "sudo", "chmod"]

Two gates, deliberately: tools_policies is per-task and travels in the chain, while the envelope is per-session and is what a human reviews. A command has to pass both.

In a chain you author yourself it is a tools_policies block on execute_config, per task:

"execute_config": {
  "model": "{{var:model}}",
  "provider": "{{var:provider}}",
  "tools": ["local_shell"],
  "tools_policies": {
    "local_shell": {
      "_allowed_commands": "git,go,make,ls,cat",
      "_denied_commands":  "sudo,su,dd,mkfs"
    }
  }
}
  • _allowed_commands — comma-separated list of permitted programs, by name (looked up on PATH) or absolute path. When set, every program of a command line must be on this list or the whole call is rejected before anything runs, so ls && rm -rf / is refused for the rm, not for the line.
  • _denied_commands — comma-separated programs that are always blocked, regardless of the allowlist.
  • _allowed_dir — if set, the program path must reside under this directory, and a cd cannot leave it.

With a policy active, command is read with the sh grammar and each step is checked: cd is interpreted (it moves the steps after it and needs no allowlist entry), &&/||/; sequences run in order, and anything the policy cannot vouch for is refused rather than guessed at — pipes, redirection, globs, $VAR/$(...), escapes, &, !, VAR=x prefixes and compound constructs (if, for, subshells). Each of those becomes its own call.

A quoted pattern is a literal argument, not a glob, so find . -name '*.go' is allowed — the program does the matching — while ls *.go is refused, because the shell would have expanded it and the policy cannot see what it expands to. Without any tools_policies entry there is no policy to enforce, so shell: true still hands the line to the platform shell.

The default chains ship with sensible defaults: common dev tools allowed, privilege-escalation and raw-disk commands denied.

To use local_shell with no policy restrictions (fully open), omit tools_policies entirely. Only do this in fully trusted, local-only environments. Review tool use in your chain and enable shell only when you intend to grant command execution.

Tool

local_shell

ParameterTypeRequiredDescription
commandstring✅The command line to run: {"command": "ls -F"}, or {"command": "cd sub && go test ./..."}
argsstring | array—Operands for a single executable: an array of argument strings, or a space-separated string. Passed through literally, never read as syntax
cwdstring—Working directory
timeoutstring—Duration e.g. 30s
shellboolean—Run via /bin/sh -c (allows pipes, redirects, $VAR). Disabled when a command policy is active — under one, command is read as a command line instead and anything needing a real shell is refused.

Adding custom local tools

Adding new local tools types requires modifying the Contenox Go source code and implementing the taskengine.HookRepo interface. For custom capabilities without writing Go, build a small HTTP service (FastAPI, Express, etc.) and register it as a Remote Tools instead — no code changes required.

Esc to close