Thuta Learning
ProjectsAIbeginner

Project: A Local Coding Agent with Safe Permissions

What you'll walk away with

  • Explain the core ideas behind Project: A Local Coding Agent with Safe Permissions
  • Read the diagram and trace how data or requests flow through the architecture
  • Decide what this means for your own hardware and use case

Build the mental model

This project combines tool calling with the agent-safety principles from Lesson 18, and the combination is the entire point: a capable local coding agent and a safe one are built from the same pieces, arranged so that capability never outruns supervision.

Tool calling, from the agents chapter, is what lets the local model do more than talk — it can request read-file, list-directory, and run-tests calls, and the application executes them and returns real results the model reasons over next.

Here, the tool set is deliberately small and explicit: every tool the model can call is read-only or low-risk, listed on a fixed allowlist rather than discovered dynamically, so there is no path from "the model decided to" to "an arbitrary shell command ran."

A workspace sandbox restricts every one of those tools to a single project directory resolved and checked before each call, so a path like ../../.ssh/id_rsa is rejected the same way a request to touch a file outside the workspace would be, regardless of how the model phrases the request.

The part that makes this an agent rather than a single tool call is the loop: the model can call a tool, read the result, and decide to call another tool before answering, chaining read-file and run-tests calls until it has enough information to explain a file, point at a likely bug, or draft a fix.

The loop stops before anything destructive

But drafting a fix is where the loop stops on its own. Writing or deleting anything requires an explicit confirmation step a human approves — matching, not contradicting, the same never-autonomous-destructive-action rule Lesson 18 established.

text
LOCAL CODING AGENT PERMISSION LOOP
----------------------------------
USER REQUEST
      |
      v
AGENT (local chat model, tool-calling loop)
      |
      |--- tool call --->  ALLOWLISTED TOOLS  (sandboxed to ./workspace)
      |                      readFile            read-only
      |                      listDirectory       read-only
      |                      runTests            low-risk, timeout-limited
      |<-- tool result ------------|
      |
   (loop repeats: model may call more tools before it answers)
      |
      v
PROPOSED ANSWER
   - explanation / likely-bug pointer  -->  shown to user directly
   - proposed file write or delete     -->  NOT executed yet, shown as diff
                    |
                    v
          HUMAN CONFIRMATION GATE  (review diff, click Approve)
                    |
                    v
      only now: writeFile / deleteFile actually runs on disk
      (writeFile/deleteFile were never tools the model could call itself)

Connect it to a real scenario

Build three pieces around one project directory: the tool layer, the agent loop, and the confirmation gate.

Build the tool layer

Define a small fixed set of functions — readFile(path), listDirectory(path), runTests() — each resolving the requested path against the workspace root and refusing anything that resolves outside it, before doing any real filesystem or process work.

Run the agent loop

Send the user's request plus the tool definitions to the local model's chat completions endpoint. Whenever the response contains a tool call, run the matching function, append the tool result to the conversation, and call the model again.

Stop at a plain answer

Repeat the loop until the model returns a plain answer instead of another tool call.

Gate destructive changes

When the agent's answer includes a proposed file write or delete, do not execute it — render the change as a diff and wait for an explicit approve action from the human.

Apply only after approval

Only after approval, call a separate writeFile or deleteFile function that the model itself was never given as a callable tool.

"Done" for this project means:

  • asking it to explain a file or find a likely bug produces a correct, grounded answer built from real tool results, not guesses
  • asking it to fix something produces a reviewable diff and stops there
  • attempting a path outside the project directory is rejected by the tool layer itself
  • no file on disk changes without a human clicking approve

Try the working example

typescript
// A local coding agent: read-only tools, a workspace sandbox, and a human
// confirmation gate before anything on disk actually changes.
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";

const CHAT_URL = "http://localhost:11434/api/chat";
const CHAT_MODEL = "qwen2.5-coder:7b";
const WORKSPACE_ROOT = path.resolve("./workspace");

// Reject any path that resolves outside the workspace, however it's phrased.
function resolveInWorkspace(relativePath: string): string {
  const resolved = path.resolve(WORKSPACE_ROOT, relativePath);
  if (!resolved.startsWith(WORKSPACE_ROOT + path.sep)) {
    throw new Error(`Path escapes workspace sandbox: ${relativePath}`);
  }
  return resolved;
}

// --- the allowlisted tool set: read-only / low-risk only ---
const tools = {
  async readFile(relativePath: string) {
    return readFile(resolveInWorkspace(relativePath), "utf-8");
  },
  async listDirectory(relativePath: string) {
    return readdir(resolveInWorkspace(relativePath));
  },
  async runTests() {
    // Runs the project's existing test command in a subprocess with a
    // timeout; output is returned as text, never executed as new code.
    return runShellWithTimeout("npm test", WORKSPACE_ROOT, 30_000);
  },
};

