Build a Mini Agent Harness in Python

Build a Python harness that safely routes model tool calls to local files.

Introduction

30 Second Summary

Giving AI access to your files raises a practical question. Who checks each requested action before anything changes?

In this project, you will build a local agent harness in Python that turns structured model decisions into controlled file actions. Each run reveals the full path from model request to recorded result.

What You'll Build

You will run your harness, watch each tool request appear in the terminal, decide whether to allow a file write, and inspect the summary plus session transcript it creates.

By the end of this project, you'll have:

  • A visible model-to-tool loop that lists files, reads notes, and reports each tool result in the terminal.
  • A human approval gate that proves a denied write leaves workspace/summary.txt absent. Approval on the next run creates the file.
  • A readable JSON session transcript in session.json that records model requests, tool results, and the final response.
  • Secret Mission: Probe write_file() with traversal paths plus absolute paths to prove the sandbox blocks escapes from workspace/.

Are there any prerequisites?

This project assumes basic Python syntax. You need Python 3 plus Visual Studio Code on macOS before you begin.

Before We Start

Before any hands-on work begins, commit to building a local Python agent harness where a deterministic model simulator requests filesystem tools. The harness controls execution with human approval for file-changing actions.

Prepare the Sandbox

Your agent harness eventually executes filesystem tools. A sandbox gives those tools a controlled workspace.

This step creates that workspace in Visual Studio Code. You will also confirm that Python supports the path checks used later.

In this step, get ready to:
  • Open the mini-agent-harness folder as your active workspace.
  • Create an empty hello.py file plus workspace/notes.txt.
  • Verify Python 3.6 or later in the integrated terminal.
Create the project workspace

A Visual Studio Code workspace places every project file under one named folder. This gives the harness a clear starting point for future tool actions.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code in Spotlight.
  • Press Return to open Visual Studio Code.

You will see Visual Studio Code without an active project workspace.

  • Click File in the top menu bar.
  • Click Open Folder....
  • Select Desktop in the folder picker.
  • Confirm the selection to open your Desktop as a temporary workspace.

You will see your Desktop contents in the Explorer sidebar.

  • Select the Desktop workspace name at the top of the Explorer sidebar.
  • Click New Folder in the Explorer toolbar.
  • Enter mini-agent-harness as the folder name.

You will see the mini-agent-harness folder on your Desktop.

  • Click File in the top menu bar.
  • Click Open Folder....
  • Select the mini-agent-harness folder on your Desktop.
  • Confirm the selection to make it your active workspace.

That is the first boundary in place. You will see mini-agent-harness as the workspace name in the Explorer sidebar.

Create the starter files

The workspace folder holds the files that future tools may access. The empty hello.py file becomes the harness in the next step.

  • Select the mini-agent-harness workspace name in the Explorer sidebar.
  • Click New Folder in the Explorer toolbar.
  • Enter workspace as the folder name.

You will see an empty workspace folder in the Explorer sidebar.

  • Select the workspace folder in the Explorer sidebar.
  • Click New File in the Explorer toolbar.
  • Enter notes.txt as the file name.

You will see notes.txt nested inside the workspace folder.

  • Select the mini-agent-harness workspace name in the Explorer sidebar.
  • Click New File in the Explorer toolbar.
  • Enter hello.py as the file name.

You will see an empty hello.py file beside the workspace folder.

  • Return to workspace/notes.txt from the Explorer sidebar.
  • Add the three harness notes by pasting this text:
Agent harnesses connect models to tools.
Approvals protect side effects.
Sessions preserve state.

What Do These Notes Establish?

  • The first note connects model decisions to tool execution.
  • The second note identifies approval as the control for side effects.
  • The third note identifies session history as the record of an interaction.
  • Press Cmd+S to save workspace/notes.txt.
  • Check the file hierarchy in the Explorer sidebar.

You should see an empty hello.py file beside a workspace folder that contains notes.txt.

Files in the Wrong Location?

Check the Explorer indentation. The notes.txt file belongs inside workspace.

The hello.py file belongs beside that folder. Help me correct the file structure in Visual Studio Code.

Verify Python in the integrated terminal

The later filesystem boundary uses path behavior available in Python 3.6 or later. A version check confirms that your runtime supports the harness before you write it.

  • Click View in the top menu bar.
  • Click Terminal.

