Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nullclaw/nullclaw/llms.txt

Use this file to discover all available pages before exploring further.

File Operations

NullClaw provides four file operation tools with strict path security and workspace scoping:
  • file_read — Read file contents
  • file_write — Write or overwrite files
  • file_edit — Find and replace text
  • file_append — Append content to files
All file tools enforce:
  • Workspace scoping — operations restricted to workspace_dir by default
  • Absolute path support — with explicit allowed_paths configuration
  • Path traversal protection../ patterns blocked
  • Symlink validation — links validated after resolution
  • System path blocklist — sensitive paths like /etc/passwd rejected

file_read

Read file contents with size limits and path validation.

Parameters

path
string
required
Relative path to the file within the workspace

Configuration

const ft = try allocator.create(file_read.FileReadTool);
ft.* = .{
    .workspace_dir = "/path/to/workspace",
    .allowed_paths = &.{},  // Empty = workspace-only
    .max_file_size = 10 * 1024 * 1024,  // 10MB default
};

Usage

{
  "tool": "file_read",
  "path": "src/main.zig"
}
Response:
const std = @import("std");

pub fn main() !void {
    std.debug.print("Hello, world!\n", .{});
}

Error Cases

  • File not found — Returns failure with “Failed to resolve file path”
  • Path traversal — Returns “Path not allowed: contains traversal or absolute path”
  • Outside workspace — Returns “Path is outside allowed areas”
  • File too large — Returns “File too large: X bytes (limit: Y bytes)”
  • Missing parameter — Returns “Missing ‘path’ parameter”

Absolute Path Support

To read files outside the workspace:
ft.* = .{
    .workspace_dir = "/workspace",
    .allowed_paths = &.{
        "/data",
        "/home/user/configs",
    },
};
Then read absolute paths:
{
  "tool": "file_read",
  "path": "/data/input.json"
}
Absolute paths without allowed_paths configuration are rejected with: “Absolute paths not allowed (no allowed_paths configured)“

Source

src/tools/file_read.zig:13-91

file_write

Write contents to a file with atomic temp+rename for hard link safety.

Parameters

path
string
required
Relative path to the file within the workspace
content
string
required
Content to write to the file

Configuration

const wt = try allocator.create(file_write.FileWriteTool);
wt.* = .{
    .workspace_dir = "/path/to/workspace",
    .allowed_paths = &.{},
};

Usage

{
  "tool": "file_write",
  "path": "output.txt",
  "content": "Hello, NullClaw!"
}
Response:
Written 16 bytes to output.txt

Behavior

  • Creates parent directories — Automatically creates missing parent dirs
  • Overwrites existing files — Replaces content atomically
  • Preserves executable mode — On Unix, preserves file mode (e.g., 0755)
  • Symlink-aware — Writes to canonical target, preserving symlink
  • Hard link isolation — Uses temp+rename to avoid mutating other inodes

Atomic Write Strategy

To prevent partial writes and hard link side effects:
  1. Create temp file .nullclaw-write-{timestamp}-{attempt}.tmp
  2. Write content to temp file
  3. Preserve file mode from existing file (if any)
  4. Rename temp file to target (atomic inode swap)
  5. Validate final path is within allowed areas
  6. Clean up on failure
Existing symlink:
# workspace/link.txt -> target.txt
Write to link.txt → writes to target.txt, preserves symlink Symlink escape prevention:
# workspace/escape.txt -> /etc/passwd
Write to escape.txtrejected (target outside workspace) Without hard link protection:
# /outside/file.txt and workspace/file.txt point to same inode
# Write to workspace/file.txt mutates /outside/file.txt ❌
With temp+rename (NullClaw):
# Write creates new inode via temp+rename
# workspace/file.txt now points to new inode
# /outside/file.txt unchanged ✅

Source

src/tools/file_write.zig:11-222

file_edit

Find and replace the first occurrence of text in a file.

Parameters

path
string
required
Relative path to the file within the workspace
old_text
string
required
Text to find in the file (must be non-empty)
new_text
string
required
Replacement text (can be empty to delete)

Configuration

const et = try allocator.create(file_edit.FileEditTool);
et.* = .{
    .workspace_dir = "/path/to/workspace",
    .allowed_paths = &.{},
    .max_file_size = 10 * 1024 * 1024,
};

Usage

{
  "tool": "file_edit",
  "path": "README.md",
  "old_text": "version: 0.1.0",
  "new_text": "version: 0.2.0"
}
Response:
Replaced 14 bytes with 14 bytes in README.md