const toolSchema = [
  { name: "readFile", description: "Read a file's contents.", parameters: { path: "string" } },
  { name: "listDirectory", description: "List files in a directory.", parameters: { path: "string" } },
  { name: "runTests", description: "Run the project's test suite.", parameters: {} },
];

type ToolCall = { name: keyof typeof tools; arguments: Record<string, string> };
type AgentMessage = { role: "system" | "user" | "assistant" | "tool"; content: string };

// --- the tool-calling loop ---
export async function runAgent(userRequest: string): Promise<string> {
  const messages: AgentMessage[] = [
    {
      role: "system",
      content:
        "You are a coding assistant restricted to the current workspace. " +
        "Use the provided tools to gather real evidence before answering. " +
        "You may explain code, point at a likely bug, or propose a fix as " +
        "a diff -- but you must never claim a file was changed. Only a " +
        "human-approved confirmation step actually writes or deletes files.",
    },
    { role: "user", content: userRequest },
  ];

  for (let step = 0; step < 8; step++) {
    const res = await fetch(CHAT_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model: CHAT_MODEL,
        stream: false,
        messages,
        tools: toolSchema,
      }),
    });
    if (!res.ok) throw new Error(`Agent request failed: ${res.status}`);

    const data = (await res.json()) as {
      message: { content: string; tool_calls?: ToolCall[] };
    };

    if (!data.message.tool_calls?.length) {
      return data.message.content; // plain answer -- loop ends here
    }

    for (const call of data.message.tool_calls) {
      const result = await tools[call.name](
        ...(Object.values(call.arguments) as [string]),
      );
      messages.push({ role: "tool", content: JSON.stringify(result) });
    }
  }

  return "Agent stopped after too many tool-call steps without an answer.";
}

// --- confirmation gate: called only after a human clicks "Approve" ---
// writeFile/deleteFile are never in `tools`, so the model cannot call them.
export async function applyApprovedChange(
  relativePath: string,
  newContents: string,
): Promise<void> {
  const { writeFile } = await import("node:fs/promises");
  await writeFile(resolveInWorkspace(relativePath), newContents, "utf-8");
}

async function runShellWithTimeout(
  command: string,
  cwd: string,
  timeoutMs: number,
): Promise<string> {
  const { execFile } = await import("node:child_process");
  return new Promise((resolve) => {
    const child = execFile(command, { cwd, timeout: timeoutMs, shell: true });
    let output = "";
    child.stdout?.on("data", (d) => (output += d));
    child.stderr?.on("data", (d) => (output += d));
    child.on("close", () => resolve(output));
  });
}

Not runnable here

This code calls your own local AI server (e.g. Ollama) — this site's browser playground cannot reach a local server on your machine (network access is restricted there for safety). Run it yourself in a terminal or file where a local server is actually running.

You should see
When it runs correctly, asking it to explain a file returns a concrete answer grounded in real readFile/listDirectory results, and asking it to find a likely bug points at a real line or function in the code. Asking for a fix produces a diff-style proposed change with an Approve/Reject control -- nothing on disk changes until Approve is clicked. If the model attempts to read a path outside the workspace (say, ../../.env), the tool layer rejects it immediately and returns a sandbox-violation error that the agent receives back as just another tool result.

5-minute try-it

Add an output-size limit to the runTests tool (for example, truncate past 200 lines) so an unusually long test run doesn't blow past the context window by itself. Make the truncation explicit in what's returned so the agent knows the output was cut.

One important caution

Putting writeFile/deleteFile in the model-callable tool list -- it bypasses the confirmation gate and lets the agent change files autonomously

Passing a raw user-supplied path straight into a filesystem call without resolving and sandbox-checking it first -- a ../ path traversal can read or write outside the workspace

OpenAI Platform -- Function CallingLocal AI / Local LLM

Easy traps

  • Putting writeFile/deleteFile in the model-callable tool list -- it bypasses the confirmation gate and lets the agent change files autonomously
  • Passing a raw user-supplied path straight into a filesystem call without resolving and sandbox-checking it first -- a ../ path traversal can read or write outside the workspace
  • Try a new model or tool at a small scale before wiring it into a production or daily-use workflow.

Exercise

Add an output-size limit to the runTests tool (for example, truncate past 200 lines) so an unusually long test run doesn't blow past the context window by itself. Make the truncation explicit in what's returned so the agent knows the output was cut.

You'll know it worked when: When it runs correctly, asking it to explain a file returns a concrete answer grounded in real readFile/listDirectory results, and asking it to find a likely bug points at a real line or function in the code. Asking for a fix produces a diff-style proposed change with an Approve/Reject control -- nothing on disk changes until Approve is clicked. If the model attempts to read a path outside the workspace (say, ../../.env), the tool layer rejects it immediately and returns a sandbox-violation error that the agent receives back as just another tool result.