跳到主要內容
maxstudioNews
AI 自動化DEV Community (Claude Code)約 16 分鐘

Claude Code 提示詞提交掛鉤行為分析:被阻擋的提示詞仍會寫入對話紀錄

本文深入探討 Claude Code 的 `UserPromptSubmit` 掛鉤行為。研究發現,即使掛鉤成功阻擋了提示詞,使其未送達模型,這些提示詞卻仍會被寫入對話紀錄,且程式仍顯示成功執行。此外,文章也詳細說明了掛鉤輸出(如純文字輸出、額外上下文)如何影響模型可見內容、使用者介面顯示及檔案儲存,並探討了字元限制的處理方式。

Claude Code 提示詞提交掛鉤行為分析:被阻擋的提示詞仍會寫入對話紀錄
圖片來源:DEV Community (Claude Code)

在 UserPromptSubmit 掛鉤阻擋的六個提示詞中,全部都未送達模型,但這六個提示詞卻仍被寫入會話對話紀錄(其中四個甚至被寫入兩次),且 claude -p 每次都以 "subtype": "success" 和結束代碼0退出。

上下文注入的行為與掛鉤頁面所述一致:純標準輸出(stdout)和 additionalContext 在兩次執行中都成功傳遞,一萬字元的內容完整送達,而五萬字元的內容則變成一個1,949字元的預覽。此外,退出代碼為1或僅印出裸JSON物件的掛鉤,在四次執行中都未傳遞任何內容。

UserPromptSubmit 掛鉤會在您按下 Enter 鍵後、Claude 看到您輸入內容前執行。這個位置使其成為兩種常見情境的首選掛鉤:一是為每個提示詞添加上下文(例如當前分支、日期、重複規則),二是直接拒絕提示詞(例如貼上的密鑰、不應發出的請求)。

這兩種任務都歸結為一個問題:掛鉤輸出的所有內容中,哪些會傳給模型,哪些會傳給執行 Claude Code 的人或腳本,以及哪些會寫入磁碟?掛鉤參考文件以散文形式回答了大部分問題,但我希望從會話檔案中獲得答案。

因此,我建立了一個只有一個掛鉤的實驗環境,在 claude -p 下針對十種輸出形式各執行兩次,總共執行了二十次,並檢查每次執行留下的內容。以下所有實驗均於2026年9月30日,使用 Claude Code 2.1.285(透過 claude --version 確認)和 claude-opus-5-5 模型(透過 --model opus 選擇)執行。

文件引用來自同日透過 trafilatura 抓取的 https://code.claude.com/docs/en/hooks.md。

掛鉤頁面關於輸出行為的說明,以下是我測試的句子,皆為原文引用:對於結束代碼0的情況:「對於大多數事件,Claude Code 會將標準輸出寫入除錯日誌,但不會顯示在對話紀錄中。例外情況是 UserPromptSubmit、UserPromptExpansion、SessionStart 和 PostModelSwitch,在這些事件中,Claude Code 會將純文字標準輸出作為上下文添加,供 Claude 讀取和處理。」

在 UserPromptSubmit 部分:「純標準輸出和 additionalContext 值都會作為系統提醒注入,以掛鉤名稱開頭;Claude 會讀取兩者。」

關於阻擋,在決策表中:「block 會阻止提示詞被處理並從上下文中清除」,而 reason 則「在決策為 block 時顯示給使用者。不會添加到上下文。」在結束代碼表中,此事件的結束代碼2「阻擋提示詞處理並清除提示詞」。特別是關於結束代碼2:「透過結束代碼2阻擋的掛鉤,其處理方式與 reason 相同:阻擋訊息會向使用者顯示標準錯誤(stderr)文字,且不會添加到上下文。」

suppressOriginalPrompt:「如果決策為 block 時此值為 true,則會從顯示給使用者的阻擋訊息中省略原始提示詞文字。」對於其他結束代碼與純文字標準輸出:「對於大多數掛鉤事件,這是一個非阻擋錯誤:動作會繼續執行。」

關於何謂 JSON:「以 { 開頭並以 } 結尾:Claude Code 會將其解析為 JSON。」關於大小限制:純標準輸出和 additionalContext「上限為10,000個字元」,超過限制時「Claude Code 會將輸出儲存到會話目錄中的檔案,並以檔案路徑和最多前2,000個字元的預覽取代。」

此外:「Claude Code 不會要求 Claude 讀取該檔案,因此任何 Claude 必須始終看到的內容都應保持在限制內。」

實驗環境是一個全新的目錄:沒有 Git 儲存庫,沒有 CLAUDE.md 檔案,只有一個設定檔和一個腳本。.claude/settings.json 註冊了一個單一的命令掛鉤:

{ "hooks" : { "UserPromptSubmit" : [ { "hooks" : [ { "type" : "command" , "command" : "python3 <lab>/.claude/hooks/ups-probe.py" } ] } ] } }

(在實際檔案中,解釋器和腳本都是絕對路徑;本文中 <lab> 代表實驗目錄。)

