DS 6042 · Lab 04 · 2026

microagent — guided walkthrough

A 90-line ReAct agent, taken apart and rebuilt in front of you.

Every agent framework you'll meet in industry — LangChain, LlamaIndex, OpenAI Assistants, Anthropic's computer_use, AutoGen — is a wrapper over the same 90 lines. Understanding what those 90 lines actually do (and don't do) is the precondition for both building useful agents and attacking ones that aren't yours. This lab gives you that grounding. Lab 05 then uses it to attack a near-identical agent.

The whole agent is one Python file: a system prompt, one tool, two LLM clients (one canned-toy, one real), one ReAct loop, and a CLI. Run it once in toy mode and you have run the entire loop offline — no Rivanna credentials, no internet — and watched every action and every observation print as it goes. Once the loop makes sense in 90 lines, the only thing that changes in the 200-line production version is the surface area: FastAPI replaces input(), three tools replace one, two filters wrap the IO, and a SQLite-backed knowledge base joins the context. The loop is the same loop.

agent pattern: ReAct · Yao et al., 2022 · companion walkthrough: Lab 02 · microGPT · production agent and attacks: Lab 05 · Attacking AI Agents · runtime: any OpenAI-compatible endpoint, default is UVA RC GenAI Kimi K2.5.
important · inline questions

Short questions appear throughout the lab in boxes like this one. They mirror the kind of question that will show up on the midterm and final, so take a beat to answer them in your head (or out loud with a partner) before clicking Show answer. There are six in total.

Before you start · launch a Rivanna interactive session

You'll run everything in this lab — the toy agent, the real-model runs, and your assignment — inside a Code Server session on Rivanna. Open Open OnDemandInteractive Apps → Code Server and launch it with these settings (no GPU needed — the agent just calls the remote model):

When the session shows Running, click Connect to Code Server, then open a terminal (Terminal → New Terminal). Every command in this lab runs in that terminal.

OOD Code Server launch form: partition=Interactive, hours=2, cores=1, memory=6 GB, Work Directory=SCRATCH, GPU for Interactive partition=No, ready to Launch
The Code Server launch form for this lab — Interactive partition, 1 core, 6 GB, no GPU. microagent only needs CPU; the model lives on the RC GenAI service, which your Rivanna session reaches directly.
Try it · pick a question, watch the agent run
Three canned scenarios. Pick one and press ▶ Step to walk the ReAct loop one action at a time. Each step shows what the LLM produced (a JSON action), what the tool returned (the observation), and what the LLM's context window now contains. The trace below is exactly what python microagent.py --toy prints to your terminal — no API, no network.
Trace · what the agent does
Context window · what the LLM reads on the next step

The whole agent is the dance between those two panels. The LLM looks at the context, emits an action, the runtime executes the action, the result appends to the context. Repeat until the LLM emits {"action": "final"}. There is no other machinery.

Where to find it

1 · Anatomy of an agent

An agent is a program that chooses its own next action. A chatbot returns the LLM's reply directly; an agent treats the LLM's reply as a structured plan, executes that plan, and feeds the result back into the next LLM call. More precisely: an agent receives your message, reasons about what to do, decides which tools to call, executes those tools, observes the results, and either takes another action or responds.

That description names five components — and you can see each of them in microagent.py with a single grep:

Componentmicroagent.py (this lab)baseline_agent.py (next lab)Claude Code (reverse-engineered source)
LLM core30 lines · ToyLLM or RealLLMsame RealLLM in llm_client.pystreaming Anthropic messages.create()
System prompt10 lines · inline string30 lines · loaded from system_prompt.txtmulti-section prompt built at launch + CLAUDE.md
Tools1 tool · file_read3 tools · file_search, file_read, config_lookupdozens · Bash, Read, Edit, Grep, Task (sub-agents), WebFetch, …
Memoryconversation only (per session)+ SQLite knowledge base read on every /chatconversation context (auto-compacted) + CLAUDE.md memory files
Guardrailsnoneinput keyword filter + output substring filtertool-permission prompts + hooks + model-level refusals
Loop runtimereact() · 15 linessame react_loop() · 25 linessame loop, client-side in the CLI — readable in the leaked source
All three columns run the same loop. microagent is the loop with the dressings removed; baseline_agent is what most "production" agents in 2026 actually look like; Claude Code is the same loop, battle-hardened and wrapped in a polished CLI — and because its source has been reverse-engineered, you can read the production version of the exact react() loop you're about to build.
Try it · what changes between columns?

Looking at the three columns of the table above: which component grows most between microagent and baseline_agent, and which one changes the least? Why?

Show answer
The system prompt grows the most (10 → 30 lines) — production agents pack policy, planted credentials, and behavioral rules into it. The one that changes the least is the LLM core: baseline_agent imports the very same RealLLM.chat(messages) you use here, untouched. The loop runtime grows only a little (15 → 25 lines) — a guardrail check and a memory read slotted into the same bounded think → act → observe cycle — so what grows is the surface around the loop, not the cycle itself. That's the structural claim of the lab: more surface, same loop.

2 · The microagent code

Open agent/microagent.py in your editor. ↓ download lab-04-code.zip Read along below — every line is here, broken into the six sections you saw in the table. Hover any line for an explanation; the panel updates with the rationale.

2.1 The tool

An agent's tools are the only way it can affect the world. microagent has one: file_read. It is six lines, including the docstring, and it does exactly what it says.

def file_read(path: str) -> str:
"""The agent's only tool: read a markdown file from microdata/."""
p = DATA / path
if p.exists(): return p.read_text()
return f"not found: {path}. available files: " + ", ".join(sorted(q.name for q in DATA.glob('*.md')))
hover any line to see what it does
scaling up · what production tools look like

baseline_agent.py exposes three tools (file_search, file_read, config_lookup), all in tools.py, all scoped to one directory. In LangChain, every tool has to be turned into a class with a type-checked schema describing its arguments. In Claude Code, every tool ships a JSON-schema spec the model is shown up front, and the CLI executes it locally behind a permission prompt. There is more setup code in each case, but the actual job each tool does is still the same six lines you just read above.

Try it · why scope the tool to one folder?

The tool resolves every path argument relative to microdata/ and refuses to read anything outside it. If we trust the LLM to call its own tool with sensible arguments, why bother with the scoping at all?

Show answer
Because the LLM's "arguments" are not written by you — they come from the same token stream as the user's message, and a clever input could persuade the LLM to call file_read("/etc/passwd") just as easily as file_read("password_policy.md"). Scoping the tool to a known directory is a structural defense that doesn't depend on the LLM behaving well. Keep an eye on places like this throughout the lab — they are the seams where the next lab's attacks land.
Try it · why list the files when one isn't found?

When file_read can't find a path it doesn't just say not found — it returns the names of the files that do exist (and the system prompt lists them too). Why go to that trouble instead of a bare error?

Show answer
The error message is the agent's observation — the only thing the LLM learns about what happened. The model can't ls the directory itself; it knows only what the tool hands back. A bare not found leaves it guessing another filename or quitting with an empty answer (exactly the failure you get without this). Naming the available files turns the error into a recovery hint: the next loop iteration reads the right file. The lesson generalizes — in an agent, a tool error is feedback to a model, not a human, so make errors actionable. (Same reason the prompt names the files up front: the model only ever knows what's in its context.)

2.2 The system prompt

This is the only "intelligence configuration" the LLM gets. Eleven lines tell it who it is, what one tool it has, and the JSON shape it must respond in.

SYSTEM_PROMPT = """You are a tiny IT helper. You have one tool:
file_read(path) — read a file from the knowledge base
The knowledge base contains: hello.md, network_help.md, password_policy.md.
When you want to call the tool, reply with this JSON, nothing else:
{"action": "file_read", "args": {"path": "..."}}
When you have the answer, reply with: {"action": "final", "answer": "..."} """
hover any line to see what it does
why this is the attack surface

Everything the LLM "knows" about its own rules is in that string. Anything that gets concatenated next to the system prompt — a user message, a tool observation, a poisoned document — is read with the same trust level. The system prompt has no asterisk on it. As you read the rest of the lab, see how many ways you can imagine to make the agent ignore one of the rules in that string. Some are subtle; some are not.

Try it · trust boundary

The system prompt sits in a Python string variable; the user's message arrives over an input() call. From the LLM's point of view, what is the difference between those two strings once they reach the model?

Show answer
None — both are tokenized and placed into the same context window with a role label. The LLM has no cryptographic way to verify which tokens were authored by the developer vs. the user. The "trust" the developer places in the system prompt is purely conventional. This is the structural fact every prompt-injection attack exploits.

2.3 The toy LLM

The agent's "intelligence" is the part that decides which action to emit. We replace it with a dictionary so you can read the entire decision policy in one screen. Three scenarios, two-or-three steps each. Don't worry — we swap this dictionary out for a call to a real LLM (Rivanna's Kimi K2.5) in the next section. Same loop, same interface, just a different chat() method.

class ToyLLM:
    """Canned responses, hand-pinned to scenarios. No API call."""

    SCRIPTS = {
        "how do i reset my password": [
            {"action": "file_read", "args": {"path": "password_policy.md"}},
            {"action": "final", "answer":
             "Visit https://password.megacorpone.local. Locked out? Ext. 4357."},
        ],
        "my wifi keeps dropping": [
            {"action": "file_read", "args": {"path": "network_help.md"}},
            {"action": "final", "answer":
             "Forget the network and rejoin megacorp-corp (not guest). "
             "If it keeps dropping, restart NetworkManager."},
        ],
        "hello": [
            {"action": "final", "answer": "Hi! Ask me about passwords or wifi."},
        ],
    }

    def __init__(self):
        self.step = 0
        self.script = None

The SCRIPTS dictionary is the "intelligence." Each key is a normalized user question; each value is a sequence of {"action": ...} dicts — exactly what a real LLM would emit, hand-pinned. The __init__ seeds two pieces of state: step (which action to return next) and script (which scenario we've locked in for this conversation). Before we discuss the chat() method, let's discuss one Python primitive to call out:

Python aside · what does next(...) do?

If you haven't seen next() before, it's the built-in that pulls the next value from any iterator and stops there — useful when you want the first match out of a sequence without writing a full for loop. Three quick examples, then on to the microagent line.

# 1. Plain iterator — next() returns the next item, one at a time.
nums = iter([10, 20, 30])
next(nums)              # → 10
next(nums)              # → 20

# 2. Generator expression — pull the FIRST value the expression yields.
#    Reads left-to-right: "for each x in [...], if x > 1, give me x".
next(x for x in [1, 2, 3] if x > 1)        # → 2  (the first match)

# 3. Default value — what to return if the iterator is empty.
next((x for x in [] if x > 0), "no match") # → "no match"
#    Without the default, next() raises StopIteration on an empty iterator.

Here's the microagent line we're about to hover:

key = next(m["content"] for m in messages if m["role"] == "user")

It says: "walk messages; for each message m whose role is "user", give me m["content"]; stop at the first one and assign it to key." Equivalent to this longer version:

key = None
for m in messages:
    if m["role"] == "user":
        key = m["content"]
        break

The two-line / six-line difference is what makes next() idiomatic Python for "find the first thing in a sequence that matches a condition." It is a common interview-style pattern; worth recognizing in the wild.

With that in your pocket, here's chat() line by line:

def chat(self, messages):
if self.script is None:
key = next(m["content"] for m in messages if m["role"] == "user")
self.script = self.SCRIPTS.get(key.lower().strip(" ?.!"), [ {"action": "final", "answer": "No canned answer for that."}, ])
action = self.script[min(self.step, len(self.script) - 1)]
self.step += 1
return json.dumps(action)
hover any line to see what it does

Replacing this class with a real LLM client (next subsection) does not change the loop one line. That is the entire point of the abstraction.

Try it · chat with the toy agent
Same canned dictionary you just read, wired up as a chat. Type a question and press ▶ Send. If the dictionary has a matching script, the agent walks the ReAct loop and shows you each action and observation. If it does not, you see the fallback. The three preset chips below match the three keys in SCRIPTS — start there, then try one of your own to see what happens.
Type a message below, or click one of the preset chips above.

The agent's entire "intelligence" is the dictionary above. When you type something the dictionary doesn't know, the toy LLM emits its fallback final action and the loop ends after one step. Swap ToyLLM for RealLLM in the next section and the same code path runs against Kimi K2.5 — same loop, same chat UI, different brain.

2.4 The real LLM

The OpenAI-compatible REST client. Works against UVA Rivanna's Kimi K2.5, against vLLM, against Ollama in OpenAI-mode, against any chat-completions endpoint. The RC GenAI endpoint streams its replies as Server-Sent Events (even for one-shot calls), so chat() stitches the assistant's content deltas back together — and skips Kimi's reasoning deltas, which are its private chain-of-thought.

class RealLLM:
    def __init__(self):
        import httpx
        self.url   = os.environ["LLM_BASE_URL"].rstrip("/") + "/chat/completions"
        self.key   = os.environ["LLM_API_KEY"]
        self.model = os.environ["LLM_MODEL"]
        self.client = httpx.Client(timeout=120)

    def chat(self, messages):
        r = self.client.post(self.url,
            headers={"Authorization": f"Bearer {self.key}"},
            json={"model": self.model, "messages": messages,
                  "temperature": 0.2, "stream": True})
        r.raise_for_status()
        out = []
        for line in r.text.splitlines():            # Server-Sent Events: "data: {json}"
            if not line.startswith("data: "): continue
            payload = line[6:].strip()
            if payload == "[DONE]": break
            try:    delta = json.loads(payload)["choices"][0]["delta"]
            except (json.JSONDecodeError, KeyError, IndexError): continue
            if delta.get("content"): out.append(delta["content"])
        return "".join(out).strip()

Same chat(messages) → string interface as ToyLLM. The loop calls this exactly the same way — it just reassembles the streamed answer before returning.

setup · getting an API key and creating .env

The three os.environ[...] calls in the constructor mean the agent expects LLM_BASE_URL, LLM_API_KEY, and LLM_MODEL in the environment. Here is how to get them set:

Step 1 · get your API key from the RC GenAI portal. Open open-webui.rc.virginia.edu and sign in with NetBadge. Click your name → Settings, open the Account tab, and scroll to API keys at the bottom — generate one (or reveal and copy an existing key). That key is your LLM_API_KEY. The portal's model picker also lists the available model identifiers; Kimi K2.5 (model id Kimi K2.5) is what this lab targets.

UVA RC Open WebUI Settings page with the Account tab selected and the API keys field at the bottom
In open-webui.rc.virginia.edu: Settings → Account → API keys. Reveal and copy the key here, then paste it into agent/.env as LLM_API_KEY. Treat it like a password — it's personal and revocable.

Step 2 · copy the example file, then open agent/.env in the Code Server editor and paste your token.

$ cd Class/labs/lab-04
$ cp agent/.env.example agent/.env

The file looks like this — replace the placeholder on the second line with the token from the portal:

LLM_BASE_URL=https://open-webui.rc.virginia.edu/api
LLM_API_KEY=replace-with-your-rivanna-genai-token
LLM_MODEL=Kimi K2.5

Step 3 · install the dotenv helper so microagent.py picks the file up automatically:

$ pip install python-dotenv httpx

That's it. Run python agent/microagent.py --real in §3.2 below and the agent will read your .env, hit the endpoint, and talk to Kimi K2.5. Do not commit agent/.env to git — the token is personal and revocable. The .env.example file is the one you check in; .env stays local.

Alternative (no .env file) — if you'd rather not install python-dotenv, just export the three variables in your shell before running. The same three names; same effect. The .env route is friendlier when you'll be running the agent in multiple terminals over the course of a lab session.

2.5 The ReAct loop

This is the heart of the agent and the thing every framework reimplements with more ceremony. Fifteen lines, including the print statements. Before the code, here is the loop as a picture — click any node to see what runs there and (down below) the matching lines in the code light up.

Try it · the ReAct loop as a flow diagram
Each rectangle is one step in the loop. The decision diamond branches: final exits the loop; anything else dispatches a tool, appends the result back into the message list, and goes around again. The loop is bounded — for step in range(5) — so a broken LLM can't run forever.
for step in range(5): Seed messages system + user LLM thinks llm.chat(messages) Parse action JSON parse_action(reply) action == "final" ? yes no Dispatch tool TOOLS[name](**args) Append turns assistant + user(obs) loop back · up to 5 times Final answer return action["answer"]
Click any node
Pick a step in the diagram on the left. The detail panel will show what runs at that step, the matching lines in the annotated code below, and what an attacker could inject there.

Two structural facts to notice: (1) the loop body is "ask LLM → maybe call a tool → record both turns in messages → repeat" — there is no other ceremony; (2) the "append turns" step does not distinguish the tool's output from a real user message. That single design decision is what makes prompt injection possible.

Python aside · what does ** mean in tool(**args)? click to expand

One line in the loop is doing several things at once:

obs = tool(**action.get("args", {})) if tool else f"unknown tool: {action['action']}"

The piece that's easiest to miss is the leading ** on action.get("args", {}). That's Python's dictionary-unpacking operator: it takes a dict and spreads it out as keyword arguments to the function call. Two expressions that mean the same thing:

tool(path="password_policy.md")                  # explicit keyword

args = {"path": "password_policy.md"}
tool(**args)                                     # unpack the dict
# Equivalent — the ** spreads {"path": "..."} into path="..."

If the dict has more than one key, all of them get spread:

def greet(name, greeting="hi"):
    return f"{greeting}, {name}!"

kw = {"name": "alice", "greeting": "hello"}
greet(**kw)            # → "hello, alice!"
# same as           greet(name="alice", greeting="hello")

The rule: each key in the dict must match a parameter name on the function. An unknown key raises TypeError. A missing required parameter does too. That's why file_read(**{"path": "policy.md"}) works (path is the parameter name) but file_read(**{"file": "policy.md"}) would not.

Now read the rest of the line:

obs = tool(**action.get("args", {})) if tool else f"unknown tool: {action['action']}"
#     └──────────────┬───────────┘  └─┬─┘   └──────────────┬──────────────────┘
#       call tool with whatever     pick which              fallback string when
#       args the LLM produced       branch                  the tool name is not in TOOLS

The same **dict spread is everywhere in Python: argparse, dataclasses, dictionary merging ({**a, **b}), Flask/FastAPI routes. Worth recognizing.

def react(llm, user_msg: str, verbose: bool = True) -> str:
messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_msg}]
for step in range(5):
action = parse_action(llm.chat(messages))
if verbose: print(f" [step {step}] action: {action}")
if action["action"] == "final": return action["answer"]
tool = TOOLS.get(action["action"]) obs = tool(**action.get("args", {})) if tool else f"unknown tool: {action['action']}"
if verbose: print(f" [step {step}] observation: {obs[:120].rstrip()}")
messages.append({"role": "assistant", "content": json.dumps(action)})
messages.append({"role": "user", "content": f"Observation: {obs}"})
return "(ran out of steps)"
hover any line to see what it does
the trust-boundary problem in two lines

Look at the last two append lines. The assistant's tool action and the tool's output both get appended to the same message list. The next LLM call cannot distinguish "I, the LLM, said this" from "the tool said this" from "the user said this". Every token in messages reads with the same authority. This is the structural reason prompt injection works. If you were the attacker, which of those three channels would you reach for first?

Try it · why role "user" for an observation?

The runtime appends the tool's output as {"role": "user", "content": "Observation: ..."}. Why not {"role": "tool", ...}, given that there is a tool role in modern chat APIs?

Show answer
For microagent, we want the loop to be a single screen of code and to work against any OpenAI-compatible endpoint — including older ones that don't define a tool role. Re-using user for observations is the smallest portable choice. The structural problem doesn't go away with a dedicated role: the LLM still reads every message in the same context with the same authority. A new role label is a hint, not a trust boundary.

2.6 The CLI

An input() loop with an arg parser. Nothing surprising — it picks which LLM client to instantiate and then asks for questions.

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--toy",  action="store_true")
    ap.add_argument("--real", action="store_true")
    ap.add_argument("-q", "--quiet", action="store_true")
    args = ap.parse_args()
    llm = ToyLLM() if (args.toy or not args.real) else RealLLM()
    print("microagent ready. type questions; Ctrl-D to quit.\n")
    while True:
        try:
            q = input("you> ").strip()
        except (EOFError, KeyboardInterrupt):
            print(); break
        if not q: continue
        print("agent>", react(llm, q, verbose=not args.quiet))
Python aside · what does action="store_true" do? click to expand

Three of the four lines added to the argument parser use action="store_true". This is argparse's way of declaring a boolean flag — a switch that's either on or off, with no value after it on the command line.

The rule, in one sentence: if the user types the flag, argparse sets the matching attribute on args to True; if they don't, it's False (the default).

ap.add_argument("--toy",  action="store_true")    # args.toy   ← bool
ap.add_argument("--real", action="store_true")    # args.real  ← bool
ap.add_argument("-q", "--quiet", action="store_true")  # args.quiet ← bool

That's it — no =value, no extra argument. Compare with the more common form:

ap.add_argument("--port")                # python ... --port 8001  → args.port = "8001"
ap.add_argument("--port", type=int)      # python ... --port 8001  → args.port = 8001  (cast to int)
ap.add_argument("--toy", action="store_true")  # python ... --toy        → args.toy  = True
                                         # python ...               → args.toy  = False

Test it yourself — drop a print(args) into main() right after args = ap.parse_args() and watch the values change:

$ python agent/microagent.py
Namespace(toy=False, real=False, quiet=False)

$ python agent/microagent.py --toy
Namespace(toy=True, real=False, quiet=False)

$ python agent/microagent.py --real --quiet
Namespace(toy=False, real=True, quiet=True)

$ python agent/microagent.py -q          # short form of --quiet
Namespace(toy=False, real=False, quiet=True)

Two consequences worth knowing:

That is the whole agent. Six sections, ninety lines, one screen.

3 · The toy LLM, hand-traced ~20 min

The toy LLM is the same trick as microGPT's toy walkthrough: pin every number, then run the full loop with the pinned numbers visible. Here we pin the LLM's "thoughts" instead of the model weights, but the technique is the same — the abstract becomes concrete by lookup, not by computation.

3.1 Run it yourself

$ cd Class/labs/lab-04
$ python agent/microagent.py --toy
microagent ready. type questions; Ctrl-D to quit.

you> how do I reset my password?
  [step 0] action: {'action': 'file_read', 'args': {'path': 'password_policy.md'}}
  [step 0] observation: # Password Reset Policy

Visit https://password.megacorpone.local to reset your password.

Requirements: 12+ chars, mixed cas …
  [step 1] action: {'action': 'final', 'answer': 'Visit https://password.megacorpone.local. Locked out? Ext. 4357.'}
agent> Visit https://password.megacorpone.local. Locked out? Ext. 4357.

That is one complete ReAct cycle. The LLM (ToyLLM) emitted a tool call, the runtime read the file, the LLM (still ToyLLM) emitted a final answer, the runtime returned it.

3.2 Run it against Rivanna

$ export LLM_BASE_URL=https://open-webui.rc.virginia.edu/api
$ export LLM_API_KEY=...      # from open-webui.rc.virginia.edu → Settings → Account
$ export LLM_MODEL="Kimi K2.5"
$ python agent/microagent.py --real

Because you're working inside a Rivanna interactive session (see the setup note at the top of the lab), the RC GenAI endpoint is reachable directly from your terminal — no VPN, no tunnel. Just export the three variables and run.

Same agent. Same loop. The only line of code that changes is the llm = ... in main(). Try a question the toy LLM has no canned answer for; Kimi K2.5 will pick whichever tool it thinks fits and trace exactly the same way.

Try it · what happens with a new scenario

Add a fourth entry to ToyLLM.SCRIPTS: when the user says "where is the helpdesk", make the agent file_read a new file you create at microdata/helpdesk_location.md. Run it. Confirm the trace shows two steps and the final answer reads from the file you wrote.

Show what you should see
Two lines in the trace: a step-0 action with file_read + your path, a step-0 observation with the file contents, then a step-1 final action. The "answer" string in the final action is the one you wrote in the script. The agent never invents content — every word in the final answer came from you.

4 · What's next · before the attacks

Lab 05 · Attacking AI Agents takes the production agent (baseline_agent.py) and runs the canonical memory-poisoning attack against it. Lab 05 opens by walking that agent block by block — the same FastAPI service, the two pattern-matching filters, and the SQLite memory — then attacks the seam that matters most:

  1. The unverified knowledge-base retrieval — persistent memory poisoning: one planted row changes the agent's answer for every future user.

Before you click through, look back at microagent one more time. Everything Lab 05 attacks is one new line or one new function bolted onto the loop you just read — once you can hold microagent in your head, the production agent is just microagent with a deployment story, and the bolts are where the attacks live. The further you can reason about each seam on your own first, the more useful Lab 05 will be.

Here is a taste of those seams, straight from baseline_agent.py — read each one and ask yourself "what does this trust, and what happens if that trust is misplaced?"

1 · The deployment shell. The CLI input() loop becomes a FastAPI /chat endpoint. It is the same react_loop() you just read, now sandwiched between an input filter, a memory read, and an output filter:

@app.post("/chat")
def chat(req: ChatIn) -> dict[str, str]:
    refusal = input_filter(req.message)               # runs BEFORE the LLM
    if refusal:
        return {"response": refusal, ...}
    notes = _retrieve_notes(req.message)              # long-term memory
    answer = react_loop(req.message, extra_context=notes)
    return {"response": output_filter(answer), ...}   # runs AFTER the LLM

2 · Two pattern-matching filters. Both compare against fixed strings with Python's in. Anything that means the same thing without matching the literal string sails past — a blind spot Lab 05 leaves vulnerable for you to explore on your own.

def input_filter(text):
    low = text.lower()
    return ("I cannot process that request."
            if any(k in low for k in INJECTION_KEYWORDS) else None)
def output_filter(text):
    return ("I cannot provide that information."
            if any(s in text for s in SECRET_PATTERNS) else text)

3 · Unverified long-term memory. Retrieval sorts by recency and never checks who wrote the row. Whoever writes last owns the agent's next answer on that topic — that is the memory-poisoning attack Lab 05 walks end to end.

rows = conn.execute(
    "SELECT title, body, author, updated_at FROM kb_articles "
    "ORDER BY updated_at DESC"        # freshest wins, author never checked
).fetchall()

That is the whole game: more surface, same loop. Lab 05 walks the agent in detail, then attacks its memory — a hardened secure_agent.py ships alongside if you want to see how the same payload gets blocked.

Assignment · give the agent a shell — and a leash ~2 hours · autograded

The walkthrough agent has one safe tool (file_read). Your job is to give it the single most dangerous tool there is — the ability to run shell commands — and then add the two guards that keep it from becoming a remote-code-execution hole. That is the lab's Build → Break → Secure arc in one deliverable: build a run_command tool, break it in your head (an LLM driving a shell is one prompt injection away from disaster), and secure it with (1) an allowlist and (2) a human-in-the-loop confirmation prompt.

⚠ A command tool runs whatever it's told — keep a human in the loop

A command-running agent does whatever its LLM — or an attacker who reaches its prompt — tells it to. You'll run this inside your Rivanna interactive session (the same one you started at the top of the lab), so two safeguards do the work the isolation used to: the allowlist refuses anything dangerous outright, and the confirmation prompt means nothing executes until you read it and type y. The agent proposes; you decide. This is exactly how production agents like Claude Code gate risky tools.

What you build

Edit microagent.py (template below). Four TODOs, all inside the "YOUR JOB" block:

  1. ALLOWED_COMMANDS — the allowlist of programs the agent may run. Read-only / inspection commands an IT helper actually needs (ls, cat, echo, pwd, whoami, head, …) and nothing that can mutate the system, reach the network, or escalate privilege.
  2. is_safe_command(command) -> Optional[str] — return None to allow, or a one-sentence reason string to refuse. Block at least three classes of attack: shell metacharacters that chain/redirect/substitute (; | & > < ` $(), programs not on your allowlist, and arguments that escape the sandbox (absolute paths, ..).
  3. run_command(command) -> str — if is_safe_command refuses, return "REFUSED: <reason>" (don't even ask). Otherwise print the command and ask the operator to approve it with input("… [y/N] "); if they don't type y, return "SKIPPED: …" and do not run it. If approved, run it without a shell (shlex.split + subprocess.run, no shell=True, with a timeout) inside the sandbox/ directory and return its output.
  4. Register run_command in TOOLS, document it in SYSTEM_PROMPT, and add a ToyLLM scenario that calls it.

Downloads

Interface (don't break it — the autograder reads it)

is_safe_command(command: str) -> Optional[str]   # None = allow; str = refuse-reason
run_command(command: str) -> str                 # output, "REFUSED: ..." (guard), or "SKIPPED: ..." (operator declined)
TOOLS["run_command"] = run_command               # registered, callable
# leave react(), parse_action(), and the ToyLLM interface unchanged

Get the code & run it (in your Rivanna session)

In your Rivanna interactive session's terminal, pull the template into a folder with curl:

mkdir -p lab04/microdata lab04/sandbox && cd lab04
BASE=https://researcher111.github.io/ML-Security-Public/labs/lab-04/assignment
curl -sO "$BASE/microagent.py"
curl -sO "$BASE/test_microagent.py"
curl -sO "$BASE/.env.example"
for f in hello.md network_help.md password_policy.md; do curl -s "$BASE/microdata/$f" -o "microdata/$f"; done
for f in readme.txt host_info.txt;                    do curl -s "$BASE/sandbox/$f"   -o "sandbox/$f";   done

For the real model, copy the env template and fill in your key (edit .env right in Code Server):

cp .env.example .env        # then open .env in the Code Server editor and paste your API key

Then edit microagent.py and run it:

python microagent.py --toy        # canned LLM, watch your scenario run
python test_microagent.py         # local checks: safe allowed, dangerous refused, confirmation works

With .env filled in, run python microagent.py --real — the agent reads .env automatically. You're already on Rivanna, so the endpoint is reachable directly — no tunnel.

What the autograder tests

Gradescope imports your microagent.py and exercises the tool directly (it supplies the y/N confirmation answer automatically). The scary attack strings (rm -rf /, curl | sh, …) are checked only through is_safe_command, which never executes anything — so a buggy guard can't damage the grader. It scores six things, all with partial credit:

  1. Registered & documentedrun_command is in TOOLS and SYSTEM_PROMPT, allowlist non-empty.
  2. Benign allowed — you don't over-block ordinary commands.
  3. Dangerous refused — metacharacters, off-allowlist programs, and sandbox escapes are rejected before any prompt.
  4. Approved commands run — when confirmed, echo, pwd, whoami, ls return real output.
  5. Human-in-the-loop — declining (n) returns SKIPPED and the command does not execute.
  6. Looprun_command's output flows through react() into a final answer.

The grader uses its own attack strings (a superset of test_microagent.py), so a permissive "allow everything" guard fails (3), an over-zealous "refuse everything" guard fails (2) and (4), and a tool that runs without asking fails (5). You need a balanced allowlist plus the confirmation gate.

Submission

Upload your edited microagent.py to Gradescope. (No transcript, no writeup required — it's autograded.)

Rubric

component (autograded)points
run_command registered & documented (TOOLS, prompt, allowlist)10
Benign commands are allowed (no over-blocking)15
Dangerous commands are refused30
Approved commands actually run15
Human-in-the-loop: declining skips the command15
run_command flows through the ReAct loop15
Total100
Going further · optional, ungraded — but this is Lab 05

Once your guard passes, try to beat it. Write three command strings you think should be blocked but slip through, and three the LLM might be tricked into emitting via a poisoned file_read result (indirect prompt injection). That exercise — making the model issue a command its operator never intended — is exactly the attack you'll run for real against a near-identical agent in Lab 05.

FAQ

Why isn't this just LangChain?

LangChain runs the same loop. It also runs a hundred other things: schema-validated tools, vector stores, retrievers, callbacks, tracing, agent executors, partial templates, output parsers, and a quickly-aging plugin ecosystem. None of that is wrong. But the cost is that you can't read the source of your own agent in one screen, and when something behaves surprisingly you have to figure out which abstraction layer to blame. microagent makes a deliberate trade: smaller surface, fewer integrations, every line visible. Reach for LangChain in production; use microagent to understand what LangChain is doing.

Why is the "user" role used for tool observations?

Because the chat-completions API has three roles — system, user, assistant — and tool observations need to enter the context as "something the model reads but didn't say." User is the closest fit. OpenAI's later tool role and Anthropic's tool_result blocks formalize this with a dedicated role, but the semantics are the same. The structural problem doesn't go away with a new role — the LLM still reads everything together, and the trust boundary between a real user and a tool's output is still invisible.

Five steps max — what if a real task needs more?

Raise the limit. Production systems often run 10–50 step budgets and add a time budget on top. The point of the bound is that it exists. An unbounded loop with a flaky LLM is just an infinite loop with extra cost.

Why does the toy LLM ship with three scenarios instead of zero?

So you can run the agent immediately with no setup. Lab pedagogy in this course is "run the smallest thing that works, then read why." Three canned scenarios cover the three loop shapes — one tool + final, multiple tools, final-only. Adding a fourth is the assignment.

The real LLM never reads ToyLLM.SCRIPTS. Why is it in the file?

So both clients live in the same file and you can switch with a flag. In production you'd separate them. In a 90-line teaching artifact, the two-line difference between ToyLLM() and RealLLM() sitting side by side is more valuable than the cleaner separation.

What's the relationship between this lab and Lab 02?

The two labs have the same pedagogical shape. Lab 02 builds the smallest readable transformer; Lab 04 builds the smallest readable agent. Both then scale up to the production version in the next lab (Lab 03's nanochat for the transformer; Lab 05's baseline_agent for the agent). If you found Lab 02 useful, the analog here should feel familiar.