You will see the integrated terminal open at the mini-agent-harness workspace.

Before you run the check, do you expect your installed Python version to meet the minimum?

  • Check the installed Python version by running this command:
python3 --version

What Does This Command Check?

The --version option prints the Python version number. You can compare that number with the required minimum of 3.6.

✔️ I see version 3.6 or higher

Your terminal prints Python followed by version 3.6 or later.

You have cleared the runtime check. Python can support the sandbox path behavior used by the harness.

ⓧ I see an older version

Your installed Python predates the required path behavior. Install the current stable Python 3 release before continuing.

  • Open the Python releases for macOS page in your browser.
  • Choose the latest stable Python 3 release.
  • Click Download macOS installer for that release.
  • Complete the downloaded installer using its default options.
  • Return to Visual Studio Code.
  • Close the existing integrated terminal.
  • Click View in the top menu bar.
  • Click Terminal.

Before you check again, do you expect the fresh terminal to use the newly installed Python version?

  • Repeat the version check by running this command:
python3 --version

What Should You See?

The fresh terminal should print Python followed by version 3.6 or later. This confirms that Visual Studio Code can use the supported runtime.

Still Seeing the Older Version?

Close Visual Studio Code completely. Reopen the mini-agent-harness workspace before repeating the version check.

If the older version remains, help me identify which Python installation the terminal is using.

ⓧ Command not found

The terminal cannot find the Python 3 launcher. Install the current stable Python 3 release before repeating the check.

  • Open the Python releases for macOS page in your browser.
  • Choose the latest stable Python 3 release.
  • Click Download macOS installer for that release.
  • Complete the downloaded installer using its default options.
  • Return to Visual Studio Code.
  • Close the existing integrated terminal.
  • Click View in the top menu bar.
  • Click Terminal.

Before you check again, do you expect the fresh terminal to find Python?

  • Repeat the version check by running this command:
python3 --version

What Should You See?

The fresh terminal should print Python followed by version 3.6 or later. This confirms that the Python 3 launcher is available.

Command Still Unavailable?

Close Visual Studio Code completely. Reopen the mini-agent-harness workspace before repeating the version check.

If the command remains unavailable, help me check the Python installation on macOS.

Your terminal now confirms Python 3.6 or later. The Explorer shows hello.py plus workspace/notes.txt.

No harness has run yet. You should not see a summary file or session file in the workspace.

✔️ Awesome, I've got everything!

Your hello.py file is completely empty. Your saved workspace/notes.txt file contains all three notes.

ⓧ I'd like to double check the full code

The hello.py file should contain zero characters. Leave its editor completely blank.

The workspace/notes.txt file should match this text exactly:

Agent harnesses connect models to tools.
Approvals protect side effects.
Sessions preserve state.

What Should Match?

Check the wording of all three lines. Keep each note on its own line.

Your controlled workspace is ready. Next, you will make the model simulator emit its first structured tool request.

Inspect an Inert Tool Request

Your sandbox is ready. Now you can inspect the boundary between a model simulator and an agent harness.

A model can request an action. The structured request remains inert data until the harness validates it and executes it.

In this step, get ready to:
  • Define a deterministic model simulator that returns a structured tool request.
  • Run the Python script to print the raw request.
  • Confirm that printing the request leaves the workspace unchanged.
Build the inert request demo

The simulator acts as a predictable stand-in for the model boundary. Its dictionary describes one request through the type, name, and arguments fields.

  • Select hello.py in the Explorer sidebar.
  • Replace the empty file with the code below:
def scripted_model(messages):
    del messages
    return {
        "type": "tool_call",
        "name": "list_files",
        "arguments": {},
    }


def main():
    messages = [{"role": "user", "content": "List the workspace files."}]
    print(scripted_model(messages))


if __name__ == "__main__":
    main()

What Does This Code Do?

  • The scripted_model() function returns the same structured decision every time.
  • The messages parameter keeps the model boundary ready for conversation history.
  • The initial messages list contains the user's request to list the workspace files.
  • The main() function prints the returned dictionary without routing it to a tool.
  • The entry-point guard runs main() when you execute hello.py as a script.
  • Save hello.py.
Run the raw request

The script now has enough code to reveal the model's decision. It has no tool registry or filesystem function that could act on that decision.

Before you run this, predict whether the script can list real files with no tool implementation.

  • Run the model simulator in the integrated terminal with this command:
python3 hello.py

What Does This Output Prove?

  • The type field is tool_call.
  • The name field is list_files.
  • The arguments field is an empty dictionary.
  • Together, these values describe an action without executing it.

That boundary is now visible: the simulator can ask for a tool without touching the workspace.

Terminal Stays Blank?

  • Save hello.py before running the command again.
  • Check that main() is indented beneath the entry-point guard.
  • Compare every bracket and quote with the complete file below if Python reports a syntax problem.

Help me debug why hello.py does not print the structured tool request.

✔️ Awesome, I've got everything!

Good work. Your model simulator now emits a visible request without changing the workspace.

ⓧ I'd like to double check the full code

Compare your saved hello.py file with this complete version:

def scripted_model(messages):
    del messages
    return {
        "type": "tool_call",
        "name": "list_files",
        "arguments": {},
    }


def main():
    messages = [{"role": "user", "content": "List the workspace files."}]
    print(scripted_model(messages))


if __name__ == "__main__":
    main()

This reference contains the simulator, the initial message, the print statement, and the entry-point guard used in this step.

Confirm the request stayed inert

A printed request only describes what the model wants. The unchanged files prove that no harness has executed the request.

Before you inspect the files, predict whether printing the request could have changed workspace/notes.txt.

  • Expand workspace in the Explorer sidebar.
  • Select workspace/notes.txt.

You should still see the three original notes with no changes.

  • Look for summary.txt inside workspace.
  • Look for session.json beside hello.py in the Explorer.

You should see notes.txt as the only file inside workspace.

You should see no session.json file. This confirms the request produced output without causing a filesystem action.

You have proved that a structured request is only data on its own. Next, you will route model requests through a bounded tool loop.

Wire Up the Tool Loop

Your raw tool request is visible in Visual Studio Code. However, the request remains inert because nothing validates its tool name or executes the selected function.

Now you'll turn that request into a working agent harness. A bounded loop will return each tool result to the simulator so it can choose its next action.

In this step, get ready to:
  • Confine filesystem paths to the workspace/ folder.
  • Route structured requests through a tool registry and bounded loop.
  • Run the complete tool sequence to observe its effect on the workspace.
Add sandboxed filesystem tools

A sandbox gives the tools a controlled filesystem boundary. Resolving each requested path exposes attempts to leave the named workspace before a tool reads or writes anything.

  • In hello.py, place your cursor above the existing scripted_model function.
  • Add the imports and workspace boundary by pasting this code:
import json
from pathlib import Path

WORKSPACE = Path("workspace")
MAX_STEPS = 6


def workspace_path(relative_path):
    root = WORKSPACE.resolve()
    candidate = (root / relative_path).resolve(strict=False)
    if candidate != root and root not in candidate.parents:
        raise ValueError("Tool path must stay inside workspace/")
    return candidate

How does the boundary work?

  • The WORKSPACE constant identifies the only folder the tools may access.
  • The workspace_path() function resolves the requested path before trusting it.
  • The ancestry check raises an error when the resolved path sits outside workspace/.
  • Save hello.py.
  • Confirm that the existing simulator still runs by using this command in the integrated terminal:
python3 hello.py

You'll still see the raw response containing tool_call and list_files. This confirms the boundary code loads without disrupting the simulator.

Seeing a Python error?

Check that both import lines sit at the top of hello.py. Make sure every line inside workspace_path() has four spaces of indentation.

Help me fix the workspace boundary code in hello.py.

The boundary controls where each filesystem operation may point. The next functions perform the actual listing, reading, and writing inside that boundary.

  • In hello.py, place your cursor directly below the workspace_path() function.
  • Add the three filesystem tools by pasting this code:
def list_files(arguments):
    del arguments
    root = WORKSPACE.resolve()
    files = []
    for path in root.rglob("*"):
        if path.is_file():
            files.append(str(path.relative_to(root)))
    return json.dumps(sorted(files))


def read_file(arguments):
    path = workspace_path(arguments["path"])
    return path.read_text(encoding="utf-8")


def write_file(arguments):
    path = workspace_path(arguments["path"])
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(arguments["content"], encoding="utf-8")
    return "Wrote {}".format(path.relative_to(WORKSPACE.resolve()))

