Building a Read-Only Mode for Safe Exploration
A cli agent read only mode is a hard, code-enforced guarantee that an agent can look but not touch. Here's how to build it as a real boundary, default it safe, and cover shell and network side effects.
READ_ONLY_TOOLS = {"read_file", "list_dir", "grep", "git_log", "git_diff"}
WRITE_TOOLS = {"write_file", "run_shell", "delete_file", "git_commit"}
def dispatch(tool, args, read_only=True):
if read_only and tool not in READ_ONLY_TOOLS:
return f"BLOCKED: '{tool}' is not permitted in read-only mode."
return execute(tool, args)Picture this: you're a new engineer on a team, it's your second day, and you point your CLI agent at the production codebase to understand how billing works. You want it to read, explain, trace call paths — and under no circumstances to change anything. But your agent has a write_file tool and a shell tool, and the only thing standing between curiosity and catastrophe is hoping the model behaves. That gap is exactly what a cli agent read only mode closes: a hard, code-enforced guarantee that the agent can look but not touch. It's the feature that makes an agent safe to point at anything.
Read-only mode isn't a nice-to-have you bolt on later. Built in from the start, it's the foundation that lets you explore unfamiliar or sensitive systems without holding your breath.
What Is a cli agent read only mode?
A read-only mode is an enforced state in which the agent can use inspection tools — read files, list directories, run safe queries — but every tool that could modify state is blocked at the code level, regardless of what the model tries to do. The key phrase is at the code level. A read-only mode implemented only by asking the model nicely in the system prompt isn't a read-only mode; it's a suggestion the model can wander past.
The distinction matters because models are capable and occasionally wrong. A cli agent read only mode built as a real boundary means that even if the model becomes convinced it needs to write a file to accomplish the task, the write simply doesn't happen — the dispatch layer refuses it and hands back an error the model can reason about. The guarantee holds no matter how the conversation goes.
READ_ONLY_TOOLS = {,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],}
WRITE_TOOLS = {,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],}
,[object Object], ,[object Object],(,[object Object],):
,[object Object], read_only ,[object Object], tool ,[object Object], ,[object Object], READ_ONLY_TOOLS:
,[object Object], ,[object Object],
,[object Object], execute(tool, args)What this does: Enforces the boundary in the one place every tool call must pass through. In read-only mode, only explicitly-safe tools run; anything else returns a block message. The model can request a write, but the code is what decides, and the code says no.
Why It Matters
Because the alternative — trusting the model not to make changes — fails in exactly the situations where changes are most dangerous. Exploring production, auditing a security-sensitive repo, letting a new hire loose on unfamiliar code: these are precisely the moments you want a guarantee, not a hope. A read-only mode converts "I think it won't break anything" into "it cannot break anything."
Three people who need this guarantee for different reasons:
A security auditor reviewing a client's codebase must be able to promise, contractually, that the tooling made zero modifications. A code-enforced read-only mode is that promise made real — there is no write path to accidentally trigger.
A new team member learning a large system wants to ask the agent anything without fear that a misunderstood request rewrites a config file. Read-only mode lets them be fearlessly curious, which is how people actually learn a codebase fast.
A site reliability engineer investigating a live incident wants the agent to pull logs, inspect state, and correlate events across production systems without any risk that a diagnostic step mutates the very system they're debugging.
The common need is a boundary that holds under pressure, when the stakes are highest and the temptation to "just fix it" is strongest.
How Should Read-Only Mode Be the Default?
By making writes the exception you opt into, not the default you forget to turn off. The safest design starts every session read-only and requires an explicit, deliberate action — a flag, a command, a confirmation — to unlock write capabilities. Fail-safe, not fail-open.
[object Object], ,[object Object],:
,[object Object], ,[object Object],(,[object Object],):
,[object Object],.read_only = ,[object Object], allow_writes ,[object Object],
,[object Object], ,[object Object],(,[object Object],):
,[object Object],
,[object Object],.read_only = ,[object Object],
log(,[object Object],)What this does: Defaults the agent to read-only and treats enabling writes as an explicit, logged event. Someone has to consciously choose to allow modifications, which means the dangerous state is never the one you land in by accident.
This default-safe posture is what separates a tool you can hand to anyone from one that needs a warning label. When read-only is the resting state, the worst a confused user or a confused model can do is read something they didn't need to — never write something they shouldn't. Flipping the default so writes require intent removes an entire category of accidents.
⚡ Pro tip: Show the current mode in the prompt itself — agent (read-only)> versus agent (writes enabled)>. Making the mode visible on every line means nobody ever forgets which state they're in, and the shift to a writes-enabled prompt is a small, constant reminder that the guardrails are down.
⚡ Pro tip: Add a dry-run that shows what writes would happen without doing them. In read-only mode, instead of blocking a write_file outright, you can render the diff it would produce and label it "(dry run — not applied)". The user sees exactly what the agent wanted to change, gaining the insight of a write with none of the risk.
How Do You Verify Read-Only Mode Actually Holds?
A boundary you haven't tested is a boundary you're hoping about. Because read-only enforcement lives in code, you can test it deterministically — no model required. Write tests that attempt every write tool in read-only mode and assert each one is blocked, so a future refactor can't silently open a hole.
[object Object], ,[object Object],():
,[object Object], tool ,[object Object], WRITE_TOOLS:
result = dispatch(tool, {,[object Object],: ,[object Object],}, read_only=,[object Object],)
,[object Object], result.startswith(,[object Object],), ,[object Object],
,[object Object], ,[object Object],():
,[object Object], tool ,[object Object], READ_ONLY_TOOLS:
,[object Object], ,[object Object], dispatch(tool, {,[object Object],: ,[object Object],}, read_only=,[object Object],).startswith(,[object Object],)What this does: Asserts that every write tool is refused and every read tool is permitted while in read-only mode, catching a leak the instant someone adds a tool without classifying it. This is exactly the kind of deterministic harness test that pays for itself the first time it fails.
The most valuable version of this test iterates your entire tool registry, not a hand-listed subset, so a newly added tool that nobody classified fails the test by default. That failure is the point — it forces a deliberate decision about whether the new tool is safe in read-only mode, rather than letting it default into the allowed set unnoticed.
⚡ Pro tip: Make the test fail closed for unclassified tools. If a tool appears in neither the read nor write set, treat that as a test failure, not a pass. An unclassified tool is an unmade safety decision, and the test suite is the right place to catch it before it reaches a user.
⚡ Pro tip: Run these boundary tests in CI on every commit. Read-only enforcement is a security property, and security properties deserve a guard that fires automatically — not a manual check someone remembers to run. A green build should mean, among other things, that the read-only boundary still holds.
How Do You Enforce Read-Only Beyond File Writes?
By remembering that "read-only" means no side effects anywhere, not just no file writes — and shell commands are where this gets subtle. A read-only mode that blocks write_file but allows arbitrary run_shell has a hole you could drive a truck through, because a shell command can delete, deploy, or POST to an API just as easily as it can list files.
SAFE_SHELL = {,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],}
,[object Object], ,[object Object],(,[object Object],):
first = cmd.strip().split()[,[object Object],] ,[object Object], cmd.strip() ,[object Object], ,[object Object],
,[object Object],
,[object Object], first == ,[object Object],:
sub = cmd.split()[,[object Object],] ,[object Object], ,[object Object],(cmd.split()) > ,[object Object], ,[object Object], ,[object Object],
,[object Object], sub ,[object Object], {,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],}
,[object Object], first ,[object Object], SAFE_SHELLWhat this does: Applies read-only enforcement to shell commands, allowlisting inspection commands and even distinguishing safe git subcommands from mutating ones. A git log passes; a git push doesn't. The boundary follows the effect, not just the tool name.
The network is the other overlooked side effect. A tool that makes HTTP requests can have real consequences — triggering a deploy webhook, sending an email, charging a card — even though nothing on the local disk changed. True read-only means restricting outbound calls to safe methods too, or blocking them entirely unless you know they're side-effect-free.
Common Mistakes
⚠️ Common mistake: Implementing read-only mode purely in the system prompt. Telling the model "you are in read-only mode, do not modify anything" is useful as a hint, but it is not a boundary — a capable model under task pressure can talk itself into a write, and a prompt injection can instruct it to. The enforcement must live in code that the model cannot argue with. The prompt sets expectations; the dispatch layer sets the law.
Beyond that, a few errors recur. Allowlisting run_shell without inspecting the command reopens every hole you closed. Forgetting network side effects means "read-only" leaks writes over HTTP. And making write mode sticky across sessions — enabling it once and never resetting — quietly erodes the default-safe guarantee until every session runs unguarded. Reset to read-only at the start of each session, always.
Conclusion
A cli agent read only mode is the boundary that makes an agent safe to point at anything — production, a stranger's repo, a system you don't yet understand. Build it as code-level enforcement, not a prompt request; make it the default that writes must explicitly override; and extend it past file writes to shell commands and network calls, because read-only means no side effects anywhere. Get that right and exploration stops being a gamble.
The tool classifications — which tools read, which write, which shell subcommands are safe — plus the system-prompt language that explains the mode to the model are reusable across every agent you build. Keeping them in a library like PromptABCD means your next agent starts read-only and safe by default, with the same carefully-drawn boundary instead of one you redraw, and inevitably get slightly wrong, from scratch.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
