Build the mental model
A plain LLM is a function: text goes in, text comes out, and nothing in the world changes as a result. An agent wraps that same LLM with four additional pieces
- Memory — state carried across steps
- Tools — functions the model can invoke, like reading a file or calling an API
- Planning — breaking a goal into steps
- Execution — actually running those steps and feeding results back
The LLM itself doesn't change; what changes is that its text output can now cause real side effects.
User request
The user sends a request to the LLM.
LLM decides
Given a list of available tools and their descriptions, the LLM decides whether answering requires calling one, and if so, emits a structured call (tool name plus arguments) instead of a plain-text answer.
Program executes
The surrounding program — not the model — actually executes that tool, since the LLM has no direct access to your filesystem, network, or shell; it only ever produces text describing what it wants to happen.
Result fed back
The tool's result is fed back into the conversation, and the LLM either calls another tool or produces a final answer.
Because the model decides which tool to call based on its own (sometimes wrong, sometimes manipulated) reasoning, the execution layer around it — not the model's judgment — is where safety has to live.
- Least-privilege access — grant only the specific tools a task needs, nothing broader
- A sandboxed workspace — a directory or container the agent cannot escape
- Read-only tools by default, with write/delete access opt-in
- An explicit allowlist of permitted tools rather than an open-ended shell
- Validation of tool arguments before execution
- Audit logging of every call
- Timeouts and network restrictions
An agent with unrestricted file or system access is not a more capable assistant — it is a standing risk.
- Agent
- An LLM combined with memory, tools, planning, and an execution layer that can carry out multi-step tasks with real side effects, rather than only producing a single text response.
- Tool Calling
- The mechanism by which an LLM emits a structured request to run a specific function with specific arguments, which the surrounding program executes and feeds the result of back into the conversation.
THE TOOL-CALLING LOOP
---------------------
THE TOOL-CALLING LOOP
------------------------
user request
|
v
LLM -----------------------------+
| decides a tool is needed |
v |
tool call (name + arguments) |
| |
v |
[ CONFIRMATION GATE ] <-- required for destructive tools
| (blocked until user confirms)
v
tool executes (outside the model)
|
v
result returned to LLM -------------+
|
v
LLM responds to user (or calls another tool)Connect it to a real scenario
The code example is a minimal version of a pattern every real agent framework implements in some form: a lookup table deciding whether a requested tool call is even allowed to run, checked before any execution happens, not after.
First, default-deny: a tool name that isn't in the allowlist at all is rejected outright, rather than the system trying to guess whether it's safe — `send_email` in the example fails simply because it was never granted, regardless of what arguments came with it.
Second, a separate destructive flag: tools that change or delete state (here, `delete_file` and `run_shell_command`) require an explicit `confirmed=True` on top of being allowlisted, modeling the idea that read access and write/delete access should never share the same bar.
A real implementation would check this before every single tool call an agent makes, log the decision either way, and treat `confirmed=True` as something only a human (or a policy the human explicitly set) can supply.
Never Let the Model Confirm Itself
`confirmed=True` should never be something the LLM can set for itself by including it in its own tool-call arguments — that would let a manipulated model confirm its own destructive actions.
Unrestricted Agent Access Is a Risk, Not a Feature
An agent that can read, write, and delete files or run arbitrary shell commands with no allowlist, no sandboxing, and no confirmation step is not a more powerful assistant — it is equivalent to giving an unattended, occasionally-wrong script full access to your system. Treat every destructive capability as opt-in and explicitly scoped, never as a default.
Try the working example
ALLOWED_TOOLS = {
"read_file": {"destructive": False},
"list_directory": {"destructive": False},
"web_search": {"destructive": False},
"delete_file": {"destructive": True},
"run_shell_command": {"destructive": True},
}
def check_permission(tool_name, allowed_tools, confirmed=False):
if tool_name not in allowed_tools:
return False, "not in allowlist"
if allowed_tools[tool_name]["destructive"] and not confirmed:
return False, "destructive tool requires confirmed=True"
return True, "permitted"
calls = [
("read_file", False),
("send_email", False), # not in allowlist at all
("delete_file", False), # destructive, no confirmation
("delete_file", True), # destructive, confirmed
]
for tool_name, confirmed in calls:
ok, reason = check_permission(tool_name, ALLOWED_TOOLS, confirmed=confirmed)
print(f"tool={tool_name!r} confirmed={confirmed} -> allowed={ok} ({reason})")Running the four example calls through check_permission produces:
tool='read_file' confirmed=False -> allowed=True (permitted)
tool='send_email' confirmed=False -> allowed=False (not in allowlist)
tool='delete_file' confirmed=False -> allowed=False (destructive tool requires confirmed=True)
tool='delete_file' confirmed=True -> allowed=True (permitted)
read_file is allowed immediately because it's both allowlisted and non-destructive. send_email is denied purely for not being in the allowlist at all — its destructive flag is never even checked. delete_file is denied without confirmation and allowed with it, showing the two-layer check (allowlist membership, then a separate confirmation requirement for destructive tools) working as designed.5-minute try-it
Extend check_permission so that non-destructive tools can also be restricted by an optional allowed_paths argument (for example, read_file should only succeed if the requested path is inside a specific sandbox directory). Write test calls showing a read_file request inside the sandbox succeeding and one outside the sandbox being denied, even though read_file itself is allowlisted and non-destructive.
One important caution
Letting the LLM's own output set a 'confirmed' or 'approved' flag on a destructive tool call — if the model can grant its own permission, the confirmation step is decorative, not a real gate.
Giving an agent a general-purpose shell tool 'to keep things flexible' instead of specific narrow tools — an open shell defeats least-privilege and an allowlist at the same time.
Anthropic — Tool Use Overview — Local AI / Local LLM