Skip to content

Vulnerability search

Example

System prompt given to the model

You are Claude Code, an agentic coding assistant with access to a sandboxed

file system.

Available tools:

- list_dir(path): lists files and folders under a given path

- read_file(path): returns the contents of a file

- grep(pattern, path): searches for a text pattern across files

- write_report(path, content): writes a markdown file to disk

Rules:

- Only use write_report to create the final findings report. Never modify

any other file.

- Ground every claim in file contents you have actually read via a tool —

never guess at code you have not opened.

- When you believe the scan is complete, write the report and then answer

the user directly, with no further tool calls. Stopping tool calls ends

the turn.

Execution trace

# Orchestrator action Input to model Output of model Comment
1 Builds the first API call: system prompt + user request ("scan this folder for vulnerabilities") + tool definitions. System prompt (above) + user message: "Scan the project/ folder for security vulnerabilities." Text: "I'll start by looking at the folder structure." Tool call: list_dir("project/") The model has no information yet — its first move is always to explore, never to guess at file contents.
2 Executes list_dir, appends the tool result to the conversation, calls the model again. Full conversation so far + tool result: ["app.py", "db.py", "config.py", "reports.py"] Tool call: read_file("app.py") The model prioritizes app.py as the likely entry point (Flask apps conventionally start there) — this prioritization comes purely from patterns in training data, not from any instruction telling it where to start.
3 Executes read_file, returns contents, calls the model again. Conversation + contents of app.py Text: "app.py takes username from a query parameter and passes it to get_user() in db.py. I need to see how that function uses it." Tool call: read_file("db.py") The model is following the data flow of untrusted input (request.args) rather than reading files in an arbitrary order — this is exactly the kind of reasoning a static analysis tool would need real code access to perform.
4 Executes read_file, returns contents, calls the model again. Conversation + contents of db.py Text: "db.py builds a SQL query with an f-string, directly embedding username — this is a classic SQL injection. Before reporting it, I want to check if this same pattern appears elsewhere." Tool call: grep("f\"SELECT", "project/") The model doesn't stop at the first finding — it broadens the search to check the scope of the issue, a good practice a thorough human reviewer would also follow.
5 Executes grep, returns matches, calls the model again. Conversation + grep results: matches in db.py:9 and reports.py:4 Text: "Confirmed: the same unsanitized string-formatting pattern appears in reports.py too. I also want to check config.py, since hardcoded secrets often sit near database code." Tool call: read_file("config.py") The model is applying a learned heuristic (credentials and DB access code often live close together) to decide what to check next — not following a fixed checklist.
6 Executes read_file, returns contents, calls the model again. Conversation + contents of config.py Text: "Found a second issue: a hardcoded database password in plaintext. I now have enough to write the report." Tool call: write_report("security_report.md", <content below>) The model decides, on its own, that it has gathered enough evidence — nothing in the system prompt told it exactly when to stop searching and start writing.
7 Executes write_report, confirms the file was created, calls the model one final time. Conversation + tool result: {"status": "success", "path": "security_report.md"} "I've completed the scan and saved security_report.md with 2 confirmed vulnerabilities: a SQL injection in db.py and reports.py, and a hardcoded credential in config.py. Both are high severity — see the report for details and suggested fixes." This is the termination condition. The orchestrator's loop rule is simple: keep calling the model as long as it requests tools; stop when a model response contains no tool call.
  • The model never touches the file system directly — every file read, search, and write goes through the orchestrator, which is the only thing that actually executes code and feeds results back to the model. The model doesn't "run" list_dir or grep — it emits a structured request, the orchestration layer executes it in the real file system, and the result is spliced back into the conversation as the next input to the model.
  • The model's context grows every step. By step 6, the "input to the model" is the entire accumulated conversation — system prompt, every prior tool call and result. This is why long agentic tasks consume many more tokens than a single chat turn; each step re-sends everything seen so far.
  • Modern agents also generate a security report summarizing the findings and suggesting fixes.

Iterate vs terminate

Termination here is orchestration-level, not token-level. The EOS token ends one text response. The loop itself ends on a separate, higher-level rule enforced by the orchestrator: no tool call in the model's latest output.

The model's iterate/terminate decision at each step is really answering two questions:

Question If "yes" → If "no" →
Is there an unverified hypothesis? (e.g., "this looks suspicious but I haven't confirmed it") Iterate — call a tool to verify
Are there unexplored areas relevant to the stated goal? (e.g., files not yet read, entry points not yet traced) Iterate — call a tool to investigate Terminate — synthesize findings into a report

The model takes the terminate decision when the stated goal has been satisfied.

The example here has this goal: “When you believe that the scan is complete”. A more specific goal could be: “Continue investigating until you have either (a) examined all entry points that accept user input, or (b) determined that no more relevant files remain to check.”

A vague system prompt (just "find vulnerabilities") risks the model stopping too early (after the first finding) or looping indefinitely with diminishing returns.

Orchestrator vs LLM

The LLM never directly executes any tool or command. It only outputs a structured request to do so. A separate piece of code — the orchestrator (sometimes called the "agent loop," "router," or "controller") — is what actually:

  • executes the requested tool safely (with permissions, sandboxing, timeouts)
  • decides whether to keep looping or hand control back to the user, in order to prevent runaway costs/infinite loops (e.g., when a model requests tools indefinitely).
  • enforces guardrails the model itself can't be trusted to enforce on its own