What do these tools do?

  • The list_files() tool returns a sorted JSON string containing the files under workspace/.
  • The read_file() tool resolves a relative path before reading its text.
  • The write_file() tool resolves a relative path before creating its parent folders and writing its content.
  • Save hello.py.
  • Check that all three tool definitions load successfully by running:
python3 hello.py

You'll see the same raw list_files request with no traceback. The tools now exist, while the request still has no route to execute them.

Seeing a syntax or indentation error?

Check that list_files(), read_file(), and write_file() all begin at the left edge of the file. Keep each function body indented by four spaces.

Help me debug my three filesystem tool functions.

Build the registry and bounded loop

A tool registry maps a requested name to a Python function. Conversation helpers then let the simulator count previous requests and read the latest result.

  • In hello.py, place your cursor directly below the write_file() function.
  • Add the registry and conversation helpers by pasting this code:
TOOL_REGISTRY = {
    "list_files": list_files,
    "read_file": read_file,
    "write_file": write_file,
}


def count_tool_calls(messages):
    return sum(
        1
        for message in messages
        if message["role"] == "assistant"
        and message.get("type") == "tool_call"
    )


def latest_tool_result(messages):
    for message in reversed(messages):
        if message["role"] == "tool":
            return message["content"]
    return ""

How does routing gain state?

  • The TOOL_REGISTRY dictionary turns a model-selected name into a callable function.
  • The count_tool_calls() helper identifies the simulator's current stage from the message history.
  • The latest_tool_result() helper gives the simulator the newest tool output.
  • In hello.py, select the complete existing scripted_model() function.
  • Replace the selected function with this first section:
def scripted_model(messages):
    tool_calls = count_tool_calls(messages)

    if tool_calls == 0:
        return {
            "type": "tool_call",
            "name": "list_files",
            "arguments": {},
        }

What starts the sequence?

The simulator counts earlier tool requests before choosing its next response. A new session begins with the same structured list_files request you already tested.

  • Place your cursor directly below the first return block inside scripted_model().
  • Complete the remaining simulator decisions by pasting this code:
    if tool_calls == 1:
        return {
            "type": "tool_call",
            "name": "read_file",
            "arguments": {"path": "notes.txt"},
        }

    if tool_calls == 2:
        notes = latest_tool_result(messages).strip()
        summary = (
            "# Harness Summary\n\n"
            + notes
            + "\n\nGenerated through a model-tool-harness loop.\n"
        )
        return {
            "type": "tool_call",
            "name": "write_file",
            "arguments": {
                "path": "unsafe_summary.txt",
                "content": summary,
            },
        }

    return {
        "type": "final",
        "content": "Summary created after the harness completed its tool loop.",
    }

How does the simulator progress?

  • The first completed tool call advances the simulator to its file-reading request.
  • The latest tool result supplies the notes used to prepare the summary request.
  • The final response ends the deterministic sequence after three tool calls.
  • Save hello.py.
  • Check the expanded simulator with the existing one-response program by running:
python3 hello.py

You'll see the initial list_files request again. The simulator needs a loop before it can receive a result and advance to its second decision.

Simulator stops with an error?

Check that the second and third if blocks align with the first one. Make sure the final return aligns with all three conditions.

Help me fix the branching in scripted_model().

The simulator now describes the full sequence. The harness still needs a bounded turn structure that stores every assistant decision.

  • In hello.py, place your cursor above the existing main() function.
  • Add the first section of the bounded harness by pasting this code:
def run_harness(goal):
    WORKSPACE.mkdir(exist_ok=True)
    messages = [{"role": "user", "content": goal}]

    for step in range(1, MAX_STEPS + 1):
        response = scripted_model(messages)
        assistant_message = {"role": "assistant"}
        assistant_message.update(response)
        messages.append(assistant_message)

        if response["type"] == "final":
            print("\nAgent:", response["content"])
            return

What bounds each run?

  • The messages list begins with the goal that drives the session.
  • The MAX_STEPS limit prevents the simulator from requesting tools forever.
  • Each response becomes an assistant message before the harness checks for completion.
  • Select from the blank line immediately below the return statement through the end of the existing main() function.
  • Replace the selection with the tool-dispatch section and updated main() function below:
        tool_name = response["name"]
        arguments = response["arguments"]
        print("\nStep {} model requested:".format(step), tool_name, arguments)

        if tool_name not in TOOL_REGISTRY:
            result = "Unknown tool: {}".format(tool_name)
        else:
            try:
                result = TOOL_REGISTRY[tool_name](arguments)
            except (KeyError, OSError, ValueError) as error:
                result = "Tool error: {}".format(error)

        messages.append(
            {"role": "tool", "name": tool_name, "content": result}
        )
        print("Tool result:", result)

    raise RuntimeError("Harness stopped after MAX_STEPS")