Behavior

  • First occurrence only — Replaces only the first match
  • Exact match required — Text must match exactly (case-sensitive)
  • Empty new_text — Deletes old_text
  • Empty old_text — Rejected with “old_text must not be empty”
  • No match — Returns failure “old_text not found in file”

Example: Multiple replacements

File: config.toml
port = 8080
host = "localhost"
port = 9090  # This port NOT replaced
Edit:
{
  "tool": "file_edit",
  "path": "config.toml",
  "old_text": "port = 8080",
  "new_text": "port = 3000"
}
Result:
port = 3000
host = "localhost"
port = 9090  # Unchanged (second occurrence)
For global find-replace, call file_edit multiple times or use shell with sed.

Source

src/tools/file_edit.zig:13-116

file_append

Append content to the end of a file (creates file if missing).

Parameters

path
string
required
Relative path to the file within the workspace
content
string
required
Content to append to the file

Configuration

const at = try allocator.create(file_append.FileAppendTool);
at.* = .{
    .workspace_dir = "/path/to/workspace",
    .allowed_paths = &.{},
    .max_file_size = 10 * 1024 * 1024,
};

Usage

Append to existing file:
{
  "tool": "file_append",
  "path": "log.txt",
  "content": "2026-03-01 12:00:00 INFO: Task completed\n"
}
Response:
Appended 45 bytes to log.txt
Create new file:
{
  "tool": "file_append",
  "path": "notes.txt",
  "content": "First line\n"
}
Response:
Appended 11 bytes to notes.txt

Behavior

  • Creates file if missing — No error if file doesn’t exist
  • Reads existing content — Loads existing file first
  • Concatenates — Appends new content to end
  • Writes atomically — Truncate+write ensures consistency
  • Validates new files — Ensures created files are within allowed areas

Use Cases

Log file:
{
  "tool": "file_append",
  "path": "agent.log",
  "content": "[2026-03-01 12:00] Started task\n"
}
Multi-append:
# Initial: "A"
file_append("file.txt", "B")  # -> "AB"
file_append("file.txt", "C")  # -> "ABC"
Concurrent appends are not atomic. Use locking or sequential writes for multi-agent scenarios.

Source

src/tools/file_append.zig:18-124

Path Security

workspace_only Mode

By default, all file tools are restricted to workspace_dir:
ft.* = .{ .workspace_dir = "/workspace", .allowed_paths = &.{} };
Allowed:
  • src/main.zig/workspace/src/main.zig
  • data/input.json/workspace/data/input.json
  • ./file.txt/workspace/file.txt
Blocked:
  • /etc/passwd → “Absolute paths not allowed”
  • ../../../etc/passwd → “Path not allowed: contains traversal”
  • src/../../../etc/passwd → “Path is outside allowed areas” (resolved)

Absolute Path Support

With allowed_paths configuration:
ft.* = .{
    .workspace_dir = "/workspace",
    .allowed_paths = &.{
        "/data",
        "/home/user/configs",
    },
};
Allowed:
  • src/main.zig/workspace/src/main.zig (workspace)
  • /data/input.json/data/input.json (allowed_paths)
  • /home/user/configs/app.yaml → allowed
Blocked:
  • /etc/passwd → “Path is outside allowed areas”
  • /tmp/file.txt → “Path is outside allowed areas”

Path Resolution

All paths are resolved with realpathAlloc() to:
  • Canonicalize paths (remove . and ..)
  • Follow symlinks to final destination
  • Validate against workspace + allowed_paths
Example:
# workspace/link -> /etc/passwd
Read link → resolves to /etc/passwdrejected

System Blocklist

The following paths are always blocked by isResolvedPathAllowed():
  • /etc/* (except on macOS where some system paths are safe)
  • /var/run/*
  • /sys/*
  • /proc/*
  • /dev/*

Null Byte Injection

Paths containing null bytes are rejected:
{
  "tool": "file_read",
  "path": "file.txt\u0000/etc/passwd"
}
Response:
Path contains null bytes

Testing

All file tools include comprehensive test coverage in src/tools/file_*.zig:
  • Basic operations — read, write, edit, append
  • Path security — traversal, symlinks, hard links
  • Error cases — missing files, missing params
  • Absolute paths — with/without allowed_paths
  • Edge cases — empty files, empty content, UTF-8 boundaries
Run tests:
zig build test --summary all
Test count: 3,371 tests across entire codebase