要看懂「每一步都被允許執行但串連後卻是惡意行為」的動作鏈,得先看懂 agent 執行任務時的內部運作。
今天就來看看一個真的會連網、會自己決定下一步動作的 agent 是如何運作的。(偏基礎導向)(使用Google Colab + Gemini API)
聊天機器人是文字進、文字出:你輸入內容,它會從自己的訓練記憶中找出合適的內容來回覆。
Agent 的動作比起普通聊天機器人還多了一個環節:它在回答你之前,會先去「想辦法執行被交付的任務」——有可能得呼叫一個工具、取得工具執行後回傳的結果、再根據回傳結果決定下一步該做甚麼。所以對於 Agent 而言,我們交付給他的任務不是「單次即可生成」的,而是多個任務執行並串聯起的迴圈:模型決定要呼叫哪個工具 → 你的程式去執行 → 把結果餵回模型 → 模型再決定 → …… → 模型覺得足夠完成你指定的標準了,才給你最終回答。
來吧,我們來簡單的交付任務給 Agent 吧!
這是我們給 Agent 的任務內容:
task = (
"第一步:fetch https://api.github.com/repos/python/cpython ,"
"從回傳的 JSON 裡告訴我它有幾顆星(stargazers_count)、主要語言(language)。"
"第二步:從同一份 JSON 裡找出 owner 的 API 網址(owner 物件裡的 url 欄位),"
"fetch 那個網址,告訴我這個 owner 有多少個公開 repo(public_repos)。"
"最後把星數、語言、公開 repo 數整理成一句話回覆我。"
)
接下來就是一些我們寫好的處理程序了。
比較重要的是:模型自己不會、也不能執行工具。它能做的只有去產出一個結構化的請求,一般而言內容大意是「請呼叫 fetch_url,參數 url 帶這個值」。真正去發出網路請求、去執行讀取檔案的動作的,是你自己寫的靜態程式碼。模型僅負責「決定」,你的程式負責「執行」,兩邊是分開的。
程式碼:
!pip install -q google-genai
from google import genai
from google.genai import types
from google.colab import userdata
client = genai.Client(api_key=userdata.get("GEMINI_API_KEY"))
import requests
def fetch_url(url: str) -> str:
r = requests.get(url, timeout=10)
return r.text[:8000]
TOOLS = {"fetch_url": fetch_url}
config = types.GenerateContentConfig(
tools=list(TOOLS.values()),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
config = types.GenerateContentConfig(
tools=list(TOOLS.values()),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
如上文,本次範例使用的工具只有一個 fetch_url,做的事很單純:拿一個網址、發 GET 請求、並把接收到的內容回傳。
程式碼:
task = (
"第一步:fetch https://api.github.com/repos/python/cpython ,"
"從回傳的 JSON 裡告訴我它有幾顆星(stargazers_count)、主要語言(language)。"
"第二步:從同一份 JSON 裡找出 owner 的 API 網址(owner 物件裡的 url 欄位),"
"fetch 那個網址,告訴我這個 owner 有多少個公開 repo(public_repos)。"
"最後把星數、語言、公開 repo 數整理成一句話回覆我。"
)
contents = [types.Content(role="user", parts=[types.Part.from_text(text=task)])]
trace = []
for step in range(8): # 設上限 8 次,避免無窮迴圈的浪費
resp = client.models.generate_content(model="gemini-3.6-flash", contents=contents, config=config)
calls = resp.function_calls
if not calls:
print("\n最終回覆:", resp.text)
break
contents.append(resp.candidates[0].content) # 把模型這輪的決定接回對話
for call in calls:
result = TOOLS[call.name](**dict(call.args)) # ← 真正執行工具的是這行
trace.append({"step": step, "action": call.name,
"args": dict(call.args),
"observation": str(result)[:200]})
print(f"[步驟 {step}] 呼叫 {call.name}({dict(call.args)}) → {str(result)[:120]} …")
contents.append(types.Content(role="user", parts=[types.Part.from_function_response(name=call.name,response={"result": result})]))
而我們寫的這段迴圈就是整個 agent 的核心 —— 它反覆執行「問模型要不要呼叫工具 → 有的話就執行、把結果接回對話 → 再問模型」,直到模型不再需要工具為止。
特別注意迴圈裡真正執行工具的是 TOOLS[call.name](**call.args) 這一行:模型只是回報它想呼叫的名字和參數,是這一行把它的需求轉變成一個真實發生的request。後面談到「權限」與「攔截」時會有所著墨討論。
我給它的任務是:先去 fetch cpython 這個 repo 的資料,回報它幾顆星、主要語言;再從那份資料裡找出 owner 的 API 網址,去 fetch 它,回報這個 owner 有幾個公開專案。
開始執行
輸出:
[步驟 0] 呼叫 fetch_url({'url': 'https://api.github.com/repos/python/cpython'}) → {"id":81598961,"node_id":"MDEwOlJlcG9zaXRvcnk4MTU5ODk2MQ==","name":"cpython","full_name":"python/cpython","private":fals …
[步驟 1] 呼叫 fetch_url({'url': 'https://api.github.com/users/python'}) → {"login":"python","id":1525981,"node_id":"MDEyOk9yZ2FuaXphdGlvbjE1MjU5ODE=","avatar_url":"https://avatars.githubusercont …
最終回覆: `python/cpython` 擁有 **75,017** 顆星(`stargazers_count`),主要語言為 **Python**,而其擁有者(owner: python)共有 **94** 個公開專案(`public_repos`)。
如上文,agent 走了兩步:
步驟 0 抓 api.github.com/repos/python/cpython
步驟 1 抓 api.github.com/users/python,最後回覆「75,017 顆星、主要語言 Python、owner 有 94 個公開專案」。
現在看步驟 1 的網址 api.github.com/users/python——它是哪來的?
答案是 從步驟 0 的/repos/python/cpython 中取回的 JSON 檔裡的 owner.url 欄位找到的。agent 把一段外部找來的內容,原封不動塞進了下一個動作的參數,中間沒有做任何檢查。
反過來想就知道為什麼危險:既然它 fetch 的目標是「內容裡的某個欄位值」,那只要有人能改到那個欄位,就等於能決定 agent 下一步去打哪裡。攻擊者不用直接連你的伺服器,他只要能污染 agent 會讀到的一個欄位就夠了。
我在迴圈裡順手把每一步記了下來,變成一份結構化的紀錄。
程式碼:
import json
print(json.dumps(trace, ensure_ascii=False, indent=2))
輸出:
{
"step": 0,
"action": "fetch_url",
"args": {
"url": "https://api.github.com/repos/python/cpython"
},
"observation": "{\"id\":81598961,\"node_id\":\"MDEwOlJlcG9zaXRvcnk4MTU5ODk2MQ==\",\"name\":\"cpython\",\"full_name\":\"python/cpython\",\"private\":false,\"owner\":{\"login\":\"python\",\"id\":1525981,\"node_id\":\"MDEyOk9yZ2FuaXphdGlvbjE1MjU5"
},
{
"step": 1,
"action": "fetch_url",
"args": {
"url": "https://api.github.com/users/python"
},
"observation": "{\"login\":\"python\",\"id\":1525981,\"node_id\":\"MDEyOk9yZ2FuaXphdGlvbjE1MjU5ODE=\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/1525981?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/pyth"
}
如上文,這份 軌跡(trace) 每一筆就四個欄位:
這就是這個系列需要擁有的基礎條件,我們要看懂 trace 的輸出 —— agent 做過的每一個動作、它的執行順序、每一步驟獲得甚麼資料並回傳了哪些內容,全都記錄在這裡。希望這次的範例,能讓大家都對Agent是怎麼執行任務的問題都有更清楚的認知。
今天這句話看似普通,是因為今天我讓它讀取到的資料,是在我的控制範圍內,且是已知來自 GitHub 的安全資料,所以它做了很正常的安全任務。但把如果把這句話倒過來唸的話就會變成:誰能控制 agent 讀到的內容,誰就能控制它的下一步動作。 決定權會從你手上,換到埋藏惡意內容給它的人的手上。
這是甚麼意思呢?請大家仔細想想看剛剛的範例。既然fetch_url 是接收到什麼網址就輸出什麼內容,而我們剛剛也看到了,那個網址可以是「從讀取到的內容裡挖出來的資料」。那這是不是就代表著,有人可以把步驟 0 讀取回來的內容裡,放置 http://169.254.169.254/latest/meta-data/(雲端主機拿身分憑證的 metadata 位址)或你內網某台服務的位址,agent 也會照抓不誤,然後把抓到的東西再往下傳,對吧?
其實這就是 SSRF,而且它不是攻擊者直接打進來的——是被 agent 讀取到的內容給觸發的。一個看起來人畜無害的小小工具,一旦遇到「動作由內容決定」這件事,就成了攻擊面。
今天把 agent 的運作拆成「決定 → 執行 → 看結果 → 再決定」的基礎迴圈;也學會簡易的捕捉 Agent 的動作軌跡。
明天我們會在同一套程式上,塞一封「有問題」的信進去,看著這條原本的安全軌跡,是因為甚麼原因與方式被突破的。
感謝大家今日份的閱讀。