def main():
    run_harness("Turn workspace/notes.txt into workspace/unsafe_summary.txt.")

What does the dispatch section do?

  • The registry lookup validates the requested tool name before execution.
  • The selected function receives the model-generated arguments.
  • The resulting tool message gives the simulator fresh state for its next decision.
  • The final error protects the harness when the sequence exceeds MAX_STEPS.
Run the unguarded harness

The model-to-tool route is now complete. This run reveals how much authority the registry gives each accepted request.

  • Save hello.py.

Before you run it, predict whether the registered write request can now change the workspace.

  • Run the complete harness in the integrated terminal with this command:
python3 hello.py

You'll see requests for list_files, read_file, and write_file in sequence. The write result says Wrote unsafe_summary.txt before the simulator prints its final response.

  • Expand the workspace folder in the Explorer sidebar.
  • Select unsafe_summary.txt to inspect the generated summary.
  • Return to notes.txt to confirm its original three lines remain unchanged.

Inside unsafe_summary.txt, you'll see the # Harness Summary heading followed by all three notes. The last line states that the content was generated through the model-tool-harness loop.

The unsafe_summary.txt file appeared without a permission question. The Explorer still has no session.json file.

The automatic write is intentional

Your harness successfully turned a structured decision into a filesystem change. That success exposes the safety gap because every registered tool currently receives the same execution authority.

A file-changing request needs a human approval boundary. You'll add that boundary in the next step.

Missing unsafe_summary.txt?

Check the terminal for the last tool request that ran. If the sequence stops earlier, compare the names in TOOL_REGISTRY with the names returned by scripted_model().

Help me trace why unsafe_summary.txt was not created.

✔️ Awesome, I've got everything!

Great work. Your harness now executes all three tools inside workspace/ and completes its bounded loop.

ⓧ I'd like to double check the full code

Compare your complete hello.py file with this reference:

import json
from pathlib import Path

WORKSPACE = Path("workspace")
MAX_STEPS = 6


def workspace_path(relative_path):
    root = WORKSPACE.resolve()
    candidate = (root / relative_path).resolve(strict=False)
    if candidate != root and root not in candidate.parents:
        raise ValueError("Tool path must stay inside workspace/")
    return candidate


def list_files(arguments):
    del arguments
    root = WORKSPACE.resolve()
    files = []
    for path in root.rglob("*"):
        if path.is_file():
            files.append(str(path.relative_to(root)))
    return json.dumps(sorted(files))


def read_file(arguments):
    path = workspace_path(arguments["path"])
    return path.read_text(encoding="utf-8")


def write_file(arguments):
    path = workspace_path(arguments["path"])
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(arguments["content"], encoding="utf-8")
    return "Wrote {}".format(path.relative_to(WORKSPACE.resolve()))


TOOL_REGISTRY = {
    "list_files": list_files,
    "read_file": read_file,
    "write_file": write_file,
}


def count_tool_calls(messages):
    return sum(
        1
        for message in messages
        if message["role"] == "assistant"
        and message.get("type") == "tool_call"
    )


def latest_tool_result(messages):
    for message in reversed(messages):
        if message["role"] == "tool":
            return message["content"]
    return ""


def scripted_model(messages):
    tool_calls = count_tool_calls(messages)

    if tool_calls == 0:
        return {
            "type": "tool_call",
            "name": "list_files",
            "arguments": {},
        }

    if tool_calls == 1:
        return {
            "type": "tool_call",
            "name": "read_file",
            "arguments": {"path": "notes.txt"},
        }

    if tool_calls == 2:
        notes = latest_tool_result(messages).strip()
        summary = (
            "# Harness Summary\n\n"
            + notes
            + "\n\nGenerated through a model-tool-harness loop.\n"
        )
        return {
            "type": "tool_call",
            "name": "write_file",
            "arguments": {
                "path": "unsafe_summary.txt",
                "content": summary,
            },
        }

    return {
        "type": "final",
        "content": "Summary created after the harness completed its tool loop.",
    }