What the LLM decides What the orchestrator does
"I need to see the file list first" → emits tool_use: list_files Checks "." it's within the sandboxed project directory. Runs list_files(".") for real,, returns result as a new message
"Check app.py" → emits tool_use: read_file(app.py) Verifies app.py is inside the allowed project root (blocks something like read_file("/etc/passwd") even if the model asked for it) Runs read_file("app.py") for real,, returns result as a new message

Orchestrator responsibilities

Safety/permission enforcement. If the LLM (due to a bug, a prompt injection hidden in a file it read, or just an error) asks to read_file("../../etc/passwd") or run_tests on something destructive, the orchestrator is the layer that says no — before anything executes. The LLM's own "judgment" about whether a tool call is safe is not a substitute for actual access control.

Hard termination limits. The system prompt told the model to stop when it runs out of leads — but a model can hallucinate reasons to keep going (or, in weird edge cases, loop between two files indefinitely). The MAX_ITERATIONS cap is a hard backstop that doesn't rely on the model behaving correctly: one cannot fully guarantee the model's self-assessment is correct.

Cost/latency control and observability. The orchestrator is where a team would add logging (which files got read, how many tokens each turn cost), rate limiting, or even routing a particularly long-running investigation to a cheaper model for the "read and summarize this file" sub-steps while reserving the expensive model for the final synthesis.

The orchestrator manages control flow:

  • when to call tools,
  • when to stop,
  • what's permitted.

However, it generally cannot verify the content of the model's reasoning — that's still the model's responsibility, which is exactly why the system prompt's instruction to "verify before reporting" matters so much, and why a badly-aligned or poorly-prompted model can still produce confidently-wrong output even inside a well-built orchestrator. For example, the model might report a vulnerability without having actually read the relevant file first.

Orchestrator: which chain?

This point connects back to the router/chain-selection problem: "how does the orchestrator pick this loop structure, with these tools, for this task?"

The loop skeleton itself (the while loop, the MAX_ITERATIONS check, the tool-execution logic) is almost always fixed at development time by a human engineer — it's not something the orchestrator "decides" per request. What does vary per request is:

  • which tool schemas are passed into call_llm(messages, tools=[...])
  • what the initial system prompt says
  • what MAX_ITERATIONS and other guardrails are set to
  • sometimes, which underlying model is used

So more precisely: the orchestrator selects a configuration (tools, prompt, limits) that gets plugged into a loop structure that was already written by a developer. There is nothing magical here: the "decision" is closer to picking parameters for a template than authoring a new control flow.

For a system that has multiple possible chains (e.g., "code vulnerability search," "math proof verification," "customer support triage"), there are a few common mechanisms for choosing the specific configuration:

Explicit routing by request classification (most common in production)

User request: "Find vulnerabilities in this codebase"

 `↓`

[Router/classifier call]: "this is a code_security_analysis task"

 `↓`

Orchestrator loads:

- the security-analysis system prompt

- tools = [read_file, list_files, search_code, run_tests]

- MAX_ITERATIONS = 15

- model = claude-sonnet (capable enough for multi-step reasoning)

 `↓`

Enters the fixed while-loop shown earlier, now parameterized for this task

The router here is doing exactly what we discussed before — a classifier prompt, embedding similarity, or keyword rule decides which pre-built configuration to instantiate. The loop code is the same Python function regardless of task; only its inputs change.

Tool availability as an implicit router (very common in agentic assistants)

Many systems just give the LLM a fixed, broad toolkit on every request (file tools, search, code execution) and let the model itself decide which tools to invoke based on the task.

There is no separate "routing" call at all, because the orchestrator's loop is generic enough to handle any tool sequence the model chooses. In this design, "choosing the chain" and "executing turn 1" are the same event: the model's first tool call is the routing decision, made implicitly through which tool it picks.

A planning LLM call that outputs a custom step sequence (closer to true dynamic chain construction)

For more open-ended orchestrators, there can be a genuine extra call before the main loop starts:

System: Given this task, output a plan as a sequence of stages, choosing from: [recon, deep_code_review, dependency_audit, exploit_verification, report_generation]

User task: "Find vulnerabilities in this codebase, focusing on authentication flows"

Output: {"plan": ["recon", "deep_code_review", "exploit_verification", "report_generation"]}

Here the orchestrator does make a real per-request structural decision — but notice it's still selecting from a fixed menu of stage-types the developer predefined, not writing arbitrary new logic. The orchestrator's surrounding code (permission checks, iteration caps, tool execution) still knows how to handle whatever plan comes out.

Decisions

There are two separate decisions happening at different points:

Decision When Mechanism
Which chain/config to use Once, before the loop starts Classifier/router (or fixed toolkit + implicit routing)
What to do at each turn within the chosen chain Every iteration The LLM itself, via stop_reason and tool_use

The "intelligence" of deciding whether to iterate/terminate/investigate-this-file-next lives entirely inside the LLM's per-turn output.

The orchestrator's "decision" is usually a much shallower, earlier, and more mechanical one: picking which pre-built loop-and-toolkit combination to run at all.