Context Course documentation

Tools and Sandboxing in Detail

Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

Tools and Sandboxing in Detail

Tools are the agent’s hands. Nano Harness enforces its security model at the tool boundary: path confinement, command allowlists, output limits, and write-off-by-default.

The Four Tools

1. list_dir(path) — List Files

def list_dir(path="."):
    """List files and directories."""
    p = safe_path(path)  # Verify path is in workspace
    if not p.is_dir():
        raise NotADirectoryError(str(p))
    return sorted(x.name + ("/" if x.is_dir() else "") for x in p.iterdir())

safe_path() blocks directory traversal (e.g., ../../etc/passwd), the return values are filenames only — not absolute paths — and directories are suffixed with /.

Examples:

list_dir(".")             # ✓ OK
list_dir("src")           # ✓ OK
list_dir("../etc")        # ✗ BLOCKED by safe_path()

2. read_file(path, max_chars) — Read File

def read_file(path, max_chars=4000):
    """Read file with size limit."""
    p = safe_path(path)
    content = p.read_text(encoding="utf-8", errors="replace")
    # Enforce framework-level limit
    return clip(content, min(max_chars, MAX_CHARS))  # MAX_CHARS=8000

Paths go through safe_path(), the requested character count is capped at MAX_CHARS, and errors="replace" keeps binary files from crashing the read. Even if the agent asks for max_chars=999999, min(999999, 8000) wins.

Examples:

read_file("README.md")             # ✓ Up to 4000 chars
read_file("data.txt", max_chars=500)  # ✓ Up to 500 chars
read_file("huge.db")               # ✓ Clipped to 8000 chars (not unlimited)
read_file("/etc/passwd")           # ✗ BLOCKED by safe_path()

3. write_file(path, content) — Write File

ALLOW_WRITE = False  # Disabled by default!

def write_file(path, content):
    """Write file (gated by ALLOW_WRITE flag)."""
    if not ALLOW_WRITE:
        raise PermissionError("write_file disabled")
    
    p = safe_path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(str(content), encoding="utf-8")
    return f"Wrote {len(str(content))} bytes to {p}"

Writing is off by default — the agent must have ALLOW_WRITE=True to touch disk. Paths are confined, and parent directories are created on demand.

Usage:

# Enable write explicitly
ALLOW_WRITE = True

write_file("output.txt", "Hello")  # ✓ OK if ALLOW_WRITE=True

4. exec_cmd(args) — Execute Shell Command

ALLOW_COMMANDS = ["ls", "cat", "pwd", "echo", "head", "tail", "wc", "rg"]

def exec_cmd(args):
    """Execute command (whitelist only)."""
    if args[0] not in ALLOW_COMMANDS:
        raise PermissionError(f"Command '{args[0]}' not allowed")
    
    try:
        result = subprocess.run(
            args,
            capture_output=True,
            timeout=TIMEOUT_S,  # e.g., 30 seconds
            text=True
        )
        output_parts = []
        if result.stdout:
            output_parts.append(f"stdout:\n{result.stdout}")
        if result.stderr:
            output_parts.append(f"stderr:\n{result.stderr}")
        output = "\n\n".join(output_parts) or f"(exit code {result.returncode} with no output)"
        return clip(output, MAX_CHARS)
    except subprocess.TimeoutExpired:
        return "Error: Command timed out"

Only commands in the allowlist run — ls, cat, pwd, echo, head, tail, wc, rg. Anything that mutates state or reaches the network (rm, mv, curl, wget) is rejected. A subprocess timeout stops hangs, stdout and stderr are both captured, and the combined output is clipped to MAX_CHARS.

Examples:

exec_cmd(["ls", "-la"])      # ✓ OK (ls whitelisted)
exec_cmd(["pwd"])            # ✓ OK
exec_cmd(["rg", "ERROR"])    # ✓ OK (rg is ripgrep, safe)
exec_cmd(["rm", "-rf", "/"]) # ✗ BLOCKED (rm not whitelisted)
exec_cmd(["curl", "evil.com"])  # ✗ BLOCKED (curl not whitelisted)

Path Confinement: safe_path()

Everything that takes a path flows through this one function:

WORKSPACE = Path.cwd()  # e.g., /home/user/project

def safe_path(user_input):
    """Ensure path is within workspace."""
    # Resolve to absolute path
    requested = (WORKSPACE / user_input).resolve()
    
    # Check if it's inside workspace
    if not requested.is_relative_to(WORKSPACE):
        raise ValueError(f"Path {user_input} escapes workspace")
    
    return requested

What it prevents:

# Attacker tries directory traversal
safe_path("../../../etc/passwd")
# → Resolves to /home/user/project/../../etc/passwd
# → Resolves to /etc/passwd
# → NOT relative to WORKSPACE
# → Raises ValueError ✓

# Attacker tries absolute path
safe_path("/etc/passwd")
# → Doesn't start with WORKSPACE
# → Raises ValueError ✓

# Legitimate use
safe_path("data/models.txt")
# → Resolves to /home/user/project/data/models.txt
# → IS relative to WORKSPACE
# → Returns path ✓

Execution Sandboxing

Limited Global Scope

When executing agent code:

# Only these functions are available
exec_globals = {
    "__builtins__": {}, # need to explicitly remove builtins
    "list_dir": list_dir,
    "read_file": read_file,
    "write_file": write_file,
    "exec_cmd": exec_cmd,
    "final_answer": final_answer
}

# Agent code runs here
exec(agent_code, exec_globals)

# Agent CANNOT do:
# - import modules (no __builtins__)
# - access files outside tools
# - use network directly
# - access parent process variables

This is intentionally strict. If you want helpers like len() or print(), add a small allowlist of safe builtins explicitly rather than inheriting Python’s full default builtins by accident.

Error Containment

Exceptions are caught and reported:

try:
    exec(agent_code, exec_globals)
except Exception as e:
    error_msg = f"Error: {type(e).__name__}: {str(e)}"
    messages.append({"role": "user", "content": error_msg})
    # Continue to next iteration; agent adapts

Example workflow:

Step 1: Agent tries to read huge file
  Code: content = read_file("huge.dat", max_chars=999999)
  Result: (Clipped to 8000 chars, agent sees it)

Step 2: Agent tries forbidden command
  Code: exec_cmd(["rm", "data.txt"])
  Result: PermissionError: Command 'rm' not allowed
  Agent sees error and tries different approach

Step 3: Agent tries to escape workspace
  Code: read_file("../../../etc/passwd")
  Result: ValueError: Path escapes workspace
  Agent learns and adjusts

Commercial Agents vs. Nano Harness

Commercial agents handle the same concerns behind the scenes. Claude Code and Codex both enforce path confinement, permission prompts, timeouts, and output limits automatically; the cost is that the rules are less visible. Nano Harness keeps everything explicit so you can read and modify the policy, at the cost of having to implement it yourself.

Designing Custom Tools

If you extend nano_harness with new tools, follow these patterns:

Good Tool Design

def good_tool(user_input, max_result_size=1000):
    """
    1. Validate and confine input
    2. Perform operation
    3. Limit output size
    4. Return safe result
    """
    # 1. Validate input
    if not isinstance(user_input, str):
        raise TypeError("user_input must be string")
    
    path = safe_path(user_input)  # Confine paths
    
    # 2. Perform operation
    result = path.read_text()
    
    # 3. Limit output
    return clip(result, min(max_result_size, MAX_CHARS))

Tool Anti-Patterns

# ✗ BAD: No input validation
def bad_tool_1(path):
    return open(path).read()  # Reads anything!

# ✗ BAD: No output limit
def bad_tool_2(query):
    return database.query(query)  # Could be terabytes

# ✗ BAD: No error handling
def bad_tool_3(url):
    return requests.get(url).text  # Can timeout, hang

# ✗ BAD: Trusts agent completely
def bad_tool_4(command):
    os.system(command)  # Agent can run rm -rf /

Key Takeaways

Limit what tools can reach. safe_path() keeps file access inside the workspace, command allowlists gate subprocess calls, output is clipped to stop context blowups, and writing is off until you turn it on. Errors become observations so the agent can adapt instead of crashing.

Next, extending nano_harness with new tools and models.

Update on GitHub