<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Hooks on Tarragon</title><link>https://tarrragon.github.io/blog/tags/hooks/</link><description>Recent content in Hooks on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 08 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/hooks/index.xml" rel="self" type="application/rss+xml"/><item><title>在 container 裡跑 Claude Code：安裝、認證與 hooks 通知</title><link>https://tarrragon.github.io/blog/linux/tools/remote/claude-code-container-and-hooks/</link><pubDate>Wed, 08 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/remote/claude-code-container-and-hooks/</guid><description>&lt;p>把 Claude Code 裝進 container 當遠端 agent 工作機時，真正要解的兩件事是：認證怎麼活過 container 重建、以及任務結束怎麼主動通知。這篇聚焦 Claude Code 本身在這個情境下的安裝、認證模型與 hooks 配置——&lt;a href="../agent-workstation-vm-handson/">遠端 agent 工作機實作記錄&lt;/a>（連線、session、隔離三層怎麼疊起來）有完整的端到端脈絡，這裡把其中 Claude Code 相關的機制單獨講清楚，重點是它的認證模型——env-var token 注入、與登入態解耦。&lt;/p>
&lt;h2 id="安裝一個-npm-全域套件">安裝：一個 npm 全域套件&lt;/h2>
&lt;p>Claude Code 是 npm 套件，需要 Node runtime。在 container 裡最省事的是用官方 node base image、直接全域安裝：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="s"> node:22-bookworm-slim&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">RUN&lt;/span> npm install -g @anthropic-ai/claude-code&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 node base 而非在別的 base 上自己裝 Node，少一層版本漂移的風險。base image 的 tag 要釘住（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/image-tag-pinning/" data-link-title="Image Tag Pinning" data-link-desc="本機跟線上跑同一份 code 卻行為不一致、或 image 隔幾週重 build 就變樣時回來讀 — 為什麼 tag 要釘到 OS 世代">Image Tag Pinning&lt;/a>），讓 image 可重現。&lt;/p>
&lt;h2 id="認證setup-token-是-env-var-注入模型">認證：setup-token 是 env-var 注入模型&lt;/h2>
&lt;p>對無人值守的容器化 agent，&lt;code>claude setup-token&lt;/code> 給的認證形態是「長效 token 的環境變數注入」、而不是「一次登入、狀態存在本地之後都在」。&lt;/p>
&lt;p>&lt;code>setup-token&lt;/code> 走一次互動登入（需要真 TTY、&lt;code>docker run -it&lt;/code>），完成後印出一個 &lt;code>sk-ant-oat01-&lt;/code> 開頭的長效 token（宣告有效約一年）。關鍵是：它&lt;strong>不會&lt;/strong>把這顆 token 寫進 &lt;code>~/.claude&lt;/code>、只把它印出來、明示你設成環境變數 &lt;code>CLAUDE_CODE_OAUTH_TOKEN&lt;/code>。所以持久化的責任在你——把 token 存成 host 側的機密、在 &lt;code>docker run&lt;/code> 時注入：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 存成 host 的 gitignored 機密（不進 image 也不進 git）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">printf&lt;/span> &lt;span class="s1">&amp;#39;CLAUDE_CODE_OAUTH_TOKEN=%s\n&amp;#39;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$TOKEN&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &amp;gt; ~/.env &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> chmod &lt;span class="m">600&lt;/span> ~/.env
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 每次 run 注入&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">docker run --rm --env-file ~/.env &amp;lt;image&amp;gt; claude -p &lt;span class="s2">&amp;#34;任務&amp;#34;&lt;/span> --dangerously-skip-permissions&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個「認證走環境變數注入的機密、不烤進 image 也不進 repo」正是 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/runtime-secret-injection/" data-link-title="Runtime Secret Injection（機密注入）" data-link-desc="要決定 token / 金鑰放哪、能不能烤進 image 或提交進（即使私有的）repo 時回來讀">機密 runtime 注入&lt;/a> 的實例。好處是認證跟 image、container 生命週期完全解耦：rebuild image 幾次都不影響認證、換憑證只改注入的檔。&lt;/p>
&lt;h2 id="認證綁-token-注入不綁-session">認證綁 token 注入、不綁 session&lt;/h2>
&lt;p>env-var 模型的核心是：&lt;strong>能不能認證，取決於這次 run 有沒有注入 token、跟 session 或登入態無關&lt;/strong>。這帶來兩個直接後果：&lt;/p>
&lt;ul>
&lt;li>直接打 &lt;code>claude&lt;/code>（沒注入 token）即使在一個還活著的多工器（tmux / zellij）session 裡，也會要求重新認證——因為它沒拿到憑證。&lt;/li>
&lt;li>在一個 &lt;code>--rm&lt;/code> 的臨時 container 裡走一次互動登入，憑證寫進容器的 &lt;code>~/.claude&lt;/code>、容器一結束就蒸發（除非登入時掛了 volume 讓它落在持久儲存）。等於把憑證寫進一個即將被回收的容器，它跟著容器一起消失、留不到下一次。&lt;/li>
&lt;/ul>
&lt;p>可靠的做法是不依賴任何登入態、每次用同一條 &lt;code>docker run --env-file&lt;/code> 指令把 token 注入。要驗證認證確實純綁 token：不掛任何 volume（排除一切存檔登入）、只注入 token 即認證成功；不注入則回 &lt;code>Not logged in&lt;/code>——這證明認證來源純粹是注入的 token。&lt;/p>
&lt;h2 id="github-認證正交的第二顆機密同一個注入模式">GitHub 認證：正交的第二顆機密、同一個注入模式&lt;/h2>
&lt;p>agent 要 clone / push 私有 repo，需要的是一顆 GitHub token，跟前面認證 Claude Code 本身的 &lt;code>CLAUDE_CODE_OAUTH_TOKEN&lt;/code> 是兩件事：前者讓 git 對 GitHub 證明身分（能不能 clone / push 私有 repo），後者讓 agent 對 Anthropic 取得運作授權（能不能運作）。這兩顆機密職責正交、彼此無關，但持久化與注入走同一套機制——gitignored 檔案存機密、&lt;code>docker run&lt;/code> 時用環境變數注入、不烤進 image 也不進 repo，正是 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/runtime-secret-injection/" data-link-title="Runtime Secret Injection（機密注入）" data-link-desc="要決定 token / 金鑰放哪、能不能烤進 image 或提交進（即使私有的）repo 時回來讀">機密 runtime 注入&lt;/a> 的另一個實例。&lt;/p></description><content:encoded><![CDATA[<p>把 Claude Code 裝進 container 當遠端 agent 工作機時，真正要解的兩件事是：認證怎麼活過 container 重建、以及任務結束怎麼主動通知。這篇聚焦 Claude Code 本身在這個情境下的安裝、認證模型與 hooks 配置——<a href="../agent-workstation-vm-handson/">遠端 agent 工作機實作記錄</a>（連線、session、隔離三層怎麼疊起來）有完整的端到端脈絡，這裡把其中 Claude Code 相關的機制單獨講清楚，重點是它的認證模型——env-var token 注入、與登入態解耦。</p>
<h2 id="安裝一個-npm-全域套件">安裝：一個 npm 全域套件</h2>
<p>Claude Code 是 npm 套件，需要 Node runtime。在 container 裡最省事的是用官方 node base image、直接全域安裝：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="s"> node:22-bookworm-slim</span><span class="err">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"></span><span class="k">RUN</span> npm install -g @anthropic-ai/claude-code</span></span></code></pre></div><p>用 node base 而非在別的 base 上自己裝 Node，少一層版本漂移的風險。base image 的 tag 要釘住（見 <a href="/blog/linux/dotfile/knowledge-cards/image-tag-pinning/" data-link-title="Image Tag Pinning" data-link-desc="本機跟線上跑同一份 code 卻行為不一致、或 image 隔幾週重 build 就變樣時回來讀 — 為什麼 tag 要釘到 OS 世代">Image Tag Pinning</a>），讓 image 可重現。</p>
<h2 id="認證setup-token-是-env-var-注入模型">認證：setup-token 是 env-var 注入模型</h2>
<p>對無人值守的容器化 agent，<code>claude setup-token</code> 給的認證形態是「長效 token 的環境變數注入」、而不是「一次登入、狀態存在本地之後都在」。</p>
<p><code>setup-token</code> 走一次互動登入（需要真 TTY、<code>docker run -it</code>），完成後印出一個 <code>sk-ant-oat01-</code> 開頭的長效 token（宣告有效約一年）。關鍵是：它<strong>不會</strong>把這顆 token 寫進 <code>~/.claude</code>、只把它印出來、明示你設成環境變數 <code>CLAUDE_CODE_OAUTH_TOKEN</code>。所以持久化的責任在你——把 token 存成 host 側的機密、在 <code>docker run</code> 時注入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 存成 host 的 gitignored 機密（不進 image 也不進 git）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;CLAUDE_CODE_OAUTH_TOKEN=%s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$TOKEN</span><span class="s2">&#34;</span> &gt; ~/.env <span class="o">&amp;&amp;</span> chmod <span class="m">600</span> ~/.env
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 每次 run 注入</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker run --rm --env-file ~/.env &lt;image&gt; claude -p <span class="s2">&#34;任務&#34;</span> --dangerously-skip-permissions</span></span></code></pre></div><p>這個「認證走環境變數注入的機密、不烤進 image 也不進 repo」正是 <a href="/blog/linux/dotfile/knowledge-cards/runtime-secret-injection/" data-link-title="Runtime Secret Injection（機密注入）" data-link-desc="要決定 token / 金鑰放哪、能不能烤進 image 或提交進（即使私有的）repo 時回來讀">機密 runtime 注入</a> 的實例。好處是認證跟 image、container 生命週期完全解耦：rebuild image 幾次都不影響認證、換憑證只改注入的檔。</p>
<h2 id="認證綁-token-注入不綁-session">認證綁 token 注入、不綁 session</h2>
<p>env-var 模型的核心是：<strong>能不能認證，取決於這次 run 有沒有注入 token、跟 session 或登入態無關</strong>。這帶來兩個直接後果：</p>
<ul>
<li>直接打 <code>claude</code>（沒注入 token）即使在一個還活著的多工器（tmux / zellij）session 裡，也會要求重新認證——因為它沒拿到憑證。</li>
<li>在一個 <code>--rm</code> 的臨時 container 裡走一次互動登入，憑證寫進容器的 <code>~/.claude</code>、容器一結束就蒸發（除非登入時掛了 volume 讓它落在持久儲存）。等於把憑證寫進一個即將被回收的容器，它跟著容器一起消失、留不到下一次。</li>
</ul>
<p>可靠的做法是不依賴任何登入態、每次用同一條 <code>docker run --env-file</code> 指令把 token 注入。要驗證認證確實純綁 token：不掛任何 volume（排除一切存檔登入）、只注入 token 即認證成功；不注入則回 <code>Not logged in</code>——這證明認證來源純粹是注入的 token。</p>
<h2 id="github-認證正交的第二顆機密同一個注入模式">GitHub 認證：正交的第二顆機密、同一個注入模式</h2>
<p>agent 要 clone / push 私有 repo，需要的是一顆 GitHub token，跟前面認證 Claude Code 本身的 <code>CLAUDE_CODE_OAUTH_TOKEN</code> 是兩件事：前者讓 git 對 GitHub 證明身分（能不能 clone / push 私有 repo），後者讓 agent 對 Anthropic 取得運作授權（能不能運作）。這兩顆機密職責正交、彼此無關，但持久化與注入走同一套機制——gitignored 檔案存機密、<code>docker run</code> 時用環境變數注入、不烤進 image 也不進 repo，正是 <a href="/blog/linux/dotfile/knowledge-cards/runtime-secret-injection/" data-link-title="Runtime Secret Injection（機密注入）" data-link-desc="要決定 token / 金鑰放哪、能不能烤進 image 或提交進（即使私有的）repo 時回來讀">機密 runtime 注入</a> 的另一個實例。</p>
<p>這顆 token 決定 container 對私有 repo 的存取範圍：有它才能 clone / push 私有 repo，缺了只能匿名讀 public repo。在無人值守（非互動）的 container 裡，私有 repo 的 clone / push 會直接失敗於 <code>could not read Username for 'https://github.com'</code>（互動終端下則是提示你輸入帳密、而非直接報這行錯）。作法是產一顆 fine-grained PAT（GitHub 較新的 token 格式，建立時逐 repo 勾選授權範圍與權限，把爆炸半徑收到最小）注入環境變數 <code>GH_TOKEN</code>，並讓 image 內的 git 用 gh 的 <a href="/blog/linux/dotfile/knowledge-cards/git-credential-helper/" data-link-title="git credential helper" data-link-desc="要弄懂 git 怎麼不手打帳密就取得 HTTPS 認證、gh auth login 自動設定跟手動指到某程式是不是同一機制、或 clone/push 卡認證時回來讀">credential helper</a> 現讀它：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">RUN</span> git config --global credential.<span class="s2">&#34;https://github.com&#34;</span>.helper <span class="s1">&#39;!gh auth git-credential&#39;</span></span></span></code></pre></div><p>git 走 HTTPS 時把 <code>GH_TOKEN</code> 當密碼、<code>x-access-token</code> 當使用者名帶進請求；token 從不寫進 <code>.gitconfig</code> 或 gh 的登入檔，每次現讀環境變數。這跟 setup-token 的模型一致——認證綁「這次 run 有沒有注入機密」、不綁存檔登入。<code>gh</code> CLI 本身也讀同一顆 <code>GH_TOKEN</code>，所以 <code>gh pr create</code> 這類指令不需 <code>gh auth login</code> 的互動登入（<code>gh</code> 同時認 <code>GH_TOKEN</code> 與 <code>GITHUB_TOKEN</code>、前者優先；若這個 container 也在 CI runner 裡跑、環境可能已自帶 <code>GITHUB_TOKEN</code>，注入的 <code>GH_TOKEN</code> 會蓋過它、不會兩顆打架）。</p>
<p><code>GH_TOKEN</code> 跟 Claude Code 的 token 放同一個 <code>.env</code>、由同一條 <code>docker run</code> 一起注入，不需要分開的機制：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 兩顆機密進同一個 gitignored 機密檔（600），runtime 一起注入</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;CLAUDE_CODE_OAUTH_TOKEN=%s\nGH_TOKEN=%s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$OAUTH</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$PAT</span><span class="s2">&#34;</span> &gt; ~/.env <span class="o">&amp;&amp;</span> chmod <span class="m">600</span> ~/.env
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># clone 私有 repo：git 走 credential helper 現讀注入的 GH_TOKEN</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker run --rm --env-file ~/.env -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">:/work&#34;</span> &lt;image&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  git clone https://github.com/org/private-repo /work/repo</span></span></code></pre></div><p>這個範例用 <code>git clone</code>（讀）驗證注入通了，但只驗到讀路徑：fine-grained token 的 <code>Contents</code> 權限分 read / write 兩級，只給 <strong>Read</strong> 的 token clone 得動、卻會在 agent 真正 <code>git push</code> 時才卡。要 agent 自動 push，建 token 時 <code>Contents</code> 要給到 <strong>Read and write</strong>——別把「clone 成功」當成「push 也就緒」。</p>
<p>GitHub 認證看起來失敗時，錯誤訊息本身就是最快的診斷——兩種訊號指向不同病灶、修法也不同，關鍵是把「認證管線通不通」跟「這個 repo 有沒有授權」分開判讀：</p>
<ul>
<li><code>could not read Username for 'https://github.com'</code>：git 手上根本沒有憑證，<code>GH_TOKEN</code> 沒注入或為空——把有值的 <code>.env</code> 用 <code>--env-file</code> 帶進 runtime 就補齊了。</li>
<li><code>403 Write access to repository not granted</code>（或 <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;</code> 回 <code>404 Not Found</code>）：token 已經被 git 帶到 GitHub、身分也驗過了——拿到 403 而不是要求輸入帳號，本身就反證 credential helper 這條路是通的，缺的是授權。這個 403 是 <strong>push（寫）</strong> 被拒的訊息；同一個授權缺口換成 <code>git clone</code>（讀）不到的 repo，git 印的是 <code>remote: Repository not found</code>、不是 403——同樣是 scope / 授權問題，換個操作換一張臉。最常見的成因是這顆 fine-grained token 的 Repository access 不含這個 repo，修法是編輯同一顆 token 的 Repository access 把該 repo 加進來（token 值不變、注入的 <code>.env</code> 不用改）。同一個 403 也可能來自其他授權面——repo 在範圍內但缺 Contents 的 write 權限、org 開了 SAML SSO 而 token 未 authorize、org 的 IP allowlist 擋掉——這些各自要對應的授權設定才修得好、不是加 repo 能解（token 過期或撤銷則是回 <code>401 Bad credentials</code>、不是 403）。fine-grained token 對授權外的 repo 一律回 404 / 403、不會退回匿名讀，所以連公開 repo 都可能在注入 token 後反而被擋（這條診斷邏輯建立在 fine-grained token 上；沿用舊的 classic PAT 則 scope 較粗、公開 repo 永遠可讀、不會出現「授權外全擋」）——判讀時記得這是 scope / 授權問題、不是 helper 壞了。</li>
</ul>
<p>安全邊界跟前一節那顆長效 token 相同：PAT 一樣在 container 的環境變數裡、程序讀得到自己的 <code>/proc/self/environ</code>，在 skip-permissions 疊開放 egress 下可被外洩（見下方 <code>--dangerously-skip-permissions</code> 段的三個邊界內風險）。所以用 fine-grained、最小 repo 範圍、短輪替，把 blast radius 壓到最小。</p>
<h2 id="狀態的兩個位置claude-與-claudejson">狀態的兩個位置：~/.claude 與 .claude.json</h2>
<p>Claude Code 的狀態分兩處放，持久化邊界不同：</p>
<ul>
<li><strong><code>~/.claude/</code></strong>（目錄）：放設定 <code>settings.json</code>（含 hooks）等。掛成 named volume 就跨 container 重建持久化。</li>
<li><strong><code>$HOME/.claude.json</code></strong>（單一檔）：放專案信任、onboarding 狀態這類頂層設定。它<strong>不在</strong> <code>~/.claude/</code> 目錄裡，所以掛 <code>~/.claude</code> 的 volume 不會涵蓋它——重建後會出現「configuration file not found」的非致命警告。</li>
</ul>
<p>判讀原則是分清缺的是「認證」還是「設定」：認證缺了（沒注入 token）agent 直接無法運作；<code>.claude.json</code> 缺了只是回到預設狀態、用 token + <code>--dangerously-skip-permissions</code> 的無人值守流程照跑。要保留專案級狀態（逐專案信任、MCP 設定）才需要額外把 <code>.claude.json</code> 也掛成持久檔。把 <code>~/.claude</code> 掛成 volume 時、還要注意掛載點 owner（見 <a href="/blog/linux/dotfile/knowledge-cards/docker-named-volume-ownership/" data-link-title="Docker Named Volume Ownership（掛載點擁有者）" data-link-desc="container 內非 root 使用者寫不進掛載的 named volume、出現 permission denied 時回來讀">Docker named volume 掛載點 owner</a>）——空 volume 預設 root-owned、非 root 使用者寫不進憑證與設定。</p>
<h2 id="hooks任務結束推通知">hooks：任務結束推通知</h2>
<p>Claude Code 的 hooks 讓你在特定事件觸發外部指令。把工作流從「掛在終端上等」翻成「離開、跑完被叫回來」的關鍵是 <code>Stop</code> hook——它在每次回應結束時觸發，對應「一輪任務跑完」這個要通知的時機。設定寫在 <code>~/.claude/settings.json</code>。這個檔在掛成 named volume 的 <code>~/.claude</code> 裡、host 上沒有對應路徑，要寫它有兩條路：<code>docker run</code> 進容器用 <code>cat &gt; ~/.claude/settings.json</code> 或容器內編輯器寫（改動落在 volume、跨重建保留），或啟動時另外 bind-mount 一份 host 上的 <code>settings.json</code> 蓋過去。內容如下：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;hooks&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nt">&#34;Stop&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="p">{</span> <span class="nt">&#34;hooks&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;command&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">          <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;curl -s -H &#39;Title: 任務完成&#39; -d &#39;agent 跑完了&#39; \&#34;https://ntfy.sh/$NTFY_TOPIC\&#34;&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="p">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個範例把兩類值分開處理：<code>$NTFY_TOPIC</code> 是機密（猜到就能發/收你的通知——<a href="../../../debug/ntfy-push-notification-service/">topic 名稱本身就是密碼</a>），走跟認證 token 同一套注入模式——填進 host 側的 <code>.env</code>、<code>docker run</code> 時注入環境變數；hook 命令由 Claude Code 經 shell 執行，依子程序繼承環境的標準行為拿到 container 的環境變數、<code>$NTFY_TOPIC</code> 由該 shell 展開，<code>settings.json</code> 因此不含機密、可以進版控。（這條依賴「hook 以繼承環境的 shell 執行」這個執行模型，實際設 hook 前先確認你的 topic 有正確展開。）<code>Title:</code> header（<code>任務完成</code>）反過來——它是會被公共 server 看到的顯示字、不是機密、直接寫死，重點是別把敏感內容放進去。</p>
<p>觸發事件的選擇有語意差別：<code>Stop</code> 是「這一輪跑完了」，另一個候選 <code>Notification</code> 是 agent 主動要求關注時觸發、語意是「需要你介入」。兩者可並存但對應不同時機。推播服務本身（ntfy topic 是機密、不進 git）見 <a href="../../../debug/ntfy-push-notification-service/">ntfy 推播通知服務</a>。</p>
<p>要留意 ntfy 這條通知鏈沒有投遞保證：公共 <code>ntfy.sh</code> 掛掉、手機離線或開勿擾時，推播會靜默漏掉、沒有 ack 或重送——而整套工作流的賣點正是「離開、跑完被叫回來」，漏一則就變成空等。在意就別只靠一則 fire-and-forget 推播：手機端保留主動查狀態的路徑（連進 session 看、或 poll ntfy 的訊息歷史）。公共 server 也會看到完成訊息的 metadata（超過「topic 被猜到」的曝露面），敏感內容別寫進推播標題。</p>
<p>hook 的第一個除錯檢查點是「hook 指令依賴的工具在 container 裡存不存在」：上面的 hook 用 <code>curl</code>，而多數 slim base image（<code>node:slim</code> 這類）不內建 curl——少了它、hook 的指令會找不到執行檔而靜默失效，表現為「手動 curl 通、hook 卻不發訊」。修法是把 curl 加進 image 的套件安裝。</p>
<h2 id="dangerously-skip-permissions-在-container-下的定位">&ndash;dangerously-skip-permissions 在 container 下的定位</h2>
<p>無人值守跑 <code>claude -p</code> 時通常要加 <code>--dangerously-skip-permissions</code>，在容器化這個架構下這是正確選擇、不是偷懶：container 邊界本身就是權限邊界。agent 只碰得到掛進去的工作目錄（掛載清單即授權清單）、爆了困在 cgroup 的資源上限內、看不到未掛載的 host 路徑。既然容器已經把 agent 圈在一個受限的沙盒裡，容器內再逐次確認檔案權限是重複的一層。把信任邊界劃在容器邊界（mount 清單 + 資源上限），而不是容器內的每次操作確認，才對得上這個架構。</p>
<p>這個論證只對「host 檔案系統與資源的 blast radius」成立，邊界<strong>內</strong>還有三個風險要清醒面對：掛進去的 <code>/work</code> 是真實專案目錄、不是副本，agent 有無人監督的寫入權、可以改壞或重寫檔案（要保護就掛副本、或用 git worktree 隔離）；container 的對外網路預設全開，skip-permissions 疊上開放 egress 再疊上 prompt injection，理論上能把資料送出去（要收緊就設 egress allowlist）；<code>CLAUDE_CODE_OAUTH_TOKEN</code> 就在 container 的環境變數裡、跑在其中的程序讀得到自己的 <code>/proc/self/environ</code>，所以那顆長效 token 在「skip-permissions + 開放 egress」下是可被外洩的——這也是它該用短期輪替、不該長放的理由。容器邊界擋得住對 host 的破壞，擋不住這三者，值得在放手無人值守前各自評估。這三項是本文示範設定本身會暴露的風險面、不是 container 風險的全集：若另外掛了 docker socket、或跑在共享的網路 namespace，還有本文未涵蓋的橫向移動風險要另外評估。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>完整的端到端脈絡（連線 / session / 隔離三層怎麼疊起來）：<a href="../agent-workstation-vm-handson/">遠端 agent 工作機實作記錄</a></li>
<li>機器該放家用還是 VPS、隔離層的信任邊界判讀：<a href="../agent-workstation-home-vs-vps/">遠端 agent 工作機選型</a></li>
<li>推播通知服務的架構與自架取捨：<a href="../../../debug/ntfy-push-notification-service/">ntfy 推播通知服務</a></li>
<li>相關術語卡：<a href="/blog/linux/dotfile/knowledge-cards/runtime-secret-injection/" data-link-title="Runtime Secret Injection（機密注入）" data-link-desc="要決定 token / 金鑰放哪、能不能烤進 image 或提交進（即使私有的）repo 時回來讀">機密 runtime 注入</a>、<a href="/blog/linux/dotfile/knowledge-cards/git-credential-helper/" data-link-title="git credential helper" data-link-desc="要弄懂 git 怎麼不手打帳密就取得 HTTPS 認證、gh auth login 自動設定跟手動指到某程式是不是同一機制、或 clone/push 卡認證時回來讀">git credential helper</a>、<a href="/blog/linux/dotfile/knowledge-cards/docker-named-volume-ownership/" data-link-title="Docker Named Volume Ownership（掛載點擁有者）" data-link-desc="container 內非 root 使用者寫不進掛載的 named volume、出現 permission denied 時回來讀">Docker named volume 掛載點 owner</a></li>
</ul>
]]></content:encoded></item></channel></rss>