該腳本從 mode.txt 讀取模式,印出一種輸出形式,並將一行內容附加到自己的日誌中,以便我確認每次執行都只運行一次。確實如此:日誌有20行。以下是移除日誌記錄後的腳本內容:

import json , os , sys

LAB = os . path.dirname ( os . path.dirname ( os . path.dirname ( os . path.abspath ( __file__ ))))

sys . stdin . read ()

mode = open ( os . path.join ( LAB , " mode.txt " )). read (). strip ()

out , err , code = "" , "" , 0

def long_text ( n ):

`line = " filler line: the quick brown fox jumps over the lazy dog, again and again.

"`

chars = list (( line * ( n // len ( line ) + 2 ))[: n ])

`marks = [( 0 , " UPS-LONG-AT0000

" ), ( 1900 , "

UPS-LONG-AT1900

" ), ( 2100 , "

UPS-LONG-AT2100

" ), ( n // 2 , "

UPS-LONG-AT%05d

" % ( n // 2 )), ( n - 13 , "

UPS-LONG-END " )]`

for at , text in marks : chars [ at : at + len ( text )] = list ( text )

return "" . join ( chars )

if mode == " plain " :

`out = " Lab note from the prompt hook: the marker for this prompt is UPS-STDOUT-PLAIN-7K2.

"`

elif mode == " addctx " :

out = json . dumps ({ " hookSpecificOutput " : { " hookEventName " : " UserPromptSubmit " , " additionalContext " : " Lab note from the prompt hook: the marker for this prompt is UPS-ADDCTX-JSON-4Q9. " }})

elif mode in ( " block " , " suppress " ):

specific = { " hookEventName " : " UserPromptSubmit " , " additionalContext " : " Lab note from the prompt hook: UPS-BLOCK-ADDCTX-6T8. " }

reason = " Lab block reason: UPS-BLOCK-REASON-2M5. "

if mode == " suppress " :

specific = { " hookEventName " : " UserPromptSubmit " , " additionalContext " : " Lab note from the prompt hook: UPS-SUPPRESS-ADDCTX-7V3. " , " suppressOriginalPrompt " : True }

reason = " Lab block reason: UPS-SUPPRESS-REASON-5N1. "

out = json . dumps ({ " decision " : " block " , " reason " : reason , " hookSpecificOutput " : specific })

elif mode == " exit2 " :

`out = " Lab note from the prompt hook: UPS-EXIT2-STDOUT-1R6.

"`

`err = " UPS-EXIT2-STDERR-L1-8P3 first line of stderr

UPS-EXIT2-STDERR-L2-5W1 second line of stderr

"`

code = 2

elif mode == " exit1 " :

`out = " Lab note from the prompt hook: UPS-EXIT1-STDOUT-6D4.

"`

`err = " UPS-EXIT1-STDERR-L1-3H7 first line of stderr

UPS-EXIT1-STDERR-L2-9B2 second line of stderr

"`

code = 1

elif mode . startswith ( " long " ):

out = long_text ( int ( mode [ 4 :]))

elif mode == " rawjson " :

out = json . dumps ({ " branch " : " main " , " open_issues " : 3 , " lab_marker " : " UPS-RAWJSON-DATA-3C1 " })

sys . stdout . write ( out ); sys . stderr . write ( err ); sys . exit ( code )

控制模式不印出任何內容並以結束代碼0退出。每個標記都以 UPS- 開頭,提示詞要求模型列出它能看到的所有標記,僅此而已:「請勿使用任何工具。查看本次對話中所有上下文,包括此訊息附帶的任何文字。列出所有以四個字元 UPS- 開頭並後接字母或數字的標記,依出現順序以單一空格分隔。如果沒有,請回覆 NONE。僅回覆該單行。」

每次執行都是一次調用:claude -p " $PROMPT " --setting-sources project,local --tools "" --strict-mcp-config --max-turns 1 --model opus --output-format stream-json --verbose --include-hook-events --session-id " $SID " --debug-file "runs/ $NAME /debug.txt" < /dev/null。

--setting-sources project,local 排除了我的使用者設定,因此我自己的掛鉤都不會觸發;串流顯示每次執行都只有一個 hook_started 事件,就是這個掛鉤。--tools "" 移除了所有內建工具,因此模型只能報告其上下文中已有的內容,而不能例如打開掛鉤輸出指向的檔案。

--strict-mcp-config 在沒有設定檔的情況下表示沒有 MCP 伺服器。--include-hook-events 將 hook_started、hook_progress 和 hook_response 事件添加到串流中,而 --session-id 使得在 ~/.claude/projects/<project>/<session-id>.jsonl 中很容易找到對話紀錄。

我還取消設定了我的 Claude Code 會話導出的環境變數(CLAUDECODE、CLAUDE_CODE_SESSION_ID、CLAUDE_EFFORT 以及十多個其他變數),因此子執行不會繼承它們。

以下所有主張都基於每次執行的三個來源。第一個是模型的回答。第二個是對話紀錄:Claude Code 保留的每個掛鉤輸出都會成為一個附件記錄,這些記錄帶有一個 rendered 欄位,其中包含 Claude Code 為模型渲染的文字,如果沒有渲染任何內容則為 null。

第三個是第一次請求的大小,即助理訊息使用量中的 input_tokens + cache_creation_input_tokens + cache_read_input_tokens,與控制掛鉤(在兩次控制執行中均為2,756個 token)進行比較。

回答中的標記表示它在上下文中,rendered 欄位說明它是如何到達的,而 token 計數則說明其成本。

二十次執行結果彙總表:

| 掛鉤輸出(各執行2次) | 結束代碼 | 模型請求 | 首次請求 Token 數(與控制組比較) | 模型列出的標記 | 對話紀錄保留的內容 |

|---|---|---|---|---|---|

| 無(控制組) | 0 | 1 | 2,756,| 無 | 無掛鉤記錄 |

| 83字元純標準輸出 | 0 | 1 | 2,811 +55 | 標準輸出標記,2次皆有 | hook_success,發送 JSON |

| additionalContext | 0 | 1 | 2,813 +57 | 上下文標記,2次皆有 | hook_additional_context,發送 |

| 10,000字元標準輸出 | 0 | 1 | 6,407 +3,651 | 5個標記皆有,2次皆有 | hook_success,發送10,068字元 |

| 50,000字元標準輸出 | 0 | 1 | 3,708 / 3,711 +952 / +955 | 5個標記中的2個,2次皆有 | hook_success,路徑加1,949字元預覽 |

| 標準輸出加兩行標準錯誤 | 1 | 1 | 2,756 0 | 無 | hook_non_blocking_error,渲染為 null |

| 裸 JSON 資料物件 | 0 | 1 | 2,756 0 | 無 | 完全沒有 |

| 決策:"block" 和 reason | 0 | 0 |,| (無模型呼叫) | 提示詞,兩次 |

| 兩行標準錯誤(和一行標準輸出) | 2 | 0 |,| (無模型呼叫) | 提示詞,兩次 |

| 決策:"block" 和 suppressOriginalPrompt | 0 | 0 |,| (無模型呼叫) | 提示詞,一次 |

所有20個 claude -p 程序都以結束代碼0退出,包括那六個提示詞從未到達模型的執行。

兩種文件記載的通道及其成本:結束代碼0時的純標準輸出,其行為與頁面所述完全一致。對話紀錄中記錄了一個 hook_success 附件,其渲染文字為:

<system-reminder> UserPromptSubmit hook success: Lab note from the prompt hook: the marker for this prompt is UPS-STDOUT-PLAIN-7K2. </system-reminder>

JSON 形式則產生了一個 hook_additional_context 附件,以相同方式渲染,只是將 UserPromptSubmit hook success: 替換為 UserPromptSubmit hook additional context:。

兩者都以系統角色發送,且在每種形式的兩次執行中,模型都列出了該標記,沒有其他內容。提醒開頭的「掛鉤名稱」就是事件名稱。純標準輸出記錄也儲存了掛鉤的完整命令列、原始標準輸出、結束代碼和持續時間,但這些都沒有進入渲染文字。

成本方面,純文字形式為55個 token,JSON 形式為57個 token,對應83個字元的標準輸出(82個字元加上 Claude Code 修剪掉的換行符)和81個字元的 additionalContext,因此無論哪種方式,包裝器都只佔用少量 token。根據這些證據,單純為了上下文而偏好 JSON 沒有理由;差異僅在於您還希望在同一輸出中包含其他欄位時才重要。

一萬字元限制有效,且讓較大的輸出更便宜:一萬字元的標準輸出完整通過。其渲染的附件為10,068個字元,即輸出內容加上68個字元的包裝器,模型在兩次執行中都列出了所有五個標記(位於偏移量0、1,901、2,101、5,001和9,988)。這為第一個請求增加了3,651個 token。

五萬字元的標準輸出則未能完整通過。其附件被渲染為一個指標和預覽:

UserPromptSubmit hook success: <persisted-output> Output too large (48.8KB). Full output saved to: ~/.claude/projects/<project>/<session-id>/tool-results/hook-<id>-stdout.txt Preview (first 2KB): UPS-LONG-AT0000 quick brown fox jumps over the lazy dog, again and again. [25 filler lines omitted here] UPS-LONG-AT1900 r the lazy dog, again and again. ... </persisted-output>

除了我自行添加的括號內文字、被遮蔽的路徑以及周圍的 <system-reminder> 標籤外,這就是渲染的文字;結束標籤前的 ... 是 Claude Code 的。預覽包含了前1,949個字元,在第2,000個字元前的最後一個換行符處截斷,因此位於偏移量1,901的標記成功進入。

Share

覺得值得一看?分享給朋友。

以上為 AI 自動翻譯導讀。原文版權歸 DEV Community (Claude Code) 所有, 建議透過「閱讀原文」前往原始網站,以取得最完整資訊並支持原作者。

Newsletter

不用每天追,AI 重點每週寄給你

43 個來源、每 2 小時更新,太多看不完。電子報每週挑最值得看的幾則,附上 Max 的看法。

每週一封。Email 只用來寄電子報,每封信都能一鍵退訂。