contenox
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 a client is running on: local_fs and local_shell calls are forwarded to the connected client’s fs/* and terminal/* capabilities, which the client — contenox beam, or an editor — actually carries out.

A contenox serve host has no such client, so neither of these toolsets exists there: every capability a host has is an MCP server or an OpenAPI service you attached.

Neither does an unattended mission unit — what contenox run "<task>" dispatches, and what contenox mission fire and /mission fire — and for the same reason: nothing is attached to forward fs/* or terminal/* to. Such a run reaches the in-process toolsets (native-fs-browse, native-git, native-go, native-jq, the mission tools) and whatever MCP servers you connected; a local_fs or local_shell call comes back to the model as tool local_shell not found, and --shell does not change that. Give a run file or shell access by connecting an MCP server for it, or drive the task from contenox beam or an editor, where a client exists to carry it out.

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. local_fs no longer has tools for directory listing, searching or globbing, so a shell is one way to do those (ls, find, grep/rg) — but not the only one: native-fs-browse (list_dir, grep, find_files, stat_file, count_stats) runs in-process and is therefore also reachable from a shape with no client to forward a terminal to.

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 command names. When set, any command not on this list is rejected before it runs.
  • _denied_commands — comma-separated commands that are always blocked, regardless of the allowlist.
  • _allowed_dir — if set, the command executable or script path must reside under this directory.

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
commandstringThe executable alone — flags and operands go in args: {"command": "ls", "args": ["-F"]}, never {"command": "ls -F"}
argsstring | arrayEverything after the executable: an array of argument strings, or a space-separated string
cwdstringWorking directory
timeoutstringDuration e.g. 30s
shellbooleanRun via /bin/sh -c (allows pipes, redirects, $VAR); without it the argv is executed directly and none of those are interpreted. Disabled when _allowed_commands or _allowed_dir is set.

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