def run_harness(goal):
    WORKSPACE.mkdir(exist_ok=True)
    messages = [{"role": "user", "content": goal}]

    for step in range(1, MAX_STEPS + 1):
        response = scripted_model(messages)
        assistant_message = {"role": "assistant"}
        assistant_message.update(response)
        messages.append(assistant_message)

        if response["type"] == "final":
            print("\nAgent:", response["content"])
            return

        tool_name = response["name"]
        arguments = response["arguments"]
        print("\nStep {} model requested:".format(step), tool_name, arguments)

        if tool_name not in TOOL_REGISTRY:
            result = "Unknown tool: {}".format(tool_name)
        else:
            try:
                result = TOOL_REGISTRY[tool_name](arguments)
            except (KeyError, OSError, ValueError) as error:
                result = "Tool error: {}".format(error)

        messages.append(
            {"role": "tool", "name": tool_name, "content": result}
        )
        print("Tool result:", result)

    raise RuntimeError("Harness stopped after MAX_STEPS")


def main():
    run_harness("Turn workspace/notes.txt into workspace/unsafe_summary.txt.")


if __name__ == "__main__":
    main()

Your harness can now turn model decisions into real tool actions. Next, you'll place file-changing tools behind approval and record the complete interaction in a session transcript.

Add Approval and Session History

Your bounded loop can now turn model requests into real filesystem actions. The automatic creation of unsafe_summary.txt exposed the risk because a successful tool call can still cause an unwanted side effect.

This step adds a human approval gate before write_file. It also saves each request and result as readable JSON session history.

In this step, get ready to:
  • Classify the file-writing tool as sensitive.
  • Require human approval before a sensitive tool runs.
  • Save each model request and tool result in a session transcript.
Classify writes and ask for approval

A tool registry answers which function should run. An approval policy answers whether that function may run with the requested arguments.

  • In scripted_model(), change the write_file path from unsafe_summary.txt to summary.txt.
  • In main(), change the goal to Turn workspace/notes.txt into workspace/summary.txt.
  • Replace the existing TOOL_REGISTRY block with this policy-aware version:
TOOL_REGISTRY = {
    "list_files": list_files,
    "read_file": read_file,
    "write_file": write_file,
}

SENSITIVE_TOOLS = {"write_file"}

What does the policy add?

  • TOOL_REGISTRY still maps each requested name to its Python function.
  • SENSITIVE_TOOLS identifies the tools that require consent before execution.
  • write_file is sensitive because it changes the filesystem.
  • Add the approval function below scripted_model() in hello.py by copying this code:
def approve(tool_name, arguments):
    print("\nApproval required for:", tool_name)
    print(json.dumps(arguments, indent=2))
    answer = input("Run this tool? [y/N]: ").strip().lower()
    return answer == "y"

How does approval work?

  • approve() displays the requested tool name.
  • json.dumps() formats the arguments so you can review the proposed path and content.
  • input() pauses execution until you respond.
  • The function returns True only when the cleaned response equals y.
Persist each run as session history

Approval controls one sensitive action. A session history preserves the surrounding model requests and tool results so you can audit the complete run.

  • Replace the constants at the top of hello.py with this version:
WORKSPACE = Path("workspace")
SESSION_FILE = Path("session.json")
MAX_STEPS = 6

Where does the transcript live?

SESSION_FILE points to session.json inside the mini-agent-harness folder. Keeping the transcript beside hello.py makes each run easy to inspect.

  • Add save_session() below approve() by copying this code:
def save_session(messages):
    SESSION_FILE.write_text(
        json.dumps(messages, indent=2),
        encoding="utf-8",
    )

What does session saving capture?

  • messages contains the user goal plus every assistant request and tool result from the current run.
  • indent=2 produces a readable nested transcript.
  • write_text() replaces the previous transcript with the current run.
  • In run_harness(), replace the dispatch branch that begins with if tool_name not in TOOL_REGISTRY: with this gated version:
        if tool_name not in TOOL_REGISTRY:
            result = "Unknown tool: {}".format(tool_name)
        elif tool_name in SENSITIVE_TOOLS and not approve(tool_name, arguments):
            result = "Denied by user."
            messages.append(
                {"role": "tool", "name": tool_name, "content": result}
            )
            print("Tool result:", result)
            save_session(messages)
            return
        else:

How does the gate protect writes?

  • Unknown tool names still produce a result instead of calling an unregistered function.
  • Sensitive requests pass through approve() before dispatch.
  • A denial becomes a tool result so the transcript records what happened.
  • The harness saves the denied session before stopping.
  • In the final-response branch of run_harness(), insert save_session(messages) immediately before return.
  • At the bottom of run_harness(), insert save_session(messages) immediately before raise RuntimeError("Harness stopped after MAX_STEPS").
  • Save hello.py.

The harness now saves a transcript after denial, successful completion, or exhaustion of the step limit. Each exit path leaves evidence behind.

Prove denial and approval

The approval gate matters only if denial blocks the side effect. You will test both outcomes against the same write_file request.

Before you run the harness, do you expect workspace/summary.txt to appear when you reject the write?

  • Switch back to the integrated terminal from the previous step.
  • Run the harness with this command:
python3 hello.py

What does this run test?

The harness executes list_files and read_file without interruption. It pauses when the model requests write_file so you can inspect its arguments.

  • Enter n when the terminal displays Run this tool? [y/N]:.

You will see Tool result: Denied by user. in the terminal. The harness stops before creating workspace/summary.txt.

  • In the Explorer sidebar, expand workspace to confirm that summary.txt is absent.
  • In the Explorer sidebar, select session.json.
  • Find Denied by user. in the final tool entry.

Did the write run without asking?

Check that write_file appears inside SENSITIVE_TOOLS. Confirm that the sensitive-tool branch appears before the final else: block.

Help me debug my approval gate.

Before you run the harness again, what changes when the same request receives approval?

  • Run the harness again with this command:
python3 hello.py

What should complete this time?

The same tool sequence reaches the protected write request. Approval allows the registry to call write_file and return its result to the model.

  • Enter y when the terminal displays Run this tool? [y/N]:.

You will see Wrote summary.txt followed by the final agent response. The approved run now reaches successful completion.

  • In the Explorer sidebar, select workspace/summary.txt.
  • Confirm that the file contains the three notes plus the generated-loop message.
  • Return to session.json in the Explorer sidebar.
  • Confirm that the transcript contains the user goal plus three model requests.
  • Confirm that the transcript contains three tool results plus the final response.

Missing the summary or final response?

Check that you entered y at the approval prompt. Confirm that scripted_model() targets summary.txt.

Help me trace my approved harness run.

You've closed the safety gap. The harness now asks before a write. Its session file preserves every decision from the completed run.

✔️ Awesome, I've got everything!

Great. Double-check that hello.py is saved. Confirm that the approved run created workspace/summary.txt and replaced session.json with the successful transcript.

ⓧ I'd like to double check the full code

Compare your complete hello.py file with this reference:

import json
from pathlib import Path

WORKSPACE = Path("workspace")
SESSION_FILE = Path("session.json")
MAX_STEPS = 6


def workspace_path(relative_path):
    root = WORKSPACE.resolve()
    candidate = (root / relative_path).resolve(strict=False)
    if candidate != root and root not in candidate.parents:
        raise ValueError("Tool path must stay inside workspace/")
    return candidate


def list_files(arguments):
    del arguments
    root = WORKSPACE.resolve()
    files = []
    for path in root.rglob("*"):
        if path.is_file():
            files.append(str(path.relative_to(root)))
    return json.dumps(sorted(files))


def read_file(arguments):
    path = workspace_path(arguments["path"])
    return path.read_text(encoding="utf-8")


def write_file(arguments):
    path = workspace_path(arguments["path"])
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(arguments["content"], encoding="utf-8")
    return "Wrote {}".format(path.relative_to(WORKSPACE.resolve()))


TOOL_REGISTRY = {
    "list_files": list_files,
    "read_file": read_file,
    "write_file": write_file,
}

SENSITIVE_TOOLS = {"write_file"}


def count_tool_calls(messages):
    return sum(
        1
        for message in messages
        if message["role"] == "assistant"
        and message.get("type") == "tool_call"
    )


def latest_tool_result(messages):
    for message in reversed(messages):
        if message["role"] == "tool":
            return message["content"]
    return ""


