Use when bounding an LLM agent that already runs — scoping its task domain, gating tools to least privilege, defending against prompt injection in untrusted web/email/RAG text, requiring human approval on irreversible actions, capping runtime and cost, or triaging what it already did. NOT building the loop, tools, or RAG (that is `building-agents`).
npx skills add https://github.com/ericrisco/rsc-harness --skill agent-safety
You are the security review for an agent's agency, not for its code. The loop works,
tools are wired, memory persists — your job is to make that autonomy *bounded*. If you
want to review ordinary endpoints, auth, or secrets handling, that is
../secure-coding/SKILL.md — this skill is the *Agentic* Top 10, the risks that exist only
because a model has tools and autonomy. If the loop or tools do not exist yet, that is
../building-agents/SKILL.md. You arrive *after* both.
references/threat-model.md carries the OWASP Agentic Top 10 2026 risks mapped to the
controls below, the pre-ship guardrail checklist, and the incident-triage flow for "the
agent did X" — open it when you are reviewing before ship or reconstructing an incident.
Agent security splits into four layers — Model · Harness · Tools · Environment. The
model provider owns only the Model layer (alignment, refusals). Everything else is yours:
the Harness (loop, memory, context assembly), the Tools (what the agent can *do*), and the
Environment (creds, network, blast radius). Do not outsource a layer you own to "the model
is aligned."
Three excesses cause almost every agentic incident. Cut all three:
The operating principle is least agency: autonomy is earned per task, not defaulted.
undeclared scope is an infinite scope; "you are a refund assistant; you do not touch
payroll" is a constraint a reviewer can check.
task justification. Why: an opt-out tool list grows; an opt-in list stays minimal.
trusted instructions. Everything the agent reads at runtime = data, never instructions.
Why: this single boundary is what stops indirect injection (LLM01).
Give every tool a profile: read / write / exec / send, the exact resources it may
touch, and an allowlist (never a wildcard). Block destructive flags and secret paths
at the tool boundary, not in the prompt — the prompt is advisory, the boundary is enforced.
# Bad: one wildcard tool = unbounded blast radius, runs anything the loop emits
def run_shell(cmd: str) -> str:
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout
# Good: narrow tool, allowlisted root, denied patterns, no shell
ALLOWED_ROOT = pathlib.Path("/srv/agent/workspace").resolve()
DENY = ("*.key", "*.pem", "*secret*", "*.env", "id_rsa*")
def read_file(path: str) -> str:
p = (ALLOWED_ROOT / path).resolve()
if not p.is_relative_to(ALLOWED_ROOT): # no traversal out of scope
raise PermissionError("path outside workspace")
if any(p.match(g) for g in DENY): # never read secrets
raise PermissionError("denied pattern")
return p.read_text()
should be valid only for the specific tool and the duration of one task. Why: a hijacked
loop cannot reuse a session-wide token it never held.
steps only need to read, so most steps should be unable to mutate anything.
Treat all external data as untrusted: user messages, retrieved documents, API
responses, emails, web pages, other agents' output. Sanitize and delimit before it enters
context, and never let external text reach a privileged tool unmediated.
| Source | Trust level | Required mediation before it can act |
| ------------------------------ | ----------- | ------------------------------------------- |
| System / developer prompt | Trusted | none (this is the only instruction channel) |
| End-user chat message | Untrusted | delimit; treat as data, not commands |
| Retrieved RAG / KB document | Untrusted | delimit; strip instruction-like spans |
| Fetched web page / API JSON | Untrusted | parse to schema; no raw text → tool args |
| Inbound email / ticket body | Untrusted | delimit; HITL on any action it requests |
| Another agent's message | Untrusted | same as external user input |
# Bad: retrieved chunk flows straight into a privileged action
chunk = retriever.search(q)[0].text # attacker-controlled doc
agent.call_tool("send_email", to=extract_to(chunk), body=chunk)
# Good: external content is quarantined data; the action is schema-validated + gated
chunk = retriever.search(q)[0].text
ctx = f"<retrieved untrusted>\n{chunk}\n</retrieved untrusted>" # delimited, labeled
proposal = agent.draft("send_email", context=ctx) # model proposes
args = SendEmail.model_validate(proposal.args) # schema or reject
if args.to_domain not in ALLOWED_DOMAINS: # exfil guard
raise PermissionError("recipient outside allowlist")
require_human_approval("send_email", args) # irreversible → HITL
schema rejects the surprise field, encoded payload, or off-allowlist recipient injection
produces.
outside the allowlist. Why: data theft is the common payload of a successful injection.
Do not approve every action — reported ~93% of permission prompts get approved without
being read, so blanket prompting trains a rubber stamp. Gate by risk class, keyed on
reversibility × blast radius. Bind each approval to the exact parameters with a
short-lived token so the approved action cannot be swapped after the click.
| Action type (examples) | Reversible? | Blast radius | Control |
| ----------------------------------------------- | ----------- | ------------ | ------------------ |
| Read file, search KB, fetch page | n/a | none | auto |
| Write to scratch workspace, internal draft | yes | local | log-only |
| Mutate prod DB, deploy, change config | hard | system | approve (HITL) |
| Send email/payment to external party, post live | no | external | approve (HITL) |
| Delete backups, rotate prod creds, mass-delete | no | catastrophic | block (or step-up auth) |
ambient session. Why: a hijacked session should not also hold the keys to the worst action.
across sessions (OWASP Agentic T1) — unlike session-scoped injection, a poisoned memory
re-attacks every future run until purged.
reads.** Why: shared memory is a cross-tenant injection channel.
Why: stale instructions and leaked secrets both age into liabilities.
A looping or hijacked agent must hit a wall on its own. Set hard caps, fail closed:
Log every tool call (arguments redacted) and alert on repeated approval-bypass attempts.
| Anti-pattern | Why it bites | Do instead |
| ---------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
| Approve every action | ~93% rubber-stamped; the real risky one slips through | Gate by risk class; HITL only on irreversible/external |
| One broad session token shared by all tools | Hijacked loop reuses it everywhere | Task-scoped, short-lived per-tool tokens |
| Trust RAG / fetched / email content | Indirect injection (LLM01) becomes direct tool execution | Delimit as untrusted data; mediate before any tool |
| Wildcard run_shell(cmd) tool | Unbounded blast radius | Narrow tools, allowlisted resources, denied patterns |
| Raw external text piped into tool args | Attacker controls the action's parameters | Schema-validate args; allowlist recipients/domains |
| Scope/limits stated only in the prompt | Prompt is advisory; the model can be talked out of it | Enforce at the tool/harness boundary |
| No loop / cost / rate cap | A hijacked or looping agent runs until it runs out of money | Hard fail-closed kill-switches |
| Redact PII only in the UI | The secret was already written to memory/logs | Redact before persistence, at the source |
Take ericrisco/agent-safety from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.