def scripted_model(messages):
    tool_calls = count_tool_calls(messages)

    if tool_calls == 0:
        return {
            "type": "tool_call",
            "name": "list_files",
            "arguments": {},
        }

    if tool_calls == 1:
        return {
            "type": "tool_call",
            "name": "read_file",
            "arguments": {"path": "notes.txt"},
        }

    if tool_calls == 2:
        notes = latest_tool_result(messages).strip()
        summary = (
            "# Harness Summary\n\n"
            + notes
            + "\n\nGenerated through a model-tool-harness loop.\n"
        )
        return {
            "type": "tool_call",
            "name": "write_file",
            "arguments": {
                "path": "summary.txt",
                "content": summary,
            },
        }

    return {
        "type": "final",
        "content": "Summary created after the harness completed its tool loop.",
    }


def approve(tool_name, arguments):
    print("\nApproval required for:", tool_name)
    print(json.dumps(arguments, indent=2))
    answer = input("Run this tool? [y/N]: ").strip().lower()
    return answer == "y"


def save_session(messages):
    SESSION_FILE.write_text(
        json.dumps(messages, indent=2),
        encoding="utf-8",
    )


def run_harness(goal):
    WORKSPACE.mkdir(exist_ok=True)
    messages = [{"role": "user", "content": goal}]

    for step in range(1, MAX_STEPS + 1):
        response = scripted_model(messages)
        assistant_message = {"role": "assistant"}
        assistant_message.update(response)
        messages.append(assistant_message)

        if response["type"] == "final":
            print("\nAgent:", response["content"])
            save_session(messages)
            return

        tool_name = response["name"]
        arguments = response["arguments"]
        print("\nStep {} model requested:".format(step), tool_name, arguments)

        if tool_name not in TOOL_REGISTRY:
            result = "Unknown tool: {}".format(tool_name)
        elif tool_name in SENSITIVE_TOOLS and not approve(tool_name, arguments):
            result = "Denied by user."
            messages.append(
                {"role": "tool", "name": tool_name, "content": result}
            )
            print("Tool result:", result)
            save_session(messages)
            return
        else:
            try:
                result = TOOL_REGISTRY[tool_name](arguments)
            except (KeyError, OSError, ValueError) as error:
                result = "Tool error: {}".format(error)

        messages.append(
            {"role": "tool", "name": tool_name, "content": result}
        )
        print("Tool result:", result)

    save_session(messages)
    raise RuntimeError("Harness stopped after MAX_STEPS")


def main():
    run_harness("Turn workspace/notes.txt into workspace/summary.txt.")


if __name__ == "__main__":
    main()

Secret mission

Red-Team the Tool Boundary

Your approval gate controls writes routed through the harness. Can the tool's own filesystem boundary stop traversal and absolute paths when you call it directly? Probe both escape routes, then prove a safe nested write still works inside workspace/.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep, pause, or delete your local harness. Everything runs locally at no ongoing cost.

Resources you used:

  • The mini-agent-harness local project folder contains every project artifact.

Keep everything running

No action is needed. Choose this option if you want to run the harness again or review its session history.

  • Keep the mini-agent-harness folder in its current location.
  • Use session.json as the readable record of the approved harness run.

Pause - I'll come back to this later

Pausing preserves every project file while ending the current editor session.

  • Close Visual Studio Code.
  • Leave the mini-agent-harness folder in its current location.

No background process or paid resource remains active.

Delete - I don't want to use this again

Removing the project folder clears every harness artifact from its current location. Your installed tools remain unchanged.

  • Close Visual Studio Code.
  • In Finder, locate the mini-agent-harness folder.
  • Move the mini-agent-harness folder to Trash.
  • Confirm that the mini-agent-harness folder no longer appears in its previous location.

Nice Work!

Nice Work!

You did it! Your local mini agent harness now turns structured model decisions into controlled filesystem actions.

What you learned:

  • Built a deterministic model simulator in Python that emits structured requests through list_files, read_file, or write_file.
  • Turned inert requests into a bounded model-tool-result loop through TOOL_REGISTRY and MAX_STEPS. Confined each file operation to workspace/ through resolved-path validation.
  • Protected write_file with a human approval gate. Saved every decision through a readable JSON session transcript in session.json.
  • Secret Mission: Red-teamed the filesystem boundary with traversal paths. Tested an absolute path. Confirmed a safe nested path at workspace/reports/safe.txt. Proved that direct tool calls cannot escape workspace/.

Ready to quiz yourself?